first commit

This commit is contained in:
张乾
2024-10-16 00:20:59 +08:00
parent 84ae12296c
commit 02730bc441
172 changed files with 53542 additions and 0 deletions

View File

@@ -0,0 +1,355 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
28 JVM 问题排查分析下篇(案例实战)
GC 问题排查实战案例
这一部分,我们来看一个实际的案例。
假设我们有一个提供高并发请求的服务,系统使用 Spring Boot 框架,指标采集使用 MicroMeter监控数据上报给 Datadog 服务。
当然Micrometer支 持将数据上报给各种监控系统例如AppOptics、Atlas、Datadog、Dynatrace、Elastic、Ganglia、Graphite、Humio、Influx、Instana、JMX、KairosDB、New Relic、Prometh eus、SignalFx、Stackdriver、StatsD、Wavefront 等等。
有关MicroMeter的信息可参考
https://micrometer.io/docs
问题现象描述
最近一段时间,通过监控指标发现,有一个服务节点的最大 GC 暂停时间经常会达到 400ms 以上。
如下图所示:
从图中可以看到GC 暂停时间的峰值达到了 546ms这里展示的时间点是 2020 年 02 月 04 日 09:20:00 左右。
客户表示这种情况必须解决,因为服务调用的超时时间为 1s要求最大 GC 暂停时间不超过 200ms平均暂停时间达到 100ms 以内,对客户的交易策略产生了极大的影响。
CPU 负载
CPU 的使用情况如下图所示:
从图中可以看到:系统负载为 4.92CPU使用率 7% 左右,其实这个图中隐含了一些重要的线索,但我们此时并没有发现什么问题。
GC 内存使用情况
然后我们排查了这段时间的内存使用情况:
从图中可以看到,大约 09:25 左右 old_gen 使用量大幅下跌,确实是发生了 FullGC。
但 09:20 前后,老年代空间的使用量在缓慢上升,并没有下降,也就是说引发最大暂停时间的这个点并没有发生 FullGC。
当然,这些是事后复盘分析得出的结论。当时对监控所反馈的信息并不是特别信任,怀疑就是触发了 FullGC 导致的长时间 GC 暂停。
为什么有怀疑呢,因为 Datadog 这个监控系统,默认 10s 上报一次数据。有可能在这 10s 内发生些什么事情但是被漏报了(当然,这是不可能的,如果上报失败会在日志系统中打印相关的错误)。
再分析上面这个图,可以看到老年代对应的内存池是 “ps_old_gen”通过前面的学习我们知道ps 代表的是 ParallelGC 垃圾收集器。
JVM 启动参数
查看 JVM 的启动参数,发现是这样的:
-Xmx4g -Xms4g
我们使用的是 JDK 8启动参数中没有指定 GC确定这个服务使用了默认的并行垃圾收集器。
于是怀疑问题出在这款垃圾收集器上面,因为很多情况下 ParallelGC 为了最大的系统处理能力,即吞吐量,而牺牲掉了单次的暂停时间,导致暂停时间会比较长。
使用 G1 垃圾收集器
怎么办呢?准备换成 G1毕竟现在新版本的 JDK 8 中 G1 很稳定,而且性能不错。
然后换成了下面的启动参数:
# 这个参数有问题,启动失败
-Xmx4g -Xms4g -XX:+UseG1GC -XX:MaxGCPauseMills=50ms
结果启动失败,忙中出错,参数名和参数值都写错了。
修正如下:
-Xmx4g -Xms4g -XX:+UseG1GC -XX:MaxGCPauseMillis=50
接着服务启动成功,等待健康检测自动切换为新的服务节点,继续查看指标。
看看暂停时间,每个节点的 GC 暂停时间都降下来了,基本上在 50ms 以内,比较符合我们的预期。
嗯!事情到此结束了?远远没有。
“彩蛋”惊喜
过了一段时间,我们发现了个下面这个惊喜(也许是惊吓),如下图所示:
中奖了,运行一段时间后,最大 GC 暂停时间达到了 1300ms。
情况似乎更恶劣了。
继续观察,发现不是个别现象:
内心是懵的,觉得可能是指标算错了,比如把 10s 内的暂停时间全部加到了一起。
注册 GC 事件监听
于是想了个办法,通过 JMX 注册 GC 事件监听,把相关的信息直接打印出来。
关键代码如下所示:
// 每个内存池都注册监听
for (GarbageCollectorMXBean mbean
: ManagementFactory.getGarbageCollectorMXBeans()) {
if (!(mbean instanceof NotificationEmitter)) {
continue; // 假如不支持监听...
}
final NotificationEmitter emitter = (NotificationEmitter) mbean;
// 添加监听
final NotificationListener listener = getNewListener(mbean);
emitter.addNotificationListener(listener, null, null);
}
通过这种方式,我们可以在程序中监听 GC 事件,并将相关信息汇总或者输出到日志。 具体的实现代码在后面的章节《应对容器时代面临的挑战》中给出。
再启动一次,运行一段时间后,看到下面这样的日志信息:
{
"duration":1869,
"maxPauseMillis":1869,
"promotedBytes":"139MB",
"gcCause":"G1 Evacuation Pause",
"collectionTime":27281,
"gcAction":"end of minor GC",
"afterUsage":
{
"G1 Old Gen":"1745MB",
"Code Cache":"53MB",
"G1 Survivor Space":"254MB",
"Compressed Class Space":"9MB",
"Metaspace":"81MB",
"G1 Eden Space":"0"
},
"gcId":326,
"collectionCount":326,
"gcName":"G1 Young Generation",
"type":"jvm.gc.pause"
}
情况确实有点不妙。
这次实锤了,不是 FullGC而是年轻代 GC而且暂停时间达到了 1869ms。 一点道理都不讲,我认为这种情况不合理,而且观察 CPU 使用量也不高。
找了一大堆资料,试图证明这个 1869ms 不是暂停时间,而只是 GC 事件的结束时间减去开始时间。
打印 GC 日志
既然这些手段不靠谱,那就只有祭出我们的终极手段:打印 GC 日志。
修改启动参数如下:
-Xmx4g -Xms4g -XX:+UseG1GC -XX:MaxGCPauseMillis=50
-Xloggc:gc.log -XX:+PrintGCDetails -XX:+PrintGCDateStamps
重新启动,希望这次能排查出问题的原因。
运行一段时间,又发现了超长的暂停时间。
分析 GC 日志
因为不涉及敏感数据,那么我们把 GC 日志下载到本地进行分析。
定位到这次暂停时间超长的 GC 事件,关键的信息如下所示:
Java HotSpot(TM) 64-Bit Server VM (25.162-b12) for linux-amd64 JRE (1.8.0_162-b12),
built on Dec 19 2017 21:15:48 by "java_re" with gcc 4.3.0 20080428 (Red Hat 4.3.0-8)
Memory: 4k page, physical 144145548k(58207948k free), swap 0k(0k free)
CommandLine flags:
-XX:InitialHeapSize=4294967296 -XX:MaxGCPauseMillis=50 -XX:MaxHeapSize=4294967296
-XX:+PrintGC -XX:+PrintGCDateStamps -XX:+PrintGCDetails -XX:+PrintGCTimeStamps
-XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:+UseG1GC
2020-02-24T18:02:31.853+0800: 2411.124: [GC pause (G1 Evacuation Pause) (young), 1.8683418 secs]
[Parallel Time: 1861.0 ms, GC Workers: 48]
[GC Worker Start (ms): Min: 2411124.3, Avg: 2411125.4, Max: 2411126.2, Diff: 1.9]
[Ext Root Scanning (ms): Min: 0.0, Avg: 0.3, Max: 2.7, Diff: 2.7, Sum: 16.8]
[Update RS (ms): Min: 0.0, Avg: 3.6, Max: 6.8, Diff: 6.8, Sum: 172.9]
[Processed Buffers: Min: 0, Avg: 2.3, Max: 8, Diff: 8, Sum: 111]
[Scan RS (ms): Min: 0.0, Avg: 0.2, Max: 0.5, Diff: 0.5, Sum: 7.7]
[Code Root Scanning (ms): Min: 0.0, Avg: 0.0, Max: 0.1, Diff: 0.1, Sum: 0.3]
[Object Copy (ms): Min: 1851.6, Avg: 1854.6, Max: 1857.4, Diff: 5.8, Sum: 89020.4]
[Termination (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.6]
[Termination Attempts: Min: 1, Avg: 1.0, Max: 1, Diff: 0, Sum: 48]
[GC Worker Other (ms): Min: 0.0, Avg: 0.3, Max: 0.7, Diff: 0.6, Sum: 14.7]
[GC Worker Total (ms): Min: 1858.0, Avg: 1859.0, Max: 1860.3, Diff: 2.3, Sum: 89233.3]
[GC Worker End (ms): Min: 2412984.1, Avg: 2412984.4, Max: 2412984.6, Diff: 0.5]
[Code Root Fixup: 0.0 ms]
[Code Root Purge: 0.0 ms]
[Clear CT: 1.5 ms]
[Other: 5.8 ms]
[Choose CSet: 0.0 ms]
[Ref Proc: 1.7 ms]
[Ref Enq: 0.0 ms]
[Redirty Cards: 1.1 ms]
[Humongous Register: 0.1 ms]
[Humongous Reclaim: 0.0 ms]
[Free CSet: 2.3 ms]
[Eden: 2024.0M(2024.0M)->0.0B(2048.0K)
Survivors: 2048.0K->254.0M
Heap: 3633.6M(4096.0M)->1999.3M(4096.0M)]
[Times: user=1.67 sys=14.00, real=1.87 secs]
前后的 GC 事件都很正常,也没发现 FullGC 或者并发标记周期,但找到了几个可疑的点。
physical 144145548k(58207948k free)JVM 启动时,物理内存 137GB空闲内存 55GB。
[Parallel Time: 1861.0 ms, GC Workers: 48]:垃圾收集器工作线程 48 个。
我们前面的课程中学习了怎样分析 GC 日志,一起来回顾一下。
user=1.67:用户线程耗时 1.67s
sys=14.00:系统调用和系统等待时间 14s
real=1.87 secs实际暂停时间 1.87s
GC 之前,年轻代使用量 2GB堆内存使用量 3.6GB,存活区 2MB可推断出老年代使用量 1.6GB
GC 之后,年轻代使用量为 0堆内存使用量 2GB存活区 254MB那么老年代大约 1.8GB,那么“内存提升量为 200MB 左右”。
这样分析之后,可以得出结论:
年轻代转移暂停,复制了 400MB 左右的对象,却消耗了 1.8s,系统调用和系统等待的时间达到了 14s。
JVM 看到的物理内存 137GB。
推算出 JVM 看到的 CPU 内核数量 72个因为 GC 工作线程 72* 5/8 ~= 48 个。
看到这么多的 GC 工作线程我就开始警惕了,毕竟堆内存才指定了 4GB。
按照一般的 CPU 和内存资源配比,常见的比例差不多是 4 核 4GB、4 核 8GB 这样的。
看看对应的 CPU 负载监控信息:
通过和运维同学的沟通,得到这个节点的配置被限制为 4 核 8GB。
这样一来GC 暂停时间过长的原因就定位到了:
K8S 的资源隔离和 JVM 未协调好,导致 JVM 看见了 72 个 CPU 内核,默认的并行 GC 线程设置为 72* 5/8 ~= 48 个,但是 K8S 限制了这个 Pod 只能使用 4 个 CPU 内核的计算量,致使 GC 发生时48 个线程在 4 个 CPU 核心上发生资源竞争,导致大量的上下文切换。
处置措施为:
限制 GC 的并行线程数量
事实证明,打印 GC 日志确实是一个很有用的排查分析方法。
限制 GC 的并行线程数量
下面是新的启动参数配置:
-Xmx4g -Xms4g
-XX:+UseG1GC -XX:MaxGCPauseMillis=50 -XX:ParallelGCThreads=4
-Xloggc:gc.log -XX:+PrintGCDetails -XX:+PrintGCDateStamps
这里指定了 -XX:ParallelGCThreads=4为什么这么配呢我们看看这个参数的说明。
-XX:ParallelGCThreads=n
设置 STW 阶段的并行 worker 线程数量。 如果逻辑处理器小于等于 8 个,则默认值 n 等于逻辑处理器的数量。
如果逻辑处理器大于 8 个,则默认值 n 大约等于处理器数量的 5/8。在大多数情况下都是个比较合理的值。如果是高配置的 SPARC 系统,则默认值 n 大约等于逻辑处理器数量的 5/16。
-XX:ConcGCThreads=n
设置并发标记的 GC 线程数量。默认值大约是 ParallelGCThreads 的四分之一。
一般来说不用指定并发标记的 GC 线程数量,只用指定并行的即可。
重新启动之后,看看 GC 暂停时间指标:
红色箭头所指示的点就是重启的时间点,可以发现,暂停时间基本上都处于 50ms 范围内。
后续的监控发现,这个参数确实解决了问题。
那么还有没有其他的办法呢?请关注后续的章节《应对容器时代面临的挑战》。
小结
通过这个案例我们可以看到JVM 问题排查和性能调优主要基于监控数据来进行。
还是那句话:没有量化,就没有改进。
简单汇总一下这里使用到的手段:
指标监控
指定 JVM 启动内存
指定垃圾收集器
打印和分析 GC 日志
GC 和内存是最常见的 JVM 调优场景,还记得课程开始时我们介绍的 GC 的性能维度吗?
延迟GC 中影响延迟的主要因素就是暂停时间。
吞吐量,主要看业务线程消耗的 CPU 资源百分比GC 占用的部分包括GC 暂停时间,以及高负载情况下并发 GC 消耗的 CPU 资源。
系统容量,主要说的是硬件配置,以及服务能力。
只要这些方面的指标都能够满足,各种资源占用也保持在合理范围内,就达成了我们的预期。
参考
Native Memory TrackingNMTNative 内存跟踪)排查文档
生产环境 GC 参数调优
https://plumbr.io/blog/monitoring/why-is-troubleshooting-so-hard
Linux 的性能调优的思路
Linux 工具快速教程

View File

@@ -0,0 +1,388 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
29 GC 疑难情况问题排查与分析(上篇)
本章介绍导致 GC 性能问题的典型情况。相关示例都来源于生产环境,为演示需要做了一定程度的精简。
名词说明Allocation Rate翻译为“分配速率”而不是分配率。因为不是百分比而是单位时间内分配的量。同理Promotion Rate 翻译为“提升速率”。
高分配速率High Allocation Rate
分配速率Allocation Rate表示单位时间内分配的内存量。通常使用 MB/sec 作为单位,也可以使用 PB/year 等。分配速率过高就会严重影响程序的性能,在 JVM 中可能会导致巨大的 GC 开销。
如何测量分配速率?
通过指定 JVM 参数:-XX:+PrintGCDetails -XX:+PrintGCTimeStamps通过 GC 日志来计算分配速率。GC 日志如下所示:
0.291: [GC (Allocation Failure)
[PSYoungGen: 33280K->5088K(38400K)]
33280K->24360K(125952K), 0.0365286 secs]
[Times: user=0.11 sys=0.02, real=0.04 secs]
0.446: [GC (Allocation Failure)
[PSYoungGen: 38368K->5120K(71680K)]
57640K->46240K(159232K), 0.0456796 secs]
[Times: user=0.15 sys=0.02, real=0.04 secs]
0.829: [GC (Allocation Failure)
[PSYoungGen: 71680K->5120K(71680K)]
112800K->81912K(159232K), 0.0861795 secs]
[Times: user=0.23 sys=0.03, real=0.09 secs]
具体就是计算上一次垃圾收集之后,与下一次 GC 开始之前的年轻代使用量,两者的差值除以时间,就是分配速率。通过上面的日志,可以计算出以下信息:
JVM 启动之后 291ms共创建了 33280KB 的对象。第一次 Minor GC小型 GC完成后年轻代中还有 5088KB 的对象存活。
在启动之后 446ms年轻代的使用量增加到 38368KB触发第二次 GC完成后年轻代的使用量减少到 5120KB。
在启动之后 829ms年轻代的使用量为 71680KBGC 后变为 5120KB。
可以通过年轻代的使用量来计算分配速率,如下表所示:
Event
Time
Young before
Young after
Allocated during
Allocation rate
1st GC
291ms
33,280KB
5,088KB
33,280KB
114MB/sec
2nd GC
446ms
38,368KB
5,120KB
33,280KB
215MB/sec
3rd GC
829ms
71,680KB
5,120KB
66,560KB
174MB/sec
Total
829ms
N/A
N/A
133,120KB
161MB/sec
通过这些信息可以知道,在此期间,该程序的内存分配速率为 16MB/sec。
分配速率的意义
分配速率的变化,会增加或降低 GC 暂停的频率,从而影响吞吐量。但只有年轻代的 Minor GC 受分配速率的影响,老年代 GC 的频率和持续时间一般不受 分配速率Allocation Rate的直接影响想想为什么而是受到 提升速率Promotion Rate的影响请参见下文。
现在我们只关心 Minor GC 暂停,查看年轻代的 3 个内存池。因为对象在 Eden 区分配,所以我们一起来看 Eden 区的大小和分配速率的关系。看看增加 Eden 区的容量,能不能减少 Minor GC 暂停次数,从而使程序能够维持更高的分配速率。
经过我们的实验,通过参数 -XX:NewSize、-XX:MaxNewSize 以及 -XX:SurvivorRatio 设置不同的 Eden 空间,运行同一程序时,可以发现:
Eden 空间为 100MB 时,分配速率低于 100MB/秒。
将 Eden 区增大为 1GB分配速率也随之增长大约等于 200MB/秒。
为什么会这样?
因为减少 GC 暂停,就等价于减少了任务线程的停顿,就可以做更多工作,也就创建了更多对象,所以对同一应用来说,分配速率越高越好。
在得出“Eden 区越大越好”这个结论前,我们注意到:分配速率可能会、也可能不会影响程序的实际吞吐量。
总而言之,吞吐量和分配速率有一定关系,因为分配速率会影响 Minor GC 暂停,但对于总体吞吐量的影响,还要考虑 Major GC 暂停等。
示例
参考 Demo 程序。假设系统连接了一个外部的数字传感器。应用通过专有线程,不断地获取传感器的值(此处使用随机数模拟),其他线程会调用 processSensorValue() 方法,传入传感器的值来执行某些操作。
public class BoxingFailure {
private static volatile Double sensorValue;
private static void readSensor() {
while(true) sensorValue = Math.random();
}
private static void processSensorValue(Double value) {
if(value != null) {
//...
}
}
}
如同类名所示,这个 Demo 是模拟 boxing 的。为了 null 值判断,使用的是包装类型 Double。程序基于传感器的最新值进行计算但从传感器取值是一个耗时的操作所以采用了异步方式一个线程不断获取新值计算线程则直接使用暂存的最新值从而避免同步等待。
Demo 程序在运行的过程中,由于分配速率太大而受到 GC 的影响。下面将确认问题,并给出解决办法。
高分配速率对 JVM 的影响
首先我们应该检查程序的吞吐量是否降低。如果创建了过多的临时对象Minor GC 的次数就会增加。如果并发较大,则 GC 可能会严重影响吞吐量。
遇到这种情况时GC 日志将会像下面这样,当然这是上面的示例程序 产生的 GC 日志。
JVM 启动参数为:-XX:+PrintGCDetails -XX:+PrintGCTimeStamps -Xmx32m。
2.808: [GC (Allocation Failure)
[PSYoungGen: 9760K->32K(10240K)], 0.0003076 secs]
2.819: [GC (Allocation Failure)
[PSYoungGen: 9760K->32K(10240K)], 0.0003079 secs]
2.830: [GC (Allocation Failure)
[PSYoungGen: 9760K->32K(10240K)], 0.0002968 secs]
2.842: [GC (Allocation Failure)
[PSYoungGen: 9760K->32K(10240K)], 0.0003374 secs]
2.853: [GC (Allocation Failure)
[PSYoungGen: 9760K->32K(10240K)], 0.0004672 secs]
2.864: [GC (Allocation Failure)
[PSYoungGen: 9760K->32K(10240K)], 0.0003371 secs]
2.875: [GC (Allocation Failure)
[PSYoungGen: 9760K->32K(10240K)], 0.0003214 secs]
2.886: [GC (Allocation Failure)
[PSYoungGen: 9760K->32K(10240K)], 0.0003374 secs]
2.896: [GC (Allocation Failure)
[PSYoungGen: 9760K->32K(10240K)], 0.0003588 secs]
很显然 Minor GC 的频率太高了。这说明创建了大量的对象。另外,年轻代在 GC 之后的使用量又很低,也没有 Full GC 发生。种种迹象表明GC 对吞吐量造成了严重的影响。
解决方案
在某些情况下,只要增加年轻代的大小,即可降低分配速率过高所造成的影响。增加年轻代空间并不会降低分配速率,但是会减少 GC 的频率。如果每次 GC 后只有少量对象存活Minor GC 的暂停时间就不会明显增加。
运行 示例程序 时,增加堆内存大小(同时也就增大了年轻代的大小),使用的 JVM 参数为:-Xmx64m。
2.808: [GC (Allocation Failure)
[PSYoungGen: 20512K->32K(20992K)], 0.0003748 secs]
2.831: [GC (Allocation Failure)
[PSYoungGen: 20512K->32K(20992K)], 0.0004538 secs]
2.855: [GC (Allocation Failure)
[PSYoungGen: 20512K->32K(20992K)], 0.0003355 secs]
2.879: [GC (Allocation Failure)
[PSYoungGen: 20512K->32K(20992K)], 0.0005592 secs]
但有时候增加堆内存的大小,并不能解决问题。
通过前面学到的知识,我们可以通过分配分析器找出大部分垃圾产生的位置。实际上,在此示例中 99% 的对象属于 Double 包装类在readSensor 方法中创建。
最简单的优化,将创建的 Double 对象替换为原生类型 double而针对 null 值的检测,可以使用 Double.NaN 来进行。
由于原生类型不算是对象,也就不会产生垃圾,导致 GC 事件。
优化之后,不在堆中分配新对象,而是直接覆盖一个属性域即可。对示例程序进行简单的改造(查看 diffGC 暂停基本上完全消除。
有时候 JVM 也很智能会使用逃逸分析技术Escape Analysis Technique来避免过度分配。
简单来说JIT 编译器可以通过分析得知,方法创建的某些对象永远都不会“逃出”此方法的作用域。这时候就不需要在堆上分配这些对象,也就不会产生垃圾,所以 JIT 编译器的一种优化手段就是:消除堆上内存分配(请参考基准测试)。
过早提升Premature Promotion
提升速率Promotion Rate用于衡量单位时间内从年轻代提升到老年代的数据量。一般使用 MB/sec 作为单位,和“分配速率”类似。
JVM 会将长时间存活的对象从年轻代提升到老年代。根据分代假设,可能存在一种情况,老年代中不仅有存活时间长的对象,也可能有存活时间短的对象。这就是过早提升:对象存活时间还不够长的时候就被提升到了老年代。
Major GC 不是为频繁回收而设计的,但 Major GC 现在也要清理这些生命短暂的对象,就会导致 GC 暂停时间过长。这会严重影响系统的吞吐量。
如何测量提升速率
可以指定 JVM 参数 -XX:+PrintGCDetails -XX:+PrintGCTimeStamps通过 GC 日志来测量提升速率。JVM 记录的 GC 暂停信息如下所示:
0.291: [GC (Allocation Failure)
[PSYoungGen: 33280K->5088K(38400K)]
33280K->24360K(125952K), 0.0365286 secs]
[Times: user=0.11 sys=0.02, real=0.04 secs]
0.446: [GC (Allocation Failure)
[PSYoungGen: 38368K->5120K(71680K)]
57640K->46240K(159232K), 0.0456796 secs]
[Times: user=0.15 sys=0.02, real=0.04 secs]
0.829: [GC (Allocation Failure)
[PSYoungGen: 71680K->5120K(71680K)]
112800K->81912K(159232K), 0.0861795 secs]
[Times: user=0.23 sys=0.03, real=0.09 secs]
从上面的日志可以得知GC 之前和之后的年轻代使用量以及堆内存使用量。这样就可以通过差值算出老年代的使用量。GC 日志中的信息可以表述为:
Event
Time
Young decreased
Total decreased
Promoted
Promotion rate
(事件)
(耗时)
(年轻代减少)
(整个堆内存减少)
(提升量)
(提升速率)
1st GC
291ms
28192K
8920K
19272K
66.2 MB/sec
2nd GC
446ms
33248K
11400K
21848K
140.95 MB/sec
3rd GC
829ms
66560K
30888K
35672K
93.14 MB/sec
Total
829ms
76792K
92.63 MB/sec
根据这些信息,就可以计算出观测周期内的提升速率:平均提升速率为 92MB/秒,峰值为 140.95MB/秒。
请注意,只能根据 Minor GC 计算提升速率。Full GC 的日志不能用于计算提升速率,因为 Major GC 会清理掉老年代中的一部分对象。
提升速率的意义
和分配速率一样,提升速率也会影响 GC 暂停的频率。但分配速率主要影响 minor GC而提升速率则影响 major GC 的频率。有大量的对象提升,自然很快将老年代填满。老年代填充的越快,则 Major GC 事件的频率就会越高。
前面章节提到过Full GC 通常需要更多的时间,因为需要处理更多的对象,还要执行碎片整理等额外的复杂过程。
示例
让我们看一个过早提升的示例。这个程序创建/获取大量的对象/数据,并暂存到集合之中,达到一定数量后进行批处理:
public class PrematurePromotion {
private static final Collection<byte[]> accumulatedChunks
= new ArrayList<>();
private static void onNewChunk(byte[] bytes) {
accumulatedChunks.add(bytes);
if(accumulatedChunks.size() > MAX_CHUNKS) {
processBatch(accumulatedChunks);
accumulatedChunks.clear();
}
}
}
此 Demo 程序 受到过早提升的影响。下面将进行验证并给出解决办法。
过早提升的影响
一般来说过早提升的症状表现为以下形式:
短时间内频繁地执行 Full GC
每次 Full GC 后老年代的使用率都很低,在 10~20% 或以下
提升速率接近于分配速率
要演示这种情况稍微有点麻烦,所以我们使用特殊手段,让对象提升到老年代的年龄比默认情况小很多。指定 GC 参数 -Xmx24m -XX:NewSize=16m -XX:MaxTenuringThreshold=1运行程序之后可以看到下面的 GC 日志:
2.176: [Full GC (Ergonomics)
[PSYoungGen: 9216K->0K(10752K)]
[ParOldGen: 10020K->9042K(12288K)]
19236K->9042K(23040K), 0.0036840 secs]
2.394: [Full GC (Ergonomics)
[PSYoungGen: 9216K->0K(10752K)]
[ParOldGen: 9042K->8064K(12288K)]
18258K->8064K(23040K), 0.0032855 secs]
2.611: [Full GC (Ergonomics)
[PSYoungGen: 9216K->0K(10752K)]
[ParOldGen: 8064K->7085K(12288K)]
17280K->7085K(23040K), 0.0031675 secs]
2.817: [Full GC (Ergonomics)
[PSYoungGen: 9216K->0K(10752K)]
[ParOldGen: 7085K->6107K(12288K)]
16301K->6107K(23040K), 0.0030652 secs]
乍一看似乎不是过早提升的问题,每次 GC 之后老年代的使用率似乎在减少。但反过来想,要是没有对象提升或者提升率很小,也就不会看到这么多的 Full GC 了。
简单解释一下这里的 GC 行为:有很多对象提升到老年代,同时老年代中也有很多对象被回收了,这就造成了老年代使用量减少的假象。但事实是大量的对象不断地被提升到老年代,并触发 Full GC。
解决方案
简单来说,要解决这类问题,需要让年轻代存放得下暂存的数据。有两种简单的方法:
一是增加年轻代的大小,设置 JVM 启动参数,类似这样:-Xmx64m -XX:NewSize=32m程序在执行时Full GC 的次数自然会减少很多,只会对 Minor GC 的持续时间产生影响:
2.251: [GC (Allocation Failure)
[PSYoungGen: 28672K->3872K(28672K)]
37126K->12358K(61440K), 0.0008543 secs]
2.776: [GC (Allocation Failure)
[PSYoungGen: 28448K->4096K(28672K)]
36934K->16974K(61440K), 0.0033022 secs]
二是减少每次批处理的数量,也能得到类似的结果。
至于选用哪个方案,要根据业务需求决定。
在某些情况下,业务逻辑不允许减少批处理的数量,那就只能增加堆内存,或者重新指定年轻代的大小。
如果都不可行,就只能优化数据结构,减少内存消耗。但总体目标依然是一致的——让临时数据能够在年轻代存放得下。

View File

@@ -0,0 +1,299 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
30 GC 疑难情况问题排查与分析(下篇)
Weak、Soft 及 Phantom 引用
另一类影响 GC 的问题是程序中的 non-strong 引用。虽然这类引用在很多情况下可以避免出现 OutOfMemoryError但过量使用也会对 GC 造成严重的影响,反而降低系统性能。
弱引用的缺点
首先弱引用weak reference是可以被 GC 强制回收的。当垃圾收集器发现一个弱可达对象weakly reachable即指向该对象的引用只剩下弱引用就会将其置入相应的 ReferenceQueue 中,变成可终结的对象。之后可能会遍历这个 reference queue并执行相应的清理。典型的示例是清除缓存中不再引用的 KEY。
当然在这个时候我们还可以将该对象赋值给新的强引用在最后终结和回收前GC 会再次确认该对象是否可以安全回收。因此,弱引用对象的回收过程是横跨多个 GC 周期的。
实际上弱引用使用的很多。大部分缓存框架都是基于弱引用实现的,所以虽然业务代码中没有直接使用弱引用,但程序中依然会大量存在。
其次软引用soft reference比弱引用更难被垃圾收集器回收。回收软引用没有确切的时间点由 JVM 自己决定。一般只会在即将耗尽可用内存时,才会回收软引用,以作最后手段。这意味着可能会有更频繁的 Full GC暂停时间也比预期更长因为老年代中的存活对象会很多。
最后使用虚引用phantom reference必须手动进行内存管理以标识这些对象是否可以安全地回收。表面上看起来很正常但实际上并不是这样。javadoc 中写道:
In order to ensure that a reclaimable object remains so, the referent of a phantom reference may not be retrieved: The get method of a phantom reference always returns null.
为了防止可回收对象的残留虚引用对象不应该被获取phantom reference 的 get 方法返回值永远是 null。
令人惊讶的是,很多开发者忽略了下一段内容(这才是重点):
Unlike soft and weak referencesphantom references are not automatically cleared by the garbage collector as they are enqueued. An object that is reachable via phantom references will remain so until all such references are cleared or themselves become unreachable.
与软引用和弱引用不同,虚引用不会被 GC 自动清除,因为他们被存放到队列中。通过虚引用可达的对象会继续留在内存中,直到调用此引用的 clear 方法,或者引用自身变为不可达。
也就是说,我们必须手动调用 clear() 来清除虚引用,否则可能会造成 OutOfMemoryError 而导致 JVM 挂掉。使用虚引用的理由是,对于用编程手段来跟踪某个对象何时变为不可达对象,这是唯一的常规手段。和软引用/弱引用不同的是我们不能“复活”虚可达phantom-reachable对象。
示例
让我们看一个弱引用示例,其中创建了大量的对象,并在 Minor GC 中完成回收。和前面一样,修改提升阀值。可以使用下列 JVM 参数:
-Xmx24m -XX:NewSize=16m -XX:MaxTenuringThreshold=1
此时 GC 日志如下所示:
2.330: [GC (Allocation Failure) 20933K->8229K(22528K), 0.0033848 secs]
2.335: [GC (Allocation Failure) 20517K->7813K(22528K), 0.0022426 secs]
2.339: [GC (Allocation Failure) 20101K->7429K(22528K), 0.0010920 secs]
2.341: [GC (Allocation Failure) 19717K->9157K(22528K), 0.0056285 secs]
2.348: [GC (Allocation Failure) 21445K->8997K(22528K), 0.0041313 secs]
2.354: [GC (Allocation Failure) 21285K->8581K(22528K), 0.0033737 secs]
2.359: [GC (Allocation Failure) 20869K->8197K(22528K), 0.0023407 secs]
2.362: [GC (Allocation Failure) 20485K->7845K(22528K), 0.0011553 secs]
2.365: [GC (Allocation Failure) 20133K->9501K(22528K), 0.0060705 secs]
2.371: [Full GC (Ergonomics) 9501K->2987K(22528K), 0.0171452 secs]
可以看到Full GC 的次数很少。但如果使用弱引用来指向创建的对象,使用 JVM 参数 -Dweak.refs=true则情况会发生明显变化。使用弱引用的原因很多比如在 weak hash map 中将对象作为 Key 的情况。在任何情况下,使用弱引用都可能会导致以下情形:
2.059: [Full GC (Ergonomics) 20365K->19611K(22528K), 0.0654090 secs]
2.125: [Full GC (Ergonomics) 20365K->19711K(22528K), 0.0707499 secs]
2.196: [Full GC (Ergonomics) 20365K->19798K(22528K), 0.0717052 secs]
2.268: [Full GC (Ergonomics) 20365K->19873K(22528K), 0.0686290 secs]
2.337: [Full GC (Ergonomics) 20365K->19939K(22528K), 0.0702009 secs]
2.407: [Full GC (Ergonomics) 20365K->19995K(22528K), 0.0694095 secs]
可以看到,发生了多次 Full GC比起前一节的示例GC 时间增加了一个数量级!
这是过早提升的另一个例子,但这次情况更加棘手:问题的根源在于弱引用。这些临死的对象,在添加弱引用之后,被提升到了老年代。但是,他们现在陷入另一次 GC 循环之中,所以需要对其做一些适当的清理。
像之前一样,最简单的办法是增加年轻代的大小,例如指定 JVM 参数 -Xmx64m -XX:NewSize=32m
2.328: [GC (Allocation Failure) 38940K->13596K(61440K)0.0012818 secs]
2.332: [GC (Allocation Failure) 38172K->14812K(61440K)0.0060333 secs]
2.341: [GC (Allocation Failure) 39388K->13948K(61440K)0.0029427 secs]
2.347: [GC (Allocation Failure) 38524K->15228K(61440K)0.0101199 secs]
2.361: [GC (Allocation Failure) 39804K->14428K(61440K)0.0040940 secs]
2.368: [GC (Allocation Failure) 39004K->13532K(61440K)0.0012451 secs]
这时候,对象在 Minor GC 中就被回收了。
更坏的情况是使用软引用,例如这个软引用示例程序。如果程序不是即将发生 OutOfMemoryError软引用对象就不会被回收。在示例程序中用软引用替代弱引用立即出现了更多的 Full GC 事件:
2.162: [Full GC (Ergonomics) 31561K->12865K(61440K)0.0181392 secs]
2.184: [GC (Allocation Failure) 37441K->17585K(61440K)0.0024479 secs]
2.189: [GC (Allocation Failure) 42161K->27033K(61440K)0.0061485 secs]
2.195: [Full GC (Ergonomics) 27033K->14385K(61440K)0.0228773 secs]
2.221: [GC (Allocation Failure) 38961K->20633K(61440K)0.0030729 secs]
2.227: [GC (Allocation Failure) 45209K->31609K(61440K)0.0069772 secs]
2.234: [Full GC (Ergonomics) 31609K->15905K(61440K)0.0257689 secs]
最有趣的是虚引用示例中的虚引用,使用同样的 JVM 参数启动其结果和弱引用示例非常相似。实际上Full GC 暂停的次数会小得多,原因前面说过,他们有不同的终结方式。
如果禁用虚引用清理,增加 JVM 启动参数(-Dno.ref.clearing=true则可以看到
4.180: [Full GC (Ergonomics) 57343K->57087K(61440K)0.0879851 secs]
4.269: [Full GC (Ergonomics) 57089K->57088K(61440K)0.0973912 secs]
4.366: [Full GC (Ergonomics) 57091K->57089K(61440K)0.0948099 secs]
主线程中很快抛出异常:
java.lang.OutOfMemoryError: Java heap space
使用虚引用时要小心谨慎,并及时清理虚可达对象。如果不清理,很可能会发生 OutOfMemoryError。
请相信我们的经验教训:处理 reference queue 的线程中如果没 catch 住异常,系统很快就会被整挂了。
使用非强引用的影响
建议使用 JVM 参数 -XX:+PrintReferenceGC 来看看各种引用对 GC 的影响。如果将此参数用于启动弱引用示例,将会看到:
2.173: [Full GC (Ergonomics)
2.234: [SoftReference0 refs0.0000151 secs]
2.234: [WeakReference2648 refs0.0001714 secs]
2.234: [FinalReference1 refs0.0000037 secs]
2.234: [PhantomReference0 refs0 refs0.0000039 secs]
2.234: [JNI Weak Reference0.0000027 secs]
[PSYoungGen: 9216K->8676K(10752K)]
[ParOldGen: 12115K->12115K(12288K)]
21331K->20792K(23040K),
[Metaspace: 3725K->3725K(1056768K)],
0.0766685 secs]
[Times: user=0.49 sys=0.01real=0.08 secs]
2.250: [Full GC (Ergonomics)
2.307: [SoftReference0 refs0.0000173 secs]
2.307: [WeakReference2298 refs0.0001535 secs]
2.307: [FinalReference3 refs0.0000043 secs]
2.307: [PhantomReference0 refs0 refs0.0000042 secs]
2.307: [JNI Weak Reference0.0000029 secs]
[PSYoungGen: 9215K->8747K(10752K)]
[ParOldGen: 12115K->12115K(12288K)]
21331K->20863K(23040K),
[Metaspace: 3725K->3725K(1056768K)],
0.0734832 secs]
[Times: user=0.52 sys=0.01real=0.07 secs]
2.323: [Full GC (Ergonomics)
2.383: [SoftReference0 refs0.0000161 secs]
2.383: [WeakReference1981 refs0.0001292 secs]
2.383: [FinalReference16 refs0.0000049 secs]
2.383: [PhantomReference0 refs0 refs0.0000040 secs]
2.383: [JNI Weak Reference0.0000027 secs]
[PSYoungGen: 9216K->8809K(10752K)]
[ParOldGen: 12115K->12115K(12288K)]
21331K->20925K(23040K),
[Metaspace: 3725K->3725K(1056768K)],
0.0738414 secs]
[Times: user=0.52 sys=0.01real=0.08 secs]
只有确定 GC 对应用的吞吐量和延迟造成影响之后,才应该花心思来分析这些信息,审查这部分日志。通常情况下,每次 GC 清理的引用数量都是很少的,大部分情况下为 0。
如果 GC 花了较多时间来清理这类引用,或者清除了很多的此类引用,就需要进一步观察和分析了。
解决方案
如果程序确实碰到了 mis-、ab- 等问题或者滥用 weak/soft/phantom 引用,一般都要修改程序的实现逻辑。每个系统不一样,因此很难提供通用的指导建议,但有一些常用的经验办法:
弱引用Weak references如果某个内存池的使用量增大造成了性能问题那么增加这个内存池的大小可能也要增加堆内存的最大容量。如同示例中所看到的增加堆内存的大小以及年轻代的大小可以减轻症状。
软引用Soft references如果确定问题的根源是软引用唯一的解决办法是修改程序源码改变内部实现逻辑。
虚引用Phantom references请确保在程序中调用了虚引用的 clear 方法。编程中很容易忽略某些虚引用,或者清理的速度跟不上生产的速度,又或者清除引用队列的线程挂了,就会对 GC 造成很大压力,最终可能引起 OutOfMemoryError。
其他性能问题的案例
前面介绍了最常见的 GC 性能问题,本节介绍一些不常见、但也可能会导致系统故障的问题。
RMI 与 GC
如果系统提供或者消费 RMI 服务,则 JVM 会定期执行 Full GC 来确保本地未使用的对象在另一端也不占用空间。即使你的代码中没有发布 RMI 服务,但第三方或者工具库也可能会打开 RMI 终端。最常见的元凶是 JMX如果通过 JMX 连接到远端,底层则会使用 RMI 发布数据。
问题是有很多不必要的周期性 Full GC。查看老年代的使用情况一般是没有内存压力其中还存在大量的空闲区域但 Full GC 就是被触发了,也就会暂停所有的应用线程。
这种周期性调用 System.gc() 删除远程引用的行为,是在 sun.rmi.transport.ObjectTable 类中,通过 sun.misc.GC.requestLatency(long gcInterval) 调用的。
对许多应用来说,根本没必要,甚至对性能有害。禁止这种周期性的 GC 行为,可以使用以下 JVM 参数:
java -Dsun.rmi.dgc.server.gcInterval=9223372036854775807L
-Dsun.rmi.dgc.client.gcInterval=9223372036854775807L
com.yourcompany.YourApplication
这让 Long.MAX_VALUE 毫秒之后,才调用 System.gc(),实际运行的系统可能永远都不会触发。
// ObjectTable.class
private static final long gcInterval =
((Long)AccessController.doPrivileged(
new GetLongAction("sun.rmi.dgc.server.gcInterval"3600000L)
)).longValue();
可以看到,默认值为 3600000L也就是 1 小时触发一次 Full GC。
另一种方式是指定 JVM 参数 -XX:+DisableExplicitGC禁止显式地调用 System.gc()。但我们强烈反对这种方式,因为我们不清楚这么做是否埋有地雷,例如第三方库里需要显式调研。
JVMTI tagging 与 GC
如果在程序启动时指定了 Java Agent-javaagentAgent 就可以使用 JVMTI tagging 标记堆中的对象。如果 tagging 标记了大量的对象,很可能会引起 GC 性能问题,导致延迟增加,以及吞吐量降低。
问题发生在 native 代码中JvmtiTagMap::do_weak_oops 在每次 GC 时都会遍历所有标标记tag并执行一些比较耗时的操作。更坑的是这种操作是串行执行的。
如果存在大量的标记,就意味着 GC 时有很大一部分工作是单线程执行的GC 暂停时间可能会增加一个数量级。
检查是否因为 Java Agent 增加了 GC 暂停时间,可以使用诊断参数 XX:+TraceJVMTIObjectTagging。
启用跟踪之后,可以估算出内存中 的标记映射了多少 native 内存,以及遍历所消耗的时间。
如果你不是 需要使用的这个 agent 的作者,那一般是搞不定这类问题的。除了提 Bug 之外你什么都做不了。如果发生了这种情况,请建议厂商清理不必要的标记。(以前我们就在生产环境里发现 APM 厂商的 Agent 偶尔会导致 JVM OOM 崩溃。)
巨无霸对象的分配Humongous Allocations
如果使用 G1 垃圾收集算法,会产生一种巨无霸对象引起的 GC 性能问题。
说明:在 G1 中巨无霸对象是指所占空间超过一个小堆区region50% 的对象。
频繁地创建巨无霸对象,无疑会造成 GC 的性能问题,看看 G1 的处理方式:
如果某个 region 中含有巨无霸对象,则巨无霸对象后面的空间将不会被分配。如果所有巨无霸对象都超过某个比例,则未使用的空间就会引发内存碎片问题。
G1 没有对巨无霸对象进行优化。这在 JDK 8 以前是个特别棘手的问题——在 Java 1.8u40 之前的版本中,巨无霸对象所在 region 的回收只能在 Full GC 中进行。最新版本的 Hotspot JVM在 marking 阶段之后的 cleanup 阶段中释放巨无霸区间,所以这个问题在新版本 JVM 中的影响已大大降低。
要监控是否存在巨无霸对象,可以打开 GC 日志,使用的命令如下:
java -XX:+PrintGCDetails -XX:+PrintGCTimeStamps
-XX:+PrintReferenceGC -XX:+UseG1GC
-XX:+PrintAdaptiveSizePolicy -Xmx128m
MyClass
GC 日志中可能会发现这样的部分:
0.106: [G1Ergonomics (Concurrent Cycles)
request concurrent cycle initiation,
reason: occupancy higher than threshold,
occupancy: 60817408 bytes,
allocation request: 1048592 bytes,
threshold: 60397965 bytes (45.00 %),
source: concurrent humongous allocation]
0.106: [G1Ergonomics (Concurrent Cycles)
request concurrent cycle initiation,
reason: requested by GC cause,
GC cause: G1 Humongous Allocation]
0.106: [G1Ergonomics (Concurrent Cycles)
initiate concurrent cycle,
reason: concurrent cycle initiation requested]
0.106: [GC pause (G1 Humongous Allocation)
(young) (initial-mark)
0.106: [G1Ergonomics (CSet Construction)
start choosing CSet,
_pending_cards: 0,
predicted base
time: 10.00 ms,
remaining time: 190.00 ms,
target pause time: 200.00 ms]
这样的日志就是证据,表明程序中确实创建了巨无霸对象。可以看到 G1 Humongous Allocation 是 GC 暂停的原因。再看前面一点的 allocation request: 1048592 bytes可以发现程序试图分配一个 1048592 字节的对象这要比巨无霸区域2MB的 50% 多出 16 个字节。
第一种解决方式,是修改 region size以使得大多数的对象不超过 50%也就不进行巨无霸对象区域的分配。G1 的 region 大小默认值在启动时根据堆内存的大小算出。但也可以指定参数来覆盖默认设置,-XX:G1HeapRegionSize=XX。指定的 region size 必须在 1~32MB 之间,还必须是 2 的幂2^10=1024=1KB2^20=1MB所以 region size 只能是下列值之一1m、2m、4m、8m、16m、32m
这种方式也有副作用,增加 region 的大小也就变相地减少了 region 的数量,所以需要谨慎使用,最好进行一些测试,看看是否改善了吞吐量和延迟。
更好的使用方式是,在程序中限制对象的大小,我们可以在运行时使用内存分析工具,展示出巨无霸对象的信息,以及分配时所在的堆栈跟踪信息。
总结
Java 作为一个通用平台,运行在 JVM 上的应用程序多种多样,其启动参数也有上百个,其中有很多会影响到 GC 和性能,所以调优 GC 性能的方法也有很多种。
但是我们也要时刻提醒自己:没有真正的银弹,能满足所有的性能调优指标。
我们需要做的,就是了解这些可能会出现问题的各个要点,掌握常见的排查分析方法和工具。
在碰到类似问题时知道是知其然知其所以然,深入理解 JVM/GC 的工作原理,熟练应用各种手段,观察各种现象,收集各种有用的指标数据,进行定性和定量的分析,找到瓶颈,制定解决方案,进行调优和改进,提高应用系统的性能和稳定性。

View File

@@ -0,0 +1,461 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
31 JVM 相关的常见面试问题汇总:运筹策帷帐之中,决胜于千里之外
面试和笔试的要点其实差不多,基础知识和实战经验都是最重要的关注点(当然,面试时的态度和眼缘也很重要)。
实际面试时,因为时间有限,不可能所有问题都问一遍,一般是根据简历上涉及的内容,抽一部分话题来聊一聊。看看面试者的经验、态度,以及面对一层层深入问题时的处理思路。借此了解面试者的技术水平,对深度、广度,以及思考和解决问题的能力。
常见的面试套路是什么呢?
XXX 是什么?
实现原理是什么?
为什么这样实现?
如果让你实现你会怎么做?
分析下你的实现有什么优缺点?
有哪些需要改进的地方?
下面总结一些比较常见的面试题,供大家参考。针对这些问题,大家可以给自己打一个分。
0 分:不清楚相关知识。
30 分:有一点印象,知道一些名词。
60 分:知道一些概念以及含义,了解功能和常见用途。
80 分:能在参考答案的基础上进行补充。
100 分:发现参考答案的问题。
下面我们来看看 JVM 相关面试问题。
1. 什么是 JVM
JVM 全称是 Java Virtual Machine中文称为 Java 虚拟机。
JVM 是 Java 程序运行的底层平台,与 Java 支持库一起构成了 Java 程序的执行环境。
分为 JVM 规范和 JVM 实现两个部分。简单来说Java 虚拟机就是指能执行标准 Java 字节码的虚拟计算机。
1.1 请问 JDK 与 JVM 有什么区别?
现在的 JDK、JRE 和 JVM 一般是整套出现的。
JDK = JRE + 开发调试诊断工具
JRE = JVM + Java 标准库
1.2 你认识哪些 JVM 厂商?
常见的 JDK 厂商包括:
Oracle 公司,包括 Hotspot 虚拟机、GraalVM分为 OpenJDK 和 OracleJDK 两种版本
IBM 公司J9 虚拟机,用在 IBM 的产品套件中
Azul Systems 公司,高性能的 Zing 和开源的 Zulu
阿里巴巴Dragonwell 是阿里开发的 OpenJDK 定制版
亚马逊Corretto OpenJDK
Red Hat 公司的 OpenJDK
Adopt OpenJDK
此外,还有一些开源和试验性质的 JVM 实现,比如 Go.JVM
1.3 OracleJDK 与 OpenJDK 有什么区别?
各种版本的 JDK 一般来说都会符合 Java 虚拟机规范。 两者的区别一般来说包括:
两种 JDK 提供的工具套件略有差别,比如 jmc 等有版权的工具。
某些协议或配置不一样,比如美国限制出口的加密算法。
其他细微差别,比如 JRE 中某些私有的 API 不一样。
1.4 开发中使用哪个版本的 JDK生产环境呢为什么这么选
有一说一,选择哪个版本需要考虑研发团队的具体情况:比如机器的操作系统、团队成员的掌握情况、兼顾遗留项目等等。
当前 Java 最受欢迎的长期维护版本是 Java 8 和 Java 11。
Java 8 是经典 LTS 版本,性能优秀,系统稳定,良好支持各种 CPU 架构和操作系统平台。
Java 11 是新的长期支持版,性能更强,支持更多新特性,而且经过几年的维护已经很稳定。
有的企业在开发环境使用 OracleJDK在生产环境使用 OpenJDK。也有的企业恰好相反在开发环境使用 OpenJDK在生产环境使用 OracleJDK。也有的公司使用同样的打包版本。开发和部署时只要进行过测试就没问题。一般来说测试环境、预上线环境的 JDK 配置需要和生产环境一致。
2. 什么是 Java 字节码?
Java 中的字节码,是值 Java 源代码编译后的中间代码格式,一般称为字节码文件。
2.1 字节码文件中包含哪些内容?
字节码文件中,一般包含以下部分:
版本号信息
静态常量池(符号常量)
类相关的信息
字段相关的信息
方法相关的信息
调试相关的信息
可以说,大部分信息都是通过常量池中的符号常量来表述的。
2.2 什么是常量?
常量是指不变的量,字母 K 或者数字 1024 在 UTF-8 编码中对应到对应的二进制格式都是不变的。同样地,字符串在 Java 中的二进制表示也是不变的, 比如 “KK”。
在 Java 中需要注意的是final 关键字修饰的字段和变量,表示最终变量,只能赋值 1 次,不允许再次修改,由编译器和执行引擎共同保证。
2.3 你怎么理解常量池?
在 Java 中,常量池包括两层含义:
静态常量池class 文件中的一个部分,里面保存的是类相关的各种符号常量。
运行时常量池,其内容主要由静态常量池解析得到,但也可以由程序添加。
3. JVM 的运行时数据区有哪些?
根据 JVM 规范,标准的 JVM 运行时数据区包括以下部分:
程序计数器
Java 虚拟机栈
堆内存
方法区
运行时常量池
本地方法栈
具体的 JVM 实现可根据实际情况进行优化或者合并,满足规范的要求即可。
3.1 什么是堆内存?
堆内存是指由程序代码自由分配的内存,与栈内存作区分。
在 Java 中,堆内存主要用于分配对象的存储空间,只要拿到对象引用,所有线程都可以访问堆内存。
3.2 堆内存包括哪些部分?
以 Hotspot 为例堆内存HEAP主要由 GC 模块进行分配和管理,可分为以下部分:
新生代
存活区
老年代
其中,新生代和存活区一般称为年轻代。
3.3 什么是非堆内存?
除堆内存之外JVM 的内存池还包括非堆NON_HEAP对应于 JVM 规范中的方法区,常量池等部分:
MetaSpace
CodeCache
Compressed Class Space
4. 什么是内存溢出?
内存溢出OOM是指可用内存不足。
程序运行需要使用的内存超出最大可用值,如果不进行处理就会影响到其他进程,所以现在操作系统的处理办法是:只要超出立即报错,比如抛出“内存溢出错误”。
就像杯子装不下,满了要溢出来一样,比如一个杯子只有 500ml 的容量,却倒进去 600ml于是水就溢出造成破坏。
4.1 什么是内存泄漏?
内存泄漏Memory Leak是指本来无用的对象却继续占用内存没有再恰当的时机释放占用的内存。
不使用的内存,却没有被释放,称为“内存泄漏”。也就是该释放的没释放,该回收的没回收。
比较典型的场景是:每一个请求进来,或者每一次操作处理,都分配了内存,却有一部分不能回收(或未释放),那么随着处理的请求越来越多,内存泄漏也就越来越严重。
在 Java 中一般是指无用的对象却因为错误的引用关系,不能被 GC 回收清理。
4.2 两者有什么关系?
如果存在严重的内存泄漏问题,随着时间的推移,则必然会引起内存溢出。
内存泄漏一般是资源管理问题和程序 Bug内存溢出则是内存空间不足和内存泄漏的最终结果。
5. 给定一个具体的类,请分析对象的内存占用
public class MyOrder{
private long orderId;
private long userId;
private byte state;
private long createMillis;
}
一般来说MyOrder 类的每个对象会占用 40 个字节。
5.1 怎么计算出来的?
计算方式为:
对象头占用 12 字节。
每个 long 类型的字段占用 8 字节3 个 long 字段占用 24 字节。
byte 字段占用 1 个字节。
以上合计 37 字节,加上以 8 字节对齐,则实际占用 40 个字节。
5.2 对象头中包含哪些部分?
对象头中一般包含两个部分:
标记字,占用一个机器字,也就是 8 字节。
类型指针,占用一个机器字,也就是 8 个字节。
如果堆内存小于 32GBJVM 默认会开启指针压缩,则只占用 4 个字节。
所以前面的计算中,对象头占用 12 字节。如果是数组,对象头中还会多出一个部分:
数组长度int 值,占用 4 字节。
6. 常用的 JVM 启动参数有哪些?
截止目前2020 年 3 月JVM 可配置参数已经达到 1000 多个,其中 GC 和内存配置相关的 JVM 参数就有 600 多个。但在绝大部分业务场景下,常用的 JVM 配置参数也就 10 来个。
例如:
# JVM 启动参数不换行
# 设置堆内存
-Xmx4g -Xms4g
# 指定 GC 算法
-XX:+UseG1GC -XX:MaxGCPauseMillis=50
# 指定 GC 并行线程数
-XX:ParallelGCThreads=4
# 打印 GC 日志
-XX:+PrintGCDetails -XX:+PrintGCDateStamps
# 指定 GC 日志文件
-Xloggc:gc.log
# 指定 Meta 区的最大值
-XX:MaxMetaspaceSize=2g
# 设置单个线程栈的大小
-Xss1m
# 指定堆内存溢出时自动进行 Dump
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/usr/local/
此外,还有一些常用的属性配置:
# 指定默认的连接超时时间
-Dsun.net.client.defaultConnectTimeout=2000
-Dsun.net.client.defaultReadTimeout=2000
# 指定时区
-Duser.timezone=GMT+08
# 设置默认的文件编码为 UTF-8
-Dfile.encoding=UTF-8
# 指定随机数熵源(Entropy Source)
-Djava.security.egd=file:/dev/./urandom
6.1 设置堆内存 XMX 应该考虑哪些因素?
需要根据系统的配置来确定,要给操作系统和 JVM 本身留下一定的剩余空间。推荐配置系统或容器里可用内存的 70~80% 最好。
6.2 假设物理内存是 8G设置多大堆内存比较合适
比如说系统有 8G 物理内存,系统自己可能会用掉一点,大概还有 7.5G 可以用,那么建议配置 -Xmx6g。
说明7.5G*0.8=6G如果知道系统里有明确使用堆外内存的地方还需要进一步降低这个值。
6.3 -Xmx 设置的值与 JVM 进程所占用的内存有什么关系?
JVM 总内存 = 栈 + 堆 + 非堆 + 堆外 + Native
6.4 怎样开启 GC 日志?
一般来说JDK 8 及以下版本通过以下参数来开启 GC 日志:
-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:gc.log
如果是在 JDK 9 及以上的版本,则格式略有不同:
-Xlog:gc*=info:file=gc.log:time:filecount=0
6.5 请指定使用 G1 垃圾收集器来启动 Hello 程序
java -XX:+UseG1GC
-Xms4g
-Xmx4g
-Xloggc:gc.log
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
Hello
7. Java 8 默认使用的垃圾收集器是什么?
Java 8 版本的 Hotspot JVM默认情况下使用的是并行垃圾收集器Parallel GC。其他厂商提供的 JDK 8 基本上也默认使用并行垃圾收集器。
7.1 Java11 的默认垃圾收集器是什么?
Java 9 之后,官方 JDK 默认使用的垃圾收集器是 G1。
7.2 常见的垃圾收集器有哪些?
常见的垃圾收集器包括:
串行垃圾收集器:-XX:+UseSerialGC
并行垃圾收集器:-XX:+UseParallelGC
CMS 垃圾收集器:-XX:+UseConcMarkSweepGC
G1 垃圾收集器:-XX:+UseG1GC
7.3 什么是串行垃圾收集?
就是只有单个 worker 线程来执行 GC 工作。
7.4 什么是并行垃圾收集?
并行垃圾收集,是指使用多个 GC worker 线程并行地执行垃圾收集,能充分利用多核 CPU 的能力,缩短垃圾收集的暂停时间。
除了单线程的 GC其他的垃圾收集器比如 PS、CMS、G1 等新的垃圾收集器都使用了多个线程来并行执行 GC 工作。
7.5 什么是并发垃圾收集器?
并发垃圾收集器,是指在应用程序在正常执行时,有一部分 GC 任务,由 GC 线程在应用线程一起并发执行。 例如 CMS/G1 的各种并发阶段。
7.6 什么是增量式垃圾收集?
首先G1 的堆内存不再单纯划分为年轻代和老年代,而是划分为多个(通常是 2048 个可以存放对象的小块堆区域smaller heap regions
每个小块,可能一会被定义成 Eden 区,一会被指定为 Survivor 区或者 Old 区。
这样划分之后,使得 G1 不必每次都去回收整个堆空间,而是以增量的方式来进行处理:每次只处理一部分内存块,称为此次 GC 的回收集collection set
下一次 GC 时在本次的基础上,再选定一定的区域来进行回收。增量式垃圾收集的好处是大大降低了单次 GC 暂停的时间。
7.7 什么是年轻代?
年轻代是分来垃圾收集算法中的一个概念,相对于老年代而言,年轻代一般包括:
新生代Eden 区。
存活区,执行年轻代 GC 时,用存活区来保存活下来的对象。存活区也是年轻代的一部分,但一般有 2 个存活区,所以可以来回倒腾。
7.8 什么是 GC 停顿GC pause
因为 GC 过程中,有一部分操作需要等所有应用线程都到达安全点,暂停之后才能执行,这时候就叫做 GC 停顿,或者叫做 GC 暂停。
7.9 GC 停顿与 STW 停顿有什么区别?
这两者一般可以认为就是同一个意思。
8. 如果 CPU 使用率突然飙升,你会怎么排查?
缺乏经验的话,针对当前问题,往往需要使用不同的工具来收集信息,例如:
收集不同的指标CPU、内存、磁盘 IO、网络等等
分析应用日志
分析 GC 日志
获取线程转储并分析
获取堆转储来进行分析
8.1 如果系统响应变慢,你会怎么排查?
一般根据 APM 监控来排查应用系统本身的问题,有时候也可以使用 Chrome 浏览器等工具来排查外部原因,比如网络问题。
8.2 系统性能一般怎么衡量?
可量化的 3 个性能指标:
系统容量:比如硬件配置,设计容量;
吞吐量:最直观的指标是 TPS
响应时间:也就是系统延迟,包括服务端延时和网络延迟。
这些指标。可以具体拓展到单机并发、总体并发、数据量、用户数、预算成本等等。
9. 使用过哪些 JVM 相关的工具?
这个问题请根据实际情况回答,比如 Linux 命令,或者 JDK 提供的工具等。
9.1 查看 JVM 进程号的命令是什么?
可以使用 ps -ef 和 jps -v 等等。
9.2 怎么查看剩余内存?
比如free -m、free -h、top 命令等等。
9.3 查看线程栈的工具是什么?
一般先使用 jps 命令,再使用 jstack -l。
9.4 用什么工具来获取堆内存转储?
一般使用 jmap 工具来获取堆内存快照。
9.5 内存 Dump 时有哪些注意事项?
根据实际情况来看,获取内存快照可能会让系统暂停或阻塞一段时间,根据内存量决定。
使用 jmap 时,如果指定 live 参数,则会触发一次 Full GC需要注意。
9.6 使用 JMAP 转储堆内存大致的参数怎么处理?
示例:
jmap -dump:format=b,file=3826.hprof 3826
9.7 为什么转储文件以 .hprof 结尾?
JVM 有一个内置的分析器叫做 HPROF堆内存转储文件的格式最早就是这款工具定义的。
9.8 内存 Dump 完成之后,用什么工具来分析?
一般使用 Eclipse MAT 工具,或者 jhat 工具来处理。
9.9 如果忘记了使用什么参数你一般怎么处理?
上网搜索是比较笨的办法,但也是一种办法。
另外就是,各种 JDK 工具都支持 -h 选项来查看帮助信息,只要用得比较熟练,即使忘记了也很容易根据提示进行操作。
10. 开发性问题:你碰到过哪些 JVM 问题?
比如 GC 问题、内存泄漏问题、或者其他疑难杂症等等。然后可能还有一些后续的问题。例如:
你遇到过的印象最深的 JVM 问题是什么?
这个问题是怎么分析和解决的?
这个过程中有哪些值得分享的经验?
此问题为开放性问题,请根据自身情况进行回答,可以把自己思考的答案发到本专栏的微信群里,我们会逐个进行分析点评。

View File

@@ -0,0 +1,464 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
32 应对容器时代面临的挑战:长风破浪会有时、直挂云帆济沧海
当今的时代容器的使用越来越普及Cgroups、Docker、Kubernetes 等项目和技术越来越成熟,成为很多大规模集群的基石。
容器是一种沙盒技术,可以对资源进行调度分配和限制配额、对不同应用进行环境隔离。
容器时代不仅给我们带来的机遇,也带来了很多挑战。跨得过去就是机会,跳不过去就是坑。
在容器环境下,要直接进行调试并不容易,我们更多地是进行应用性能指标的采集和监控,并构建预警机制。而这需要架构师、开发、测试、运维人员的协作。
但监控领域的工具又多又杂,而且在持续发展和不断迭代。最早期的监控,只在系统发布时检查服务器相关的参数,并将这些参数用作系统运行状况的指标。监控服务器的健康状况,与用户体验之间紧密相关,悲剧在于监控的不完善,导致发生的问题比实际检测到的要多很多。
随着时间推移,日志管理、预警、遥测以及系统报告领域持续发力。其中有很多有效的措施,诸如安全事件、有效警报、记录资源使用量等等。但前提是我们需要有一个清晰的策略和对应工具,进行用户访问链路跟踪,比如 Zabbix、Nagios 以及 Prometheus 等工具在生产环境中被广泛使用。
性能问题的关键是人,也就是我们的用户。但已有的这些工具并没有实现真正的用户体验监控。仅仅使用这些软件也不能缓解性能问题,我们还需要采取各种措施,在勇敢和专注下不懈地努力。
一方面Web 系统的问题诊断和性能调优,是一件意义重大的事情。需要严格把控,也需要付出很多精力。
当然,成功实施这些工作对企业的回报也是巨大的!
另一方面,拿 Java 领域事实上的标准 Spring 来说SpringBoot 提供了一款应用指标收集器——Micrometer官方文档连接https://micrometer.io/docs。
支持直接将数据上报给 Elasticsearch、Datadog、InfluxData 等各种流行的监控系统。
自动采集最大延迟、平均延迟、95% 线、吞吐量、内存使用量等指标。
此外,在小规模集群中,我们还可以使用 Pinpoint、Skywalking 等开源 APM 工具。
容器环境的资源隔离性
容器毕竟是一种轻量级的实现方式,所以其封闭性不如虚拟机技术。
举个例子:
物理机/宿主机有 96 个 CPU 内核、256GB 物理内存,容器限制的资源是 4 核 8G那么容器内部的 JVM 进程看到的内核数和内存数是多少呢?
目前来说JVM 看到的内核数是 96内存值是 256G。
这会造成一些问题,基于 CPU 内核数 availableProcessors 的各种算法都会受到影响,比如默认 GC 线程数假如啥都不配置JVM 看见 96 个内核,设置 GC 并行线程数为 96*5/8~=60但容器限制了只能使用 4 个内核资源,于是 60 个并行 GC 线程来争抢 4 个机器内核,造成严重的 GC 性能问题。
同样的道理,很多线程池的实现,根据内核数量来设置并发线程数,也会造成剧烈的资源争抢。如果容器不限制资源的使用也会造成一些困扰,比如下面介绍的坏邻居效应。基于物理内存 totalPhysicalMemorySize 和空闲内存 freePhysicalMemorySize 等配置信息的算法也会产生一些奇怪的 Bug。
最新版的 JDK 加入了一些修正手段。
JDK 对容器的支持和限制
新版 JDK 支持 Docker 容器的 CPU 和内存限制:
https://blogs.oracle.com/java-platform-group/java-se-support-for-docker-cpu-and-memory-limits
可以增加 JVM 启动参数来读取 Cgroups 对 CPU 的限制:
https://www.oracle.com/technetwork/java/javase/8u191-relnotes-5032181.html#JDK-8146115
Hotspot 是一个规范的开源项目,关于 JDK 的新特性,可以阅读官方的邮件订阅,例如:
https://mail.openjdk.java.net/pipermail/jdk8u-dev/
其他版本的 JDK 特性,也可以按照类似的命名规范,从官网的 Mailing Lists 中找到:
https://mail.openjdk.java.net/mailman/listinfo
关于这个问题的排查和分析,请参考前面的章节[《JVM 问题排查分析调优经验》]。
坏邻居效应
有共享资源的地方,就会有资源争用。在计算机领域,共享的资源主要包括:
网络
磁盘
CPU
内存
在多租户的公有云环境中会存在一种严重的问题称为“坏邻居效应”noisy neighbor phenomenon。当一个或多个客户过度使用了某种公共资源时就会明显损害到其他客户的系统性能。就像是小区宽带一样
吵闹的坏邻居noisy neighbor用于描述云计算领域中用来描述抢占共有带宽磁盘 I/O、CPU 以及其他资源的行为。
坏邻居效应,对同一环境下的其他虚拟机/应用的性能会造成影响或抖动。一般来说,会对其他用户的性能和体验造成恶劣的影响。
云,是一种多租户环境,同一台物理机,会共享给多个客户来运行程序/存储数据。
坏邻居效应产生的原因,是某个虚拟机/应用霸占了大部分资源,进而影响到其他客户的性能。
带宽不足是造成网络性能问题的主要原因。在网络中传输数据严重依赖带宽的大小,如果某个应用或实例占用太多的网络资源,很可能对其他用户造成延迟/缓慢。坏邻居会影响虚拟机、数据库、网络、存储以及其他云服务。
有一种避免坏邻居效应的方法是使用裸机云bare-metal cloud。裸机云在硬件上直接运行一个应用相当于创建了一个单租户环境所以能消除坏邻居。虽然单租户环境避免了坏邻居效应但并没有解决根本问题。超卖over-commitment或者共享给太多的租户都会限制整个云环境的性能。
另一种避免坏邻居效应的方法,是通过在物理机之间进行动态迁移,以保障每个客户获得必要的资源。此外,还可以通过 存储服务质量保障QoSquality of service控制每个虚拟机的 IOPS来限制坏邻居效应。通过 IOPS 来限制每个虚拟机使用的资源量,就不会造成某个客户的虚机/应用/实例去挤占其他客户的资源/性能。
有兴趣的同学可以查看:
谈谈公有云的坏邻居效应
GC 日志监听
从 JDK 7 开始,每一款垃圾收集器都提供了通知机制,在程序中监听 GarbageCollectorMXBean即可在垃圾收集完成后收到 GC 事件的详细信息。目前的监听机制只能得到 GC 完成之后的 Pause 数据,其它环节的 GC 情况无法观察到。
一个简单的监听程序实现如下:
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.sun.management.GarbageCollectionNotificationInfo;
import com.sun.management.GcInfo;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Configuration;
import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;
import javax.management.ListenerNotFoundException;
import javax.management.Notification;
import javax.management.NotificationEmitter;
import javax.management.NotificationListener;
import javax.management.openmbean.CompositeData;
import java.lang.management.*;
import java.util.*;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicLong;
/**
* GC 日志监听并输出到 Log
* JVM 启动参数示例:
* -Xmx4g -Xms4g -XX:+UseG1GC -XX:MaxGCPauseMillis=50
* -Xloggc:gc.log -XX:+PrintGCDetails -XX:+PrintGCDateStamps
*/
@Configuration
public class BindGCNotifyConfig {
public BindGCNotifyConfig() {
}
//
private Logger logger = LoggerFactory.getLogger(this.getClass());
private final AtomicBoolean inited = new AtomicBoolean(Boolean.FALSE);
private final List<Runnable> notifyCleanTasks = new CopyOnWriteArrayList<Runnable>();
private final AtomicLong maxPauseMillis = new AtomicLong(0L);
private final AtomicLong maxOldSize = new AtomicLong(getOldGen().getUsage().getMax());
private final AtomicLong youngGenSizeAfter = new AtomicLong(0L);
@PostConstruct
public void init() {
try {
doInit();
} catch (Throwable e) {
logger.warn("[GC 日志监听-初始化]失败! ", e);
}
}
@PreDestroy
public void close() {
for (Runnable task : notifyCleanTasks) {
task.run();
}
notifyCleanTasks.clear();
}
private void doInit() {
//
if (!inited.compareAndSet(Boolean.FALSE, Boolean.TRUE)) {
return;
}
logger.info("[GC 日志监听-初始化]maxOldSize=" + mb(maxOldSize.longValue()));
// 每个 mbean 都注册监听
for (GarbageCollectorMXBean mbean : ManagementFactory.getGarbageCollectorMXBeans()) {
if (!(mbean instanceof NotificationEmitter)) {
continue;
}
final NotificationEmitter notificationEmitter = (NotificationEmitter) mbean;
// 添加监听
final NotificationListener notificationListener = getNewListener(mbean);
notificationEmitter.addNotificationListener(notificationListener, null, null);
logger.info("[GC 日志监听-初始化]MemoryPoolNames=" + JSON.toJSONString(mbean.getMemoryPoolNames()));
// 加入清理队列
notifyCleanTasks.add(new Runnable() {
@Override
public void run() {
try {
// 清理掉绑定的 listener
notificationEmitter.removeNotificationListener(notificationListener);
} catch (ListenerNotFoundException e) {
logger.error("[GC 日志监听-清理]清理绑定的 listener 失败", e);
}
}
});
}
}
private NotificationListener getNewListener(final GarbageCollectorMXBean mbean) {
//
final NotificationListener listener = new NotificationListener() {
@Override
public void handleNotification(Notification notification, Object ref) {
// 只处理 GC 事件
if (!notification.getType().equals(GarbageCollectionNotificationInfo.GARBAGE_COLLECTION_NOTIFICATION)) {
return;
}
CompositeData cd = (CompositeData) notification.getUserData();
GarbageCollectionNotificationInfo notificationInfo = GarbageCollectionNotificationInfo.from(cd);
//
JSONObject gcDetail = new JSONObject();
String gcName = notificationInfo.getGcName();
String gcAction = notificationInfo.getGcAction();
String gcCause = notificationInfo.getGcCause();
GcInfo gcInfo = notificationInfo.getGcInfo();
// duration 是指 Pause 阶段的总停顿时间,并发阶段没有 pause 不会通知。
long duration = gcInfo.getDuration();
if (maxPauseMillis.longValue() < duration) {
maxPauseMillis.set(duration);
}
long gcId = gcInfo.getId();
//
String type = "jvm.gc.pause";
//
if (isConcurrentPhase(gcCause)) {
type = "jvm.gc.concurrent.phase.time";
}
//
gcDetail.put("gcName", gcName);
gcDetail.put("gcAction", gcAction);
gcDetail.put("gcCause", gcCause);
gcDetail.put("gcId", gcId);
gcDetail.put("duration", duration);
gcDetail.put("maxPauseMillis", maxPauseMillis);
gcDetail.put("type", type);
gcDetail.put("collectionCount", mbean.getCollectionCount());
gcDetail.put("collectionTime", mbean.getCollectionTime());
// 存活数据量
AtomicLong liveDataSize = new AtomicLong(0L);
// 提升数据量
AtomicLong promotedBytes = new AtomicLong(0L);
// Update promotion and allocation counters
final Map<String, MemoryUsage> before = gcInfo.getMemoryUsageBeforeGc();
final Map<String, MemoryUsage> after = gcInfo.getMemoryUsageAfterGc();
//
Set<String> keySet = new HashSet<String>();
keySet.addAll(before.keySet());
keySet.addAll(after.keySet());
//
final Map<String, String> afterUsage = new HashMap<String, String>();
//
for (String key : keySet) {
final long usedBefore = before.get(key).getUsed();
final long usedAfter = after.get(key).getUsed();
long delta = usedAfter - usedBefore;
// 判断是 yong 还是 old算法不同
if (isYoungGenPool(key)) {
delta = usedBefore - youngGenSizeAfter.get();
youngGenSizeAfter.set(usedAfter);
} else if (isOldGenPool(key)) {
if (delta > 0L) {
// 提升到老年代的量
promotedBytes.addAndGet(delta);
gcDetail.put("promotedBytes", mb(promotedBytes));
}
if (delta < 0L || GcGenerationAge.OLD.contains(gcName)) {
liveDataSize.set(usedAfter);
gcDetail.put("liveDataSize", mb(liveDataSize));
final long oldMaxAfter = after.get(key).getMax();
if (maxOldSize.longValue() != oldMaxAfter) {
maxOldSize.set(oldMaxAfter);
// 扩容老年代的 max 有变更
gcDetail.put("maxOldSize", mb(maxOldSize));
}
}
} else if (delta > 0L) {
//
} else if (delta < 0L) {
// 判断 G1
}
afterUsage.put(key, mb(usedAfter));
}
//
gcDetail.put("afterUsage", afterUsage);
//
logger.info("[GC 日志监听-GC 事件]gcId={}; duration:{}; gcDetail: {}", gcId, duration, gcDetail.toJSONString());
}
};
return listener;
}
private static String mb(Number num) {
long mbValue = num.longValue() / (1024 * 1024);
if (mbValue < 1) {
return "" + mbValue;
}
return mbValue + "MB";
}
private static MemoryPoolMXBean getOldGen() {
List<MemoryPoolMXBean> list = ManagementFactory
.getPlatformMXBeans(MemoryPoolMXBean.class);
//
for (MemoryPoolMXBean memoryPoolMXBean : list) {
// 非堆的部分-不是老年代
if (!isHeap(memoryPoolMXBean)) {
continue;
}
if (!isOldGenPool(memoryPoolMXBean.getName())) {
continue;
}
return (memoryPoolMXBean);
}
return null;
}
private static boolean isConcurrentPhase(String cause) {
return "No GC".equals(cause);
}
private static boolean isYoungGenPool(String name) {
return name.endsWith("Eden Space");
}
private static boolean isOldGenPool(String name) {
return name.endsWith("Old Gen") || name.endsWith("Tenured Gen");
}
private static boolean isHeap(MemoryPoolMXBean memoryPoolBean) {
return MemoryType.HEAP.equals(memoryPoolBean.getType());
}
private enum GcGenerationAge {
OLD,
YOUNG,
UNKNOWN;
private static Map<String, GcGenerationAge> knownCollectors = new HashMap<String, BindGCNotifyConfig.GcGenerationAge>() {{
put("ConcurrentMarkSweep", OLD);
put("Copy", YOUNG);
put("G1 Old Generation", OLD);
put("G1 Young Generation", YOUNG);
put("MarkSweepCompact", OLD);
put("PS MarkSweep", OLD);
put("PS Scavenge", YOUNG);
put("ParNew", YOUNG);
}};
static GcGenerationAge fromName(String name) {
return knownCollectors.getOrDefault(name, UNKNOWN);
}
public boolean contains(String name) {
return this == fromName(name);
}
}
}
不只是 GC 事件,内存相关的信息都可以通过 JMX 来实现监听。很多 APM 也是通过类似的手段来实现数据上报。
APM 工具与监控系统
在线可视化监控是如今生产环境必备的一个功能。业务出错和性能问题随时都可能会发生,而且现在很多系统不再有固定的业务窗口期,所以必须做到 7x24 小时的实时监控。
目前业界有很多监控工具,各有优缺点,需要根据需要进行抉择。
一般来说,系统监控可以分为三个部分:
系统性能监控,包括 CPU、内存、磁盘 IO、网络等硬件资源和系统负载的监控信息。
业务日志监控,场景的是 ELK 技术栈、并使用 Logback+Kafka 等技术来采集日志。
APM 性能指标监控,比如 QPS、TPS、响应时间等等例如 MicroMeter、Pinpoint 等。
系统监控的模块也是两大块:
指标采集部分
数据可视化系统
如今监控工具是生产环境的重要组成部分。测量结果的可视化、错误追踪、性能监控和应用分析是对应用的运行状况进行深入观测的基本手段。
认识到这一需求非常容易,但要选择哪一款监控工具或者哪一组监控工具却异常困难。
下面介绍几款监测工具,这些工具包括混合开源和 SaaS 模式,每个都有其优缺点,可以说没有完美的工具,只有合适的工具。
指标采集客户端
Micrometer作为指标采集的基础类库基于客户端机器来进行用户无需关注具体的 JVM 版本和厂商。以相同的方式来配置可以对接到不同的可视化监控系统服务。主要用于监控、告警以及对当前的系统环境变化做出响应。Micrometer 还会注册 JMX 相关的 MBeans非常简单和方便地在本地通过 JMX 来查看相关指标。如果是生产环境中使用,则一般是将监控指标导出到其他监控系统中保存起来。
云服务监控系统:云服务监控系统厂商一般都会提供配套的指标采集客户端,并对外开放各种 API 接口和数据标准,允许客户使用自己的指标采集系统。
开源监控系统:各种开源监控系统也会提供对应的指标采集客户端。
云服务监控系统
SaaS 服务的监控系统一般提供存储、查询、可视化等功能的一体化云服务。大多包含免费试用和收费服务两种模式。如果企业和机构的条件允许,付费使用云服务一般是最好的选择,毕竟“免费的才是最贵的”。
下面我们一起来看看有哪些云服务:
AppOptics支持 APM 和系统监控的 SaaS 服务,支持各种仪表板和时间轴等监控界面,提供 API 和客户端。
Datadog支持 APM 和系统监控的 SaaS 服务,内置各种仪表板,支持告警。支持 API 和客户端,以及客户端代理。
Dynatrace支持 APM 和系统监控的 SaaS 服务,内置各种仪表板,集成了监控和分析平台。
Humio支持 APM、日志和系统监控的 SaaS 服务。
Instana支持自动 APM、系统监控的 SaaS 服务。
New Relic这是一款具有完整 UI 的可视化 SaaS 产品,支持 NRQL 查询语言New Relic Insights 基于推模型来运行。
SignalFx在推送模型上运行的 SaaS 服务,具有完整 UI。支持实时的系统性能、微服务以及 APM 监控系统,支持多样化的预警“检测器”。
Stackdriver是 Google Cloud 的嵌入式监测套件,用于监控云基础架构、软件和应用的性能,排查其中的问题并加以改善。这个监测套件属于 SaaS 服务,支持内置仪表板和告警功能。
Wavefront是基于 SaaS 的指标监视和分析平台,支持可视化查询,以及预警监控等功能,包括系统性能、网络、自定义指标、业务 KPI 等等。
听云是国内最大的应用性能管理APM解决方案提供商。可以实现应用性能全方位可视化从 PC 端、浏览器端、移动客户端到服务端,监控定位崩溃、卡顿、交互过慢、第三方 API 调用失败、数据库性能下降、CDN 质量差等多维复杂的性能问题。
OneAPMOneAPM蓝海讯通提供端到端 APM 应用性能管理软件及应用性能监控软件解决方案。
Plumbr监测可用性和性能问题使用跟踪技术能迅速定位错误相关的位置信息发现、验证和修复各种故障和性能问题。
Takipi现在改名叫做 OverOps系统故障实时监测系统。能快速定位问题发生的时间、位置和原因。
其中做得比较好的有国外的 Datadog国内的听云。
开源监控系统
Pinpoint受 Dapper 启发,使用 Java/PHP 来实现的大型分布式系统 APM 工具。Pinpoint 提供了一套解决方案,可通过跟踪分布式应用程序之间的事务来快速定位调用链路。
Atlas是 Netflix 旗下的一款开源的,基于内存的时序数据库,内置图形界面,支持高级数学运算和自定义查询语言。
ELK 技术栈一般用于日志监控Elasticsearch 是搜索引擎,支持各种数据和指标存储,日志监控一般通过 Logstash 执行分析Kibana 负责人机交互和可视化。
InfluxInfluxDB 是由 InfluxData 开发的一款开源时序型数据库。它由 Go 写成着力于高性能地查询与存储时序数据。InfluxDB 被广泛应用于存储系统的监控数据、IoT 行业的实时数据等场景,通过类似 SQL 的查询语言来完成数据分析。InfluxData 工具套件可用于实时流处理,支持抽样采集指标、自动过期、删除不需要的数据,以及备份和还原等功能。
Ganglia用于高性能计算系统、群集和网络的可伸缩的分布式监控工具。起源于加州大学伯克利分校是一款历史悠久的多层级指标监控系统在 Linux 系统中广受欢迎。
Graphite当前非常流行的多层级次指标监控系统使用固定数量的底层数据库其设计和目的与 RRD 相似。由 Orbitz 在 2006 年创建,并于 2008 年开源。
KairosDB是建立在 Apache Cassandra 基础上的时序数据库。可以通过 Grafana 来绘制精美漂亮的监控图表。
Prometheus具有简单的内置 UI支持自定义查询语言和数学运算的、开源的内存时序数据库。Prometheus 设计为基于拉模型来运行,根据服务发现,定期从应用程序实例中收集指标。
StatsD开源的、简单但很强大的统计信息聚合服务器。
其中 Pinpoint 和 Prometheus 比较受欢迎。
参考链接
利用 JMX 的 Notifications 监听 GC
推荐 7 个超棒的监控工具

View File

@@ -0,0 +1,101 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
00 开篇词 业务代码真的会有这么多坑?
我先和你说说我这 15 年的工作经历吧,以加深彼此的了解。前 7 年,我专注于.NET 领域,负责业务项目的同时,也做了很多社区工作。在 CSDN 做版主期间,我因为回答了大量有关.NET 的问题,并把很多问题的答案总结成了博客,获得了 3 次微软 MVP 的称号。
后来,我转到了 Java 领域,也从程序员变为了架构师,更关注开源项目和互联网架构设计。在空中网,我整体负责了百万人在线的大型 MMO 网游《激战》技术平台的架构设计,期间和团队开发了许多性能和稳定性都不错的 Java 框架;在饿了么,我负责过日千万订单量的物流平台的开发管理和架构工作,遇到了许多只有高并发下才会出现的问题,积累了大量的架构经验;现在,我在贝壳金服的基础架构团队,负责基础组件、中间件、基础服务开发规划,制定一些流程和规范,带领团队自研 Java 后端开发框架、微服务治理平台等,在落地 Spring Cloud 结合 Kubernetes 容器云平台技术体系的过程中,摸索出了很多适合公司项目的基础组件和最佳实践。
这 15 年来,我一直没有脱离编码工作,接触过大大小小的项目不下 400 个,自己亲身经历的、见别人踩过的坑不计其数。我感触很深的一点是,业务代码中真的有太多的坑:有些是看似非常简单的知识点反而容易屡次踩坑,比如 Spring 声明式事务不生效的问题;而有些坑因为“潜伏期”长,引发的线上事故造成了大量的人力和资金损失。因此,我系统梳理了这些案例和坑点,最终筛选出 100 个案例,涉及 130 多个坑点,组成了这个课程。
意识不到业务代码的坑,很危险
我想看到 100、130 这两个数字,你不禁要问了:“我写了好几年的业务代码了,遇到问题时上网搜一下就有答案,遇到最多的问题就是服务器不稳定,重启一下基本就可以解决,哪里会有这么多坑呢?”带着这个问题,你继续听我往下说吧。
据我观察,很多开发同学没意识到这些坑,有以下三种可能:
意识不到坑的存在,比如所谓的服务器不稳定很可能是代码问题导致的,很多时候遇到 OOM、死锁、超时问题在运维层面通过改配置、重启、扩容等手段解决了没有反推到开发层面去寻找根本原因。
有些问题只会在特定情况下暴露。比如,缓存击穿、在多线程环境使用非线程安全的类,只有在多线程或高并发的情况才会暴露问题。
有些性能问题不会导致明显的 Bug只会让程序运行缓慢、内存使用增加但会在量变到质变的瞬间爆发。
而正是因为没有意识到这些坑和问题,采用了错误的处理方式,最后问题一旦爆发,处理起来就非常棘手,这是非常可怕的。下面这些场景有没有感觉似曾相识呢?
比如,我曾听说过有一个订单量很大的项目,每天总有上千份订单的状态或流程有问题,需要花费大量的时间来核对数据,修复订单状态。开发同学因为每天牵扯太多精力在排查问题上,根本没时间开发新需求。技术负责人为此头痛不已,无奈之下招了专门的技术支持人员。最后痛定思痛,才决定开启明细日志彻查这个问题,结果发现是自调用方法导致事务没生效的坑。
再比如,有个朋友告诉我,他们的金融项目计算利息的代码中,使用了 float 类型而不是 BigDecimal 类来保存和计算金额,导致给用户结算的每一笔利息都多了几分钱。好在,日终对账及时发现了问题。试想一下,结算的有上千个用户,每个用户有上千笔小订单,如果等月终对账的时候再发现,可能已经损失了几百万。
再比如,我们使用 RabbitMQ 做异步处理,业务处理失败的消息会循环不断地进入 MQ。问题爆发之前可能只影响了消息处理的时效性。但等 MQ 彻底瘫痪时,面对 MQ 中堆积的、混杂了死信和正常消息的几百万条数据,你除了清空又能怎么办。但清空 MQ就意味着要花费几小时甚至几十小时的时间来补正常的业务数据对业务影响时间很长。
像这样由一个小坑引发的重大事故,不仅仅会给公司造成损失,还会因为自责影响工作状态,降低编码的自信心。我就曾遇到过一位比较负责的核心开发同学,因为一个 Bug 给公司带来数万元的经济损失,最后心理上承受不住提出了辞职。
其实,很多时候不是我们不想从根本上解决问题,只是不知道问题到底在了哪里。要避开这些坑、找到这些定时炸弹,第一步就是得知道它们是什么、在哪里、为什么会出现。而讲清楚这些坑点和相关的最佳实践,正是本课程的主要内容。
这个课程是什么?
如果用几个关键词概括这个课程的话那我会选择“Java”“业务开发”“避坑 100 例”这 3 个。接下来,我就和你详细说说这个课程是什么,以及有什么特点。
第一个关键词是“Java”指的是课程内所有 Demo 都是基于 Java 语言的。
如果你熟悉 Java那可以 100% 体会到这些坑点,也可以直接用这些 Demo 去检查你的业务代码是否也有类似的错误实现。
如果你不熟悉 Java 问题也不大,现在大部分高级语言的特性和结构都差不多,许多都是共性问题。此外“设计篇”“安全篇”的内容,基本是脱离具体语言层面的、高层次的问题。因此,即使不使用 Java你也可以有不少收获这也是本课程的第一个特点。
讲到这里,我要说明的是,这个课程是围绕坑点而不是 Java 语言体系展开的,因此不是系统学习 Java 的教材。
第二个关键词是“业务开发”,也就是说课程内容限定在业务项目的开发,侧重业务项目开发时可能遇到的坑。
我们先看“业务”这个词。做业务开发时间长的同学尤其知道,业务项目有两大特点:
工期紧、逻辑复杂,开发人员会更多地考虑主流程逻辑的正确实现,忽略非主流程逻辑,或保障、补偿、一致性逻辑的实现;
往往缺乏详细的设计、监控和容量规划的闭环,结果就是随着业务发展出现各种各样的事故。
根据这些性质,我总结出了近 30 个方面的内容,力求覆盖业务项目开发的关键问题。案例的全面性,是本课程的第二大特点。
这些案例可以看作是 Java 业务代码的避坑大全,帮助你写出更好的代码,也能帮你进一步补全知识网增加面试的信心。你甚至可以把二级目录当作代码审核的 Checklist帮助业务项目一起成长和避坑。
我们再看“开发”这个词。为了更聚焦,也更有针对性,我把专栏内容限定在业务开发,不会过多地讨论架构、测试、部署运维等阶段的问题。而“设计篇”,重在讲述架构设计上可能会遇到的坑,不会全面、完整地介绍高可用、高并发、可伸缩性等架构因素。
第三个关键词是“避坑 100 例”。坑就是容易犯的错,避坑就是踩坑后分析根因,避免重复踩同样的坑。
整个课程 30 篇文章,涉及 100 个案例、约 130 个小坑,其中 40% 来自于我经历过或者是见过的 200 多个线上生产事故,剩下的 60% 来自于我开发业务项目,以及日常审核别人的代码发现的问题。贴近实际,而不是讲述过时的或日常开发根本用不到的技术或框架,就是本课程的第三大特点了。
大部分案例我会配合一个可执行的 Demo 来演示Demo 中不仅有错误实现(踩坑),还有修正后的正确实现(避坑)。完整且连续、授人以渔,是本课程的第四大特点。
完整且连续,知其所以然。我会按照“知识介绍 -> 还原业务场景 -> 错误实现 -> 正确实现 -> 原理分析 -> 小总结 ”来讲解每个案例,针对每个坑点我至少会给出一个解决方案,并会挑选核心的点和你剖析源码。这样一来,你不仅能避坑,更能知道产生坑的根本原因,提升自己的技术能力。
授人以渔。在遇到问题的时候,我们一定是先通过经验和工具来定位分析问题,然后才能定位到坑,并不是一开始就知道为什么的。在这个课程中,我会尽可能地把分析问题的过程完整地呈现给你,而不是直接告诉你为什么,这样你以后遇到问题时也能有解决问题的思路。
这也是为什么,网络上虽然有很多关于 Java 代码踩坑的资料,但很多同学却和我反馈说,看过之后印象不深刻,也因为没吃透导致在一个知识点上重复踩坑。鉴于此,我还会与你分析我根据多年经验和思考,梳理出的一些最佳实践。
看到这里,是不是迫不及待地想要看看这个专栏的内容都会涉及哪些坑点了呢?那就看看下面这张思维导图吧:
鉴于这个专栏的内容和特点,我再和你说说最佳的学习方式是什么。
学习课程的最佳方法
我们都知道,编程是一门实践科学,只看不练、不思考,效果通常不会太好。因此,我建议你打开每篇文章后,能够按照下面的方式深入学习:
对于每一个坑点,实际运行调试一下源码,使用文中提到的工具和方法重现问题,眼见为实。
对于每一个坑点,再思考下除了文内的解决方案和思路外,是否还有其他修正方式。
对于坑点根因中涉及的 JDK 或框架源码分析,你可以找到相关类再系统阅读一下源码。
实践课后思考题。这些思考题,有的是对文章内容的补充,有的是额外容易踩的坑。
理解了课程涉及的所有案例后,你应该就对业务代码大部分容易犯错的点了如指掌了,不仅仅自己可以写出更高质量的业务代码,还可以在审核别人代码时发现可能存在的问题,帮助整个团队成长。
当然了,你从这个课程收获的将不仅是解决案例中那些问题的方法,还可以提升自己分析定位问题、阅读源码的能力。当你再遇到其他诡异的坑时,也能有清晰的解决思路,也可以成长为一名救火专家,帮助大家一起定位、分析问题。
好了,以上就是我今天想要和你分享的内容了。请赶快跟随我们的课程开启避坑之旅吧,也欢迎你留言说说自己的情况,你都踩过哪些坑、对写业务代码又有哪些困惑?我们下一讲见!

View File

@@ -0,0 +1,452 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
01 使用了并发工具类库,线程安全就高枕无忧了吗?
作为课程的第一讲,我今天要和你聊聊使用并发工具类库相关的话题。
在代码审核讨论的时候,我们有时会听到有关线程安全和并发工具的一些片面的观点和结论,比如“把 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符合预期
随后用户 2 来请求接口,这次就出现了 Bug第一和第二次获取到用户 ID 分别是 1 和 2显然第一次获取到了用户 1 的信息,原因就是 Tomcat 的线程池重用了线程。从图中可以看到两次请求的线程都是同一个线程http-nio-8080-exec-1。
这个例子告诉我们,在写业务代码时,首先要理解代码会跑在什么线程上:
我们可能会抱怨学多线程没用,因为代码里没有开启使用多线程。但其实,可能只是我们没有意识到,在 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
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";
}
访问接口后程序输出的日志内容如下:
从日志中可以看到:
初始大小 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";
}
重新调用接口,程序的日志输出结果符合预期:
可以看到,只有一个线程查询到了需要补 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 的和,是否符合预期来校验代码的正确性。测试结果如下:
可以看到,优化后的代码,相比使用锁来操作 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 慢一百倍:
而在大量读的场景下100 万次 get 操作CopyOnWriteArray 又比同步的 ArrayList 快五倍以上:
你可能会问为何在大量写的场景下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 的文档,看一下相关原子性操作 API 是否可以满足业务需求,如果可以则优先考虑使用。
四是,没有了解清楚工具的适用场景,在不合适的场景下使用了错误的工具导致性能更差。比如,没有理解 CopyOnWriteArrayList 的适用场景,把它用在了读写均衡或者大量写操作的场景下,导致性能问题。对于这种场景,你可以考虑是用普通的 List。
其实,这四类坑之所以容易踩到,原因可以归结为,我们在使用并发工具的时候,并没有充分理解其可能存在的问题、适用场景等。所以最后,我还要和你分享两点建议:
一定要认真阅读官方文档(比如 Oracle JDK 文档)。充分阅读官方文档,理解工具的适用场景及其 API 的用法,并做一些小实验。了解之后再去使用,就可以避免大部分坑。
如果你的代码运行在多线程环境下,那么就会有并发问题,并发问题不那么容易重现,可能需要使用压力测试模拟并发场景,来发现其中的 Bug 或性能问题。
今天用到的代码我都放在了GitHub上你可以点击这个链接查看。
思考与讨论
今天我们多次用到了 ThreadLocalRandom你觉得是否可以把它的实例设置到静态变量中在多线程情况下重用呢
ConcurrentHashMap 还提供了 putIfAbsent 方法你能否通过查阅JDK文档说说 computeIfAbsent 和 putIfAbsent 方法的区别?
你在使用并发工具时,还遇到过其他坑吗?我是朱晔,欢迎在评论区与我留言分享你的想法,也欢迎你把这篇文章分享给你的朋友或同事,一起交流。

View File

@@ -0,0 +1,352 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
02 代码加锁:不要让“锁”事成为烦心事
在上一讲中,我与你介绍了使用并发容器等工具解决线程安全的误区。今天,我们来看看解决线程安全问题的另一种重要手段——锁,在使用上比较容易犯哪些错。
我先和你分享一个有趣的案例吧。有一天,一位同学在群里说“见鬼了,疑似遇到了一个 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 方法在判断 ab 也成立:
群里一位同学看到这个问题笑了,说:“这哪是 JVM 的 Bug分明是线程安全问题嘛。很明显你这是在操作两个字段 a 和 b有线程安全问题应该为 add 方法加上锁,确保 a 和 b 的 ++ 是原子性的,就不会错乱了。”随后,他为 add 方法加上了锁:
public synchronized void add()
但,加锁后问题并没有解决。
我们来仔细想一下,为什么锁可以解决线程安全问题呢。因为只有一个线程可以拿到锁,所以加锁后的代码中的资源操作是线程安全的。但是,这个案例中的 add 方法始终只有一个线程在操作,显然只为 add 方法加锁是没用的。
之所以出现这种错乱,是因为两个线程是交错执行 add 和 compare 方法中的业务逻辑而且这些业务逻辑不是原子性的a++ 和 b++ 操作中可以穿插在 compare 方法的比较代码中更需要注意的是a 这种比较操作在字节码层面是加载 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
我们来分析下为什么会出现这个问题吧。
在非静态的 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 秒。
如果精细化考虑了锁应用范围后,性能还无法满足需求的话,我们就要考虑另一个维度的粒度问题了,即:区分读写场景以及资源的访问冲突,考虑使用悲观方式的锁还是乐观方式的锁。
一般业务代码中,很少需要进一步考虑这两种更细粒度的锁,所以我只和你分享几个大概的结论,你可以根据自己的需求来考虑是否有必要进一步优化:
对于读写比例差异明显的场景,考虑使用 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;
}
运行程序,输出如下日志:
可以看到100 次下单操作成功了 65 次10 种商品总计 10000 件,库存总计为 9805消耗了 195 件符合预期65 次下单成功,每次下单包含三件商品),总耗时 50 秒。
为什么会这样呢?
使用 JDK 自带的 VisualVM 工具来跟踪一下,重新执行方法后不久就可以看到,线程 Tab 中提示了死锁问题,根据提示点击右侧线程 Dump 按钮进行线程抓取操作:
查看抓取出的线程栈,在页面中部可以看到如下日志:
显然,是出现了死锁,线程 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
这个案例中,虽然产生了死锁问题,但因为尝试获取锁的操作并不是无限阻塞的,所以没有造成永久死锁,之后的改进就是避免循环等待,通过对购物车的商品进行排序来实现有顺序的加锁,避免循环等待。
重点回顾
我们一起总结回顾下,使用锁来解决多线程情况下线程安全问题的坑吧。
第一,使用 synchronized 加锁虽然简单但我们首先要弄清楚共享资源是类还是实例级别的、会被哪些线程操作synchronized 关联的锁对象或方法又是什么范围的。
第二,加锁尽可能要考虑粒度和场景,锁保护的代码意味着无法进行多线程操作。对于 Web 类型的天然多线程项目,对方法进行大范围加锁会显著降级并发能力,要考虑尽可能地只为必要的代码块加锁,降低锁的粒度;而对于要求超高性能的业务,还要细化考虑锁的读写场景,以及悲观优先还是乐观优先,尽可能针对明确场景精细化加锁方案,可以在适当的场景下考虑使用 ReentrantReadWriteLock、StampedLock 等高级的锁工具类。
第三,业务逻辑中有多把锁时要考虑死锁问题,通常的规避方案是,避免无限等待和循环等待。
此外,如果业务逻辑中锁的实现比较复杂的话,要仔细看看加锁和释放是否配对,是否有遗漏释放或重复释放的可能性;并且对于分布式锁要考虑锁自动超时释放了,而业务逻辑却还在进行的情况下,如果别的线线程或进程拿到了相同的锁,可能会导致重复执行。
为演示方便,今天的案例是在 Controller 的逻辑中开新的线程或使用线程池进行并发模拟,我们当然可以意识到哪些对象是并发操作的。但对于 Web 应用程序的天然多线程场景你可能更容易忽略这点并且也可能因为误用锁降低应用整体的吞吐量。Argentina
今天用到的代码,我都放在了 GitHub 上,你可以点击这个链接查看。
思考与讨论
本文开头的例子里,变量 a、b 都使用了 volatile 关键字,你知道原因吗?我之前遇到过这样一个坑:我们开启了一个线程无限循环来跑一些任务,有一个 bool 类型的变量来控制循环的退出,默认为 true 代表执行,一段时间后主线程将这个变量设置为了 false。如果这个变量不是 volatile 修饰的,子线程可以退出吗?你能否解释其中的原因呢?
文末我们又提了两个坑,一是加锁和释放没有配对的问题,二是锁自动释放导致的重复逻辑执行的问题。你有什么方法来发现和解决这两种问题吗?
在使用锁的过程中,你还遇到过其他坑吗?我是朱晔,欢迎在评论区与我留言分享你的想法,也欢迎你把这篇文章分享给你的朋友或同事,一起交流。

View File

@@ -0,0 +1,449 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
03 线程池:业务代码最常用也最容易犯错的组件
今天,我来讲讲使用线程池需要注意的一些问题。
在程序中,我们会用各种池化技术来缓存创建昂贵的对象,比如线程池、连接池、内存池。一般是预先创建一些对象放入池中,使用的时候直接取出使用,用完归还以便复用,还会通过一定的策略调整池中缓存对象的数量,实现池的动态伸缩。
由于线程的创建比较昂贵,随意、没有控制地创建大量线程会造成性能问题,因此短平快的任务一般考虑使用线程池来处理,而不是直接创建线程。
今天,我们就针对线程池这个话题展开讨论,通过三个生产事故,来看看使用线程池应该注意些什么。
线程池的声明需要手动进行
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 次提交失败了:
并且日志中也出现了 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 方法打印出的日志绘制成图表,得出如下曲线:
至此,我们可以总结出线程池默认的工作行为:
不会初始化 corePoolSize 个线程,有任务来了才创建工作线程;
当核心线程满了之后不会立即扩容线程池,而是把任务堆积到工作队列中;
当工作队列满了后扩容线程池,一直到线程个数达到 maximumPoolSize 为止;
如果队列已满且达到了最大线程后还有任务进来,按照拒绝策略处理;
当线程数大于核心线程数时,线程等待 keepAliveTime 后还是没有任务需要处理的话,收缩线程到核心线程数。
了解这个策略,有助于我们根据实际的容量规划需求,为线程池设置合适的初始化参数。当然,我们也可以通过一些手段来改变这些默认工作行为,比如:
声明线程池后立即调用 prestartAllCoreThreads 方法,来启动所有核心线程;
传入 true 给 allowCoreThreadTimeOut 方法,来让线程池在空闲的时候同样回收核心线程。
不知道你有没有想过Java 线程池是先用工作队列来存放来不及处理的任务,满了之后再扩容线程池。当我们的工作队列设置得很大时,最大线程数这个参数显得没有意义,因为队列很难满,或者到满的时候再去扩容线程池已经于事无补了。
那么,我们有没有办法让线程池更激进一点,优先开启更多的线程,而把队列当成一个后备方案呢?比如我们这个例子,任务执行得很慢,需要 10 秒,如果线程池可以优先扩容到 5 个最大线程,那么这些任务最终都可以完成,而不会因为线程池扩容过晚导致慢任务来不及处理。
限于篇幅,这里我只给你一个大致思路:
由于线程池在工作队列满了无法入队的情况下会扩容线程池,那么我们是否可以重写队列的 offer 方法,造成这个队列已满的假象呢?
由于我们 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 方法打印出的日志,我们观察下线程池的负担:
可以看到,线程池的 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性能的确非常差。
细想一下,问题其实没有这么简单。因为原来执行 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
可以看到,盲目复用线程池混用线程的问题在于,别人定义的线程池属性不一定适合你的任务,而且混用会相互干扰。这就好比,我们往往会用虚拟化技术来实现资源的隔离,而不是让所有应用程序都直接使用物理机。
就线程池混用问题我想再和你补充一个坑Java 8 的 parallel stream 功能,可以让我们很方便地并行处理集合中的元素,其背后是共享同一个 ForkJoinPool默认并行度是 CPU 核数 -1。对于 CPU 绑定的任务来说,使用这样的配置比较合适,但如果集合操作涉及同步 IO 操作的话(比如数据库操作、外部服务调用等),建议自定义一个 ForkJoinPool或普通线程池。你可以参考第一讲的相关 Demo。
重点回顾
线程池管理着线程,线程又属于宝贵的资源,有许多应用程序的性能问题都来自线程池的配置和使用不当。在今天的学习中,我通过三个和线程池相关的生产事故,和你分享了使用线程池的几个最佳实践。
第一Executors 类提供的一些快捷声明线程池的方法虽然简单,但隐藏了线程池的参数细节。因此,使用线程池时,我们一定要根据场景和需求配置合理的线程数、任务队列、拒绝策略、线程回收策略,并对线程进行明确的命名方便排查问题。
第二,既然使用了线程池就需要确保线程池是在复用的,每次 new 一个线程池出来可能比不用线程池还糟糕。如果你没有直接声明线程池而是使用其他同学提供的类库来获得一个线程池,请务必查看源码,以确认线程池的实例化方式和配置是符合预期的。
第三,复用线程池不代表应用程序始终使用同一个线程池,我们应该根据任务的性质来选用不同的线程池。特别注意 IO 绑定的任务和 CPU 绑定的任务对于线程池属性的偏好,如果希望减少任务间的相互干扰,考虑按需使用隔离的线程池。
最后我想强调的是,线程池作为应用程序内部的核心组件往往缺乏监控(如果你使用类似 RabbitMQ 这样的 MQ 中间件,运维同学一般会帮我们做好中间件监控),往往到程序崩溃后才发现线程池的问题,很被动。在设计篇中我们会重新谈及这个问题及其解决方案。
今天用到的代码,我都放在了 GitHub 上,你可以点击这个链接查看。
思考与讨论
在第一节中我们提到,或许一个激进创建线程的弹性线程池更符合我们的需求,你能给出相关的实现吗?实现后再测试一下,是否所有的任务都可以正常处理完成呢?
在第二节中,我们改进了 ThreadPoolHelper 使其能够返回复用的线程池。如果我们不小心每次都创建了这样一个自定义的线程池10 核心线程50 最大线程2 秒回收的),反复执行测试接口线程,最终可以被回收吗?会出现 OOM 问题吗?
你还遇到过线程池相关的其他坑吗?我是朱晔,欢迎在评论区与我留言分享你的想法,也欢迎你把这篇文章分享给你的朋友或同事,一起交流。

View File

@@ -0,0 +1,523 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
04 连接池:别让连接池帮了倒忙
今天,我们来聊聊使用连接池需要注意的问题。
在上一讲,我们学习了使用线程池需要注意的问题。今天,我再与你说说另一种很重要的池化技术,即连接池。
我先和你说说连接池的结构。连接池一般对外提供获得连接、归还连接的接口给客户端使用,并暴露最小空闲连接数、最大连接数等可配置参数,在内部则实现连接建立、连接心跳保持、连接管理、空闲连接回收、连接可用性检测等功能。连接池的结构示意图,如下所示:
业务项目中经常会用到的连接池主要是数据库连接池、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=1Key=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 继承了 BinaryJedisBinaryJedis 中保存了单个 Client 的实例Client 最终继承了 ConnectionConnection 中保存了单个 Socket 的实例 Socket 对应的两个读写流因此一个 Jedis 对应一个 Socket 连接类图如下
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而后者继承了抽象类 PoolPool 内部维护了 Apache Common 的通用池 GenericObjectPool。JedisPool 的连接池就是基于 GenericObjectPool 的。
看到这里我们了解了Jedis 的 API 实现是我们说的三种类型中的第一种,也就是连接池和连接分离的 APIJedisPool 是线程安全的连接池Jedis 是非线程安全的单一连接。知道了原理之后,我们再使用 Jedis 就胸有成竹了。
使用连接池务必确保复用
在介绍线程池的时候我们强调过,池一定是用来复用的,否则其使用代价会比每次创建单一对象更大。对连接池来说更是如此,原因如下:
创建连接池的时候很可能一次性创建了多个连接,大多数连接池考虑到性能,会在初始化的时候维护一定数量的最小连接(毕竟初始化连接池的过程一般是一次性的),可以直接使用。如果每次使用连接池都按需创建连接池,那么很可能你只用到一个连接,但是创建了 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 的线程,且这些线程不会销毁:
对这个接口进行几秒的压测(压测使用 wrk1 个并发 1 个连接)可以看到,已经建立了三千多个 TCP 连接到 45678 端口(其中有 1 个是压测客户端到 Tomcat 的连接,大部分都是 HttpClient 到 Tomcat 的连接):
好在有了空闲连接回收的策略60 秒之后连接处于 CLOSE_WAIT 状态,最终彻底关闭。
这 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
如此大的性能差异显然是因为 TCP 连接的复用。你可能注意到了,刚才定义连接池时,我将最大连接数设置为 1。所以复用连接池方式复用的始终应该是同一个连接而新建连接池方式应该是每次都会创建新的 TCP 连接。
接下来,我们通过网络抓包工具 Wireshark 来证实这一点。
如果调用 wrong2 接口每次创建新的连接池来发起 HTTP 请求,从 Wireshark 可以看到,每次请求服务端 45678 的客户端端口都是新的。这里我发起了三次请求,程序通过 HttpClient 访问服务端 45678 的客户端端口号,分别是 51677、51679 和 51681
也就是说,每次都是新的 TCP 连接,放开 HTTP 这个过滤条件也可以看到完整的 TCP 握手、挥手的过程:
而复用连接池方式的接口 right 的表现就完全不同了。可以看到,第二次 HTTP 请求 #41 的客户端端口 61468 和第一次连接 #23 的端口是一样的Wireshark 也提示了整个 TCP 会话中,当前 #41 请求是第二次请求,前一次是 #23,后面一次是 #75
只有 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
使用 wrk 对应用进行压测,可以看到连接数一下子从 0 到了 10有 20 个线程在等待获取连接:
不久就出现了无法获取数据库连接的异常,如下所示:
[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
然后,再观察一下这个参数是否适合当前压力,满足需求的同时也不占用过多资源。从监控来看这个调整是合理的,有一半的富余资源,再也没有线程需要等待连接了:
在这个 Demo 里,我知道压测大概能对应使用 25 左右的并发连接,所以直接把连接池最大连接设置为了 50。在真实情况下只要数据库可以承受你可以选择在遇到连接超限的时候先设置一个足够大的连接数然后观察最终应用的并发再按照实际并发数留出一半的余量来设置最终的最大连接。
其实,看到错误日志后再调整已经有点儿晚了。更合适的做法是,对类似数据库连接池的重要资源进行持续检测,并设置一半的使用量作为报警阈值,出现预警后及时扩容。
在这里我是为了演示,才通过 JConsole 查看参数配置后的效果,生产上需要把相关数据对接到指标监控体系中持续监测。
这里要强调的是,修改配置参数务必验证是否生效,并且在监控系统中确认参数是否生效、是否合理。之所以要“强调”,是因为这里有坑。
我之前就遇到过这样一个事故。应用准备针对大促活动进行扩容,把数据库配置文件中 Druid 连接池最大连接数 maxActive 从 50 提高到了 100修改后并没有通过监控验证结果大促当天应用因为连接池连接数不够爆了。
经排查发现,当时修改的连接数并没有生效。原因是,应用虽然一开始使用的是 Druid 连接池,但后来框架升级了,把连接池替换为了 Hikari 实现,原来的那些配置其实都是无效的,修改后的参数配置当然也不会生效。
所以说,对连接池进行调参,一定要眼见为实。
重点回顾
今天,我以三种业务代码最常用的 Redis 连接池、HTTP 连接池、数据库连接池为例,和你探讨了有关连接池实现方式、使用姿势和参数配置的三大问题。
客户端 SDK 实现连接池的方式包括池和连接分离、内部带有连接池和非连接池三种。要正确使用连接池就必须首先鉴别连接池的实现方式。比如Jedis 的 API 实现的是池和连接分离的方式,而 Apache HttpClient 是内置连接池的 API。
对于使用姿势其实就是两点,一是确保连接池是复用的,二是尽可能在程序退出之前显式关闭连接池释放资源。连接池设计的初衷就是为了保持一定量的连接,这样连接可以随取随用。从连接池获取连接虽然很快,但连接池的初始化会比较慢,需要做一些管理模块的初始化以及初始最小闲置连接。一旦连接池不是复用的,那么其性能会比随时创建单一连接更差。
最后连接池参数配置中最重要的是最大连接数许多高并发应用往往因为最大连接数不够导致性能问题。但最大连接数不是设置得越大越好够用就好。需要注意的是针对数据库连接池、HTTP 连接池、Redis 连接池等重要连接池,务必建立完善的监控和报警机制,根据容量规划及时调整参数配置。
今天用到的代码,我都放在了 GitHub 上,你可以点击这个链接查看。
思考与讨论
有了连接池之后,获取连接是从连接池获取,没有足够连接时连接池会创建连接。这时,获取连接操作往往有两个超时时间:一个是从连接池获取连接的最长等待时间,通常叫作请求连接超时 connectRequestTimeout 或连接等待超时 connectWaitTimeout一个是连接池新建 TCP 连接三次握手的连接超时,通常叫作连接超时 connectTimeout。针对 JedisPool、Apache HttpClient 和 Hikari 数据库连接池,你知道如何设置这 2 个参数吗?
对于带有连接池的 SDK 的使用姿势,最主要的是鉴别其内部是否实现了连接池,如果实现了连接池要尽量复用 Client。对于 NoSQL 中的 MongoDB 来说,使用 MongoDB Java 驱动时MongoClient 类应该是每次都创建还是复用呢?你能否在官方文档中找到答案呢?
关于连接池,你还遇到过什么坑吗?我是朱晔,欢迎在评论区与我留言分享,也欢迎你把这篇文章分享给你的朋友或同事,一起交流。

View File

@@ -0,0 +1,533 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
05 HTTP调用你考虑到超时、重试、并发了吗
今天,我们一起聊聊进行 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 和 ReadTimeoutRequest.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 最大并发是 1020 不会成为瓶颈。举一个例子,使用同一个 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 协议要求的,这里有这么一段话:
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 协议的建议来使用合适的 HTTP 方法。
最后我们看到,包括 HttpClient 在内的 HTTP 客户端以及浏览器,都会限制客户端调用的最大并发数。如果你的客户端有比较大的请求调用并发,比如做爬虫,或是扮演类似代理的角色,又或者是程序本身并发较高,如此小的默认值很容易成为吞吐量的瓶颈,需要及时调整。
今天用到的代码,我都放在了 GitHub 上,你可以点击这个链接查看。
思考与讨论
第一节中我们强调了要注意连接超时和读取超时参数的配置,大多数的 HTTP 客户端也都有这两个参数。有读就有写,但为什么我们很少看到“写入超时”的概念呢?
除了 Ribbon 的 AutoRetriesNextServer 重试机制Nginx 也有类似的重试功能。你了解 Nginx 相关的配置吗?
针对 HTTP 调用,你还遇到过什么坑吗?我是朱晔,欢迎在评论区与我留言分享你的想法,也欢迎你把这篇文章分享给你的朋友或同事,一起交流。

View File

@@ -0,0 +1,508 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
06 2成的业务代码的Spring声明式事务可能都没处理正确
今天,我来和你聊聊业务代码中与数据库事务相关的坑。
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 访问方法必然不是代理。
把 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 三种实现的区别:
通过 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代表只有一个用户也就是主用户注册完成了符合预期
重点回顾
今天,我针对业务代码中最常见的使用数据库事务的方式,即 Spring 声明式事务,与你总结了使用上可能遇到的三类坑,包括:
第一,因为配置不正确,导致方法上的事务没生效。我们务必确认调用 @Transactional 注解标记的方法是 public 的,并且是通过 Spring 注入的 Bean 进行调用的。
第二因为异常处理不正确导致事务虽然生效但出现异常时没回滚。Spring 默认只会对标记 @Transactional 注解的方法出现了 RuntimeException 和 Error 的时候回滚,如果我们的方法捕获了异常,那么需要通过手动编码处理事务回滚。如果希望 Spring 针对其他异常也可以回滚,那么可以相应配置 @Transactional 注解的 rollbackFor 和 noRollbackFor 属性来覆盖其默认设置。
第三,如果方法涉及多次数据库操作,并希望将它们作为独立的事务进行提交或回滚,那么我们需要考虑进一步细化配置事务传播方式,也就是 @Transactional 注解的 Propagation 属性。
可见,正确配置事务可以提高业务项目的健壮性。但,又因为健壮性问题往往体现在异常情况或一些细节处理上,很难在主流程的运行和测试中发现,导致业务代码的事务处理逻辑往往容易被忽略,因此我在代码审查环节一直很关注事务是否正确处理。
如果你无法确认事务是否真正生效,是否按照预期的逻辑进行,可以尝试打开 Spring 的部分 Debug 日志,通过事务的运作细节来验证。也建议你在单元测试时尽量覆盖多的异常场景,这样在重构时,也能及时发现因为方法的调用方式、异常处理逻辑的调整,导致的事务失效问题。
今天用到的代码,我都放在了 GitHub 上,你可以点击这个链接查看。
思考与讨论
考虑到 Demo 的简洁,文中所有数据访问使用的都是 Spring Data JPA。国内大多数互联网业务项目是使用 MyBatis 进行数据访问的,使用 MyBatis 配合 Spring 的声明式事务也同样需要注意文中提到的这些点。你可以尝试把今天的 Demo 改为 MyBatis 做数据访问实现,看看日志中是否可以体现出这些坑。
在第一节中我们提到,如果要针对 private 方法启用事务,动态代理方式的 AOP 不可行,需要使用静态织入方式的 AOP也就是在编译期间织入事务增强代码可以配置 Spring 框架使用 AspectJ 来实现 AOP。你能否参阅 Spring 的文档“Using @Transactional with AspectJ”试试呢注意AspectJ 配合 lombok 使用,还可能会踩一些坑。
有关数据库事务,你还遇到过其他坑吗?我是朱晔,欢迎在评论区与我留言分享,也欢迎你把这篇文章分享给你的朋友或同事,一起交流。

View File

@@ -0,0 +1,371 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
07 数据库索引:索引并不是万能药
今天,我要和你分享的主题是,数据库的索引并不是万能药。
几乎所有的业务项目都会涉及数据存储,虽然当前各种 NoSQL 和文件系统大行其道,但 MySQL 等关系型数据库因为满足 ACID、可靠性高、对开发友好等特点仍然最常被用于存储重要数据。在关系型数据库中索引是优化查询性能的重要手段。
为此,我经常看到一些同学一遇到查询性能问题,就盲目要求运维或 DBA 给数据表相关字段创建大量索引。显然,这种想法是错误的。今天,我们就以 MySQL 为例来深入理解下索引的原理,以及相关误区。
InnoDB 是如何存储数据的?
MySQL 把数据存储和查询操作抽象成了存储引擎不同的存储引擎对数据的存储和读取方式各不相同。MySQL 支持多种存储引擎,并且可以以表为粒度设置存储引擎。因为支持事务,我们最常使用的是 InnoDB。为方便理解下面的内容我先和你简单说说 InnoDB 是如何存储数据的。
虽然数据保存在磁盘中但其处理是在内存中进行的。为了减少磁盘随机读取次数InnoDB 采用页而不是行的粒度来保存数据即数据被分成若干页以页为单位保存在磁盘中。InnoDB 的页大小,一般是 16KB。
各个数据页组成一个双向链表,每个数据页中的记录按照主键顺序组成单向链表;每一个数据页中有一个页目录,方便按照主键查询记录。数据页的结构如下:
页目录通过槽把记录分成不同的小组,每个小组有若干条记录。如图所示,记录中最前面的小方块中的数字,代表的是当前分组的记录条数,最小和最大的槽指向 2 个特殊的伪记录。有了槽之后,我们按照主键搜索页中记录时,就可以采用二分法快速搜索,无需从最小记录开始遍历整个页中的记录链表。
举一个例子如果要搜索主键PK=15 的记录:
先二分得出槽中间位是 (0+6)/2=3看到其指向的记录是 1215所以需要从 #3 槽后继续搜索记录;
再使用二分搜索出 #3 槽和 #6 槽的中间位是 (3+6)/2=4.5 取整 4#4 槽对应的记录是 1615所以记录一定在 #4 槽中;
再从 #3 槽指向的 12 号记录开始向下搜索 3 次,定位到 15 号记录。
理解了 InnoDB 存储数据的原理后,我们就可以继续学习 MySQL 索引相关的原理和坑了。
聚簇索引和二级索引
说到索引,页目录就是最简单的索引,是通过对记录进行一级分组来降低搜索的时间复杂度。但,这样能够降低的时间复杂度数量级,非常有限。当有无数个数据页来存储表数据的时候,我们就需要考虑如何建立合适的索引,才能方便定位记录所在的页。
为了解决这个问题InnoDB 引入了 B+ 树。如下图所示B+ 树是一棵倒过来的树:
B+ 树的特点包括:
最底层的节点叫作叶子节点,用来存放数据;
其他上层节点叫作非叶子节点,仅用来存放目录项,作为索引;
非叶子节点分为不同层次,通过分层来降低每一层的搜索量;
所有节点按照索引键大小排序,构成一个双向链表,加速范围查找。
因此InnoDB 使用 B+ 树,既可以保存实际数据,也可以加速数据搜索,这就是聚簇索引。如果把上图叶子节点下面方块中的省略号看作实际数据的话,那么它就是聚簇索引的示意图。由于数据在物理上只会保存一份,所以包含实际数据的聚簇索引只能有一个。
InnoDB 会自动使用主键(唯一定义一条记录的单个或多个字段)作为聚簇索引的索引键(如果没有主键,就选择第一个不包含 NULL 值的唯一列)。上图方框中的数字代表了索引键的值,对聚簇索引而言一般就是主键。
我们再看看 B+ 树如何实现快速查找主键。比如,我们要搜索 PK=4 的数据,通过根节点中的索引可以知道数据在第一个记录指向的 2 号页中,通过 2 号页的索引又可以知道数据在 5 号页5 号页就是实际的数据页,然后再通过二分法查找页目录马上可以找到记录的指针。
为了实现非主键字段的快速搜索,就引出了二级索引,也叫作非聚簇索引、辅助索引。二级索引,也是利用的 B+ 树的数据结构,如下图所示:
这次二级索引的叶子节点中保存的不是实际数据,而是主键,获得主键值后去聚簇索引中获得数据行。这个过程就叫作回表。
举个例子,有个索引是针对用户名字段创建的,索引记录上面方块中的字母是用户名,按照顺序形成链表。如果我们要搜索用户名为 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 代价,并且可能在操作过程中产生死锁。
你可以查看这个文档,以进一步了解如何设置合理的合并阈值,来平衡页的空闲率和因为再次页分裂产生的代价。
其次是空间代价。虽然二级索引不保存原始数据但要保存索引列的数据所以会占用更多的空间。比如person 表创建了两个索引后,使用下面的 SQL 查看数据和索引占用的磁盘:
DATA_LENGTH, INDEX_LENGTH FROM information_schema.TABLES WHERE TABLE_NAME='person'
结果显示,数据本身只占用了 4.7M,而索引占用了 8.4M。
最后是回表的代价。二级索引不保存原始数据,通过索引找到主键后需要再查询聚簇索引,才能得到我们要的数据。比如,使用 SELECT * 按照 name 字段查询用户,使用 EXPLAIN 查看执行计划:
EXPLAIN SELECT * FROM person WHERE NAME='name1'
执行计划如下,可以发现:
key 字段代表实际走的是哪个索引,其值是 name_score说明走的是 name_score 这个索引。
type 字段代表了访问表的方式,其值 ref 说明是二级索引等值匹配,符合我们的查询。
把 SQL 中的 * 修改为 NAME 和 SCORE也就是 SELECT name_score 联合索引包含的两列:
EXPLAIN SELECT NAME,SCORE FROM person WHERE NAME='name1'
再来看看执行计划:
可以看到Extra 列多了一行 Using index 的提示,证明这次查询直接查的是二级索引,免去了回表。
原因很简单,联合索引中其实保存了多个索引列的值,对于页中的记录先按照字段 1 排序,如果相同再按照字段 2 排序,如图所示:
图中,叶子节点每一条记录的第一和第二个方块是索引列的数据,第三个方块是记录的主键。如果我们需要查询的是索引列索引或联合索引能覆盖的数据,那么查询索引本身已经“覆盖”了需要的数据,不再需要回表查询。因此,这种情况也叫作索引覆盖。我会在最后一小节介绍如何查看不同查询的成本,和你一起看看索引覆盖和索引查询后回表的代价差异。
最后,我和你总结下关于索引开销的最佳实践吧。
第一,无需一开始就建立索引,可以等到业务场景明确后,或者是数据量超过 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
把百分号放到后面走前缀匹配type=range 表示走索引扫描key=name_score 看到实际走了 name_score 索引:
EXPLAIN SELECT * FROM person WHERE NAME LIKE 'name123%' LIMIT 100
原因很简单,索引 B+ 树中行数据按照索引值排序,只能根据前缀进行比较。如果要按照后缀搜索也希望走索引的话,并且永远只是按照后缀搜索的话,可以把数据反过来存,用的时候再倒过来。
第二,条件涉及函数操作无法走索引。比如搜索条件用到了 LENGTH 函数,肯定无法走索引:
EXPLAIN SELECT * FROM person WHERE LENGTH(NAME)=7
同样的原因,索引保存的是索引列的原始值,而不是经过函数计算后的值。如果需要针对函数调用走数据库索引的话,只能保存一份函数变换后的值,然后重新针对这个计算列做索引。
第三,联合索引只能匹配左边的列。也就是说,虽然对 name 和 score 建了联合索引,但是仅按照 score 列搜索无法走索引:
EXPLAIN SELECT * FROM person WHERE SCORE>45678
原因也很简单,在联合索引的情况下,数据是按照索引第一列排序,第一列数据相同时才会按照第二列排序。也就是说,如果我们想使用联合索引中尽可能多的列,查询条件中的各个列必须是联合索引中从最左边开始连续的列。如果我们仅仅按照第二列搜索,肯定无法走索引。尝试把搜索条件加入 name 列,可以看到走了 name_score 索引:
EXPLAIN SELECT * FROM person WHERE SCORE>45678 AND NAME LIKE 'NAME45%'
需要注意的是,因为有查询优化器,所以 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'
输出如下:
可以看到:
总行数是 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'
其执行计划是全表扫描:
只要把 create_time 条件中的 5 点改为 6 点就变为走索引了,并且走的是 create_time 索引而不是 name_score 联合索引:
我们可以得到两个结论:
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 的文档
重点回顾
今天我先和你分析了 MySQL InnoDB 存储引擎页聚簇索引和二级索引的结构然后分析了关于索引的两个误区
第一个误区是考虑到索引的维护代价空间占用和查询时回表的代价不能认为索引越多越好索引一定是按需创建的并且要尽可能确保足够轻量一旦创建了多字段的联合索引我们要考虑尽可能利用索引本身完成数据查询减少回表的成本
第二个误区是不能认为建了索引就一定有效对于后缀的匹配查询查询中不包含联合索引的第一列查询条件涉及函数计算等情况无法使用索引此外即使 SQL 本身符合索引的使用条件MySQL 也会通过评估各种查询方式的代价来决定是否走索引以及走哪个索引
因此在尝试通过索引进行 SQL 性能优化的时候务必通过执行计划或实际的效果来确认索引是否能有效改善性能问题否则增加了索引不但没解决性能问题还增加了数据库增删改的负担如果对 EXPLAIN 给出的执行计划有疑问的话你还可以利用 optimizer_trace 查看详细的执行计划做进一步分析
今天用到的代码我都放在了 GitHub 你可以点击这个链接查看
思考与讨论
在介绍二级索引代价时我们通过 EXPLAIN 命令看到了索引覆盖和回表的两种情况你能用 optimizer trace 来分析一下这两种情况的成本差异吗
索引除了可以用于加速搜索外还可以在排序时发挥作用你能通过 EXPLAIN 来证明吗你知道在什么情况下针对排序索引会失效吗
针对数据库索引你还有什么心得吗我是朱晔欢迎在评论区与我留言分享也欢迎你把这篇文章分享给你的朋友或同事一起交流

View File

@@ -0,0 +1,676 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
08 判等问题:程序里如何确定你就是你?
今天,我来和你聊聊程序里的判等问题。
你可能会说,判等不就是一行代码的事情吗,有什么好说的。但,这一行代码如果处理不当,不仅会出现 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 的类代码快捷生成菜单支持的功能如下:
注意 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 为 2name 是 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 注解(详见这里)其实包含了 @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 规约插件(详见这里),来及时提示我们这类低级错误:
今天用到的代码,我都放在了 GitHub 上,你可以点击这个链接查看。
思考与讨论
在实现 equals 时,我是先通过 getClass 方法判断两个对象的类型,你可能会想到还可以使用 instanceof 来判断。你能说说这两种实现方式的区别吗?
在第三节的例子中,我演示了可以通过 HashSet 的 contains 方法判断元素是否在 HashSet 中,同样是 Set 的 TreeSet 其 contains 方法和 HashSet 有什么区别吗?
有关对象判等、比较,你还遇到过其他坑吗?我是朱晔,欢迎在评论区与我留言分享你的想法,也欢迎你把这篇文章分享给你的朋友或同事,一起交流。

View File

@@ -0,0 +1,340 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
09 数值计算:注意精度、舍入和溢出问题
今天,我要和你说说数值计算的精度、舍入和溢出问题。
之所以要单独分享数值计算,是因为很多时候我们习惯的或者说认为理所当然的计算,在计算器或计算机看来并不是那么回事儿。就比如前段时间爆出的一条新闻,说是手机计算器把 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 标准实现浮点数的表达和运算,你可以通过这里查看数值转化为二进制的结果。
比如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 来进行浮点数的表示、计算、格式化。在上一讲介绍判等问题时,我提到一个原则:包装类的比较要通过 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 和 scale1.0 的 scale 是 11 的 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 相同的 BigDecimalscale 也是一致的:
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 上,你可以点击这个链接查看。
思考与讨论
BigDecimal提供了 8 种舍入模式,你能通过一些例子说说它们的区别吗?
数据库(比如 MySQL中的浮点数和整型数字你知道应该怎样定义吗又如何实现浮点数的准确计算呢
针对数值运算,你还遇到过什么坑吗?我是朱晔,欢迎在评论区与我留言分享你的想法,也欢迎你把这篇文章分享给你的朋友或同事,一起交流。

View File

@@ -0,0 +1,538 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
10 集合类坑满地的List列表操作
今天,我来和你说说 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 的“解耦”,不再相互影响。同时,因为操作的是真正的 ArrayListadd 也不再出错:
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 迭代异常。
一定要让合适的数据结构做合适的事情
在介绍并发工具时,我提到要根据业务场景选择合适的并发工具或容器。在使用 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 毫秒。
“shell
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](assets/1e8492040dd4b1af6114a6eeba06e524.png)
![img](assets/53d53e3ce2efcb081f8d9fa496cb8ec7.png)
所以,在应用内存吃紧的情况下,我们需要考虑是否值得使用更多的内存消耗来换取更高的性能。这里我们看到的是平衡的艺术,空间换时间,还是时间换空间,只考虑任何一个方面都是不对的。
第二个误区是,过于迷信教科书的大 O 时间复杂度。
数据结构中要实现一个列表,有基于连续存储的数组和基于指针串联的链表两种方式。在 Java 中,有代表性的实现是 ArrayList 和 LinkedList前者背后的数据结构是数组后者则是双向链表。
在选择数据结构的时候,我们通常会考虑每种数据结构不同操作的时间复杂度,以及使用场景两个因素。查看这里,你可以看到数组和链表大 O 时间复杂度的显著差异:
对于数组,随机元素访问的时间复杂度是 O(1),元素插入操作是 O(n)
对于链表,随机元素访问的时间复杂度是 O(n),元素插入操作是 O(1)。
那么,在大量的元素插入、很少的随机访问的业务场景下,是不是就应该使用 LinkedList 呢?接下来,我们写一段代码测试下两者随机访问和插入的性能吧。
定义四个参数一致的方法,分别对元素个数为 elementCount 的 LinkedList 和 ArrayList循环 loopCount 次,进行随机访问和增加元素到随机位置的操作:
```java
//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 是我写的但我从来不用,有谁会真的用吗?
这告诉我们,任何东西理论上和实际上是有差距的,请勿迷信教科书的理论,最好在下定论之前实际测试一下。抛开算法层面不谈,由于 CPU 缓存、内存连续性等问题,链表这种数据结构的实现方式对性能并不友好,即使在它最擅长的场景都不一定可以发挥威力。
重点回顾
今天,我分享了若干和 List 列表相关的错误案例,基本都是由“想当然”导致的。
第一想当然认为Arrays.asList 和 List.subList 得到的 List 是普通的、独立的 ArrayList在使用时出现各种奇怪的问题。
Arrays.asList 得到的是 Arrays 的内部类 ArrayListList.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 上,你可以点击这个链接查看。
思考与讨论
最后,我给你留下与 ArrayList 在删除元素方面的坑有关的两个思考题吧。
调用类型是 Integer 的 ArrayList 的 remove 方法删除元素,传入一个 Integer 包装类的数字和传入一个 int 基本类型的数字,结果一样吗?
循环遍历 List调用 remove 方法删除元素,往往会遇到 ConcurrentModificationException 异常,原因是什么,修复方式又是什么呢?
你还遇到过与集合类相关的其他坑吗?我是朱晔,欢迎在评论区与我留言分享你的想法,也欢迎你把这篇文章分享给你的朋友或同事,一起交流。

View File

@@ -0,0 +1,478 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
11 空值处理分不清楚的null和恼人的空指针
今天,我要和你分享的主题是,空值处理:分不清楚的 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。Arthas 简单易用功能强大,可以定位出大多数的 Java 生产问题。
接下来,我就和你演示下如何在 30 秒内知道 wrongMethod 方法的入参,从而定位到空指针到底是哪个入参引起的。如下截图中有三个红框,我先和你分析第二和第三个红框:
第二个红框表示Arthas 启动后被附加到了 JVM 进程;
第三个红框表示,通过 watch 命令监控 wrongMethod 方法的入参。
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 其他参数是 nullArchas 正确输出了方法的所有入参,这样我们很容易就能定位到空指针的问题了。
到这里,如果是简单的业务逻辑的话,你就可以定位到空指针异常了;如果是分支复杂的业务逻辑,你需要再借助 stack 命令来查看 wrongMethod 方法的调用栈,并配合 watch 命令查看各方法的入参,就可以很方便地定位到空指针的根源了。
下图演示了通过 stack 命令观察 wrongMethod 的调用路径:
如果你想了解 Arthas 各种命令的详细使用方法,可以点击这里查看。
接下来,我们看看如何修复上面出现的 5 种空指针异常。
其实,对于任何空指针异常的处理,最直白的方式是先判空后操作。不过,这只能让异常不再出现,我们还是要找到程序逻辑中出现的空指针究竟是来源于入参还是 Bug
如果是来源于入参,还要进一步分析入参是否合理等;
如果是来源于 Bug那空指针不一定是纯粹的程序 Bug可能还涉及业务属性和接口调用规范等。
在这里,因为是 Demo所以我们只考虑纯粹的空指针判空这种修复方式。如果要先判空后处理大多数人会想到使用 if-else 代码块。但,这种方式既增加代码量又会降低易读性,我们可以尝试利用 Java 8 的 Optional 类来消除这样的 if-else 逻辑,使用一行代码进行判空和处理。
修复思路如下:
对于 Integer 的判空,可以使用 Optional.ofNullable 来构造一个 Optional然后使用 orElse(0) 把 null 替换为默认值再进行 +1 操作。
对于 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由于不能确认其是否为 null所以在调用 size 方法获得列表大小之前,同样可以使用 Optional.ofNullable 包装一下返回值,然后通过.orElse(Collections.emptyList()) 实现在 List 为 null 的时候获得一个空的 List最后再调用 size 方法。
private List<String> rightMethod(FooService fooService, Integer i, String s, String t) {
log.info("result {} {} {} {}", Optional.ofNullable(i).orElse(0) + 1, "OK".equals(s), Objects.equals(s, t), new HashMap<String, String>().put(null, null));
Optional.ofNullable(fooService)
.map(FooService::getBarService)
.filter(barService -> "OK".equals(barService.bar()))
.ifPresent(result -> log.info("OK"));
return new ArrayList<>();
}
@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 层:
可以看到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
然后,使用 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"}%
接口返回的结果和数据库中记录一致:
可以看到,这里存在如下三个问题:
调用方只希望重置用户名,但 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
使用相同的参数调用 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"}%
结果如下:
可以看到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 是自增列自动设置的 1score 是 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类来区分 null 的定位。同时,为避免把空值更新到数据库中,可以实现动态 SQL只更新必要的字段。
最后,我分享了数据库字段使用 NULL 可能会带来的三个坑(包括 sum 函数、count 函数,以及 NULL 值条件),以及解决方式。
总结来讲null 的正确处理以及避免空指针异常,绝不是判空这么简单,还要根据业务属性从前到后仔细考虑,客户端传入的 null 代表了什么,出现了 null 是否允许使用默认值替代,入库的时候应该传入 null 还是空值,并确保整个逻辑处理的一致性,才能尽量避免 Bug。
为处理好 null作为客户端的开发者需要和服务端对齐字段 null 的含义以及降级逻辑;而作为服务端的开发者,需要对入参进行前置判断,提前挡掉服务端不可接受的空值,同时在整个业务逻辑过程中进行完善的空值处理。
今天用到的代码,我都放在了 GitHub 上,你可以点击这个链接查看。
思考与讨论
ConcurrentHashMap 的 Key 和 Value 都不能为 null而 HashMap 却可以你知道这么设计的原因是什么吗TreeMap、Hashtable 等 Map 的 Key 和 Value 是否支持 null 呢?
对于 Hibernate 框架可以使用 @DynamicUpdate 注解实现字段的动态更新,对于 MyBatis 框架如何实现类似的动态 SQL 功能,实现插入和修改 SQL 只包含 POJO 中的非空字段?
关于程序和数据库中的 null、空指针问题你还遇到过什么坑吗我是朱晔欢迎在评论区与我留言分享也欢迎你把这篇文章分享给你的朋友或同事一起交流。

View File

@@ -0,0 +1,556 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
12 异常处理:别让自己在出问题的时候变为瞎子
今天,我来和你聊聊异常处理容易踩的坑。
应用程序避免不了出异常,捕获和处理异常是考验编程功力的一个精细活。一些业务项目中,我曾看到开发同学在开发业务逻辑时不考虑任何异常处理,项目接近完成时再采用“流水线”的方式进行异常处理,也就是统一为所有方法打上 try…catch…捕获所有异常记录日志有些技巧的同学可能会使用 AOP 来进行类似的“统一异常处理”。
其实,这种处理异常的方式非常不可取。那么今天,我就和你分享下不可取的原因、与异常处理相关的坑和最佳实践。
捕获和处理异常容易犯的错
“统一异常处理”方式正是我要说的第一个错:不在业务代码层面考虑异常处理,仅在框架层面粗犷捕获和处理异常。
为了理解错在何处,我们先来看看大多数业务应用都采用的三层架构:
Controller 层负责信息收集、参数校验、转换服务层处理的数据适配前端,轻业务逻辑;
Service 层负责核心业务逻辑,包括各种外部服务调用、访问数据库、缓存处理、消息处理等;
Repository 层负责数据访问实现,一般没有业务逻辑。
每层架构的工作性质不同,且从业务性质上异常可能分为业务异常和系统异常两大类,这就决定了很难进行统一的异常处理。我们从底向上看一下三层架构:
Repository 层出现异常或许可以忽略,或许可以降级,或许需要转化为一个友好的异常。如果一律捕获异常仅记录日志,很可能业务逻辑已经出错,而用户和程序本身完全感知不到。
Service 层往往涉及数据库事务,出现异常同样不适合捕获,否则事务无法自动回滚。此外 Service 层涉及业务逻辑,有些业务逻辑执行中遇到业务异常,可能需要在异常后转入分支业务流程。如果业务异常都被框架捕获了,业务功能就会不正常。
如果下层异常上升到 Controller 层还是无法处理的话Controller 层往往会给予用户友好提示,或是根据每一个 API 的异常表返回指定的异常类型,同样无法对所有异常一视同仁。
因此,我不建议在框架层面进行异常的自动、统一处理,尤其不要随意捕获异常。但,框架可以做兜底工作。如果异常上升到最上层逻辑还是无法处理的话,可以以统一的方式进行异常转换,比如通过 @RestControllerAdvice + @ExceptionHandler,来捕获这些“未处理”异常:
对于自定义的业务异常,以 Warn 级别的日志记录异常以及当前 URL、执行方法等信息后提取异常中的错误码和消息等信息转换为合适的 API 包装体返回给 API 调用方;
对于无法处理的系统异常,以 Error 级别的日志记录异常和上下文信息(比如 URL、参数、用户 ID转换为普适的“服务器忙请稍后再试”异常信息同样以 API 包装体返回给调用方。
比如,下面这段代码的做法:
@RestControllerAdvice
@Slf4j
public class RestControllerExceptionHandler {
private static int GENERIC_SERVER_ERROR_CODE = 2000;
private static String GENERIC_SERVER_ERROR_MESSAGE = "服务器忙,请稍后再试";
@ExceptionHandler
public APIResponse handle(HttpServletRequest req, HandlerMethod method, Exception ex) {
if (ex instanceof BusinessException) {
BusinessException exception = (BusinessException) ex;
log.warn(String.format("访问 %s -> %s 出现业务异常!", req.getRequestURI(), method.toString()), ex);
return new APIResponse(false, null, exception.getCode(), exception.getMessage());
} else {
log.error(String.format("访问 %s -> %s 出现系统异常!", req.getRequestURI(), method.toString()), ex);
return new APIResponse(false, null, GENERIC_SERVER_ERROR_CODE, GENERIC_SERVER_ERROR_MESSAGE);
}
}
}
出现运行时系统异常后,异常处理程序会直接把异常转换为 JSON 返回给调用方:
要做得更好,你可以把相关出入参、用户信息在脱敏后记录到日志中,方便出现问题时根据上下文进一步排查。
第二个错,捕获了异常后直接生吞。在任何时候,我们捕获了异常都不应该生吞,也就是直接丢弃异常不记录、不抛出。这样的处理方式还不如不捕获异常,因为被生吞掉的异常一旦导致 Bug就很难在程序中找到蛛丝马迹使得 Bug 排查工作难上加难。
通常情况下,生吞异常的原因,可能是不希望自己的方法抛出受检异常,只是为了把异常“处理掉”而捕获并生吞异常,也可能是想当然地认为异常并不重要或不可能产生。但不管是什么原因,不管是你认为多么不重要的异常,都不应该生吞,哪怕是一个日志也好。
第三个错,丢弃异常的原始信息。我们来看两个不太合适的异常处理方式,虽然没有完全生吞异常,但也丢失了宝贵的异常信息。
比如有这么一个会抛出受检异常的方法 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 问题)可能永远都无法知道了,对问题定位造成了很大困扰:
第四个错,抛出异常时不指定任何消息。我见过一些代码中的偷懒做法,直接抛出没有 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 讲介绍线程池时我提到,线程池常用作异步处理或并行处理。那么,把任务提交到线程池处理,任务本身出现异常时会怎样呢?
我们来看一个例子:提交 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 步:
以 execute 方法提交到线程池的异步任务,最好在任务内部做好异常处理;
设置自定义的异常处理程序作为保底,比如在声明线程池时自定义线程池的未捕获异常处理程序:
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 上,你可以点击这个链接查看。
思考与讨论
关于在 finally 代码块中抛出异常的坑,如果在 finally 代码块中返回值,你觉得程序会以 try 或 catch 中返回值为准,还是以 finally 中的返回值为准呢?
对于手动抛出的异常,不建议直接使用 Exception 或 RuntimeException通常建议复用 JDK 中的一些标准异常比如IllegalArgumentException、IllegalStateException、UnsupportedOperationException你能说说它们的适用场景并列出更多常用异常吗
不知道针对异常处理,你还遇到过什么坑,还有什么最佳实践的心得吗?我是朱晔,欢迎在评论区与我留言分享,也欢迎你把这篇文章分享给你的朋友或同事,一起交流。

View File

@@ -0,0 +1,657 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
13 日志:日志记录真没你想象的那么简单
今天,我和你分享的是,记录日志可能会踩的坑。
一些同学可能要说了,记录日志还不简单,无非是几个常用的 API 方法,比如 debug、info、warn、error但我就见过不少坑都是记录日志引起的容易出错主要在于三个方面
日志框架众多,不同的类库可能会使用不同的日志框架,如何兼容是一个问题。
配置复杂且容易出错。日志配置文件通常很复杂,因此有些开发同学会从其他项目或者网络上复制一份配置文件,但却不知道如何修改,甚至是胡乱修改,造成很多问题。比如,重复记录日志的问题、同步日志的性能问题、异步记录的错误配置问题。
日志记录本身就有些误区,比如没考虑到日志内容获取的代价、胡乱使用日志级别等。
Logback、Log4j、Log4j2、commons-logging、JDK 自带的 java.util.logging 等,都是 Java 体系的日志框架,确实非常多。而不同的类库,还可能选择使用不同的日志框架。这样一来,日志的统一管理就变得非常困难。为了解决这个问题,就有了 SLF4JSimple Logging Facade For Java如下图所示
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 的桥接:
接下来,我就用几个实际的案例和你说说日志配置和记录这两大问题,顺便以 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 定义了日志的输出格式。关于格式化字符串的各种使用方式,你可以进一步查阅官方文档。
第 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>
这段配置看起来没啥问题,但执行方法后出现了日志重复记录的问题:
从配置文件的第 9 和 12 行可以看到CONSOLE 这个 Appender 同时挂载到了两个 Logger 上,一个是我们定义的 ,一个是 ,由于我们定义的 继承自 ,所以同一条日志既会通过 logger 记录,也会发送到 root 记录,因此应用 package 下的日志出现了重复记录。
后来我了解到,这个同学如此配置的初衷是实现自定义的 logger 配置,让应用内的日志暂时开启 DEBUG 级别的日志记录。其实,他完全不需要重复挂载 Appender去掉 下挂载的 Appender 即可:
<logger name="org.geekbang.time.commonmistakes.logging" level="DEBUG"/>
如果自定义的 需要把日志输出到不同的 Appender比如将应用的日志输出到文件 app.log、把其他框架的日志输出到控制台可以设置 的 additivity 属性为 false这样就不会继承 的 Appender 了:
<?xml version="1.0" encoding="UTF-8" ?>
<configuration>
<appender name="FILE" class="ch.qos.logback.core.FileAppender">
<file>app.log</file>
<encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
<pattern>[%d{yyyy-MM-dd HH:mm:ss.SSS}] [%thread] [%-5level] [%logger{40}:%line] - %msg%n</pattern>
</encoder>
</appender>
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<layout class="ch.qos.logback.classic.PatternLayout">
<pattern>[%d{yyyy-MM-dd HH:mm:ss.SSS}] [%thread] [%-5level] [%logger{40}:%line] - %msg%n</pattern>
</layout>
</appender>
<logger name="org.geekbang.time.commonmistakes.logging" level="DEBUG" additivity="false">
<appender-ref ref="FILE"/>
</logger>
<root level="INFO">
<appender-ref ref="CONSOLE" />
</root>
</configuration>
第二个案例是,错误配置 LevelFilter 造成日志重复记录。
一般互联网公司都会使用 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 文件中。
运行一下测试程序:
可以看到_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 秒:
对于只记录文件日志的代码了来说,这个耗时挺长的。为了分析其中原因,我们需要分析下 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 毫秒:
性能居然这么好,你觉得其中有什么问题吗?异步日志真的如此神奇和万能吗?当然不是,因为这样并没有记录下所有日志。我之前就遇到过很多关于 AsyncAppender 异步日志的坑,这些坑可以归结为三类:
记录异步日志撑爆内存;
记录异步日志出现日志丢失;
记录异步日志出现阻塞。
为了解释这三种坑,我来模拟一个慢日志记录场景:首先,自定义一个继承自 ConsoleAppender 的 MySlowAppender作为记录到控制台的输出器写入日志时休眠 1 秒。
public class MySlowAppender extends ConsoleAppender {
@Override
protected void subAppend(Object event) {
try {
// 模拟慢日志
TimeUnit.MILLISECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
super.subAppend(event);
}
}
然后,在配置文件中使用 AsyncAppender将 MySlowAppender 包装为异步日志记录:
<?xml version="1.0" encoding="UTF-8" ?>
<configuration>
<appender name="CONSOLE" class="org.geekbang.time.commonmistakes.logging.async.MySlowAppender">
<layout class="ch.qos.logback.classic.PatternLayout">
<pattern>[%d{yyyy-MM-dd HH:mm:ss.SSS}] [%thread] [%-5level] [%logger{40}:%line] - %msg%n</pattern>
</layout>
</appender>
<appender name="ASYNC" class="ch.qos.logback.classic.AsyncAppender">
<appender-ref ref="CONSOLE" />
</appender>
<root level="INFO">
<appender-ref ref="ASYNC" />
</root>
</configuration>
定义一段测试代码,循环记录一定次数的日志,最后输出方法执行耗时:
@GetMapping("manylog")
public void manylog(@RequestParam(name = "count", defaultValue = "1000") int count) {
long begin = System.currentTimeMillis();
IntStream.rangeClosed(1, count).forEach(i -> log.info("log-{}", i));
System.out.println("took " + (System.currentTimeMillis() - begin) + " ms");
}
执行方法后发现,耗时很短但出现了日志丢失:我们要记录 1000 条日志,最终控制台只能搜索到 215 条日志,而且日志的行号变为了一个问号。
出现这个问题的原因在于AsyncAppender 提供了一些配置参数,而我们没用对。我们结合相关源码分析一下:
includeCallerData 用于控制是否收集调用方数据,默认是 false此时方法行号、方法名等信息将不能显示源码第 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 级别的日志那么出现大量错误日志时还是会阻塞程序
可以看出 queueSizediscardingThreshold 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 秒:
使用占位符方式记录 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 方法:
其实,我们只是换成了 Log4j2 API真正的日志记录还是走的 Logback 框架。没错,这就是 SLF4J 适配的一个好处。
重点回顾
我将记录日志的坑,总结为框架使用配置和记录本身两个方面。
Java 的日志框架众多SLF4J 实现了这些框架记录日志的统一。在使用 SLF4J 时,我们需要理清楚其桥接 API 和绑定这两个模块。如果程序启动时出现 SLF4J 的错误提示,那很可能是配置出现了问题,可以使用 Maven 的 dependency:tree 命令梳理依赖关系。
Logback 是 Java 最常用的日志框架,其配置比较复杂,你可以参考官方文档中关于 Appender、Layout、Filter 的配置,切记不要随意从其他地方复制别人的配置,避免出现错误或与当前需求不符。
使用异步日志解决性能问题,是用空间换时间。但空间毕竟有限,当空间满了之后,我们要考虑是阻塞等待,还是丢弃日志。如果更希望不丢弃重要日志,那么选择阻塞等待;如果更希望程序不要因为日志记录而阻塞,那么就需要丢弃日志。
最后,我强调的是,日志框架提供的参数化日志记录方式不能完全取代日志级别的判断。如果你的日志量很大,获取日志参数代价也很大,就要进行相应日志级别的判断,避免不记录日志也要花费时间获取日志参数的问题。
今天用到的代码,我都放在了 GitHub 上,你可以点击这个链接查看。
思考与讨论
在第一小节的案例中,我们把 INFO 级别的日志存放到 _info.log 中,把 WARN 和 ERROR 级别的日志存放到 _error.log 中。如果现在要把 INFO 和 WARN 级别的日志存放到 _info.log 中,把 ERROR 日志存放到 _error.log 中,应该如何配置 Logback 呢?
生产级项目的文件日志肯定需要按时间和日期进行分割和归档处理,以避免单个文件太大,同时保留一定天数的历史日志,你知道如何配置吗?可以在官方文档找到答案。
针对日志记录和配置,你还遇到过其他坑吗?我是朱晔,欢迎在评论区与我留言分享,也欢迎你把这篇文章分享给你的朋友或同事,一起交流。

View File

@@ -0,0 +1,425 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
14 文件IO实现高效正确的文件读写并非易事
今天,我们来聊聊如何实现高效、正确的文件操作。
随着数据库系统的成熟和普及,需要直接做文件 IO 操作的需求越来越少,这就导致我们对相关 API 不够熟悉,以至于遇到类似文件导出、三方文件对账等需求时,只能临时抱佛脚,随意搜索一些代码完成需求,出现性能问题或者 Bug 后不知从何处入手。
今天这篇文章,我就会从字符编码、缓冲区和文件句柄释放这 3 个常见问题出发,和你分享如何解决与文件操作相关的性能问题或者 Bug。如果你对文件操作相关的 API 不够熟悉可以查看Oracle 官网的介绍。
文件读写需要确保字符编码一致
有一个项目需要读取三方的对账文件定时对账,原先一直是单机处理的,没什么问题。后来为了提升性能,使用双节点同时处理对账,每一个节点处理部分对账数据,但新增的节点在处理文件中中文的时候总是读取到乱码。
程序代码都是一致的,为什么老节点就不会有问题呢?我们知道,这很可能是写代码时没有注意编码问题导致的。接下来,我们就分析下这个问题吧。
为模拟这个场景,我们使用 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 文档可以发现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 中返回,如果内存无法容纳这个 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 不同lines 方法返回的是 Stream。这使得我们在需要时可以不断读取、使用文件中的内容而不是一次性地把所有内容都读取到内存中因此避免了 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());
输出结果如下:
可以看到,实现了全文件的读取、统计了整个文件的行数,并没有出现 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 文档中有提到,注意使用 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);
}
你可以通过这篇文章,了解 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 上,你可以点击这个链接查看。
思考与讨论
Files.lines 方法进行流式处理,需要使用 try-with-resources 进行资源释放。那么,使用 Files 类中其他返回 Stream 包装对象的方法进行流式处理,比如 newDirectoryStream 方法返回 DirectoryStreamlist、walk 和 find 方法返回 Stream也同样有资源释放问题吗
Java 的 File 类和 Files 类提供的文件复制、重命名、删除等操作,是原子性的吗?
对于文件操作,你还遇到过什么坑吗?我是朱晔,欢迎在评论区与我留言分享你的想法,也欢迎你把这篇文章分享给你的朋友或同事,一起交流。

View File

@@ -0,0 +1,749 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
15 序列化:一来一回你还是原来的你吗?
今天,我来和你聊聊序列化相关的坑和最佳实践。
序列化是把对象转换为字节流的过程,以方便传输或存储。反序列化,则是反过来把字节流转换为对象的过程。在介绍文件 IO的时候我提到字符编码是把字符转换为二进制的过程至于怎么转换需要由字符集制定规则。同样地对象的序列化和反序列化也需要由序列化算法制定规则。
关于序列化算法,几年前常用的有 JDKJava序列化、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 无法查到数据:
查看 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 的 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 对象:
但值得注意的是这里有一个坑。第一行的日志输出显示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 种 RedisSerializerRedis 序列化器):
默认情况下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、DeserializationFeature和MapperFeature。
忽略多余字段是我们写业务代码时最容易遇到的一个配置项。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 字段来序列化,传入“已送到”作为入参:
会得到异常,提示“已送到”不是正确的枚举值:
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序列化也是一样
你可能也知道,要让枚举的序列化和反序列化走 desc 字段,可以在字段上加 @JsonValue 注解,修改 StatusEnumServer 和 StatusEnumClient
@JsonValue
private final String desc;
然后再尝试下,果然可以用 desc 作为入参了,而且出参也使用了枚举的 desc
但是,如果你认为这样就完美解决问题了,那就大错特错了。你可以再尝试把 @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应该会在 2.11 解决。
如下图所示,我们调用服务端接口,传入一个不存在的 status 值 0也能反序列化成功最后服务端的返回是 1
有一个解决办法是,设置 @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 上,你可以点击这个链接查看。
思考与讨论
在讨论 Redis 序列化方式的时候,我们自定义了 RedisTemplate让 Key 使用 String 序列化、让 Value 使用 JSON 序列化,从而使 Redis 获得的 Value 可以直接转换为需要的对象类型。那么,使用 RedisTemplate 能否存取 Value 是 Long 的数据呢?这其中有什么坑吗?
你可以看一下 Jackson2ObjectMapperBuilder 类源码的实现(注意 configure 方法),分析一下其除了关闭 FAIL_ON_UNKNOWN_PROPERTIES 外,还做了什么吗?
关于序列化和反序列化,你还遇到过什么坑吗?我是朱晔,欢迎在评论区与我留言分享,也欢迎你把这篇文章分享给你的朋友或同事,一起交流。

View File

@@ -0,0 +1,526 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
16 用好Java 8的日期时间类少踩一些“老三样”的坑
今天,我来和你说说恼人的时间错乱问题。
在 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 的文档中有说明:小写 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
SimpleDateFormat 的作用是定义解析和格式化日期时间的模式看起来这是一次性的工作应该复用但它的解析和格式化操作是非线程安全的我们来分析一下相关源码
SimpleDateFormat 继承了 DateFormatDateFormat 有一个字段 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 前后的日期时间类型,汇总到了一张思维导图上,图中箭头代表的是新老类型在概念上等价的类型:
这里有个误区是,认为 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 上,你可以点击这个链接查看。
思考与讨论
我今天多次强调 Date 是一个时间戳,是 UTC 时间、没有时区概念,为什么调用其 toString 方法会输出类似 CST 之类的时区字样呢?
日期时间数据始终要保存到数据库中MySQL 中有两种数据类型 datetime 和 timestamp 可以用来保存日期时间。你能说说它们的区别吗,它们是否包含时区信息呢?
对于日期和时间,你还遇到过什么坑吗?我是朱晔,欢迎在评论区与我留言分享你的想法,也欢迎你把今天的内容分享给你的朋友或同事,一起交流。

View File

@@ -0,0 +1,439 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
17 别以为“自动挡”就不可能出现OOM
今天,我要和你分享的主题是,别以为“自动挡”就不可能出现 OOM。
这里的“自动挡”,是我对 Java 自动垃圾收集器的戏称。的确经过这么多年的发展Java 的垃圾收集器已经非常成熟了。有了自动垃圾收集器,绝大多数情况下我们写程序时可以专注于业务逻辑,无需过多考虑对象的分配和释放,一般也不会出现 OOM。
内存空间始终是有限的Java 的几大内存区域始终都有 OOM 的可能。相应地Java 程序的常见 OOM 类型,可以分为堆内存的 OOM、栈 OOM、元空间 OOM、直接内存 OOM 等。几乎每一种 OOM 都可以使用几行代码模拟,市面上也有很多资料在堆、元空间、直接内存中分配超大对象或是无限分配对象,尝试创建无限个线程或是进行方法无限递归调用来模拟。
但值得注意的是,我们的业务代码并不会这么干。所以今天,我会从内存分配意识的角度通过一些案例,展示业务代码中可能导致 OOM 的一些坑。这些坑,或是因为我们意识不到对象的分配,或是因为不合理的资源使用,或是没有控制缓存的数据量等。
在第 3 讲介绍线程时,我们已经看到了两种 OOM 的情况,一是因为使用无界队列导致的堆 OOM二是因为使用没有最大线程数量限制的线程池导致无限创建线程的 OOM。接下来我们再一起看看在写业务代码的过程中还有哪些意识上的疏忽可能会导致 OOM。
太多份相同的对象导致 OOM
我要分享的第一个案例是这样的。有一个项目在内存中缓存了全量用户数据,在搜索用户时可以直接从缓存中返回用户信息。现在为了改善用户体验,需要实现输入部分用户名自动在下拉框提示补全用户名的功能(也就是所谓的自动完成功能)。
在第 10 讲介绍集合时,我提到对于这种快速检索的需求,最好使用 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一共有 1 万个用户 *6=6 万个 UserDTO 对象。
使用内存分析工具 MAT 打开堆 dump 发现6 万个 UserDTO 占用了约 1.2GB 的内存:
看到这里发现,虽然真正的用户只有 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。这才是我们真正想要的结果。
修复后的程序,不仅相同的 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
如下是 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 对象继承了 WeakReferenceEntry 的构造函数调用了 super (key,queue)这是父类的构造函数。其中key 是我们执行 put 方法时的 keyqueue 是一个 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类可以使用弱引用、软引用做缓存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
通过查看引用可以发现,大量引用都是 Tomcat 的工作线程。大部分工作线程都分配了两个 10M 左右的数组100 个左右工作线程吃满了内存。第一个红框是 Http11InputBuffer其 buffer 大小是 10008192 字节;而第二个红框的 Http11OutputBuffer 的 buffer正好占用 10000000 字节:
我们先来看看第一个 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 文档中有提到这个 Socket 的读缓冲也就是 readBuffer 默认是 8192 字节显然问题出在了 headerBufferSize
向上追溯初始化 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 你可以点击这个链接查看
思考与讨论
Spring ConcurrentReferenceHashMap针对 Key Value 支持软引用和弱引用两种方式你觉得哪种方式更适合做缓存呢
当我们需要动态执行一些表达式时可以使用 Groovy 动态语言实现new 出一个 GroovyShell 然后调用 evaluate 方法动态执行脚本这种方式的问题是会重复产生大量的类增加 Metaspace 区的 GC 负担有可能会引起 OOM你知道如何避免这个问题吗
针对 OOM 或内存泄露你还遇到过什么案例吗我是朱晔欢迎在评论区与我留言分享也欢迎你把今天的内容分享给你的朋友或同事一起交流

View File

@@ -0,0 +1,420 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
18 当反射、注解和泛型遇到OOP时会有哪些坑
今天,我们聊聊 Java 高级特性的话题,看看反射、注解和泛型遇到重载和继承时可能会产生的坑。
你可能说,业务项目中几乎都是增删改查,用到反射、注解和泛型这些高级特性的机会少之又少,没啥好学的。但我要说的是,只有学好、用好这些高级特性,才能开发出更简洁易读的代码,而且几乎所有的框架都使用了这三大高级特性。比如,要减少重复代码,就得用到反射和注解(详见第 21 讲)。
如果你从来没用过反射、注解和泛型,可以先通过官网有一个大概了解:
Java Reflection API & Reflection Tutorials
Annotations & Lesson: Annotations
Generics & Lesson: Generics。
接下来,我们就通过几个案例,看看这三大特性结合 OOP 使用时会有哪些坑吧。
反射调用方法不是以传参决定重载
反射的功能包括,在运行时动态获取类和类成员定义,以及动态读取属性调用方法。也就是说,针对类动态调用方法,不管类中字段和方法怎么变动,我们都可以用相同的规则来读取信息和执行方法。因此,几乎所有的 ORM对象关系映射、对象映射、MVC 框架都使用了反射。
反射的起点是 Class 类Class 类提供了各种方法帮我们查询它的信息。你可以通过这个文档,了解每一个方法的作用。
接下来,我们先看一个反射调用方法遇到重载的坑:有两个叫 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。
如果不通过反射来调用方法,我们确实很难发现这个问题。其实,这就是泛型类型擦除导致的问题。我们来分析一下。
我们知道Java 的泛型类型在编译后擦除为 Object。虽然子类指定了父类泛型 T 类型是 String但编译后 T 会被擦除成为 Object所以父类 setValue 方法的入参是 Objectvalue 也是 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 代表这是泛型类型擦除后生成的桥接代码:
知道这个问题之后,修改方式就明朗了,可以使用 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 的文档就会发现,@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 和对象,在实现上都有一些细节差异,详情请查看官方文档。今天提到的 getDeclaredMethods 方法无法获得父类定义的方法,而 getMethods 方法可以,只是差异之一,不能适用于所有的 XXX。
第三,泛型因为类型擦除会导致泛型方法 T 占位符被替换为 Object子类如果使用具体类型覆盖父类实现编译器会生成桥接方法。这样既满足子类方法重写父类方法的定义又满足子类实现的方法有具体的类型。使用反射来获取方法清单时你需要特别注意这一点。
第四,自定义注解可以通过标记元注解 @Inherited 实现注解的继承,不过这只适用于类。如果要继承定义在接口或方法上的注解,可以使用 Spring 的工具类 AnnotatedElementUtils并注意各种 getXXX 方法和 findXXX 方法的区别详情查看Spring 的文档。
最后,我要说的是。编译后的代码和原始代码并不完全一致,编译器可能会做一些优化,加上还有诸如 AspectJ 等编译时增强框架,使用反射动态获取类型的元数据可能会和我们编写的源码有差异,这点需要特别注意。你可以在反射中多写断言,遇到非预期的情况直接抛异常,避免通过反射实现的业务逻辑不符合预期。
今天用到的代码,我都放在了 GitHub 上,你可以点击这个链接查看。
思考与讨论
泛型类型擦除后会生成一个 bridge 方法,这个方法同时又是 synthetic 方法。除了泛型类型擦除,你知道还有什么情况编译器会生成 synthetic 方法吗?
关于注解继承问题,你觉得 Spring 的常用注解 @Service@Controller 是否支持继承呢?
你还遇到过与 Java 高级特性相关的其他坑吗?我是朱晔,欢迎在评论区与我留言分享你的想法,也欢迎你把今天的内容分享给你的朋友或同事,一起交流。

View File

@@ -0,0 +1,547 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
19 Spring框架IoC和AOP是扩展的核心
今天,我们来聊聊 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 技术看作为蛋糕做奶油夹层的工序。如果我们希望找到一个合适的地方把奶油注入蛋糕胚子中,那应该如何指导工人完成操作呢?
首先我们要提醒他只能往蛋糕胚子里面加奶油而不能上面或下面加奶油。这就是连接点Join point对于 Spring AOP 来说,连接点就是方法执行。
然后,我们要告诉他,在什么点切开蛋糕加奶油。比如,可以在蛋糕坯子中间加入一层奶油,在中间切一次;也可以在中间加两层奶油,在 13 和 23 的地方切两次。这就是切点PointcutSpring 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获取到 SayHello 和 SayBye而没想过类的生命周期
@Autowired
List<SayService> sayServiceList;
@GetMapping("test")
public void test() {
log.info("====================");
sayServiceList.forEach(SayService::say);
}
这一个点非常容易忽略。开发基类的架构师将基类设计为有状态的,但并不知道子类是怎么使用基类的;而开发子类的同学,没多想就直接标记了 @Service,让类成为了 Bean通过 @Autowired 注解来注入这个服务。但这样设置后,有状态的基类就可能产生内存泄露或线程安全问题。
正确的方式是,在为类标记上 @Service 注解把类型交由容器管理前,首先评估一下类是否有状态,然后为 Bean 设置合适的 Scope。好在上线前架构师发现了这个内存泄露问题开发同学也做了修改为 SayHello 和 SayBye 两个类都标记了 @Scope 注解,设置了 PROTOTYPE 的生命周期,也就是多例:
@Scope(value = ConfigurableBeanFactory.SCOPE_PROTOTYPE)
但,上线后还是出现了内存泄漏,证明修改是无效的。
从日志可以看到第一次调用和第二次调用的时候SayBye 对象都是 4c0bfe9eSayHello 也是一样的问题。从日志第 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 生成的代理类:
当然,如果不希望走代理的话还有一种方式是,每次直接从 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 讲中提到的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】
在介绍数据库事务时,我们分析了 Spring 通过 TransactionAspectSupport 类实现事务。在 invokeWithinTransaction 方法中设置断点可以发现,在执行 Service 的 createUser 方法时TransactionAspectSupport 并没有捕获到异常,所以自然无法回滚事务。原因就是,异常被 MetricsAspect 吃掉了。
我们知道,切面本身是一个 BeanSpring 对不同切面增强的执行顺序是由 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 的方法后,通过日志输出可以看到,增强执行顺序符合切面执行顺序的三个规则:
因为 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 上,你可以点击这个链接查看。
思考与讨论
除了通过 @Autowired 注入 Bean 外,还可以使用 @Inject@Resource 来注入 Bean。你知道这三种方式的区别是什么吗
当 Bean 产生循环依赖时,比如 BeanA 的构造方法依赖 BeanB 作为成员需要注入BeanB 也依赖 BeanA你觉得会出现什么问题呢又有哪些解决方式呢
在下一讲中,我会继续与你探讨 Spring 核心的其他问题。我是朱晔,欢迎在评论区与我留言分享你的想法,也欢迎你把今天的内容分享给你的朋友或同事,一起交流。

View File

@@ -0,0 +1,639 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
20 Spring框架框架帮我们做了很多工作也带来了复杂度
今天,我们聊聊 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
所以,没有指定 URL 的 @FeignClient 对应的 LoadBalanceFeignClient是可以通过 feign.Client 切入的。
在我们上面贴出来的源码的 16 行可以看到,当 URL 不为空的时候client 设置为了 LoadBalanceFeignClient 的 delegate 属性。其原因注释中有提到,因为有了 URL 就不需要客户端负载均衡了,但因为 Ribbon 在 classpath 中,所以需要从 LoadBalanceFeignClient 提取出真正的 Client。断点调试下可以看到这时 client 是一个 ApacheHttpClient
那么,这个 ApacheHttpClient 是从哪里来的呢?这里,我教你一个小技巧:如果你希望知道一个类是怎样调用栈初始化的,可以在构造方法中设置一个断点进行调试。这样,你就可以在 IDE 的栈窗口看到整个方法调用栈,然后点击每一个栈帧看到整个过程。
用这种方式,我们可以看到,是 HttpClientFeignLoadBalancedConfiguration 类实例化的 ApacheHttpClient
进一步查看 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
到这里坑还没完,为了方便用户登录,需要在页面上显示默认的管理员用户名,于是开发同学在配置文件中定义了一个 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 和配置文件也有关系,每个环境都有独立的配置文件,但我们只会激活某一个环境来生效特定环境的配置文件。
接下来,我们重点看看 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 个问题,我们需要分析下源码。我先说明下,下面源码分析的逻辑有些复杂,你可以结合着下面的整体流程图来理解:
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
继续查看 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 其实类似代理的角色,实际配置是从系统属性中获得的:
继续查看 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 的地方:
可以看到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 各种预定义的配置源的优先级,可以参考官方文档。
重点回顾
今天,我用两个业务开发中的实际案例,带你进一步学习了 Spring 的 AOP 和配置优先级这两大知识点。现在,你应该也感受到 Spring 实现的复杂度了。
对于 AOP 切 Feign 的案例我们在实现功能时走了一些弯路。Spring Cloud 会使用 Spring Boot 的特性,根据当前引入包的情况做各种自动装配。如果我们要扩展 Spring 的组件,那么只有清晰了解 Spring 自动装配的运作方式,才能鉴别运行时对象在 Spring 容器中的情况,不能想当然认为代码中能看到的所有 Spring 的类都是 Bean。
对于配置优先级的案例,分析配置源优先级时,如果我们以为看到 PropertySourcesPropertyResolver 就看到了真相,后续进行扩展开发时就可能会踩坑。我们一定要注意,分析 Spring 源码时,你看到的表象不一定是实际运行时的情况,还需要借助日志或调试工具来理清整个过程。如果没有调试工具,你可以借助第 11 讲用到的 Arthas来分析代码调用路径。
今天用到的代码,我都放在了 GitHub 上,你可以点击这个链接查看。
思考与讨论
除了我们这两讲用到 execution、within、@within@annotation 四个指示器外Spring AOP 还支持 this、target、args、@target@args。你能说说后面五种指示器的作用吗?
Spring 的 Environment 中的 PropertySources 属性可以包含多个 PropertySource越往前优先级越高。那我们能否利用这个特点实现配置文件中属性值的自动赋值呢比如我们可以定义 %%MYSQL.URL%%、%%MYSQL.USERNAME%% 和 %%MYSQL.PASSWORD%%,分别代表数据库连接字符串、用户名和密码。在配置数据源时,我们只要设置其值为占位符,框架就可以自动根据当前应用程序名 application.name统一把占位符替换为真实的数据库信息。这样生产的数据库信息就不需要放在配置文件中了会更安全。
关于 Spring Core、Spring Boot 和 Spring Cloud你还遇到过其他坑吗我是朱晔欢迎在评论区与我留言分享你的想法也欢迎你把今天的内容分享给你的朋友或同事一起交流。

View File

@@ -0,0 +1,729 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
21 代码重复:搞定代码重复的三个绝招
今天,我来和你聊聊搞定代码重复的三个绝招。
业务同学抱怨业务开发没有技术含量用不到设计模式、Java 高级特性、OOP平时写代码都在堆 CRUD个人成长无从谈起。每次面试官问到“请说说平时常用的设计模式”都只能答单例模式因为其他设计模式的确是听过但没用过对于反射、注解之类的高级特性也只是知道它们在写框架的时候非常常用但自己又不写框架代码没有用武之地。
其实我认为不是这样的。设计模式、OOP 是前辈们在大型项目中积累下来的经验,通过这些方法论来改善大型项目的可维护性。反射、注解、泛型等高级特性在框架中大量使用的原因是,框架往往需要以同一套算法来应对不同的数据结构,而这些特性可以帮助减少重复代码,提升项目可维护性。
在我看来,可维护性是大型项目成熟度的一个重要指标,而提升可维护性非常重要的一个手段就是减少代码重复。那为什么这样说呢?
如果多处重复代码实现完全相同的功能,很容易修改一处忘记修改另一处,造成 Bug
有一些代码并不是完全重复,而是相似度很高,修改这些类似的代码容易改(复制粘贴)错,把原本有区别的地方改为了一样。
今天,我就从业务代码中最常见的三个需求展开,和你聊聊如何使用 Java 中的一些高级特性、设计模式,以及一些工具消除重复代码,才能既优雅又高端。通过今天的学习,也希望改变你对业务代码没有技术含量的看法。
利用工厂模式 + 模板方法模式,消除 if…else 和重复代码
假设要开发一个购物车下单的功能,针对不同用户进行不同处理:
普通用户需要收取运费,运费是商品价格的 10%,无商品折扣;
VIP 用户同样需要收取商品价格 10% 的快递费,但购买两件以上相同商品时,第三件开始享受一定折扣;
内部用户可以免运费,无商品折扣。
我们的目标是实现三种类型的购物车业务逻辑,把入参 Map 对象Key 是商品 IDValue 是商品数量),转换为出参购物车类型 Cart。
先实现针对普通用户的购物车处理逻辑:
//购物车
@Data
public class Cart {
//商品清单
private List<Item> items = new ArrayList<>();
//总优惠
private BigDecimal totalDiscount;
//商品总价
private BigDecimal totalItemPrice;
//总运费
private BigDecimal totalDeliveryPrice;
//应付总价
private BigDecimal payPrice;
}
//购物车中的商品
@Data
public class Item {
//商品ID
private long id;
//商品数量
private int quantity;
//商品单价
private BigDecimal price;
//商品优惠
private BigDecimal couponPrice;
//商品运费
private BigDecimal deliveryPrice;
}
//普通用户购物车处理
public class NormalUserCart {
public Cart process(long userId, Map<Long, Integer> items) {
Cart cart = new Cart();
//把Map的购物车转换为Item列表
List<Item> itemList = new ArrayList<>();
items.entrySet().stream().forEach(entry -> {
Item item = new Item();
item.setId(entry.getKey());
item.setPrice(Db.getItemPrice(entry.getKey()));
item.setQuantity(entry.getValue());
itemList.add(item);
});
cart.setItems(itemList);
//处理运费和商品优惠
itemList.stream().forEach(item -> {
//运费为商品总价的10%
item.setDeliveryPrice(item.getPrice().multiply(BigDecimal.valueOf(item.getQuantity())).multiply(new BigDecimal("0.1")));
//无优惠
item.setCouponPrice(BigDecimal.ZERO);
});
//计算商品总价
cart.setTotalItemPrice(cart.getItems().stream().map(item -> item.getPrice().multiply(BigDecimal.valueOf(item.getQuantity()))).reduce(BigDecimal.ZERO, BigDecimal::add));
//计算运费总价
cart.setTotalDeliveryPrice(cart.getItems().stream().map(Item::getDeliveryPrice).reduce(BigDecimal.ZERO, BigDecimal::add));
//计算总优惠
cart.setTotalDiscount(cart.getItems().stream().map(Item::getCouponPrice).reduce(BigDecimal.ZERO, BigDecimal::add));
//应付总价=商品总价+运费总价-总优惠
cart.setPayPrice(cart.getTotalItemPrice().add(cart.getTotalDeliveryPrice()).subtract(cart.getTotalDiscount()));
return cart;
}
}
然后实现针对 VIP 用户的购物车逻辑。与普通用户购物车逻辑的不同在于VIP 用户能享受同类商品多买的折扣。所以,这部分代码只需要额外处理多买折扣部分:
public class VipUserCart {
public Cart process(long userId, Map<Long, Integer> items) {
...
itemList.stream().forEach(item -> {
//运费为商品总价的10%
item.setDeliveryPrice(item.getPrice().multiply(BigDecimal.valueOf(item.getQuantity())).multiply(new BigDecimal("0.1")));
//购买两件以上相同商品,第三件开始享受一定折扣
if (item.getQuantity() > 2) {
item.setCouponPrice(item.getPrice()
.multiply(BigDecimal.valueOf(100 - Db.getUserCouponPercent(userId)).divide(new BigDecimal("100")))
.multiply(BigDecimal.valueOf(item.getQuantity() - 2)));
} else {
item.setCouponPrice(BigDecimal.ZERO);
}
});
...
return cart;
}
}
最后是免运费、无折扣的内部用户,同样只是处理商品折扣和运费时的逻辑差异:
public class InternalUserCart {
public Cart process(long userId, Map<Long, Integer> items) {
...
itemList.stream().forEach(item -> {
//免运费
item.setDeliveryPrice(BigDecimal.ZERO);
//无优惠
item.setCouponPrice(BigDecimal.ZERO);
});
...
return cart;
}
}
对比一下代码量可以发现,三种购物车 70% 的代码是重复的。原因很简单,虽然不同类型用户计算运费和优惠的方式不同,但整个购物车的初始化、统计总价、总运费、总优惠和支付价格的逻辑都是一样的。
正如我们开始时提到的,代码重复本身不可怕,可怕的是漏改或改错。比如,写 VIP 用户购物车的同学发现商品总价计算有 Bug不应该是把所有 Item 的 price 加在一起,而是应该把所有 Item 的 price*quantity 加在一起。这时,他可能会只修改 VIP 用户购物车的代码,而忽略了普通用户、内部用户的购物车中,重复的逻辑实现也有相同的 Bug。
有了三个购物车后,我们就需要根据不同的用户类型使用不同的购物车了。如下代码所示,使用三个 if 实现不同类型用户调用不同购物车的 process 方法:
@GetMapping("wrong")
public Cart wrong(@RequestParam("userId") int userId) {
//根据用户ID获得用户类型
String userCategory = Db.getUserCategory(userId);
//普通用户处理逻辑
if (userCategory.equals("Normal")) {
NormalUserCart normalUserCart = new NormalUserCart();
return normalUserCart.process(userId, items);
}
//VIP用户处理逻辑
if (userCategory.equals("Vip")) {
VipUserCart vipUserCart = new VipUserCart();
return vipUserCart.process(userId, items);
}
//内部用户处理逻辑
if (userCategory.equals("Internal")) {
InternalUserCart internalUserCart = new InternalUserCart();
return internalUserCart.process(userId, items);
}
return null;
}
电商的营销玩法是多样的,以后势必还会有更多用户类型,需要更多的购物车。我们就只能不断增加更多的购物车类,一遍一遍地写重复的购物车逻辑、写更多的 if 逻辑吗?
当然不是,相同的代码应该只在一处出现!
如果我们熟记抽象类和抽象方法的定义的话,这时或许就会想到,是否可以把重复的逻辑定义在抽象类中,三个购物车只要分别实现不同的那份逻辑呢?
其实,这个模式就是模板方法模式。我们在父类中实现了购物车处理的流程模板,然后把需要特殊处理的地方留空白也就是留抽象方法定义,让子类去实现其中的逻辑。由于父类的逻辑不完整无法单独工作,因此需要定义为抽象类。
如下代码所示AbstractCart 抽象类实现了购物车通用的逻辑额外定义了两个抽象方法让子类去实现。其中processCouponPrice 方法用于计算商品折扣processDeliveryPrice 方法用于计算运费。
public abstract class AbstractCart {
//处理购物车的大量重复逻辑在父类实现
public Cart process(long userId, Map<Long, Integer> items) {
Cart cart = new Cart();
List<Item> itemList = new ArrayList<>();
items.entrySet().stream().forEach(entry -> {
Item item = new Item();
item.setId(entry.getKey());
item.setPrice(Db.getItemPrice(entry.getKey()));
item.setQuantity(entry.getValue());
itemList.add(item);
});
cart.setItems(itemList);
//让子类处理每一个商品的优惠
itemList.stream().forEach(item -> {
processCouponPrice(userId, item);
processDeliveryPrice(userId, item);
});
//计算商品总价
cart.setTotalItemPrice(cart.getItems().stream().map(item -> item.getPrice().multiply(BigDecimal.valueOf(item.getQuantity()))).reduce(BigDecimal.ZERO, BigDecimal::add));
//计算总运费
cart.setTotalDeliveryPrice(cart.getItems().stream().map(Item::getDeliveryPrice).reduce(BigDecimal.ZERO, BigDecimal::add));
//计算总折扣
cart.setTotalDiscount(cart.getItems().stream().map(Item::getCouponPrice).reduce(BigDecimal.ZERO, BigDecimal::add));
//计算应付价格
cart.setPayPrice(cart.getTotalItemPrice().add(cart.getTotalDeliveryPrice()).subtract(cart.getTotalDiscount()));
return cart;
}
//处理商品优惠的逻辑留给子类实现
protected abstract void processCouponPrice(long userId, Item item);
//处理配送费的逻辑留给子类实现
protected abstract void processDeliveryPrice(long userId, Item item);
}
有了这个抽象类,三个子类的实现就非常简单了。普通用户的购物车 NormalUserCart实现的是 0 优惠和 10% 运费的逻辑:
@Service(value = "NormalUserCart")
public class NormalUserCart extends AbstractCart {
@Override
protected void processCouponPrice(long userId, Item item) {
item.setCouponPrice(BigDecimal.ZERO);
}
@Override
protected void processDeliveryPrice(long userId, Item item) {
item.setDeliveryPrice(item.getPrice()
.multiply(BigDecimal.valueOf(item.getQuantity()))
.multiply(new BigDecimal("0.1")));
}
}
VIP 用户的购物车 VipUserCart直接继承了 NormalUserCart只需要修改多买优惠策略
@Service(value = "VipUserCart")
public class VipUserCart extends NormalUserCart {
@Override
protected void processCouponPrice(long userId, Item item) {
if (item.getQuantity() > 2) {
item.setCouponPrice(item.getPrice()
.multiply(BigDecimal.valueOf(100 - Db.getUserCouponPercent(userId)).divide(new BigDecimal("100")))
.multiply(BigDecimal.valueOf(item.getQuantity() - 2)));
} else {
item.setCouponPrice(BigDecimal.ZERO);
}
}
}
内部用户购物车 InternalUserCart 是最简单的,直接设置 0 运费和 0 折扣即可:
@Service(value = "InternalUserCart")
public class InternalUserCart extends AbstractCart {
@Override
protected void processCouponPrice(long userId, Item item) {
item.setCouponPrice(BigDecimal.ZERO);
}
@Override
protected void processDeliveryPrice(long userId, Item item) {
item.setDeliveryPrice(BigDecimal.ZERO);
}
}
抽象类和三个子类的实现关系图,如下所示:
是不是比三个独立的购物车程序简单了很多呢?接下来,我们再看看如何能避免三个 if 逻辑。
或许你已经注意到了,定义三个购物车子类时,我们在 @Service 注解中对 Bean 进行了命名。既然三个购物车都叫 XXXUserCart那我们就可以把用户类型字符串拼接 UserCart 构成购物车 Bean 的名称,然后利用 Spring 的 IoC 容器,通过 Bean 的名称直接获取到 AbstractCart调用其 process 方法即可实现通用。
其实,这就是工厂模式,只不过是借助 Spring 容器实现罢了:
@GetMapping("right")
public Cart right(@RequestParam("userId") int userId) {
String userCategory = Db.getUserCategory(userId);
AbstractCart cart = (AbstractCart) applicationContext.getBean(userCategory + "UserCart");
return cart.process(userId, items);
}
试想, 之后如果有了新的用户类型、新的用户逻辑,是不是完全不用对代码做任何修改,只要新增一个 XXXUserCart 类继承 AbstractCart实现特殊的优惠和运费处理逻辑就可以了
这样一来,我们就利用工厂模式 + 模板方法模式,不仅消除了重复代码,还避免了修改既有代码的风险。这就是设计模式中的开闭原则:对修改关闭,对扩展开放。
利用注解 + 反射消除重复代码
是不是有点兴奋了,业务代码居然也能 OOP 了。我们再看一个三方接口的调用案例,同样也是一个普通的业务逻辑。
假设银行提供了一些 API 接口,对参数的序列化有点特殊,不使用 JSON而是需要我们把参数依次拼在一起构成一个大字符串。
按照银行提供的 API 文档的顺序,把所有参数构成定长的数据,然后拼接在一起作为整个字符串。
因为每一种参数都有固定长度,未达到长度时需要做填充处理:
字符串类型的参数不满长度部分需要以下划线右填充,也就是字符串内容靠左;
数字类型的参数不满长度部分以 0 左填充,也就是实际数字靠右;
货币类型的表示需要把金额向下舍入 2 位到分,以分为单位,作为数字类型同样进行左填充。
对所有参数做 MD5 操作作为签名为了方便理解Demo 中不涉及加盐处理)。
比如,创建用户方法和支付方法的定义是这样的:
代码很容易实现,直接根据接口定义实现填充操作、加签名、请求调用操作即可:
public class BankService {
//创建用户方法
public static String createUser(String name, String identity, String mobile, int age) throws IOException {
StringBuilder stringBuilder = new StringBuilder();
//字符串靠左多余的地方填充_
stringBuilder.append(String.format("%-10s", name).replace(' ', '_'));
//字符串靠左多余的地方填充_
stringBuilder.append(String.format("%-18s", identity).replace(' ', '_'));
//数字靠右多余的地方用0填充
stringBuilder.append(String.format("%05d", age));
//字符串靠左多余的地方用_填充
stringBuilder.append(String.format("%-11s", mobile).replace(' ', '_'));
//最后加上MD5作为签名
stringBuilder.append(DigestUtils.md2Hex(stringBuilder.toString()));
return Request.Post("http://localhost:45678/reflection/bank/createUser")
.bodyString(stringBuilder.toString(), ContentType.APPLICATION_JSON)
.execute().returnContent().asString();
}
//支付方法
public static String pay(long userId, BigDecimal amount) throws IOException {
StringBuilder stringBuilder = new StringBuilder();
//数字靠右多余的地方用0填充
stringBuilder.append(String.format("%020d", userId));
//金额向下舍入2位到分以分为单位作为数字靠右多余的地方用0填充
stringBuilder.append(String.format("%010d", amount.setScale(2, RoundingMode.DOWN).multiply(new BigDecimal("100")).longValue()));
//最后加上MD5作为签名
stringBuilder.append(DigestUtils.md2Hex(stringBuilder.toString()));
return Request.Post("http://localhost:45678/reflection/bank/pay")
.bodyString(stringBuilder.toString(), ContentType.APPLICATION_JSON)
.execute().returnContent().asString();
}
}
可以看到,这段代码的重复粒度更细:
三种标准数据类型的处理逻辑有重复,稍有不慎就会出现 Bug
处理流程中字符串拼接、加签和发请求的逻辑,在所有方法重复;
实际方法的入参的参数类型和顺序,不一定和接口要求一致,容易出错;
代码层面针对每一个参数硬编码,无法清晰地进行核对,如果参数达到几十个、上百个,出错的概率极大。
那应该如何改造这段代码呢?没错,就是要用注解和反射!
使用注解和反射这两个武器,就可以针对银行请求的所有逻辑均使用一套代码实现,不会出现任何重复。
要实现接口逻辑和逻辑实现的剥离,首先需要以 POJO 类(只有属性没有任何业务逻辑的数据类)的方式定义所有的接口参数。比如,下面这个创建用户 API 的参数:
@Data
public class CreateUserAPI {
private String name;
private String identity;
private String mobile;
private int age;
}
有了接口参数定义,我们就能通过自定义注解为接口和所有参数增加一些元数据。如下所示,我们定义一个接口 API 的注解 BankAPI包含接口 URL 地址和接口说明:
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
@Documented
@Inherited
public @interface BankAPI {
String desc() default "";
String url() default "";
}
然后,我们再定义一个自定义注解 @BankAPIField,用于描述接口的每一个字段规范,包含参数的次序、类型和长度三个属性:
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
@Documented
@Inherited
public @interface BankAPIField {
int order() default -1;
int length() default -1;
String type() default "";
}
接下来,注解就可以发挥威力了。
如下所示,我们定义了 CreateUserAPI 类描述创建用户接口的信息,通过为接口增加 @BankAPI 注解,来补充接口的 URL 和描述等元数据;通过为每一个字段增加 @BankAPIField 注解,来补充参数的顺序、类型和长度等元数据:
@BankAPI(url = "/bank/createUser", desc = "创建用户接口")
@Data
public class CreateUserAPI extends AbstractAPI {
@BankAPIField(order = 1, type = "S", length = 10)
private String name;
@BankAPIField(order = 2, type = "S", length = 18)
private String identity;
@BankAPIField(order = 4, type = "S", length = 11) //注意这里的order需要按照API表格中的顺序
private String mobile;
@BankAPIField(order = 3, type = "N", length = 5)
private int age;
}
另一个 PayAPI 类也是类似的实现:
@BankAPI(url = "/bank/pay", desc = "支付接口")
@Data
public class PayAPI extends AbstractAPI {
@BankAPIField(order = 1, type = "N", length = 20)
private long userId;
@BankAPIField(order = 2, type = "M", length = 10)
private BigDecimal amount;
}
这 2 个类继承的 AbstractAPI 类是一个空实现,因为这个案例中的接口并没有公共数据可以抽象放到基类。
通过这 2 个类,我们可以在几秒钟内完成和 API 清单表格的核对。理论上,如果我们的核心翻译过程(也就是把注解和接口 API 序列化为请求需要的字符串的过程没问题只要注解和表格一致API 请求的翻译就不会有任何问题。
以上,我们通过注解实现了对 API 参数的描述。接下来,我们再看看反射如何配合注解实现动态的接口参数组装:
第 3 行代码中,我们从类上获得了 BankAPI 注解,然后拿到其 URL 属性,后续进行远程调用。
第 6~9 行代码,使用 stream 快速实现了获取类中所有带 BankAPIField 注解的字段,并把字段按 order 属性排序,然后设置私有字段反射可访问。
第 12~38 行代码,实现了反射获取注解的值,然后根据 BankAPIField 拿到的参数类型,按照三种标准进行格式化,将所有参数的格式化逻辑集中在了这一处。
第 41~48 行代码,实现了参数加签和请求调用。
private static String remoteCall(AbstractAPI api) throws IOException {
//从BankAPI注解获取请求地址
BankAPI bankAPI = api.getClass().getAnnotation(BankAPI.class);
bankAPI.url();
StringBuilder stringBuilder = new StringBuilder();
Arrays.stream(api.getClass().getDeclaredFields()) //获得所有字段
.filter(field -> field.isAnnotationPresent(BankAPIField.class)) //查找标记了注解的字段
.sorted(Comparator.comparingInt(a -> a.getAnnotation(BankAPIField.class).order())) //根据注解中的order对字段排序
.peek(field -> field.setAccessible(true)) //设置可以访问私有字段
.forEach(field -> {
//获得注解
BankAPIField bankAPIField = field.getAnnotation(BankAPIField.class);
Object value = "";
try {
//反射获取字段值
value = field.get(api);
} catch (IllegalAccessException e) {
e.printStackTrace();
}
//根据字段类型以正确的填充方式格式化字符串
switch (bankAPIField.type()) {
case "S": {
stringBuilder.append(String.format("%-" + bankAPIField.length() + "s", value.toString()).replace(' ', '_'));
break;
}
case "N": {
stringBuilder.append(String.format("%" + bankAPIField.length() + "s", value.toString()).replace(' ', '0'));
break;
}
case "M": {
if (!(value instanceof BigDecimal))
throw new RuntimeException(String.format("{} 的 {} 必须是BigDecimal", api, field));
stringBuilder.append(String.format("%0" + bankAPIField.length() + "d", ((BigDecimal) value).setScale(2, RoundingMode.DOWN).multiply(new BigDecimal("100")).longValue()));
break;
}
default:
break;
}
});
//签名逻辑
stringBuilder.append(DigestUtils.md2Hex(stringBuilder.toString()));
String param = stringBuilder.toString();
long begin = System.currentTimeMillis();
//发请求
String result = Request.Post("http://localhost:45678/reflection" + bankAPI.url())
.bodyString(param, ContentType.APPLICATION_JSON)
.execute().returnContent().asString();
log.info("调用银行API {} url:{} 参数:{} 耗时:{}ms", bankAPI.desc(), bankAPI.url(), param, System.currentTimeMillis() - begin);
return result;
}
可以看到,所有处理参数排序、填充、加签、请求调用的核心逻辑,都汇聚在了 remoteCall 方法中。有了这个核心方法BankService 中每一个接口的实现就非常简单了,只是参数的组装,然后调用 remoteCall 即可。
//创建用户方法
public static String createUser(String name, String identity, String mobile, int age) throws IOException {
CreateUserAPI createUserAPI = new CreateUserAPI();
createUserAPI.setName(name);
createUserAPI.setIdentity(identity);
createUserAPI.setAge(age);
createUserAPI.setMobile(mobile);
return remoteCall(createUserAPI);
}
//支付方法
public static String pay(long userId, BigDecimal amount) throws IOException {
PayAPI payAPI = new PayAPI();
payAPI.setUserId(userId);
payAPI.setAmount(amount);
return remoteCall(payAPI);
}
其实,许多涉及类结构性的通用处理,都可以按照这个模式来减少重复代码。反射给予了我们在不知晓类结构的时候,按照固定的逻辑处理类的成员;而注解给了我们为这些成员补充元数据的能力,使得我们利用反射实现通用逻辑的时候,可以从外部获得更多我们关心的数据。
利用属性拷贝工具消除重复代码
最后,我们再来看一种业务代码中经常出现的代码逻辑,实体之间的转换复制。
对于三层架构的系统,考虑到层之间的解耦隔离以及每一层对数据的不同需求,通常每一层都会有自己的 POJO 作为数据实体。比如,数据访问层的实体一般叫作 DataObject 或 DO业务逻辑层的实体一般叫作 Domain表现层的实体一般叫作 Data Transfer Object 或 DTO。
这里我们需要注意的是,如果手动写这些实体之间的赋值代码,同样容易出错。
对于复杂的业务系统,实体有几十甚至几百个属性也很正常。就比如 ComplicatedOrderDTO 这个数据传输对象,描述的是一个订单中的几十个属性。如果我们要把这个 DTO 转换为一个类似的 DO复制其中大部分的字段然后把数据入库势必需要进行很多属性映射赋值操作。就像这样密密麻麻的代码是不是已经让你头晕了
ComplicatedOrderDTO orderDTO = new ComplicatedOrderDTO();
ComplicatedOrderDO orderDO = new ComplicatedOrderDO();
orderDO.setAcceptDate(orderDTO.getAcceptDate());
orderDO.setAddress(orderDTO.getAddress());
orderDO.setAddressId(orderDTO.getAddressId());
orderDO.setCancelable(orderDTO.isCancelable());
orderDO.setCommentable(orderDTO.isComplainable()); //属性错误
orderDO.setComplainable(orderDTO.isCommentable()); //属性错误
orderDO.setCancelable(orderDTO.isCancelable());
orderDO.setCouponAmount(orderDTO.getCouponAmount());
orderDO.setCouponId(orderDTO.getCouponId());
orderDO.setCreateDate(orderDTO.getCreateDate());
orderDO.setDirectCancelable(orderDTO.isDirectCancelable());
orderDO.setDeliverDate(orderDTO.getDeliverDate());
orderDO.setDeliverGroup(orderDTO.getDeliverGroup());
orderDO.setDeliverGroupOrderStatus(orderDTO.getDeliverGroupOrderStatus());
orderDO.setDeliverMethod(orderDTO.getDeliverMethod());
orderDO.setDeliverPrice(orderDTO.getDeliverPrice());
orderDO.setDeliveryManId(orderDTO.getDeliveryManId());
orderDO.setDeliveryManMobile(orderDO.getDeliveryManMobile()); //对象错误
orderDO.setDeliveryManName(orderDTO.getDeliveryManName());
orderDO.setDistance(orderDTO.getDistance());
orderDO.setExpectDate(orderDTO.getExpectDate());
orderDO.setFirstDeal(orderDTO.isFirstDeal());
orderDO.setHasPaid(orderDTO.isHasPaid());
orderDO.setHeadPic(orderDTO.getHeadPic());
orderDO.setLongitude(orderDTO.getLongitude());
orderDO.setLatitude(orderDTO.getLongitude()); //属性赋值错误
orderDO.setMerchantAddress(orderDTO.getMerchantAddress());
orderDO.setMerchantHeadPic(orderDTO.getMerchantHeadPic());
orderDO.setMerchantId(orderDTO.getMerchantId());
orderDO.setMerchantAddress(orderDTO.getMerchantAddress());
orderDO.setMerchantName(orderDTO.getMerchantName());
orderDO.setMerchantPhone(orderDTO.getMerchantPhone());
orderDO.setOrderNo(orderDTO.getOrderNo());
orderDO.setOutDate(orderDTO.getOutDate());
orderDO.setPayable(orderDTO.isPayable());
orderDO.setPaymentAmount(orderDTO.getPaymentAmount());
orderDO.setPaymentDate(orderDTO.getPaymentDate());
orderDO.setPaymentMethod(orderDTO.getPaymentMethod());
orderDO.setPaymentTimeLimit(orderDTO.getPaymentTimeLimit());
orderDO.setPhone(orderDTO.getPhone());
orderDO.setRefundable(orderDTO.isRefundable());
orderDO.setRemark(orderDTO.getRemark());
orderDO.setStatus(orderDTO.getStatus());
orderDO.setTotalQuantity(orderDTO.getTotalQuantity());
orderDO.setUpdateTime(orderDTO.getUpdateTime());
orderDO.setName(orderDTO.getName());
orderDO.setUid(orderDTO.getUid());
如果不是代码中有注释,你能看出其中的诸多问题吗?
如果原始的 DTO 有 100 个字段,我们需要复制 90 个字段到 DO 中,保留 10 个不赋值,最后应该如何校验正确性呢?数数吗?即使数出有 90 行代码,也不一定正确,因为属性可能重复赋值。
有的时候字段命名相近,比如 complainable 和 commentable容易搞反第 7 和第 8 行),或者对两个目标字段重复赋值相同的来源字段(比如第 28 行)
明明要把 DTO 的值赋值到 DO 中,却在 set 的时候从 DO 自己取值(比如第 20 行),导致赋值无效。
这段代码并不是我随手写出来的,而是一个真实案例。有位同学就像代码中那样把经纬度赋值反了,因为落库的字段实在太多了。这个 Bug 很久都没发现,直到真正用到数据库中的经纬度做计算时,才发现一直以来都存错了。
修改方法很简单,可以使用类似 BeanUtils 这种 Mapping 工具来做 Bean 的转换copyProperties 方法还允许我们提供需要忽略的属性:
ComplicatedOrderDTO orderDTO = new ComplicatedOrderDTO();
ComplicatedOrderDO orderDO = new ComplicatedOrderDO();
BeanUtils.copyProperties(orderDTO, orderDO, "id");
return orderDO;
重点回顾
正所谓“常在河边走哪有不湿鞋”,重复代码多了总有一天会出错。今天,我从几个最常见的维度,和你分享了几个实际业务场景中可能出现的重复问题,以及消除重复的方式。
第一种代码重复是,有多个并行的类实现相似的代码逻辑。我们可以考虑提取相同逻辑在父类中实现,差异逻辑通过抽象方法留给子类实现。使用类似的模板方法把相同的流程和逻辑固定成模板,保留差异的同时尽可能避免代码重复。同时,可以使用 Spring 的 IoC 特性注入相应的子类,来避免实例化子类时的大量 if…else 代码。
第二种代码重复是,使用硬编码的方式重复实现相同的数据处理算法。我们可以考虑把规则转换为自定义注解,作为元数据对类或对字段、方法进行描述,然后通过反射动态读取这些元数据、字段或调用方法,实现规则参数和规则定义的分离。也就是说,把变化的部分也就是规则的参数放入注解,规则的定义统一处理。
第三种代码重复是,业务代码中常见的 DO、DTO、VO 转换时大量字段的手动赋值,遇到有上百个属性的复杂类型,非常非常容易出错。我的建议是,不要手动进行赋值,考虑使用 Bean 映射工具进行。此外,还可以考虑采用单元测试对所有字段进行赋值正确性校验。
最后,我想说的是,我会把代码重复度作为评估一个项目质量的重要指标,如果一个项目几乎没有任何重复代码,那么它内部的抽象一定是非常好的。在做项目重构的时候,你也可以以消除重复为第一目标去考虑实现。
今天用到的代码,我都放在了 GitHub 上,你可以点击这个链接查看。
思考与讨论
除了模板方法设计模式是减少重复代码的一把好手观察者模式也常用于减少代码重复并且是松耦合方式。Spring 也提供了类似工具(点击这里查看),你能想到有哪些应用场景吗?
关于 Bean 属性复制工具,除了最简单的 Spring 的 BeanUtils 工具类的使用,你还知道哪些对象映射类库吗?它们又有什么功能呢?
你还有哪些消除重复代码的心得和方法吗?我是朱晔,欢迎在评论区与我留言分享你的想法,也欢迎你把今天的内容分享给你的朋友或同事,一起交流。

View File

@@ -0,0 +1,896 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
22 接口设计:系统间对话的语言,一定要统一
今天,我要和你分享的主题是,在做接口设计时一定要确保系统之间对话的语言是统一的。
我们知道,开发一个服务的第一步就是设计接口。接口的设计需要考虑的点非常多,比如接口的命名、参数列表、包装结构体、接口粒度、版本策略、幂等性实现、同步异步处理方式等。
这其中,和接口设计相关比较重要的点有三个,分别是包装结构体、版本策略、同步异步处理方式。今天,我就通过我遇到的实际案例,和你一起看看因为接口设计思路和调用方理解不一致所导致的问题,以及相关的实践经验。
接口的响应要明确表示接口的处理结果
我曾遇到过一个处理收单的收单中心项目,下单接口返回的响应体中,包含了 success、code、info、message 等属性,以及二级嵌套对象 data 结构体。在对项目进行重构的时候,我们发现真的是无从入手,接口缺少文档,代码一有改动就出错。
有时候下单操作的响应结果是这样的success 是 true、message 是 OK貌似代表下单成功了但 info 里却提示订单存在风险code 是一个 5001 的错误码data 中能看到订单状态是 Cancelled订单 ID 是 -1好像又说明没有下单成功。
{
"success": true,
"code": 5001,
"info": "Risk order detected",
"message": "OK",
"data": {
"orderStatus": "Cancelled",
"orderId": -1
}
}
有些时候这个下单接口又会返回这样的结果success 是 falsemessage 提示非法用户 ID看上去下单失败但 data 里的 orderStatus 是 Created、info 是空、code 是 0。那么这次下单到底是成功还是失败呢
{
"success": false,
"code": 0,
"info": "",
"message": "Illegal userId",
"data": {
"orderStatus": "Created",
"orderId": 0
}
}
这样的结果,让我们非常疑惑:
结构体的 code 和 HTTP 响应状态码,是什么关系?
success 到底代表下单成功还是失败?
info 和 message 的区别是什么?
data 中永远都有数据吗?什么时候应该去查询 data
造成如此混乱的原因是:这个收单服务本身并不真正处理下单操作,只是做一些预校验和预处理;真正的下单操作,需要在收单服务内部调用另一个订单服务来处理;订单服务处理完成后,会返回订单状态和 ID。
在一切正常的情况下,下单后的订单状态就是已创建 Created订单 ID 是一个大于 0 的数字。而结构体中的 message 和 success其实是收单服务的处理异常信息和处理成功与否的结果code、info 是调用订单服务的结果。
对于第一次调用收单服务自己没问题success 是 truemessage 是 OK但调用订单服务时却因为订单风险问题被拒绝所以 code 是 5001info 是 Risk order detecteddata 中的信息是订单服务返回的,所以最终订单状态是 Cancelled。
对于第二次调用,因为用户 ID 非法,所以收单服务在校验了参数后直接就返回了 success 是 falsemessage 是 Illegal userId。因为请求没有到订单服务所以 info、code、data 都是默认值,订单状态的默认值是 Created。因此第二次下单肯定失败了但订单状态却是已创建。
可以看到,如此混乱的接口定义和实现方式,是无法让调用者分清到底应该怎么处理的。为了将接口设计得更合理,我们需要考虑如下两个原则:
对外隐藏内部实现。虽然说收单服务调用订单服务进行真正的下单操作,但是直接接口其实是收单服务提供的,收单服务不应该“直接”暴露其背后订单服务的状态码、错误描述。
设计接口结构时,明确每个字段的含义,以及客户端的处理方式。
基于这两个原则,我们调整一下返回结构体,去掉外层的 info即不再把订单服务的调用结果告知客户端
@Data
public class APIResponse<T> {
private boolean success;
private T data;
private int code;
private String message;
}
并明确接口的设计逻辑:
如果出现非 200 的 HTTP 响应状态码,就代表请求没有到收单服务,可能是网络出问题、网络超时,或者网络配置的问题。这时,肯定无法拿到服务端的响应体,客户端可以给予友好提示,比如让用户重试,不需要继续解析响应结构体。
如果 HTTP 响应码是 200解析响应体查看 success为 false 代表下单请求处理失败,可能是因为收单服务参数验证错误,也可能是因为订单服务下单操作失败。这时,根据收单服务定义的错误码表和 code做不同处理。比如友好提示或是让用户重新填写相关信息其中友好提示的文字内容可以从 message 中获取。
success 为 true 的情况下,才需要继续解析响应体中的 data 结构体。data 结构体代表了业务数据,通常会有下面两种情况。
通常情况下success 为 true 时订单状态是 Created获取 orderId 属性可以拿到订单号。
特殊情况下,比如收单服务内部处理不当,或是订单服务出现了额外的状态,虽然 success 为 true但订单实际状态不是 Created这时可以给予友好的错误提示。
明确了接口的设计逻辑,我们就是可以实现收单服务的服务端和客户端来模拟这些情况了。
首先,实现服务端的逻辑:
@GetMapping("server")
public APIResponse<OrderInfo> server(@RequestParam("userId") Long userId) {
APIResponse<OrderInfo> response = new APIResponse<>();
if (userId == null) {
//对于userId为空的情况收单服务直接处理失败给予相应的错误码和错误提示
response.setSuccess(false);
response.setCode(3001);
response.setMessage("Illegal userId");
} else if (userId == 1) {
//对于userId=1的用户模拟订单服务对于风险用户的情况
response.setSuccess(false);
//把订单服务返回的错误码转换为收单服务错误码
response.setCode(3002);
response.setMessage("Internal Error, order is cancelled");
//同时日志记录内部错误
log.warn("用户 {} 调用订单服务失败,原因是 Risk order detected", userId);
} else {
//其他用户,下单成功
response.setSuccess(true);
response.setCode(2000);
response.setMessage("OK");
response.setData(new OrderInfo("Created", 2L));
}
return response;
}
客户端代码,则可以按照流程图上的逻辑来实现,同样模拟三种出错情况和正常下单的情况:
error==1 的用例模拟一个不存在的 URL请求无法到收单服务会得到 404 的 HTTP 状态码,直接进行友好提示,这是第一层处理。
error==2 的用例模拟 userId 参数为空的情况,收单服务会因为缺少 userId 参数提示非法用户。这时,可以把响应体中的 message 展示给用户,这是第二层处理。
error==3 的用例模拟 userId 为 1 的情况,因为用户有风险,收单服务调用订单服务出错。处理方式和之前没有任何区别,因为收单服务会屏蔽订单服务的内部错误。
但在服务端可以看到如下错误信息:
[14:13:13.951] [http-nio-45678-exec-8] [WARN ] [.c.a.d.APIThreeLevelStatusController:36 ] - 用户 1 调用订单服务失败,原因是 Risk order detected
error==0 的用例模拟正常用户,下单成功。这时可以解析 data 结构体提取业务结果,作为兜底,需要判断订单状态,如果不是 Created 则给予友好提示,否则查询 orderId 获得下单的订单号,这是第三层处理。
客户端的实现代码如下:
@GetMapping("client")
public String client(@RequestParam(value = "error", defaultValue = "0") int error) {
String url = Arrays.asList("http://localhost:45678/apiresposne/server?userId=2",
"http://localhost:45678/apiresposne/server2",
"http://localhost:45678/apiresposne/server?userId=",
"http://localhost:45678/apiresposne/server?userId=1").get(error);
//第一层先看状态码如果状态码不是200不处理响应体
String response = "";
try {
response = Request.Get(url).execute().returnContent().asString();
} catch (HttpResponseException e) {
log.warn("请求服务端出现返回非200", e);
return "服务器忙,请稍后再试!";
} catch (IOException e) {
e.printStackTrace();
}
//状态码为200的情况下处理响应体
if (!response.equals("")) {
try {
APIResponse<OrderInfo> apiResponse = objectMapper.readValue(response, new TypeReference<APIResponse<OrderInfo>>() {
});
//第二层success是false直接提示用户
if (!apiResponse.isSuccess()) {
return String.format("创建订单失败,请稍后再试,错误代码: %s 错误原因:%s", apiResponse.getCode(), apiResponse.getMessage());
} else {
//第三层往下解析OrderInfo
OrderInfo orderInfo = apiResponse.getData();
if ("Created".equals(orderInfo.getStatus()))
return String.format("创建订单成功,订单号是:%s状态是%s", orderInfo.getOrderId(), orderInfo.getStatus());
else
return String.format("创建订单失败,请联系客服处理");
}
} catch (JsonProcessingException e) {
e.printStackTrace();
}
}
return "";
}
相比原来混乱的接口定义和处理逻辑,改造后的代码,明确了接口每一个字段的含义,以及对于各种情况服务端的输出和客户端的处理步骤,对齐了客户端和服务端的处理逻辑。那么现在,你能回答前面那 4 个让人疑惑的问题了吗?
最后分享一个小技巧。为了简化服务端代码,我们可以把包装 API 响应体 APIResponse 的工作交由框架自动完成,这样直接返回 DTO OrderInfo 即可。对于业务逻辑错误,可以抛出一个自定义异常:
@GetMapping("server")
public OrderInfo server(@RequestParam("userId") Long userId) {
if (userId == null) {
throw new APIException(3001, "Illegal userId");
}
if (userId == 1) {
...
//直接抛出异常
throw new APIException(3002, "Internal Error, order is cancelled");
}
//直接返回DTO
return new OrderInfo("Created", 2L);
}
在 APIException 中包含错误码和错误消息:
public class APIException extends RuntimeException {
@Getter
private int errorCode;
@Getter
private String errorMessage;
public APIException(int errorCode, String errorMessage) {
super(errorMessage);
this.errorCode = errorCode;
this.errorMessage = errorMessage;
}
public APIException(Throwable cause, int errorCode, String errorMessage) {
super(errorMessage, cause);
this.errorCode = errorCode;
this.errorMessage = errorMessage;
}
}
然后,定义一个 @RestControllerAdvice 来完成自动包装响应体的工作:
通过实现 ResponseBodyAdvice 接口的 beforeBodyWrite 方法,来处理成功请求的响应体转换。
实现一个 @ExceptionHandler 来处理业务异常时APIException 到 APIResponse 的转换。
//此段代码只是Demo生产级应用还需要扩展很多细节
@RestControllerAdvice
@Slf4j
public class APIResponseAdvice implements ResponseBodyAdvice<Object> {
//自动处理APIException包装为APIResponse
@ExceptionHandler(APIException.class)
public APIResponse handleApiException(HttpServletRequest request, APIException ex) {
log.error("process url {} failed", request.getRequestURL().toString(), ex);
APIResponse apiResponse = new APIResponse();
apiResponse.setSuccess(false);
apiResponse.setCode(ex.getErrorCode());
apiResponse.setMessage(ex.getErrorMessage());
return apiResponse;
}
//仅当方法或类没有标记@NoAPIResponse才自动包装
@Override
public boolean supports(MethodParameter returnType, Class converterType) {
return returnType.getParameterType() != APIResponse.class
&& AnnotationUtils.findAnnotation(returnType.getMethod(), NoAPIResponse.class) == null
&& AnnotationUtils.findAnnotation(returnType.getDeclaringClass(), NoAPIResponse.class) == null;
}
//自动包装外层APIResposne响应
@Override
public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class<? extends HttpMessageConverter<?>> selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {
APIResponse apiResponse = new APIResponse();
apiResponse.setSuccess(true);
apiResponse.setMessage("OK");
apiResponse.setCode(2000);
apiResponse.setData(body);
return apiResponse;
}
}
在这里,我们实现了一个 @NoAPIResponse 自定义注解。如果某些 @RestController 的接口不希望实现自动包装的话,可以标记这个注解:
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface NoAPIResponse {
}
在 ResponseBodyAdvice 的 support 方法中,我们排除了标记有这个注解的方法或类的自动响应体包装。比如,对于刚才我们实现的测试客户端 client 方法不需要包装为 APIResponse就可以标记上这个注解
@GetMapping("client")
@NoAPIResponse
public String client(@RequestParam(value = "error", defaultValue = "0") int error)
这样我们的业务逻辑中就不需要考虑响应体的包装,代码会更简洁。
要考虑接口变迁的版本控制策略
接口不可能一成不变,需要根据业务需求不断增加内部逻辑。如果做大的功能调整或重构,涉及参数定义的变化或是参数废弃,导致接口无法向前兼容,这时接口就需要有版本的概念。在考虑接口版本策略设计时,我们需要注意的是,最好一开始就明确版本策略,并考虑在整个服务端统一版本策略。
第一,版本策略最好一开始就考虑。
既然接口总是要变迁的,那么最好一开始就确定版本策略。比如,确定是通过 URL Path 实现,是通过 QueryString 实现,还是通过 HTTP 头实现。这三种实现方式的代码如下:
//通过URL Path实现版本控制
@GetMapping("/v1/api/user")
public int right1(){
return 1;
}
//通过QueryString中的version参数实现版本控制
@GetMapping(value = "/api/user", params = "version=2")
public int right2(@RequestParam("version") int version) {
return 2;
}
//通过请求头中的X-API-VERSION参数实现版本控制
@GetMapping(value = "/api/user", headers = "X-API-VERSION=3")
public int right3(@RequestHeader("X-API-VERSION") int version) {
return 3;
}
这样,客户端就可以在配置中处理相关版本控制的参数,有可能实现版本的动态切换。
这三种方式中URL Path 的方式最直观也最不容易出错QueryString 不易携带,不太推荐作为公开 API 的版本策略HTTP 头的方式比较没有侵入性,如果仅仅是部分接口需要进行版本控制,可以考虑这种方式。
第二,版本实现方式要统一。
之前,我就遇到过一个 O2O 项目,需要针对商品、商店和用户实现 REST 接口。虽然大家约定通过 URL Path 方式实现 API 版本控制,但实现方式不统一,有的是 /api/item/v1有的是 /api/v1/shop还有的是 /v1/api/merchant
@GetMapping("/api/item/v1")
public void wrong1(){
}
@GetMapping("/api/v1/shop")
public void wrong2(){
}
@GetMapping("/v1/api/merchant")
public void wrong3(){
}
显然,商品、商店和商户的接口开发同学,没有按照一致的 URL 格式来实现接口的版本控制。更要命的是,我们可能开发出两个 URL 类似接口,比如一个是 /api/v1/user另一个是 /api/user/v1这到底是一个接口还是两个接口呢
相比于在每一个接口的 URL Path 中设置版本号,更理想的方式是在框架层面实现统一。如果你使用 Spring 框架的话,可以按照下面的方式自定义 RequestMappingHandlerMapping 来实现。
首先,创建一个注解来定义接口的版本。@APIVersion 自定义注解可以应用于方法或 Controller 上:
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface APIVersion {
String[] value();
}
然后,定义一个 APIVersionHandlerMapping 类继承 RequestMappingHandlerMapping。
RequestMappingHandlerMapping 的作用,是根据类或方法上的 @RequestMapping 来生成 RequestMappingInfo 的实例。我们覆盖 registerHandlerMethod 方法的实现,从 @APIVersion 自定义注解中读取版本信息,拼接上原有的、不带版本号的 URL Pattern构成新的 RequestMappingInfo来通过注解的方式为接口增加基于 URL 的版本号:
public class APIVersionHandlerMapping extends RequestMappingHandlerMapping {
@Override
protected boolean isHandler(Class<?> beanType) {
return AnnotatedElementUtils.hasAnnotation(beanType, Controller.class);
}
@Override
protected void registerHandlerMethod(Object handler, Method method, RequestMappingInfo mapping) {
Class<?> controllerClass = method.getDeclaringClass();
//类上的APIVersion注解
APIVersion apiVersion = AnnotationUtils.findAnnotation(controllerClass, APIVersion.class);
//方法上的APIVersion注解
APIVersion methodAnnotation = AnnotationUtils.findAnnotation(method, APIVersion.class);
//以方法上的注解优先
if (methodAnnotation != null) {
apiVersion = methodAnnotation;
}
String[] urlPatterns = apiVersion == null ? new String[0] : apiVersion.value();
PatternsRequestCondition apiPattern = new PatternsRequestCondition(urlPatterns);
PatternsRequestCondition oldPattern = mapping.getPatternsCondition();
PatternsRequestCondition updatedFinalPattern = apiPattern.combine(oldPattern);
//重新构建RequestMappingInfo
mapping = new RequestMappingInfo(mapping.getName(), updatedFinalPattern, mapping.getMethodsCondition(),
mapping.getParamsCondition(), mapping.getHeadersCondition(), mapping.getConsumesCondition(),
mapping.getProducesCondition(), mapping.getCustomCondition());
super.registerHandlerMethod(handler, method, mapping);
}
}
最后,也是特别容易忽略的一点,要通过实现 WebMvcRegistrations 接口,来生效自定义的 APIVersionHandlerMapping
@SpringBootApplication
public class CommonMistakesApplication implements WebMvcRegistrations {
...
@Override
public RequestMappingHandlerMapping getRequestMappingHandlerMapping() {
return new APIVersionHandlerMapping();
}
}
这样,就实现了在 Controller 上或接口方法上通过注解,来实现以统一的 Pattern 进行版本号控制:
@GetMapping(value = "/api/user")
@APIVersion("v4")
public int right4() {
return 4;
}
加上注解后,访问浏览器查看效果:
使用框架来明确 API 版本的指定策略,不仅实现了标准化,更实现了强制的 API 版本控制。对上面代码略做修改,我们就可以实现不设置 @APIVersion 接口就给予报错提示。
接口处理方式要明确同步还是异步
看到这个标题,你可能感觉不太好理解,我们直接看一个实际案例吧。
有一个文件上传服务 FileService其中一个 upload 文件上传接口特别慢,原因是这个上传接口在内部需要进行两步操作,首先上传原图,然后压缩后上传缩略图。如果每一步都耗时 5 秒的话,那么这个接口返回至少需要 10 秒的时间。
于是,开发同学把接口改为了异步处理,每一步操作都限定了超时时间,也就是分别把上传原文件和上传缩略图的操作提交到线程池,然后等待一定的时间:
private ExecutorService threadPool = Executors.newFixedThreadPool(2);
//我没有贴出两个文件上传方法uploadFile和uploadThumbnailFile的实现它们在内部只是随机进行休眠然后返回文件名对于本例来说不是很重要
public UploadResponse upload(UploadRequest request) {
UploadResponse response = new UploadResponse();
//上传原始文件任务提交到线程池处理
Future<String> uploadFile = threadPool.submit(() -> uploadFile(request.getFile()));
//上传缩略图任务提交到线程池处理
Future<String> uploadThumbnailFile = threadPool.submit(() -> uploadThumbnailFile(request.getFile()));
//等待上传原始文件任务完成最多等待1秒
try {
response.setDownloadUrl(uploadFile.get(1, TimeUnit.SECONDS));
} catch (Exception e) {
e.printStackTrace();
}
//等待上传缩略图任务完成最多等待1秒
try {
response.setThumbnailDownloadUrl(uploadThumbnailFile.get(1, TimeUnit.SECONDS));
} catch (Exception e) {
e.printStackTrace();
}
return response;
}
上传接口的请求和响应比较简单,传入二进制文件,传出原文件和缩略图下载地址:
@Data
public class UploadRequest {
private byte[] file;
}
@Data
public class UploadResponse {
private String downloadUrl;
private String thumbnailDownloadUrl;
}
到这里,你能看出这种实现方式的问题是什么吗?
从接口命名上看虽然是同步上传操作,但其内部通过线程池进行异步上传,并因为设置了较短超时所以接口整体响应挺快。但是,一旦遇到超时,接口就不能返回完整的数据,不是无法拿到原文件下载地址,就是无法拿到缩略图下载地址,接口的行为变得不可预测:
所以,这种优化接口响应速度的方式并不可取,更合理的方式是,让上传接口要么是彻底的同步处理,要么是彻底的异步处理:
所谓同步处理,接口一定是同步上传原文件和缩略图的,调用方可以自己选择调用超时,如果来得及可以一直等到上传完成,如果等不及可以结束等待,下一次再重试;
所谓异步处理,接口是两段式的,上传接口本身只是返回一个任务 ID然后异步做上传操作上传接口响应很快客户端需要之后再拿着任务 ID 调用任务查询接口查询上传的文件 URL。
同步上传接口的实现代码如下,把超时的选择留给客户端:
public SyncUploadResponse syncUpload(SyncUploadRequest request) {
SyncUploadResponse response = new SyncUploadResponse();
response.setDownloadUrl(uploadFile(request.getFile()));
response.setThumbnailDownloadUrl(uploadThumbnailFile(request.getFile()));
return response;
}
这里的 SyncUploadRequest 和 SyncUploadResponse 类,与之前定义的 UploadRequest 和 UploadResponse 是一致的。对于接口的入参和出参 DTO 的命名,我比较建议的方式是,使用接口名 +Request 和 Response 后缀。
接下来,我们看看异步的上传文件接口如何实现。异步上传接口在出参上有点区别,不再返回文件 URL而是返回一个任务 ID
@Data
public class AsyncUploadRequest {
private byte[] file;
}
@Data
public class AsyncUploadResponse {
private String taskId;
}
在接口实现上,我们同样把上传任务提交到线程池处理,但是并不会同步等待任务完成,而是完成后把结果写入一个 HashMap任务查询接口通过查询这个 HashMap 来获得文件的 URL
//计数器作为上传任务的ID
private AtomicInteger atomicInteger = new AtomicInteger(0);
//暂存上传操作的结果,生产代码需要考虑数据持久化
private ConcurrentHashMap<String, SyncQueryUploadTaskResponse> downloadUrl = new ConcurrentHashMap<>();
//异步上传操作
public AsyncUploadResponse asyncUpload(AsyncUploadRequest request) {
AsyncUploadResponse response = new AsyncUploadResponse();
//生成唯一的上传任务ID
String taskId = "upload" + atomicInteger.incrementAndGet();
//异步上传操作只返回任务ID
response.setTaskId(taskId);
//提交上传原始文件操作到线程池异步处理
threadPool.execute(() -> {
String url = uploadFile(request.getFile());
//如果ConcurrentHashMap不包含Key则初始化一个SyncQueryUploadTaskResponse然后设置DownloadUrl
downloadUrl.computeIfAbsent(taskId, id -> new SyncQueryUploadTaskResponse(id)).setDownloadUrl(url);
});
//提交上传缩略图操作到线程池异步处理
threadPool.execute(() -> {
String url = uploadThumbnailFile(request.getFile());
downloadUrl.computeIfAbsent(taskId, id -> new SyncQueryUploadTaskResponse(id)).setThumbnailDownloadUrl(url);
});
return response;
}
文件上传查询接口则以任务 ID 作为入参,返回两个文件的下载地址,因为文件上传查询接口是同步的,所以直接命名为 syncQueryUploadTask
//syncQueryUploadTask接口入参
@Data
@RequiredArgsConstructor
public class SyncQueryUploadTaskRequest {
private final String taskId;//使用上传文件任务ID查询上传结果
}
//syncQueryUploadTask接口出参
@Data
@RequiredArgsConstructor
public class SyncQueryUploadTaskResponse {
private final String taskId; //任务ID
private String downloadUrl; //原始文件下载URL
private String thumbnailDownloadUrl; //缩略图下载URL
}
public SyncQueryUploadTaskResponse syncQueryUploadTask(SyncQueryUploadTaskRequest request) {
SyncQueryUploadTaskResponse response = new SyncQueryUploadTaskResponse(request.getTaskId());
//从之前定义的downloadUrl ConcurrentHashMap查询结果
response.setDownloadUrl(downloadUrl.getOrDefault(request.getTaskId(), response).getDownloadUrl());
response.setThumbnailDownloadUrl(downloadUrl.getOrDefault(request.getTaskId(), response).getThumbnailDownloadUrl());
return response;
}
经过改造的 FileService 不再提供一个看起来是同步上传,内部却是异步上传的 upload 方法,改为提供很明确的:
同步上传接口 syncUpload
异步上传接口 asyncUpload搭配 syncQueryUploadTask 查询上传结果。
使用方可以根据业务性质选择合适的方法:如果是后端批处理使用,那么可以使用同步上传,多等待一些时间问题不大;如果是面向用户的接口,那么接口响应时间不宜过长,可以调用异步上传接口,然后定时轮询上传结果,拿到结果再显示。
重点回顾
今天,我针对接口设计,和你深入探讨了三个方面的问题。
第一,针对响应体的设计混乱、响应结果的不明确问题,服务端需要明确响应体每一个字段的意义,以一致的方式进行处理,并确保不透传下游服务的错误。
第二,针对接口版本控制问题,主要就是在开发接口之前明确版本控制策略,以及尽量使用统一的版本控制策略两方面。
第三,针对接口的处理方式,我认为需要明确要么是同步要么是异步。如果 API 列表中既有同步接口也有异步接口,那么最好直接在接口名中明确。
一个良好的接口文档不仅仅需要说明如何调用接口,更需要补充接口使用的最佳实践以及接口的 SLA 标准。我看到的大部分接口文档只给出了参数定义,但诸如幂等性、同步异步、缓存策略等看似内部实现相关的一些设计,其实也会影响调用方对接口的使用策略,最好也可以体现在接口文档中。
最后,我再额外提一下,对于服务端出错的时候是否返回 200 响应码的问题,其实一直有争论。从 RESTful 设计原则来看,我们应该尽量利用 HTTP 状态码来表达错误,但也不是这么绝对。
如果我们认为 HTTP 状态码是协议层面的履约,那么当这个错误已经不涉及 HTTP 协议时(换句话说,服务端已经收到请求进入服务端业务处理后产生的错误),不一定需要硬套协议本身的错误码。但涉及非法 URL、非法参数、没有权限等无法处理请求的情况还是应该使用正确的响应码来应对。
今天用到的代码,我都放在了 GitHub 上,你可以点击这个链接查看。
思考与讨论
在第一节的例子中,接口响应结构体中的 code 字段代表执行结果的错误码对于业务特别复杂的接口可能会有很多错误情况code 可能会有几十甚至几百个。客户端开发人员需要根据每一种错误情况逐一写 if-else 进行不同交互处理,会非常麻烦,你觉得有什么办法来改进吗?作为服务端,是否有必要告知客户端接口执行的错误码呢?
在第二节的例子中,我们在类或方法上标记 @APIVersion 自定义注解,实现了 URL 方式统一的接口版本定义。你可以用类似的方式(也就是自定义 RequestMappingHandlerMapping来实现一套统一的基于请求头方式的版本控制吗
关于接口设计,你还遇到过其他问题吗?我是朱晔,欢迎在评论区与我留言分享你的想法,也欢迎你把今天的内容分享给你的朋友或同事,一起交流。

View File

@@ -0,0 +1,587 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
23 缓存设计:缓存可以锦上添花也可以落井下石
今天,我从设计的角度,与你聊聊缓存。
通常我们会使用更快的介质(比如内存)作为缓存,来解决较慢介质(比如磁盘)读取数据慢的问题,缓存是用空间换时间,来解决性能问题的一种架构设计模式。更重要的是,磁盘上存储的往往是原始数据,而缓存中保存的可以是面向呈现的数据。这样一来,缓存不仅仅是加快了 IO还可以减少原始数据的计算工作。
此外,缓存系统一般设计简单,功能相对单一,所以诸如 Redis 这种缓存系统的整体吞吐量,能达到关系型数据库的几倍甚至几十倍,因此缓存特别适用于互联网应用的高并发场景。
使用 Redis 做缓存虽然简单好用,但使用和设计缓存并不是 set 一下这么简单,需要注意缓存的同步、雪崩、并发、穿透等问题。今天,我们就来详细聊聊。
不要把 Redis 当作数据库
通常,我们会使用 Redis 等分布式缓存数据库来缓存数据,但是千万别把 Redis 当做数据库来使用。我就见过许多案例,因为 Redis 中数据消失导致业务逻辑错误,并且因为没有保留原始数据,业务都无法恢复。
Redis 的确具有数据持久化功能,可以实现服务重启后数据不丢失。这一点,很容易让我们误认为 Redis 可以作为高性能的 KV 数据库。
其实从本质上来看Redis免费版是一个内存数据库所有数据保存在内存中并且直接从内存读写数据响应操作只不过具有数据持久化能力。所以Redis 的特点是,处理请求很快,但无法保存超过内存大小的数据。
备注VM 模式虽然可以保存超过内存大小的数据,但是因为性能原因从 2.6 开始已经被废弃。此外Redis 企业版提供了 Redis on Flash 可以实现 Key+ 字典 + 热数据保存在内存中,冷数据保存在 SSD 中。
因此,把 Redis 用作缓存,我们需要注意两点。
第一,从客户端的角度来说,缓存数据的特点一定是有原始数据来源,且允许丢失,即使设置的缓存时间是 1 分钟,在 30 秒时缓存数据因为某种原因消失了,我们也要能接受。当数据丢失后,我们需要从原始数据重新加载数据,不能认为缓存系统是绝对可靠的,更不能认为缓存系统不会删除没有过期的数据。
第二,从 Redis 服务端的角度来说,缓存系统可以保存的数据量一定是小于原始数据的。首先,我们应该限制 Redis 对内存的使用量,也就是设置 maxmemory 参数;其次,我们应该根据数据特点,明确 Redis 应该以怎样的算法来驱逐数据。
从Redis 的文档可以看到,常用的数据淘汰策略有:
allkeys-lru针对所有 Key优先删除最近最少使用的 Key
volatile-lru针对带有过期时间的 Key优先删除最近最少使用的 Key
volatile-ttl针对带有过期时间的 Key优先删除即将过期的 Key根据 TTL 的值);
allkeys-lfuRedis 4.0 以上),针对所有 Key优先删除最少使用的 Key
volatile-lfuRedis 4.0 以上),针对带有过期时间的 Key优先删除最少使用的 Key。
其实,这些算法是 Key 范围 +Key 选择算法的搭配组合,其中范围有 allkeys 和 volatile 两种,算法有 LRU、TTL 和 LFU 三种。接下来,我就从 Key 范围和算法角度,和你说说如何选择合适的驱逐算法。
首先从算法角度来说Redis 4.0 以后推出的 LFU 比 LRU 更“实用”。试想一下,如果一个 Key 访问频率是 1 天一次,但正好在 1 秒前刚访问过,那么 LRU 可能不会选择优先淘汰这个 Key反而可能会淘汰一个 5 秒访问一次但最近 2 秒没有访问过的 Key而 LFU 算法不会有这个问题。而 TTL 会比较“头脑简单”一点,优先删除即将过期的 Key但有可能这个 Key 正在被大量访问。
然后,从 Key 范围角度来说allkeys 可以确保即使 Key 没有 TTL 也能回收,如果使用的时候客户端总是“忘记”设置缓存的过期时间,那么可以考虑使用这个系列的算法。而 volatile 会更稳妥一些,万一客户端把 Redis 当做了长效缓存使用,只是启动时候初始化一次缓存,那么一旦删除了此类没有 TTL 的数据,可能就会导致客户端出错。
所以,不管是使用者还是管理者都要考虑 Redis 的使用方式,使用者需要考虑应该以缓存的姿势来使用 Redis管理者应该为 Redis 设置内存限制和合适的驱逐策略,避免出现 OOM。
注意缓存雪崩问题
由于缓存系统的 IOPS 比数据库高很多,因此要特别小心短时间内大量缓存失效的情况。这种情况一旦发生,可能就会在瞬间有大量的数据需要回源到数据库查询,对数据库造成极大的压力,极限情况下甚至导致后端数据库直接崩溃。这就是我们常说的缓存失效,也叫作缓存雪崩。
从广义上说,产生缓存雪崩的原因有两种:
第一种是,缓存系统本身不可用,导致大量请求直接回源到数据库;
第二种是,应用设计层面大量的 Key 在同一时间过期,导致大量的数据回源。
第一种原因,主要涉及缓存系统本身高可用的配置,不属于缓存设计层面的问题,所以今天我主要和你说说如何确保大量 Key 不在同一时间被动过期。
程序初始化的时候放入 1000 条城市数据到 Redis 缓存中,过期时间是 30 秒;数据过期后从数据库获取数据然后写入缓存,每次从数据库获取数据后计数器 +1在程序启动的同时启动一个定时任务线程每隔一秒输出计数器的值并把计数器归零。
压测一个随机查询某城市信息的接口,观察一下数据库的 QPS
@Autowired
private StringRedisTemplate stringRedisTemplate;
private AtomicInteger atomicInteger = new AtomicInteger();
@PostConstruct
public void wrongInit() {
//初始化1000个城市数据到Redis所有缓存数据有效期30秒
IntStream.rangeClosed(1, 1000).forEach(i -> stringRedisTemplate.opsForValue().set("city" + i, getCityFromDb(i), 30, TimeUnit.SECONDS));
log.info("Cache init finished");
//每秒一次输出数据库访问的QPS
Executors.newSingleThreadScheduledExecutor().scheduleAtFixedRate(() -> {
log.info("DB QPS : {}", atomicInteger.getAndSet(0));
}, 0, 1, TimeUnit.SECONDS);
}
@GetMapping("city")
public String city() {
//随机查询一个城市
int id = ThreadLocalRandom.current().nextInt(1000) + 1;
String key = "city" + id;
String data = stringRedisTemplate.opsForValue().get(key);
if (data == null) {
//回源到数据库查询
data = getCityFromDb(id);
if (!StringUtils.isEmpty(data))
//缓存30秒过期
stringRedisTemplate.opsForValue().set(key, data, 30, TimeUnit.SECONDS);
}
return data;
}
private String getCityFromDb(int cityId) {
//模拟查询数据库,查一次增加计数器加一
atomicInteger.incrementAndGet();
return "citydata" + System.currentTimeMillis();
}
使用 wrk 工具,设置 10 线程 10 连接压测 city 接口:
wrk -c10 -t10 -d 100s http://localhost:45678/cacheinvalid/city
启动程序 30 秒后缓存过期,回源的数据库 QPS 最高达到了 700 多:
解决缓存 Key 同时大规模失效需要回源,导致数据库压力激增问题的方式有两种。
方案一,差异化缓存过期时间,不要让大量的 Key 在同一时间过期。比如,在初始化缓存的时候,设置缓存的过期时间是 30 秒 +10 秒以内的随机延迟(扰动值)。这样,这些 Key 不会集中在 30 秒这个时刻过期,而是会分散在 30~40 秒之间过期:
@PostConstruct
public void rightInit1() {
//这次缓存的过期时间是30秒+10秒内的随机延迟
IntStream.rangeClosed(1, 1000).forEach(i -> stringRedisTemplate.opsForValue().set("city" + i, getCityFromDb(i), 30 + ThreadLocalRandom.current().nextInt(10), TimeUnit.SECONDS));
log.info("Cache init finished");
//同样1秒一次输出数据库QPS
Executors.newSingleThreadScheduledExecutor().scheduleAtFixedRate(() -> {
log.info("DB QPS : {}", atomicInteger.getAndSet(0));
}, 0, 1, TimeUnit.SECONDS);
}
修改后,缓存过期时的回源不会集中在同一秒,数据库的 QPS 从 700 多降到了最高 100 左右:
方案二,让缓存不主动过期。初始化缓存数据的时候设置缓存永不过期,然后启动一个后台线程 30 秒一次定时把所有数据更新到缓存,而且通过适当的休眠,控制从数据库更新数据的频率,降低数据库压力:
@PostConstruct
public void rightInit2() throws InterruptedException {
CountDownLatch countDownLatch = new CountDownLatch(1);
//每隔30秒全量更新一次缓存
Executors.newSingleThreadScheduledExecutor().scheduleAtFixedRate(() -> {
IntStream.rangeClosed(1, 1000).forEach(i -> {
String data = getCityFromDb(i);
//模拟更新缓存需要一定的时间
try {
TimeUnit.MILLISECONDS.sleep(20);
} catch (InterruptedException e) { }
if (!StringUtils.isEmpty(data)) {
//缓存永不过期,被动更新
stringRedisTemplate.opsForValue().set("city" + i, data);
}
});
log.info("Cache update finished");
//启动程序的时候需要等待首次更新缓存完成
countDownLatch.countDown();
}, 0, 30, TimeUnit.SECONDS);
Executors.newSingleThreadScheduledExecutor().scheduleAtFixedRate(() -> {
log.info("DB QPS : {}", atomicInteger.getAndSet(0));
}, 0, 1, TimeUnit.SECONDS);
countDownLatch.await();
}
这样修改后,虽然缓存整体更新的耗时在 21 秒左右,但数据库的压力会比较稳定:
关于这两种解决方案,我们需要特别注意以下三点:
方案一和方案二是截然不同的两种缓存方式,如果无法全量缓存所有数据,那么只能使用方案一;
即使使用了方案二,缓存永不过期,同样需要在查询的时候,确保有回源的逻辑。正如之前所说,我们无法确保缓存系统中的数据永不丢失。
不管是方案一还是方案二,在把数据从数据库加入缓存的时候,都需要判断来自数据库的数据是否合法,比如进行最基本的判空检查。
之前我就遇到过这样一个重大事故,某系统会在缓存中对基础数据进行长达半年的缓存,在某个时间点 DBA 把数据库中的原始数据进行了归档(可以认为是删除)操作。因为缓存中的数据一直在所以一开始没什么问题,但半年后的一天缓存中数据过期了,就从数据库中查询到了空数据加入缓存,爆发了大面积的事故。
这个案例说明,缓存会让我们更不容易发现原始数据的问题,所以在把数据加入缓存之前一定要校验数据,如果发现有明显异常要及时报警。
说到这里,我们再仔细看一下回源 QPS 超过 700 的截图,可以看到在并发情况下,总共 1000 条数据回源达到了 1002 次,说明有一些条目出现了并发回源。这,就是我后面要讲到的缓存并发问题。
注意缓存击穿问题
在某些 Key 属于极端热点数据,且并发量很大的情况下,如果这个 Key 过期,可能会在某个瞬间出现大量的并发请求同时回源,相当于大量的并发请求直接打到了数据库。这种情况,就是我们常说的缓存击穿或缓存并发问题。
我们来重现下这个问题。在程序启动的时候,初始化一个热点数据到 Redis 中,过期时间设置为 5 秒,每隔 1 秒输出一下回源的 QPS
@PostConstruct
public void init() {
//初始化一个热点数据到Redis中过期时间设置为5秒
stringRedisTemplate.opsForValue().set("hotsopt", getExpensiveData(), 5, TimeUnit.SECONDS);
//每隔1秒输出一下回源的QPS
Executors.newSingleThreadScheduledExecutor().scheduleAtFixedRate(() -> {
log.info("DB QPS : {}", atomicInteger.getAndSet(0));
}, 0, 1, TimeUnit.SECONDS);
}
@GetMapping("wrong")
public String wrong() {
String data = stringRedisTemplate.opsForValue().get("hotsopt");
if (StringUtils.isEmpty(data)) {
data = getExpensiveData();
//重新加入缓存过期时间还是5秒
stringRedisTemplate.opsForValue().set("hotsopt", data, 5, TimeUnit.SECONDS);
}
return data;
}
可以看到,每隔 5 秒数据库都有 20 左右的 QPS
如果回源操作特别昂贵,那么这种并发就不能忽略不计。这时,我们可以考虑使用锁机制来限制回源的并发。比如如下代码示例,使用 Redisson 来获取一个基于 Redis 的分布式锁,在查询数据库之前先尝试获取锁:
@Autowired
private RedissonClient redissonClient;
@GetMapping("right")
public String right() {
String data = stringRedisTemplate.opsForValue().get("hotsopt");
if (StringUtils.isEmpty(data)) {
RLock locker = redissonClient.getLock("locker");
//获取分布式锁
if (locker.tryLock()) {
try {
data = stringRedisTemplate.opsForValue().get("hotsopt");
//双重检查因为可能已经有一个B线程过了第一次判断在等锁然后A线程已经把数据写入了Redis中
if (StringUtils.isEmpty(data)) {
//回源到数据库查询
data = getExpensiveData();
stringRedisTemplate.opsForValue().set("hotsopt", data, 5, TimeUnit.SECONDS);
}
} finally {
//别忘记释放另外注意写法获取锁后整段代码try+finally确保unlock万无一失
locker.unlock();
}
}
}
return data;
}
这样,可以把回源到数据库的并发限制在 1
在真实的业务场景下,不一定要这么严格地使用双重检查分布式锁进行全局的并发限制,因为这样虽然可以把数据库回源并发降到最低,但也限制了缓存失效时的并发。可以考虑的方式是:
方案一,使用进程内的锁进行限制,这样每一个节点都可以以一个并发回源数据库;
方案二,不使用锁进行限制,而是使用类似 Semaphore 的工具限制并发数,比如限制为 10这样既限制了回源并发数不至于太大又能使得一定量的线程可以同时回源。
注意缓存穿透问题
在之前的例子中,缓存回源的逻辑都是当缓存中查不到需要的数据时,回源到数据库查询。这里容易出现的一个漏洞是,缓存中没有数据不一定代表数据没有缓存,还有一种可能是原始数据压根就不存在。
比如下面的例子。数据库中只保存有 ID 介于 0不含和 10000包含之间的用户如果从数据库查询 ID 不在这个区间的用户,会得到空字符串,所以缓存中缓存的也是空字符串。如果使用 ID=0 去压接口的话,从缓存中查出了空字符串,认为是缓存中没有数据回源查询,其实相当于每次都回源:
@GetMapping("wrong")
public String wrong(@RequestParam("id") int id) {
String key = "user" + id;
String data = stringRedisTemplate.opsForValue().get(key);
//无法区分是无效用户还是缓存失效
if (StringUtils.isEmpty(data)) {
data = getCityFromDb(id);
stringRedisTemplate.opsForValue().set(key, data, 30, TimeUnit.SECONDS);
}
return data;
}
private String getCityFromDb(int id) {
atomicInteger.incrementAndGet();
//注意只有ID介于0不含和10000包含之间的用户才是有效用户可以查询到用户信息
if (id > 0 && id <= 10000) return "userdata";
//否则返回空字符串
return "";
}
压测后数据库的 QPS 达到了几千:
如果这种漏洞被恶意利用的话,就会对数据库造成很大的性能压力。这就是缓存穿透。
这里需要注意,缓存穿透和缓存击穿的区别:
缓存穿透是指,缓存没有起到压力缓冲的作用;
而缓存击穿是指,缓存失效时瞬时的并发打到数据库。
解决缓存穿透有以下两种方案。
方案一,对于不存在的数据,同样设置一个特殊的 Value 到缓存中,比如当数据库中查出的用户信息为空的时候,设置 NODATA 这样具有特殊含义的字符串到缓存中。这样下次请求缓存的时候还是可以命中缓存,即直接从缓存返回结果,不查询数据库:
@GetMapping("right")
public String right(@RequestParam("id") int id) {
String key = "user" + id;
String data = stringRedisTemplate.opsForValue().get(key);
if (StringUtils.isEmpty(data)) {
data = getCityFromDb(id);
//校验从数据库返回的数据是否有效
if (!StringUtils.isEmpty(data)) {
stringRedisTemplate.opsForValue().set(key, data, 30, TimeUnit.SECONDS);
}
else {
//如果无效直接在缓存中设置一个NODATA这样下次查询时即使是无效用户还是可以命中缓存
stringRedisTemplate.opsForValue().set(key, "NODATA", 30, TimeUnit.SECONDS);
}
}
return data;
}
但,这种方式可能会把大量无效的数据加入缓存中,如果担心大量无效数据占满缓存的话还可以考虑方案二,即使用布隆过滤器做前置过滤。
布隆过滤器是一种概率型数据库结构,由一个很长的二进制向量和一系列随机映射函数组成。它的原理是,当一个元素被加入集合时,通过 k 个散列函数将这个元素映射成一个 m 位 bit 数组中的 k 个点,并置为 1。
检索时,我们只要看看这些点是不是都是 1 就(大概)知道集合中有没有它了。如果这些点有任何一个 0则被检元素一定不在如果都是 1则被检元素很可能在。
原理如下图所示:
布隆过滤器不保存原始值,空间效率很高,平均每一个元素占用 2.4 字节就可以达到万分之一的误判率。这里的误判率是指,过滤器判断值存在而实际并不存在的概率。我们可以设置布隆过滤器使用更大的存储空间,来得到更小的误判率。
你可以把所有可能的值保存在布隆过滤器中,从缓存读取数据前先过滤一次:
如果布隆过滤器认为值不存在,那么值一定是不存在的,无需查询缓存也无需查询数据库;
对于极小概率的误判请求,才会最终让非法 Key 的请求走到缓存或数据库。
要用上布隆过滤器,我们可以使用 Google 的 Guava 工具包提供的 BloomFilter 类改造一下程序:启动时,初始化一个具有所有有效用户 ID 的、10000 个元素的 BloomFilter在从缓存查询数据之前调用其 mightContain 方法,来检测用户 ID 是否可能存在;如果布隆过滤器说值不存在,那么一定是不存在的,直接返回:
private BloomFilter<Integer> bloomFilter;
@PostConstruct
public void init() {
//创建布隆过滤器元素数量10000期望误判率1%
bloomFilter = BloomFilter.create(Funnels.integerFunnel(), 10000, 0.01);
//填充布隆过滤器
IntStream.rangeClosed(1, 10000).forEach(bloomFilter::put);
}
@GetMapping("right2")
public String right2(@RequestParam("id") int id) {
String data = "";
//通过布隆过滤器先判断
if (bloomFilter.mightContain(id)) {
String key = "user" + id;
//走缓存查询
data = stringRedisTemplate.opsForValue().get(key);
if (StringUtils.isEmpty(data)) {
//走数据库查询
data = getCityFromDb(id);
stringRedisTemplate.opsForValue().set(key, data, 30, TimeUnit.SECONDS);
}
}
return data;
}
对于方案二,我们需要同步所有可能存在的值并加入布隆过滤器,这是比较麻烦的地方。如果业务规则明确的话,你也可以考虑直接根据业务规则判断值是否存在。
其实,方案二可以和方案一同时使用,即将布隆过滤器前置,对于误判的情况再保存特殊值到缓存,双重保险避免无效数据查询请求打到数据库。
注意缓存数据同步策略
前面提到的 3 个案例,其实都属于缓存数据过期后的被动删除。在实际情况下,修改了原始数据后,考虑到缓存数据更新的及时性,我们可能会采用主动更新缓存的策略。这些策略可能是:
先更新缓存,再更新数据库;
先更新数据库,再更新缓存;
先删除缓存,再更新数据库,访问的时候按需加载数据到缓存;
先更新数据库,再删除缓存,访问的时候按需加载数据到缓存。
那么,我们应该选择哪种更新策略呢?我来和你逐一分析下这 4 种策略:
“先更新缓存再更新数据库”策略不可行。数据库设计复杂,压力集中,数据库因为超时等原因更新操作失败的可能性较大,此外还会涉及事务,很可能因为数据库更新失败,导致缓存和数据库的数据不一致。
“先更新数据库再更新缓存”策略不可行。一是,如果线程 A 和 B 先后完成数据库更新,但更新缓存时却是 B 和 A 的顺序,那很可能会把旧数据更新到缓存中引起数据不一致;二是,我们不确定缓存中的数据是否会被访问,不一定要把所有数据都更新到缓存中去。
“先删除缓存再更新数据库,访问的时候按需加载数据到缓存”策略也不可行。在并发的情况下,很可能删除缓存后还没来得及更新数据库,就有另一个线程先读取了旧值到缓存中,如果并发量很大的话这个概率也会很大。
“先更新数据库再删除缓存,访问的时候按需加载数据到缓存”策略是最好的。虽然在极端情况下,这种策略也可能出现数据不一致的问题,但概率非常低,基本可以忽略。举一个“极端情况”的例子,比如更新数据的时间节点恰好是缓存失效的瞬间,这时 A 先读取到了旧值,随后在 B 操作数据库完成更新并且删除了缓存之后A 再把旧值加入缓存。
需要注意的是,更新数据库后删除缓存的操作可能失败,如果失败则考虑把任务加入延迟队列进行延迟重试,确保数据可以删除,缓存可以及时更新。因为删除操作是幂等的,所以即使重复删问题也不是太大,这又是删除比更新好的一个原因。
因此,针对缓存更新更推荐的方式是,缓存中的数据不由数据更新操作主动触发,统一在需要使用的时候按需加载,数据更新后及时删除缓存中的数据即可。
重点回顾
今天,我主要是从设计的角度,和你分享了数据缓存的三大问题。
第一,我们不能把诸如 Redis 的缓存数据库完全当作数据库来使用。我们不能假设缓存始终可靠,也不能假设没有过期的数据必然可以被读取到,需要处理好缓存的回源逻辑;而且要显式设置 Redis 的最大内存使用和数据淘汰策略,避免出现 OOM 的问题。
第二,缓存的性能比数据库好很多,我们需要考虑大量请求绕过缓存直击数据库造成数据库瘫痪的各种情况。对于缓存瞬时大面积失效的缓存雪崩问题,可以通过差异化缓存过期时间解决;对于高并发的缓存 Key 回源问题,可以使用锁来限制回源并发数;对于不存在的数据穿透缓存的问题,可以通过布隆过滤器进行数据存在性的预判,或在缓存中也设置一个值来解决。
第三,当数据库中的数据有更新的时候,需要考虑如何确保缓存中数据的一致性。我们看到,“先更新数据库再删除缓存,访问的时候按需加载数据到缓存”的策略是最为妥当的,并且要尽量设置合适的缓存过期时间,这样即便真的发生不一致,也可以在缓存过期后数据得到及时同步。
最后,我要提醒你的是,在使用缓存系统的时候,要监控缓存系统的内存使用量、命中率、对象平均过期时间等重要指标,以便评估系统的有效性,并及时发现问题。
今天用到的代码,我都放在了 GitHub 上,你可以点击这个链接查看。
思考与讨论
在聊到缓存并发问题时,我们说到热点 Key 回源会对数据库产生的压力问题,如果 Key 特别热的话,可能缓存系统也无法承受,毕竟所有的访问都集中打到了一台缓存服务器。如果我们使用 Redis 来做缓存,那可以把一个热点 Key 的缓存查询压力,分散到多个 Redis 节点上吗?
大 Key 也是数据缓存容易出现的一个问题。如果一个 Key 的 Value 特别大,那么可能会对 Redis 产生巨大的性能影响,因为 Redis 是单线程模型,对大 Key 进行查询或删除等操作,可能会引起 Redis 阻塞甚至是高可用切换。你知道怎么查询 Redis 中的大 Key以及如何在设计上实现大 Key 的拆分吗?
关于缓存设计,你还遇到过哪些坑呢?我是朱晔,欢迎在评论区与我留言分享你的想法,也欢迎你把今天的内容分享给你的朋友或同事,一起交流。

View File

@@ -0,0 +1,946 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
24 业务代码写完,就意味着生产就绪了?
今天,我们来聊聊业务代码写完,是不是就意味着生产就绪,可以直接投产了。
所谓生产就绪Production-ready是指应用开发完成要投入生产环境开发层面需要额外做的一些工作。在我看来如果应用只是开发完成了功能代码然后就直接投产那意味着应用其实在裸奔。在这种情况下遇到问题因为缺乏有效的监控导致无法排查定位问题同时很可能遇到问题我们自己都不知道需要依靠用户反馈才知道应用出了问题。
那么,生产就绪需要做哪些工作呢?我认为,以下三方面的工作最重要。
第一,提供健康检测接口。传统采用 ping 的方式对应用进行探活检测并不准确。有的时候,应用的关键内部或外部依赖已经离线,导致其根本无法正常工作,但其对外的 Web 端口或管理端口是可以 ping 通的。我们应该提供一个专有的监控检测接口,并尽可能触达一些内部组件。
第二,暴露应用内部信息。应用内部诸如线程池、内存队列等组件,往往在应用内部扮演了重要的角色,如果应用或应用框架可以对外暴露这些重要信息,并加以监控,那么就有可能在诸如 OOM 等重大问题暴露之前发现蛛丝马迹,避免出现更大的问题。
第三,建立应用指标 Metrics 监控。Metrics 可以翻译为度量或者指标,指的是对于一些关键信息以可聚合的、数值的形式做定期统计,并绘制出各种趋势图表。这里的指标监控,包括两个方面:一是,应用内部重要组件的指标监控,比如 JVM 的一些指标、接口的 QPS 等;二是,应用的业务数据的监控,比如电商订单量、游戏在线人数等。
今天,我就通过实际案例,和你聊聊如何快速实现这三方面的工作。
准备工作:配置 Spring Boot Actuator
Spring Boot 有一个 Actuator 模块封装了诸如健康检测、应用内部信息、Metrics 指标等生产就绪的功能。今天这一讲后面的内容都是基于 Actuator 的,因此我们需要先完成 Actuator 的引入和配置。
我们可以像这样在 pom 中通过添加依赖的方式引入 Actuator
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
之后,你就可以直接使用 Actuator 了,但还要注意一些重要的配置:
如果你不希望 Web 应用的 Actuator 管理端口和应用端口重合的话,可以使用 management.server.port 设置独立的端口。
Actuator 自带了很多开箱即用提供信息的端点Endpoint可以通过 JMX 或 Web 两种方式进行暴露。考虑到有些信息比较敏感,这些内置的端点默认不是完全开启的,你可以通过官网查看这些默认值。在这里,为了方便后续 Demo我们设置所有端点通过 Web 方式开启。
默认情况下Actuator 的 Web 访问方式的根地址为 /actuator可以通过 management.endpoints.web.base-path 参数进行修改。我来演示下,如何将其修改为 /admin。
management.server.port=45679
management.endpoints.web.exposure.include=*
management.endpoints.web.base-path=/admin
现在,你就可以访问 http://localhost:45679/admin ,来查看 Actuator 的所有功能 URL 了:
其中,大部分端点提供的是只读信息,比如查询 Spring 的 Bean、ConfigurableEnvironment、定时任务、SpringBoot 自动配置、Spring MVC 映射等;少部分端点还提供了修改功能,比如优雅关闭程序、下载线程 Dump、下载堆 Dump、修改日志级别等。
你可以访问这里,查看所有这些端点的功能,详细了解它们提供的信息以及实现的操作。此外,我再分享一个不错的 Spring Boot 管理工具Spring Boot Admin它把大部分 Actuator 端点提供的功能封装为了 Web UI。
健康检测需要触达关键组件
在这一讲开始我们提到,健康检测接口可以让监控系统或发布工具知晓应用的真实健康状态,比 ping 应用端口更可靠。不过,要达到这种效果最关键的是,我们能确保健康检测接口可以探查到关键组件的状态。
好在 Spring Boot Actuator 帮我们预先实现了诸如数据库、InfluxDB、Elasticsearch、Redis、RabbitMQ 等三方系统的健康检测指示器 HealthIndicator。
通过 Spring Boot 的自动配置这些指示器会自动生效。当这些组件有问题的时候HealthIndicator 会返回 DOWN 或 OUT_OF_SERVICE 状态health 端点 HTTP 响应状态码也会变为 503我们可以以此来配置程序健康状态监控报警。
为了演示,我们可以修改配置文件,把 management.endpoint.health.show-details 参数设置为 always让所有用户都可以直接查看各个组件的健康情况如果配置为 when-authorized那么可以结合 management.endpoint.health.roles 配置授权的角色):
management.endpoint.health.show-details=always
访问 health 端点可以看到数据库、磁盘、RabbitMQ、Redis 等组件健康状态是 UP整个应用的状态也是 UP
在了解了基本配置之后,我们考虑一下,如果程序依赖一个很重要的三方服务,我们希望这个服务无法访问的时候,应用本身的健康状态也是 DOWN。
比如三方服务有一个 user 接口,出现异常的概率是 50%
@Slf4j
@RestController
@RequestMapping("user")
public class UserServiceController {
@GetMapping
public User getUser(@RequestParam("userId") long id) {
//一半概率返回正确响应,一半概率抛异常
if (ThreadLocalRandom.current().nextInt() % 2 == 0)
return new User(id, "name" + id);
else
throw new RuntimeException("error");
}
}
要实现这个 user 接口是否正确响应和程序整体的健康状态挂钩的话,很简单,只需定义一个 UserServiceHealthIndicator 实现 HealthIndicator 接口即可。
在 health 方法中,我们通过 RestTemplate 来访问这个 user 接口,如果结果正确则返回 Health.up(),并把调用执行耗时和结果作为补充信息加入 Health 对象中。如果调用接口出现异常,则返回 Health.down(),并把异常信息作为补充信息加入 Health 对象中:
@Component
@Slf4j
public class UserServiceHealthIndicator implements HealthIndicator {
@Autowired
private RestTemplate restTemplate;
@Override
public Health health() {
long begin = System.currentTimeMillis();
long userId = 1L;
User user = null;
try {
//访问远程接口
user = restTemplate.getForObject("http://localhost:45678/user?userId=" + userId, User.class);
if (user != null && user.getUserId() == userId) {
//结果正确返回UP状态补充提供耗时和用户信息
return Health.up()
.withDetail("user", user)
.withDetail("took", System.currentTimeMillis() - begin)
.build();
} else {
//结果不正确返回DOWN状态补充提供耗时
return Health.down().withDetail("took", System.currentTimeMillis() - begin).build();
}
} catch (Exception ex) {
//出现异常先记录异常然后返回DOWN状态补充提供异常信息和耗时
log.warn("health check failed!", ex);
return Health.down(ex).withDetail("took", System.currentTimeMillis() - begin).build();
}
}
}
我们再来看一个聚合多个 HealthIndicator 的案例,也就是定义一个 CompositeHealthContributor 来聚合多个 HealthContributor实现一组线程池的监控。
首先,在 ThreadPoolProvider 中定义两个线程池,其中 demoThreadPool 是包含一个工作线程的线程池,类型是 ArrayBlockingQueue阻塞队列的长度为 10还有一个 ioThreadPool 模拟 IO 操作线程池,核心线程数 10最大线程数 50
public class ThreadPoolProvider {
//一个工作线程的线程池队列长度10
private static ThreadPoolExecutor demoThreadPool = new ThreadPoolExecutor(
1, 1,
2, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(10),
new ThreadFactoryBuilder().setNameFormat("demo-threadpool-%d").get());
//核心线程数10最大线程数50的线程池队列长度50
private static ThreadPoolExecutor ioThreadPool = new ThreadPoolExecutor(
10, 50,
2, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(100),
new ThreadFactoryBuilder().setNameFormat("io-threadpool-%d").get());
public static ThreadPoolExecutor getDemoThreadPool() {
return demoThreadPool;
}
public static ThreadPoolExecutor getIOThreadPool() {
return ioThreadPool;
}
}
然后,我们定义一个接口,来把耗时很长的任务提交到这个 demoThreadPool 线程池,以模拟线程池队列满的情况:
@GetMapping("slowTask")
public void slowTask() {
ThreadPoolProvider.getDemoThreadPool().execute(() -> {
try {
TimeUnit.HOURS.sleep(1);
} catch (InterruptedException e) {
}
});
}
做了这些准备工作后,让我们来真正实现自定义的 HealthIndicator 类,用于单一线程池的健康状态。
我们可以传入一个 ThreadPoolExecutor通过判断队列剩余容量来确定这个组件的健康状态有剩余量则返回 UP否则返回 DOWN并把线程池队列的两个重要数据也就是当前队列元素个数和剩余量作为补充信息加入 Health
public class ThreadPoolHealthIndicator implements HealthIndicator {
private ThreadPoolExecutor threadPool;
public ThreadPoolHealthIndicator(ThreadPoolExecutor threadPool) {
this.threadPool = threadPool;
}
@Override
public Health health() {
//补充信息
Map<String, Integer> detail = new HashMap<>();
//队列当前元素个数
detail.put("queue_size", threadPool.getQueue().size());
//队列剩余容量
detail.put("queue_remaining", threadPool.getQueue().remainingCapacity());
//如果还有剩余量则返回UP否则返回DOWN
if (threadPool.getQueue().remainingCapacity() > 0) {
return Health.up().withDetails(detail).build();
} else {
return Health.down().withDetails(detail).build();
}
}
}
再定义一个 CompositeHealthContributor来聚合两个 ThreadPoolHealthIndicator 的实例,分别对应 ThreadPoolProvider 中定义的两个线程池:
@Component
public class ThreadPoolsHealthContributor implements CompositeHealthContributor {
//保存所有的子HealthContributor
private Map<String, HealthContributor> contributors = new HashMap<>();
ThreadPoolsHealthContributor() {
//对应ThreadPoolProvider中定义的两个线程池
this.contributors.put("demoThreadPool", new ThreadPoolHealthIndicator(ThreadPoolProvider.getDemoThreadPool()));
this.contributors.put("ioThreadPool", new ThreadPoolHealthIndicator(ThreadPoolProvider.getIOThreadPool()));
}
@Override
public HealthContributor getContributor(String name) {
//根据name找到某一个HealthContributor
return contributors.get(name);
}
@Override
public Iterator<NamedContributor<HealthContributor>> iterator() {
//返回NamedContributor的迭代器NamedContributor也就是Contributor实例+一个命名
return contributors.entrySet().stream()
.map((entry) -> NamedContributor.of(entry.getKey(), entry.getValue())).iterator();
}
}
程序启动后可以看到health 接口展现了线程池和外部服务 userService 的健康状态,以及一些具体信息:
我们看到一个 demoThreadPool 为 DOWN 导致父 threadPools 为 DOWN进一步导致整个程序的 status 为 DOWN
以上,就是通过自定义 HealthContributor 和 CompositeHealthContributor来实现监控检测触达程序内部诸如三方服务、线程池等关键组件是不是很方便呢
额外补充一下Spring Boot 2.3.0增强了健康检测的功能,细化了 Liveness 和 Readiness 两个端点,便于 Spring Boot 应用程序和 Kubernetes 整合。
对外暴露应用内部重要组件的状态
除了可以把线程池的状态作为整个应用程序是否健康的依据外,我们还可以通过 Actuator 的 InfoContributor 功能,对外暴露程序内部重要组件的状态数据。这里,我会用一个例子演示使用 info 的 HTTP 端点、JMX MBean 这两种方式,如何查看状态数据。
我们看一个具体案例,实现一个 ThreadPoolInfoContributor 来展现线程池的信息。
@Component
public class ThreadPoolInfoContributor implements InfoContributor {
private static Map threadPoolInfo(ThreadPoolExecutor threadPool) {
Map<String, Object> info = new HashMap<>();
info.put("poolSize", threadPool.getPoolSize());//当前池大小
info.put("corePoolSize", threadPool.getCorePoolSize());//设置的核心池大小
info.put("largestPoolSize", threadPool.getLargestPoolSize());//最大达到过的池大小
info.put("maximumPoolSize", threadPool.getMaximumPoolSize());//设置的最大池大小
info.put("completedTaskCount", threadPool.getCompletedTaskCount());//总完成任务数
return info;
}
@Override
public void contribute(Info.Builder builder) {
builder.withDetail("demoThreadPool", threadPoolInfo(ThreadPoolProvider.getDemoThreadPool()));
builder.withDetail("ioThreadPool", threadPoolInfo(ThreadPoolProvider.getIOThreadPool()));
}
}
访问 /admin/info 接口,可以看到这些数据:
此外,如果设置开启 JMX 的话:
spring.jmx.enabled=true
可以通过 jconsole 工具,在 org.springframework.boot.Endpoint 中找到 Info 这个 MBean然后执行 info 操作可以看到,我们刚才自定义的 InfoContributor 输出的有关两个线程池的信息:
这里,我再额外补充一点。对于查看和操作 MBean除了使用 jconsole 之外,你可以使用 jolokia 把 JMX 转换为 HTTP 协议,引入依赖:
<dependency>
<groupId>org.jolokia</groupId>
<artifactId>jolokia-core</artifactId>
</dependency>
然后,你就可以通过 jolokia来执行 org.springframework.boot:type=Endpoint,name=Info 这个 MBean 的 info 操作:
指标 Metrics 是快速定位问题的“金钥匙”
指标是指一组和时间关联的、衡量某个维度能力的量化数值。通过收集指标并展现为曲线图、饼图等图表,可以帮助我们快速定位、分析问题。
我们通过一个实际的案例,来看看如何通过图表快速定位问题。
有一个外卖订单的下单和配送流程如下图所示。OrderController 进行下单操作,下单操作前先判断参数,如果参数正确调用另一个服务查询商户状态,如果商户在营业的话继续下单,下单成功后发一条消息到 RabbitMQ 进行异步配送流程;然后另一个 DeliverOrderHandler 监听这条消息进行配送操作。
对于这样一个涉及同步调用和异步调用的业务流程,如果用户反馈下单失败,那我们如何才能快速知道是哪个环节出了问题呢?
这时,指标体系就可以发挥作用了。我们可以分别为下单和配送这两个重要操作,建立一些指标进行监控。
对于下单操作,可以建立 4 个指标:
下单总数量指标,监控整个系统当前累计的下单量;
下单请求指标,对于每次收到下单请求,在处理之前 +1
下单成功指标,每次下单成功完成 +1
下单失败指标,下单操作处理出现异常 +1并且把异常原因附加到指标上。
对于配送操作,也是建立类似的 4 个指标。我们可以使用 Micrometer 框架实现指标的收集,它也是 Spring Boot Actuator 选用的指标框架。它实现了各种指标的抽象,常用的有三种:
gauge红色它反映的是指标当前的值是多少就是多少不能累计比如本例中的下单总数量指标又比如游戏的在线人数、JVM 当前线程数都可以认为是 gauge。
counter绿色每次调用一次方法值增加 1是可以累计的比如本例中的下单请求指标。举一个例子如果 5 秒内我们调用了 10 次方法Micrometer 也是每隔 5 秒把指标发送给后端存储系统一次,那么它可以只发送一次值,其值为 10。
timer蓝色类似 counter只不过除了记录次数还记录耗时比如本例中的下单成功和下单失败两个指标。
所有的指标还可以附加一些 tags 标签,作为补充数据。比如,当操作执行失败的时候,我们就会附加一个 reason 标签到指标上。
Micrometer 除了抽象了指标外,还抽象了存储。你可以把 Micrometer 理解为类似 SLF4J 这样的框架,只不过后者针对日志抽象,而 Micrometer 是针对指标进行抽象。Micrometer 通过引入各种 registry可以实现无缝对接各种监控系统或时间序列数据库。
在这个案例中,我们引入了 micrometer-registry-influx 依赖,目的是引入 Micrometer 的核心依赖,以及通过 Micrometer 对于InfluxDBInfluxDB 是一个时间序列数据库,其专长是存储指标数据)的绑定,以实现指标数据可以保存到 InfluxDB
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-influx</artifactId>
</dependency>
然后,修改配置文件,启用指标输出到 InfluxDB 的开关、配置 InfluxDB 的地址,以及设置指标每秒在客户端聚合一次,然后发送到 InfluxDB
management.metrics.export.influx.enabled=true
management.metrics.export.influx.uri=http://localhost:8086
management.metrics.export.influx.step=1S
接下来,我们在业务逻辑中增加相关的代码来记录指标。
下面是 OrderController 的实现,代码中有详细注释,我就不一一说明了。你需要注意观察如何通过 Micrometer 框架,来实现下单总数量、下单请求、下单成功和下单失败这四个指标,分别对应代码的第 17、25、43、47 行:
//下单操作,以及商户服务的接口
@Slf4j
@RestController
@RequestMapping("order")
public class OrderController {
//总订单创建数量
private AtomicLong createOrderCounter = new AtomicLong();
@Autowired
private RabbitTemplate rabbitTemplate;
@Autowired
private RestTemplate restTemplate;
@PostConstruct
public void init() {
//注册createOrder.received指标gauge指标只需要像这样初始化一次直接关联到AtomicLong引用即可
Metrics.gauge("createOrder.totalSuccess", createOrderCounter);
}
//下单接口提供用户ID和商户ID作为入参
@GetMapping("createOrder")
public void createOrder(@RequestParam("userId") long userId, @RequestParam("merchantId") long merchantId) {
//记录一次createOrder.received指标这是一个counter指标表示收到下单请求
Metrics.counter("createOrder.received").increment();
Instant begin = Instant.now();
try {
TimeUnit.MILLISECONDS.sleep(200);
//模拟无效用户的情况ID<10为无效用户
if (userId < 10)
throw new RuntimeException("invalid user");
//查询商户服务
Boolean merchantStatus = restTemplate.getForObject("http://localhost:45678/order/getMerchantStatus?merchantId=" + merchantId, Boolean.class);
if (merchantStatus == null || !merchantStatus)
throw new RuntimeException("closed merchant");
Order order = new Order();
order.setId(createOrderCounter.incrementAndGet()); //gauge指标可以得到自动更新
order.setUserId(userId);
order.setMerchantId(merchantId);
//发送MQ消息
rabbitTemplate.convertAndSend(Consts.EXCHANGE, Consts.ROUTING_KEY, order);
//记录一次createOrder.success指标这是一个timer指标表示下单成功同时提供耗时
Metrics.timer("createOrder.success").record(Duration.between(begin, Instant.now()));
} catch (Exception ex) {
log.error("creareOrder userId {} failed", userId, ex);
//记录一次createOrder.failed指标这是一个timer指标表示下单失败同时提供耗时并且以tag记录失败原因
Metrics.timer("createOrder.failed", "reason", ex.getMessage()).record(Duration.between(begin, Instant.now()));
}
}
//商户查询接口
@GetMapping("getMerchantStatus")
public boolean getMerchantStatus(@RequestParam("merchantId") long merchantId) throws InterruptedException {
//只有商户ID为2的商户才是营业的
TimeUnit.MILLISECONDS.sleep(200);
return merchantId == 2;
}
}
当用户 ID
接下来是 DeliverOrderHandler 配送服务的实现
其中deliverOrder 方法监听 OrderController 发出的 MQ 消息模拟配送如下代码所示 172532 36 行代码实现了配送相关四个指标的记录
//配送服务消息处理程序
@RestController
@Slf4j
@RequestMapping("deliver")
public class DeliverOrderHandler {
//配送服务运行状态
private volatile boolean deliverStatus = true;
private AtomicLong deliverCounter = new AtomicLong();
//通过一个外部接口来改变配送状态模拟配送服务停工
@PostMapping("status")
public void status(@RequestParam("status") boolean status) {
deliverStatus = status;
}
@PostConstruct
public void init() {
//同样注册一个gauge指标deliverOrder.totalSuccess代表总的配送单量只需注册一次即可
Metrics.gauge("deliverOrder.totalSuccess", deliverCounter);
}
//监听MQ消息
@RabbitListener(queues = Consts.QUEUE_NAME)
public void deliverOrder(Order order) {
Instant begin = Instant.now();
//对deliverOrder.received进行递增代表收到一次订单消息counter类型
Metrics.counter("deliverOrder.received").increment();
try {
if (!deliverStatus)
throw new RuntimeException("deliver outofservice");
TimeUnit.MILLISECONDS.sleep(500);
deliverCounter.incrementAndGet();
//配送成功指标deliverOrder.successtimer类型
Metrics.timer("deliverOrder.success").record(Duration.between(begin, Instant.now()));
} catch (Exception ex) {
log.error("deliver Order {} failed", order, ex);
//配送失败指标deliverOrder.failed同样附加了失败原因作为tagstimer类型
Metrics.timer("deliverOrder.failed", "reason", ex.getMessage()).record(Duration.between(begin, Instant.now()));
}
}
}
同时我们模拟了一个配送服务整体状态的开关调用 status 接口可以修改其状态至此我们完成了场景准备接下来开始配置指标监控
首先我们来安装 Grafana然后进入 Grafana 配置一个 InfluxDB 数据源
配置好数据源之后就可以添加一个监控面板然后在面板中添加各种监控图表比如我们在一个下单次数图表中添加了下单收到成功和失败三个指标
关于这张图中的配置
红色框数据源配置选择刚才配置的数据源
蓝色框 FROM 配置选择我们的指标名
绿色框 SELECT 配置选择我们要查询的指标字段也可以应用一些聚合函数在这里我们取 count 字段的值然后使用 sum 函数进行求和
紫色框 GROUP BY 配置我们配置了按 1 分钟时间粒度和 reason 字段进行分组这样指标的 Y 轴代表 QPM每分钟请求数且每种失败的情况都会绘制单独的曲线
黄色框 ALIAS BY 配置中设置了每一个指标的别名在别名中引用了 reason 这个 tag
使用 Grafana 配置 InfluxDB 指标的详细方式你可以参考这里其中的 FROMSELECTGROUP BY 的含义和 SQL 类似理解起来应该不困难
类似地 我们配置出一个完整的业务监控面板包含之前实现的 8 个指标
配置 2 Gauge 图表分别呈现总订单完成次数总配送完成次数
配置 4 Graph 图表分别呈现下单操作的次数和性能以及配送操作的次数和性能
下面我们进入实战使用 wrk 针对四种情况进行压测然后通过曲线来分析定位问题
第一种情况是使用合法的用户 ID 和营业的商户 ID 运行一段时间
wrk -t 1 -c 1 -d 3600s http://localhost:45678/order/createOrder\?userId\=20\&merchantId\=2
从监控面板可以一目了然地看到整个系统的运作情况可以看到目前系统运行良好不管是下单还是配送操作都是成功的且下单操作平均处理时间 400ms配送操作则是在 500ms 左右符合预期注意下单次数曲线中的绿色和黄色两条曲线其实是重叠在一起的表示所有下单都成功了
第二种情况是模拟无效用户 ID 运行一段时间
wrk -t 1 -c 1 -d 3600s http://localhost:45678/order/createOrder\?userId\=2\&merchantId\=2
使用无效用户下单显然会导致下单全部失败接下来我们就看看从监控图中是否能看到这个现象
绿色框可以看到下单现在出现了 invalid user 这条蓝色的曲线并和绿色收到下单请求的曲线是吻合的表示所有下单都失败了原因是无效用户错误说明源头并没有问题
红色框可以看到虽然下单都是失败的但是下单操作时间从 400ms 减少为 200ms 说明下单失败之前也消耗了 200ms和代码符合)。而因为下单失败操作的响应时间减半了反而导致吞吐翻倍了
观察两个配送监控可以发现配送曲线出现掉 0 现象是因为下单失败导致的下单失败 MQ 消息压根就不会发出再注意下蓝色那条线可以看到配送曲线掉 0 延后于下单成功曲线的掉 0原因是配送走的是异步流程虽然从某个时刻开始下单全部失败了但是 MQ 队列中还有一些之前未处理的消息
第三种情况是尝试一下因为商户不营业导致的下单失败
wrk -t 1 -c 1 -d 3600s http://localhost:45678/order/createOrder\?userId\=20\&merchantId\=1
我把变化的地方圈了出来你可以自己尝试分析一下
第四种情况是配送停止我们通过 curl 调用接口来设置配送停止开关
curl -X POST 'http://localhost:45678/deliver/status?status=false'
从监控可以看到从开关关闭那刻开始所有的配送消息全部处理失败了原因是 deliver outofservice配送操作性能从 500ms 左右到了 0ms说明配送失败是一个本地快速失败并不是因为服务超时等导致的失败而且虽然配送失败但下单操作都是正常的
最后希望说的是除了手动添加业务监控指标外Micrometer 框架还帮我们自动做了很多有关 JVM 内部各种数据的指标进入 InfluxDB 命令行客户端你可以看到下面的这些表指标其中前 8 个是我们自己建的业务指标后面都是框架帮我们建的 JVM各种组件状态的指标
\> USE mydb
Using database mydb
\> SHOW MEASUREMENTS
name: measurements
name
\----
createOrder_failed
createOrder_received
createOrder_success
createOrder_totalSuccess
deliverOrder_failed
deliverOrder_received
deliverOrder_success
deliverOrder_totalSuccess
hikaricp_connections
hikaricp_connections_acquire
hikaricp_connections_active
hikaricp_connections_creation
hikaricp_connections_idle
hikaricp_connections_max
hikaricp_connections_min
hikaricp_connections_pending
hikaricp_connections_timeout
hikaricp_connections_usage
http_server_requests
jdbc_connections_max
jdbc_connections_min
jvm_buffer_count
jvm_buffer_memory_used
jvm_buffer_total_capacity
jvm_classes_loaded
jvm_classes_unloaded
jvm_gc_live_data_size
jvm_gc_max_data_size
jvm_gc_memory_allocated
jvm_gc_memory_promoted
jvm_gc_pause
jvm_memory_committed
jvm_memory_max
jvm_memory_used
jvm_threads_daemon
jvm_threads_live
jvm_threads_peak
jvm_threads_states
logback_events
process_cpu_usage
process_files_max
process_files_open
process_start_time
process_uptime
rabbitmq_acknowledged
rabbitmq_acknowledged_published
rabbitmq_channels
rabbitmq_connections
rabbitmq_consumed
rabbitmq_failed_to_publish
rabbitmq_not_acknowledged_published
rabbitmq_published
rabbitmq_rejected
rabbitmq_unrouted_published
spring_rabbitmq_listener
system_cpu_count
system_cpu_usage
system_load_average_1m
tomcat_sessions_active_current
tomcat_sessions_active_max
tomcat_sessions_alive_max
tomcat_sessions_created
tomcat_sessions_expired
tomcat_sessions_rejected
我们可以按照自己的需求选取其中的一些指标 Grafana 中配置应用监控面板
看到这里通过监控图表来定位问题是不是比日志方便了很多呢
重点回顾
今天我和你介绍了如何使用 Spring Boot Actuaor 实现生产就绪的几个关键点包括健康检测暴露应用信息和指标监控
所谓磨刀不误砍柴工健康检测可以帮我们实现负载均衡的联动应用信息以及 Actuaor 提供的各种端点可以帮我们查看应用内部情况甚至对应用的一些参数进行调整而指标监控则有助于我们整体观察应用运行情况帮助我们快速发现和定位问题
其实完整的应用监控体系一般由三个方面构成包括日志 Logging指标 Metrics 和追踪 Tracing其中日志和指标我相信你应该已经比较清楚了追踪一般不涉及开发工作就没有展开阐述我和你简单介绍一下
追踪也叫做全链路追踪比较有代表性的开源系统是SkyWalking和Pinpoint一般而言接入此类系统无需额外开发使用其提供的 javaagent 来启动 Java 程序就可以通过动态修改字节码实现各种组件的改写以加入追踪代码类似 AOP)。
全链路追踪的原理是
请求进入第一个组件时先生成一个 TraceID作为整个调用链Trace的唯一标识
对于每次操作都记录耗时和相关信息形成一个 Span 挂载到调用链上Span Span 之间同样可以形成树状关联出现远程调用跨系统调用的时候 TraceID 进行透传比如HTTP 调用通过请求透传MQ 消息则通过消息透传
把这些数据汇总提交到数据库中通过一个 UI 界面查询整个树状调用链
同时我们一般会把 TraceID 记录到日志中方便实现日志和追踪的关联
我用一张图对比了日志指标和追踪的区别和特点
在我看来完善的监控体系三者缺一不可它们还可以相互配合比如通过指标发现性能问题通过追踪定位性能问题所在的应用和操作最后通过日志定位出具体请求的明细参数
今天用到的代码我都放在了 GitHub 你可以点击这个链接查看
思考与讨论
Spring Boot Actuator 提供了大量内置端点你觉得端点和自定义一个 @RestController 有什么区别呢你能否根据官方文档开发一个自定义端点呢
在介绍指标 Metrics 时我们看到InfluxDB 中保存了由 Micrometer 框架自动帮我们收集的一些应用指标你能否参考源码中两个 Grafana 配置的 JSON 文件把这些指标在 Grafana 中配置出一个完整的应用监控面板呢
应用投产之前你还会做哪些生产就绪方面的工作呢我是朱晔欢迎在评论区与我留言分享你的想法也欢迎你把今天的内容分享给你的朋友或同事一起交流

View File

@@ -0,0 +1,918 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
25 异步处理好用,但非常容易用错
今天,我来和你聊聊好用但容易出错的异步处理。
异步处理是互联网应用不可或缺的一种架构模式,大多数业务项目都是由同步处理、异步处理和定时任务处理三种模式相辅相成实现的。
区别于同步处理,异步处理无需同步等待流程处理完毕,因此适用场景主要包括:
服务于主流程的分支流程。比如,在注册流程中,把数据写入数据库的操作是主流程,但注册后给用户发优惠券或欢迎短信的操作是分支流程,时效性不那么强,可以进行异步处理。
用户不需要实时看到结果的流程。比如,下单后的配货、送货流程完全可以进行异步处理,每个阶段处理完成后,再给用户发推送或短信让用户知晓即可。
同时,异步处理因为可以有 MQ 中间件的介入用于任务的缓冲的分发,所以相比于同步处理,在应对流量洪峰、实现模块解耦和消息广播方面有功能优势。
不过,异步处理虽然好用,但在实现的时候却有三个最容易犯的错,分别是异步处理流程的可靠性问题、消息发送模式的区分问题,以及大量死信消息堵塞队列的问题。今天,我就用三个代码案例结合目前常用的 MQ 系统 RabbitMQ来和你具体聊聊。
今天这一讲的演示,我都会使用 Spring AMQP 来操作 RabbitMQ所以你需要先引入 amqp 依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
异步处理需要消息补偿闭环
使用类似 RabbitMQ、RocketMQ 等 MQ 系统来做消息队列实现异步处理,虽然说消息可以落地到磁盘保存,即使 MQ 出现问题消息数据也不会丢失,但是异步流程在消息发送、传输、处理等环节,都可能发生消息丢失。此外,任何 MQ 中间件都无法确保 100% 可用,需要考虑不可用时异步流程如何继续进行。
因此,对于异步处理流程,必须考虑补偿或者说建立主备双活流程。
我们来看一个用户注册后异步发送欢迎消息的场景。用户注册落数据库的流程为同步流程,会员服务收到消息后发送欢迎消息的流程为异步流程。
我们来分析一下:
蓝色的线,使用 MQ 进行的异步处理,我们称作主线,可能存在消息丢失的情况(虚线代表异步调用);
绿色的线,使用补偿 Job 定期进行消息补偿,我们称作备线,用来补偿主线丢失的消息;
考虑到极端的 MQ 中间件失效的情况,我们要求备线的处理吞吐能力达到主线的能力水平。
我们来看一下相关的实现代码。
首先,定义 UserController 用于注册 + 发送异步消息。对于注册方法,我们一次性注册 10 个用户,用户注册消息不能发送出去的概率为 50%。
@RestController
@Slf4j
@RequestMapping("user")
public class UserController {
@Autowired
private UserService userService;
@Autowired
private RabbitTemplate rabbitTemplate;
@GetMapping("register")
public void register() {
//模拟10个用户注册
IntStream.rangeClosed(1, 10).forEach(i -> {
//落库
User user = userService.register();
//模拟50%的消息可能发送失败
if (ThreadLocalRandom.current().nextInt(10) % 2 == 0) {
//通过RabbitMQ发送消息
rabbitTemplate.convertAndSend(RabbitConfiguration.EXCHANGE, RabbitConfiguration.ROUTING_KEY, user);
log.info("sent mq user {}", user.getId());
}
});
}
}
然后,定义 MemberService 类用于模拟会员服务。会员服务监听用户注册成功的消息,并发送欢迎短信。我们使用 ConcurrentHashMap 来存放那些发过短信的用户 ID 实现幂等,避免相同的用户进行补偿时重复发送短信:
@Component
@Slf4j
public class MemberService {
//发送欢迎消息的状态
private Map<Long, Boolean> welcomeStatus = new ConcurrentHashMap<>();
//监听用户注册成功的消息,发送欢迎消息
@RabbitListener(queues = RabbitConfiguration.QUEUE)
public void listen(User user) {
log.info("receive mq user {}", user.getId());
welcome(user);
}
//发送欢迎消息
public void welcome(User user) {
//去重操作
if (welcomeStatus.putIfAbsent(user.getId(), true) == null) {
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
}
log.info("memberService: welcome new user {}", user.getId());
}
}
}
对于 MQ 消费程序,处理逻辑务必考虑去重(支持幂等),原因有几个:
MQ 消息可能会因为中间件本身配置错误、稳定性等原因出现重复。
自动补偿重复,比如本例,同一条消息可能既走 MQ 也走补偿,肯定会出现重复,而且考虑到高内聚,补偿 Job 本身不会做去重处理。
人工补偿重复。出现消息堆积时,异步处理流程必然会延迟。如果我们提供了通过后台进行补偿的功能,那么在处理遇到延迟的时候,很可能会先进行人工补偿,过了一段时间后处理程序又收到消息了,重复处理。我之前就遇到过一次由 MQ 故障引发的事故MQ 中堆积了几十万条发放资金的消息,导致业务无法及时处理,运营以为程序出错了就先通过后台进行了人工处理,结果 MQ 系统恢复后消息又被重复处理了一次,造成大量资金重复发放。
接下来,定义补偿 Job 也就是备线操作。
我们在 CompensationJob 中定义一个 @Scheduled 定时任务5 秒做一次补偿操作,因为 Job 并不知道哪些用户注册的消息可能丢失,所以是全量补偿,补偿逻辑是:每 5 秒补偿一次,按顺序一次补偿 5 个用户,下一次补偿操作从上一次补偿的最后一个用户 ID 开始;对于补偿任务我们提交到线程池进行“异步”处理,提高处理能力。
@Component
@Slf4j
public class CompensationJob {
//补偿Job异步处理线程池
private static ThreadPoolExecutor compensationThreadPool = new ThreadPoolExecutor(
10, 10,
1, TimeUnit.HOURS,
new ArrayBlockingQueue<>(1000),
new ThreadFactoryBuilder().setNameFormat("compensation-threadpool-%d").get());
@Autowired
private UserService userService;
@Autowired
private MemberService memberService;
//目前补偿到哪个用户ID
private long offset = 0;
//10秒后开始补偿5秒补偿一次
@Scheduled(initialDelay = 10_000, fixedRate = 5_000)
public void compensationJob() {
log.info("开始从用户ID {} 补偿", offset);
//获取从offset开始的用户
userService.getUsersAfterIdWithLimit(offset, 5).forEach(user -> {
compensationThreadPool.execute(() -> memberService.welcome(user));
offset = user.getId();
});
}
}
为了实现高内聚,主线和备线处理消息,最好使用同一个方法。比如,本例中 MemberService 监听到 MQ 消息和 CompensationJob 补偿,调用的都是 welcome 方法。
此外值得一说的是Demo 中的补偿逻辑比较简单,生产级的代码应该在以下几个方面进行加强:
考虑配置补偿的频次、每次处理数量,以及补偿线程池大小等参数为合适的值,以满足补偿的吞吐量。
考虑备线补偿数据进行适当延迟。比如,对注册时间在 30 秒之前的用户再进行补偿,以方便和主线 MQ 实时流程错开,避免冲突。
诸如当前补偿到哪个用户的 offset 数据,需要落地数据库。
补偿 Job 本身需要高可用,可以使用类似 XXLJob 或 ElasticJob 等任务系统。
运行程序,执行注册方法注册 10 个用户,输出如下:
[17:01:16.570] [http-nio-45678-exec-1] [INFO ] [o.g.t.c.a.compensation.UserController:28 ] - sent mq user 1
[17:01:16.571] [http-nio-45678-exec-1] [INFO ] [o.g.t.c.a.compensation.UserController:28 ] - sent mq user 5
[17:01:16.572] [http-nio-45678-exec-1] [INFO ] [o.g.t.c.a.compensation.UserController:28 ] - sent mq user 7
[17:01:16.573] [http-nio-45678-exec-1] [INFO ] [o.g.t.c.a.compensation.UserController:28 ] - sent mq user 8
[17:01:16.594] [org.springframework.amqp.rabbit.RabbitListenerEndpointContainer#0-1] [INFO ] [o.g.t.c.a.compensation.MemberService:18 ] - receive mq user 1
[17:01:18.597] [org.springframework.amqp.rabbit.RabbitListenerEndpointContainer#0-1] [INFO ] [o.g.t.c.a.compensation.MemberService:28 ] - memberService: welcome new user 1
[17:01:18.601] [org.springframework.amqp.rabbit.RabbitListenerEndpointContainer#0-1] [INFO ] [o.g.t.c.a.compensation.MemberService:18 ] - receive mq user 5
[17:01:20.603] [org.springframework.amqp.rabbit.RabbitListenerEndpointContainer#0-1] [INFO ] [o.g.t.c.a.compensation.MemberService:28 ] - memberService: welcome new user 5
[17:01:20.604] [org.springframework.amqp.rabbit.RabbitListenerEndpointContainer#0-1] [INFO ] [o.g.t.c.a.compensation.MemberService:18 ] - receive mq user 7
[17:01:22.605] [org.springframework.amqp.rabbit.RabbitListenerEndpointContainer#0-1] [INFO ] [o.g.t.c.a.compensation.MemberService:28 ] - memberService: welcome new user 7
[17:01:22.606] [org.springframework.amqp.rabbit.RabbitListenerEndpointContainer#0-1] [INFO ] [o.g.t.c.a.compensation.MemberService:18 ] - receive mq user 8
[17:01:24.611] [org.springframework.amqp.rabbit.RabbitListenerEndpointContainer#0-1] [INFO ] [o.g.t.c.a.compensation.MemberService:28 ] - memberService: welcome new user 8
[17:01:25.498] [scheduling-1] [INFO ] [o.g.t.c.a.compensation.CompensationJob:29 ] - 开始从用户ID 0 补偿
[17:01:27.510] [compensation-threadpool-1] [INFO ] [o.g.t.c.a.compensation.MemberService:28 ] - memberService: welcome new user 2
[17:01:27.510] [compensation-threadpool-3] [INFO ] [o.g.t.c.a.compensation.MemberService:28 ] - memberService: welcome new user 4
[17:01:27.511] [compensation-threadpool-2] [INFO ] [o.g.t.c.a.compensation.MemberService:28 ] - memberService: welcome new user 3
[17:01:30.496] [scheduling-1] [INFO ] [o.g.t.c.a.compensation.CompensationJob:29 ] - 开始从用户ID 5 补偿
[17:01:32.500] [compensation-threadpool-6] [INFO ] [o.g.t.c.a.compensation.MemberService:28 ] - memberService: welcome new user 6
[17:01:32.500] [compensation-threadpool-9] [INFO ] [o.g.t.c.a.compensation.MemberService:28 ] - memberService: welcome new user 9
[17:01:35.496] [scheduling-1] [INFO ] [o.g.t.c.a.compensation.CompensationJob:29 ] - 开始从用户ID 9 补偿
[17:01:37.501] [compensation-threadpool-0] [INFO ] [o.g.t.c.a.compensation.MemberService:28 ] - memberService: welcome new user 10
[17:01:40.495] [scheduling-1] [INFO ] [o.g.t.c.a.compensation.CompensationJob:29 ] - 开始从用户ID 10 补偿
可以看到:
总共 10 个用户MQ 发送成功的用户有四个,分别是用户 1、5、7、8。
补偿任务第一次运行,补偿了用户 2、3、4第二次运行补偿了用户 6、9第三次运行补充了用户 10。
最后提一下,针对消息的补偿闭环处理的最高标准是,能够达到补偿全量数据的吞吐量。也就是说,如果补偿备线足够完善,即使直接把 MQ 停机,虽然会略微影响处理的及时性,但至少确保流程都能正常执行。
注意消息模式是广播还是工作队列
在今天这一讲的一开始,我们提到异步处理的一个重要优势,是实现消息广播。
消息广播,和我们平时说的“广播”意思差不多,就是希望同一条消息,不同消费者都能分别消费;而队列模式,就是不同消费者共享消费同一个队列的数据,相同消息只能被某一个消费者消费一次。
比如,同一个用户的注册消息,会员服务需要监听以发送欢迎短信,营销服务同样需要监听以发送新用户小礼物。但是,会员服务、营销服务都可能有多个实例,我们期望的是同一个用户的消息,可以同时广播给不同的服务(广播模式),但对于同一个服务的不同实例(比如会员服务 1 和会员服务 2不管哪个实例来处理处理一次即可工作队列模式
在实现代码的时候,我们务必确认 MQ 系统的机制,确保消息的路由按照我们的期望。
对于类似 RocketMQ 这样的 MQ 来说,实现类似功能比较简单直白:如果消费者属于一个组,那么消息只会由同一个组的一个消费者来消费;如果消费者属于不同组,那么每个组都能消费一遍消息。
而对于 RabbitMQ 来说,消息路由的模式采用的是队列 + 交换器,队列是消息的载体,交换器决定了消息路由到队列的方式,配置比较复杂,容易出错。所以,接下来我重点和你讲讲 RabbitMQ 的相关代码实现。
我们还是以上面的架构图为例,来演示使用 RabbitMQ 实现广播模式和工作队列模式的坑。
第一步,实现会员服务监听用户服务发出的新用户注册消息的那部分逻辑。
如果我们启动两个会员服务,那么同一个用户的注册消息应该只能被其中一个实例消费。
我们分别实现 RabbitMQ 队列、交换器、绑定三件套。其中,队列用的是匿名队列,交换器用的是直接交换器 DirectExchange交换器绑定到匿名队列的路由 Key 是空字符串。在收到消息之后,我们会打印所在实例使用的端口:
//为了代码简洁直观我们把消息发布者、消费者、以及MQ的配置代码都放在了一起
@Slf4j
@Configuration
@RestController
@RequestMapping("workqueuewrong")
public class WorkQueueWrong {
private static final String EXCHANGE = "newuserExchange";
@Autowired
private RabbitTemplate rabbitTemplate;
@GetMapping
public void sendMessage() {
rabbitTemplate.convertAndSend(EXCHANGE, "", UUID.randomUUID().toString());
}
//使用匿名队列作为消息队列
@Bean
public Queue queue() {
return new AnonymousQueue();
}
//声明DirectExchange交换器绑定队列到交换器
@Bean
public Declarables declarables() {
DirectExchange exchange = new DirectExchange(EXCHANGE);
return new Declarables(queue(), exchange,
BindingBuilder.bind(queue()).to(exchange).with(""));
}
//监听队列队列名称直接通过SpEL表达式引用Bean
@RabbitListener(queues = "#{queue.name}")
public void memberService(String userName) {
log.info("memberService: welcome message sent to new user {} from {}", userName, System.getProperty("server.port"));
}
}
使用 12345 和 45678 两个端口启动两个程序实例后,调用 sendMessage 接口发送一条消息,输出的日志,显示同一个会员服务两个实例都收到了消息:
出现这个问题的原因是,我们没有理清楚 RabbitMQ 直接交换器和队列的绑定关系。
如下图所示RabbitMQ 的直接交换器根据 routingKey 对消息进行路由。由于我们的程序每次启动都会创建匿名(随机命名)的队列,所以相当于每一个会员服务实例都对应独立的队列,以空 routingKey 绑定到直接交换器。用户服务发出消息的时候也设置了 routingKey 为空,所以直接交换器收到消息之后,发现有两条队列匹配,于是都转发了消息:
要修复这个问题其实很简单,对于会员服务不要使用匿名队列,而是使用同一个队列即可。把上面代码中的匿名队列替换为一个普通队列:
private static final String QUEUE = "newuserQueue";
@Bean
public Queue queue() {
return new Queue(QUEUE);
}
测试发现,对于同一条消息来说,两个实例中只有一个实例可以收到,不同的消息按照轮询分发给不同的实例。现在,交换器和队列的关系是这样的:
第二步,进一步完整实现用户服务需要广播消息给会员服务和营销服务的逻辑。
我们希望会员服务和营销服务都可以收到广播消息,但会员服务或营销服务中的每个实例只需要收到一次消息。
代码如下,我们声明了一个队列和一个广播交换器 FanoutExchange然后模拟两个用户服务和两个营销服务
@Slf4j
@Configuration
@RestController
@RequestMapping("fanoutwrong")
public class FanoutQueueWrong {
private static final String QUEUE = "newuser";
private static final String EXCHANGE = "newuser";
@Autowired
private RabbitTemplate rabbitTemplate;
@GetMapping
public void sendMessage() {
rabbitTemplate.convertAndSend(EXCHANGE, "", UUID.randomUUID().toString());
}
//声明FanoutExchange然后绑定到队列FanoutExchange绑定队列的时候不需要routingKey
@Bean
public Declarables declarables() {
Queue queue = new Queue(QUEUE);
FanoutExchange exchange = new FanoutExchange(EXCHANGE);
return new Declarables(queue, exchange,
BindingBuilder.bind(queue).to(exchange));
}
//会员服务实例1
@RabbitListener(queues = QUEUE)
public void memberService1(String userName) {
log.info("memberService1: welcome message sent to new user {}", userName);
}
//会员服务实例2
@RabbitListener(queues = QUEUE)
public void memberService2(String userName) {
log.info("memberService2: welcome message sent to new user {}", userName);
}
//营销服务实例1
@RabbitListener(queues = QUEUE)
public void promotionService1(String userName) {
log.info("promotionService1: gift sent to new user {}", userName);
}
//营销服务实例2
@RabbitListener(queues = QUEUE)
public void promotionService2(String userName) {
log.info("promotionService2: gift sent to new user {}", userName);
}
}
我们请求四次 sendMessage 接口,注册四个用户。通过日志可以发现,一条用户注册的消息,要么被会员服务收到,要么被营销服务收到,显然这不是广播。那,我们使用的 FanoutExchange看名字就应该是实现广播的交换器为什么根本没有起作用呢
其实,广播交换器非常简单,它会忽略 routingKey广播消息到所有绑定的队列。在这个案例中两个会员服务和两个营销服务都绑定了同一个队列所以这四个服务只能收到一次消息
修改方式很简单,我们把队列进行拆分,会员和营销两组服务分别使用一条独立队列绑定到广播交换器即可:
@Slf4j
@Configuration
@RestController
@RequestMapping("fanoutright")
public class FanoutQueueRight {
private static final String MEMBER_QUEUE = "newusermember";
private static final String PROMOTION_QUEUE = "newuserpromotion";
private static final String EXCHANGE = "newuser";
@Autowired
private RabbitTemplate rabbitTemplate;
@GetMapping
public void sendMessage() {
rabbitTemplate.convertAndSend(EXCHANGE, "", UUID.randomUUID().toString());
}
@Bean
public Declarables declarables() {
//会员服务队列
Queue memberQueue = new Queue(MEMBER_QUEUE);
//营销服务队列
Queue promotionQueue = new Queue(PROMOTION_QUEUE);
//广播交换器
FanoutExchange exchange = new FanoutExchange(EXCHANGE);
//两个队列绑定到同一个交换器
return new Declarables(memberQueue, promotionQueue, exchange,
BindingBuilder.bind(memberQueue).to(exchange),
BindingBuilder.bind(promotionQueue).to(exchange));
}
@RabbitListener(queues = MEMBER_QUEUE)
public void memberService1(String userName) {
log.info("memberService1: welcome message sent to new user {}", userName);
}
@RabbitListener(queues = MEMBER_QUEUE)
public void memberService2(String userName) {
log.info("memberService2: welcome message sent to new user {}", userName);
}
@RabbitListener(queues = PROMOTION_QUEUE)
public void promotionService1(String userName) {
log.info("promotionService1: gift sent to new user {}", userName);
}
@RabbitListener(queues = PROMOTION_QUEUE)
public void promotionService2(String userName) {
log.info("promotionService2: gift sent to new user {}", userName);
}
}
现在,交换器和队列的结构是这样的:
从日志输出可以验证,对于每一条 MQ 消息,会员服务和营销服务分别都会收到一次,一条消息广播到两个服务的同时,在每一个服务的两个实例中通过轮询接收:
所以说,理解了 RabbitMQ 直接交换器、广播交换器的工作方式之后,我们对消息的路由方式了解得很清晰了,实现代码就不会出错。
对于异步流程来说,消息路由模式一旦配置出错,轻则可能导致消息的重复处理,重则可能导致重要的服务无法接收到消息,最终造成业务逻辑错误。
每个 MQ 中间件对消息的路由处理的配置各不相同,我们一定要先了解原理再着手编码。
别让死信堵塞了消息队列
我们在介绍线程池的时候提到,如果线程池的任务队列没有上限,那么最终可能会导致 OOM。使用消息队列处理异步流程的时候我们也同样要注意消息队列的任务堆积问题。对于突发流量引起的消息队列堆积问题并不大适当调整消费者的消费能力应该就可以解决。但在很多时候消息队列的堆积堵塞是因为有大量始终无法处理的消息。
比如,用户服务在用户注册后发出一条消息,会员服务监听到消息后给用户派发优惠券,但因为用户并没有保存成功,会员服务处理消息始终失败,消息重新进入队列,然后还是处理失败。这种在 MQ 中像幽灵一样回荡的同一条消息,就是死信。
随着 MQ 被越来越多的死信填满,消费者需要花费大量时间反复处理死信,导致正常消息的消费受阻,最终 MQ 可能因为数据量过大而崩溃。
我们来测试一下这个场景。首先,定义一个队列、一个直接交换器,然后把队列绑定到交换器:
@Bean
public Declarables declarables() {
//队列
Queue queue = new Queue(Consts.QUEUE);
//交换器
DirectExchange directExchange = new DirectExchange(Consts.EXCHANGE);
//快速声明一组对象,包含队列、交换器,以及队列到交换器的绑定
return new Declarables(queue, directExchange,
BindingBuilder.bind(queue).to(directExchange).with(Consts.ROUTING_KEY));
}
然后,实现一个 sendMessage 方法来发送消息到 MQ访问一次提交一条消息使用自增标识作为消息内容
//自增消息标识
AtomicLong atomicLong = new AtomicLong();
@Autowired
private RabbitTemplate rabbitTemplate;
@GetMapping("sendMessage")
public void sendMessage() {
String msg = "msg" + atomicLong.incrementAndGet();
log.info("send message {}", msg);
//发送消息
rabbitTemplate.convertAndSend(Consts.EXCHANGE, msg);
}
收到消息后,直接抛出空指针异常,模拟处理出错的情况:
@RabbitListener(queues = Consts.QUEUE)
public void handler(String data) {
log.info("got message {}", data);
throw new NullPointerException("error");
}
调用 sendMessage 接口发送两条消息,然后来到 RabbitMQ 管理台,可以看到这两条消息始终在队列中,不断被重新投递,导致重新投递 QPS 达到了 1063。
同时,在日志中可以看到大量异常信息:
[20:02:31.533] [org.springframework.amqp.rabbit.RabbitListenerEndpointContainer#0-1] [WARN ] [o.s.a.r.l.ConditionalRejectingErrorHandler:129 ] - Execution of Rabbit message listener failed.
org.springframework.amqp.rabbit.support.ListenerExecutionFailedException: Listener method 'public void org.geekbang.time.commonmistakes.asyncprocess.deadletter.MQListener.handler(java.lang.String)' threw exception
at org.springframework.amqp.rabbit.listener.adapter.MessagingMessageListenerAdapter.invokeHandler(MessagingMessageListenerAdapter.java:219)
at org.springframework.amqp.rabbit.listener.adapter.MessagingMessageListenerAdapter.invokeHandlerAndProcessResult(MessagingMessageListenerAdapter.java:143)
at org.springframework.amqp.rabbit.listener.adapter.MessagingMessageListenerAdapter.onMessage(MessagingMessageListenerAdapter.java:132)
at org.springframework.amqp.rabbit.listener.AbstractMessageListenerContainer.doInvokeListener(AbstractMessageListenerContainer.java:1569)
at org.springframework.amqp.rabbit.listener.AbstractMessageListenerContainer.actualInvokeListener(AbstractMessageListenerContainer.java:1488)
at org.springframework.amqp.rabbit.listener.AbstractMessageListenerContainer.invokeListener(AbstractMessageListenerContainer.java:1476)
at org.springframework.amqp.rabbit.listener.AbstractMessageListenerContainer.doExecuteListener(AbstractMessageListenerContainer.java:1467)
at org.springframework.amqp.rabbit.listener.AbstractMessageListenerContainer.executeListener(AbstractMessageListenerContainer.java:1411)
at org.springframework.amqp.rabbit.listener.SimpleMessageListenerContainer.doReceiveAndExecute(SimpleMessageListenerContainer.java:958)
at org.springframework.amqp.rabbit.listener.SimpleMessageListenerContainer.receiveAndExecute(SimpleMessageListenerContainer.java:908)
at org.springframework.amqp.rabbit.listener.SimpleMessageListenerContainer.access$1600(SimpleMessageListenerContainer.java:81)
at org.springframework.amqp.rabbit.listener.SimpleMessageListenerContainer$AsyncMessageProcessingConsumer.mainLoop(SimpleMessageListenerContainer.java:1279)
at org.springframework.amqp.rabbit.listener.SimpleMessageListenerContainer$AsyncMessageProcessingConsumer.run(SimpleMessageListenerContainer.java:1185)
at java.lang.Thread.run(Thread.java:748)
Caused by: java.lang.NullPointerException: error
at org.geekbang.time.commonmistakes.asyncprocess.deadletter.MQListener.handler(MQListener.java:14)
at sun.reflect.GeneratedMethodAccessor46.invoke(Unknown Source)
at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
at java.lang.reflect.Method.invoke(Method.java:498)
at org.springframework.messaging.handler.invocation.InvocableHandlerMethod.doInvoke(InvocableHandlerMethod.java:171)
at org.springframework.messaging.handler.invocation.InvocableHandlerMethod.invoke(InvocableHandlerMethod.java:120)
at org.springframework.amqp.rabbit.listener.adapter.HandlerAdapter.invoke(HandlerAdapter.java:50)
at org.springframework.amqp.rabbit.listener.adapter.MessagingMessageListenerAdapter.invokeHandler(MessagingMessageListenerAdapter.java:211)
... 13 common frames omitted
解决死信无限重复进入队列最简单的方式是,在程序处理出错的时候,直接抛出 AmqpRejectAndDontRequeueException 异常,避免消息重新进入队列:
throw new AmqpRejectAndDontRequeueException("error");
但,我们更希望的逻辑是,对于同一条消息,能够先进行几次重试,解决因为网络问题导致的偶发消息处理失败,如果还是不行的话,再把消息投递到专门的一个死信队列。对于来自死信队列的数据,我们可能只是记录日志发送报警,即使出现异常也不会再重复投递。整个逻辑如下图所示:
针对这个问题Spring AMQP 提供了非常方便的解决方案:
首先,定义死信交换器和死信队列。其实,这些都是普通的交换器和队列,只不过被我们专门用于处理死信消息。
然后,通过 RetryInterceptorBuilder 构建一个 RetryOperationsInterceptor用于处理失败时候的重试。这里的策略是最多尝试 5 次(重试 4 次);并且采取指数退避重试,首次重试延迟 1 秒,第二次 2 秒,以此类推,最大延迟是 10 秒;如果第 4 次重试还是失败,则使用 RepublishMessageRecoverer 把消息重新投入一个“死信交换器”中。
最后,定义死信队列的处理程序。这个案例中,我们只是简单记录日志。
对应的实现代码如下:
//定义死信交换器和队列,并且进行绑定
@Bean
public Declarables declarablesForDead() {
Queue queue = new Queue(Consts.DEAD_QUEUE);
DirectExchange directExchange = new DirectExchange(Consts.DEAD_EXCHANGE);
return new Declarables(queue, directExchange,
BindingBuilder.bind(queue).to(directExchange).with(Consts.DEAD_ROUTING_KEY));
}
//定义重试操作拦截器
@Bean
public RetryOperationsInterceptor interceptor() {
return RetryInterceptorBuilder.stateless()
.maxAttempts(5) //最多尝试不是重试5次
.backOffOptions(1000, 2.0, 10000) //指数退避重试
.recoverer(new RepublishMessageRecoverer(rabbitTemplate, Consts.DEAD_EXCHANGE, Consts.DEAD_ROUTING_KEY)) //重新投递重试达到上限的消息
.build();
}
//通过定义SimpleRabbitListenerContainerFactory设置其adviceChain属性为之前定义的RetryOperationsInterceptor来启用重试拦截器
@Bean
public SimpleRabbitListenerContainerFactory rabbitListenerContainerFactory(ConnectionFactory connectionFactory) {
SimpleRabbitListenerContainerFactory factory = new SimpleRabbitListenerContainerFactory();
factory.setConnectionFactory(connectionFactory);
factory.setAdviceChain(interceptor());
return factory;
}
//死信队列处理程序
@RabbitListener(queues = Consts.DEAD_QUEUE)
public void deadHandler(String data) {
log.error("got dead message {}", data);
}
执行程序,发送两条消息,日志如下:
[11:22:02.193] [http-nio-45688-exec-1] [INFO ] [o.g.t.c.a.d.DeadLetterController:24 ] - send message msg1
[11:22:02.219] [org.springframework.amqp.rabbit.RabbitListenerEndpointContainer#0-1] [INFO ] [o.g.t.c.a.deadletter.MQListener:13 ] - got message msg1
[11:22:02.614] [http-nio-45688-exec-2] [INFO ] [o.g.t.c.a.d.DeadLetterController:24 ] - send message msg2
[11:22:03.220] [org.springframework.amqp.rabbit.RabbitListenerEndpointContainer#0-1] [INFO ] [o.g.t.c.a.deadletter.MQListener:13 ] - got message msg1
[11:22:05.221] [org.springframework.amqp.rabbit.RabbitListenerEndpointContainer#0-1] [INFO ] [o.g.t.c.a.deadletter.MQListener:13 ] - got message msg1
[11:22:09.223] [org.springframework.amqp.rabbit.RabbitListenerEndpointContainer#0-1] [INFO ] [o.g.t.c.a.deadletter.MQListener:13 ] - got message msg1
[11:22:17.224] [org.springframework.amqp.rabbit.RabbitListenerEndpointContainer#0-1] [INFO ] [o.g.t.c.a.deadletter.MQListener:13 ] - got message msg1
[11:22:17.226] [org.springframework.amqp.rabbit.RabbitListenerEndpointContainer#0-1] [WARN ] [o.s.a.r.retry.RepublishMessageRecoverer:172 ] - Republishing failed message to exchange 'deadtest' with routing key deadtest
[11:22:17.227] [org.springframework.amqp.rabbit.RabbitListenerEndpointContainer#0-1] [INFO ] [o.g.t.c.a.deadletter.MQListener:13 ] - got message msg2
[11:22:17.229] [org.springframework.amqp.rabbit.RabbitListenerEndpointContainer#1-1] [ERROR] [o.g.t.c.a.deadletter.MQListener:20 ] - got dead message msg1
[11:22:18.232] [org.springframework.amqp.rabbit.RabbitListenerEndpointContainer#0-1] [INFO ] [o.g.t.c.a.deadletter.MQListener:13 ] - got message msg2
[11:22:20.237] [org.springframework.amqp.rabbit.RabbitListenerEndpointContainer#0-1] [INFO ] [o.g.t.c.a.deadletter.MQListener:13 ] - got message msg2
[11:22:24.241] [org.springframework.amqp.rabbit.RabbitListenerEndpointContainer#0-1] [INFO ] [o.g.t.c.a.deadletter.MQListener:13 ] - got message msg2
[11:22:32.245] [org.springframework.amqp.rabbit.RabbitListenerEndpointContainer#0-1] [INFO ] [o.g.t.c.a.deadletter.MQListener:13 ] - got message msg2
[11:22:32.246] [org.springframework.amqp.rabbit.RabbitListenerEndpointContainer#0-1] [WARN ] [o.s.a.r.retry.RepublishMessageRecoverer:172 ] - Republishing failed message to exchange 'deadtest' with routing key deadtest
[11:22:32.250] [org.springframework.amqp.rabbit.RabbitListenerEndpointContainer#1-1] [ERROR] [o.g.t.c.a.deadletter.MQListener:20 ] - got dead message msg2
可以看到:
msg1 的 4 次重试间隔分别是 1 秒、2 秒、4 秒、8 秒,再加上首次的失败,所以最大尝试次数是 5。
4 次重试后RepublishMessageRecoverer 把消息发往了死信交换器。
死信处理程序输出了 got dead message 日志。
这里需要尤其注意的一点是,虽然我们几乎同时发送了两条消息,但是 msg2 是在 msg1 的四次重试全部结束后才开始处理。原因是,默认情况下 SimpleMessageListenerContainer 只有一个消费线程。可以通过增加消费线程来避免性能问题,如下我们直接设置 concurrentConsumers 参数为 10来增加到 10 个工作线程:
@Bean
public SimpleRabbitListenerContainerFactory rabbitListenerContainerFactory(ConnectionFactory connectionFactory) {
SimpleRabbitListenerContainerFactory factory = new SimpleRabbitListenerContainerFactory();
factory.setConnectionFactory(connectionFactory);
factory.setAdviceChain(interceptor());
factory.setConcurrentConsumers(10);
return factory;
}
当然,我们也可以设置 maxConcurrentConsumers 参数,来让 SimpleMessageListenerContainer 自己动态地调整消费者线程数。不过,我们需要特别注意它的动态开启新线程的策略。你可以通过官方文档,来了解这个策略。
重点回顾
在使用异步处理这种架构模式的时候,我们一般都会使用 MQ 中间件配合实现异步流程,需要重点考虑四个方面的问题。
第一,要考虑异步流程丢消息或处理中断的情况,异步流程需要有备线进行补偿。比如,我们今天介绍的全量补偿方式,即便异步流程彻底失效,通过补偿也能让业务继续进行。
第二,异步处理的时候需要考虑消息重复的可能性,处理逻辑需要实现幂等,防止重复处理。
第三,微服务场景下不同服务多个实例监听消息的情况,一般不同服务需要同时收到相同的消息,而相同服务的多个实例只需要轮询接收消息。我们需要确认 MQ 的消息路由配置是否满足需求,以避免消息重复或漏发问题。
第四,要注意始终无法处理的死信消息,可能会引发堵塞 MQ 的问题。一般在遇到消息处理失败的时候,我们可以设置一定的重试策略。如果重试还是不行,那可以把这个消息扔到专有的死信队列特别处理,不要让死信影响到正常消息的处理。
今天用到的代码,我都放在了 GitHub 上,你可以点击这个链接查看。
思考与讨论
在用户注册后发送消息到 MQ然后会员服务监听消息进行异步处理的场景下有些时候我们会发现虽然用户服务先保存数据再发送 MQ但会员服务收到消息后去查询数据库却发现数据库中还没有新用户的信息。你觉得这可能是什么问题呢又该如何解决呢
除了使用 Spring AMQP 实现死信消息的重投递外RabbitMQ 2.8.0 后支持的死信交换器 DLX 也可以实现类似功能。你能尝试用 DLX 实现吗,并比较下这两种处理机制?
关于使用 MQ 进行异步处理流程,你还遇到过其他问题吗?我是朱晔,欢迎在评论区与我留言分享你的想法,也欢迎你把今天的内容分享给你的朋友或同事,一起交流。

View File

@@ -0,0 +1,897 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
26 数据存储NoSQL与RDBMS如何取长补短、相辅相成
今天,我来和你聊聊数据存储的常见错误。
近几年,各种非关系型数据库,也就是 NoSQL 发展迅猛在项目中也非常常见。其中不乏一些使用上的极端情况比如直接把关系型数据库RDBMS全部替换为 NoSQL或是在不合适的场景下错误地使用 NoSQL。
其实,每种 NoSQL 的特点不同,都有其要着重解决的某一方面的问题。因此,我们在使用 NoSQL 的时候,要尽量让它去处理擅长的场景,否则不但发挥不出它的功能和优势,还可能会导致性能问题。
NoSQL 一般可以分为缓存数据库、时间序列数据库、全文搜索数据库、文档数据库、图数据库等。今天,我会以缓存数据库 Redis、时间序列数据库 InfluxDB、全文搜索数据库 ElasticSearch 为例,通过一些测试案例,和你聊聊这些常见 NoSQL 的特点,以及它们擅长和不擅长的地方。最后,我也还会和你说说 NoSQL 如何与 RDBMS 相辅相成,来构成一套可以应对高并发的复合数据库体系。
取长补短之 Redis vs MySQL
Redis 是一款设计简洁的缓存数据库,数据都保存在内存中,所以读写单一 Key 的性能非常高。
我们来做一个简单测试,分别填充 10 万条数据到 Redis 和 MySQL 中。MySQL 中的 name 字段做了索引,相当于 Redis 的 Keydata 字段为 100 字节的数据,相当于 Redis 的 Value
@SpringBootApplication
@Slf4j
public class CommonMistakesApplication {
//模拟10万条数据存到Redis和MySQL
public static final int ROWS = 100000;
public static final String PAYLOAD = IntStream.rangeClosed(1, 100).mapToObj(__ -> "a").collect(Collectors.joining(""));
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Autowired
private JdbcTemplate jdbcTemplate;
@Autowired
private StandardEnvironment standardEnvironment;
public static void main(String[] args) {
SpringApplication.run(CommonMistakesApplication.class, args);
}
@PostConstruct
public void init() {
//使用-Dspring.profiles.active=init启动程序进行初始化
if (Arrays.stream(standardEnvironment.getActiveProfiles()).anyMatch(s -> s.equalsIgnoreCase("init"))) {
initRedis();
initMySQL();
}
}
//填充数据到MySQL
private void initMySQL() {
//删除表
jdbcTemplate.execute("DROP TABLE IF EXISTS `r`;");
//新建表name字段做了索引
jdbcTemplate.execute("CREATE TABLE `r` (\n" +
" `id` bigint(20) NOT NULL AUTO_INCREMENT,\n" +
" `data` varchar(2000) NOT NULL,\n" +
" `name` varchar(20) NOT NULL,\n" +
" PRIMARY KEY (`id`),\n" +
" KEY `name` (`name`) USING BTREE\n" +
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;");
//批量插入数据
String sql = "INSERT INTO `r` (`data`,`name`) VALUES (?,?)";
jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() {
@Override
public void setValues(PreparedStatement preparedStatement, int i) throws SQLException {
preparedStatement.setString(1, PAYLOAD);
preparedStatement.setString(2, "item" + i);
}
@Override
public int getBatchSize() {
return ROWS;
}
});
log.info("init mysql finished with count {}", jdbcTemplate.queryForObject("SELECT COUNT(*) FROM `r`", Long.class));
}
//填充数据到Redis
private void initRedis() {
IntStream.rangeClosed(1, ROWS).forEach(i -> stringRedisTemplate.opsForValue().set("item" + i, PAYLOAD));
log.info("init redis finished with count {}", stringRedisTemplate.keys("item*"));
}
}
启动程序后,输出了如下日志,数据全部填充完毕:
[14:22:47.195] [main] [INFO ] [o.g.t.c.n.r.CommonMistakesApplication:80 ] - init redis finished with count 100000
[14:22:50.030] [main] [INFO ] [o.g.t.c.n.r.CommonMistakesApplication:74 ] - init mysql finished with count 100000
然后,比较一下从 MySQL 和 Redis 随机读取单条数据的性能。“公平”起见,像 Redis 那样,我们使用 MySQL 时也根据 Key 来查 Value也就是根据 name 字段来查 data 字段,并且我们给 name 字段做了索引:
@Autowired
private JdbcTemplate jdbcTemplate;
@Autowired
private StringRedisTemplate stringRedisTemplate;
@GetMapping("redis")
public void redis() {
//使用随机的Key来查询Value结果应该等于PAYLOAD
Assert.assertTrue(stringRedisTemplate.opsForValue().get("item" + (ThreadLocalRandom.current().nextInt(CommonMistakesApplication.ROWS) + 1)).equals(CommonMistakesApplication.PAYLOAD));
}
@GetMapping("mysql")
public void mysql() {
//根据随机name来查dataname字段有索引结果应该等于PAYLOAD
Assert.assertTrue(jdbcTemplate.queryForObject("SELECT data FROM `r` WHERE name=?", new Object[]{("item" + (ThreadLocalRandom.current().nextInt(CommonMistakesApplication.ROWS) + 1))}, String.class)
.equals(CommonMistakesApplication.PAYLOAD));
}
在我的电脑上,使用 wrk 加 10 个线程 50 个并发连接做压测。可以看到MySQL 90% 的请求需要 61msQPS 为 1460而 Redis 90% 的请求在 5ms 左右QPS 达到了 14008几乎是 MySQL 的十倍:
但 Redis 薄弱的地方是,不擅长做 Key 的搜索。对 MySQL我们可以使用 LIKE 操作前匹配走 B+ 树索引实现快速搜索;但对 Redis我们使用 Keys 命令对 Key 的搜索,其实相当于在 MySQL 里做全表扫描。
我写一段代码来对比一下性能:
@GetMapping("redis2")
public void redis2() {
Assert.assertTrue(stringRedisTemplate.keys("item71*").size() == 1111);
}
@GetMapping("mysql2")
public void mysql2() {
Assert.assertTrue(jdbcTemplate.queryForList("SELECT name FROM `r` WHERE name LIKE 'item71%'", String.class).size() == 1111);
}
可以看到,在 QPS 方面MySQL 的 QPS 达到了 Redis 的 157 倍在延迟方面MySQL 的延迟只有 Redis 的十分之一。
Redis 慢的原因有两个:
Redis 的 Keys 命令是 O(n) 时间复杂度。如果数据库中 Key 的数量很多,就会非常慢。
Redis 是单线程的,对于慢的命令如果有并发,串行执行就会非常耗时。
一般而言,我们使用 Redis 都是针对某一个 Key 来使用,而不能在业务代码中使用 Keys 命令从 Redis 中“搜索数据”,因为这不是 Redis 的擅长。对于 Key 的搜索,我们可以先通过关系型数据库进行,然后再从 Redis 存取数据(如果实在需要搜索 Key 可以使用 SCAN 命令)。在生产环境中,我们一般也会配置 Redis 禁用类似 Keys 这种比较危险的命令,你可以参考这里。
总结一下,正如“缓存设计”一讲中提到的,对于业务开发来说,大多数业务场景下 Redis 是作为关系型数据库的辅助用于缓存的,我们一般不会把它当作数据库独立使用。
此外值得一提的是Redis 提供了丰富的数据结构Set、SortedSet、Hash、List并围绕这些数据结构提供了丰富的 API。如果我们好好利用这个特点的话可以直接在 Redis 中完成一部分服务端计算,避免“读取缓存 -> 计算数据 -> 保存缓存”三部曲中的读取和保存缓存的开销,进一步提高性能。
取长补短之 InfluxDB vs MySQL
InfluxDB 是一款优秀的时序数据库。在“生产就绪”这一讲中,我们就是使用 InfluxDB 来做的 Metrics 打点。时序数据库的优势,在于处理指标数据的聚合,并且读写效率非常高。
同样的,我们使用一些测试来对比下 InfluxDB 和 MySQL 的性能。
在如下代码中,我们分别填充了 1000 万条数据到 MySQL 和 InfluxDB 中。其中,每条数据只有 ID、时间戳、10000 以内的随机值这 3 列信息,对于 MySQL 我们把时间戳列做了索引:
@SpringBootApplication
@Slf4j
public class CommonMistakesApplication {
public static void main(String[] args) {
SpringApplication.run(CommonMistakesApplication.class, args);
}
//测试数据量
public static final int ROWS = 10000000;
@Autowired
private JdbcTemplate jdbcTemplate;
@Autowired
private StandardEnvironment standardEnvironment;
@PostConstruct
public void init() {
//使用-Dspring.profiles.active=init启动程序进行初始化
if (Arrays.stream(standardEnvironment.getActiveProfiles()).anyMatch(s -> s.equalsIgnoreCase("init"))) {
initInfluxDB();
initMySQL();
}
}
//初始化MySQL
private void initMySQL() {
long begin = System.currentTimeMillis();
jdbcTemplate.execute("DROP TABLE IF EXISTS `m`;");
//只有ID、值和时间戳三列
jdbcTemplate.execute("CREATE TABLE `m` (\n" +
" `id` bigint(20) NOT NULL AUTO_INCREMENT,\n" +
" `value` bigint NOT NULL,\n" +
" `time` timestamp NOT NULL,\n" +
" PRIMARY KEY (`id`),\n" +
" KEY `time` (`time`) USING BTREE\n" +
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;");
String sql = "INSERT INTO `m` (`value`,`time`) VALUES (?,?)";
//批量插入数据
jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() {
@Override
public void setValues(PreparedStatement preparedStatement, int i) throws SQLException {
preparedStatement.setLong(1, ThreadLocalRandom.current().nextInt(10000));
preparedStatement.setTimestamp(2, Timestamp.valueOf(LocalDateTime.now().minusSeconds(5 * i)));
}
@Override
public int getBatchSize() {
return ROWS;
}
});
log.info("init mysql finished with count {} took {}ms", jdbcTemplate.queryForObject("SELECT COUNT(*) FROM `m`", Long.class), System.currentTimeMillis()-begin);
}
//初始化InfluxDB
private void initInfluxDB() {
long begin = System.currentTimeMillis();
OkHttpClient.Builder okHttpClientBuilder = new OkHttpClient().newBuilder()
.connectTimeout(1, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.writeTimeout(10, TimeUnit.SECONDS);
try (InfluxDB influxDB = InfluxDBFactory.connect("http://127.0.0.1:8086", "root", "root", okHttpClientBuilder)) {
String db = "performance";
influxDB.query(new Query("DROP DATABASE " + db));
influxDB.query(new Query("CREATE DATABASE " + db));
//设置数据库
influxDB.setDatabase(db);
//批量插入10000条数据刷一次或1秒刷一次
influxDB.enableBatch(BatchOptions.DEFAULTS.actions(10000).flushDuration(1000));
IntStream.rangeClosed(1, ROWS).mapToObj(i -> Point
.measurement("m")
.addField("value", ThreadLocalRandom.current().nextInt(10000))
.time(LocalDateTime.now().minusSeconds(5 * i).toInstant(ZoneOffset.UTC).toEpochMilli(), TimeUnit.MILLISECONDS).build())
.forEach(influxDB::write);
influxDB.flush();
log.info("init influxdb finished with count {} took {}ms", influxDB.query(new Query("SELECT COUNT(*) FROM m")).getResults().get(0).getSeries().get(0).getValues().get(0).get(1), System.currentTimeMillis()-begin);
}
}
}
启动后,程序输出了如下日志:
[16:08:25.062] [main] [INFO ] [o.g.t.c.n.i.CommonMistakesApplication:104 ] - init influxdb finished with count 1.0E7 took 54280ms
[16:11:50.462] [main] [INFO ] [o.g.t.c.n.i.CommonMistakesApplication:80 ] - init mysql finished with count 10000000 took 205394ms
InfluxDB 批量插入 1000 万条数据仅用了 54 秒,相当于每秒插入 18 万条数据速度相当快MySQL 的批量插入,速度也挺快达到了每秒 4.8 万。
接下来,我们测试一下。
对这 1000 万数据进行一个统计,查询最近 60 天的数据,按照 1 小时的时间粒度聚合,统计 value 列的最大值、最小值和平均值,并将统计结果绘制成曲线图:
@Autowired
private JdbcTemplate jdbcTemplate;
@GetMapping("mysql")
public void mysql() {
long begin = System.currentTimeMillis();
//使用SQL从MySQL查询按照小时分组
Object result = jdbcTemplate.queryForList("SELECT date_format(time,'%Y%m%d%H'),max(value),min(value),avg(value) FROM m WHERE time>now()- INTERVAL 60 DAY GROUP BY date_format(time,'%Y%m%d%H')");
log.info("took {} ms result {}", System.currentTimeMillis() - begin, result);
}
@GetMapping("influxdb")
public void influxdb() {
long begin = System.currentTimeMillis();
try (InfluxDB influxDB = InfluxDBFactory.connect("http://127.0.0.1:8086", "root", "root")) {
//切换数据库
influxDB.setDatabase("performance");
//InfluxDB的查询语法InfluxQL类似SQL
Object result = influxDB.query(new Query("SELECT MEAN(value),MIN(value),MAX(value) FROM m WHERE time > now() - 60d GROUP BY TIME(1h)"));
log.info("took {} ms result {}", System.currentTimeMillis() - begin, result);
}
}
因为数据量非常大,单次查询就已经很慢了,所以这次我们不进行压测。分别调用两个接口,可以看到 MySQL 查询一次耗时 29 秒左右,而 InfluxDB 耗时 980ms
[16:19:26.562] [http-nio-45678-exec-1] [INFO ] [o.g.t.c.n.i.PerformanceController:31 ] - took 28919 ms result [{date_format(time,'%Y%m%d%H')=2019121308, max(value)=9993, min(value)=4, avg(value)=5129.5639}, {date_format(time,'%Y%m%d%H')=2019121309, max(value)=9990, min(value)=12, avg(value)=4856.0556}, {date_format(time,'%Y%m%d%H')=2019121310, max(value)=9998, min(value)=8, avg(value)=4948.9347}, {date_format(time,'%Y%m%d%H')...
[16:20:08.170] [http-nio-45678-exec-6] [INFO ] [o.g.t.c.n.i.PerformanceController:40 ] - took 981 ms result QueryResult [results=[Result [series=[Series [name=m, tags=null, columns=[time, mean, min, max], values=[[2019-12-13T08:00:00Z, 5249.2468619246865, 21.0, 9992.0],...
在按照时间区间聚合的案例上,我们看到了 InfluxDB 的性能优势。但,我们肯定不能把 InfluxDB 当作普通数据库,原因是:
InfluxDB 不支持数据更新操作,毕竟时间数据只能随着时间产生新数据,肯定无法对过去的数据做修改;
从数据结构上说,时间序列数据数据没有单一的主键标识,必须包含时间戳,数据只能和时间戳进行关联,不适合普通业务数据。
此外需要注意,即便只是使用 InfluxDB 保存和时间相关的指标数据,我们也要注意不能滥用 tag。
InfluxDB 提供的 tag 功能,可以为每一个指标设置多个标签,并且 tag 有索引,可以对 tag 进行条件搜索或分组。但是tag 只能保存有限的、可枚举的标签,不能保存 URL 等信息否则可能会出现high series cardinality 问题,导致占用大量内存,甚至是 OOM。你可以点击这里查看 series 和内存占用的关系。对于 InfluxDB我们无法把 URL 这种原始数据保存到数据库中,只能把数据进行归类,形成有限的 tag 进行保存。
总结一下,对于 MySQL 而言,针对大量的数据使用全表扫描的方式来聚合统计指标数据,性能非常差,一般只能作为临时方案来使用。此时,引入 InfluxDB 之类的时间序列数据库,就很有必要了。时间序列数据库可以作为特定场景(比如监控、统计)的主存储,也可以和关系型数据库搭配使用,作为一个辅助数据源,保存业务系统的指标数据。
取长补短之 Elasticsearch vs MySQL
Elasticsearch以下简称 ES是目前非常流行的分布式搜索和分析数据库独特的倒排索引结构尤其适合进行全文搜索。
简单来讲,倒排索引可以认为是一个 Map其 Key 是分词之后的关键字Value 是文档 ID/ 片段 ID 的列表。我们只要输入需要搜索的单词,就可以直接在这个 Map 中得到所有包含这个单词的文档 ID/ 片段 ID 列表,然后再根据其中的文档 ID/ 片段 ID 查询出实际的文档内容。
我们来测试一下,对比下使用 ES 进行关键字全文搜索、在 MySQL 中使用 LIKE 进行搜索的效率差距。
首先,定义一个实体 News包含新闻分类、标题、内容等字段。这个实体同时会用作 Spring Data JPA 和 Spring Data Elasticsearch 的实体:
@Entity
@Document(indexName = "news", replicas = 0) //@Document注解定义了这是一个ES的索引索引名称news数据不需要冗余
@Table(name = "news", indexes = {@Index(columnList = "cateid")}) //@Table注解定义了这是一个MySQL表表名news对cateid列做索引
@Data
@AllArgsConstructor
@NoArgsConstructor
@DynamicUpdate
public class News {
@Id
private long id;
@Field(type = FieldType.Keyword)
private String category;//新闻分类名称
private int cateid;//新闻分类ID
@Column(columnDefinition = "varchar(500)")//@Column注解定义了在MySQL中字段比如这里定义title列的类型是varchar(500)
@Field(type = FieldType.Text, analyzer = "ik_max_word", searchAnalyzer = "ik_smart")//@Field注解定义了ES字段的格式使用ik分词器进行分词
private String title;//新闻标题
@Column(columnDefinition = "text")
@Field(type = FieldType.Text, analyzer = "ik_max_word", searchAnalyzer = "ik_smart")
private String content;//新闻内容
}
接下来,我们实现主程序。在启动时,我们会从一个 csv 文件中加载 4000 条新闻数据,然后复制 100 份,拼成 40 万条数据,分别写入 MySQL 和 ES
@SpringBootApplication
@Slf4j
@EnableElasticsearchRepositories(includeFilters = @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, value = NewsESRepository.class)) //明确设置哪个是ES的Repository
@EnableJpaRepositories(excludeFilters = @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, value = NewsESRepository.class)) //其他的是MySQL的Repository
public class CommonMistakesApplication {
public static void main(String[] args) {
Utils.loadPropertySource(CommonMistakesApplication.class, "es.properties");
SpringApplication.run(CommonMistakesApplication.class, args);
}
@Autowired
private StandardEnvironment standardEnvironment;
@Autowired
private NewsESRepository newsESRepository;
@Autowired
private NewsMySQLRepository newsMySQLRepository;
@PostConstruct
public void init() {
//使用-Dspring.profiles.active=init启动程序进行初始化
if (Arrays.stream(standardEnvironment.getActiveProfiles()).anyMatch(s -> s.equalsIgnoreCase("init"))) {
//csv中的原始数据只有4000条
List<News> news = loadData();
AtomicLong atomicLong = new AtomicLong();
news.forEach(item -> item.setTitle("%%" + item.getTitle()));
//我们模拟100倍的数据量也就是40万条
IntStream.rangeClosed(1, 100).forEach(repeat -> {
news.forEach(item -> {
//重新设置主键ID
item.setId(atomicLong.incrementAndGet());
//每次复制数据稍微改一下title字段在前面加上一个数字代表这是第几次复制
item.setTitle(item.getTitle().replaceFirst("%%", String.valueOf(repeat)));
});
initMySQL(news, repeat == 1);
log.info("init MySQL finished for {}", repeat);
initES(news, repeat == 1);
log.info("init ES finished for {}", repeat);
});
}
}
//从news.csv中解析得到原始数据
private List<News> loadData() {
//使用jackson-dataformat-csv实现csv到POJO的转换
CsvMapper csvMapper = new CsvMapper();
CsvSchema schema = CsvSchema.emptySchema().withHeader();
ObjectReader objectReader = csvMapper.readerFor(News.class).with(schema);
ClassLoader classLoader = getClass().getClassLoader();
File file = new File(classLoader.getResource("news.csv").getFile());
try (Reader reader = new FileReader(file)) {
return objectReader.<News>readValues(reader).readAll();
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
//把数据保存到ES中
private void initES(List<News> news, boolean clear) {
if (clear) {
//首次调用的时候先删除历史数据
newsESRepository.deleteAll();
}
newsESRepository.saveAll(news);
}
//把数据保存到MySQL中
private void initMySQL(List<News> news, boolean clear) {
if (clear) {
//首次调用的时候先删除历史数据
newsMySQLRepository.deleteAll();
}
newsMySQLRepository.saveAll(news);
}
}
由于我们使用了 Spring Data直接定义两个 Repository然后直接定义查询方法无需实现任何逻辑即可实现查询Spring Data 会根据方法名生成相应的 SQL 语句和 ES 查询 DSL其中 ES 的翻译逻辑详见这里。
在这里,我们定义一个 countByCateidAndContentContainingAndContentContaining 方法,代表查询条件是:搜索分类等于 cateid 参数,且内容同时包含关键字 keyword1 和 keyword2计算符合条件的新闻总数量
@Repository
public interface NewsMySQLRepository extends JpaRepository<News, Long> {
//JPA搜索分类等于cateid参数且内容同时包含关键字keyword1和keyword2计算符合条件的新闻总数量
long countByCateidAndContentContainingAndContentContaining(int cateid, String keyword1, String keyword2);
}
@Repository
public interface NewsESRepository extends ElasticsearchRepository<News, Long> {
//ES搜索分类等于cateid参数且内容同时包含关键字keyword1和keyword2计算符合条件的新闻总数量
long countByCateidAndContentContainingAndContentContaining(int cateid, String keyword1, String keyword2);
}
对于 ES 和 MySQL我们使用相同的条件进行搜索搜素分类是 1关键字是社会和苹果然后输出搜索结果和耗时
//测试MySQL搜索最后输出耗时和结果
@GetMapping("mysql")
public void mysql(@RequestParam(value = "cateid", defaultValue = "1") int cateid,
@RequestParam(value = "keyword1", defaultValue = "社会") String keyword1,
@RequestParam(value = "keyword2", defaultValue = "苹果") String keyword2) {
long begin = System.currentTimeMillis();
Object result = newsMySQLRepository.countByCateidAndContentContainingAndContentContaining(cateid, keyword1, keyword2);
log.info("took {} ms result {}", System.currentTimeMillis() - begin, result);
}
//测试ES搜索最后输出耗时和结果
@GetMapping("es")
public void es(@RequestParam(value = "cateid", defaultValue = "1") int cateid,
@RequestParam(value = "keyword1", defaultValue = "社会") String keyword1,
@RequestParam(value = "keyword2", defaultValue = "苹果") String keyword2) {
long begin = System.currentTimeMillis();
Object result = newsESRepository.countByCateidAndContentContainingAndContentContaining(cateid, keyword1, keyword2);
log.info("took {} ms result {}", System.currentTimeMillis() - begin, result);
}
分别调用接口可以看到ES 耗时仅仅 48msMySQL 耗时 6 秒多是 ES 的 100 倍。很遗憾,虽然新闻分类 ID 已经建了索引,但是这个索引只能起到加速过滤分类 ID 这一单一条件的作用对于文本内容的全文搜索B+ 树索引无能为力。
[22:04:00.951] [http-nio-45678-exec-6] [INFO ] [o.g.t.c.n.esvsmyql.PerformanceController:48 ] - took 48 ms result 2100
Hibernate: select count(news0_.id) as col_0_0_ from news news0_ where news0_.cateid=? and (news0_.content like ? escape ?) and (news0_.content like ? escape ?)
[22:04:11.946] [http-nio-45678-exec-7] [INFO ] [o.g.t.c.n.esvsmyql.PerformanceController:39 ] - took 6637 ms result 2100
但 ES 这种以索引为核心的数据库,也不是万能的,频繁更新就是一个大问题。
MySQL 可以做到仅更新某行数据的某个字段,但 ES 里每次数据字段更新都相当于整个文档索引重建。即便 ES 提供了文档部分更新的功能,但本质上只是节省了提交文档的网络流量,以及减少了更新冲突,其内部实现还是文档删除后重新构建索引。因此,如果要在 ES 中保存一个类似计数器的值,要实现不断更新,其执行效率会非常低。
我们来验证下,分别使用 JdbcTemplate+SQL 语句、ElasticsearchTemplate+ 自定义 UpdateQuery实现部分更新 MySQL 表和 ES 索引的一个字段,每个方法都是循环更新 1000 次:
@GetMapping("mysql2")
public void mysql2(@RequestParam(value = "id", defaultValue = "400000") long id) {
long begin = System.currentTimeMillis();
//对于MySQL使用JdbcTemplate+SQL语句实现直接更新某个category字段更新1000次
IntStream.rangeClosed(1, 1000).forEach(i -> {
jdbcTemplate.update("UPDATE `news` SET category=? WHERE id=?", new Object[]{"test" + i, id});
});
log.info("mysql took {} ms result {}", System.currentTimeMillis() - begin, newsMySQLRepository.findById(id));
}
@GetMapping("es2")
public void es(@RequestParam(value = "id", defaultValue = "400000") long id) {
long begin = System.currentTimeMillis();
IntStream.rangeClosed(1, 1000).forEach(i -> {
//对于ES通过ElasticsearchTemplate+自定义UpdateQuery实现文档的部分更新
UpdateQuery updateQuery = null;
try {
updateQuery = new UpdateQueryBuilder()
.withIndexName("news")
.withId(String.valueOf(id))
.withType("_doc")
.withUpdateRequest(new UpdateRequest().doc(
jsonBuilder()
.startObject()
.field("category", "test" + i)
.endObject()))
.build();
} catch (IOException e) {
e.printStackTrace();
}
elasticsearchTemplate.update(updateQuery);
});
log.info("es took {} ms result {}", System.currentTimeMillis() - begin, newsESRepository.findById(id).get());
}
可以看到MySQL 耗时仅仅 1.5 秒,而 ES 耗时 6.8 秒:
ES 是一个分布式的全文搜索数据库,所以与 MySQL 相比的优势在于文本搜索,而且因为其分布式的特性,可以使用一个大 ES 集群处理大规模数据的内容搜索。但,由于 ES 的索引是文档维度的,所以不适用于频繁更新的 OLTP 业务。
一般而言,我们会把 ES 和 MySQL 结合使用MySQL 直接承担业务系统的增删改操作,而 ES 作为辅助数据库,直接扁平化保存一份业务数据,用于复杂查询、全文搜索和统计。接下来,我也会继续和你分析这一点。
结合 NoSQL 和 MySQL 应对高并发的复合数据库架构
现在,我们通过一些案例看到了 Redis、InfluxDB、ES 这些 NoSQL 数据库,都有擅长和不擅长的场景。那么,有没有全能的数据库呢?
我认为没有。每一个存储系统都有其独特的数据结构,数据结构的设计就决定了其擅长和不擅长的场景。
比如MySQL InnoDB 引擎的 B+ 树对排序和范围查询友好,频繁数据更新的代价不是太大,因此适合 OLTPOn-Line Transaction Processing
又比如ES 的 Lucene 采用了 FSTFinite State Transducer索引 + 倒排索引,空间效率高,适合对变动不频繁的数据做索引,实现全文搜索。存储系统本身不可能对一份数据使用多种数据结构保存,因此不可能适用于所有场景。
虽然在大多数业务场景下MySQL 的性能都不算太差但对于数据量大、访问量大、业务复杂的互联网应用来说MySQL 因为实现了 ACID原子性、一致性、隔离性、持久性会比较重而且横向扩展能力较差、功能单一无法扛下所有数据量和流量无法应对所有功能需求。因此我们需要通过架构手段来组合使用多种存储系统取长补短实现 1+1>2 的效果。
我来举个例子。我们设计了一个包含多个数据库系统的、能应对各种高并发场景的一套数据服务的系统架构,其中包含了同步写服务、异步写服务和查询服务三部分,分别实现主数据库写入、辅助数据库写入和查询路由。
我们按照服务来依次分析下这个架构。
首先要明确的是,重要的业务主数据只能保存在 MySQL 这样的关系型数据库中,原因有三点:
RDBMS 经过了几十年的验证,已经非常成熟;
RDBMS 的用户数量众多Bug 修复快、版本稳定、可靠性很高;
RDBMS 强调 ACID能确保数据完整。
有两种类型的查询任务可以交给 MySQL 来做,性能会比较好,这也是 MySQL 擅长的地方:
按照主键 ID 的查询。直接查询聚簇索引,其性能会很高。但是单表数据量超过亿级后,性能也会衰退,而且单个数据库无法承受超大的查询并发,因此我们可以把数据表进行 Sharding 操作,均匀拆分到多个数据库实例中保存。我们把这套数据库集群称作 Sharding 集群。
按照各种条件进行范围查询,查出主键 ID。对二级索引进行查询得到主键只需要查询一棵 B+ 树,效率同样很高。但索引的值不宜过大,比如对 varchar(1000) 进行索引不太合适,而索引外键(一般是 int 或 bigint 类型)性能就会比较好。因此,我们可以在 MySQL 中建立一张“索引表”,除了保存主键外,主要是保存各种关联表的外键,以及尽可能少的 varchar 类型的字段。这张索引表的大部分列都可以建上二级索引,用于进行简单搜索,搜索的结果是主键的列表,而不是完整的数据。由于索引表字段轻量并且数量不多(一般控制在 10 个以内),所以即便索引表没有进行 Sharding 拆分,问题也不会很大。
如图上蓝色线所示,写入两种 MySQL 数据表和发送 MQ 消息的这三步,我们用一个同步写服务完成了。我在“异步处理”中提到,所有异步流程都需要补偿,这里的异步流程同样需要。只不过为了简洁,我在这里省略了补偿流程。
然后,如图中绿色线所示,有一个异步写服务,监听 MQ 的消息,继续完成辅助数据的更新操作。这里我们选用了 ES 和 InfluxDB 这两种辅助数据库,因此整个异步写数据操作有三步:
MQ 消息不一定包含完整的数据,甚至可能只包含一个最新数据的主键 ID我们需要根据 ID 从查询服务查询到完整的数据。
写入 InfluxDB 的数据一般可以按时间间隔进行简单聚合,定时写入 InfluxDB。因此这里会进行简单的客户端聚合然后写入 InfluxDB。
ES 不适合在各索引之间做连接Join操作适合保存扁平化的数据。比如我们可以把订单下的用户、商户、商品列表等信息作为内嵌对象嵌入整个订单 JSON然后把整个扁平化的 JSON 直接存入 ES。
对于数据写入操作,我们认为操作返回的时候同步数据一定是写入成功的,但是由于各种原因,异步数据写入无法确保立即成功,会有一定延迟,比如:
异步消息丢失的情况,需要补偿处理;
写入 ES 的索引操作本身就会比较慢;
写入 InfluxDB 的数据需要客户端定时聚合。
因此,对于查询服务,如图中红色线所示,我们需要根据一定的上下文条件(比如查询一致性要求、时效性要求、搜索的条件、需要返回的数据字段、搜索时间区间等)来把请求路由到合适的数据库,并且做一些聚合处理:
需要根据主键查询单条数据,可以从 MySQL Sharding 集群或 Redis 查询,如果对实时性要求不高也可以从 ES 查询。
按照多个条件搜索订单的场景,可以从 MySQL 索引表查询出主键列表,然后再根据主键从 MySQL Sharding 集群或 Redis 获取数据详情。
各种后台系统需要使用比较复杂的搜索条件,甚至全文搜索来查询订单数据,或是定时分析任务需要一次查询大量数据,这些场景对数据实时性要求都不高,可以到 ES 进行搜索。此外MySQL 中的数据可以归档,我们可以在 ES 中保留更久的数据,而且查询历史数据一般并发不会很大,可以统一路由到 ES 查询。
监控系统或后台报表系统需要呈现业务监控图表或表格,可以把请求路由到 InfluxDB 查询。
重点回顾
今天,我通过三个案例分别对比了缓存数据库 Redis、时间序列数据库 InfluxDB、搜索数据库 ES 和 MySQL 的性能。我们看到:
Redis 对单条数据的读取性能远远高于 MySQL但不适合进行范围搜索。
InfluxDB 对于时间序列数据的聚合效率远远高于 MySQL但因为没有主键所以不是一个通用数据库。
ES 对关键字的全文搜索能力远远高于 MySQL但是字段的更新效率较低不适合保存频繁更新的数据。
最后,我们给出了一个混合使用 MySQL + Redis + InfluxDB + ES 的架构方案,充分发挥了各种数据库的特长,相互配合构成了一个可以应对各种复杂查询,以及高并发读写的存储架构。
主数据由两种 MySQL 数据表构成其中索引表承担简单条件的搜索来得到主键Sharding 表承担大并发的主键查询。主数据由同步写服务写入,写入后发出 MQ 消息。
辅助数据可以根据需求选用合适的 NoSQL由单独一个或多个异步写服务监听 MQ 后异步写入。
由统一的查询服务,对接所有查询需求,根据不同的查询需求路由查询到合适的存储,确保每一个存储系统可以根据场景发挥所长,并分散各数据库系统的查询压力。
今天用到的代码,我都放在了 GitHub 上,你可以点击这个链接查看。
思考与讨论
我们提到InfluxDB 不能包含太多 tag。你能写一段测试代码来模拟这个问题并观察下 InfluxDB 的内存使用情况吗?
文档数据库 MongoDB也是一种常用的 NoSQL。你觉得 MongoDB 的优势和劣势是什么呢?它适合用在什么场景下呢?
关于数据存储,你还有其他心得吗?我是朱晔,欢迎在评论区与我留言分享你的想法,也欢迎你把今天的内容分享给你的朋友或同事,一起交流。

View File

@@ -0,0 +1,532 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
27 数据源头:任何客户端的东西都不可信任
从今天开始,我要和你讨论几个有关安全的话题。首先声明,我不是安全专家,但我发现有这么一个问题,那就是许多做业务开发的同学往往一点点安全意识都没有。如果有些公司没有安全部门或专家的话,安全问题就会非常严重。
如果只是用一些所谓的渗透服务浅层次地做一下扫描和渗透,而不在代码和逻辑层面做进一步分析的话,能够发现的安全问题非常有限。要做好安全,还是要靠一线程序员和产品经理点点滴滴的意识。
所以接下来的几篇文章,我会从业务开发的角度,和你说说我们应该最应该具备的安全意识。
对于 HTTP 请求,我们要在脑子里有一个根深蒂固的概念,那就是任何客户端传过来的数据都是不能直接信任的。客户端传给服务端的数据只是信息收集,数据需要经过有效性验证、权限验证等后才能使用,并且这些数据只能认为是用户操作的意图,不能直接代表数据当前的状态。
举一个简单的例子,我们打游戏的时候,客户端发给服务端的只是用户的操作,比如移动了多少位置,由服务端根据用户当前的状态来设置新的位置再返回给客户端。为了防止作弊,不可能由客户端直接告诉服务端用户当前的位置。
因此,客户端发给服务端的指令,代表的只是操作指令,并不能直接决定用户的状态,对于状态改变的计算在服务端。而网络不好时,我们往往会遇到走了 10 步又被服务端拉回来的现象,就是因为有指令丢失,客户端使用服务端计算的实际位置修正了客户端玩家的位置。
今天,我通过四个案例来和你说说,为什么“任何客户端的东西都不可信任”。
客户端的计算不可信
我们先看一个电商下单操作的案例。
在这个场景下,可能会暴露这么一个 /order 的 POST 接口给客户端,让客户端直接把组装后的订单信息 Order 传给服务端:
@PostMapping("/order")
public void wrong(@RequestBody Order order) {
this.createOrder(order);
}
订单信息 Order 可能包括商品 ID、商品价格、数量、商品总价
@Data
public class Order {
private long itemId; //商品ID
private BigDecimal itemPrice; //商品价格
private int quantity; //商品数量
private BigDecimal itemTotalPrice; //商品总价
}
虽然用户下单时客户端肯定有商品的价格等信息,也会计算出订单的总价给用户确认,但是这些信息只能用于呈现和核对。即使客户端传给服务端的 POJO 中包含了这些信息,服务端也一定要重新从数据库来初始化商品的价格,重新计算最终的订单价格。如果不这么做的话,很可能会被黑客利用,商品总价被恶意修改为比较低的价格。
因此,我们真正直接使用的、可信赖的只是客户端传过来的商品 ID 和数量,服务端会根据这些信息重新计算最终的总价。如果服务端计算出来的商品价格和客户端传过来的价格不匹配的话,可以给客户端友好提示,让用户重新下单。修改后的代码如下:
@PostMapping("/orderRight")
public void right(@RequestBody Order order) {
//根据ID重新查询商品
Item item = Db.getItem(order.getItemId());
//客户端传入的和服务端查询到的商品单价不匹配的时候,给予友好提示
if (!order.getItemPrice().equals(item.getItemPrice())) {
throw new RuntimeException("您选购的商品价格有变化,请重新下单");
}
//重新设置商品单价
order.setItemPrice(item.getItemPrice());
//重新计算商品总价
BigDecimal totalPrice = item.getItemPrice().multiply(BigDecimal.valueOf(order.getQuantity()));
//客户端传入的和服务端查询到的商品总价不匹配的时候,给予友好提示
if (order.getItemTotalPrice().compareTo(totalPrice)!=0) {
throw new RuntimeException("您选购的商品总价有变化,请重新下单");
}
//重新设置商品总价
order.setItemTotalPrice(totalPrice);
createOrder(order);
}
还有一种可行的做法是,让客户端仅传入需要的数据给服务端,像这样重新定义一个 POJO CreateOrderRequest 作为接口入参,比直接使用领域模型 Order 更合理。在设计接口时,我们会思考哪些数据需要客户端提供,而不是把一个大而全的对象作为参数提供给服务端,以避免因为忘记在服务端重置客户端数据而导致的安全问题。
下单成功后,服务端处理完成后会返回诸如商品单价、总价等信息给客户端。此时,客户端可以进行一次判断,如果和之前客户端的数据不一致的话,给予用户提示,用户确认没问题后再进入支付阶段:
@Data
public class CreateOrderRequest {
private long itemId; //商品ID
private int quantity; //商品数量
}
@PostMapping("orderRight2")
public Order right2(@RequestBody CreateOrderRequest createOrderRequest) {
//商品ID和商品数量是可信的没问题其他数据需要由服务端计算
Item item = Db.getItem(createOrderRequest.getItemId());
Order order = new Order();
order.setItemPrice(item.getItemPrice());
order.setItemTotalPrice(item.getItemPrice().multiply(BigDecimal.valueOf(order.getQuantity())));
createOrder(order);
return order;
}
通过这个案例我们可以看到,在处理客户端提交过来的数据时,服务端需要明确区分,哪些数据是需要客户端提供的,哪些数据是客户端从服务端获取后在客户端计算的。其中,前者可以信任;而后者不可信任,服务端需要重新计算,如果客户端和服务端计算结果不一致的话,可以给予友好提示。
客户端提交的参数需要校验
对于客户端的数据,我们还容易忽略的一点是,误以为客户端的数据来源是服务端,客户端就不可能提交异常数据。我们看一个案例。
有一个用户注册页面要让用户选择所在国家,我们会把服务端支持的国家列表返回给页面,供用户选择。如下代码所示,我们的注册只支持中国、美国和英国三个国家,并不对其他国家开放,因此从数据库中筛选了 id 的国家返回给页面进行填充:
@Slf4j
@RequestMapping("trustclientdata")
@Controller
public class TrustClientDataController {
//所有支持的国家
private HashMap<Integer, Country> allCountries = new HashMap<>();
public TrustClientDataController() {
allCountries.put(1, new Country(1, "China"));
allCountries.put(2, new Country(2, "US"));
allCountries.put(3, new Country(3, "UK"));
allCountries.put(4, new Country(4, "Japan"));
}
@GetMapping("/")
public String index(ModelMap modelMap) {
List<Country> countries = new ArrayList<>();
//从数据库查出ID<4的三个国家作为白名单在页面显示
countries.addAll(allCountries.values().stream().filter(country -> country.getId()<4).collect(Collectors.toList()));
modelMap.addAttribute("countries", countries);
return "index";
}
}
我们通过服务端返回的数据来渲染模板
...
<form id="myForm" method="post" th:action="@{/trustclientdata/wrong}">
<select id="countryId" name="countryId">
<option value="0">Select country</option>
<option th:each="country : ${countries}" th:text="${country.name}" th:value="${country.id}"></option>
</select>
<button th:text="Register" type="submit"/>
</form>
...
在页面上,的确也只有这三个国家的可选项:
但我们要知道的是,页面是给普通用户使用的,而黑客不会在乎页面显示什么,完全有可能尝试给服务端返回页面上没显示的其他国家 ID。如果像这样直接信任客户端传来的国家 ID 的话,很可能会把用户注册功能开放给其他国家的人:
@PostMapping("/wrong")
@ResponseBody
public String wrong(@RequestParam("countryId") int countryId) {
return allCountries.get(countryId).getName();
}
即使我们知道参数的范围来自下拉框,而下拉框的内容也来自服务端,也需要对参数进行校验。因为接口不一定要通过浏览器请求,只要知道接口定义完全可以通过其他工具提交:
curl http://localhost:45678/trustclientdata/wrong\?countryId=4 -X POST
修改方式是,在使用客户端传过来的参数之前,对参数进行有效性校验:
@PostMapping("/right")
@ResponseBody
public String right(@RequestParam("countryId") int countryId) {
if (countryId < 1 || countryId > 3)
throw new RuntimeException("非法参数");
return allCountries.get(countryId).getName();
}
或者是,使用 Spring Validation 采用注解的方式进行参数校验,更优雅:
@Validated
public class TrustClientParameterController {
@PostMapping("/better")
@ResponseBody
public String better(
@RequestParam("countryId")
@Min(value = 1, message = "非法参数")
@Max(value = 3, message = "非法参数") int countryId) {
return allCountries.get(countryId).getName();
}
}
客户端提交的参数需要校验的问题,可以引申出一个更容易忽略的点是,我们可能会把一些服务端的数据暂存在网页的隐藏域中,这样下次页面提交的时候可以把相关数据再传给服务端。虽然用户通过网页界面的操作无法修改这些数据,但这些数据对于 HTTP 请求来说就是普通数据,完全可以随时修改为任意值。所以,服务端在使用这些数据的时候,也同样要特别小心。
不能信任请求头里的任何内容
刚才我们介绍了,不能直接信任客户端的传参,也就是通过 GET 或 POST 方法传过来的数据,此外请求头的内容也不能信任。
一个比较常见的需求是,为了防刷,我们需要判断用户的唯一性。比如,针对未注册的新用户发送一些小奖品,我们不希望相同用户多次获得奖品。考虑到未注册的用户因为没有登录过所以没有用户标识,我们可能会想到根据请求的 IP 地址,来判断用户是否已经领过奖品。
比如,下面的这段测试代码。我们通过一个 HashSet 模拟已发放过奖品的 IP 名单,每次领取奖品后把 IP 地址加入这个名单中。IP 地址的获取方式是:优先通过 X-Forwarded-For 请求头来获取,如果没有的话再通过 HttpServletRequest 的 getRemoteAddr 方法来获取。
@Slf4j
@RequestMapping("trustclientip")
@RestController
public class TrustClientIpController {
HashSet<String> activityLimit = new HashSet<>();
@GetMapping("test")
public String test(HttpServletRequest request) {
String ip = getClientIp(request);
if (activityLimit.contains(ip)) {
return "您已经领取过奖品";
} else {
activityLimit.add(ip);
return "奖品领取成功";
}
}
private String getClientIp(HttpServletRequest request) {
String xff = request.getHeader("X-Forwarded-For");
if (xff == null) {
return request.getRemoteAddr();
} else {
return xff.contains(",") ? xff.split(",")[0] : xff;
}
}
}
之所以这么做是因为通常我们的应用之前都部署了反向代理或负载均衡器remoteAddr 获得的只能是代理的 IP 地址,而不是访问用户实际的 IP。这不符合我们的需求因为反向代理在转发请求时通常会把用户真实 IP 放入 X-Forwarded-For 这个请求头中。
这种过于依赖 X-Forwarded-For 请求头来判断用户唯一性的实现方式,是有问题的:
完全可以通过 cURL 类似的工具来模拟请求,随意篡改头的内容:
curl http://localhost:45678/trustclientip/test -H "X-Forwarded-For:183.84.18.71, 10.253.15.1"
网吧、学校等机构的出口 IP 往往是同一个,在这个场景下,可能只有最先打开这个页面的用户才能领取到奖品,而其他用户会被阻拦。
因此IP 地址或者说请求头里的任何信息,包括 Cookie 中的信息、Referer只能用作参考不能用作重要逻辑判断的依据。而对于类似这个案例唯一性的判断需求更好的做法是让用户进行登录或三方授权登录比如微信拿到用户标识来做唯一性判断。
用户标识不能从客户端获取
聊到用户登录,业务代码非常容易犯错的一个地方是,使用了客户端传给服务端的用户 ID类似这样
@GetMapping("wrong")
public String wrong(@RequestParam("userId") Long userId) {
return "当前用户Id" + userId;
}
你可能觉得没人会这么干,但我就真实遇到过:一个大项目因为服务端直接使用了客户端传过来的用户标识,导致了安全问题。
犯类似低级错误的原因,有三个:
开发同学没有正确认识接口或服务面向的用户。如果接口面向内部服务,由服务调用方传入用户 ID 没什么不合理,但是这样的接口不能直接开放给客户端或 H5 使用。
在测试阶段为了方便测试调试,我们通常会实现一些无需登录即可使用的接口,直接使用客户端传过来的用户标识,却在上线之前忘记删除类似的超级接口。
一个大型网站前端可能由不同的模块构成,不一定是一个系统,而用户登录状态可能也没有打通。有些时候,我们图简单可能会在 URL 中直接传用户 ID以实现通过前端传值来打通用户登录状态。
如果你的接口直面用户(比如给客户端或 H5 页面调用),那么一定需要用户先登录才能使用。登录后用户标识保存在服务端,接口需要从服务端(比如 Session 中)获取。这里有段代码演示了一个最简单的登录操作,登录后在 Session 中设置了当前用户的标识:
@GetMapping("login")
public long login(@RequestParam("username") String username, @RequestParam("password") String password, HttpSession session) {
if (username.equals("admin") && password.equals("admin")) {
session.setAttribute("currentUser", 1L);
return 1L;
}
return 0L;
}
这里,我再分享一个 Spring Web 的小技巧。
如果希望每一个需要登录的方法,都从 Session 中获得当前用户标识,并进行一些后续处理的话,我们没有必要在每一个方法内都复制粘贴相同的获取用户身份的逻辑,可以定义一个自定义注解 @LoginRequired 到 userId 参数上,然后通过 HandlerMethodArgumentResolver 自动实现参数的组装:
@GetMapping("right")
public String right(@LoginRequired Long userId) {
return "当前用户Id" + userId;
}
@LoginRequired 本身并无特殊,只是一个自定义注解:
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.PARAMETER)
@Documented
public @interface LoginRequired {
String sessionKey() default "currentUser";
}
魔法来自 HandlerMethodArgumentResolver。我们自定义了一个实现类 LoginRequiredArgumentResolver实现了 HandlerMethodArgumentResolver 接口的 2 个方法:
supportsParameter 方法判断当参数上有 @LoginRequired 注解时,再做自定义参数解析的处理;
resolveArgument 方法用来实现解析逻辑本身。在这里,我们尝试从 Session 中获取当前用户的标识,如果无法获取到的话提示非法调用的错误,如果获取到则返回 userId。这样一来Controller 中的 userId 参数就可以自动赋值了。
@Slf4j
public class LoginRequiredArgumentResolver implements HandlerMethodArgumentResolver {
//解析哪些参数
@Override
public boolean supportsParameter(MethodParameter methodParameter) {
//匹配参数上具有@LoginRequired注解的参数
return methodParameter.hasParameterAnnotation(LoginRequired.class);
}
@Override
public Object resolveArgument(MethodParameter methodParameter, ModelAndViewContainer modelAndViewContainer, NativeWebRequest nativeWebRequest, WebDataBinderFactory webDataBinderFactory) throws Exception {
//从参数上获得注解
LoginRequired loginRequired = methodParameter.getParameterAnnotation(LoginRequired.class);
//根据注解中的Session Key从Session中查询用户信息
Object object = nativeWebRequest.getAttribute(loginRequired.sessionKey(), NativeWebRequest.SCOPE_SESSION);
if (object == null) {
log.error("接口 {} 非法调用!", methodParameter.getMethod().toString());
throw new RuntimeException("请先登录!");
}
return object;
}
}
当然,我们要实现 WebMvcConfigurer 接口的 addArgumentResolvers 方法,来增加这个自定义的处理器 LoginRequiredArgumentResolver
SpringBootApplication
public class CommonMistakesApplication implements WebMvcConfigurer {
...
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(new LoginRequiredArgumentResolver());
}
}
测试发现,经过这样的实现,登录后所有需要登录的方法都可以一键通过加 @LoginRequired 注解来拿到用户标识,方便且安全:
重点回顾
今天,我就“任何客户端的东西都不可信任”这个结论,和你讲解了一些有代表性的错误。
第一,客户端的计算不可信。虽然目前很多项目的前端都是富前端,会做大量的逻辑计算,无需访问服务端接口就可以顺畅完成各种功能,但来自客户端的计算结果不能直接信任。最终在进行业务操作时,客户端只能扮演信息收集的角色,虽然可以将诸如价格等信息传给服务端,但只能用于校对比较,最终要以服务端的计算结果为准。
第二,所有来自客户端的参数都需要校验判断合法性。即使我们知道用户是在一个下拉列表选择数据,即使我们知道用户通过网页正常操作不可能提交不合法的值,服务端也应该进行参数校验,防止非法用户绕过浏览器 UI 页面通过工具直接向服务端提交参数。
第三,除了请求 Body 中的信息,请求头里的任何信息同样不能信任。我们要知道,来自请求头的 IP、Referer 和 Cookie 都有被篡改的可能性,相关数据只能用来参考和记录,不能用作重要业务逻辑。
第四,如果接口面向外部用户,那么一定不能出现用户标识这样的参数,当前用户的标识一定来自服务端,只有经过身份认证后的用户才会在服务端留下标识。如果你的接口现在面向内部其他服务,那么也要千万小心这样的接口只能内部使用,还可能需要进一步考虑服务端调用方的授权问题。
安全问题是木桶效应,整个系统的安全等级取决于安全性最薄弱的那个模块。在写业务代码的时候,要从我做起,建立最基本的安全意识,从源头杜绝低级安全问题。
今天用到的代码,我都放在了 GitHub 上,你可以点击这个链接查看。
思考与讨论
在讲述用户标识不能从客户端获取这个要点的时候,我提到开发同学可能会因为用户信息未打通而通过前端来传用户 ID。那我们有什么好办法来打通不同的系统甚至不同网站的用户标识吗
还有一类和客户端数据相关的漏洞非常重要,那就是 URL 地址中的数据。在把匿名用户重定向到登录页面的时候,我们一般会带上 redirectUrl这样用户登录后可以快速返回之前的页面。黑客可能会伪造一个活动链接由真实的网站 + 钓鱼的 redirectUrl 构成,发邮件诱导用户进行登录。用户登录时访问的其实是真的网站,所以不容易察觉到 redirectUrl 是钓鱼网站,登录后却来到了钓鱼网站,用户可能会不知不觉就把重要信息泄露了。这种安全问题,我们叫做开放重定向问题。你觉得,从代码层面应该怎么预防开放重定向问题呢?
你还遇到过因为信任 HTTP 请求中客户端传给服务端的信息导致的安全问题吗?我是朱晔,欢迎在评论区与我留言分享你的想法,也欢迎你把今天的内容分享给你的朋友或同事,一起交流。

View File

@@ -0,0 +1,329 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
28 安全兜底:涉及钱时,必须考虑防刷、限量和防重
今天,我要和你分享的主题是,任何涉及钱的代码必须要考虑防刷、限量和防重,要做好安全兜底。
涉及钱的代码,主要有以下三类。
第一,代码本身涉及有偿使用的三方服务。如果因为代码本身缺少授权、用量控制而被利用导致大量调用,势必会消耗大量的钱,给公司造成损失。有些三方服务可能采用后付款方式的结算,出现问题后如果没及时发现,下个月结算时就会收到一笔数额巨大的账单。
第二,代码涉及虚拟资产的发放,比如积分、优惠券等。虽然说虚拟资产不直接对应货币,但一般可以在平台兑换具有真实价值的资产。比如,优惠券可以在下单时使用,积分可以兑换积分商城的商品。所以从某种意义上说,虚拟资产就是具有一定价值的钱,但因为不直接涉及钱和外部资金通道,所以容易产生随意性发放而导致漏洞。
第三,代码涉及真实钱的进出。比如,对用户扣款,如果出现非正常的多次重复扣款,小则用户投诉、用户流失,大则被相关管理机构要求停业整改,影响业务。又比如,给用户发放返现的付款功能,如果出现漏洞造成重复付款,涉及 B 端的可能还好,但涉及 C 端用户的重复付款可能永远无法追回。
前段时间拼多多一夜之间被刷了大量 100 元无门槛优惠券的事情,就是限量和防刷出了问题。
今天,我们就通过三个例子,和你说明如何在代码层面做好安全兜底。
开放平台资源的使用需要考虑防刷
我以真实遇到的短信服务被刷案例,和你说说防刷。
有次短信账单月结时发现,之前每个月是几千元的短信费用,这个月突然变为了几万元。查数据库记录发现,之前是每天发送几千条短信验证码,从某天开始突然变为了每天几万条,但注册用户数并没有激增。显然,这是短信接口被刷了。
我们知道,短信验证码服务属于开放性服务,由用户侧触发,且因为是注册验证码所以不需要登录就可以使用。如果我们的发短信接口像这样没有任何防刷的防护,直接调用三方短信通道,就相当于“裸奔”,很容易被短信轰炸平台利用:
@GetMapping("wrong")
public void wrong() {
sendSMSCaptcha("13600000000");
}
private void sendSMSCaptcha(String mobile) {
//调用短信通道
}
对于短信验证码这种开放接口,程序逻辑内需要有防刷逻辑。好的防刷逻辑是,对正常使用的用户毫无影响,只有疑似异常使用的用户才会感受到。对于短信验证码,有如下 4 种可行的方式来防刷。
第一种方式,只有固定的请求头才能发送验证码。
也就是说,我们通过请求头中网页或 App 客户端传给服务端的一些额外参数,来判断请求是不是 App 发起的。其实,这种方式“防君子不防小人”。
比如,判断是否存在浏览器或手机型号、设备分辨率请求头。对于那些使用爬虫来抓取短信接口地址的程序来说,往往只能抓取到 URL而难以分析出请求发送短信还需要的额外请求头可以看作第一道基本防御。
第二种方式,只有先到过注册页面才能发送验证码。
对于普通用户来说,不管是通过 App 注册还是 H5 页面注册,一定是先进入注册页面才能看到发送验证码按钮,再点击发送。我们可以在页面或界面打开时请求固定的前置接口,为这个设备开启允许发送验证码的窗口,之后的请求发送验证码才是有效请求。
这种方式可以防御直接绕开固定流程,通过接口直接调用的发送验证码请求,并不会干扰普通用户。
第三种方式,控制相同手机号的发送次数和发送频次。
除非是短信无法收到,否则用户不太会请求了验证码后不完成注册流程,再重新请求。因此,我们可以限制同一手机号每天的最大请求次数。验证码的到达需要时间,太短的发送间隔没有意义,所以我们还可以控制发送的最短间隔。比如,我们可以控制相同手机号一天只能发送 10 次验证码,最短发送间隔 1 分钟。
第四种方式,增加前置图形验证码。
短信轰炸平台一般会收集很多免费短信接口,一个接口只会给一个用户发一次短信,所以控制相同手机号发送次数和间隔的方式不够有效。这时,我们可以考虑对用户体验稍微有影响,但也是最有效的方式作为保底,即将弹出图形验证码作为前置。
除了图形验证码,我们还可以使用其他更友好的人机验证手段(比如滑动、点击验证码等),甚至是引入比较新潮的无感知验证码方案(比如,通过判断用户输入手机号的打字节奏,来判断是用户还是机器),来改善用户体验。
此外,我们也可以考虑在监测到异常的情况下再弹出人机检测。比如,短时间内大量相同远端 IP 发送验证码的时候,才会触发人机检测。
总之,我们要确保,只有正常用户经过正常的流程才能使用开放平台资源,并且资源的用量在业务需求合理范围内。此外,还需要考虑做好短信发送量的实时监控,遇到发送量激增要及时报警。
接下来,我们一起看看限量的问题。
虚拟资产并不能凭空产生无限使用
虚拟资产虽然是平台方自己生产和控制,但如果生产出来可以立即使用就有立即变现的可能性。比如,因为平台 Bug 有大量用户领取高额优惠券,并立即下单使用。
在商家看来,这很可能只是一个用户支付的订单,并不会感知到用户使用平台方优惠券的情况;同时,因为平台和商家是事后结算的,所以会马上安排发货。而发货后基本就不可逆了,一夜之间造成了大量资金损失。
我们从代码层面模拟一个优惠券被刷的例子。
假设有一个 CouponCenter 类负责优惠券的产生和发放。如下是错误做法,只要调用方需要,就可以凭空产生无限的优惠券:
@Slf4j
public class CouponCenter {
//用于统计发了多少优惠券
AtomicInteger totalSent = new AtomicInteger(0);
public void sendCoupon(Coupon coupon) {
if (coupon != null)
totalSent.incrementAndGet();
}
public int getTotalSentCoupon() {
return totalSent.get();
}
//没有任何限制,来多少请求生成多少优惠券
public Coupon generateCouponWrong(long userId, BigDecimal amount) {
return new Coupon(userId, amount);
}
}
这样一来,使用 CouponCenter 的 generateCouponWrong 方法,想发多少优惠券就可以发多少:
@GetMapping("wrong")
public int wrong() {
CouponCenter couponCenter = new CouponCenter();
//发送10000个优惠券
IntStream.rangeClosed(1, 10000).forEach(i -> {
Coupon coupon = couponCenter.generateCouponWrong(1L, new BigDecimal("100"));
couponCenter.sendCoupon(coupon);
});
return couponCenter.getTotalSentCoupon();
}
更合适的做法是,把优惠券看作一种资源,其生产不是凭空的,而是需要事先申请,理由是:
虚拟资产如果最终可以对应到真实金钱上的优惠,那么,能发多少取决于运营和财务的核算,应该是有计划、有上限的。引言提到的无门槛优惠券,需要特别小心。有门槛优惠券的大量使用至少会带来大量真实的消费,而使用无门槛优惠券下的订单,可能用户一分钱都没有支付。
即使虚拟资产不值钱,大量不合常规的虚拟资产流入市场,也会冲垮虚拟资产的经济体系,造成虚拟货币的极速贬值。有量的控制才有价值。
资产的申请需要理由,甚至需要走流程,这样才可以追溯是什么活动需要、谁提出的申请,程序依据申请批次来发放。
接下来,我们按照这个思路改进一下程序。
首先,定义一个 CouponBatch 类,要产生优惠券必须先向运营申请优惠券批次,批次中包含了固定张数的优惠券、申请原因等信息:
//优惠券批次
@Data
public class CouponBatch {
private long id;
private AtomicInteger totalCount;
private AtomicInteger remainCount;
private BigDecimal amount;
private String reason;
}
在业务需要发放优惠券的时候,先申请批次,然后再通过批次发放优惠券:
@GetMapping("right")
public int right() {
CouponCenter couponCenter = new CouponCenter();
//申请批次
CouponBatch couponBatch = couponCenter.generateCouponBatch();
IntStream.rangeClosed(1, 10000).forEach(i -> {
Coupon coupon = couponCenter.generateCouponRight(1L, couponBatch);
//发放优惠券
couponCenter.sendCoupon(coupon);
});
return couponCenter.getTotalSentCoupon();
}
可以看到generateCouponBatch 方法申请批次时,设定了这个批次包含 100 张优惠券。在通过 generateCouponRight 方法发放优惠券时,每发一次都会从批次中扣除一张优惠券,发完了就没有了:
public Coupon generateCouponRight(long userId, CouponBatch couponBatch) {
if (couponBatch.getRemainCount().decrementAndGet() >= 0) {
return new Coupon(userId, couponBatch.getAmount());
} else {
log.info("优惠券批次 {} 剩余优惠券不足", couponBatch.getId());
return null;
}
}
public CouponBatch generateCouponBatch() {
CouponBatch couponBatch = new CouponBatch();
couponBatch.setAmount(new BigDecimal("100"));
couponBatch.setId(1L);
couponBatch.setTotalCount(new AtomicInteger(100));
couponBatch.setRemainCount(couponBatch.getTotalCount());
couponBatch.setReason("XXX活动");
return couponBatch;
}
这样改进后的程序,一个批次最多只能发放 100 张优惠券:
因为是 Demo所以我们只是凭空 new 出来一个 Coupon。在真实的生产级代码中一定是根据 CouponBatch 在数据库中插入一定量的 Coupon 记录,每一个优惠券都有唯一的 ID可跟踪、可注销。
最后,我们再看看防重。
钱的进出一定要和订单挂钩并且实现幂等
涉及钱的进出,需要做好以下两点。
第一,任何资金操作都需要在平台侧生成业务属性的订单,可以是优惠券发放订单,可以是返现订单,也可以是借款订单,一定是先有订单再去做资金操作。同时,订单的产生需要有业务属性。业务属性是指,订单不是凭空产生的,否则就没有控制的意义。比如,返现发放订单必须关联到原先的商品订单产生;再比如,借款订单必须关联到同一个借款合同产生。
第二,一定要做好防重,也就是实现幂等处理,并且幂等处理必须是全链路的。这里的全链路是指,从前到后都需要有相同的业务订单号来贯穿,实现最终的支付防重。
关于这两点,你可以参考下面的代码示例:
//错误每次使用UUID作为订单号
@GetMapping("wrong")
public void wrong(@RequestParam("orderId") String orderId) {
PayChannel.pay(UUID.randomUUID().toString(), "123", new BigDecimal("100"));
}
//正确:使用相同的业务订单号
@GetMapping("right")
public void right(@RequestParam("orderId") String orderId) {
PayChannel.pay(orderId, "123", new BigDecimal("100"));
}
//三方支付通道
public class PayChannel {
public static void pay(String orderId, String account, BigDecimal amount) {
...
}
}
对于支付操作,我们一定是调用三方支付公司的接口或银行接口进行处理的。一般而言,这些接口都会有商户订单号的概念,对于相同的商户订单号,无法进行重复的资金处理,所以三方公司的接口可以实现唯一订单号的幂等处理。
但是,业务系统在实现资金操作时容易犯的错是,没有自始至终地使用一个订单号作为商户订单号,透传给三方支付接口。出现这个问题的原因是,比较大的互联网公司一般会把支付独立一个部门。支付部门可能会针对支付做聚合操作,内部会维护一个支付订单号,然后使用支付订单号和三方支付接口交互。最终虽然商品订单是一个,但支付订单是多个,相同的商品订单因为产生多个支付订单导致多次支付。
如果说,支付出现了重复扣款,我们可以给用户进行退款操作,但给用户付款的操作一旦出现重复付款,就很难把钱追回来了,所以更要小心。
这,就是全链路的意义,从一开始就需要先有业务订单产生,然后使用相同的业务订单号一直贯穿到最后的资金通路,才能真正避免重复资金操作。
重点回顾
今天,我从安全兜底聊起,和你分享了涉及钱的业务最需要做的三方面工作,防刷、限量和防重。
第一,使用开放的、面向用户的平台资源要考虑防刷,主要包括正常使用流程识别、人机识别、单人限量和全局限量等手段。
第二,虚拟资产不能凭空产生,一定是先有发放计划、申请批次,然后通过批次来生产资产。这样才能达到限量、有审计、能追溯的目的。
第三,真实钱的进出操作要额外小心,做好防重处理。不能凭空去操作用户的账户,每次操作以真实的订单作为依据,通过业务订单号实现全链路的幂等控制。
如果程序逻辑涉及有价值的资源或是真实的钱,我们必须有敬畏之心。程序上线后,人是有休息时间的,但程序是一直运行着的,如果产生安全漏洞,就很可能在一夜之间爆发,被大量人利用导致大量的金钱损失。
除了在流程上做好防刷、限量和防重控制之外,我们还需要做好三方平台调用量、虚拟资产使用量、交易量、交易金额等重要数据的监控报警,这样即使出现问题也能第一时间发现。
今天用到的代码,我都放在了 GitHub 上,你可以点击这个链接查看。
思考与讨论
防重、防刷都是事前手段,如果我们的系统正在被攻击或利用,你有什么办法及时发现问题吗?
任何三方资源的使用一般都会定期对账,如果在对账中发现我们系统记录的调用量低于对方系统记录的使用量,你觉得一般是什么问题引起的呢?
有关安全兜底,你还有什么心得吗?我是朱晔,欢迎在评论区与我留言分享你的想法,也欢迎你把今天的内容分享给你的朋友或同事,一起交流。

View File

@@ -0,0 +1,877 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
30 如何正确保存和传输敏感数据?
今天,我们从安全角度来聊聊用户名、密码、身份证等敏感信息,应该怎么保存和传输。同时,你还可以进一步复习加密算法中的散列、对称加密和非对称加密算法,以及 HTTPS 等相关知识。
应该怎样保存用户密码?
最敏感的数据恐怕就是用户的密码了。黑客一旦窃取了用户密码,或许就可以登录进用户的账号,消耗其资产、发布不良信息等;更可怕的是,有些用户至始至终都是使用一套密码,密码一旦泄露,就可以被黑客用来登录全网。
为了防止密码泄露,最重要的原则是不要保存用户密码。你可能会觉得很好笑,不保存用户密码,之后用户登录的时候怎么验证?其实,我指的是不保存原始密码,这样即使拖库也不会泄露用户密码。
我经常会听到大家说,不要明文保存用户密码,应该把密码通过 MD5 加密后保存。这的确是一个正确的方向,但这个说法并不准确。
首先MD5 其实不是真正的加密算法。所谓加密算法,是可以使用密钥把明文加密为密文,随后还可以使用密钥解密出明文,是双向的。
而 MD5 是散列、哈希算法或者摘要算法。不管多长的数据,使用 MD5 运算后得到的都是固定长度的摘要信息或指纹信息无法再解密为原始数据。所以MD5 是单向的。最重要的是,仅仅使用 MD5 对密码进行摘要,并不安全。
比如,使用如下代码在保持用户信息时,对密码进行了 MD5 计算:
UserData userData = new UserData();
userData.setId(1L);
userData.setName(name);
//密码字段使用MD5哈希后保存
userData.setPassword(DigestUtils.md5Hex(password));
return userRepository.save(userData);
通过输出,可以看到密码是 32 位的 MD5
"password": "325a2cc052914ceeb8c19016c091d2ac"
到某 MD5 破解网站上输入这个 MD5不到 1 秒就得到了原始密码:
其实你可以想一下,虽然 MD5 不可解密,但是我们可以构建一个超大的数据库,把所有 20 位以内的数字和字母组合的密码全部计算一遍 MD5 存进去,需要解密的时候搜索一下 MD5 就可以得到原始值了。这就是字典表。
目前,有些 MD5 解密网站使用的是彩虹表,是一种使用时间空间平衡的技术,即可以使用更大的空间来降低破解时间,也可以使用更长的破解时间来换取更小的空间。
此外,你可能会觉得多次 MD5 比较安全,其实并不是这样。比如,如下代码使用两次 MD5 进行摘要:
userData.setPassword(DigestUtils.md5Hex(DigestUtils.md5Hex( password)));
得到下面的 MD5
"password": "ebbca84993fe002bac3a54e90d677d09"
也可以破解出密码,并且破解网站还告知我们这是两次 MD5 算法:
所以直接保存 MD5 后的密码是不安全的。一些同学可能会说,还需要加盐。是的,但是加盐如果不当,还是非常不安全,比较重要的有两点。
第一,不能在代码中写死盐,且盐需要有一定的长度,比如这样:
userData.setPassword(DigestUtils.md5Hex("salt" + password));
得到了如下 MD5
"password": "58b1d63ed8492f609993895d6ba6b93a"
对于这样一串 MD5虽然破解网站上找不到原始密码但是黑客可以自己注册一个账号使用一个简单的密码比如 1
"password": "55f312f84e7785aa1efa552acbf251db"
然后,再去破解网站试一下这个 MD5就可以得到原始密码是 salt也就知道了盐值是 salt
其实,知道盐是什么没什么关系,关键的是我们是在代码里写死了盐,并且盐很短、所有用户都是这个盐。这么做有三个问题:
因为盐太短、太简单了,如果用户原始密码也很简单,那么整个拼起来的密码也很短,这样一般的 MD5 破解网站都可以直接解密这个 MD5除去盐就知道原始密码了。
相同的盐,意味着使用相同密码的用户 MD5 值是一样的,知道了一个用户的密码就可能知道了多个。
我们也可以使用这个盐来构建一张彩虹表,虽然会花不少代价,但是一旦构建完成,所有人的密码都可以被破解。
所以,最好是每一个密码都有独立的盐,并且盐要长一点,比如超过 20 位。
第二,虽然说每个人的盐最好不同,但我也不建议将一部分用户数据作为盐。比如,使用用户名作为盐:
userData.setPassword(DigestUtils.md5Hex(name + password));
如果世界上所有的系统都是按照这个方案来保存密码,那么 root、admin 这样的用户使用再复杂的密码也总有一天会被破解,因为黑客们完全可以针对这些常用用户名来做彩虹表。所以,盐最好是随机的值,并且是全球唯一的,意味着全球不可能有现成的彩虹表给你用。
正确的做法是,使用全球唯一的、和用户无关的、足够长的随机值作为盐。比如,可以使用 UUID 作为盐,把盐一起保存到数据库中:
userData.setSalt(UUID.randomUUID().toString());
userData.setPassword(DigestUtils.md5Hex(userData.getSalt() + password));
并且每次用户修改密码的时候都重新计算盐,重新保存新的密码。你可能会问,盐保存在数据库中,那被拖库了不是就可以看到了吗?难道不应该加密保存吗?
在我看来,盐没有必要加密保存。盐的作用是,防止通过彩虹表快速实现密码“解密”,如果用户的盐都是唯一的,那么生成一次彩虹表只可能拿到一个用户的密码,这样黑客的动力会小很多。
更好的做法是,不要使用像 MD5 这样快速的摘要算法,而是使用慢一点的算法。比如 Spring Security 已经废弃了 MessageDigestPasswordEncoder推荐使用 BCryptPasswordEncoder也就是BCrypt来进行密码哈希。BCrypt 是为保存密码设计的算法,相比 MD5 要慢很多。
写段代码来测试一下 MD5以及使用不同代价因子的 BCrypt看看哈希一次密码的耗时。
private static BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
@GetMapping("performance")
public void performance() {
StopWatch stopWatch = new StopWatch();
String password = "Abcd1234";
stopWatch.start("MD5");
//MD5
DigestUtils.md5Hex(password);
stopWatch.stop();
stopWatch.start("BCrypt(10)");
//代价因子为10的BCrypt
String hash1 = BCrypt.gensalt(10);
BCrypt.hashpw(password, hash1);
System.out.println(hash1);
stopWatch.stop();
stopWatch.start("BCrypt(12)");
//代价因子为12的BCrypt
String hash2 = BCrypt.gensalt(12);
BCrypt.hashpw(password, hash2);
System.out.println(hash2);
stopWatch.stop();
stopWatch.start("BCrypt(14)");
//代价因子为14的BCrypt
String hash3 = BCrypt.gensalt(14);
BCrypt.hashpw(password, hash3);
System.out.println(hash3);
stopWatch.stop();
log.info("{}", stopWatch.prettyPrint());
}
可以看到MD5 只需要 0.8 毫秒,而三次 BCrypt 哈希(代价因子分别设置为 10、12 和 14耗时分别是 82 毫秒、312 毫秒和 1.2 秒:
也就是说,如果制作 8 位密码长度的 MD5 彩虹表需要 5 个月,那么对于 BCrypt 来说,可能就需要几十年,大部分黑客应该都没有这个耐心。
我们写一段代码观察下BCryptPasswordEncoder 生成的密码哈希的规律:
@GetMapping("better")
public UserData better(@RequestParam(value = "name", defaultValue = "zhuye") String name, @RequestParam(value = "password", defaultValue = "Abcd1234") String password) {
UserData userData = new UserData();
userData.setId(1L);
userData.setName(name);
//保存哈希后的密码
userData.setPassword(passwordEncoder.encode(password));
userRepository.save(userData);
//判断密码是否匹配
log.info("match ? {}", passwordEncoder.matches(password, userData.getPassword()));
return userData;
}
我们可以发现三点规律。
第一,我们调用 encode、matches 方法进行哈希、做密码比对的时候不需要传入盐。BCrypt 把盐作为了算法的一部分,强制我们遵循安全保存密码的最佳实践。
第二,生成的盐和哈希后的密码拼在了一起:\(是字段分隔符,其中第一个\)后的 2a 代表算法版本,第二个\(后的 10 是代价因子(默认是 10代表 2 的 10 次方次哈希),第三个\)后的 22 个字符是盐,再后面是摘要。所以说,我们不需要使用单独的数据库字段来保存盐。
"password": "$2a$10$wPWdQwfQO2lMxqSIb6iCROXv7lKnQq5XdMO96iCYCj7boK9pk6QPC"
//格式为:$<ver>$<cost>$<salt><digest>
第三代价因子的值越大BCrypt 哈希的耗时越久。因此,对于代价因子的值,更建议的实践是,根据用户的忍耐程度和硬件,设置一个尽可能大的值。
最后,我们需要注意的是,虽然黑客已经很难通过彩虹表来破解密码了,但是仍然有可能暴力破解密码,也就是对于同一个用户名使用常见的密码逐一尝试登录。因此,除了做好密码哈希保存的工作外,我们还要建设一套完善的安全防御机制,在感知到暴力破解危害的时候,开启短信验证、图形验证码、账号暂时锁定等防御机制来抵御暴力破解。
应该怎么保存姓名和身份证?
我们把姓名和身份证,叫做二要素。
现在互联网非常发达,很多服务都可以在网上办理,很多网站仅仅依靠二要素来确认你是谁。所以,二要素是比较敏感的数据,如果在数据库中明文保存,那么数据库被攻破后,黑客就可能拿到大量的二要素信息。如果这些二要素被用来申请贷款等,后果不堪设想。
之前我们提到的单向散列算法,显然不适合用来加密保存二要素,因为数据无法解密。这个时候,我们需要选择真正的加密算法。可供选择的算法,包括对称加密和非对称加密算法两类。
对称加密算法,是使用相同的密钥进行加密和解密。使用对称加密算法来加密双方的通信的话,双方需要先约定一个密钥,加密方才能加密,接收方才能解密。如果密钥在发送的时候被窃取,那么加密就是白忙一场。因此,这种加密方式的特点是,加密速度比较快,但是密钥传输分发有泄露风险。
非对称加密算法,或者叫公钥密码算法。公钥密码是由一对密钥对构成的,使用公钥或者说加密密钥来加密,使用私钥或者说解密密钥来解密,公钥可以任意公开,私钥不能公开。使用非对称加密的话,通信双方可以仅分享公钥用于加密,加密后的数据没有私钥无法解密。因此,这种加密方式的特点是,加密速度比较慢,但是解决了密钥的配送分发安全问题。
但是,对于保存敏感信息的场景来说,加密和解密都是我们的服务端程序,不太需要考虑密钥的分发安全性,也就是说使用非对称加密算法没有太大的意义。在这里,我们使用对称加密算法来加密数据。
接下来,我就重点与你说说对称加密算法。对称加密常用的加密算法,有 DES、3DES 和 AES。
虽然,现在仍有许多老项目使用了 DES 算法,但我不推荐使用。在 1999 年的 DES 挑战赛 3 中DES 密码破解耗时不到一天,而现在 DES 密码破解更快,使用 DES 来加密数据非常不安全。因此,在业务代码中要避免使用 DES 加密。
而 3DES 算法,是使用不同的密钥进行三次 DES 串联调用,虽然解决了 DES 不够安全的问题,但是比 AES 慢,也不太推荐。
AES 是当前公认的比较安全兼顾性能的对称加密算法。不过严格来说AES 并不是实际的算法名称而是算法标准。2000 年NIST 选拔出 Rijndael 算法作为 AES 的标准。
AES 有一个重要的特点就是分组加密体制,一次只能处理 128 位的明文,然后生成 128 位的密文。如果要加密很长的明文,那么就需要迭代处理,而迭代方式就叫做模式。网上很多使用 AES 来加密的代码,使用的是最简单的 ECB 模式(也叫电子密码本模式),其基本结构如下:
可以看到,这种结构有两个风险:明文和密文是一一对应的,如果明文中有重复的分组,那么密文中可以观察到重复,掌握密文的规律;因为每一个分组是独立加密和解密的 ,如果密文分组的顺序,也可以反过来操纵明文,那么就可以实现不解密密文的情况下,来修改明文。
我们写一段代码来测试下。在下面的代码中,我们使用 ECB 模式测试:
加密一段包含 16 个字符的字符串,得到密文 A然后把这段字符串复制一份成为一个 32 个字符的字符串,再进行加密得到密文 B。我们验证下密文 B 是不是重复了一遍的密文 A。
模拟银行转账的场景,假设整个数据由发送方账号、接收方账号、金额三个字段构成。我们尝试改变密文中数据的顺序来操纵明文。
private static final String KEY = "secretkey1234567"; //密钥
//测试ECB模式
@GetMapping("ecb")
public void ecb() throws Exception {
Cipher cipher = Cipher.getInstance("AES/ECB/NoPadding");
test(cipher, null);
}
//获取加密秘钥帮助方法
private static SecretKeySpec setKey(String secret) {
return new SecretKeySpec(secret.getBytes(), "AES");
}
//测试逻辑
private static void test(Cipher cipher, AlgorithmParameterSpec parameterSpec) throws Exception {
//初始化Cipher
cipher.init(Cipher.ENCRYPT_MODE, setKey(KEY), parameterSpec);
//加密测试文本
System.out.println("一次:" + Hex.encodeHexString(cipher.doFinal("abcdefghijklmnop".getBytes())));
//加密重复一次的测试文本
System.out.println("两次:" + Hex.encodeHexString(cipher.doFinal("abcdefghijklmnopabcdefghijklmnop".getBytes())));
//下面测试是否可以通过操纵密文来操纵明文
//发送方账号
byte[] sender = "1000000000012345".getBytes();
//接收方账号
byte[] receiver = "1000000000034567".getBytes();
//转账金额
byte[] money = "0000000010000000".getBytes();
//加密发送方账号
System.out.println("发送方账号:" + Hex.encodeHexString(cipher.doFinal(sender)));
//加密接收方账号
System.out.println("接收方账号:" + Hex.encodeHexString(cipher.doFinal(receiver)));
//加密金额
System.out.println("金额:" + Hex.encodeHexString(cipher.doFinal(money)));
//加密完整的转账信息
byte[] result = cipher.doFinal(ByteUtils.concatAll(sender, receiver, money));
System.out.println("完整数据:" + Hex.encodeHexString(result));
//用于操纵密文的临时字节数组
byte[] hack = new byte[result.length];
//把密文前两段交换
System.arraycopy(result, 16, hack, 0, 16);
System.arraycopy(result, 0, hack, 16, 16);
System.arraycopy(result, 32, hack, 32, 16);
cipher.init(Cipher.DECRYPT_MODE, setKey(KEY), parameterSpec);
//尝试解密
System.out.println("原始明文:" + new String(ByteUtils.concatAll(sender, receiver, money)));
System.out.println("操纵密文:" + new String(cipher.doFinal(hack)));
}
输出如下:
可以看到:
两个相同明文分组产生的密文,就是两个相同的密文分组叠在一起。
在不知道密钥的情况下,我们操纵密文实现了对明文数据的修改,对调了发送方账号和接收方账号。
所以说ECB 模式虽然简单但是不安全不推荐使用。我们再看一下另一种常用的加密模式CBC 模式。
CBC 模式,在解密或解密之前引入了 XOR 运算,第一个分组使用外部提供的初始化向量 IV从第二个分组开始使用前一个分组的数据这样即使明文是一样的加密后的密文也是不同的并且分组的顺序不能任意调换。这就解决了 ECB 模式的缺陷:
我们把之前的代码修改为 CBC 模式,再次进行测试:
private static final String initVector = "abcdefghijklmnop"; //初始化向量
@GetMapping("cbc")
public void cbc() throws Exception {
Cipher cipher = Cipher.getInstance("AES/CBC/NoPadding");
IvParameterSpec iv = new IvParameterSpec(initVector.getBytes("UTF-8"));
test(cipher, iv);
}
可以看到,相同的明文字符串复制一遍得到的密文并不是重复两个密文分组,并且调换密文分组的顺序无法操纵明文:
其实,除了 ECB 模式和 CBC 模式外AES 算法还有 CFB、OFB、CTR 模式,你可以参考这里了解它们的区别。《实用密码学》一书比较推荐的是 CBC 和 CTR 模式。还需要注意的是ECB 和 CBC 模式还需要设置合适的填充模式,才能处理超过一个分组的数据。
对于敏感数据保存,除了选择 AES+ 合适模式进行加密外,我还推荐以下几个实践:
不要在代码中写死一个固定的密钥和初始化向量,最好和之前提到的盐一样,是唯一、独立并且每次都变化的。
推荐使用独立的加密服务来管控密钥、做加密操作,千万不要把密钥和密文存在一个数据库,加密服务需要设置非常高的管控标准。
数据库中不能保存明文的敏感信息,但可以保存脱敏的信息。普通查询的时候,直接查脱敏信息即可。
接下来,我们按照这个策略完成相关代码实现。
第一步,对于用户姓名和身份证,我们分别保存三个信息,脱敏后的明文、密文和加密 ID。加密服务加密后返回密文和加密 ID随后使用加密 ID 来请求加密服务进行解密:
@Data
@Entity
public class UserData {
@Id
private Long id;
private String idcard;//脱敏的身份证
private Long idcardCipherId;//身份证加密ID
private String idcardCipherText;//身份证密文
private String name;//脱敏的姓名
private Long nameCipherId;//姓名加密ID
private String nameCipherText;//姓名密文
}
第二步,加密服务数据表保存加密 ID、初始化向量和密钥。加密服务表中没有密文实现了密文和密钥分离保存
@Data
@Entity
public class CipherData {
@Id
@GeneratedValue(strategy = AUTO)
private Long id;
private String iv;//初始化向量
private String secureKey;//密钥
}
第三步,加密服务使用 GCM 模式( Galois/Counter Mode的 AES-256 对称加密算法,也就是 AES-256-GCM。
这是一种AEADAuthenticated Encryption with Associated Data认证加密算法除了能实现普通加密算法提供的保密性之外还能实现可认证性和密文完整性是目前最推荐的 AES 模式。
使用类似 GCM 的 AEAD 算法进行加解密,除了需要提供初始化向量和密钥之外,还可以提供一个 AAD附加认证数据additional authenticated data用于验证未包含在明文中的附加信息解密时不使用加密时的 AAD 将解密失败。其实GCM 模式的内部使用的就是 CTR 模式,只不过还使用了 GMAC 签名算法,对密文进行签名实现完整性校验。
接下来,我们实现基于 AES-256-GCM 的加密服务,包含下面的主要逻辑:
加密时允许外部传入一个 AAD 用于认证,加密服务每次都会使用新生成的随机值作为密钥和初始化向量。
在加密后,加密服务密钥和初始化向量保存到数据库中,返回加密 ID 作为本次加密的标识。
应用解密时,需要提供加密 ID、密文和加密时的 AAD 来解密。加密服务使用加密 ID从数据库查询出密钥和初始化向量。
这段逻辑的实现代码比较长,我加了详细注释方便你仔细阅读:
@Service
public class CipherService {
//密钥长度
public static final int AES_KEY_SIZE = 256;
//初始化向量长度
public static final int GCM_IV_LENGTH = 12;
//GCM身份认证Tag长度
public static final int GCM_TAG_LENGTH = 16;
@Autowired
private CipherRepository cipherRepository;
//内部加密方法
public static byte[] doEncrypt(byte[] plaintext, SecretKey key, byte[] iv, byte[] aad) throws Exception {
//加密算法
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
//Key规范
SecretKeySpec keySpec = new SecretKeySpec(key.getEncoded(), "AES");
//GCM参数规范
GCMParameterSpec gcmParameterSpec = new GCMParameterSpec(GCM_TAG_LENGTH * 8, iv);
//加密模式
cipher.init(Cipher.ENCRYPT_MODE, keySpec, gcmParameterSpec);
//设置aad
if (aad != null)
cipher.updateAAD(aad);
//加密
byte[] cipherText = cipher.doFinal(plaintext);
return cipherText;
}
//内部解密方法
public static String doDecrypt(byte[] cipherText, SecretKey key, byte[] iv, byte[] aad) throws Exception {
//加密算法
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
//Key规范
SecretKeySpec keySpec = new SecretKeySpec(key.getEncoded(), "AES");
//GCM参数规范
GCMParameterSpec gcmParameterSpec = new GCMParameterSpec(GCM_TAG_LENGTH * 8, iv);
//解密模式
cipher.init(Cipher.DECRYPT_MODE, keySpec, gcmParameterSpec);
//设置aad
if (aad != null)
cipher.updateAAD(aad);
//解密
byte[] decryptedText = cipher.doFinal(cipherText);
return new String(decryptedText);
}
//加密入口
public CipherResult encrypt(String data, String aad) throws Exception {
//加密结果
CipherResult encryptResult = new CipherResult();
//密钥生成器
KeyGenerator keyGenerator = KeyGenerator.getInstance("AES");
//生成密钥
keyGenerator.init(AES_KEY_SIZE);
SecretKey key = keyGenerator.generateKey();
//IV数据
byte[] iv = new byte[GCM_IV_LENGTH];
//随机生成IV
SecureRandom random = new SecureRandom();
random.nextBytes(iv);
//处理aad
byte[] aaddata = null;
if (!StringUtils.isEmpty(aad))
aaddata = aad.getBytes();
//获得密文
encryptResult.setCipherText(Base64.getEncoder().encodeToString(doEncrypt(data.getBytes(), key, iv, aaddata)));
//加密上下文数据
CipherData cipherData = new CipherData();
//保存IV
cipherData.setIv(Base64.getEncoder().encodeToString(iv));
//保存密钥
cipherData.setSecureKey(Base64.getEncoder().encodeToString(key.getEncoded()));
cipherRepository.save(cipherData);
//返回本地加密ID
encryptResult.setId(cipherData.getId());
return encryptResult;
}
//解密入口
public String decrypt(long cipherId, String cipherText, String aad) throws Exception {
//使用加密ID找到加密上下文数据
CipherData cipherData = cipherRepository.findById(cipherId).orElseThrow(() -> new IllegalArgumentException("invlaid cipherId"));
//加载密钥
byte[] decodedKey = Base64.getDecoder().decode(cipherData.getSecureKey());
//初始化密钥
SecretKey originalKey = new SecretKeySpec(decodedKey, 0, decodedKey.length, "AES");
//加载IV
byte[] decodedIv = Base64.getDecoder().decode(cipherData.getIv());
//处理aad
byte[] aaddata = null;
if (!StringUtils.isEmpty(aad))
aaddata = aad.getBytes();
//解密
return doDecrypt(Base64.getDecoder().decode(cipherText.getBytes()), originalKey, decodedIv, aaddata);
}
}
第四步,分别实现加密和解密接口用于测试。
我们可以让用户选择,如果需要保护二要素的话,就自己输入一个查询密码作为 AAD。系统需要读取用户敏感信息的时候还需要用户提供这个密码否则无法解密。这样一来即使黑客拿到了用户数据库的密文、加密服务的密钥和 IV也会因为缺少 AAD 无法解密:
@Autowired
private CipherService cipherService;
//加密
@GetMapping("right")
public UserData right(@RequestParam(value = "name", defaultValue = "朱晔") String name,
@RequestParam(value = "idcard", defaultValue = "300000000000001234") String idCard,
@RequestParam(value = "aad", required = false)String aad) throws Exception {
UserData userData = new UserData();
userData.setId(1L);
//脱敏姓名
userData.setName(chineseName(name));
//脱敏身份证
userData.setIdcard(idCard(idCard));
//加密姓名
CipherResult cipherResultName = cipherService.encrypt(name,aad);
userData.setNameCipherId(cipherResultName.getId());
userData.setNameCipherText(cipherResultName.getCipherText());
//加密身份证
CipherResult cipherResultIdCard = cipherService.encrypt(idCard,aad);
userData.setIdcardCipherId(cipherResultIdCard.getId());
userData.setIdcardCipherText(cipherResultIdCard.getCipherText());
return userRepository.save(userData);
}
//解密
@GetMapping("read")
public void read(@RequestParam(value = "aad", required = false)String aad) throws Exception {
//查询用户信息
UserData userData = userRepository.findById(1L).get();
//使用AAD来解密姓名和身份证
log.info("name : {} idcard : {}",
cipherService.decrypt(userData.getNameCipherId(), userData.getNameCipherText(),aad),
cipherService.decrypt(userData.getIdcardCipherId(), userData.getIdcardCipherText(),aad));
}
//脱敏身份证
private static String idCard(String idCard) {
String num = StringUtils.right(idCard, 4);
return StringUtils.leftPad(num, StringUtils.length(idCard), "*");
}
//脱敏姓名
public static String chineseName(String chineseName) {
String name = StringUtils.left(chineseName, 1);
return StringUtils.rightPad(name, StringUtils.length(chineseName), "*");
访问加密接口获得如下结果,可以看到数据库表中只有脱敏数据和密文:
{"id":1,"name":"朱*","idcard":"**************1234","idcardCipherId":26346,"idcardCipherText":"t/wIh1XTj00wJP1Lt3aGzSvn9GcqQWEwthN58KKU4KZ4Tw==","nameCipherId":26347,"nameCipherText":"+gHrk1mWmveBMVUo+CYon8Zjj9QAtw=="}
访问解密接口,可以看到解密成功了:
[21:46:00.079] [http-nio-45678-exec-6] [INFO ] [o.g.t.c.s.s.StoreIdCardController:102 ] - name : 朱晔 idcard : 300000000000001234
如果 AAD 输入不对,会得到如下异常:
javax.crypto.AEADBadTagException: Tag mismatch!
at com.sun.crypto.provider.GaloisCounterMode.decryptFinal(GaloisCounterMode.java:578)
at com.sun.crypto.provider.CipherCore.finalNoPadding(CipherCore.java:1116)
at com.sun.crypto.provider.CipherCore.fillOutputBuffer(CipherCore.java:1053)
at com.sun.crypto.provider.CipherCore.doFinal(CipherCore.java:853)
at com.sun.crypto.provider.AESCipher.engineDoFinal(AESCipher.java:446)
at javax.crypto.Cipher.doFinal(Cipher.java:2164)
经过这样的设计二要素就比较安全了。黑客要查询用户二要素的话需要同时拿到密文、IV+ 密钥、AAD。而这三者可能由三方掌管要全部拿到比较困难。
用一张图说清楚 HTTPS
我们知道HTTP 协议传输数据使用的是明文。那在传输敏感信息的场景下,如果客户端和服务端中间有一个黑客作为中间人拦截请求,就可以窃听到这些数据,还可以修改客户端传过来的数据。这就是很大的安全隐患。
为解决这个安全隐患,有了 HTTPS 协议。HTTPS=SSL/TLS+HTTP通过使用一系列加密算法来确保信息安全传输以实现数据传输的机密性、完整性和权威性。
机密性:使用非对称加密来加密密钥,然后使用密钥来加密数据,既安全又解决了非对称加密大量数据慢的问题。你可以做一个实验来测试两者的差距。
完整性:使用散列算法对信息进行摘要,确保信息完整无法被中间人篡改。
权威性:使用数字证书,来确保我们是在和合法的服务端通信。
可以看出,理解 HTTPS 的流程将有助于我们理解各种加密算法的区别以及证书的意义。此外SSL/TLS 还是混合加密系统的一个典范,如果你需要自己开发应用层数据加密系统,也可以参考它的流程。
那么,我们就来看看 HTTPS TLS 1.2 连接RSA 握手)的整个过程吧。
作为准备工作,网站管理员需要申请并安装 CA 证书到服务端。CA 证书中包含非对称加密的公钥、网站域名等信息,密钥是服务端自己保存的,不会在任何地方公开。
建立 HTTPS 连接的过程,首先是 TCP 握手,然后是 TLS 握手的一系列工作,包括:
客户端告知服务端自己支持的密码套件(比如 TLS_RSA_WITH_AES_256_GCM_SHA384其中 RSA 是密钥交换的方式AES_256_GCM 是加密算法SHA384 是消息验证摘要算法),提供客户端随机数。
服务端应答选择的密码套件,提供服务端随机数。
服务端发送 CA 证书给客户端,客户端验证 CA 证书(后面详细说明)。
客户端生成 PreMasterKey并使用非对称加密 + 公钥加密 PreMasterKey。
客户端把加密后的 PreMasterKey 传给服务端。
服务端使用非对称加密 + 私钥解密得到 PreMasterKey并使用 PreMasterKey+ 两个随机数,生成 MasterKey。
客户端也使用 PreMasterKey+ 两个随机数生成 MasterKey。
客户端告知服务端之后将进行加密传输。
客户端使用 MasterKey 配合对称加密算法,进行对称加密测试。
服务端也使用 MasterKey 配合对称加密算法,进行对称加密测试。
接下来,客户端和服务端的所有通信都是加密通信,并且数据通过签名确保无法篡改。你可能会问,客户端怎么验证 CA 证书呢?
其实CA 证书是一个证书链,你可以看一下上图的左边部分:
从服务端拿到的 CA 证书是用户证书,我们需要通过证书中的签发人信息找到上级中间证书,再网上找到根证书。
根证书只有为数不多的权威机构才能生成,一般预置在 OS 中,根本无法伪造。
找到根证书后,提取其公钥来验证中间证书的签名,判断其权威性。
最后再拿到中间证书的公钥,验证用户证书的签名。
这,就验证了用户证书的合法性,然后再校验其有效期、域名等信息进一步验证有效性。
总结一下TLS 通过巧妙的流程和算法搭配解决了传输安全问题:使用对称加密加密数据,使用非对称加密算法确保密钥无法被中间人解密;使用 CA 证书链认证,确保中间人无法伪造自己的证书和公钥。
如果网站涉及敏感数据的传输,必须使用 HTTPS 协议。作为用户,如果你看到网站不是 HTTPS 的或者看到无效证书警告,也不应该继续使用这个网站,以免敏感信息被泄露。
重点回顾
今天,我们一起学习了如何保存和传输敏感数据。我来带你回顾一下重点内容。
对于数据保存,你需要记住两点:
用户密码不能加密保存,更不能明文保存,需要使用全球唯一的、具有一定长度的、随机的盐,配合单向散列算法保存。使用 BCrypt 算法,是一个比较好的实践。
诸如姓名和身份证这种需要可逆解密查询的敏感信息,需要使用对称加密算法保存。我的建议是,把脱敏数据和密文保存在业务数据库,独立使用加密服务来做数据加解密;对称加密需要用到的密钥和初始化向量,可以和业务数据库分开保存。
对于数据传输,则务必通过 SSL/TLS 进行传输。对于用于客户端到服务端传输数据的 HTTP我们需要使用基于 SSL/TLS 的 HTTPS。对于一些走 TCP 的 RPC 服务,同样可以使用 SSL/TLS 来确保传输安全。
最后,我要提醒你的是,如果不确定应该如何实现加解密方案或流程,可以咨询公司内部的安全专家,或是参考业界各大云厂商的方案,切勿自己想当然地去设计流程,甚至创造加密算法。
今天用到的代码,我都放在了 GitHub 上,你可以点击这个链接查看。
思考与讨论
虽然我们把用户名和密码脱敏加密保存在数据库中,但日志中可能还存在明文的敏感数据。你有什么思路在框架或中间件层面,对日志进行脱敏吗?
你知道 HTTPS 双向认证的目的是什么吗?流程上又有什么区别呢?
关于各种加密算法,你还遇到过什么坑吗?你又是如何保存敏感数据的呢?我是朱晔,欢迎在评论区与我留言分享你的想法,也欢迎你把今天的内容分享给你的朋友或同事,一起交流。

View File

@@ -0,0 +1,731 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
31 加餐1带你吃透课程中Java 8的那些重要知识点
Java 8 是目前最常用的 JDK 版本,在增强代码可读性、简化代码方面,相比 Java 7 增加了很多功能,比如 Lambda、Stream 流操作、并行流ParallelStream、Optional 可空类型、新日期时间类型等。
这个课程中的所有案例,都充分使用了 Java 8 的各种特性来简化代码。这也就意味着,如果你不了解这些特性的话,理解课程内的 Demo 可能会有些困难。因此,我将这些特性,单独拎了出来组成了两篇加餐。由于后面有单独一节课去讲 Java 8 的日期时间类型,所以这里就不赘述了。
如何在项目中用上 Lambda 表达式和 Stream 操作?
Java 8 的特性有很多,除了这两篇加餐外,我再给你推荐一本全面介绍 Java 8 的书叫《Java 实战(第二版)》。此外,有同学在留言区问,怎么把 Lambda 表达式和 Stream 操作运用到项目中。其实,业务代码中可以使用这些特性的地方有很多。
这里,为了帮助你学习,并把这些特性用到业务开发中,我有三个小建议。
第一,从 List 的操作开始,先尝试把遍历 List 来筛选数据和转换数据的操作,使用 Stream 的 filter 和 map 实现,这是 Stream 最常用、最基本的两个 API。你可以重点看看接下来两节的内容来入门。
第二,使用高级的 IDE 来写代码,以此找到可以利用 Java 8 语言特性简化代码的地方。比如,对于 IDEA我们可以把匿名类型使用 Lambda 替换的检测规则,设置为 Error 级别严重程度:
这样运行 IDEA 的 Inspect Code 的功能,可以在 Error 级别的错误中看到这个问题,引起更多关注,帮助我们建立使用 Lambda 表达式的习惯:
第三,如果你不知道如何把匿名类转换为 Lambda 表达式,可以借助 IDE 来重构:
反过来,如果你在学习课程内案例时,如果感觉阅读 Lambda 表达式和 Stream API 比较吃力,同样可以借助 IDE 把 Java 8 的写法转换为使用循环的写法:
或者是把 Lambda 表达式替换为匿名类:
Lambda 表达式
Lambda 表达式的初衷是进一步简化匿名类的语法不过实现上Lambda 表达式并不是匿名类的语法糖),使 Java 走向函数式编程。对于匿名类,虽然没有类名,但还是要给出方法定义。这里有个例子,分别使用匿名类和 Lambda 表达式创建一个线程打印字符串:
//匿名类
new Thread(new Runnable(){
@Override
public void run(){
System.out.println("hello1");
}
}).start();
//Lambda表达式
new Thread(() -> System.out.println("hello2")).start();
那么Lambda 表达式如何匹配 Java 的类型系统呢?
答案就是,函数式接口。
函数式接口是一种只有单一抽象方法的接口,使用 @FunctionalInterface 来描述,可以隐式地转换成 Lambda 表达式。使用 Lambda 表达式来实现函数式接口,不需要提供类名和方法定义,通过一行代码提供函数式接口的实例,就可以让函数成为程序中的头等公民,可以像普通数据一样作为参数传递,而不是作为一个固定的类中的固定方法。
函数式接口到底是什么样的呢java.util.function 包中定义了各种函数式接口。比如,用于提供数据的 Supplier 接口,就只有一个 get 抽象方法,没有任何入参、有一个返回值:
@FunctionalInterface
public interface Supplier<T> {
/**
* Gets a result.
*
* @return a result
*/
T get();
}
我们可以使用 Lambda 表达式或方法引用,来得到 Supplier 接口的实例:
//使用Lambda表达式提供Supplier接口实现返回OK字符串
Supplier<String> stringSupplier = ()->"OK";
//使用方法引用提供Supplier接口实现返回空字符串
Supplier<String> supplier = String::new;
这样,是不是很方便?为了帮你掌握函数式接口及其用法,我再举几个使用 Lambda 表达式或方法引用来构建函数的例子:
//Predicate接口是输入一个参数返回布尔值。我们通过and方法组合两个Predicate条件判断是否值大于0并且是偶数
Predicate<Integer> positiveNumber = i -> i > 0;
Predicate<Integer> evenNumber = i -> i % 2 == 0;
assertTrue(positiveNumber.and(evenNumber).test(2));
//Consumer接口是消费一个数据。我们通过andThen方法组合调用两个Consumer输出两行abcdefg
Consumer<String> println = System.out::println;
println.andThen(println).accept("abcdefg");
//Function接口是输入一个数据计算后输出一个数据。我们先把字符串转换为大写然后通过andThen组合另一个Function实现字符串拼接
Function<String, String> upperCase = String::toUpperCase;
Function<String, String> duplicate = s -> s.concat(s);
assertThat(upperCase.andThen(duplicate).apply("test"), is("TESTTEST"));
//Supplier是提供一个数据的接口。这里我们实现获取一个随机数
Supplier<Integer> random = ()->ThreadLocalRandom.current().nextInt();
System.out.println(random.get());
//BinaryOperator是输入两个同类型参数输出一个同类型参数的接口。这里我们通过方法引用获得一个整数加法操作通过Lambda表达式定义一个减法操作然后依次调用
BinaryOperator<Integer> add = Integer::sum;
BinaryOperator<Integer> subtraction = (a, b) -> a - b;
assertThat(subtraction.apply(add.apply(1, 2), 3), is(0));
Predicate、Function 等函数式接口,还使用 default 关键字实现了几个默认方法。这样一来,它们既可以满足函数式接口只有一个抽象方法,又能为接口提供额外的功能:
@FunctionalInterface
public interface Function<T, R> {
R apply(T t);
default <V> Function<V, R> compose(Function<? super V, ? extends T> before) {
Objects.requireNonNull(before);
return (V v) -> apply(before.apply(v));
}
default <V> Function<T, V> andThen(Function<? super R, ? extends V> after) {
Objects.requireNonNull(after);
return (T t) -> after.apply(apply(t));
}
}
很明显Lambda 表达式给了我们复用代码的更多可能性:我们可以把一大段逻辑中变化的部分抽象出函数式接口,由外部方法提供函数实现,重用方法内的整体逻辑处理。
不过需要注意的是在自定义函数式接口之前可以先确认下java.util.function 包中的 43 个标准函数式接口是否能满足需求,我们要尽可能重用这些接口,因为使用大家熟悉的标准接口可以提高代码的可读性。
使用 Java 8 简化代码
这一部分,我会通过几个具体的例子,带你感受一下使用 Java 8 简化代码的三个重要方面:
使用 Stream 简化集合操作;
使用 Optional 简化判空逻辑;
JDK8 结合 Lambda 和 Stream 对各种类的增强。
使用 Stream 简化集合操作
Lambda 表达式可以帮我们用简短的代码实现方法的定义,给了我们复用代码的更多可能性。利用这个特性,我们可以把集合的投影、转换、过滤等操作抽象成通用的接口,然后通过 Lambda 表达式传入其具体实现,这也就是 Stream 操作。
我们看一个具体的例子。这里有一段 20 行左右的代码,实现了如下的逻辑:
把整数列表转换为 Point2D 列表;
遍历 Point2D 列表过滤出 Y 轴 >1 的对象;
计算 Point2D 点到原点的距离;
累加所有计算出的距离,并计算距离的平均值。
private static double calc(List<Integer> ints) {
//临时中间集合
List<Point2D> point2DList = new ArrayList<>();
for (Integer i : ints) {
point2DList.add(new Point2D.Double((double) i % 3, (double) i / 3));
}
//临时变量,纯粹是为了获得最后结果需要的中间变量
double total = 0;
int count = 0;
for (Point2D point2D : point2DList) {
//过滤
if (point2D.getY() > 1) {
//算距离
double distance = point2D.distance(0, 0);
total += distance;
count++;
}
}
//注意count可能为0的可能
return count >0 ? total / count : 0;
}
现在,我们可以使用 Stream 配合 Lambda 表达式来简化这段代码。简化后一行代码就可以实现这样的逻辑,更重要的是代码可读性更强了,通过方法名就可以知晓大概是在做什么事情。比如:
map 方法传入的是一个 Function可以实现对象转换
filter 方法传入一个 Predicate实现对象的布尔判断只保留返回 true 的数据;
mapToDouble 用于把对象转换为 double
通过 average 方法返回一个 OptionalDouble代表可能包含值也可能不包含值的可空 double。
下面的第三行代码,就实现了上面方法的所有工作:
List<Integer> ints = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8);
double average = calc(ints);
double streamResult = ints.stream()
.map(i -> new Point2D.Double((double) i % 3, (double) i / 3))
.filter(point -> point.getY() > 1)
.mapToDouble(point -> point.distance(0, 0))
.average()
.orElse(0);
//如何用一行代码来实现,比较一下可读性
assertThat(average, is(streamResult));
到这里你可能会问了OptionalDouble 又是怎么回事儿?
有关 Optional 可空类型
其实,类似 OptionalDouble、OptionalInt、OptionalLong 等是服务于基本类型的可空对象。此外Java8 还定义了用于引用类型的 Optional 类。使用 Optional不仅可以避免使用 Stream 进行级联调用的空指针问题;更重要的是,它提供了一些实用的方法帮我们避免判空逻辑。
如下是一些例子,演示了如何使用 Optional 来避免空指针,以及如何使用它的 fluent API 简化冗长的 if-else 判空逻辑:
@Test(expected = IllegalArgumentException.class)
public void optional() {
//通过get方法获取Optional中的实际值
assertThat(Optional.of(1).get(), is(1));
//通过ofNullable来初始化一个null通过orElse方法实现Optional中无数据的时候返回一个默认值
assertThat(Optional.ofNullable(null).orElse("A"), is("A"));
//OptionalDouble是基本类型double的Optional对象isPresent判断有无数据
assertFalse(OptionalDouble.empty().isPresent());
//通过map方法可以对Optional对象进行级联转换不会出现空指针转换后还是一个Optional
assertThat(Optional.of(1).map(Math::incrementExact).get(), is(2));
//通过filter实现Optional中数据的过滤得到一个Optional然后级联使用orElse提供默认值
assertThat(Optional.of(1).filter(integer -> integer % 2 == 0).orElse(null), is(nullValue()));
//通过orElseThrow实现无数据时抛出异常
Optional.empty().orElseThrow(IllegalArgumentException::new);
}
我把 Optional 类的常用方法整理成了一张图,你可以对照案例再复习一下:
Java 8 类对于函数式 API 的增强
除了 Stream 之外Java 8 中有很多类也都实现了函数式的功能。
比如,要通过 HashMap 实现一个缓存的操作,在 Java 8 之前我们可能会写出这样的 getProductAndCache 方法:先判断缓存中是否有值;如果没有值,就从数据库搜索取值;最后,把数据加入缓存。
private Map<Long, Product> cache = new ConcurrentHashMap<>();
private Product getProductAndCache(Long id) {
Product product = null;
//Key存在返回Value
if (cache.containsKey(id)) {
product = cache.get(id);
} else {
//不存在则获取Value
//需要遍历数据源查询获得Product
for (Product p : Product.getData()) {
if (p.getId().equals(id)) {
product = p;
break;
}
}
//加入ConcurrentHashMap
if (product != null)
cache.put(id, product);
}
return product;
}
@Test
public void notcoolCache() {
getProductAndCache(1L);
getProductAndCache(100L);
System.out.println(cache);
assertThat(cache.size(), is(1));
assertTrue(cache.containsKey(1L));
}
而在 Java 8 中,我们利用 ConcurrentHashMap 的 computeIfAbsent 方法,用一行代码就可以实现这样的繁琐操作:
private Product getProductAndCacheCool(Long id) {
return cache.computeIfAbsent(id, i -> //当Key不存在的时候提供一个Function来代表根据Key获取Value的过程
Product.getData().stream()
.filter(p -> p.getId().equals(i)) //过滤
.findFirst() //找第一个得到Optional<Product>
.orElse(null)); //如果找不到Product则使用null
}
@Test
public void coolCache()
{
getProductAndCacheCool(1L);
getProductAndCacheCool(100L);
System.out.println(cache);
assertThat(cache.size(), is(1));
assertTrue(cache.containsKey(1L));
}
computeIfAbsent 方法在逻辑上相当于:
if (map.get(key) == null) {
V newValue = mappingFunction.apply(key);
if (newValue != null)
map.put(key, newValue);
}
又比如,利用 Files.walk 返回一个 Path 的流,通过两行代码就能实现递归搜索 +grep 的操作。整个逻辑是:递归搜索文件夹,查找所有的.java 文件;然后读取文件每一行内容,用正则表达式匹配 public class 关键字;最后输出文件名和这行内容。
@Test
public void filesExample() throws IOException {
//无限深度,递归遍历文件夹
try (Stream<Path> pathStream = Files.walk(Paths.get("."))) {
pathStream.filter(Files::isRegularFile) //只查普通文件
.filter(FileSystems.getDefault().getPathMatcher("glob:**/*.java")::matches) //搜索java源码文件
.flatMap(ThrowingFunction.unchecked(path ->
Files.readAllLines(path).stream() //读取文件内容转换为Stream<List>
.filter(line -> Pattern.compile("public class").matcher(line).find()) //使用正则过滤带有public class的行
.map(line -> path.getFileName() + " >> " + line))) //把这行文件内容转换为文件名+行
.forEach(System.out::println); //打印所有的行
}
}
输出结果如下:
我再和你分享一个小技巧吧。因为 Files.readAllLines 方法会抛出一个受检异常IOException所以我使用了一个自定义的函数式接口用 ThrowingFunction 包装这个方法,把受检异常转换为运行时异常,让代码更清晰:
@FunctionalInterface
public interface ThrowingFunction<T, R, E extends Throwable> {
static <T, R, E extends Throwable> Function<T, R> unchecked(ThrowingFunction<T, R, E> f) {
return t -> {
try {
return f.apply(t);
} catch (Throwable e) {
throw new RuntimeException(e);
}
};
}
R apply(T t) throws E;
}
如果用 Java 7 实现类似逻辑的话,大概需要几十行代码,你可以尝试下。
并行流
前面我们看到的 Stream 操作都是串行 Stream操作只是在一个线程中执行此外 Java 8 还提供了并行流的功能:通过 parallel 方法,一键把 Stream 转换为并行操作提交到线程池处理。
比如,如下代码通过线程池来并行消费处理 1 到 100
IntStream.rangeClosed(1,100).parallel().forEach(i->{
System.out.println(LocalDateTime.now() + " : " + i);
try {
Thread.sleep(1000);
} catch (InterruptedException e) { }
});
并行流不确保执行顺序,并且因为每次处理耗时 1 秒,所以可以看到在 8 核机器上,数组是按照 8 个一组 1 秒输出一次:
在这个课程中,有很多类似使用 threadCount 个线程对某个方法总计执行 taskCount 次操作的案例,用于演示并发情况下的多线程问题或多线程处理性能。除了会用到并行流,我们有时也会使用线程池或直接使用线程进行类似操作。为了方便你对比各种实现,这里我一次性给出实现此类操作的五种方式。
为了测试这五种实现方式,我们设计一个场景:使用 20 个线程threadCount以并行方式总计执行 10000 次taskCount操作。因为单个任务单线程执行需要 10 毫秒(任务代码如下),也就是每秒吞吐量是 100 个操作,那 20 个线程 QPS 是 2000执行完 10000 次操作最少耗时 5 秒。
private void increment(AtomicInteger atomicInteger) {
atomicInteger.incrementAndGet();
try {
TimeUnit.MILLISECONDS.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
现在我们测试一下这五种方式,是否都可以利用更多的线程并行执行操作。
第一种方式是使用线程。直接把任务按照线程数均匀分割,分配到不同的线程执行,使用 CountDownLatch 来阻塞主线程,直到所有线程都完成操作。这种方式,需要我们自己分割任务:
private int thread(int taskCount, int threadCount) throws InterruptedException {
//总操作次数计数器
AtomicInteger atomicInteger = new AtomicInteger();
//使用CountDownLatch来等待所有线程执行完成
CountDownLatch countDownLatch = new CountDownLatch(threadCount);
//使用IntStream把数字直接转为Thread
IntStream.rangeClosed(1, threadCount).mapToObj(i -> new Thread(() -> {
//手动把taskCount分成taskCount份每一份有一个线程执行
IntStream.rangeClosed(1, taskCount / threadCount).forEach(j -> increment(atomicInteger));
//每一个线程处理完成自己那部分数据之后countDown一次
countDownLatch.countDown();
})).forEach(Thread::start);
//等到所有线程执行完成
countDownLatch.await();
//查询计数器当前值
return atomicInteger.get();
}
第二种方式是,使用 Executors.newFixedThreadPool 来获得固定线程数的线程池,使用 execute 提交所有任务到线程池执行,最后关闭线程池等待所有任务执行完成:
private int threadpool(int taskCount, int threadCount) throws InterruptedException {
//总操作次数计数器
AtomicInteger atomicInteger = new AtomicInteger();
//初始化一个线程数量=threadCount的线程池
ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
//所有任务直接提交到线程池处理
IntStream.rangeClosed(1, taskCount).forEach(i -> executorService.execute(() -> increment(atomicInteger)));
//提交关闭线程池申请,等待之前所有任务执行完成
executorService.shutdown();
executorService.awaitTermination(1, TimeUnit.HOURS);
//查询计数器当前值
return atomicInteger.get();
}
第三种方式是,使用 ForkJoinPool 而不是普通线程池执行任务。
ForkJoinPool 和传统的 ThreadPoolExecutor 区别在于,前者对于 n 并行度有 n 个独立队列后者是共享队列。如果有大量执行耗时比较短的任务ThreadPoolExecutor 的单队列就可能会成为瓶颈。这时,使用 ForkJoinPool 性能会更好。
因此ForkJoinPool 更适合大任务分割成许多小任务并行执行的场景,而 ThreadPoolExecutor 适合许多独立任务并发执行的场景。
在这里,我们先自定义一个具有指定并行数的 ForkJoinPool再通过这个 ForkJoinPool 并行执行操作:
private int forkjoin(int taskCount, int threadCount) throws InterruptedException {
//总操作次数计数器
AtomicInteger atomicInteger = new AtomicInteger();
//自定义一个并行度=threadCount的ForkJoinPool
ForkJoinPool forkJoinPool = new ForkJoinPool(threadCount);
//所有任务直接提交到线程池处理
forkJoinPool.execute(() -> IntStream.rangeClosed(1, taskCount).parallel().forEach(i -> increment(atomicInteger)));
//提交关闭线程池申请,等待之前所有任务执行完成
forkJoinPool.shutdown();
forkJoinPool.awaitTermination(1, TimeUnit.HOURS);
//查询计数器当前值
return atomicInteger.get();
}
第四种方式是,直接使用并行流,并行流使用公共的 ForkJoinPool也就是 ForkJoinPool.commonPool()。
公共的 ForkJoinPool 默认的并行度是 CPU 核心数 -1原因是对于 CPU 绑定的任务分配超过 CPU 个数的线程没有意义。由于并行流还会使用主线程执行任务,也会占用一个 CPU 核心,所以公共 ForkJoinPool 的并行度即使 -1 也能用满所有 CPU 核心。
这里,我们通过配置强制指定(增大)了并行数,但因为使用的是公共 ForkJoinPool所以可能会存在干扰你可以回顾下第 3 讲有关线程池混用产生的问题:
private int stream(int taskCount, int threadCount) {
//设置公共ForkJoinPool的并行度
System.setProperty("java.util.concurrent.ForkJoinPool.common.parallelism", String.valueOf(threadCount));
//总操作次数计数器
AtomicInteger atomicInteger = new AtomicInteger();
//由于我们设置了公共ForkJoinPool的并行度直接使用parallel提交任务即可
IntStream.rangeClosed(1, taskCount).parallel().forEach(i -> increment(atomicInteger));
//查询计数器当前值
return atomicInteger.get();
}
第五种方式是,使用 CompletableFuture 来实现。CompletableFuture.runAsync 方法可以指定一个线程池,一般会在使用 CompletableFuture 的时候用到:
private int completableFuture(int taskCount, int threadCount) throws InterruptedException, ExecutionException {
//总操作次数计数器
AtomicInteger atomicInteger = new AtomicInteger();
//自定义一个并行度=threadCount的ForkJoinPool
ForkJoinPool forkJoinPool = new ForkJoinPool(threadCount);
//使用CompletableFuture.runAsync通过指定线程池异步执行任务
CompletableFuture.runAsync(() -> IntStream.rangeClosed(1, taskCount).parallel().forEach(i -> increment(atomicInteger)), forkJoinPool).get();
//查询计数器当前值
return atomicInteger.get();
}
上面这五种方法都可以实现类似的效果:
可以看到,这 5 种方式执行完 10000 个任务的耗时都在 5.4 秒到 6 秒之间。这里的结果只是证明并行度的设置是有效的,并不是性能比较。
如果你的程序对性能要求特别敏感,建议通过性能测试根据场景决定适合的模式。一般而言,使用线程池(第二种)和直接使用并行流(第四种)的方式在业务代码中比较常用。但需要注意的是,我们通常会重用线程池,而不会像 Demo 中那样在业务逻辑中直接声明新的线程池,等操作完成后再关闭。
另外需要注意的是,在上面的例子中我们一定是先运行 stream 方法再运行 forkjoin 方法,对公共 ForkJoinPool 默认并行度的修改才能生效。
这是因为 ForkJoinPool 类初始化公共线程池是在静态代码块里,加载类时就会进行的,如果 forkjoin 方法中先使用了 ForkJoinPool即便 stream 方法中设置了系统属性也不会起作用。因此我的建议是,设置 ForkJoinPool 公共线程池默认并行度的操作,应该放在应用启动时设置。
重点回顾
今天,我和你简单介绍了 Java 8 中最重要的几个功能,包括 Lambda 表达式、Stream 流式操作、Optional 可空对象、并行流操作。这些特性,可以帮助我们写出简单易懂、可读性更强的代码。特别是使用 Stream 的链式方法,可以用一行代码完成之前几十行代码的工作。
因为 Stream 的 API 非常多,使用方法也是千变万化,因此我会在下一讲和你详细介绍 Stream API 的一些使用细节。
今天用到的代码,我都放在了 GitHub 上,你可以点击这个链接查看。
思考与讨论
检查下代码中是否有使用匿名类,以及通过遍历 List 进行数据过滤、转换和聚合的代码,看看能否使用 Lambda 表达式和 Stream 来重新实现呢?
对于并行流部分的并行消费处理 1 到 100 的例子,如果把 forEach 替换为 forEachOrdered你觉得会发生什么呢
关于 Java 8你还有什么使用心得吗我是朱晔欢迎在评论区与我留言分享你的想法也欢迎你把这篇文章分享给你的朋友或同事一起交流。

View File

@@ -0,0 +1,602 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
32 加餐2带你吃透课程中Java 8的那些重要知识点
上一讲的几个例子中,其实都涉及了 Stream API 的最基本使用方法。今天,我会与你详细介绍复杂、功能强大的 Stream API。
Stream 流式操作,用于对集合进行投影、转换、过滤、排序等,更进一步地,这些操作能链式串联在一起使用,类似于 SQL 语句可以大大简化代码。可以说Stream 操作是 Java 8 中最重要的内容,也是这个课程大部分代码都会用到的操作。
我先说明下,有些案例可能不太好理解,建议你对着代码逐一到源码中查看 Stream 操作的方法定义,以及 JDK 中的代码注释。
Stream 操作详解
为了方便你理解 Stream 的各种操作,以及后面的案例,我先把这节课涉及的 Stream 操作汇总到了一张图中。你可以先熟悉一下。
在接下来的讲述中,我会围绕订单场景,给出如何使用 Stream 的各种 API 完成订单的统计、搜索、查询等功能,和你一起学习 Stream 流式操作的各种方法。你可以结合代码中的注释理解案例,也可以自己运行源码观察输出。
我们先定义一个订单类、一个订单商品类和一个顾客类,用作后续 Demo 代码的数据结构:
//订单类
@Data
public class Order {
private Long id;
private Long customerId;//顾客ID
private String customerName;//顾客姓名
private List<OrderItem> orderItemList;//订单商品明细
private Double totalPrice;//总价格
private LocalDateTime placedAt;//下单时间
}
//订单商品类
@Data
@AllArgsConstructor
@NoArgsConstructor
public class OrderItem {
private Long productId;//商品ID
private String productName;//商品名称
private Double productPrice;//商品价格
private Integer productQuantity;//商品数量
}
//顾客类
@Data
@AllArgsConstructor
public class Customer {
private Long id;
private String name;//顾客姓名
}
在这里,我们有一个 orders 字段保存了一些模拟数据,类型是 List。这里我就不贴出生成模拟数据的代码了。这不会影响你理解后面的代码你也可以自己下载源码阅读。
创建流
要使用流,就要先创建流。创建流一般有五种方式:
通过 stream 方法把 List 或数组转换为流;
通过 Stream.of 方法直接传入多个元素构成一个流;
通过 Stream.iterate 方法使用迭代的方式构造一个无限流,然后使用 limit 限制流元素个数;
通过 Stream.generate 方法从外部传入一个提供元素的 Supplier 来构造无限流,然后使用 limit 限制流元素个数;
通过 IntStream 或 DoubleStream 构造基本类型的流。
//通过stream方法把List或数组转换为流
@Test
public void stream()
{
Arrays.asList("a1", "a2", "a3").stream().forEach(System.out::println);
Arrays.stream(new int[]{1, 2, 3}).forEach(System.out::println);
}
//通过Stream.of方法直接传入多个元素构成一个流
@Test
public void of()
{
String[] arr = {"a", "b", "c"};
Stream.of(arr).forEach(System.out::println);
Stream.of("a", "b", "c").forEach(System.out::println);
Stream.of(1, 2, "a").map(item -> item.getClass().getName()).forEach(System.out::println);
}
//通过Stream.iterate方法使用迭代的方式构造一个无限流然后使用limit限制流元素个数
@Test
public void iterate()
{
Stream.iterate(2, item -> item * 2).limit(10).forEach(System.out::println);
Stream.iterate(BigInteger.ZERO, n -> n.add(BigInteger.TEN)).limit(10).forEach(System.out::println);
}
//通过Stream.generate方法从外部传入一个提供元素的Supplier来构造无限流然后使用limit限制流元素个数
@Test
public void generate()
{
Stream.generate(() -> "test").limit(3).forEach(System.out::println);
Stream.generate(Math::random).limit(10).forEach(System.out::println);
}
//通过IntStream或DoubleStream构造基本类型的流
@Test
public void primitive()
{
//演示IntStream和DoubleStream
IntStream.range(1, 3).forEach(System.out::println);
IntStream.range(0, 3).mapToObj(i -> "x").forEach(System.out::println);
IntStream.rangeClosed(1, 3).forEach(System.out::println);
DoubleStream.of(1.1, 2.2, 3.3).forEach(System.out::println);
//各种转换,后面注释代表了输出结果
System.out.println(IntStream.of(1, 2).toArray().getClass()); //class [I
System.out.println(Stream.of(1, 2).mapToInt(Integer::intValue).toArray().getClass()); //class [I
System.out.println(IntStream.of(1, 2).boxed().toArray().getClass()); //class [Ljava.lang.Object;
System.out.println(IntStream.of(1, 2).asDoubleStream().toArray().getClass()); //class [D
System.out.println(IntStream.of(1, 2).asLongStream().toArray().getClass()); //class [J
//注意基本类型流和装箱后的流的区别
Arrays.asList("a", "b", "c").stream() // Stream<String>
.mapToInt(String::length) // IntStream
.asLongStream() // LongStream
.mapToDouble(x -> x / 10.0) // DoubleStream
.boxed() // Stream<Double>
.mapToLong(x -> 1L) // LongStream
.mapToObj(x -> "") // Stream<String>
.collect(Collectors.toList());
}
filter
filter 方法可以实现过滤操作,类似 SQL 中的 where。我们可以使用一行代码通过 filter 方法实现查询所有订单中最近半年金额大于 40 的订单,通过连续叠加 filter 方法进行多次条件过滤:
//最近半年的金额大于40的订单
orders.stream()
.filter(Objects::nonNull) //过滤null值
.filter(order -> order.getPlacedAt().isAfter(LocalDateTime.now().minusMonths(6))) //最近半年的订单
.filter(order -> order.getTotalPrice() > 40) //金额大于40的订单
.forEach(System.out::println);
如果不使用 Stream 的话,必然需要一个中间集合来收集过滤后的结果,而且所有的过滤条件会堆积在一起,代码冗长且不易读。
map
map 操作可以做转换(或者说投影),类似 SQL 中的 select。为了对比我用两种方式统计订单中所有商品的数量前一种是通过两次遍历实现后一种是通过两次 mapToLong+sum 方法实现:
//计算所有订单商品数量
//通过两次遍历实现
LongAdder longAdder = new LongAdder();
orders.stream().forEach(order ->
order.getOrderItemList().forEach(orderItem -> longAdder.add(orderItem.getProductQuantity())));
//使用两次mapToLong+sum方法实现
assertThat(longAdder.longValue(), is(orders.stream().mapToLong(order ->
order.getOrderItemList().stream()
.mapToLong(OrderItem::getProductQuantity).sum()).sum()));
显然,后一种方式无需中间变量 longAdder更直观。
这里再补充一下,使用 for 循环生成数据,是我们平时常用的操作,也是这个课程会大量用到的。现在,我们可以用一行代码使用 IntStream 配合 mapToObj 替代 for 循环来生成数据,比如生成 10 个 Product 元素构成 List
//把IntStream通过转换Stream<Project>
System.out.println(IntStream.rangeClosed(1,10)
.mapToObj(i->new Product((long)i, "product"+i, i*100.0))
.collect(toList()));
flatMap
接下来,我们看看 flatMap 展开或者叫扁平化操作,相当于 map+flat通过 map 把每一个元素替换为一个流,然后展开这个流。
比如,我们要统计所有订单的总价格,可以有两种方式:
直接通过原始商品列表的商品个数 * 商品单价统计的话,可以先把订单通过 flatMap 展开成商品清单,也就是把 Order 替换为 Stream然后对每一个 OrderItem 用 mapToDouble 转换获得商品总价,最后进行一次 sum 求和;
利用 flatMapToDouble 方法把列表中每一项展开替换为一个 DoubleStream也就是直接把每一个订单转换为每一个商品的总价然后求和。
//直接展开订单商品进行价格统计
System.out.println(orders.stream()
.flatMap(order -> order.getOrderItemList().stream())
.mapToDouble(item -> item.getProductQuantity() * item.getProductPrice()).sum());
//另一种方式flatMap+mapToDouble=flatMapToDouble
System.out.println(orders.stream()
.flatMapToDouble(order ->
order.getOrderItemList()
.stream().mapToDouble(item -> item.getProductQuantity() * item.getProductPrice()))
.sum());
这两种方式可以得到相同的结果,并无本质区别。
sorted
sorted 操作可以用于行内排序的场景,类似 SQL 中的 order by。比如要实现大于 50 元订单的按价格倒序取前 5可以通过 Order::getTotalPrice 方法引用直接指定需要排序的依据字段,通过 reversed() 实现倒序:
//大于50的订单,按照订单价格倒序前5
orders.stream().filter(order -> order.getTotalPrice() > 50)
.sorted(comparing(Order::getTotalPrice).reversed())
.limit(5)
.forEach(System.out::println);
distinct
distinct 操作的作用是去重,类似 SQL 中的 distinct。比如下面的代码实现
查询去重后的下单用户。使用 map 从订单提取出购买用户,然后使用 distinct 去重。
查询购买过的商品名。使用 flatMap+map 提取出订单中所有的商品名,然后使用 distinct 去重。
//去重的下单用户
System.out.println(orders.stream().map(order -> order.getCustomerName()).distinct().collect(joining(",")));
//所有购买过的商品
System.out.println(orders.stream()
.flatMap(order -> order.getOrderItemList().stream())
.map(OrderItem::getProductName)
.distinct().collect(joining(",")));
skip & limit
skip 和 limit 操作用于分页,类似 MySQL 中的 limit。其中skip 实现跳过一定的项limit 用于限制项总数。比如下面的两段代码:
按照下单时间排序,查询前 2 个订单的顾客姓名和下单时间;
按照下单时间排序,查询第 3 和第 4 个订单的顾客姓名和下单时间。
//按照下单时间排序查询前2个订单的顾客姓名和下单时间
orders.stream()
.sorted(comparing(Order::getPlacedAt))
.map(order -> order.getCustomerName() + "@" + order.getPlacedAt())
.limit(2).forEach(System.out::println);
//按照下单时间排序查询第3和第4个订单的顾客姓名和下单时间
orders.stream()
.sorted(comparing(Order::getPlacedAt))
.map(order -> order.getCustomerName() + "@" + order.getPlacedAt())
.skip(2).limit(2).forEach(System.out::println);
collect
collect 是收集操作,对流进行终结(终止)操作,把流导出为我们需要的数据结构。“终结”是指,导出后,无法再串联使用其他中间操作,比如 filter、map、flatmap、sorted、distinct、limit、skip。
在 Stream 操作中collect 是最复杂的终结操作,比较简单的终结操作还有 forEach、toArray、min、max、count、anyMatch 等我就不再展开了你可以查询JDK 文档,搜索 terminal operation 或 intermediate operation。
接下来,我通过 6 个案例,来演示下几种比较常用的 collect 操作:
第一个案例,实现了字符串拼接操作,生成一定位数的随机字符串。
第二个案例,通过 Collectors.toSet 静态方法收集为 Set 去重,得到去重后的下单用户,再通过 Collectors.joining 静态方法实现字符串拼接。
第三个案例,通过 Collectors.toCollection 静态方法获得指定类型的集合,比如把 List转换为 LinkedList。
第四个案例,通过 Collectors.toMap 静态方法将对象快速转换为 MapKey 是订单 ID、Value 是下单用户名。
第五个案例,通过 Collectors.toMap 静态方法将对象转换为 Map。Key 是下单用户名Value 是下单时间,一个用户可能多次下单,所以直接在这里进行了合并,只获取最近一次的下单时间。
第六个案例,使用 Collectors.summingInt 方法对商品数量求和,再使用 Collectors.averagingInt 方法对结果求平均值,以统计所有订单平均购买的商品数量。
//生成一定位数的随机字符串
System.out.println(random.ints(48, 122)
.filter(i -> (i < 57 || i > 65) && (i < 90 || i > 97))
.mapToObj(i -> (char) i)
.limit(20)
.collect(StringBuilder::new, StringBuilder::append, StringBuilder::append)
.toString());
//所有下单的用户使用toSet去重后实现字符串拼接
System.out.println(orders.stream()
.map(order -> order.getCustomerName()).collect(toSet())
.stream().collect(joining(",", "[", "]")));
//用toCollection收集器指定集合类型
System.out.println(orders.stream().limit(2).collect(toCollection(LinkedList::new)).getClass());
//使用toMap获取订单ID+下单用户名的Map
orders.stream()
.collect(toMap(Order::getId, Order::getCustomerName))
.entrySet().forEach(System.out::println);
//使用toMap获取下单用户名+最近一次下单时间的Map
orders.stream()
.collect(toMap(Order::getCustomerName, Order::getPlacedAt, (x, y) -> x.isAfter(y) ? x : y))
.entrySet().forEach(System.out::println);
//订单平均购买的商品数量
System.out.println(orders.stream().collect(averagingInt(order ->
order.getOrderItemList().stream()
.collect(summingInt(OrderItem::getProductQuantity)))));
可以看到,这 6 个操作使用 Stream 方式一行代码就可以实现,但使用非 Stream 方式实现的话,都需要几行甚至十几行代码。
有关 Collectors 类的一些常用静态方法,我总结到了一张图中,你可以再整理一下思路:
其中groupBy 和 partitionBy 比较复杂,我和你举例介绍。
groupBy
groupBy 是分组统计操作,类似 SQL 中的 group by 子句。它和后面介绍的 partitioningBy 都是特殊的收集器,同样也是终结操作。分组操作比较复杂,为帮你理解得更透彻,我准备了 8 个案例:
第一个案例,按照用户名分组,使用 Collectors.counting 方法统计每个人的下单数量,再按照下单数量倒序输出。
第二个案例,按照用户名分组,使用 Collectors.summingDouble 方法统计订单总金额,再按总金额倒序输出。
第三个案例,按照用户名分组,使用两次 Collectors.summingInt 方法统计商品采购数量,再按总数量倒序输出。
第四个案例,统计被采购最多的商品。先通过 flatMap 把订单转换为商品,然后把商品名作为 Key、Collectors.summingInt 作为 Value 分组统计采购数量,再按 Value 倒序获取第一个 Entry最后查询 Key 就得到了售出最多的商品。
第五个案例,同样统计采购最多的商品。相比第四个案例排序 Map 的方式,这次直接使用 Collectors.maxBy 收集器获得最大的 Entry。
第六个案例按照用户名分组统计用户下的金额最高的订单。Key 是用户名Value 是 Order直接通过 Collectors.maxBy 方法拿到金额最高的订单,然后通过 collectingAndThen 实现 Optional.get 的内容提取,最后遍历 Key/Value 即可。
第七个案例,根据下单年月分组统计订单 ID 列表。Key 是格式化成年月后的下单时间Value 直接通过 Collectors.mapping 方法进行了转换,把订单列表转换为订单 ID 构成的 List。
第八个案例,根据下单年月 + 用户名两次分组统计订单 ID 列表,相比上一个案例多了一次分组操作,第二次分组是按照用户名进行分组。
//按照用户名分组,统计下单数量
System.out.println(orders.stream().collect(groupingBy(Order::getCustomerName, counting()))
.entrySet().stream().sorted(Map.Entry.<String, Long>comparingByValue().reversed()).collect(toList()));
//按照用户名分组,统计订单总金额
System.out.println(orders.stream().collect(groupingBy(Order::getCustomerName, summingDouble(Order::getTotalPrice)))
.entrySet().stream().sorted(Map.Entry.<String, Double>comparingByValue().reversed()).collect(toList()));
//按照用户名分组,统计商品采购数量
System.out.println(orders.stream().collect(groupingBy(Order::getCustomerName,
summingInt(order -> order.getOrderItemList().stream()
.collect(summingInt(OrderItem::getProductQuantity)))))
.entrySet().stream().sorted(Map.Entry.<String, Integer>comparingByValue().reversed()).collect(toList()));
//统计最受欢迎的商品,倒序后取第一个
orders.stream()
.flatMap(order -> order.getOrderItemList().stream())
.collect(groupingBy(OrderItem::getProductName, summingInt(OrderItem::getProductQuantity)))
.entrySet().stream()
.sorted(Map.Entry.<String, Integer>comparingByValue().reversed())
.map(Map.Entry::getKey)
.findFirst()
.ifPresent(System.out::println);
//统计最受欢迎的商品的另一种方式直接利用maxBy
orders.stream()
.flatMap(order -> order.getOrderItemList().stream())
.collect(groupingBy(OrderItem::getProductName, summingInt(OrderItem::getProductQuantity)))
.entrySet().stream()
.collect(maxBy(Map.Entry.comparingByValue()))
.map(Map.Entry::getKey)
.ifPresent(System.out::println);
//按照用户名分组,选用户下的总金额最大的订单
orders.stream().collect(groupingBy(Order::getCustomerName, collectingAndThen(maxBy(comparingDouble(Order::getTotalPrice)), Optional::get)))
.forEach((k, v) -> System.out.println(k + "#" + v.getTotalPrice() + "@" + v.getPlacedAt()));
//根据下单年月分组统计订单ID列表
System.out.println(orders.stream().collect
(groupingBy(order -> order.getPlacedAt().format(DateTimeFormatter.ofPattern("yyyyMM")),
mapping(order -> order.getId(), toList()))));
//根据下单年月+用户名两次分组统计订单ID列表
System.out.println(orders.stream().collect
(groupingBy(order -> order.getPlacedAt().format(DateTimeFormatter.ofPattern("yyyyMM")),
groupingBy(order -> order.getCustomerName(),
mapping(order -> order.getId(), toList())))));
如果不借助 Stream 转换为普通的 Java 代码,实现这些复杂的操作可能需要几十行代码。
partitionBy
partitioningBy 用于分区,分区是特殊的分组,只有 true 和 false 两组。比如,我们把用户按照是否下单进行分区,给 partitioningBy 方法传入一个 Predicate 作为数据分区的区分,输出是 Map>
public static <T> Collector<T, ?, Map<Boolean, List<T>>> partitioningBy(Predicate<? super T> predicate) {
return partitioningBy(predicate, toList());
}
测试一下partitioningBy 配合 anyMatch可以把用户分为下过订单和没下过订单两组
//根据是否有下单记录进行分区
System.out.println(Customer.getData().stream().collect(
partitioningBy(customer -> orders.stream().mapToLong(Order::getCustomerId)
.anyMatch(id -> id == customer.getId()))));
重点回顾
今天,我用了大量的篇幅和案例,和你展开介绍了 Stream 中很多具体的流式操作方法。有些案例可能不太好理解,我建议你对着代码逐一到源码中查看这些操作的方法定义,以及 JDK 中的代码注释。
最后,我建议你思考下,在日常工作中还会使用 SQL 统计哪些信息,这些 SQL 是否也可以用 Stream 来改写呢Stream 的 API 博大精深,但其中又有规律可循。这其中的规律主要就是,理清楚这些 API 传参的函数式接口定义,就能搞明白到底是需要我们提供数据、消费数据、还是转换数据等。那,掌握 Stream 的方法便是,多测试多练习,以强化记忆、加深理解。
今天用到的代码,我都放在了 GitHub 上,你可以点击这个链接查看。
思考与讨论
使用 Stream 可以非常方便地对 List 做各种操作,那有没有什么办法可以实现在整个过程中观察数据变化呢?比如,我们进行 filter+map 操作,如何观察 filter 后 map 的原始数据呢?
Collectors 类提供了很多现成的收集器,那我们有没有办法实现自定义的收集器呢?比如,实现一个 MostPopularCollector来得到 List 中出现次数最多的元素,满足下面两个测试用例:
assertThat(Stream.of(1, 1, 2, 2, 2, 3, 4, 5, 5).collect(new MostPopularCollector<>()).get(), is(2));
assertThat(Stream.of('a', 'b', 'c', 'c', 'c', 'd').collect(new MostPopularCollector<>()).get(), is('c'));
关于 Java 8你还有什么使用心得吗我是朱晔欢迎在评论区与我留言分享你的想法也欢迎你把这篇文章分享给你的朋友或同事一起交流。

View File

@@ -0,0 +1,187 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
33 加餐3定位应用问题排错套路很重要
咱们这个课程已经更新 13 讲了,感谢各位同学一直在坚持学习,并在评论区留下了很多高质量的留言。这些留言,有的是分享自己曾经踩的坑,有的是对课后思考题的详细解答,还有的是提出了非常好的问题,进一步丰富了这个课程的内容。
有同学说,这个课程的案例非常实用,都是工作中会遇到的。正如我在开篇词中所说,这个课程涉及的 100 个案例、约 130 个小坑,有 40% 来自于我经历过或者是见过的 200 多个线上生产事故,剩下的 60% 来自于我开发业务项目,以及日常审核别人的代码发现的问题。确实,我在整理这些案例上花费了很多精力,也特别感谢各位同学的认可,更希望你们能继续坚持学习,继续在评论区和我交流。
也有同学反馈,排查问题的思路很重要,希望自己遇到问题时,也能够从容、高效地定位到根因。因此,今天这一讲,我就与你说说我在应急排错方面积累的心得。这都是我多年担任技术负责人和架构师自己总结出来的,希望对你有所帮助。当然了,也期待你能留言与我说说,自己平时的排错套路。
在不同环境排查问题,有不同的方式
要说排查问题的思路,我们首先得明白是在什么环境排错。
如果是在自己的开发环境排查问题,那你几乎可以使用任何自己熟悉的工具来排查,甚至可以进行单步调试。只要问题能重现,排查就不会太困难,最多就是把程序调试到 JDK 或三方类库内部进行分析。
如果是在测试环境排查问题,相比开发环境少的是调试,不过你可以使用 JDK 自带的 jvisualvm 或阿里的Arthas附加到远程的 JVM 进程排查问题。另外,测试环境允许造数据、造压力模拟我们需要的场景,因此遇到偶发问题时,我们可以尝试去造一些场景让问题更容易出现,方便测试。
如果是在生产环境排查问题,往往比较难:一方面,生产环境权限管控严格,一般不允许调试工具从远程附加进程;另一方面,生产环境出现问题要求以恢复为先,难以留出充足的时间去慢慢排查问题。但,因为生产环境的流量真实、访问量大、网络权限管控严格、环境复杂,因此更容易出问题,也是出问题最多的环境。
接下来,我就与你详细说说,如何在生产环境排查问题吧。
生产问题的排查很大程度依赖监控
其实,排查问题就像在破案,生产环境出现问题时,因为要尽快恢复应用,就不可能保留完整现场用于排查和测试。因此,是否有充足的信息可以了解过去、还原现场就成了破案的关键。这里说的信息,主要就是日志、监控和快照。
日志就不用多说了,主要注意两点:
确保错误、异常信息可以被完整地记录到文件日志中;
确保生产上程序的日志级别是 INFO 以上。记录日志要使用合理的日志优先级DEBUG 用于开发调试、INFO 用于重要流程信息、WARN 用于需要关注的问题、ERROR 用于阻断流程的错误。
对于监控,在生产环境排查问题时,首先就需要开发和运维团队做好充足的监控,而且是多个层次的监控。
主机层面,对 CPU、内存、磁盘、网络等资源做监控。如果应用部署在虚拟机或 Kubernetes 集群中,那么除了对物理机做基础资源监控外,还要对虚拟机或 Pod 做同样的监控。监控层数取决于应用的部署方案,有一层 OS 就要做一层监控。
网络层面,需要监控专线带宽、交换机基本情况、网络延迟。
所有的中间件和存储都要做好监控,不仅仅是监控进程对 CPU、内存、磁盘 IO、网络使用的基本指标更重要的是监控组件内部的一些重要指标。比如著名的监控工具 Prometheus就提供了大量的exporter来对接各种中间件和存储系统。
应用层面,需要监控 JVM 进程的类加载、内存、GC、线程等常见指标比如使用Micrometer来做应用监控此外还要确保能够收集、保存应用日志、GC 日志。
我们再来看看快照。这里的“快照”是指,应用进程在某一时刻的快照。通常情况下,我们会为生产环境的 Java 应用设置 -XX:+HeapDumpOnOutOfMemoryError 和 -XX:HeapDumpPath=…这 2 个 JVM 参数,用于在出现 OOM 时保留堆快照。这个课程中,我们也多次使用 MAT 工具来分析堆快照。
了解过去、还原现场后,接下来我们就看看定位问题的套路。
分析定位问题的套路
定位问题,首先要定位问题出在哪个层次上。比如,是 Java 应用程序自身的问题还是外部因素导致的问题。我们可以先查看程序是否有异常,异常信息一般比较具体,可以马上定位到大概的问题方向;如果是一些资源消耗型的问题可能不会有异常,我们可以通过指标监控配合显性问题点来定位。
一般情况下,程序的问题来自以下三个方面。
第一,程序发布后的 Bug回滚后可以立即解决。这类问题的排查可以回滚后再慢慢分析版本差异。
第二,外部因素,比如主机、中间件或数据库的问题。这类问题的排查方式,按照主机层面的问题、中间件或存储(统称组件)的问题分为两类。
主机层面的问题,可以使用工具排查:
CPU 相关问题,可以使用 top、vmstat、pidstat、ps 等工具排查;
内存相关问题,可以使用 free、top、ps、vmstat、cachestat、sar 等工具排查;
IO 相关问题,可以使用 lsof、iostat、pidstat、sar、iotop、df、du 等工具排查;
网络相关问题,可以使用 ifconfig、ip、nslookup、dig、ping、tcpdump、iptables 等工具排查。
组件的问题,可以从以下几个方面排查:
排查组件所在主机是否有问题;
排查组件进程基本情况,观察各种监控指标;
查看组件的日志输出,特别是错误日志;
进入组件控制台,使用一些命令查看其运作情况。
第三,因为系统资源不够造成系统假死的问题,通常需要先通过重启和扩容解决问题,之后再进行分析,不过最好能留一个节点作为现场。系统资源不够,一般体现在 CPU 使用高、内存泄漏或 OOM 的问题、IO 问题、网络相关问题这四个方面。
对于 CPU 使用高的问题,如果现场还在,具体的分析流程是:
首先,在 Linux 服务器上运行 top -Hp pid 命令,来查看进程中哪个线程 CPU 使用高;
然后,输入大写的 P 将线程按照 CPU 使用率排序,并把明显占用 CPU 的线程 ID 转换为 16 进制;
最后,在 jstack 命令输出的线程栈中搜索这个线程 ID定位出问题的线程当时的调用栈。
如果没有条件直接在服务器上运行 top 命令的话,我们可以用采样的方式定位问题:间隔固定秒数(比如 10 秒)运行一次 jstack 命令,采样几次后,对比采样得出哪些线程始终处于运行状态,分析出问题的线程。
如果现场没有了我们可以通过排除法来分析。CPU 使用高,一般是由下面的因素引起的:
突发压力。这类问题,我们可以通过应用之前的负载均衡的流量或日志量来确认,诸如 Nginx 等反向代理都会记录 URL可以依靠代理的 Access Log 进行细化定位,也可以通过监控观察 JVM 线程数的情况。压力问题导致 CPU 使用高的情况下,如果程序的各资源使用没有明显不正常,之后可以通过压测 +Profilerjvisualvm 就有这个功能)进一步定位热点方法;如果资源使用不正常,比如产生了几千个线程,就需要考虑调参。
GC。这种情况我们可以通过 JVM 监控 GC 相关指标、GC Log 进行确认。如果确认是 GC 的压力,那么内存使用也很可能会不正常,需要按照内存问题分析流程做进一步分析。
程序中死循环逻辑或不正常的处理流程。这类问题,我们可以结合应用日志分析。一般情况下,应用执行过程中都会产生一些日志,可以重点关注日志量异常部分。
对于内存泄露或 OOM 的问题,最简单的分析方式,就是堆转储后使用 MAT 分析。堆转储,包含了堆现场全貌和线程栈信息,一般观察支配树图、直方图就可以马上看到占用大量内存的对象,可以快速定位到内存相关问题。这一点我们会在第 5 篇加餐中详细介绍。
需要注意的是Java 进程对内存的使用不仅仅是堆区,还包括线程使用的内存(线程个数 * 每一个线程的线程栈)和元数据区。每一个内存区都可能产生 OOM可以结合监控观察线程数、已加载类数量等指标分析。另外我们需要注意看一下JVM 参数的设置是否有明显不合理的地方,限制了资源使用。
IO 相关的问题,除非是代码问题引起的资源不释放等问题,否则通常都不是由 Java 进程内部因素引发的。
网络相关的问题,一般也是由外部因素引起的。对于连通性问题,结合异常信息通常比较容易定位;对于性能或瞬断问题,可以先尝试使用 ping 等工具简单判断,如果不行再使用 tcpdump 或 Wireshark 来分析。
分析和定位问题需要注意的九个点
有些时候,我们分析和定位问题时,会陷入误区或是找不到方向。遇到这种情况,你可以借鉴下我的九个心得。
第一,考虑“鸡”和“蛋”的问题。比如,发现业务逻辑执行很慢且线程数增多的情况时,我们需要考虑两种可能性:
一是程序逻辑有问题或外部依赖慢使得业务逻辑执行慢在访问量不变的情况下需要更多的线程数来应对。比如10TPS 的并发原先一次请求 1s 可以执行完成10 个线程可以支撑;现在执行完成需要 10s那就需要 100 个线程。
二是,有可能是请求量增大了,使得线程数增多,应用本身的 CPU 资源不足,再加上上下文切换问题导致处理变慢了。
出现问题的时候,我们需要结合内部表现和入口流量一起看,确认这里的“慢”到底是根因还是结果。
第二,考虑通过分类寻找规律。在定位问题没有头绪的时候,我们可以尝试总结规律。
比如,我们有 10 台应用服务器做负载均衡,出问题时可以通过日志分析是否是均匀分布的,还是问题都出现在 1 台机器。又比如,应用日志一般会记录线程名称,出问题时我们可以分析日志是否集中在某一类线程上。再比如,如果发现应用开启了大量 TCP 连接,通过 netstat 我们可以分析出主要集中连接到哪个服务。
如果能总结出规律,很可能就找到了突破点。
第三,分析问题需要根据调用拓扑来,不能想当然。比如,我们看到 Nginx 返回 502 错误,一般可以认为是下游服务的问题导致网关无法完成请求转发。对于下游服务,不能想当然就认为是我们的 Java 程序,比如在拓扑上可能 Nginx 代理的是 Kubernetes 的 Traefik Ingress链路是 Nginx->Traefik-> 应用,如果一味排查 Java 程序的健康情况,那么始终不会找到根因。
又比如,我们虽然使用了 Spring Cloud Feign 来进行服务调用,出现连接超时也不一定就是服务端的问题,有可能是客户端通过 URL 来调用服务端,并不是通过 Eureka 的服务发现实现的客户端负载均衡。换句话说,客户端连接的是 Nginx 代理而不是直接连接应用,客户端连接服务出现的超时,其实是 Nginx 代理宕机所致。
第四,考虑资源限制类问题。观察各种曲线指标,如果发现曲线慢慢上升然后稳定在一个水平线上,那么一般就是资源达到了限制或瓶颈。
比如,在观察网络带宽曲线的时候,如果发现带宽上升到 120MB 左右不动了,那么很可能就是打满了 1GB 的网卡或传输带宽。又比如,观察到数据库活跃连接数上升到 10 个就不动了,那么很可能是连接池打满了。观察监控一旦看到任何这样的曲线,都要引起重视。
第五考虑资源相互影响。CPU、内存、IO 和网络,这四类资源就像人的五脏六腑,是相辅相成的,一个资源出现了明显的瓶颈,很可能会引起其他资源的连锁反应。
比如,内存泄露后对象无法回收会造成大量 Full GC此时 CPU 会大量消耗在 GC 上从而引起 CPU 使用增加。又比如,我们经常会把数据缓存在内存队列中进行异步 IO 处理,网络或磁盘出现问题时,就很可能会引起内存的暴涨。因此,出问题的时候,我们要考虑到这一点,以避免误判。
第六排查网络问题要考虑三个方面到底是客户端问题还是服务端问题还是传输问题。比如出现数据库访问慢的现象可能是客户端的原因连接池不够导致连接获取慢、GC 停顿、CPU 占满等;也可能是传输环节的问题,包括光纤、防火墙、路由表设置等问题;也可能是真正的服务端问题,需要逐一排查来进行区分。
服务端慢一般可以看到 MySQL 出慢日志,传输慢一般可以通过 ping 来简单定位,排除了这两个可能,并且仅仅是部分客户端出现访问慢的情况,就需要怀疑是客户端本身的问题。对于第三方系统、服务或存储访问出现慢的情况,不能完全假设是服务端的问题。
第七快照类工具和趋势类工具需要结合使用。比如jstat、top、各种监控曲线是趋势类工具可以让我们观察各个指标的变化情况定位大概的问题点而 jstack 和分析堆快照的 MAT 是快照类工具,用于详细分析某一时刻应用程序某一个点的细节。
一般情况下,我们会先使用趋势类工具来总结规律,再使用快照类工具来分析问题。如果反过来可能就会误判,因为快照类工具反映的只是一个瞬间程序的情况,不能仅仅通过分析单一快照得出结论,如果缺少趋势类工具的帮助,那至少也要提取多个快照来对比。
第八,不要轻易怀疑监控。我曾看过一个空难事故的分析,飞行员在空中发现仪表显示飞机所有油箱都处于缺油的状态,他第一时间的怀疑是油表出现故障了,始终不愿意相信是真的缺油,结果飞行不久后引擎就断油熄火了。同样地,在应用出现问题时,我们会查看各种监控系统,但有些时候我们宁愿相信自己的经验,也不相信监控图表的显示。这可能会导致我们完全朝着错误的方向来排查问题。
如果你真的怀疑是监控系统有问题,可以看一下这套监控系统对于不出问题的应用显示是否正常,如果正常那就应该相信监控而不是自己的经验。
第九,如果因为监控缺失等原因无法定位到根因的话,相同问题就有再出现的风险,需要做好三项工作:
做好日志、监控和快照补漏工作,下次遇到问题时可以定位根因;
针对问题的症状做好实时报警,确保出现问题后可以第一时间发现;
考虑做一套热备的方案,出现问题后可以第一时间切换到热备系统快速解决问题,同时又可以保留老系统的现场。
重点回顾
今天,我和你总结分享了分析生产环境问题的套路。
第一,分析问题一定是需要依据的,靠猜是猜不出来的,需要提前做好基础监控的建设。监控的话,需要在基础运维层、应用层、业务层等多个层次进行。定位问题的时候,我们同样需要参照多个监控层的指标表现综合分析。
第二定位问题要先对原因进行大致分类比如是内部问题还是外部问题、CPU 相关问题还是内存相关问题、仅仅是 A 接口的问题还是整个应用的问题,然后再去进一步细化探索,一定是从大到小来思考问题;在追查问题遇到瓶颈的时候,我们可以先退出细节,再从大的方面捋一下涉及的点,再重新来看问题。
第三,分析问题很多时候靠的是经验,很难找到完整的方法论。遇到重大问题的时候,往往也需要根据直觉来第一时间找到最有可能的点,这里甚至有运气成分。我还和你分享了我的九条经验,建议你在平时解决问题的时候多思考、多总结,提炼出更多自己分析问题的套路和拿手工具。
最后,值得一提的是,定位到问题原因后,我们要做好记录和复盘。每一次故障和问题都是宝贵的资源,复盘不仅仅是记录问题,更重要的是改进。复盘时,我们需要做到以下四点:
记录完整的时间线、处理措施、上报流程等信息;
分析问题的根本原因;
给出短、中、长期改进方案包括但不限于代码改动、SOP、流程并记录跟踪每一个方案进行闭环
定期组织团队回顾过去的故障。
思考与讨论
如果你现在打开一个 App 后发现首页展示了一片空白,那这到底是客户端兼容性的问题,还是服务端的问题呢?如果是服务端的问题,又如何进一步细化定位呢?你有什么分析思路吗?
对于分析定位问题,你会做哪些监控或是使用哪些工具呢?
你有没有遇到过什么花了很长时间才定位到的,或是让你印象深刻的问题或事故呢?我是朱晔,欢迎在评论区与我留言分享你的想法,也欢迎你把这篇文章分享给你的朋友或同事,一起交流。

View File

@@ -0,0 +1,700 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
34 加餐4分析定位Java问题一定要用好这些工具
今天,我要和你分享的内容是分析定位 Java 问题常用的一些工具。
到这里,我们的课程更新 17 讲了,已经更新过半了。在学习过程中,你会发现我在介绍各种坑的时候,并不是直接给出问题的结论,而是通过工具来亲眼看到问题。
为什么这么做呢?因为我始终认为,遇到问题尽量不要去猜,一定要眼见为实。只有通过日志、监控或工具真正看到问题,然后再回到代码中进行比对确认,我们才能认为是找到了根本原因。
你可能一开始会比较畏惧使用复杂的工具去排查问题,又或者是打开了工具感觉无从下手,但是随着实践越来越多,对 Java 程序和各种框架的运作越来越熟悉,你会发现使用这些工具越来越顺手。
其实呢,工具只是我们定位问题的手段,要用好工具主要还是得对程序本身的运作有大概的认识,这需要长期的积累。
因此,我会通过两篇加餐,和你分享 4 个案例,分别展示使用 JDK 自带的工具来排查 JVM 参数配置问题、使用 Wireshark 来分析网络问题、通过 MAT 来分析内存问题,以及使用 Arthas 来分析 CPU 使用高的问题。这些案例也只是冰山一角,你可以自己再通过些例子进一步学习和探索。
在今天这篇加餐中,我们就先学习下如何使用 JDK 自带工具、Wireshark 来分析和定位 Java 程序的问题吧。
使用 JDK 自带工具查看 JVM 情况
JDK 自带了很多命令行甚至是图形界面工具,帮助我们查看 JVM 的一些信息。比如,在我的机器上运行 ls 命令,可以看到 JDK 8 提供了非常多的工具或程序:
接下来,我会与你介绍些常用的监控工具。你也可以先通过下面这张图了解下各种工具的基本作用:
为了测试这些工具,我们先来写一段代码:启动 10 个死循环的线程,每个线程分配一个 10MB 左右的字符串,然后休眠 10 秒。可以想象到,这个程序会对 GC 造成压力。
//启动10个线程
IntStream.rangeClosed(1, 10).mapToObj(i -> new Thread(() -> {
while (true) {
//每一个线程都是一个死循环休眠10秒打印10M数据
String payload = IntStream.rangeClosed(1, 10000000)
.mapToObj(__ -> "a")
.collect(Collectors.joining("")) + UUID.randomUUID().toString();
try {
TimeUnit.SECONDS.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(payload.length());
}
})).forEach(Thread::start);
TimeUnit.HOURS.sleep(1);
修改 pom.xml配置 spring-boot-maven-plugin 插件打包的 Java 程序的 main 方法类:
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<mainClass>org.geekbang.time.commonmistakes.troubleshootingtools.jdktool.CommonMistakesApplication
</mainClass>
</configuration>
</plugin>
然后使用 java -jar 启动进程,设置 JVM 参数,让堆最小最大都是 1GB
java -jar common-mistakes-0.0.1-SNAPSHOT.jar -Xms1g -Xmx1g
完成这些准备工作后,我们就可以使用 JDK 提供的工具,来观察分析这个测试程序了。
jps
首先,使用 jps 得到 Java 进程列表,这会比使用 ps 来的方便:
➜ ~ jps
12707
22261 Launcher
23864 common-mistakes-0.0.1-SNAPSHOT.jar
15608 RemoteMavenServer36
23243 Main
23868 Jps
22893 KotlinCompileDaemon
jinfo
然后,可以使用 jinfo 打印 JVM 的各种参数:
➜ ~ jinfo 23864
Java System Properties:
#Wed Jan 29 12:49:47 CST 2020
...
user.name=zhuye
path.separator=\:
os.version=10.15.2
java.runtime.name=Java(TM) SE Runtime Environment
file.encoding=UTF-8
java.vm.name=Java HotSpot(TM) 64-Bit Server VM
...
VM Flags:
-XX:CICompilerCount=4 -XX:ConcGCThreads=2 -XX:G1ConcRefinementThreads=8 -XX:G1HeapRegionSize=1048576 -XX:GCDrainStackTargetSize=64 -XX:InitialHeapSize=268435456 -XX:MarkStackSize=4194304 -XX:MaxHeapSize=4294967296 -XX:MaxNewSize=2576351232 -XX:MinHeapDeltaBytes=1048576 -XX:NonNMethodCodeHeapSize=5835340 -XX:NonProfiledCodeHeapSize=122911450 -XX:ProfiledCodeHeapSize=122911450 -XX:ReservedCodeCacheSize=251658240 -XX:+SegmentedCodeCache -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:+UseG1GC
VM Arguments:
java_command: common-mistakes-0.0.1-SNAPSHOT.jar -Xms1g -Xmx1g
java_class_path (initial): common-mistakes-0.0.1-SNAPSHOT.jar
Launcher Type: SUN_STANDARD
查看第 15 行和 19 行可以发现,我们设置 JVM 参数的方式不对,-Xms1g 和 -Xmx1g 这两个参数被当成了 Java 程序的启动参数,整个 JVM 目前最大内存是 4GB 左右,而不是 1GB。
因此,当我们怀疑 JVM 的配置很不正常的时候,要第一时间使用工具来确认参数。除了使用工具确认 JVM 参数外,你也可以打印 VM 参数和程序参数:
System.out.println("VM options");
System.out.println(ManagementFactory.getRuntimeMXBean().getInputArguments().stream().collect(Collectors.joining(System.lineSeparator())));
System.out.println("Program arguments");
System.out.println(Arrays.stream(args).collect(Collectors.joining(System.lineSeparator())));
把 JVM 参数放到 -jar 之前,重新启动程序,可以看到如下输出,从输出也可以确认这次 JVM 参数的配置正确了:
➜ target git:(master) ✗ java -Xms1g -Xmx1g -jar common-mistakes-0.0.1-SNAPSHOT.jar test
VM options
-Xms1g
-Xmx1g
Program arguments
test
jvisualvm
然后,启动另一个重量级工具 jvisualvm 观察一下程序,可以在概述面板再次确认 JVM 参数设置成功了:
继续观察监视面板可以看到JVM 的 GC 活动基本是 10 秒发生一次,堆内存在 250MB 到 900MB 之间波动,活动线程数是 22。我们可以在监视面板看到 JVM 的基本情况,也可以直接在这里进行手动 GC 和堆 Dump 操作:
jconsole
如果希望看到各个内存区的 GC 曲线图,可以使用 jconsole 观察。jconsole 也是一个综合性图形界面监控工具,比 jvisualvm 更方便的一点是,可以用曲线的形式监控各种数据,包括 MBean 中的属性值:
jstat
同样,如果没有条件使用图形界面(毕竟在 Linux 服务器上,我们主要使用命令行工具),又希望看到 GC 趋势的话,我们可以使用 jstat 工具。
jstat 工具允许以固定的监控频次输出 JVM 的各种监控指标,比如使用 -gcutil 输出 GC 和内存占用汇总信息,每隔 5 秒输出一次,输出 100 次,可以看到 Young GC 比较频繁,而 Full GC 基本 10 秒一次:
➜ ~ jstat -gcutil 23940 5000 100
S0 S1 E O M CCS YGC YGCT FGC FGCT CGC CGCT GCT
0.00 100.00 0.36 87.63 94.30 81.06 539 14.021 33 3.972 837 0.976 18.968
0.00 100.00 0.60 69.51 94.30 81.06 540 14.029 33 3.972 839 0.978 18.979
0.00 0.00 0.50 99.81 94.27 81.03 548 14.143 34 4.002 840 0.981 19.126
0.00 100.00 0.59 70.47 94.27 81.03 549 14.177 34 4.002 844 0.985 19.164
0.00 100.00 0.57 99.85 94.32 81.09 550 14.204 34 4.002 845 0.990 19.196
0.00 100.00 0.65 77.69 94.32 81.09 559 14.469 36 4.198 847 0.993 19.659
0.00 100.00 0.65 77.69 94.32 81.09 559 14.469 36 4.198 847 0.993 19.659
0.00 100.00 0.70 35.54 94.32 81.09 567 14.763 37 4.378 853 1.001 20.142
0.00 100.00 0.70 41.22 94.32 81.09 567 14.763 37 4.378 853 1.001 20.142
0.00 100.00 1.89 96.76 94.32 81.09 574 14.943 38 4.487 859 1.007 20.438
0.00 100.00 1.39 39.20 94.32 81.09 575 14.946 38 4.487 861 1.010 20.442
其中S0 表示 Survivor0 区占用百分比S1 表示 Survivor1 区占用百分比E 表示 Eden 区占用百分比O 表示老年代占用百分比M 表示元数据区占用百分比YGC 表示年轻代回收次数YGCT 表示年轻代回收耗时FGC 表示老年代回收次数FGCT 表示老年代回收耗时。
jstat 命令的参数众多,包含 -class、-compiler、-gc 等。Java 8、Linux/Unix 平台 jstat 工具的完整介绍你可以查看这里。jstat 定时输出的特性,可以方便我们持续观察程序的各项指标。
继续来到线程面板可以看到,大量以 Thread 开头的线程基本都是有节奏的 10 秒运行一下,其他时间都在休眠,和我们的代码逻辑匹配:
点击面板的线程 Dump 按钮,可以查看线程瞬时的线程栈:
jstack
通过命令行工具 jstack也可以实现抓取线程栈的操作
➜ ~ jstack 23940
2020-01-29 13:08:15
Full thread dump Java HotSpot(TM) 64-Bit Server VM (11.0.3+12-LTS mixed mode):
...
"main" #1 prio=5 os_prio=31 cpu=440.66ms elapsed=574.86s tid=0x00007ffdd9800000 nid=0x2803 waiting on condition [0x0000700003849000]
java.lang.Thread.State: TIMED_WAITING (sleeping)
at java.lang.Thread.sleep([email protected]/Native Method)
at java.lang.Thread.sleep([email protected]/Thread.java:339)
at java.util.concurrent.TimeUnit.sleep([email protected]/TimeUnit.java:446)
at org.geekbang.time.commonmistakes.troubleshootingtools.jdktool.CommonMistakesApplication.main(CommonMistakesApplication.java:41)
at jdk.internal.reflect.NativeMethodAccessorImpl.invoke0([email protected]/Native Method)
at jdk.internal.reflect.NativeMethodAccessorImpl.invoke([email protected]/NativeMethodAccessorImpl.java:62)
at jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke([email protected]/DelegatingMethodAccessorImpl.java:43)
at java.lang.reflect.Method.invoke([email protected]/Method.java:566)
at org.springframework.boot.loader.MainMethodRunner.run(MainMethodRunner.java:48)
at org.springframework.boot.loader.Launcher.launch(Launcher.java:87)
at org.springframework.boot.loader.Launcher.launch(Launcher.java:51)
at org.springframework.boot.loader.JarLauncher.main(JarLauncher.java:52)
"Thread-1" #13 prio=5 os_prio=31 cpu=17851.77ms elapsed=574.41s tid=0x00007ffdda029000 nid=0x9803 waiting on condition [0x000070000539d000]
java.lang.Thread.State: TIMED_WAITING (sleeping)
at java.lang.Thread.sleep([email protected]/Native Method)
at java.lang.Thread.sleep([email protected]/Thread.java:339)
at java.util.concurrent.TimeUnit.sleep([email protected]/TimeUnit.java:446)
at org.geekbang.time.commonmistakes.troubleshootingtools.jdktool.CommonMistakesApplication.lambda$null$1(CommonMistakesApplication.java:33)
at org.geekbang.time.commonmistakes.troubleshootingtools.jdktool.CommonMistakesApplication$$Lambda$41/0x00000008000a8c40.run(Unknown Source)
at java.lang.Thread.run([email protected]/Thread.java:834)
...
抓取后可以使用类似fastthread这样的在线分析工具来分析线程栈。
jcmd
最后,我们来看一下 Java HotSpot 虚拟机的 NMT 功能。
通过 NMT我们可以观察细粒度内存使用情况设置 -XX:NativeMemoryTracking=summary/detail 可以开启 NMT 功能,开启后可以使用 jcmd 工具查看 NMT 数据。
我们重新启动一次程序,这次加上 JVM 参数以 detail 方式开启 NMT
-Xms1g -Xmx1g -XX:ThreadStackSize=256k -XX:NativeMemoryTracking=detail
在这里,我们还增加了 -XX:ThreadStackSize 参数,并将其值设置为 256k也就是期望把线程栈设置为 256KB。我们通过 NMT 观察一下设置是否成功。
启动程序后执行如下 jcmd 命令,以概要形式输出 NMT 结果。可以看到,当前有 32 个线程,线程栈总共保留了差不多 4GB 左右的内存。我们明明配置线程栈最大 256KB 啊,为什么会出现 4GB 这么夸张的数字呢,到底哪里出了问题呢?
➜ ~ jcmd 24404 VM.native_memory summary
24404:
Native Memory Tracking:
Total: reserved=6635310KB, committed=5337110KB
\- Java Heap (reserved=1048576KB, committed=1048576KB)
(mmap: reserved=1048576KB, committed=1048576KB)
\- Class (reserved=1066233KB, committed=15097KB)
(classes #902)
(malloc=9465KB #908)
(mmap: reserved=1056768KB, committed=5632KB)
\- Thread (reserved=4209797KB, committed=4209797KB)
(thread #32)
(stack: reserved=4209664KB, committed=4209664KB)
(malloc=96KB #165)
(arena=37KB #59)
\- Code (reserved=249823KB, committed=2759KB)
(malloc=223KB #730)
(mmap: reserved=249600KB, committed=2536KB)
\- GC (reserved=48700KB, committed=48700KB)
(malloc=10384KB #135)
(mmap: reserved=38316KB, committed=38316KB)
\- Compiler (reserved=186KB, committed=186KB)
(malloc=56KB #105)
(arena=131KB #7)
\- Internal (reserved=9693KB, committed=9693KB)
(malloc=9661KB #2585)
(mmap: reserved=32KB, committed=32KB)
\- Symbol (reserved=2021KB, committed=2021KB)
(malloc=1182KB #334)
(arena=839KB #1)
\- Native Memory Tracking (reserved=85KB, committed=85KB)
(malloc=5KB #53)
(tracking overhead=80KB)
\- Arena Chunk (reserved=196KB, committed=196KB)
(malloc=196KB)
重新以 VM.native_memory detail 参数运行 jcmd
jcmd 24404 VM.native_memory detail
可以看到,有 16 个可疑线程,每一个线程保留了 262144KB 内存,也就是 256MB通过下图红框可以看到使用关键字 262144KB for Thread Stack from 搜索到了 16 个结果):
其实ThreadStackSize 参数的单位是 KB所以我们如果要设置线程栈 256KB那么应该设置 256 而不是 256k。重新设置正确的参数后使用 jcmd 再次验证下:
除了用于查看 NMT 外jcmd 还有许多功能。我们可以通过 help看到它的所有功能
jcmd 24781 help
对于其中每一种功能,我们都可以进一步使用 help 来查看介绍。比如,使用 GC.heap_info 命令可以打印 Java 堆的一些信息:
jcmd 24781 help GC.heap_info
除了 jps、jinfo、jcmd、jstack、jstat、jconsole、jvisualvm 外JDK 中还有一些工具,你可以通过官方文档查看完整介绍。
使用 Wireshark 分析 SQL 批量插入慢的问题
我之前遇到过这样一个案例:有一个数据导入程序需要导入大量的数据,开发同学就想到了使用 Spring JdbcTemplate 的批量操作功能进行数据批量导入,但是发现性能非常差,和普通的单条 SQL 执行性能差不多。
我们重现下这个案例。启动程序后,首先创建一个 testuser 表,其中只有一列 name然后使用 JdbcTemplate 的 batchUpdate 方法,批量插入 10000 条记录到 testuser 表:
@SpringBootApplication
@Slf4j
public class BatchInsertAppliation implements CommandLineRunner {
@Autowired
private JdbcTemplate jdbcTemplate;
public static void main(String[] args) {
SpringApplication.run(BatchInsertApplication.class, args);
}
@PostConstruct
public void init() {
//初始化表
jdbcTemplate.execute("drop table IF EXISTS `testuser`;");
jdbcTemplate.execute("create TABLE `testuser` (\n" +
" `id` bigint(20) NOT NULL AUTO_INCREMENT,\n" +
" `name` varchar(255) NOT NULL,\n" +
" PRIMARY KEY (`id`)\n" +
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;");
}
@Override
public void run(String... args) {
long begin = System.currentTimeMillis();
String sql = "INSERT INTO `testuser` (`name`) VALUES (?)";
//使用JDBC批量更新
jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() {
@Override
public void setValues(PreparedStatement preparedStatement, int i) throws SQLException {
//第一个参数(索引从1开始)也就是name列赋值
preparedStatement.setString(1, "usera" + i);
}
@Override
public int getBatchSize() {
//批次大小为10000
return 10000;
}
});
log.info("took : {} ms", System.currentTimeMillis() - begin);
}
}
执行程序后可以看到1 万条数据插入耗时 26 秒:
[14:44:19.094] [main] [INFO ] [o.g.t.c.t.network.BatchInsertApplication:52 ] - took : 26144 ms
其实,对于批量操作,我们希望程序可以把多条 insert SQL 语句合并成一条,或至少是一次性提交多条语句到数据库,以减少和 MySQL 交互次数、提高性能。那么,我们的程序是这样运作的吗?
我在加餐 3中提到一条原则“分析问题一定是需要依据的靠猜是猜不出来的”。现在我们就使用网络分析工具 Wireshark 来分析一下这个案例,眼见为实。
首先,我们可以在这里下载 Wireshark启动后选择某个需要捕获的网卡。由于我们连接的是本地的 MySQL因此选择 loopback 回环网卡:
然后Wireshark 捕捉这个网卡的所有网络流量。我们可以在上方的显示过滤栏输入 tcp.port == 6657来过滤出所有 6657 端口的 TCP 请求(因为我们是通过 6657 端口连接 MySQL 的)。
可以看到,程序运行期间和 MySQL 有大量交互。因为 Wireshark 直接把 TCP 数据包解析为了 MySQL 协议,所以下方窗口可以直接显示 MySQL 请求的 SQL 查询语句。我们看到testuser 表的每次 insert 操作,插入的都是一行记录:
如果列表中的 Protocol 没有显示 MySQL 的话,你可以手动点击 Analyze 菜单的 Decode As 菜单,然后加一条规则,把 6657 端口设置为 MySQL 协议:
这就说明,我们的程序并不是在做批量插入操作,和普通的单条循环插入没有区别。调试程序进入 ClientPreparedStatement 类,可以看到执行批量操作的是 executeBatchInternal 方法。executeBatchInternal 方法的源码如下:
@Override
protected long[] executeBatchInternal() throws SQLException {
synchronized (checkClosed().getConnectionMutex()) {
if (this.connection.isReadOnly()) {
throw new SQLException(Messages.getString("PreparedStatement.25") + Messages.getString("PreparedStatement.26"),
MysqlErrorNumbers.SQL_STATE_ILLEGAL_ARGUMENT);
}
if (this.query.getBatchedArgs() == null || this.query.getBatchedArgs().size() == 0) {
return new long[0];
}
// we timeout the entire batch, not individual statements
int batchTimeout = getTimeoutInMillis();
setTimeoutInMillis(0);
resetCancelledState();
try {
statementBegins();
clearWarnings();
if (!this.batchHasPlainStatements && this.rewriteBatchedStatements.getValue()) {
if (((PreparedQuery<?>) this.query).getParseInfo().canRewriteAsMultiValueInsertAtSqlLevel()) {
return executeBatchedInserts(batchTimeout);
}
if (!this.batchHasPlainStatements && this.query.getBatchedArgs() != null
&& this.query.getBatchedArgs().size() > 3 /* cost of option setting rt-wise */) {
return executePreparedBatchAsMultiStatement(batchTimeout);
}
}
return executeBatchSerially(batchTimeout);
} finally {
this.query.getStatementExecuting().set(false);
clearBatch();
}
}
}
注意第 18 行,判断了 rewriteBatchedStatements 参数是否为 true是才会开启批量的优化。优化方式有 2 种:
如果有条件的话,优先把 insert 语句优化为一条语句,也就是 executeBatchedInserts 方法;
如果不行的话,再尝试把 insert 语句优化为多条语句一起提交,也就是 executePreparedBatchAsMultiStatement 方法。
到这里就明朗了,实现批量提交优化的关键,在于 rewriteBatchedStatements 参数。我们修改连接字符串,并将其值设置为 true
spring.datasource.url=jdbc:mysql://localhost:6657/common_mistakes?characterEncoding=UTF-8&useSSL=false&rewriteBatchedStatements=true
重新按照之前的步骤打开 Wireshark 验证,可以看到:
这次 insert SQL 语句被拼接成了一条语句(如第二个红框所示);
这个 TCP 包因为太大被分割成了 11 个片段传输,#699 请求是最后一个片段,其实际内容是 insert 语句的最后一部分内容(如第一和第三个红框显示)。
为了查看整个 TCP 连接的所有数据包,你可以在请求上点击右键,选择 Follow->TCP Stream
打开后可以看到,从 MySQL 认证开始到 insert 语句的所有数据包的内容:
查看最开始的握手数据包可以发现TCP 的最大分段大小MSS是 16344 字节,而我们的 MySQL 超长 insert 的数据一共 138933 字节,因此被分成了 11 段传输,其中最大的一段是 16332 字节,低于 MSS 要求的 16344 字节。
最后可以看到插入 1 万条数据仅耗时 253 毫秒,性能提升了 100 倍:
[20:19:30.185] [main] [INFO ] [o.g.t.c.t.network.BatchInsertApplication:52 ] - took : 253 ms
虽然我们一直在使用 MySQL但我们很少会考虑 MySQL Connector Java 是怎么和 MySQL 交互的,实际发送给 MySQL 的 SQL 语句又是怎样的。有没有感觉到MySQL 协议其实并不遥远,我们完全可以使用 Wireshark 来观察、分析应用程序与 MySQL 交互的整个流程。
重点回顾
今天,我就使用 JDK 自带工具查看 JVM 情况、使用 Wireshark 分析 SQL 批量插入慢的问题,和你展示了一些工具及其用法。
首先JDK 自带的一些监控和故障诊断工具中,有命令行工具也有图形工具。其中,命令行工具更适合在服务器上使用,图形界面工具用于本地观察数据更直观。为了帮助你用好这些工具,我们带你使用这些工具,分析了程序错误设置 JVM 参数的两个问题,并且观察了 GC 工作的情况。
然后,我们使用 Wireshark 分析了 MySQL 批量 insert 操作慢的问题。我们看到,通过 Wireshark 分析网络包可以让一切变得如此透明。因此,学好 Wireshark对我们排查 C/S 网络程序的 Bug 或性能问题,会有非常大的帮助。
比如,遇到诸如 Connection reset、Broken pipe 等网络问题的时候,你可以利用 Wireshark 来定位问题,观察客户端和服务端之间到底出了什么问题。
此外如果你需要开发网络程序的话Wireshark 更是分析协议、确认程序是否正确实现的必备工具。
今天用到的代码,我都放在了 GitHub 上,你可以点击这个链接查看。
思考与讨论
JDK 中还有一个 jmap 工具,我们会使用 jmap -dump 命令来进行堆转储。那么,这条命令和 jmap -dump:live 有什么区别呢?你能否设计一个实验,来证明下它们的区别呢?
你有没有想过,客户端是如何和 MySQL 进行认证的呢你能否对照MySQL 的文档,使用 Wireshark 观察分析这一过程呢?
在平时工作中,你还会使用什么工具来分析排查 Java 应用程序的问题呢?我是朱晔,欢迎在评论区与我留言分享你的想法,也欢迎你把今天的内容分享给你的朋友或同事,一起交流。

View File

@@ -0,0 +1,314 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
35 加餐5分析定位Java问题一定要用好这些工具
在上一篇加餐中,我们介绍了使用 JDK 内置的一些工具、网络抓包工具 Wireshark 去分析、定位 Java 程序的问题。很多同学看完这一讲,留言反馈说是“打开了一片新天地,之前没有关注过 JVM”“利用 JVM 工具发现了生产 OOM 的原因”。
其实,工具正是帮助我们深入到框架和组件内部,了解其运作方式和原理的重要抓手。所以,我们一定要用好它们。
今天,我继续和你介绍如何使用 JVM 堆转储的工具 MAT 来分析 OOM 问题,以及如何使用全能的故障诊断工具 Arthas 来分析、定位高 CPU 问题。
使用 MAT 分析 OOM 问题
对于排查 OOM 问题、分析程序堆内存使用情况,最好的方式就是分析堆转储。
堆转储包含了堆现场全貌和线程栈信息Java 6 Update 14 开始包含)。我们在上一篇加餐中看到,使用 jstat 等工具虽然可以观察堆内存使用情况的变化,但是对程序内到底有多少对象、哪些是大对象还一无所知,也就是说只能看到问题但无法定位问题。而堆转储,就好似得到了病人在某个瞬间的全景核磁影像,可以拿着慢慢分析。
Java 的 OutOfMemoryError 是比较严重的问题,需要分析出根因,所以对生产应用一般都会这样设置 JVM 参数,方便发生 OOM 时进行堆转储:
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=.
上一篇加餐中我们提到的 jvisualvm 工具,同样可以进行一键堆转储后,直接打开这个 dump 查看。但是jvisualvm 的堆转储分析功能并不是很强大,只能查看类使用内存的直方图,无法有效跟踪内存使用的引用关系,所以我更推荐使用 Eclipse 的 Memory Analyzer也叫做 MAT做堆转储的分析。你可以点击这个链接下载 MAT。
使用 MAT 分析 OOM 问题,一般可以按照以下思路进行:
通过支配树功能或直方图功能查看消耗内存最大的类型,来分析内存泄露的大概原因;
查看那些消耗内存最大的类型、详细的对象明细列表,以及它们的引用链,来定位内存泄露的具体点;
配合查看对象属性的功能,可以脱离源码看到对象的各种属性的值和依赖关系,帮助我们理清程序逻辑和参数;
辅助使用查看线程栈来看 OOM 问题是否和过多线程有关,甚至可以在线程栈看到 OOM 最后一刻出现异常的线程。
比如,我手头有一个 OOM 后得到的转储文件 java_pid29569.hprof现在要使用 MAT 的直方图、支配树、线程栈、OQL 等功能来分析此次 OOM 的原因。
首先,用 MAT 打开后先进入的是概览信息界面,可以看到整个堆是 437.6MB
那么,这 437.6MB 都是什么对象呢?
如图所示工具栏的第二个按钮可以打开直方图直方图按照类型进行分组列出了每个类有多少个实例以及占用的内存。可以看到char[]字节数组占用内存最多,对象数量也很多,结合第二位的 String 类型对象数量也很多大概可以猜出String 使用 char[]作为实际数据存储)程序可能是被字符串占满了内存,导致 OOM。
我们继续分析下,到底是不是这样呢。
在 char[]上点击右键,选择 List objects->with incoming references就可以列出所有的 char[]实例,以及每个 char[]的整个引用关系链:
随机展开一个 char[],如下图所示:
接下来,我们按照红色框中的引用链来查看,尝试找到这些大 char[]的来源:
在①处看到,这些 char[]几乎都是 10000 个字符、占用 20000 字节左右char 是 UTF-16每一个字符占用 2 字节);
在②处看到char[]被 String 的 value 字段引用,说明 char[]来自字符串;
在③处看到String 被 ArrayList 的 elementData 字段引用,说明这些字符串加入了一个 ArrayList 中;
在④处看到ArrayList 又被 FooService 的 data 字段引用,这个 ArrayList 整个 RetainedHeap 列的值是 431MB。
Retained Heap深堆代表对象本身和对象关联的对象占用的内存Shallow Heap浅堆代表对象本身占用的内存。比如我们的 FooService 中的 data 这个 ArrayList 对象本身只有 16 字节,但是其所有关联的对象占用了 431MB 内存。这些就可以说明,肯定有哪里在不断向这个 List 中添加 String 数据,导致了 OOM。
左侧的蓝色框可以查看每一个实例的内部属性,图中显示 FooService 有一个 data 属性,类型是 ArrayList。
如果我们希望看到字符串完整内容的话,可以右键选择 Copy->Value把值复制到剪贴板或保存到文件中
这里,我们复制出的是 10000 个字符 a下图红色部分可以看到。对于真实案例查看大字符串、大数据的实际内容对于识别数据来源有很大意义
看到这些,我们已经基本可以还原出真实的代码是怎样的了。
其实,我们之前使用直方图定位 FooService已经走了些弯路。你可以点击工具栏中第三个按钮下图左上角的红框所示进入支配树界面有关支配树的具体概念参考这里。这个界面会按照对象保留的 Retained Heap 倒序直接列出占用内存最大的对象。
可以看到,第一位就是 FooService整个路径是 FooSerice->ArrayList->Object[]->String->char[](蓝色框部分),一共有 21523 个字符串(绿色方框部分):
这样,我们就从内存角度定位到 FooService 是根源了。那么OOM 的时候FooService 是在执行什么逻辑呢?
为解决这个问题,我们可以点击工具栏的第五个按钮(下图红色框所示)。打开线程视图,首先看到的就是一个名为 main 的线程Name 列),展开后果然发现了 FooService
先执行的方法先入栈,所以线程栈最上面是线程当前执行的方法,逐一往下看能看到整个调用路径。因为我们希望了解 FooService.oom() 方法,看看是谁在调用它,它的内部又调用了谁,所以选择以 FooService.oom() 方法(蓝色框)为起点来分析这个调用栈。
往下看整个绿色框部分oom() 方法被 OOMApplication 的 run 方法调用,而这个 run 方法又被 SpringAppliction.callRunner 方法调用。看到参数中的 CommandLineRunner 你应该能想到OOMApplication 其实是实现了 CommandLineRunner 接口,所以是 SpringBoot 应用程序启动后执行的。
以 FooService 为起点往上看,从紫色框中的 Collectors 和 IntPipeline你大概也可以猜出这些字符串是由 Stream 操作产生的。再往上看,可以发现在 StringBuilder 的 append 操作的时候,出现了 OutOfMemoryError 异常(黑色框部分),说明这这个线程抛出了 OOM 异常。
我们看到,整个程序是 Spring Boot 应用程序,那么 FooService 是不是 Spring 的 Bean 呢,又是不是单例呢?如果能分析出这点的话,就更能确认是因为反复调用同一个 FooService 的 oom 方法,然后导致其内部的 ArrayList 不断增加数据的。
点击工具栏的第四个按钮(如下图红框所示),来到 OQL 界面。在这个界面,我们可以使用类似 SQL 的语法,在 dump 中搜索数据(你可以直接在 MAT 帮助菜单搜索 OQL Syntax来查看 OQL 的详细语法)。
比如,输入如下语句搜索 FooService 的实例:
SELECT * FROM org.geekbang.time.commonmistakes.troubleshootingtools.oom.FooService
可以看到只有一个实例,然后我们通过 List objects 功能搜索引用 FooService 的对象:
得到以下结果:
可以看到,一共两处引用:
第一处是OOMApplication 使用了 FooService这个我们已经知道了。
第二处是一个 ConcurrentHashMap。可以看到这个 HashMap 是 DefaultListableBeanFactory 的 singletonObjects 字段,可以证实 FooService 是 Spring 容器管理的单例的 Bean。
你甚至可以在这个 HashMap 上点击右键,选择 Java Collections->Hash Entries 功能,来查看其内容:
这样就列出了所有的 Bean可以在 Value 上的 Regex 进一步过滤。输入 FooService 后可以看到,类型为 FooService 的 Bean 只有一个,其名字是 fooService
到现在为止,我们虽然没看程序代码,但是已经大概知道程序出现 OOM 的原因和大概的调用栈了。我们再贴出程序来对比一下,果然和我们看到得一模一样:
@SpringBootApplication
public class OOMApplication implements CommandLineRunner {
@Autowired
FooService fooService;
public static void main(String[] args) {
SpringApplication.run(OOMApplication.class, args);
}
@Override
public void run(String... args) throws Exception {
//程序启动后不断调用Fooservice.oom()方法
while (true) {
fooService.oom();
}
}
}
@Component
public class FooService {
List<String> data = new ArrayList<>();
public void oom() {
//往同一个ArrayList中不断加入大小为10KB的字符串
data.add(IntStream.rangeClosed(1, 10_000)
.mapToObj(__ -> "a")
.collect(Collectors.joining("")));
}
}
到这里,我们使用 MAT 工具从对象清单、大对象、线程栈等视角,分析了一个 OOM 程序的堆转储。可以发现,有了堆转储,几乎相当于拿到了应用程序的源码 + 当时那一刻的快照OOM 的问题无从遁形。
使用 Arthas 分析高 CPU 问题
Arthas是阿里开源的 Java 诊断工具,相比 JDK 内置的诊断工具,要更人性化,并且功能强大,可以实现许多问题的一键定位,而且可以一键反编译类查看源码,甚至是直接进行生产代码热修复,实现在一个工具内快速定位和修复问题的一站式服务。今天,我就带你使用 Arthas 定位一个 CPU 使用高的问题,系统学习下这个工具的使用。
首先,下载并启动 Arthas
curl -O https://alibaba.github.io/arthas/arthas-boot.jar
java -jar arthas-boot.jar
启动后,直接找到我们要排查的 JVM 进程,然后可以看到 Arthas 附加进程成功:
[INFO] arthas-boot version: 3.1.7
[INFO] Found existing java process, please choose one and hit RETURN.
* [1]: 12707
[2]: 30724 org.jetbrains.jps.cmdline.Launcher
[3]: 30725 org.geekbang.time.commonmistakes.troubleshootingtools.highcpu.HighCPUApplication
[4]: 24312 sun.tools.jconsole.JConsole
[5]: 26328 org.jetbrains.jps.cmdline.Launcher
[6]: 24106 org.netbeans.lib.profiler.server.ProfilerServer
3
[INFO] arthas home: /Users/zhuye/.arthas/lib/3.1.7/arthas
[INFO] Try to attach process 30725
[INFO] Attach process 30725 success.
[INFO] arthas-client connect 127.0.0.1 3658
,---. ,------. ,--------.,--. ,--. ,---. ,---.
/ O \ | .--. ''--. .--'| '--' | / O \ ' .-'
| .-. || '--'.' | | | .--. || .-. |`. `-.
| | | || |\ \ | | | | | || | | |.-' |
`--' `--'`--' '--' `--' `--' `--'`--' `--'`-----'
wiki https://alibaba.github.io/arthas
tutorials https://alibaba.github.io/arthas/arthas-tutorials
version 3.1.7
pid 30725
time 2020-01-30 15:48:33
输出 help 命令,可以看到所有支持的命令列表。今天,我们会用到 dashboard、thread、jad、watch、ognl 命令,来定位这个 HighCPUApplication 进程。你可以通过官方文档,查看这些命令的完整介绍:
dashboard 命令用于整体展示进程所有线程、内存、GC 等情况,其输出如下:
可以看到CPU 高并不是 GC 引起的,占用 CPU 较多的线程有 8 个,其中 7 个是 ForkJoinPool.commonPool。学习过加餐 1的话你应该就知道了ForkJoinPool.commonPool 是并行流默认使用的线程池。所以,此次 CPU 高的问题,应该出现在某段并行流的代码上。
接下来,要查看最繁忙的线程在执行的线程栈,可以使用 thread -n 命令。这里,我们查看下最忙的 8 个线程:
thread -n 8
输出如下:
可以看到,由于这些线程都在处理 MD5 的操作,所以占用了大量 CPU 资源。我们希望分析出代码中哪些逻辑可能会执行这个操作,所以需要从方法栈上找出我们自己写的类,并重点关注。
由于主线程也参与了 ForkJoinPool 的任务处理,因此我们可以通过主线程的栈看到需要重点关注 org.geekbang.time.commonmistakes.troubleshootingtools.highcpu.HighCPUApplication 类的 doTask 方法。
接下来,使用 jad 命令直接对 HighCPUApplication 类反编译:
jad org.geekbang.time.commonmistakes.troubleshootingtools.highcpu.HighCPUApplication
可以看到,调用路径是 main->task()->doTask(),当 doTask 方法接收到的 int 参数等于某个常量的时候,会进行 1 万次的 MD5 操作,这就是耗费 CPU 的来源。那么,这个魔法值到底是多少呢?
你可能想到了,通过 jad 命令继续查看 User 类即可。这里因为是 Demo所以我没有给出很复杂的逻辑。在业务逻辑很复杂的代码中判断逻辑不可能这么直白我们可能还需要分析出 doTask 的“慢”会慢在什么入参上。
这时,我们可以使用 watch 命令来观察方法入参。如下命令,表示需要监控耗时超过 100 毫秒的 doTask 方法的入参,并且输出入参,展开 2 层入参参数:
watch org.geekbang.time.commonmistakes.troubleshootingtools.highcpu.HighCPUApplication doTask '{params}' '#cost>100' -x 2
可以看到,所有耗时较久的 doTask 方法的入参都是 0意味着 User.ADMN_ID 常量应该是 0。
最后,我们使用 ognl 命令来运行一个表达式,直接查询 User 类的 ADMIN_ID 静态字段来验证是不是这样,得到的结果果然是 0
[arthas@31126]$ ognl '@org.geekbang.time.commonmistakes.troubleshootingtools.highcpu.User@ADMIN_ID'
@Integer[0]
需要额外说明的是,由于 monitor、trace、watch 等命令是通过字节码增强技术来实现的,会在指定类的方法中插入一些切面来实现数据统计和观测,因此诊断结束要执行 shutdown 来还原类或方法字节码,然后退出 Arthas。
在这个案例中,我们通过 Arthas 工具排查了高 CPU 的问题:
首先,通过 dashboard + thread 命令,基本可以在几秒钟内一键定位问题,找出消耗 CPU 最多的线程和方法栈;
然后,直接 jad 反编译相关代码,来确认根因;
此外,如果调用入参不明确的话,可以使用 watch 观察方法入参,并根据方法执行时间来过滤慢请求的入参。
可见,使用 Arthas 来定位生产问题根本用不着原始代码,也用不着通过增加日志来帮助我们分析入参,一个工具即可完成定位问题、分析问题的全套流程。
对于应用故障分析,除了阿里 Arthas 之外还可以关注去哪儿的Bistoury 工具,其提供了可视化界面,并且可以针对多台机器进行管理,甚至提供了在线断点调试等功能,模拟 IDE 的调试体验。
重点回顾
最后,我再和你分享一个案例吧。
有一次开发同学遇到一个 OOM 问题通过查监控、查日志、查调用链路排查了数小时也无法定位问题但我拿到堆转储文件后直接打开支配树图一眼就看到了可疑点。Mybatis 每次查询都查询出了几百万条数据,通过查看线程栈马上可以定位到出现 Bug 的方法名,然后来到代码果然发现因为参数条件为 null 导致了全表查询,整个定位过程不足 5 分钟。
从这个案例我们看到,使用正确的工具、正确的方法来分析问题,几乎可以在几分钟内定位到问题根因。今天,我和你介绍的 MAT 正是分析 Java 堆内存问题的利器,而 Arthas 是快速定位分析 Java 程序生产 Bug 的利器。利用好这两个工具,就可以帮助我们在分钟级定位生产故障。
思考与讨论
在介绍线程池的时候,我们模拟了两种可能的 OOM 情况,一种是使用 Executors.newFixedThreadPool一种是使用 Executors.newCachedThreadPool你能回忆起 OOM 的原因吗?假设并不知道 OOM 的原因,拿到了这两种 OOM 后的堆转储,你能否尝试使用 MAT 分析堆转储来定位问题呢?
Arthas 还有一个强大的热修复功能。比如,遇到高 CPU 问题时,我们定位出是管理员用户会执行很多次 MD5消耗大量 CPU 资源。这时我们可以直接在服务器上进行热修复步骤是jad 命令反编译代码 -> 使用文本编辑器(比如 Vim直接修改代码 -> 使用 sc 命令查找代码所在类的 ClassLoader-> 使用 redefine 命令热更新代码。你可以尝试使用这个流程,直接修复程序(注释 doTask 方法中的相关代码)吗?
在平时工作中,你还会使用什么工具来分析排查 Java 应用程序的问题呢?我是朱晔,欢迎在评论区与我留言分享你的想法,也欢迎你把今天的内容分享给你的朋友或同事,一起交流。

View File

@@ -0,0 +1,163 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
36 加餐6这15年来我是如何在工作中学习技术和英语的
今天,我来和你聊聊如何在工作中,让自己成长得更快。
工作这些年来,经常会有同学来找我沟通学习和成长,他们的问题可以归结为两个。
一是,长期参与 CRUD 业务开发项目,技术提升出现瓶颈,学不到新知识,完全没有办法实践各种新技术,以后会不会被淘汰、找不到工作?
二是,英语学得比较晚,大学的时候也只是为了应试,英语水平很低,看不了英文的技术资料,更别说去外企找工作了。
不知道你是不是也面临这两个问题呢?今天,我就通过自己的经历和你分享一下,如何利用有限的环境、有限的时间来学习技术和英语?
学好技术
在我看来,知识网络的搭建就是在造楼房:基础也就是地基的承载力,决定了你能把楼造多高;广度就像是把房子造大、造宽;深度就是楼房的高度。因此,如果你想要提升自己的水平,那这三个方面的发展缺一不可。
第一,学习必须靠自觉。
虽说工作经历和项目经验是实践技术、提升技术的一个重要手段,但不可能所有的工作经历和项目都能持续地提升我们的技术。所以,我们要想提升自己的技术水平,就必须打消仅仅通过工作经历来提升的念头,要靠业余时间主动地持续学习和积累来提升。
比如,你可以针对项目中用到的技术,全面阅读官方文档,做各种 Demo 来论证其技术特性。在这个过程中,你一定还会产生许多技术疑问,那就继续展开学习。
第二,不要吝啬分享。
刚毕业那会,我花了很多时间和精力在 CSDN 回答问题,积极写博客、写书和翻译书。这些经历对我的技术成长,帮助非常大。
很多知识点我们自认为完全掌握了,但其实并不是那么回事儿。当我们要说出来教别人的时候,就必须 100% 了解每一个细节。因此,分享不仅是帮助自己进一步理清每一个知识点、锻炼自己的表达能力,更是一种强迫自己学习的手段,因为你要保证按时交付。
当然了,分享的过程也需要些正向激励,让自己保持分享的激情。就比如说,我获得的几次微软 MVP、CSDN TOP3 专家等荣誉,就对我激励很大,可以让我保持热情去不断地学习并帮助别人。
第三,不要停留在舒适区。
分享一段我的真实经历吧。我加入一家公司组建新团队后,在做技术选型的时候,考虑到成本等因素,放弃了从事了七年的.NET 技术,转型 Java。有了.NET 的积累,我自己转型 Java 只用了两周。其实,一开始做这个决定非常痛苦,但是突破自己的舒适区并没有想象得那么困难。随后,我又自学了 iOS、深度学习、Python 等技术或语言。
随着掌握的技术越来越多,这些技术不但让我触类旁通,更让我理解了技术只是工具,解决问题需要使用合适的技术。因此,我也建议你,利用业余时间多学习几门不同类型的编程语言,比如 Java、Python 和 Go。
有些时候,我们因为恐惧跳出舒适区而不愿意学习和引入合适的新技术来解决问题,虽然省去了前期的学习转型成本,但是后期却会投入更多的时间来弥补技术上的短板。
第四,打好基础很重要。
这里的“基础”是指和编程语言无关的那部分知识包括硬件基础、操作系统原理、TCP/IP、HTTP、数据结构和算法、安全基础、设计模式、数据库原理等。学习基础知识是比较枯燥的过程需要大块的时间来系统阅读相关书籍并要尝试把学到的知识付诸实践。只有实践过的技术才能映入脑子里否则只是书本上的知识。
比如,学习 TCP/IP 的时候,我们可以使用 Wireshark 来观察网络数据。又比如,学习设计模式的时候,我们可以结合身边的业务案例来思考下,是否有对应的适用场景,如果没有能否模拟一个场景,然后使用所有设计模式和自己熟悉的语言开发一个实际的 Demo。
这些看似和我们日常业务开发关系不大的基础知识,是我们是否能够深入理解技术的最重要的基石。
第五,想办法积累技术深度。
对开发者而言,技术深度体现在从一个框架、组件或 SDK 的使用者转变为开发者。
虽然不建议大家重复去造轮子、造框架,但我们完全可以阅读各种框架的源码去了解其实现,并亲手实现一些框架的原型。比如,你可以尝试把 MVC、RPC、ORM、IoC、AOP 等框架,都实现一个最基本功能点原型。在实现的过程中,你一定会遇到很多问题和难点,然后再继续研究一下 Spring、Hibernate、Dubbo 等框架是如何实现的。
当把自己的小框架实现出来的那一刻,你收获的不仅是满满的成就感,更是在技术深度积累上的更进一步。在这个过程中,你肯定会遇到不少问题、解决不少问题。有了这些积累,之后再去学习甚至二次开发那些流行的开源框架,就会更容易了。
除了实现一些框架外,我还建议你选择一个中间件(比如 Redis、RocketMQ来练手学习网络知识。
我们可以先实现它的客户端,用 Netty 实现 TCP 通信层的功能,之后参照官方文档实现协议封装、客户端连接池等功能。在实现的过程中,你可以对自己实现的客户端进行压测,分析和官方实现的性能差距。这样一来,你不仅可以对 TCP/IP 网络有更深入的了解,还可以获得很多网络方面的优化经验。
然后,再尝试实现服务端,进一步加深对网络的认识。最后,尝试把服务端扩展为支持高可用的集群,来加深对分布式通信技术的理解。
在实现这样一个分布式 C/S 中间件的过程中,你对技术的理解肯定会深入了许多。在这个过程中,你会发现,技术深度的“下探”和基础知识的积累息息相关。基础知识不扎实,往深了走往往会步履维艰。这时,你可以再回过头来,重新系统学习一些基础理论。
第六,扩大技术广度也重要。
除了之前提到的多学几门编程语言之外,在技术广度的拓展上,我们还可以在两个方面下功夫。
第一,阅读大量的技术书籍。新出来的各种技术图书(不只是编程相关的),一般我都会买。十几年来,我买了 500 多本技术图书,大概有三分之一是完整看过的,还有三分之一只翻了一个大概,还有三分之一只看了目录。
广泛的阅读,让我能够了解目前各种技术的主流框架和平台。这样的好处是,在整体看技术方案的时候,我可以知道大家都在做什么,不至于只能理解方案中的一部分。对于看不完的、又比较有价值的书,我会做好标签,等空闲的时候再看。
第二,在开发程序的时候,我们会直接使用运维搭建的数据库(比如 Elasticsearch、MySQL、中间件比如 RabbitMQ、ZooKeeper、容器云比如 Kubernetes。但如果我们只会使用这些组件而不会搭建的话对它们的理解很可能只是停留在 API 或客户端层面。
因此,我建议你去尝试下从头搭建和配置这些组件,在遇到性能问题的时候自己着手分析一下。把实现技术的前后打通,遇到问题时我们就不至于手足无措了。我通常会购买公有云按小时收费的服务器,来构建一些服务器集群,尝试搭建和测试这些系统,加深对运维的理解。
学好英语
为啥要单独说英语的学习方法呢,这是因为学好英语对做技术的同学非常重要:
国外的社区环境比较好,许多技术问题只有通过英文关键字才能在 Google 或 Stackoverflow 上搜到答案;
可以第一时间学习各种新技术、阅读第一手资料,中文翻译资料往往至少有半年左右的延迟;
参与或研究各种开源项目,和老外沟通需要使用英语来提问,以及阅读别人的答复。
所以说,学好英语可以整体拓宽个人视野。不过,对于上班族来说,我们可能没有太多的大块时间投入英语学习,那如何利用碎片时间、相对休闲地学习英语呢?还有一个问题是,学好英语需要大量的练习和训练,但不在外企工作就连个英语环境都没有,那如何解决这样的矛盾呢?
接下来,我将从读、听、写和说四个方面,和你分享一下我学习英语的方法。
读方面
读对于我们这些搞技术的人来说是最重要的,并且也是最容易掌握的。我建议你这么学:
先从阅读身边的技术文档开始,有英语文档的一定要选择阅读英语文档。一来,贴近实际工作,是我们真正用得到的知识,比较容易有兴趣去读;二来,这些文档中大部分词汇,我们日常基本都接触过,难度不会太大。
技术书籍的常用词汇量不大,有了一些基础后,你可以正式或非正式地参与翻译一些英语书籍或文档。从我的经验来看,翻译过一本书之后,你在日常阅读任何技术资料时基本都不需要查字典了。
订阅一些英语报纸,比如 ChinaDaily。第一贴近日常生活都是我们身边发生的事儿不会很枯燥第二可以进一步积累词汇量。在这个过程中你肯定需要大量查字典打断阅读让你感觉很痛苦。但一般来说一个单词最多查三次也就记住了所以随着时间推移你慢慢可以摆脱字典词汇量也可以上一个台阶了。
技术方面阅读能力的培养,通常只需要三个月左右的时间,但生活方面资料的阅读可能需要一年甚至更长的时间。
听方面
读需要积累词汇量,听力的训练需要通过时间来磨耳朵。每个人都可以选择适合自己的材料来磨耳朵,比如我是通过看美剧来训练听力的。
我就以看美剧为例,说说练听力的几个关键点。
量变到质变的过程,需要 1000 小时的量。如果一部美剧是 100 小时,那么看前 9 部的时候可能都很痛苦,直到某一天你突然觉得一下子都可以听懂了。
需要确保看美剧时没有中文字幕,否则很难忍住不看,看了字幕就无法起到训练听力的效果。
在美剧的选择上,可以先选择对话比较少,也可以选择自己感兴趣的题材,这样不容易放弃。如果第一次听下来,听懂率低于 30%,连理解剧情都困难,那么可以先带着中文字幕看一遍,然后再脱离字幕看。
看美剧不在乎看的多少,而是要找适合的素材反复训练。有人说,反复看 100 遍《老友记》,英语的听说能力可以接近母语是英语的人的水平。
如果看美剧不适合你的话,你可以选择其他方式,比如开车或坐地铁的时候听一些感兴趣的 PodCast 等。
总而言之,选择自己喜欢的材料和内容,从简单开始,不断听。如果你有一定词汇量的话,查字典其实不是必须的,很多时候不借助字典,同一个单词出现 10 遍后我们也就知道它的意思了。
一定要记住,在积累 1000 小时之前,别轻易放弃。
写方面
如果有外企经历,那么平时写英语邮件和文档基本就可以让你的工作英语过关;如果没有外企经历也没关系,你可以尝试通过下面的方式锻炼写作:
每天写英语日记。日记是自己看的,没人会嘲笑你,可以从简单的开始。
在保持写作的同时,需要确保自己能有持续的一定量的阅读。因为,写作要实现从正确到准确到优雅,离不开阅读的积累。
写程序的时候使用英语注释,或者尝试写英语博客,总之利用好一切写的机会,来提升自己的英语表达。
再和你分享一个小技巧。当你要通过查词典知道中文的英语翻译时,尽量不要直接用找到的英文单词,最好先在英语例句中确认这个翻译的准确性再使用,以免闹笑话。
说方面
训练说英语的机会是最少的,毕竟身边说英语的人少,很难自己主动练习。
这里我和你分享两个方法吧。
第一是,买外教的 1-1 对话课程来训练。这些课程一般按小时计费,由母语是英语的人在线和你聊一些话题,帮助你训练对话能力。
买不买课程不重要,只要能有母语是英语的人来帮你提升就可以。同时,大量的听力训练也可以帮助你提升说的能力,很多英语短句经过反复强化会成为脱口而出的下意识反应。所以,你会发现在听力达到质变的时候,说的能力也会上一个台阶。
第二,大胆说,不要担心有语法错误、单词发音问题、表达不流畅问题而被嘲笑。其实,你可以反过来想想,老外说中文时出现这些问题,你会嘲笑他吗。
这里有一个技巧是,尽量选用简单的表达和词汇去说,先尝试把内容说出来,甚至是只说几个关键字,而不是憋着在脑子里尝试整理一个完整的句子。灵活运用有限的单词,尽可能地流畅、准确表达,才是聪明的做法。
总结
最后我想说,如果你感觉学得很累、进步很慢,也不要放弃,坚持下来就会越来越好。我刚毕业那会儿,有一阵子也对 OOP 很迷茫,感觉根本无法理解 OOP 的理念,写出的代码完全是过程化的代码。但我没有放弃,参与写了几年的复杂业务程序,再加上系统自学设计模式,到某一个时刻我突然就能写出 OOP 的业务代码了。
学习一定是一个日积月累、量变到质变的过程,希望我分享的学习方法能对你有启发。不过,每个人的情况都不同,一定要找到适合自己的学习方式,才更容易坚持下去。
持续学习很重要,不一定要短时间突击学习,而最好是慢慢学、持续积累,积累越多学习就会越轻松。如果学习遇到瓶颈感觉怎么都学不会,也不要沮丧,这其实还是因为积累不够。你一定也有过这样的经验:一本去年觉得很难啃的书,到今年再看会觉得恰到好处,明年就会觉得比较简单,就是这个道理。
我是朱晔,欢迎在评论区与我留言分享你学习技术和英语的心得,也欢迎你把今天的内容分享给你的朋友或同事,一起交流。

View File

@@ -0,0 +1,245 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
37 加餐7程序员成长28计
今天直播,我准备和你聊聊程序员成长的话题。我从毕业后入行到现在作为一个高级管理者,已经在互联网领域拼搏了 15 年了。我把这些年自己的成长历程、心得体会整理成了“程序员成长 28 计”。
今天,我就和你分别聊聊这 28 计。
入门 0.5 年
第 1 计:不要过于纠结方向选择问题。
开始入门的时候,我们可能都会纠结于选择前端还是后端,选择了后端还犹豫到底选 Java、Go 还是 Python。
其实,我觉得不用过于纠结。如果说你对偏前端的内容感兴趣,那就从前端入手;对数据库方面的内容感兴趣,那就从后端入手。等你真正入门以后,你再去转方向、转技术栈都会非常容易,因为技术都是相通的。
第 2 计:学习一定要敢于踏出真正的第一步。
这里我说的第一步,不是说开始看某个领域的书了,而是真正把 IDE 下载好、把编程环境搭建好,并实现一个最简单的程序。我一直觉得,把编程环境搭建好,就已经算是入门一半了。
如果你只是停留在看书这个层次上的话,是永远入不了门的。因为这些知识只是停留在书上,还没有真正变成你自己的。只有自己写过、实践过,才能真正掌握。
第 3 计:找人给你指一下方向。
刚入门的时候面对各种各样的语言、技术你很可能会迷茫。就比如说刚入门后端的时候Spring 全家桶有十几样还有各种数据库方面的Java 程序本身的语法和框架,那到底先学什么呢?这个时候,只要找人给你指点一下学习的顺序,以及按照怎样的主线来学习,就会事半功倍。否则,你会在大量的资料里花费大量的时间、消耗大量的精力。
第 4 计:找准合适的入门资料。
在我看来,选择入门资料,需要注意两点:
一定要选择手把手的资料,也就是从搭环境开始怎么一步步地去操作,并带一些实战项目。这样看,视频课程可能更合适。
要难度合适。
那怎么理解“难度合适”呢?举个例子,你看的这本书的知识深度在 70 分,而你自己的知识深度在 60 分,那这本书非常合适。因为从 60 到 65 的感觉是非常爽的。在 60 分的时候,你有能力去汲取 70 分深度的书里面的知识点,然后你会变成 65 分。而如果你现在的知识深度在 20 分去看 70 分的书,或者你的知识深度在 75 分却去看 70 分的书,就不会有任何感觉、任何收益。所以,很多同学看我的专栏课程会有共鸣,也是这个原因。
程序员 2 年
第 5 计:想办法系统性地学习。
步入两年这个阶段后,我们要开始想办法系统性地学习了,比如系统性地学习设计模式、算法、数据库。只有系统性地学习,才能给我们建立起完整的知识框架。因为一定是先有知识网络,才能在网络上继续铺更多的东西。
那怎么才能有系统性学习的动力呢?
第一,分享可以让自己有动力。比如,你说要写一个什么系列的文章,那话说出去了,就会逼着自己去实现。
第二,花钱买课程,做系统性的学习。当你花了几百甚至几千块钱去买课程的时候,就会逼着自己的学习,不然钱就浪费掉了。
第 6 计:选择一份好工作。
选择一份好工作,也就是选择一个好的项目,从而积累一些人脉资源,是非常重要的,可能要比技术成长更重要些。
比如说,你能够进入到一个相对较大的公司,它能带给你的最最主要的就是人脉资源,也就是你能够认识更多、更优秀的人。认识这些人,就是你日后的机会。
第 7 计:学习必须靠自觉。
我们不能期望项目经验一定或者说一直会给自己带来技术提升。即使是你能接触一些高并发的、比较复杂的项目,它们带来的提升也是有限的,或者说持续的时间通常会比较短。
因为大多数公司在乎的都是你的输出,输出你的能力和经验。所以说,学习和成长这件事儿,必须靠自觉,包括自觉地去想如何系统性地学习、如何有计划地学习,以及平时要多问为什么。
第 8 计:看适合自己的书。
这里也是说,我们在看书的过程中,要注意去鉴别书的层次,选择难度合适的书。
其实,在做程序员前两年的时间里,我不太建议去广泛地看书,要先想办法能够专注些,打好自己主要的编程语言的基础;然后,围绕着自己主要的编程语言或者主要使用的技术去看书。
第 9 计:想办法积累技术广度。
将来踏上技术管理路线之后,你有可能管的团队不是你这个领域,比如你是后端出身可能要带领移动团队。如果你不知道移动端最基本的东西的话,是没有办法跟团队成员沟通的。所以说,你可以有自己的一个专长,但是你要知道其他领域最基本的东西。
积累技术广度的方式,主要有下面三种。
第一,体验全栈。如果你是做后端的,就应该去大概了解下客户端、移动端,或者说大前端;可以了解下测试和运维怎么做,了解运维的话帮助可能会更大。你还可以动手做一个自己的项目,就从云服务器的采购开始。在搭建项目部署的过程中,你可以自己去搭建运维相关的部分,甚至是自己搭建一些中间件。
因为在大厂,一般都有自动化发布系统、有工程化平台、有自己的运维体系、有自己的监控系统等等。但是,如果只是使用这些工具的话,我们是没法建立一个全局观的,因为我们不知道它们是怎么运作的。
第二,多学一些编程语言。但是学了几门编程语言后,你会发现每门语言都有自己的特色和软肋。这就会引发你很多的思考,比如为什么这个语言没有这个特性,又怎么样去解决。另外,每门语言他都有自己的技术栈,你会来回地比较。这些思考和比较,对自己的成长都很有用。
如果你对一个语言的掌握比较透彻的话,再去学其他语言不会花很久。我刚毕业是做.net后来转了 Java再后来又去学 Python。因为高级语言的特性基本上都差不多你只要学一些语法用到的时候再去查更多的内容然后做个项目所以学一门语言可能也就需要一个月甚至会更快一些。
第三,广泛看书。
第 10 计:想办法积累技术深度。
主要的方式是造轮子、看源码和学底层。
第一,造轮子。所谓的造轮子,不一定是要造完要用,你可以拿造轮子来练手,比如徒手写一个框架。在这个过程中,你会遇到很多困难,然后可能会想办法去学习一些现有技术的源码,这对技术深度的理解是非常有帮助的。
第二,看一些源码。如果你能够理清楚一些源码的主线,然后你能积累很多设计模式的知识。
第三,学一些偏向于底层的东西,可以帮助你理解技术的本质。上层的技术都依赖于底层的技术,所以你学完了底层的技术后,就会发现上层的技术再变也没有什么本质上的区别,然后学起来就会非常快。
第 11 计:学会使用搜索引擎。
对于程序员来说,最好可以使用 Google 来搜索,也就是说要使用英文的关键字来搜索。一方面,通过 Google 你可以搜到更多的内容,另一方面国外的技术圈或者网站关于纯技术的讨论会多一些。
第 12 计:学会和适应画图、写文档。
我觉得,写文档是在锻炼自己的总结能力和表达能力,画图更多的是在锻炼自己的抽象能力。写文档、画架构图,不仅仅是架构师需要具备的能力,还是你准确表达自己观点的必备方式。所以,我们不要觉得,宁肯写 100 行代码,也不愿意写一句话。
架构师 3 年
第 13 计:注意软素质的提升。
这时候你已经有了好几年的经验了,那除了技术方面,还要注意软素质,比如沟通、自我驱动、总结等能力的提升。比如说沟通能力,就是你能不能很流畅地表达自己的观点,能不能比较主动地去沟通。
这些素质在日常工作中还是挺重要的,因为你做了架构师之后,免不了要去跟业务方和技术团队,甚至是其他的团队的架构师去沟通。 如果你的这些软素质不过硬,那可能你的方案就得不到认可,没办法达成自己的目标。
第 14 计:积累领域经验也很重要。
当你在一个领域工作几年之后,你就会对这个领域的产品非常熟悉,甚至比产品经理更懂产品。也就是说,即使这个产品没有别人的帮助,你也可以确保它朝着正确的方向发展。如果你想一直在这个领域工作的话,这种领域经验的积累就对自己的发展非常有帮助。
所以说,有些人做的是业务架构师,他可能在技术上并不是特别擅长,但对这个领域的系统设计或者说产品设计特别在行。如果说,你不想纯做技术的话,可以考虑积累更多的领域经验。
第 15 计:架构工作要接地气。
我以前做架构师的时候发现,有些架构师给出的方案非常漂亮,但就是不接地气、很难去落地。所以,在我看来,架构工作必须要接地气,包括三个方面:产出符合实际情况的方案、方案要落地实际项目、不要太技术化。
这里其实会有一个矛盾点:如果你想要提升自己的经验、技术,很多时候就需要去引入一些新技术,但是这些新技术的引入需要成本。而这里的成本不仅仅是你自己学习的成本,还需要整个团队有一定的经验。
比如 Kubernetes不是你引入了团队用就完事儿整个团队的技术都需要得到提升才能够驾驭这个系统。如果我们是为了自己的利益去引入一些不太符合公司实际情况的技术的话其实对公司来说是不负责任的而且这个方案很大程度上有可能会失败。
所以说,我觉得做架构工作是要产出一些更接地气的方案。比如同样是解决一个问题,有些架构方式或设计比较“老土”,但往往是很稳定的;而一些复杂的技术,虽然有先进的理念和设计,但你要驾驭它就需要很多成本,而且因为它的“新”往往还会存在各种各样的问题。
这也就是说,我们在设计架构的时候,必须要权衡方案是否接地气。
第 16 计:打造个人品牌。
我觉得,个人品牌包括口碑和影响力两个方面。
口碑就是你日常工作的态度,包括你的能力和沟通,会让人知道你靠不靠谱、能力是不是够强。好的口碑再加上宝贵的人脉,就是你非常重要的资源。口碑好的人基本上是不需要主动去找工作的,因为一直会有一些老领导或者朋友、同事会千方百计地想要给你机会。
很多人的技术非常不错,但就是没人知道他,问题就出在影响力上。而提升影响力的方法,无外乎就是参加技术大会、做分享、写博客、写书等等。
有了影响力和口碑,让更多的人能接触到你、认识你,你就会有更多的机会。
技术管理
第 17 计:掌握管事的方法。
“管事”就是你怎样去安排,这里包括了制定项目管理流程、制定技术标准、工具化和自动化三个方面。
刚转做技术管理时容易犯的一个错的是,把事情都抓在自己手里。这时,你一定要想通,不是你自己在干活,你的产出是靠团队的。与其说什么事情都自己干,还不如说你去制定规范、流程和方向,然后让团队去做,否则你很容易就成了整个团队的瓶颈。
第 18 计:掌握带团队的方法。
第一,招人 & 放权。带团队的话,最重要是招到优秀的人,然后就是放权。不要因为担心招到的人会比自己优秀,就想要找“弱”一些的。只有团队的事情做得更好了,你的整个团队的产出才是最高。
第二,工程师文化。通过建立工程师文化,让大家去互相交流、学习,从而建立一个良好的学习工作氛围。
第三,适当的沟通汇报制度。这也属于制定流程里面的,也是要建立一个沟通汇报的制度。
第 19 计:关注前沿技术,思考技术创新。
做了技术管理之后,你的视角要更高。你团队的成员,可能只是看到、接触到这一个部分、这一个模块,没有更多的信息,也没办法想得更远。这时,你就必须去创新、去关注更多的前沿技术,去思考自己的项目能不能用上这些技术。
第 20 计:关注产品。
在我看来,一个产品的形态很多时候决定了公司的命运,在产品上多想一些点子,往往要比技术上的重构带来的收益更大。这里不仅仅包括这个产品是怎么运作的,还包括产品中包含的创新、你能否挖掘一些衍生品。
高级技术管理
在这个层次上面,我们更高级的技术管理可能是总监级别甚至以上,我以前在两家百人以上的小公司做过 CTO。我当时的感觉是所做的事情不能仅限于产品技术本身了。
第 21 计:搭建团队最重要。
这和招人还不太一样,招人肯定招的是下属,而搭建团队是必须让团队有一个梯队。一旦你把一些核心的人固化下来以后,整个团队就发展起来了。所以,你要在招人方面花费更多的精力,当然不仅仅是指面试。
搭建团队最重要的是你自己要有一个想法,知道自己需要一个什么样的职位来填补空缺,这个岗位上又需要什么样的人。
第 22 计:打造技术文化。
虽然在做技术管理的时候,我强调说要建立制度,但文化会更高于制度,而且文化没有那么强势。因为制度其实是列出来,要求大家去遵守,有“强迫”的感觉;而文化更强调潜移默化,通过耳濡目染获得大同感。这样一来,大家慢慢地就不会觉得这是文化了,而是说我现在就是这么干事儿的。
第 23 计:提炼价值观。
价值观是说公司按照这个理念去运作,希望有一些志同道合的人在一起干活。所以价值观又会高于文化,是整个公司层面的,对大家的影响也会更多。
虽然说价值观不会那么显性,但可以长久地确保公司里面的整个团队的心都是齐的,大家都知道公司是怎么运作的,有相同的目标。
第 24 计:关注运营和财务。
到了高级技术管理的位置,你就不仅仅是一个打工的了,你的命运是和公司紧紧绑定在一起的。所以,你需要更多地关注公司的运营和财务。
当你觉得自己的团队很小却要做那么多项目的时候,可以站在更高的角度去换位思考下。这时你可能就发现,你的团队做的事情并没有那么重要,对整个公司的发展来说你的团队规模已经足够了。如果说我们再大量招人的话,那么财务上就会入不敷出,整个公司的情况肯定也不会好。
职场心得
第 25 计:掌握工作汇报的方式方法。
首先,我们不要把汇报当作负担、当作浪费时间。汇报其实是双向的,你跟上级多沟通的话,他可以反馈给你更多的信息,这个信息可能是你工作的方向,也可能是给你的一些资源,还可能是告诉你上级想要什么。因为你和你的上级其实在一个信息层面上是不对等的,他能收到更上级的信息,比如公司策略方面的信息。
第 26 计:坚持 + 信念。
第一,如果说你的目标就是成功的话,那没有什么可以阻挡你。职场上的扯皮和甩锅,都是避免不了的。举个例子吧。
我以前在一家公司工作的时候,别人不愿意配合我的工作。那怎么办呢,我知道自己的目标是把这件事儿做成。当时,这个项目的很多内容,比如说运维,都不在我这边,需要其他同事来负责。但人家就是不配合,群里艾特也不看,打电话也不接,那我怎么办呢?多打两次呗,实在不行我就发邮件抄送大家的上级。总之,就是想尽办法去沟通,因为你的目标就是成功。
第二,很多时候,创新就是相信一定可以实现才有的。
很多时候,你觉得这个事情是做不成的,然后直接拒绝掉了,创新就没有了。但如果相信这个事情一定是可以做成的,你就会想方设法去实现它,这个时候你想出来的东西就是有开创性的,就是创新。
第 27 计:持续的思考和总结。
在职场上提炼方法论是非常重要的。你要去思考自己在工作中对各种各样的事情的处理,是不是妥当,是不是能够总结出一些方法论。把这些方法论提炼保留下来,将来是能够帮到你的。很多东西,比如复盘自己的工作经历、复盘自己的选择,都要动脑子、都要去写,不能说过去了就过去了。这些经历提炼出的方法论,都是你的经验,是非常有价值的。
第 28 计:有关和平级同事的相处。
和平级同事之间,要以帮助别人的心态来合作。我们和上下级的同事来沟通,一般是不会有什么问题的,但跟平级的,尤其是跨部门的平级同事去沟通的时候,往往会因为利益问题,不会很愉快。
我觉得,这里最重要的就是以帮助别人的心态来合作。 比如这样说“你有什么困难的话,可以来问我”“你人手是不是不够,我可以帮你一起把这个项目做好”。这样大家的合作会比较顺畅,别人也不会有那么多戒心。
人和人的沟通,还在于有一层纱,突破了这层纱以后,大家就都会相信你,觉得你是一个靠谱的人。这样,平级同事也会愿意和你分享一些东西,因为他放心。
管理格言
接下来,我要推荐的 8 条管理格言,是曹操管理和用人的理念,不是我自己总结出来的。
第一,真心诚意,以情感人。人和人之间去沟通的时候,不管是和上级或者下级的沟通,都要以非常诚恳的态度去沟通。
第二,推心置腹,以诚待人。有事情不要藏在心里,做“城府很深”的管理者。我觉得更好的方式是,让大家尽可能地知道更多的事儿,统一战线,站在一个角度来考虑问题。
第三,开诚布公,以理服人。把管理策略公布出来,不管是奖励也好惩罚也罢,让团队成员感觉公平公正,
第四,言行一致,以信取人。说到做到,对于管理下属、和别人沟通都非常重要。
第五,令行禁止,依法治人。管理上,你要制定好相关的制度,而且要公开出来。如果触犯了制度就需要惩罚,做得好了就要有奖赏。
第六,设身处地,以宽容人。很多时候,我们和别人的矛盾是没有足够的换位思考,没有设身处地地去想。如果说你的下属犯了错,还是要想一想是不是多给些机会,是不是能宽容一些。
第七,扬人责己,以功归人。这是非常重要的一点。事情是团队一起做的话,那就是团队的功劳,甚至下属的功劳。如果别人做得好的话,就要多表扬一些。对自己要严格一些,很多时候团队的问题就是管理者的问题,跟下属没太多关系。
第八,论功行赏,以奖励人。做得好了,要多给别人一些奖励。这也是公平公正的,大家都能看得到。
最后,我将关于程序员成长的 28 计整理在了一张思维导图上,以方便你收藏、转发。
我是朱晔,欢迎在评论区与我留言分享你的想法,也欢迎你把今天的内容分享给你的朋友或同事,一起交流。

View File

@@ -0,0 +1,139 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
38 加餐8Java程序从虚拟机迁移到Kubernetes的一些坑
我们又见面了。结课并不意味着结束,我非常高兴能持续把好的内容分享给你,也希望你能继续在留言区与我保持交流,分享你的学习心得和实践经验。
使用 Kubernetes 大规模部署应用程序可以提升整体资源利用率提高集群稳定性还能提供快速的集群扩容能力甚至还可以实现集群根据压力自动扩容。因此现在越来越多的公司开始把程序从虚拟机VM迁移到 Kubernetes 了。
在大多数的公司中Kubernetes 集群由运维来搭建,而程序的发布一般也是由 CI/CD 平台完成。从虚拟机到 Kubernetes 的整个迁移过程,基本不需要修改任何代码,可能只是重新发布一次而已。所以,我们 Java 开发人员可能对迁移这个事情本身感知不强烈,认为 Kubernetes 只是运维需要知道的事情。但是程序一旦部署到了 Kubernetes 集群中,在容器环境中运行,总是会出现各种各样之前没有的奇怪的问题。
今天的加餐,就让我们一起看下这其中大概会遇到哪些“坑”,还有相应的“避坑方法”。
Pod IP 不固定带来的坑
Pod 是 Kubernetes 中能够创建和部署应用的最小单元,我们可以通过 Pod IP 来访问到某一个应用实例但需要注意的是如果没有经过特殊配置Pod IP 并不是固定不变的,会在 Pod 重启后会发生变化。
不过好在,通常我们的 Java 微服务都是没有状态的,我们并不需要通过 Pod IP 来访问到某一个特定的 Java 服务实例。通常来说,要访问到部署在 Kubernetes 中的微服务集群,有两种服务发现和访问的方式:
通过 Kubernetes 来实现。也就是通过 Service 进行内部服务的互访,通过 Ingress 从外部访问到服务集群。
通过微服务注册中心(比如 Eureka来实现。也就是服务之间的互访通过客户端负载均衡后 + 直接访问 Pod IP 进行,外部访问到服务集群通过微服务网关转发请求。
使用这两种方式进行微服务的访问,我们都没有和 Pod IP 直接打交道,也不会把 Pod IP 记录持久化,所以一般不需要太关注 Pod IP 变动的问题。不过在一些场景下Pod IP 的变动会造成一些问题。
之前我就遇到过这样的情况:某任务调度中间件会记录被调度节点的 IP 到数据库,随后通过访问节点 IP 查看任务节点执行日志的时候,如果节点部署在 Kubernetes 中,那么节点重启后 Pod IP 就会变动。这样,之前记录在数据库中的老节点的 Pod IP 必然访问不到,那么就会发生无法查看任务日志的情况。
遇到这种情况,我们应该怎么做呢?这时候,可能就需要修改这个中间件,把任务执行日志也进行持久化,从而避免这种访问任务节点来查看日志的行为。
总之,我们需要意识到 Pod IP 不固定的问题,并且进行“避坑操作”:在迁移到 Kubernetes 集群之前,摸排一下是否会存在需要通过 IP 访问到老节点的情况,如果有的话需要进行改造。
程序因为 OOM 被杀进程的坑
在 Kubernetes 集群中部署程序的时候我们通常会为容器设置一定的内存限制limit容器不可以使用超出其资源 limit 属性所设置的资源量。如果容器内的 Java 程序使用了大量内存,可能会出现各种 OOM 的情况。
第一种情况,是 OS OOM Kill 问题。如果过量内存导致操作系统 Kernel 不稳定,操作系统可能就会杀死 Java 进程。这时候,你能在操作系统 /var/log/messages 日志中找到类似 oom_kill_process 的关键字。
第二种情况,是我们最常遇到的 Java 程序的 OOM 问题。程序超出堆内存的限制申请内存,导致 Heap OOM后续可能会因为健康检测没有通过被 Kubernetes 重启 Pod。
在 Kubernetes 中部署 Java 程序时,这两种情况都很常见,表现出的症状也都是 OOM 关键字 + 重启。所以,当运维同学说程序因为 OOM 被杀死或重启的时候,我们一定要和运维同学一起去区分清楚,到底是哪一种情况,然后再对症处理。
对于情况 1问题的原因往往不是 Java 堆内存不够,更可能是程序使用了太多的堆外内存,超过了内存限制。这个时候,调大 JVM 最大堆内存只会让问题更严重,因为堆内存是可以通过 GC 回收的。我们需要分析 Java 进程哪部分区域内存占用过大是不是合理以及是否可能存在内存泄露问题。Java 进程的内存占用除了堆之外,还包括
直接内存
元数据区
线程栈大小 Xss * 线程数
JIT 代码缓存
GC、编译器使用额外空间
……
我们可以使用 NMT 打印各部分区域大小,从而判断到底是哪部分内存区域占用了过多内存,或是可能有内存泄露问题:
java -XX:NativeMemoryTracking=smmary/detail -XX:+UnlockDiagnosticVMOptions -XX:+PrintNMTStatistics
如果你确认 OOM 是情况 2那么我同样不建议直接调大堆内存的限制防止之后再出现情况 1。我会更建议你把堆内存限制为容器内存限制的 50%~70%,预留出足够多的内存给堆外和 OS 核心。如果需要扩容堆内存的话,那么也需要同步扩容容器的内存 limit。此外也需要通过 Heap Dump你可以回顾下第 35 讲的相关内容)等手段来排查为什么堆内存占用会这么大,排除潜在的内存泄露的可能性。
内存和 CPU 资源配置不适配容器的坑
刚刚我们提到了,堆内存扩容需要结合容器内存 limit 同步进行。其实我们更希望的是Java 程序的堆内存配置能随着容器的资源配置,实现自动扩容或缩容,而不是写死 Xmx 和 Xms。这样一来运维同学可以更方便地针对整个集群进行扩容或缩容。
对于 JDK>8u191 的版本,我们可以设置下面这些 JVM 参数,来让 JVM 自动根据容器内存限制来设置堆内存用量。比如,下面配置相当于把 Xmx 和 Xms 都设置为了容器内存 limit 的 50%
XX:MaxRAMPercentage=50.0 -XX:InitialRAMPercentage=50.0 -XX:MinRAMPercentage=50.0
接下来,我们看看 CPU 资源配置不适配容器的坑,以及对应的解决方案。
对于 CPU 资源的使用,我们主要需要注意的是,代码中的各种组件甚至是 JVM 本身,会根据 CPU 数来配置并发数等重要参数指标,那么:
如果这个值因为 JVM 对容器的兼容性问题取到了 Kubernetes 工作节点的 CPU 数量,那么这个数量可能就不是 4 或 8而是 128 以上,进而导致并发数过高。
对于 JDK>8u191 的版本可能会对容器兼容性较好,但是其获取到的 Runtime.getRuntime().availableProcessors() 其实是 request 的值而不是 limit 的值(比如我们设置 request 为 2、limit 为 8、CICompilerCount 和 ParallelGCThreads 可能只是 2那么可能并发数就会过低进而影响 JVM 的 GC 或编译性能。
所以,我的建议是:
第一,通过 -XX:+PrintFlagsFinal 开关,来确认 ActiveProcessorCount 是不是符合我们的期望,并且确认 CICompilerCount、ParallelGCThreads 等重要参数配置是否合理。
第二,直接设置 CPU 的 request 和 limit 一致,或是对于 JDK>8u191 的版本可以通过 -XX:ActiveProcessorCount=xxx 直接把 ActiveProcessorCount 设置为容器的 CPU limit。
Pod 重启以及重启后没有现场的坑
除非宿主机有问题,否则虚拟机不太会自己重启或被重启,而 Kubernetes 中 Pod 的重启绝非小概率事件。在存活检测不通过、Pod 重新进行节点调度等情况下Pod 都会进行重启。对于 Pod 的重启,我们需要关注两个问题。
第一个问题是,分析 Pod 为什么会重启。
其中,除了“程序因为 OOM 被杀进程的坑”这部分提到的 OOM 的问题之外,我们还需要关注存活检查不通过的情况。
Kubernetes 有 readinessProbe 和 livenessProbe 两个探针,前者用于检查应用是否已经启动完成,后者用于持续探活。一般而言,运维同学会配置这 2 个探针为一个健康检测的断点,如果健康检测访问一次需要消耗比较长的时间(比如涉及到存储或外部服务可用性检测),那么很可能可以通过 readinessProbe 的检查但不通过 livenessProbe 检查(毕竟我们通常会为 readinessProbe 设置比较长的超时时间,而对于 livenessProbe 则没有那么宽容)。此外,健康检测也可能会受到 Full GC 的干扰导致超时。所以,我们需要和运维同学一起确认 livenessProbe 的配置地址和超时时间设置是否合理,防止偶发的 livenessProbe 探活失败导致的 Pod 重启。
第二个问题是,要理解 Pod 和虚拟机不同。
虚拟机一般都是有状态的,即便部署在虚拟机内的 Java 程序重启了,我们始终能有现场。而对于 Pod 重启来说,则是新建一个 Pod这就意味着老的 Pod 无法进入。因此,如果因为堆 OOM 问题导致重启,我们希望事后查看当时 OS 的一些日志或是在现场执行一些命令来分析问题,就不太可能了。
所以,我们需要想办法在 Pod 关闭之前尽可能保留现场,比如:
对于程序的应用日志、标准输出、GC 日志等可以直接挂载到持久卷,不要保存在容器内部。
对于程序的堆栈现场保留,可以配置 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath 在堆 OOM 的时候生成 Dump还可以让 JVM 调用任一个 shell 脚本,通过脚本来保留线程栈等信息:
-XX:OnOutOfMemoryError=saveinfo.sh
对于容器的现场保留,可以让运维配置 preStop 钩子,在 Pod 关闭之前把必要的信息上传到持久卷或云上。
重点回顾
今天,我们探讨了 Java 应用部署到 Kubernetes 集群会遇到的 4 类问题。
第一类问题是,我们需要理解应用的 IP 会动态变化,因此要在设计上解除对 Pod IP 的强依赖,使用依赖服务发现来定位到应用。
第二类问题是,在出现 OOM 问题的时候,首先要区分 OOM 的原因来自 Java 进程层面还是容器层面。如果是容器层面的话,我们还需要进一步分析到底是哪个内存区域占用了过多内存,定位到问题后再根据容器资源设置合理的 JVM 参数或进行资源扩容。
第三类问题是,需要确保程序使用的内存和 CPU 资源匹配容器的资源限制,既要确保程序所“看”到的主机资源信息是容器本身的而不是物理机的,又要确保程序能尽可能随着容器扩容而扩容其资源限制。
第四类问题是,我们需要重点关注程序非发布期重启的问题,并且针对 Pod 的重启问题做好现场保留的准备工作,排除资源配置不合理、存活检查不通过等可能性,以避免因为程序频繁重启导致的偶发性能问题或可用性问题。
只有解决了这些隐患,才能让 Kubernetes 集群更好地发挥作用。
思考与讨论
在你的工作中,还遇到过 Java+Kubernetes 中的其他坑吗?
我是朱晔,欢迎在评论区与我留言分享,也欢迎你把今天的内容分享给你的朋友或同事,一起交流。

View File

@@ -0,0 +1,560 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
答疑篇:代码篇思考题集锦(一)
在回复《Java 业务开发常见错误 100 例》这门课留言的过程中,我看到有些同学特别想看一看咱们这个课程所有思考题的答案。因此呢,我特地将这个课程涉及的思考题进行了梳理,把其中的 67 个问题的答案或者说解题思路,详细地写了出来,并整理成了一个“答疑篇”模块。
我把这些问题拆分为了 6 篇分别更新,你可以根据自己的时间来学习,以保证学习效果。你可以通过这些回答,再来回顾下这些知识点,以求温故而知新;同时,你也可以对照着我的回答,对比下自己的解题思路,看看有没有什么不一样的地方,并留言给我。
今天是答疑篇的第一讲,我们一起来分析下咱们这门课前 6 讲的课后思考题。这些题目涉及了并发工具、代码加锁、线程池、连接池、HTTP 调用和 Spring 声明式事务的 12 道思考题。
接下来,我们就一一具体分析吧。
01 | 使用了并发工具类库,线程安全就高枕无忧了吗?
问题 1ThreadLocalRandom 是 Java 7 引入的一个生成随机数的类。你觉得可以把它的实例设置到静态变量中,在多线程情况下重用吗?
答:不能。
ThreadLocalRandom 文档里有这么一条:
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);
问题 2ConcurrentHashMap 还提供了 putIfAbsent 方法你能否通过查阅JDK 文档,说说 computeIfAbsent 和 putIfAbsent 方法的区别?
computeIfAbsent 和 putIfAbsent 这两个方法,都是判断值不存在的时候为 Map 进行赋值的原子方法,它们的区别具体包括以下三个方面:
当 Key 存在的时候,如果 Value 的获取比较昂贵的话putIfAbsent 方法就会白白浪费时间在获取这个昂贵的 Value 上(这个点特别注意),而 computeIfAbsent 则会因为传入的是 Lambda 表达式而不是实际值不会有这个问题。
Key 不存在的时候putIfAbsent 会返回 null这时候我们要小心空指针而 computeIfAbsent 会返回计算后的值,不存在空指针的问题。
当 Key 不存在的时候putIfAbsent 允许 put null 进去,而 computeIfAbsent 不能(当然了,此条针对 HashMapConcurrentHashMap 不允许 put null value 进去)。
我写了一段代码来证明这三点,你可以点击这里的 GitHub 链接查看。
02 | 代码加锁:不要让“锁”事成为烦心事
问题 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 关键字,可以让数据刷新到主内存中去。准确来说,让数据刷新到主内存中去是两件事情:
将当前处理器缓存行的数据,写回到系统内存;
这个写回内存的操作会导致其他 CPU 里缓存了该内存地址的数据变为无效。
当然,使用 AtomicBoolean 等关键字来修改变量 b 也行。但相比 volatile 来说AtomicBoolean 等关键字除了确保可见性,还提供了 CAS 方法,具有更多的功能,在本例的场景中用不到。
问题 2关于代码加锁还有两个坑一是加锁和释放没有配对的问题二是分布式锁自动释放导致的重复逻辑执行的问题。你有什么方法来发现和解决这两个问题吗
答:针对加解锁没有配对的问题,我们可以用一些代码质量工具或代码扫描工具(比如 Sonar来帮助排查。这个问题在编码阶段就能发现。
针对分布式锁超时自动释放问题,可以参考 Redisson 的 RedissonLock 的锁续期机制。锁续期是每次续一段时间,比如 30 秒,然后 10 秒执行一次续期。虽然是无限次续期,但即使客户端崩溃了也没关系,不会无限期占用锁,因为崩溃后无法自动续期自然最终会超时。
03 | 线程池:业务代码最常用也最容易犯错的组件
问题 1在讲线程池的管理策略时我们提到或许一个激进创建线程的弹性线程池更符合我们的需求你能给出相关的实现吗实现后再测试一下是否所有的任务都可以正常处理完成呢
答:我们按照文中提到的两个思路来实现一下激进线程池:
由于线程池在工作队列满了无法入队的情况下会扩容线程池,那么我们可以重写队列的 offer 方法,造成这个队列已满的假象;
由于我们 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 | 连接池:别让连接池帮了倒忙
问题 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上的源码。
问题 2对于带有连接池的 SDK 的使用姿势,最主要的是鉴别其内部是否实现了连接池,如果实现了连接池要尽量复用 Client。对于 NoSQL 中的 MongoDB 来说,使用 MongoDB Java 驱动时MongoClient 类应该是每次都创建还是复用呢?你能否在官方文档中找到答案呢?
答:官方文档里有这么一段话:
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:
All resource usage limits (e.g. max connections, etc.) apply per MongoClient instance.
To dispose of an instance, call MongoClient.close() to clean up resources.
MongoClient 类应该尽可能复用(一个 MongoDB 部署只使用一个 MongoClient不过复用不等于在任何情况下就只用一个。正如文档里所说每一个 MongoClient 示例有自己独立的资源限制。
05 | HTTP 调用:你考虑到超时、重试、并发了吗?
问题 1在“配置连接超时和读取超时参数的学问”这一节中我们强调了要注意连接超时和读取超时参数的配置大多数的 HTTP 客户端也都有这两个参数。有读就有写,但为什么我们很少看到“写入超时”的概念呢?
答:其实写入操作只是将数据写入 TCP 的发送缓冲区,已经发送到网络的数据依然需要暂存在发送缓冲区中,只有收到对方的 ack 后,操作系统内核才从缓冲区中清除这一部分数据,为后续发送数据腾出空间。
如果接收端从 socket 读取数据的速度太慢,可能会导致发送端发送缓冲区满,导致写入操作阻塞,产生写入超时。但是,因为有滑动窗口的控制,通常不太容易发生发送缓冲区满导致写入超时的情况。相反,读取超时包含了服务端处理数据执行业务逻辑的时间,所以读取超时是比较容易发生的。
这也就是为什么我们一般都会比较重视读取超时而不是写入超时的原因了。
问题 2除了 Ribbon 的 AutoRetriesNextServer 重试机制Nginx 也有类似的重试功能。你了解 Nginx 相关的配置吗?
答:关于 Nginx 的重试功能,你可以参考这里,了解下 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 声明式事务,可能都没处理正确
问题 1考虑到 Demo 的简洁,这一讲中所有数据访问使用的都是 Spring Data JPA。国内大多数互联网业务项目是使用 MyBatis 进行数据访问的,使用 MyBatis 配合 Spring 的声明式事务也同样需要注意这一讲中提到的这些点。你可以尝试把今天的 Demo 改为 MyBatis 做数据访问实现,看看日志中是否可以体现出这些坑?
答:使用 mybatis-spring-boot-starter 无需做任何配置,即可使 MyBatis 整合 Spring 的声明式事务。在 GitHub 上的课程源码中,我更新了一个使用 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");
}
执行日志如下图所示:
每个 NESTED 事务执行前,会将当前操作保存下来,叫做 savepoint保存点。NESTED 事务在外部事务提交以后自己才会提交,如果当前 NESTED 事务执行失败,则回滚到之前的保存点。
问题 2在讲“小心 Spring 的事务可能没有生效”时我们提到,如果要针对 private 方法启用事务,动态代理方式的 AOP 不可行,需要使用静态织入方式的 AOP也就是在编译期间织入事务增强代码可以配置 Spring 框架使用 AspectJ 来实现 AOP。你能否参阅 Spring 的文档“Using @Transactional with 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 编译不会有问题,同时需要设置中的 sourceDirectory 为 delombok 目录:
<sourceDirectory>${project.build.directory}/generated-sources/delombok</sourceDirectory>
第三步,设置 @EnableTransactionManagement 注解,开启事务管理走 AspectJ 模式:
@SpringBootApplication
@EnableTransactionManagement(mode = AdviceMode.ASPECTJ)
public class CommonMistakesApplication {
第四步,使用 Maven 编译项目,编译后查看 createUserPrivate 方法的源码,可以发现 AspectJ 帮我们做编译时织入Compile Time Weaving
运行程序,观察日志可以发现 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 讲的思考题答案了。
关于这些题目,以及背后涉及的知识点,如果你还有哪里感觉不清楚的,欢迎在评论区与我留言,也欢迎你把今天的内容分享给你的朋友或同事,一起交流。

View File

@@ -0,0 +1,693 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
答疑篇:代码篇思考题集锦(三)
今天,我们继续一起分析这门课第 13~20 讲的课后思考题。这些题目涉及了日志、文件 IO、序列化、Java 8 日期时间类、OOM、Java 高级特性(反射、注解和泛型)和 Spring 框架的 16 道问题。
接下来,我们就一一具体分析吧。
13 | 日志:日志记录真没你想象的那么简单
问题 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 就可以了(完整的配置代码参考这里):
<filter class="org.geekbang.time.commonmistakes.logging.duplicate.MultipleLevelsFilter">
<levels>INFO|WARN</levels>
</filter>
问题 2生产级项目的文件日志肯定需要按时间和日期进行分割和归档处理以避免单个文件太大同时保留一定天数的历史日志你知道如何配置吗可以在官方文档找到答案。
答:参考配置如下,使用 SizeAndTimeBasedRollingPolicy 来实现按照文件大小和历史文件保留天数,进行文件分割和归档:
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<!--日志文件保留天数-->
<MaxHistory>30</MaxHistory>
<!--日志文件最大的大小-->
<MaxFileSize>100MB</MaxFileSize>
<!--日志整体最大
可选的totalSizeCap属性控制所有归档文件的总大小。当超过总大小上限时将异步删除最旧的存档。
totalSizeCap属性也需要设置maxHistory属性。此外“最大历史”限制总是首先应用“总大小上限”限制其次应用。
-->
<totalSizeCap>10GB</totalSizeCap>
</rollingPolicy>
14 | 文件 IO实现高效正确的文件读写并非易事
问题 1Files.lines 方法进行流式处理,需要使用 try-with-resources 进行资源释放。那么,使用 Files 类中其他返回 Stream 包装对象的方法进行流式处理,比如 newDirectoryStream 方法返回 DirectoryStreamlist、walk 和 find 方法返回 Stream也同样有资源释放问题吗
答:使用 Files 类中其他返回 Stream 包装对象的方法进行流式处理,也同样会有资源释放问题。
因为,这些接口都需要使用 try-with-resources 模式来释放。正如文中所说,如果不显式释放,那么可能因为底层资源没有及时关闭造成资源泄露。
问题 2Java 的 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 | 序列化:一来一回你还是原来的你吗?
问题 1在讨论 Redis 序列化方式的时候,我们自定义了 RedisTemplate让 Key 使用 String 序列化、让 Value 使用 JSON 序列化,从而使 Redis 获得的 Value 可以直接转换为需要的对象类型。那么,使用 RedisTemplate 能否存取 Value 是 Long 的数据呢?这其中有什么坑吗?
答:使用 RedisTemplate不一定能存取 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 不一定就代表获取的到的 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 的日期时间类,少踩一些“老三样”的坑
问题 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
问题 1Spring 的 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);
}
我在源码中提供了一个测试程序,你可以直接去看一下。
18 | 当反射、注解和泛型遇到 OOP 时,会有哪些坑?
问题 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 静态方法:
这个 access$000 方法是 Synthetic 方法:
而 Synthetic 方法的实现转接调用了内部类的 name 字段:
反过来,内部类的 test 方法也是通过外部类 InnerClassApplication 类的桥接方法 access$100 调用到其私有字段:
问题 2关于注解继承问题你觉得 Spring 的常用注解 @Service@Controller 是否支持继承呢?
Spring 的常用注解 @Service@Controller,不支持继承。这些注解只支持放到具体的(非接口非抽象)顶层类上(来让它们成为 Bean如果支持继承会非常不灵活而且容易出错。
19 | Spring 框架IoC 和 AOP 是扩展的核心
问题 1除了通过 @Autowired 注入 Bean 外,还可以使用 @Inject@Resource 来注入 Bean。你知道这三种方式的区别是什么吗
答:我们先说一下使用 @Autowired@Inject@Resource 这三种注解注入 Bean 的方式:
@Autowired,是 Spring 的注解,优先按照类型注入。当无法确定具体注入类型的时候,可以通过 @Qualifier 注解指定 Bean 名称。
@Inject:是 JSR330 规范的实现,也是根据类型进行自动装配的,这一点和 @Autowired 类似。如果需要按名称进行装配,则需要配合使用 @Named@Autowired@Inject 的区别在于,前者可以使用 required=false 允许注入 null后者允许注入一个 Provider 实现延迟注入。
@ResourceJSR250 规范的实现,如果不指定 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 的方式解决了这个问题,不会出错。基本原理是,因为循环依赖,所以实例的初始化无法一次到位,需要分步进行:
创建 A仅仅实例化不注入依赖
创建 B仅仅实例化不注入依赖
为 B 注入 A此时 B 已健全);
为 A 注入 B此时 A 也健全)。
网上有很多相关的分析,我找了一篇比较详细的,可供你参考。
第二种,构造方法注入涉及循环依赖。遇到这种情况的话,程序无法启动,比如 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 框架:框架帮我们做了很多工作也带来了复杂度
问题 1除了 Spring 框架这两讲涉及的 execution、within、@within@annotation 四个指示器外Spring AOP 还支持 this、target、args、@target@args。你能说说后面五种指示器的作用吗?
答:关于这些指示器的作用,你可以参考官方文档,文档里已经写的很清晰。
总结一下,按照使用场景,建议使用下面这些指示器:
针对方法签名,使用 execution
针对类型匹配,使用 within匹配类型、this匹配代理类实例、target匹配代理背后的目标类实例、args匹配参数
针对注解匹配,使用 @annotation(使用指定注解标注的方法)、@target(使用指定注解标注的类)、@args(使用指定注解标注的类作为某个方法的参数)。
你可能会问,@within 怎么没有呢?
其实,对于 Spring 默认的基于动态代理或 CGLIB 的 AOP因为切点只能是方法使用 @within@target 指示器并无区别;但需要注意如果切换到 AspectJ那么使用 @within@target 这两个指示器的行为就会有所区别了,@within 会切入更多的成员的访问(比如静态构造方法、字段访问),一般而言使用 @target 指示器即可。
问题 2Spring 的 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 讲对应的源码中更新了我的实现,你可以点击这里查看。有一些同学会问,这么做的意义到底在于什么,为何不直接使用类似 Apollo 这样的配置框架呢?
其实,我们的目的就是不希望让开发人员手动配置数据库信息,希望程序启动的时候自动替换占位符实现自动配置(从 CMDB 直接拿着应用程序 ID 来换取对应的数据库信息。你可能会问了,一个应用程序 ID 对应多个数据库怎么办?其实,一般对于微服务系统来说,一个应用就应该对应一个数据库)。这样一来,除了程序其他人都不会接触到生产的数据库信息,会更安全。
以上,就是咱们这门课的第 13~20 讲的思考题答案了。
关于这些题目,以及背后涉及的知识点,如果你还有哪里感觉不清楚的,欢迎在评论区与我留言,也欢迎你把今天的内容分享给你的朋友或同事,一起交流。

View File

@@ -0,0 +1,419 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
答疑篇:代码篇思考题集锦(二)
今天,我们继续一起分析这门课第 7~12 讲的课后思考题。这些题目涉及了数据库索引、判等问题、数值计算、集合类、空值处理和异常处理的 12 道问题。
接下来,我们就一一具体分析吧。
07 | 数据库索引:索引并不是万能药
问题 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 讲的聚簇索引和二级索引部分。
08 | 判等问题:程序里如何确定你就是你?
问题 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 | 数值计算:注意精度、舍入和溢出问题
问题 1BigDecimal提供了 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 -> 62.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 列表操作
问题 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 和恼人的空指针
问题 1ConcurrentHashMap 的 Key 和 Value 都不能为 null而 HashMap 却可以你知道这么设计的原因是什么吗TreeMap、Hashtable 等 Map 的 Key 和 Value 是否支持 null 呢?
答:原因正如 ConcurrentHashMap 的作者所说:
The main reason that nulls arent allowed in ConcurrentMaps (ConcurrentHashMaps, ConcurrentSkipListMaps) is that ambiguities that may be just barely tolerable in non-concurrent maps cant be accommodated. The main one is that if map.get(key) returns null, you cant detect whether the key explicitly maps to null vs the key isnt 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 属性。关于这三个属性的使用方式,你可以参考如下源码,或是这里的官方文档:
/**
* 字段验证策略之 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 | 异常处理:别让自己在出问题的时候变为瞎子
问题 1关于在 finally 代码块中抛出异常的坑,如果在 finally 代码块中返回值,你觉得程序会以 try 或 catch 中的返回值为准,还是以 finally 中的返回值为准呢?
答:以 finally 中的返回值为准。
从语义上来说finally 是做方法收尾资源释放处理的,我们不建议在 finally 中有 return这样逻辑会很混乱。这是因为实现上 finally 中的代码块会被复制多份,分别放到 try 和 catch 调用 return 和 throw 异常之前,所以 finally 中如果有返回值,会覆盖 try 中的返回值。
问题 2对于手动抛出的异常不建议直接使用 Exception 或 RuntimeException通常建议复用 JDK 中的一些标准异常比如IllegalArgumentException、IllegalStateException、UnsupportedOperationException。你能说说它们的适用场景并列出更多常见的可重用标准异常吗
答:我们先分别看看 IllegalArgumentException、IllegalStateException、UnsupportedOperationException 这三种异常的适用场景。
IllegalArgumentException参数不合法异常适用于传入的参数不符合方法要求的场景。
IllegalStateException状态不合法异常适用于状态机的状态的无效转换当前逻辑的执行状态不适合进行相应操作等场景。
UnsupportedOperationException操作不支持异常适用于某个操作在实现或环境下不支持的场景。
还可以重用的异常有 IndexOutOfBoundsException、NullPointerException、ConcurrentModificationException 等。
以上,就是咱们这门课第 7~12 讲的思考题答案了。
关于这些题目,以及背后涉及的知识点,如果你还有哪里感觉不清楚的,欢迎在评论区与我留言,也欢迎你把今天的内容分享给你的朋友或同事,一起交流。

View File

@@ -0,0 +1,389 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
答疑篇:加餐篇思考题答案合集
今天,我们继续一起分析这门课的“不定期加餐”篇中 5 讲的课后思考题。这些题目涉及了 Java 8 基础知识、定位和分析应用问题相关的几大知识点。
接下来,我们就一一具体分析吧。
加餐 1 | 带你吃透课程中 Java 8 的那些重要知识点(一)
问题:对于并行流部分的并行消费处理 1 到 100 的例子,如果把 forEach 替换为 forEachOrdered你觉得会发生什么呢
forEachOrdered 会让 parallelStream 丧失部分的并行能力,主要原因是 forEach 遍历的逻辑无法并行起来(需要按照循序遍历,无法并行)。
我们来比较下面的三种写法:
//模拟消息数据需要1秒时间
private static void consume(int i) {
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.print(i);
}
//模拟过滤数据需要1秒时间
private static boolean filter(int i) {
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
return i % 2 == 0;
}
@Test
public void test() {
System.setProperty("java.util.concurrent.ForkJoinPool.common.parallelism", String.valueOf(10));
StopWatch stopWatch = new StopWatch();
stopWatch.start("stream");
stream();
stopWatch.stop();
stopWatch.start("parallelStream");
parallelStream();
stopWatch.stop();
stopWatch.start("parallelStreamForEachOrdered");
parallelStreamForEachOrdered();
stopWatch.stop();
System.out.println(stopWatch.prettyPrint());
}
//filtre和forEach串行
private void stream() {
IntStream.rangeClosed(1, 10)
.filter(ForEachOrderedTest::filter)
.forEach(ForEachOrderedTest::consume);
}
//filter和forEach并行
private void parallelStream() {
IntStream.rangeClosed(1, 10).parallel()
.filter(ForEachOrderedTest::filter)
.forEach(ForEachOrderedTest::consume);
}
//filter并行而forEach串行
private void parallelStreamForEachOrdered() {
IntStream.rangeClosed(1, 10).parallel()
.filter(ForEachOrderedTest::filter)
.forEachOrdered(ForEachOrderedTest::consume);
}
得到输出:
\---------------------------------------------
ns % Task name
\---------------------------------------------
15119607359 065% stream
2011398298 009% parallelStream
6033800802 026% parallelStreamForEachOrdered
从输出中,我们可以看到:
stream 方法的过滤和遍历全部串行执行,总时间是 10 秒 +5 秒 =15 秒;
parallelStream 方法的过滤和遍历全部并行执行,总时间是 1 秒 +1 秒 =2 秒;
parallelStreamForEachOrdered 方法的过滤并行执行,遍历串行执行,总时间是 1 秒 +5 秒 =6 秒。
加餐 2 | 带你吃透课程中 Java 8 的那些重要知识点(二)
问题 1使用 Stream 可以非常方便地对 List 做各种操作,那有没有什么办法可以实现在整个过程中观察数据变化呢?比如,我们进行 filter+map 操作,如何观察 filter 后 map 的原始数据呢?
答:要想观察使用 Stream 对 List 的各种操作的过程中的数据变化,主要有下面两个办法。
第一,使用 peek 方法。比如如下代码,我们对数字 1~10 进行了两次过滤,分别是找出大于 5 的数字和找出偶数,我们通过 peek 方法把两次过滤操作之前的原始数据保存了下来:
List<Integer> firstPeek = new ArrayList<>();
List<Integer> secondPeek = new ArrayList<>();
List<Integer> result = IntStream.rangeClosed(1, 10)
.boxed()
.peek(i -> firstPeek.add(i))
.filter(i -> i > 5)
.peek(i -> secondPeek.add(i))
.filter(i -> i % 2 == 0)
.collect(Collectors.toList());
System.out.println("firstPeek" + firstPeek);
System.out.println("secondPeek" + secondPeek);
System.out.println("result" + result);
最后得到输出,可以看到第一次过滤之前是数字 1~10一次过滤后变为 6~10最终输出 6、8、10 三个数字:
firstPeek[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
secondPeek[6, 7, 8, 9, 10]
result[6, 8, 10]
第二,借助 IDEA 的 Stream 的调试功能。详见这里,效果类似下图:
问题 2Collectors 类提供了很多现成的收集器,那我们有没有办法实现自定义的收集器呢?比如,实现一个 MostPopularCollector来得到 List 中出现次数最多的元素,满足下面两个测试用例:
assertThat(Stream.of(1, 1, 2, 2, 2, 3, 4, 5, 5).collect(new MostPopularCollector<>()).get(), is(2));
assertThat(Stream.of('a', 'b', 'c', 'c', 'c', 'd').collect(new MostPopularCollector<>()).get(), is('c'));
答:我来说下我的实现思路和方式:通过一个 HashMap 来保存元素的出现次数,最后在收集的时候找出 Map 中出现次数最多的元素:
public class MostPopularCollector<T> implements Collector<T, Map<T, Integer>, Optional<T>> {
//使用HashMap保存中间数据
@Override
public Supplier<Map<T, Integer>> supplier() {
return HashMap::new;
}
//每次累积数据则累加Value
@Override
public BiConsumer<Map<T, Integer>, T> accumulator() {
return (acc, elem) -> acc.merge(elem, 1, (old, value) -> old + value);
}
//合并多个Map就是合并其Value
@Override
public BinaryOperator<Map<T, Integer>> combiner() {
return (a, b) -> Stream.concat(a.entrySet().stream(), b.entrySet().stream())
.collect(Collectors.groupingBy(Map.Entry::getKey, summingInt(Map.Entry::getValue)));
}
//找出Map中Value最大的Key
@Override
public Function<Map<T, Integer>, Optional<T>> finisher() {
return (acc) -> acc.entrySet().stream()
.reduce(BinaryOperator.maxBy(Map.Entry.comparingByValue()))
.map(Map.Entry::getKey);
}
@Override
public Set<Characteristics> characteristics() {
return Collections.emptySet();
}
}
加餐 3 | 定位应用问题,排错套路很重要
问题:如果你现在打开一个 App 后发现首页展示了一片空白,那这到底是客户端兼容性的问题,还是服务端的问题呢?如果是服务端的问题,又如何进一步细化定位呢?你有什么分析思路吗?
答:首先,我们需要区分客户端还是服务端错误。我们可以先从客户端下手,排查看看是否是服务端问题,也就是通过抓包来看服务端的返回(一般而言客户端发布之前会经过测试,而且无法随时变更,所以服务端出错的可能性会更大一点)。因为一个客户端程序可能对应几百个服务端接口,先从客户端(发出请求的根源)开始排查问题,更容易找到方向。
服务端没有返回正确的输出,那么就需要继续排查服务端接口或是上层的负载均衡了,排查方式为:
查看负载均衡(比如 Nginx的日志
查看服务端日志;
查看服务端监控。
如果服务端返回了正确的输出,那么要么是由于客户端的 Bug要么就是外部配置等问题了排查方式为
查看客户端报错(一般而言,客户端都会对接 SAAS 的异常服务);
直接本地启动客户端调试。
加餐 4 | 分析定位 Java 问题,一定要用好这些工具(一)
问题 1JDK 中还有一个 jmap 工具,我们会使用 jmap -dump 命令来进行堆转储。那么,这条命令和 jmap -dump:live 有什么区别呢?你能否设计一个实验,来证明下它们的区别呢?
jmap -dump 命令是转储堆中的所有对象,而 jmap -dump:live 是转储堆中所有活着的对象。因为jmap -dump:live 会触发一次 FullGC。
写一个程序测试一下:
@SpringBootApplication
@Slf4j
public class JMapApplication implements CommandLineRunner {
//-Xmx512m -Xms512m
public static void main(String[] args) {
SpringApplication.run(JMapApplication.class, args);
}
@Override
public void run(String... args) throws Exception {
while (true) {
//模拟产生字符串每次循环后这个字符串就会失去引用可以GC
String payload = IntStream.rangeClosed(1, 1000000)
.mapToObj(__ -> "a")
.collect(Collectors.joining("")) + UUID.randomUUID().toString();
log.debug(payload);
TimeUnit.MILLISECONDS.sleep(1);
}
}
}
然后,使用 jmap 不带和带 live 分别生成两个转储:
jmap -dump:format=b,file=nolive.hprof 57323
jmap -dump:live,format=b,file=live.hprof 5732
可以看到nolive 这个转储的不可到达对象包含了 164MB char[](可以认为基本是字符串):
而 live 这个转储只有 1.3MB 的 char[],说明程序循环中的这些字符串都被 GC 了:
问题 2你有没有想过客户端是如何和 MySQL 进行认证的呢你能否对照MySQL 的文档,使用 Wireshark 观察分析这一过程呢?
答:一般而言,认证(握手)过程分为三步。
首先,服务端给客户端主动发送握手消息:
Wireshark 已经把消息的字段做了解析你可以对比官方文档的协议格式一起查看。HandshakeV10 消息体的第一个字节是消息版本 0a见图中红色框标注的部分。前面四个字节是 MySQL 的消息头其中前三个字节是消息体长度16 进制 4a=74 字节),最后一个字节是消息序列号。
然后,客户端给服务端回复的 HandshakeResponse41 消息体,包含了登录的用户名和密码:
可以看到,用户名是 string[NUL]类型的,说明字符串以 00 结尾代表字符串结束。关于 MySQL 协议中的字段类型,你可以参考这里。
最后,服务端回复的 OK 消息,代表握手成功:
这样分析下来,我们可以发现使用 Wireshark 观察客户端和 MySQL 的认证过程,非常方便。而如果不借助 Wireshark 工具,我们只能一个字节一个字节地对照协议文档分析内容。
其实,各种 CS 系统定义的通讯协议本身并不深奥,甚至可以说对着协议文档写通讯客户端是体力活。你可以继续按照这里我说的方式,结合抓包和文档,分析一下 MySQL 的查询协议。
加餐 5 | 分析定位 Java 问题,一定要用好这些工具(二)
问题Arthas 还有一个强大的热修复功能。比如,遇到高 CPU 问题时,我们定位出是管理员用户会执行很多次 MD5消耗大量 CPU 资源。这时我们可以直接在服务器上进行热修复步骤是jad 命令反编译代码 -> 使用文本编辑器(比如 Vim直接修改代码 -> 使用 sc 命令查找代码所在类的 ClassLoader-> 使用 redefine 命令热更新代码。你可以尝试使用这个流程,直接修复程序(注释 doTask 方法中的相关代码)吗?
Arthas 的官方文档有详细的操作步骤,实现 jad->sc->redefine 的整个流程,需要注意的是:
redefine 命令和 jad/watch/trace/monitor/tt 等命令会冲突。执行完 redefine 之后,如果再执行上面提到的命令,则会把 redefine 的字节码重置。 原因是JDK 本身 redefine 和 Retransform 是不同的机制,同时使用两种机制来更新字节码,只有最后的修改会生效。
使用 redefine 不允许新增或者删除 field/method并且运行中的方法不会立即生效需要等下次运行才能生效。
以上,就是咱们这门课里面 5 篇加餐文章的思考题答案了。至此,咱们这个课程的“答疑篇”模块也就结束了。
关于这些题目,以及背后涉及的知识点,如果你还有哪里感觉不清楚的,欢迎在评论区与我留言,也欢迎你把今天的内容分享给你的朋友或同事,一起交流。

View File

@@ -0,0 +1,169 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
答疑篇:安全篇思考题答案合集
今天,我们继续一起分析这门课“安全篇”模块的第 27~30 讲的课后思考题。这些题目涉及了数据源头、安全兜底、数据和代码、敏感数据相关的 4 大知识点。
接下来,我们就一一具体分析吧。
27 | 数据源头:任何客户端的东西都不可信任
问题 1在讲述用户标识不能从客户端获取这个要点的时候我提到开发同学可能会因为用户信息未打通而通过前端来传用户 ID。那我们有什么好办法来打通不同的系统甚至不同网站的用户标识吗
答:打通用户在不同系统之间的登录,大致有以下三种方案。
第一种,把用户身份放在统一的服务端,每一个系统都需要到这个服务端来做登录状态的确认,确认后在自己网站的 Cookie 中保存会话,这就是单点登录的做法。这种方案要求所有关联系统都对接一套中央认证服务器(中央保存用户会话),在未登录的时候跳转到中央认证服务器进行登录或登录状态确认。因此,这种方案适合一个公司内部的不同域名下的网站。
第二种,把用户身份信息直接放在 Token 中在客户端任意传递Token 由服务端进行校验(如果共享密钥话,甚至不需要同一个服务端进行校验),无需采用中央认证服务器,相对比较松耦合,典型的标准是 JWT。这种方案适合异构系统的跨系统用户认证打通而且相比单点登录的方案用户体验会更好一些。
第三种,如果需要打通不同公司系统的用户登录状态,那么一般都会采用 OAuth 2.0 的标准中的授权码模式,基本流程如下:
第三方网站客户端转到授权服务器,上送 ClientID、重定向地址 RedirectUri 等信息。
用户在授权服务器进行登录并且进行授权批准(授权批准这步可以配置为自动完成)。
授权完成后,重定向回到之前客户端提供的重定向地址,附上授权码。
第三方网站服务端通过授权码 +ClientID+ClientSecret 去授权服务器换取 Token。这里的 Token 包含访问 Token 和刷新 Token访问 Token 过期后用刷新 Token 去获得新的访问 Token。
因为我们不会对外暴露 ClientSecret也不会对外暴露访问 Token同时使用授权码换取 Token 的过程是服务端进行的,客户端拿到的只是一次性的授权码,所以这种模式比较安全。
问题 2还有一类和客户端数据相关的漏洞非常重要那就是 URL 地址中的数据。在把匿名用户重定向到登录页面的时候,我们一般会带上 redirectUrl这样用户登录后可以快速返回之前的页面。黑客可能会伪造一个活动链接由真实的网站 + 钓鱼的 redirectUrl 构成,发邮件诱导用户进行登录。用户登录时访问的其实是真的网站,所以不容易察觉到 redirectUrl 是钓鱼网站,登录后却来到了钓鱼网站,用户可能会不知不觉就把重要信息泄露了。这种安全问题,我们叫做开放重定向问题。你觉得,从代码层面应该怎么预防开放重定向问题呢?
答:要从代码层面预防开放重定向问题,有以下三种做法可供参考:
第一种,固定重定向的目标 URL。
第二种,可采用编号方式指定重定向的目标 URL也就是重定向的目标 URL 只能是在我们的白名单内的。
第三种,用合理充分的校验方式来校验跳转的目标地址,如果是非己方地址,就告知用户跳转有风险,小心钓鱼网站的威胁。
28 | 安全兜底:涉及钱时,必须考虑防刷、限量和防重
问题 1防重、防刷都是事前手段如果我们的系统正在被攻击或利用你有什么办法及时发现问题吗
答:对于及时发现系统正在被攻击或利用,监控是较好的手段,关键点在于报警阈值怎么设置。我觉得可以对比昨天同时、上周同时的量,发现差异达到一定百分比报警,而且报警需要有升级机制。此外,有的时候大盘很大的话,活动给整个大盘带来的变化不明显,如果进行整体监控可能出了问题也无法及时发现,因此可以考虑对于活动做独立的监控报警。
问题 2任何三方资源的使用一般都会定期对账如果在对账中发现我们系统记录的调用量低于对方系统记录的使用量你觉得一般是什么问题引起的呢
答:我之前遇到的情况是,在事务内调用外部接口,调用超时后本地事务回滚本地就没有留下数据。更合适的做法是:
请求发出之前先记录请求数据提交事务,记录状态为未知。
发布调用外部接口的请求,如果可以拿到明确的结果,则更新数据库中记录的状态为成功或失败。如果出现超时或未知异常,不能假设第三方接口调用失败,需要通过查询接口查询明确的结果。
写一个定时任务补偿数据库中所有未知状态的记录,从第三方接口同步结果。
值得注意的是,对账的时候一定要对两边,不管哪方数据缺失都可能是因为程序逻辑有 bug需要重视。此外任何涉及第三方系统的交互都建议在数据库中保持明细的请求 / 响应报文,方便在出问题的时候定位 Bug 根因。
29 | 数据和代码:数据就是数据,代码就是代码
问题 1在讨论 SQL 注入案例时,最后那次测试我们看到 sqlmap 返回了 4 种注入方式。其中,布尔盲注、时间盲注和报错注入,我都介绍过了。你知道联合查询注入,是什么吗?
答:联合查询注入,也就是通过 UNION 来实现我们需要的信息露出一般属于回显的注入方式。我们知道UNION 可以用于合并两个 SELECT 查询的结果集,因此可以把注入脚本来 UNION 到原始的 SELECT 后面。这样就可以查询我们需要的数据库元数据以及表数据了。
注入的关键点在于:
第一UNION 的两个 SELECT 语句的列数和字段类型需要一致。
第二,需要探查 UNION 后的结果和页面回显呈现数据的对应关系。
问题 2在讨论 XSS 的时候,对于 Thymeleaf 模板引擎,我们知道如何让文本进行 HTML 转义显示。FreeMarker 也是 Java 中很常用的模板引擎,你知道如何处理转义吗?
答:其实,现在大多数的模板引擎都使用了黑名单机制,而不是白名单机制来做 HTML 转义,这样更能有效防止 XSS 漏洞。也就是,默认开启 HTML 转义,如果某些情况你不需要转义可以临时关闭。
比如FreeMarker2.3.24 以上版本)默认对 HTML、XHTML、XML 等文件类型(输出格式)设置了各种转义规则,你可以使用?no_esc
<#-- 假设默认是HTML输出 -->
${'<b>test</b>'} <#-- 输出: &lt;b&gt;test&lt;/b&gt; -->
${'<b>test</b>'?no_esc} <#-- 输出: <b>test</b> -->
或 noautoesc 指示器:
${'&'} <#-- 输出: &amp; -->
<#noautoesc>
${'&'} <#-- 输出: & -->
...
${'&'} <#-- 输出: & -->
</#noautoesc>
${'&'} <#-- 输出: &amp; -->
来临时关闭转义。又比如对于模板引擎Mustache可以使用三个花括号而不是两个花括号来取消变量自动转义
模板:
* {{name}}
* {{company}}
* {{{company}}}
数据:
{
"name": "Chris",
"company": "<b>GitHub</b>"
}
输出:
* Chris
*
* &lt;b&gt;GitHub&lt;/b&gt;
* <b>GitHub</b>
30 | 如何正确保存和传输敏感数据?
问题 1虽然我们把用户名和密码脱敏加密保存在数据库中但日志中可能还存在明文的敏感数据。你有什么思路在框架或中间件层面对日志进行脱敏吗
答:如果我们希望在日志的源头进行脱敏,那么可以在日志框架层面做。比如对于 logback 日志框架,我们可以自定义 MessageConverter通过正则表达式匹配敏感信息脱敏。
需要注意的是,这种方式有两个缺点。
第一,正则表达式匹配敏感信息的格式不一定精确,会出现误杀漏杀的现象。一般来说,这个问题不会很严重。要实现精确脱敏的话,就只能提供各种脱敏工具类,然后让业务应用在日志中记录敏感信息的时候,先手动调用工具类进行脱敏。
第二,如果数据量比较大的话,脱敏操作可能会增加业务应用的 CPU 和内存使用,甚至会导致应用不堪负荷出现不可用。考虑到目前大部分公司都引入了 ELK 来集中收集日志,并且一般而言都不允许上服务器直接看文件日志,因此我们可以考虑在日志收集中间件中(比如 logstash写过滤器进行脱敏。这样可以把脱敏的消耗转义到 ELK 体系中,不过这种方式同样有第一点提到的字段不精确匹配导致的漏杀误杀的缺点。
问题 2你知道 HTTPS 双向认证的目的是什么吗?流程上又有什么区别呢?
答:单向认证一般用于 Web 网站,浏览器只需要验证服务端的身份。对于移动端 App如果我们希望有更高的安全性可以引入 HTTPS 双向认证,也就是除了客户端验证服务端身份之外,服务端也验证客户端的身份。
单向认证和双向认证的流程区别,主要包括以下三个方面。
第一,不仅仅服务端需要有 CA 证书,客户端也需要有 CA 证书。
第二,双向认证的流程中,客户端校验服务端 CA 证书之后,客户端会把自己的 CA 证书发给服务端,然后服务端需要校验客户端 CA 证书的真实性。
第三,客户端给服务端的消息会使用自己的私钥签名,服务端可以使用客户端 CA 证书中的公钥验签。
这里还想补充一点,对于移动应用程序考虑到更强的安全性,我们一般也会把服务端的公钥配置在客户端中,这种方式的叫做 SSL Pinning。也就是说由客户端直接校验服务端证书的合法性而不是通过证书信任链来校验。采用 SSL Pinning由于客户端绑定了服务端公钥因此我们无法通过在移动设备上信用根证书实现抓包。不过这种方式的缺点是需要小心服务端 CA 证书过期后续证书注意不要修改公钥。
好了以上就是咱们整个《Java 业务开发常见错误 100 例》这门课的 30 讲正文的思考题答案或者解题思路了。
关于这些题目,以及背后涉及的知识点,如果你还有哪里感觉不清楚的,欢迎在评论区与我留言,也欢迎你把今天的内容分享给你的朋友或同事,一起交流。

View File

@@ -0,0 +1,437 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
答疑篇:设计篇思考题答案合集
今天,我们继续一起分析这门课“设计篇”模块的第 21~26 讲的课后思考题。这些题目涉及了代码重复、接口设计、缓存设计、生产就绪、异步处理和数据存储这 6 大知识点。
接下来,我们就一一具体分析吧。
21 | 代码重复:搞定代码重复的三个绝招
问题 1除了模板方法设计模式是减少重复代码的一把好手观察者模式也常用于减少代码重复并且是松耦合方式Spring 也提供了类似工具(点击这里查看),你能想到观察者模式有哪些应用场景吗?
答:其实,和使用 MQ 来解耦系统和系统的调用类似,应用内各个组件之间的调用我们也可以使用观察者模式来解耦,特别是当你的应用是一个大单体的时候。观察者模式除了是让组件之间可以更松耦合,还能更有利于消除重复代码。
其原因是,对于一个复杂的业务逻辑,里面必然涉及到大量其它组件的调用,虽然我们没有重复写这些组件内部处理逻辑的代码,但是这些复杂调用本身就构成了重复代码。
我们可以考虑把代码逻辑抽象一下,抽象出许多事件,围绕这些事件来展开处理,那么这种处理模式就从“命令式”变为了“环境感知式”,每一个组件就好像活在一个场景中,感知场景中的各种事件,然后又把发出处理结果作为另一个事件。
经过这种抽象,复杂组件之间的调用逻辑就变成了“事件抽象 + 事件发布 + 事件订阅”,整个代码就会更简化。
补充说明一下,除了观察者模式我们还经常听到发布订阅模式,那么它们有什么区别呢?
其实,观察者模式也可以叫做发布订阅模式。不过在严格定义上,前者属于松耦合,后者必须要 MQ Broker 的介入,实现发布者订阅者的完全解耦。
问题 2关于 Bean 属性复制工具,除了最简单的 Spring 的 BeanUtils 工具类的使用,你还知道哪些对象映射类库吗?它们又有什么功能呢?
在众多对象映射工具中MapStruct更具特色一点。它基于 JSR 269 的 Java 注解处理器实现(你可以理解为,它是编译时的代码生成器),使用的是纯 Java 方法而不是反射进行属性赋值,并且做到了编译时类型安全。
如果你使用 IDEA 的话,可以进一步安装 IDEA MapStruct Support 插件,实现映射配置的自动完成、跳转到定义等功能。关于这个插件的具体功能,你可以参考这里。
22 | 接口设计:系统间对话的语言,一定要统一
问题 1在“接口的响应要明确表示接口的处理结果”这一节的例子中接口响应结构体中的 code 字段代表执行结果的错误码对于业务特别复杂的接口可能会有很多错误情况code 可能会有几十甚至几百个。客户端开发人员需要根据每一种错误情况逐一写 if-else 进行不同交互处理,会非常麻烦,你觉得有什么办法来改进吗?作为服务端,是否有必要告知客户端接口执行的错误码呢?
答:服务端把错误码反馈给客户端有两个目的,一是客户端可以展示错误码方便排查问题,二是客户端可以根据不同的错误码来做交互区分。
对于第一点方便客户端排查问题,服务端应该进行适当的收敛和规整错误码,而不是把服务内可能遇到的、来自各个系统各个层次的错误码,一股脑地扔给客户端提示给用户。
我的建议是,开发一个错误码服务来专门治理错误码,实现错误码的转码、分类和收敛逻辑,甚至可以开发后台,让产品来录入需要的错误码提示消息。
此外,我还建议错误码由一定的规则构成,比如错误码第一位可以是错误类型(比如 A 表示错误来源于用户B 表示错误来源于当前系统往往是业务逻辑出错或程序健壮性差等问题C 表示错误来源于第三方服务),第二、第三位可以是错误来自的系统编号(比如 01 来自用户服务02 来自商户服务等等),后面三位是自增错误码 ID。
对于第二点对不同错误码的交互区分,我觉得更好的做法是服务端驱动模式,让服务端告知客户端如何处理,说白了就是客户端只需要照做即可,不需要感知错误码的含义(即便客户端显示错误码,也只是用于排错)。
比如,服务端的返回可以包含 actionType 和 actionInfo 两个字段前者代表客户端应该做的交互动作后者代表客户端完成这个交互动作需要的信息。其中actionType 可以是 toast无需确认的消息提示、alert需要确认的弹框提示、redirectView转到另一个视图、redirectWebView打开 Web 视图actionInfo 就是 toast 的信息、alert 的信息、redirect 的 URL 等。
由服务端来明确客户端在请求 API 后的交互行为,主要的好处是灵活和统一两个方面。
灵活在于两个方面:第一,在紧急的时候还可以通过 redirect 方式进行救急。比如,遇到特殊情况需要紧急进行逻辑修改的情况时,我们可以直接在不发版的情况下切换到 H5 实现。第二是,我们可以提供后台,让产品或运营来配置交互的方式和信息(而不是改交互,改提示还需要客户端发版)。
统一:有的时候会遇到不同的客户端(比如 iOS、Android、前端对于交互的实现不统一的情况如果 API 结果可以规定这部分内容,那就可以彻底避免这个问题。
问题 2在“要考虑接口变迁的版本控制策略”这一节的例子中我们在类或方法上标记 @APIVersion 自定义注解,实现了 URL 方式统一的接口版本定义。你可以用类似的方式(也就是自定义 RequestMappingHandlerMapping来实现一套统一的基于请求头方式的版本控制吗
答:我在 GitHub 上第 21 讲的源码中更新了我的实现,你可以点击这里查看。主要原理是,定义自己的 RequestCondition 来做请求头的匹配:
public class APIVersionCondition implements RequestCondition<APIVersionCondition> {
@Getter
private String apiVersion;
@Getter
private String headerKey;
public APIVersionCondition(String apiVersion, String headerKey) {
this.apiVersion = apiVersion;
this.headerKey = headerKey;
}
@Override
public APIVersionCondition combine(APIVersionCondition other) {
return new APIVersionCondition(other.getApiVersion(), other.getHeaderKey());
}
@Override
public APIVersionCondition getMatchingCondition(HttpServletRequest request) {
String version = request.getHeader(headerKey);
return apiVersion.equals(version) ? this : null;
}
@Override
public int compareTo(APIVersionCondition other, HttpServletRequest request) {
return 0;
}
}
并且自定义 RequestMappingHandlerMapping来把方法关联到自定义的 RequestCondition
public class APIVersionHandlerMapping extends RequestMappingHandlerMapping {
@Override
protected boolean isHandler(Class<?> beanType) {
return AnnotatedElementUtils.hasAnnotation(beanType, Controller.class);
}
@Override
protected RequestCondition<APIVersionCondition> getCustomTypeCondition(Class<?> handlerType) {
APIVersion apiVersion = AnnotationUtils.findAnnotation(handlerType, APIVersion.class);
return createCondition(apiVersion);
}
@Override
protected RequestCondition<APIVersionCondition> getCustomMethodCondition(Method method) {
APIVersion apiVersion = AnnotationUtils.findAnnotation(method, APIVersion.class);
return createCondition(apiVersion);
}
private RequestCondition<APIVersionCondition> createCondition(APIVersion apiVersion) {
return apiVersion == null ? null : new APIVersionCondition(apiVersion.value(), apiVersion.headerKey());
}
}
23 | 缓存设计:缓存可以锦上添花也可以落井下石
问题 1在聊到缓存并发问题时我们说到热点 Key 回源会对数据库产生的压力问题,如果 Key 特别热的话,可能缓存系统也无法承受,毕竟所有的访问都集中打到了一台缓存服务器。如果我们使用 Redis 来做缓存,那可以把一个热点 Key 的缓存查询压力,分散到多个 Redis 节点上吗?
Redis 4.0 以上如果开启了 LFU 算法作为 maxmemory-policy那么可以使用hotkeys 配合 redis-cli 命令行工具来探查热点 Key。此外我们还可以通过 MONITOR 命令来收集 Redis 执行的所有命令然后配合redis-faina 工具来分析热点 Key、热点前缀等信息。
对于如何分散热点 Key 对于 Redis 单节点的压力的问题,我们可以考虑为 Key 加上一定范围的随机数作为后缀,让一个 Key 变为多个 Key相当于对热点 Key 进行分区操作。
当然,除了分散 Redis 压力之外,我们也可以考虑再做一层短时间的本地缓存,结合 Redis 的 Keyspace 通知功能,来处理本地缓存的数据同步。
问题 2大 Key 也是数据缓存容易出现的一个问题。如果一个 Key 的 Value 特别大,那么可能会对 Redis 产生巨大的性能影响,因为 Redis 是单线程模型,对大 Key 进行查询或删除等操作,可能会引起 Redis 阻塞甚至是高可用切换。你知道怎么查询 Redis 中的大 Key以及如何在设计上实现大 Key 的拆分吗?
Redis 的大 Key 可能会导致集群内存分布不均问题,并且大 Key 的操作可能也会产生阻塞。
关于查询 Redis 中的大 Key我们可以使用 redis-cli bigkeys 命令来实时探查大 Key。此外我们还可以使用 redis-rdb-tools 工具来分析 Redis 的 RDB 快照,得到包含 Key 的字节数、元素个数、最大元素长度等信息的 CSV 文件。然后,我们可以把这个 CSV 文件导入 MySQL 中,写 SQL 去分析。
针对大 Key我们可以考虑两方面的优化
第一,是否有必要在 Redis 保存这么多数据。一般情况下,我们在缓存系统中保存面向呈现的数据,而不是原始数据;对于原始数据的计算,我们可以考虑其它文档型或搜索型的 NoSQL 数据库。
第二,考虑把具有二级结构的 Key比如 List、Set、Hash拆分成多个小 Key来独立获取或是用 MGET 获取)。
此外值得一提的是,大 Key 的删除操作可能会产生较大性能问题。从 Redis 4.0 开始,我们可以使用 UNLINK 命令而不是 DEL 命令在后台删除大 Key而对于 4.0 之前的版本,我们可以考虑使用游标删除大 Key 中的数据,而不是直接使用 DEL 命令,比如对于 Hash 使用 HSCAN+HDEL 结合管道功能来删除。
24 | 业务代码写完,就意味着生产就绪了?
问题 1Spring Boot Actuator 提供了大量内置端点,你觉得端点和自定义一个 @RestController 有什么区别呢?你能否根据官方文档,开发一个自定义端点呢?
Endpoint 是 Spring Boot Actuator 抽象出来的一个概念,主要用于监控和配置。使用 @Endpoint 注解自定义端点,配合方法上的 @ReadOperation@WriteOperation@DeleteOperation 注解,分分钟就可以开发出自动通过 HTTP 或 JMX 进行暴露的监控点。
如果只希望通过 HTTP 暴露的话,可以使用 @WebEndpoint 注解;如果只希望通过 JMX 暴露的话,可以使用 @JmxEndpoint 注解。
而使用 @RestController 一般用于定义业务接口,如果数据需要暴露到 JMX 的话需要手动开发。
比如,下面这段代码展示了如何定义一个累加器端点,提供了读取操作和累加两个操作:
@Endpoint(id = "adder")
@Component
public class TestEndpoint {
private static AtomicLong atomicLong = new AtomicLong();
//读取值
@ReadOperation
public String get() {
return String.valueOf(atomicLong.get());
}
//累加值
@WriteOperation
public String increment() {
return String.valueOf(atomicLong.incrementAndGet());
}
}
然后,我们可以通过 HTTP 或 JMX 来操作这个累加器。这样,我们就实现了一个自定义端点,并且可以通过 JMX 来操作:
问题 2在介绍指标 Metrics 时我们看到InfluxDB 中保存了由 Micrometer 框架自动帮我们收集的一些应用指标。你能否参考源码中两个 Grafana 配置的 JSON 文件,把这些指标在 Grafana 中配置出一个完整的应用监控面板呢?
我们可以参考Micrometer 源码中的 binder 包下面的类,来了解 Micrometer 帮我们自动做的一些指标。
JVM 在线时间process.uptime
系统 CPU 使用system.cpu.usage
JVM 进程 CPU 使用process.cpu.usage
系统 1 分钟负载system.load.average.1m
JVM 使用内存jvm.memory.used
JVM 提交内存jvm.memory.committed
JVM 最大内存jvm.memory.max
JVM 线程情况jvm.threads.states
JVM GC 暂停jvm.gc.pause、jvm.gc.concurrent.phase.time
剩余磁盘disk.free
Logback 日志数量logback.events
Tomcat 线程情况最大、繁忙、当前tomcat.threads.config.max、tomcat.threads.busy、tomcat.threads.current
具体的面板配置方式,第 24 讲中已有说明。这里,我只和你分享在配置时会用到的两个小技巧。
第一个小技巧是把公共的标签配置为下拉框固定在页头显示一般来说我们会配置一个面板给所有的应用使用每一个指标中我们都会保存应用名称、IP 地址等信息,这个功能可以使用 Micrometer 的 CommonTags 实现,参考文档的 5.2 节),我们可以利用 Grafana 的Variables功能把应用名称和 IP 展示为两个下拉框显示,同时提供一个 adhoc 筛选器自由增加筛选条件:
来到 Variables 面板,可以看到我配置的三个变量:
Application 和 IP 两个变量的查询语句如下:
SHOW TAG VALUES FROM jvm_memory_used WITH KEY = "application_name"
SHOW TAG VALUES FROM jvm_memory_used WITH KEY = "ip" WHERE application_name=~ /^$Application$/
第二个小技巧是,利用 GROUP BY 功能展示一些明细的曲线:类似 jvm_threads_states、jvm.gc.pause 等指标中包含了更细节的一些状态区分标签,比如 jvm_threads_states 中的 state 标签代表了线程状态。一般而言,我们在展现图表的时候需要按照线程状态分组分曲线显示:
配置的 InfluxDB 查询语句是:
SELECT max("value") FROM "jvm_threads_states" WHERE ("application_name" =~ /^$Application$/ AND "ip" =~ /^$IP$/) AND $timeFilter GROUP BY time($__interval), "state" fill(none)
这里可以看到application_name 和 ip 两个条件的值,是关联到刚才我们配置的两个变量的,在 GROUP BY 中增加了按照 state 的分组。
25 | 异步处理好用,但非常容易用错
问题 1在用户注册后发送消息到 MQ然后会员服务监听消息进行异步处理的场景下有些时候我们会发现虽然用户服务先保存数据再发送 MQ但会员服务收到消息后去查询数据库却发现数据库中还没有新用户的信息。你觉得这可能是什么问题呢又该如何解决呢
答:我先来分享下,我遇到这个问题的真实情况。
当时,我们是因为业务代码把保存数据和发 MQ 消息放在了一个事务中,收到消息的时候有可能事务还没有提交完成。为了解决这个问题,开发同学当时的处理方式是,收 MQ 消息的时候 Sleep 1 秒再去处理。这样虽然解决了问题,但却大大降低了消息处理的吞吐量。
更好的做法是先提交事务,完成后再发 MQ 消息。但是这又引申出来一个问题MQ 消息发送失败怎么办,如何确保发送消息和本地事务有整体事务性?这就需要进一步考虑建立本地消息表来确保 MQ 消息可补偿,把业务处理和保存 MQ 消息到本地消息表的操作,放在相同事务内处理,然后异步发送和补偿消息表中的消息到 MQ。
问题 2除了使用 Spring AMQP 实现死信消息的重投递外RabbitMQ 2.8.0 后支持的死信交换器 DLX 也可以实现类似功能。你能尝试用 DLX 实现吗,并比较下这两种处理机制?
答:其实 RabbitMQ 的DLX 死信交换器和普通交换器没有什么区别,只不过它有一个特点是,可以把其它队列关联到这个 DLX 交换器上,然后消息过期后自动会转发到 DLX 交换器。那么,我们就可以利用这个特点来实现延迟消息重投递,经过一定次数之后还是处理失败则作为死信处理。
实现结构如下图所示:
关于这个实现架构图,我需要说明的是:
为了简单起见,图中圆柱体代表交换器 + 队列,并省去了 RoutingKey。
WORKER 作为 DLX 用于处理消息BUFFER 用于临时存放需要延迟重试的消息WORKER 和 BUFFER 绑定在一起。
DEAD 用于存放超过重试次数的死信。
在这里 WORKER 其实是一个 DLX我们把它绑定到 BUFFER 实现延迟重试。
通过 RabbitMQ 实现具有延迟重试功能的消息重试以及最后进入死信队列的整个流程如下:
客户端发送记录到 WORKER
Handler 收到消息后处理失败;
第一次重试,发送消息到 BUFFER
3 秒后消息过期,自动转发到 WORKER
Handler 再次收到消息后处理失败;
第二次重试,发送消息到 BUFFER
3 秒后消息过期,还是自动转发到 WORKER
Handler 再次收到消息后处理失败,达到最大重试次数;
发送消息到 DEAD作为死信消息
DeadHandler 收到死信处理(比如进行人工处理)。
整个程序的日志输出如下,可以看到输出日志和我们前面贴出的结构图、详细解释的流程一致:
[21:59:48.625] [http-nio-45678-exec-1] [INFO ] [o.g.t.c.a.r.DeadLetterController:24 ] - Client 发送消息 msg1
[21:59:48.640] [org.springframework.amqp.rabbit.RabbitListenerEndpointContainer#0-1] [INFO ] [o.g.t.c.a.rabbitmqdlx.MQListener:27 ] - Handler 收到消息msg1
[21:59:48.641] [org.springframework.amqp.rabbit.RabbitListenerEndpointContainer#0-1] [INFO ] [o.g.t.c.a.rabbitmqdlx.MQListener:33 ] - Handler 消费消息msg1 异常准备重试第1次
[21:59:51.643] [org.springframework.amqp.rabbit.RabbitListenerEndpointContainer#0-1] [INFO ] [o.g.t.c.a.rabbitmqdlx.MQListener:27 ] - Handler 收到消息msg1
[21:59:51.644] [org.springframework.amqp.rabbit.RabbitListenerEndpointContainer#0-1] [INFO ] [o.g.t.c.a.rabbitmqdlx.MQListener:33 ] - Handler 消费消息msg1 异常准备重试第2次
[21:59:54.646] [org.springframework.amqp.rabbit.RabbitListenerEndpointContainer#0-1] [INFO ] [o.g.t.c.a.rabbitmqdlx.MQListener:27 ] - Handler 收到消息msg1
[21:59:54.646] [org.springframework.amqp.rabbit.RabbitListenerEndpointContainer#0-1] [INFO ] [o.g.t.c.a.rabbitmqdlx.MQListener:40 ] - Handler 消费消息msg1 异常,已重试 2 次,发送到死信队列处理!
[21:59:54.649] [org.springframework.amqp.rabbit.RabbitListenerEndpointContainer#1-1] [ERROR] [o.g.t.c.a.rabbitmqdlx.MQListener:62 ] - DeadHandler 收到死信消息: msg1
接下来,我们再对比下这种实现方式和第 25 讲中 Spring 重试的区别。其实,这两种实现方式的差别很大,体现在下面两点。
第一点Spring 的重试是在处理的时候,在线程内休眠进行延迟重试,消息不会重发到 MQ我们这个方案中处理失败的消息会发送到 RMQ由 RMQ 做延迟处理。
第二点Spring 的重试方案,只涉及普通队列和死信队列两个队列(或者说交换器);我们这个方案的实现中涉及工作队列、缓冲队列(用于存放等待延迟重试的消息)和死信队列(真正需要人工处理的消息)三个队列。
当然了,如果你希望把存放正常消息的队列和把存放需要重试处理消息的队列区分开的话,可以把我们这个方案中的队列再拆分下,变为四个队列,也就是工作队列、重试队列、缓冲队列(关联到重试队列作为 DLX和死信队列。
这里我再强调一下,虽然说我们利用了 RMQ 的 DLX 死信交换器的功能,但是我们把 DLX 当做了工作队列来使用,因为我们利用的是其能自动(从 BUFFER 缓冲队列)接收过期消息的特性。
这部分源码比较长,我直接放在 GitHub 上了。感兴趣的话,你可以点击这里的链接查看。
26 | 数据存储NoSQL 与 RDBMS 如何取长补短、相辅相成?
问题 1我们提到InfluxDB 不能包含太多 tag。你能写一段测试代码来模拟这个问题并观察下 InfluxDB 的内存使用情况吗?
答:我们写一段如下的测试代码:向 InfluxDB 写入大量指标,每一条指标关联 10 个 Tag每一个 Tag 都是 100000 以内的随机数这种方式会造成high series cardinality 问题,从而大量占用 InfluxDB 的内存。
@GetMapping("influxdbwrong")
public void influxdbwrong() {
OkHttpClient.Builder okHttpClientBuilder = new OkHttpClient().newBuilder()
.connectTimeout(1, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.writeTimeout(60, TimeUnit.SECONDS);
try (InfluxDB influxDB = InfluxDBFactory.connect("http://127.0.0.1:8086", "root", "root", okHttpClientBuilder)) {
influxDB.setDatabase("performance");
//插入100000条记录
IntStream.rangeClosed(1, 100000).forEach(i -> {
Map<String, String> tags = new HashMap<>();
//每条记录10个tagtag的值是100000以内随机值
IntStream.rangeClosed(1, 10).forEach(j -> tags.put("tagkey" + i, "tagvalue" + ThreadLocalRandom.current().nextInt(100000)));
Point point = Point.measurement("bad")
.tag(tags)
.addField("value", ThreadLocalRandom.current().nextInt(10000))
.time(System.currentTimeMillis(), TimeUnit.MILLISECONDS)
.build();
influxDB.write(point);
});
}
}
不过因为 InfluxDB 的默认参数配置限制了 Tag 的值数量以及数据库 Series 数量:
max-values-per-tag = 100000
max-series-per-database = 1000000
所以这个程序很快就会出错,无法形成 OOM你可以把这两个参数改为 0 来解除这个限制。
继续运行程序,我们可以发现 InfluxDB 占用大量内存最终出现 OOM。
问题 2文档数据库 MongoDB也是一种常用的 NoSQL。你觉得 MongoDB 的优势和劣势是什么呢?它适合用在什么场景下呢?
MongoDB 是目前比较火的文档型 NoSQL。虽然 MongoDB 在 4.0 版本后具有了事务功能,但是它整体的稳定性相比 MySQL 还是有些差距。因此MongoDB 不太适合作为重要数据的主数据库,但可以用来存储日志、爬虫等数据重要程度不那么高,但写入并发量又很大的场景。
虽然 MongoDB 的写入性能较高,但复杂查询性能却相比 Elasticsearch 来说没啥优势;虽然 MongoDB 有 Sharding 功能,但是还不太稳定。因此,我个人建议在数据写入量不大、更新不频繁,并且不需要考虑事务的情况下,使用 Elasticsearch 来替换 MongoDB。
以上,就是咱们这门课的第 21~26 讲的思考题答案了。
关于这些题目,以及背后涉及的知识点,如果你还有哪里感觉不清楚的,欢迎在评论区与我留言,也欢迎你把今天的内容分享给你的朋友或同事,一起交流。

View File

@@ -0,0 +1,95 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
结束语 写代码时,如何才能尽量避免踩坑?
这个课程要告一段落了,在这里我要特别感谢你一直以来的认可与陪伴。于我而言,虽然这半年多以来我几乎所有的业余时间都用了在这个课程的创作,以及回答你的问题上,很累很辛苦,但是看到你的认真学习和对课程内容的好评,看到你不仅收获了知识还燃起了钻研源码的热情,我也非常高兴,深觉一切的辛苦付出都是甜蜜的。
相信一路走来,你不仅理解了业务代码开发中常见的 130 多个坑点的解决方式,也知道了其根本原因,以及如何使用一些常用工具来分析问题。这样在以后遇到各种坑的时候,你就更加能有方法、有信心来解决问题。
如何尽量避免踩坑?
不过,学习、分析这些坑点并不是我们的最终目的,在写业务代码时如何尽量避免踩坑才是。所以,接下来,我要重点和你聊聊避免踩坑的一些方法。
所谓坑,往往就是我们意识不到的陷进。虽然这个课程覆盖了 130 多个业务开发时可能会出错的点,但我相信在整个 Java 开发领域还有成千上万个可能会踩的坑。同时,随着 Java 语言以及各种新框架、新技术的产生,我们还会不断遇到各种坑,很难有一种方式确保永远不会遇到新问题。
而我们能做的,就是尽可能少踩坑,或者减少踩坑给我们带来的影响。鉴于此,我还有 10 条建议要分享给你。
第一,遇到自己不熟悉的新类,在不了解之前不要随意使用。
比如,我在并发工具这一讲中提到的 CopyOnWriteArrayList。如果你仅仅认为 CopyOnWriteArrayList 是 ArrayList 的线程安全版本,在不知晓原理之前把它用于大量写操作的场景,那么很可能会遇到性能问题。
JDK 或各种框架随着时间的推移会不断推出各种特殊类,用于极致化各种细化场景下的程序性能。在使用这些类之前,我们需要认清楚这些类的由来,以及要解决的问题,在确认自己的场景符合的情况下再去使用。
而且,越普适的工具类通常用起来越简单,越高级的类用起来越复杂,也更容易踩坑。比如,代码加锁这一讲中提到的,锁工具类 StampedLock 就比 ReentrantLock 或者 synchronized 的用法复杂得多,很容易踩坑。
第二,尽量使用更高层次的框架。
通常情况下,偏底层的框架趋向于提供更多细节的配置,尽可能让使用者根据自己的需求来进行不同的配置,而较少考虑最佳实践的问题;而高层次的框架,则会更多地考虑怎么方便开发者开箱即用。
比如在HTTP 请求这一讲中,我们谈到 Apache HttpClient 的并发数限制问题。如果你使用 Spring Cloud Feign 搭配 HttpClient就不会遇到单域名默认 2 个并发连接的问题。因为Spring Cloud Feign 已经把这个参数设置为了 50足够应对一般场景了。
第三,关注各种框架和组件的安全补丁和版本更新。
比如,我们使用的 Tomcat 服务器、序列化框架等,就是黑客关注的安全突破口。我们需要及时关注这些组件和框架的稳定大版本和补丁,并及时更新升级,以避免组件和框架本身的性能问题或安全问题带来的大坑。
第四,尽量少自己造轮子,使用流行的框架。
流行框架最大的好处是成熟,在经过大量用户的使用打磨后,你能想到、能遇到的所有问题几乎别人都遇到了,框架中也有了解决方案。很多时候我们会以“轻量级”为由来造轮子,但其实很多复杂的框架,一开始也是轻量的。只不过是,这些框架经过各种迭代解决了各种问题,做了很多可扩展性预留之后,才变得越来越复杂,而并不一定是框架本身的设计臃肿。
如果我们自己去开发框架的话,很可能会踩一些别人已经踩过的坑。比如,直接使用 JDK NIO 来开发网络程序或网络框架的话,我们可能会遇到 epoll 的 selector 空轮询 Bug最终导致 CPU 100%。而 Netty 规避了这些问题,因此使用 Netty 开发 NIO 网络程序,不但简单而且可以少踩很多坑。
第五,开发的时候遇到错误,除了搜索解决方案外,更重要的是理解原理。
比如在OOM这一讲我提到的配置超大 server.max-http-header-size 参数导致的 OOM 问题,可能就是来自网络的解决方案。网络上别人给出的解决方案,可能只是适合“自己”,不一定适合所有人。并且,各种框架迭代很频繁,今天有效的解决方案,明天可能就无效了;今天有效的参数配置,新版本可能就不再建议使用甚至失效了。
因此,只有知其所以然,才能从根本上避免踩坑。
第六,网络上的资料有很多,但不一定可靠,最可靠的还是官方文档。
比如,搜索 Java 8 的一些介绍,你可以看到有些资料提到了在 Java 8 中 Files.lines 方法进行文件读取更高效,但是 Demo 代码并没使用 try-with-resources 来释放资源。在文件 IO这一讲中我和你讲解了这么做会导致文件句柄无法释放。
其实,网上的各种资料,本来就是大家自己学习分享的经验和心得,不一定都是对的。另外,这些资料给出的都是 Demo演示的是某个类在某方面的功能不一定会面面俱到地考虑到资源释放、并发等问题。
因此,对于系统学习某个组件或框架,我最推荐的还是 JDK 或者三方库的官方文档。这些文档基本不会出现错误的示例,一般也会提到使用的最佳实践,以及最需要注意的点。
第七,做好单元测试和性能测试。
如果你开发的是一个偏底层的服务或框架,有非常多的受众和分支流程,那么单元测试(或者是自动化测试)就是必须的。
人工测试一般针对主流程和改动点,只有单元测试才可以确保任何一次改动不会影响现有服务的每一个细节点。此外,许多坑都涉及线程安全、资源使用,这些问题只有在高并发的情况下才会产生。没有经过性能测试的代码,只能认为是完成了功能,还不能确保健壮性、可扩展性和可靠性。
第八,做好设计评审和代码审查工作。
人都会犯错,而且任何一个人的知识都有盲区。因此,项目的设计如果能提前有专家组进行评审,每一段代码都能有至少三个人进行代码审核,就可以极大地减少犯错的可能性。
比如,对于熟悉 IO 的开发者来说,他肯定知道文件的读写需要基于缓冲区。如果他看到另一个同事提交的代码,是以单字节的方式来读写文件,就可以提前发现代码的性能问题。
又比如一些比较老的资料仍然提倡使用MD5 摘要来保存密码。但是,现在 MD5 已经不安全了。如果项目设计已经由公司内安全经验丰富的架构师和安全专家评审过,就可以提前避免安全疏漏。
第九,借助工具帮我们避坑。
其实,我们犯很多低级错误时,并不是自己不知道,而是因为疏忽。就好像是,即使我们知道可能存在这 100 个坑,但如果让我们一条一条地确认所有代码是否有这些坑,我们也很难办到。但是,如果我们可以把规则明确的坑使用工具来检测,就可以避免大量的低级错误。
比如,使用 YYYY 进行日期格式化的坑、使用 == 进行判等的坑、List.subList原 List 和子 List 相互影响的坑等,都可以通过阿里 P3C 代码规约扫描插件发现。我也建议你为 IDE 安装这个插件。
此外,我还建议在 CI 流程中集成Sonarqube代码静态扫描平台对需要构建发布的代码进行全面的代码质量扫描。
第十,做好完善的监控报警。
诸如内存泄露、文件句柄不释放、线程泄露等消耗型问题往往都是量变积累成为质变最后才会造成进程崩溃。如果一开始我们就可以对应用程序的内存使用、文件句柄使用、IO 使用量、网络带宽、TCP 连接、线程数等各种指标进行监控,并且基于合理阈值设置报警,那么可能就能在事故的婴儿阶段及时发现问题、解决问题。
此外,在遇到报警的时候,我们不能凭经验想当然地认为这些问题都是已知的,对报警置之不理。我们要牢记,所有报警都需要处理和记录。
以上,就是我要分享给你的 10 条建议了。用好这 10 条建议,可以帮助我们很大程度提前发现 Java 开发中的一些坑、避免一些压力引起的生产事故,或是减少踩坑的影响。
最后,正所谓师傅领进门,修行靠个人,希望你在接下来学习技术和写代码的过程中,能够养成多研究原理、多思考总结问题的习惯,点点滴滴补全自己的知识网络。对代码精益求精,写出健壮的代码,线上问题少了,不但自己的心情好了,也能得到更多认可,并有更多时间来学习提升。这样,我们的个人成长就会比较快,形成正向循环。
另外,如果你有时间,我想请你帮我填个课程问卷,和我反馈你对这个课程的想法和建议。今天虽然是结课,但我还会继续关注你的留言,也希望你能继续学习这个课程的内容,并会通过留言区和你互动。
你还可以继续把这个课程分享给身边的朋友和同事,我们继续交流、讨论在写 Java 业务代码时可能会犯的错儿。

View File

@@ -0,0 +1,127 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
00 由点及面,搭建你的 Java 并发知识网
你好欢迎学习《Java 并发编程核心 78 讲》,我是讲师徐隆曦,硕士毕业于德国慕尼黑工业大学,现就职于滴滴出行,负责小桔车服驾驶安全平台开发。
扎实的理论基础,宝贵的并发实践经验
工作期间,因为业务需要,我所开发和负责的场景大多数都是大流量和高并发的,其中有很多是对 Java 并发知识的实际应用。学习如逆旅,从小白成长为并发大神,困难重重,既然不能逃避,那么唯有改变对它的态度。
从一开始面对线程池导致的 OOM 问题的不知所措,到后来可以深入剖析 JUC 源码,并精准定位、复现、修复线上的并发问题,再到现在可以应对千万级流量的业务场景,并预判和发现隐藏在其中的线程安全隐患,这期间,我走过一些弯路,踩过一些坑,也积累了很多宝贵的并发经验。
此外,在对并发问题的逐个解决过程中,在系统的设计和实施过程中,我详细研读了大量的国内外经典并发书籍和资料,把涉及的代码一一落实、验证,并应用到业务里,这期间让我逐渐建立起了完善的 Java 并发知识体系。
为什么并发编程这么重要呢
随着接触和负责的系统越来越复杂,我逐渐发现,无论是对于优秀的系统设计,还是对于程序员的成长提高、职业发展,并发编程都是必须要跨过去的“坎”,而一旦你跨过了这道“坎”,便会豁然开朗,原来一切都如此简单,职业发展也会更上一层楼。
并发已经逐渐成为基本技能
流量稍大的系统,随着数据和用户量的不断增加,并发量轻松过万,如果不使用并发编程,那么性能很快就会成为瓶颈。而随着近年来服务器 CPU 性能和核心数的不断提高,又给并发编程带来了广阔的施展拳脚的空间。可谓是有需求,同时又有资源保障,兼具天时地利。
并发几乎是 Java 面试必考的内容
而随着互联网进入下半场,好公司对程序员的要求也水涨船高,各大互联网公司的岗位描述中,并发几乎是逃不掉的关键词,我们举几个来自拉勾网的 JD 实例。
你会发现Java 高级工程师岗位要求中并发编程几乎成为了必须掌握的技能点,而在面经里涉及的并发编程的知识也数不胜数,本专栏各课时涉及的知识点,也正是各大厂 Java 高级工程师面试的高频考题。
如何学好并发编程
在此邀请你做一个小测试,看看目录里的问题,你能否回答全面?相信你看到问题后大部分会感觉很熟悉,但要组织答案却又模棱两可,不敢太确定,那么接下来就带你了解如何学好 Java 高并发并攻克这些难题。
Java 编程是众多框架的原理和基础
无论是 Spring、tomcat 中对线程池的应用、数据库中的乐观锁思想,还是 Log4j2 对阻塞队列的应用等,无不体现着并发编程的思想,并发编程应用广泛,各大框架都和并发编程有着千丝万缕的联系。
并发编程就像是地基,掌握好以后,可以做到一通百通。
不过,要想学好并发编程,却不是一件容易的事,你有没有以下的感受?
并发的知识太多、太杂了
常见的并发工具类数不尽数:例如,线程池、各种 Lock、synchronized 关键字、ConcurrentHashMap、CopyOnWriteArrayList、ArrayBlockingQueue、ThreadLocal、原子类、CountDownLatch、Semaphore等等而它们的原理又包括 CAS、AQS、Java 内存模型等等。
从刚才那一长串的名字中可以看出,并发工具的数量很多,而且功能也不尽相同,不容易完全掌握。确实,并发涉及的知识点太琐碎了,大家或多或少都学习过一些并发的知识,但是总感觉一直学不完,东一榔头西一棒槌,很零散,也不知道尽头在哪里,导致学完以后,真正能记住的内容却很少。而且如果学到并发底层原理,就不只涉及 Java 语言,更涉及 JVM、JMM、操作系统、内存、CPU 指令等,令人一头雾水。
不容易找到清晰易懂的学习资料
在我学习的过程中,我总是有一种感受,那就是较少有资料能够把 Java 并发编程讲得非常清楚,例如我们学习一个工具类,希望了解它的诞生背景、使用场景,用法、注意点,最后理解原理,以及它和其他工具类的联系,这一系列的内容其实都是我们需要掌握的。
反观现有的网络相关资料,往往水平参差不齐,真伪难辨,而且经常含有错误,如果我们先入为主地接受了错误的观点,那就得不偿失了。
我希望本门课程可以把 Java 并发编程的这些复杂、难理解的概念,用通俗易懂、丰富的图示和例子的方式和大家分享出来,不仅知道怎么用,还能知道背后的原理。
利用“全局思维+单点突破”的理念,建立起并发的知识体系,同时又对各种常见的工具类有深刻认识,以后我们的知识就可以从点到线,从线到面,浑然一体。
学习了本门课,你会有以下收获
你可以建立完整的 Java 并发知识网
通过这门课程,你可以系统地学习 Java 并发编程知识,而不再是碎片化获取,建立起知识脉络后,每一个工具类在我们心中就不再高高在上,而仅仅是我们并发知识体系中的一块块“拼图”,相信你对并发的理解会更深入一个层次。
建立完整的知识网络后,今后即便是遇到新推出的并发工具类,也可以迅速定位到它应处的位置,并且结合已有的知识,很快就能把它掌握。
你可以掌握常用的并发工具类:
课程中包含了实际生产中常用的大多数并发工具类所对应的并发知识包括线程池、synchronized、Lock 锁悲观锁和乐观锁、可重入锁、公平锁和非公平锁、读写锁、ConcurrentHashMap、CopyOnWriteArrayList、ThreadLocal、6 种原子类、CAS 原理、线程协作的 CountDownLatch、CyclicBarrier、Semaphore、AQS 框架、Java 内存模型、happens-before 原则、volatile 关键字、线程创建和停止的正确方法、线程的 6 种状态、如何解决死锁等问题。从用法到原理,再到面试常见问题,一次性掌握透彻。
面试中获取 Offer 的利器
本课程的各小节,都是从高频常考的面试问题出发,首先给出对应的参考解答,然后引申出背后所关联的知识。不但能够让你回答好面试官的问题,而且还可以在面试问题的基础上,做进一步的升华,让面试官眼前一亮。
我还会和你分享面试经验和技巧如何把面试官往我们的思路上“引导”最终帮助你拿到心仪的Offer向更高阶的岗位迈进。
可以说并发编程是成为 Java 高级、资深工程师的必经之路。现在几乎所有的程序都或多或少的需要用到并发和多线程,如果你平时只能接触到 CRUD 的项目,想要进一步提高技术水平;或者是长期一线,只是不断地把业务逻辑“翻译”成代码;想要跳槽加薪,面试却屡屡碰壁,那么学习并发将会帮助你突破“瓶颈”,进阶到下一个层级。

View File

@@ -0,0 +1,215 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
01 为何说只有 1 种实现线程的方法?
在本课时我们主要学习为什么说本质上只有一种实现线程的方式?实现 Runnable 接口究竟比继承 Thread 类实现线程好在哪里?
实现线程是并发编程中基础中的基础,因为我们必须要先实现多线程,才可以继续后续的一系列操作。所以本课时就先从并发编程的基础如何实现线程开始讲起,希望你能够夯实基础,虽然实现线程看似简单、基础,但实际上却暗藏玄机。首先,我们来看下为什么说本质上实现线程只有一种方式?
实现线程的方式到底有几种?大部分人会说有 2 种、3 种或是 4 种,很少有人会说有 1 种。我们接下来看看它们具体指什么2 种实现方式的描述是最基本的,也是最为大家熟知的,我们就先来看看 2 种线程实现方式的源码。
实现 Runnable 接口
public class RunnableThread implements Runnable {
@Override
public void run() {
System.out.println('用实现Runnable接口实现线程');
}
}
第 1 种方式是通过实现 Runnable 接口实现多线程,如代码所示,首先通过 RunnableThread 类实现 Runnable 接口,然后重写 run() 方法,之后只需要把这个实现了 run() 方法的实例传到 Thread 类中就可以实现多线程。
继承 Thread 类
public class ExtendsThread extends Thread {
@Override
public void run() {
System.out.println('用Thread类实现线程');
}
}
第 2 种方式是继承 Thread 类,如代码所示,与第 1 种方式不同的是它没有实现接口,而是继承 Thread 类,并重写了其中的 run() 方法。相信上面这两种方式你一定非常熟悉,并且经常在工作中使用它们。
线程池创建线程
那么为什么说还有第 3 种或第 4 种方式呢?我们先来看看第 3 种方式:通过线程池创建线程。线程池确实实现了多线程,比如我们给线程池的线程数量设置成 10那么就会有 10 个子线程来为我们工作,接下来,我们深入解析线程池中的源码,来看看线程池是怎么实现线程的?
static class DefaultThreadFactory implements ThreadFactory {
DefaultThreadFactory() {
SecurityManager s = System.getSecurityManager();
group = (s != null) ? s.getThreadGroup() :
Thread.currentThread().getThreadGroup();
namePrefix = "pool-" +
poolNumber.getAndIncrement() +
"-thread-";
}
public Thread newThread(Runnable r) {
Thread t = new Thread(group, r,
namePrefix + threadNumber.getAndIncrement(),
0);
if (t.isDaemon())
t.setDaemon(false);
if (t.getPriority() != Thread.NORM_PRIORITY)
t.setPriority(Thread.NORM_PRIORITY);
return t;
}
}
对于线程池而言,本质上是通过线程工厂创建线程的,默认采用 DefaultThreadFactory ,它会给线程池创建的线程设置一些默认值,比如:线程的名字、是否是守护线程,以及线程的优先级等。但是无论怎么设置这些属性,最终它还是通过 new Thread() 创建线程的 ,只不过这里的构造函数传入的参数要多一些,由此可以看出通过线程池创建线程并没有脱离最开始的那两种基本的创建方式,因为本质上还是通过 new Thread() 实现的。
在面试中,如果你只是知道这种方式可以创建线程但不了解其背后的实现原理,就会在面试的过程中举步维艰,想更好的表现自己却给自己挖了“坑”。
所以我们在回答线程实现的问题时描述完前两种方式可以进一步引申说“我还知道线程池和Callable 也是可以创建线程的,但是它们本质上也是通过前两种基本方式实现的线程创建。”这样的回答会成为面试中的加分项。然后面试官大概率会追问线程池的构成及原理,这部分内容会在后面的课时中详细分析。
有返回值的 Callable 创建线程
class CallableTask implements Callable<Integer> {
@Override
public Integer call() throws Exception {
return new Random().nextInt();
}
}
//创建线程池
ExecutorService service = Executors.newFixedThreadPool(10);
//提交任务,并用 Future提交返回结果
Future<Integer> future = service.submit(new CallableTask());
第 4 种线程创建方式是通过有返回值的 Callable 创建线程Runnable 创建线程是无返回值的,而 Callable 和与之相关的 Future、FutureTask它们可以把线程执行的结果作为返回值返回如代码所示实现了 Callable 接口,并且给它的泛型设置成 Integer然后它会返回一个随机数。
但是,无论是 Callable 还是 FutureTask它们首先和 Runnable 一样,都是一个任务,是需要被执行的,而不是说它们本身就是线程。它们可以放到线程池中执行,如代码所示, submit() 方法把任务放到线程池中,并由线程池创建线程,不管用什么方法,最终都是靠线程来执行的,而子线程的创建方式仍脱离不了最开始讲的两种基本方式,也就是实现 Runnable 接口和继承 Thread 类。
其他创建方式
定时器 Timer
class TimerThread extends Thread {
//具体实现
}
讲到这里你可能会说,我还知道一些其他的实现线程的方式。比如,定时器也可以实现线程,如果新建一个 Timer令其每隔 10 秒或设置两个小时之后,执行一些任务,那么这时它确实也创建了线程并执行了任务,但如果我们深入分析定时器的源码会发现,本质上它还是会有一个继承自 Thread 类的 TimerThread所以定时器创建线程最后又绕回到最开始说的两种方式。
其他方法
/**
*描述:匿名内部类创建线程
*/
new Thread(new Runnable() {
@Override
public void run() {
System.out.println(Thread.currentThread().getName());
}
}).start();
}
}
或许你还会说,我还知道一些其他方式,比如匿名内部类或 lambda 表达式方式,实际上,匿名内部类或 lambda 表达式创建线程,它们仅仅是在语法层面上实现了线程,并不能把它归结于实现多线程的方式,如匿名内部类实现线程的代码所示,它仅仅是用一个匿名内部类把需要传入的 Runnable 给实例出来。
new Thread(() -> System.out.println(Thread.currentThread().getName())).start();
}
我们再来看下 lambda 表达式方式。如代码所示,最终它们依然符合最开始所说的那两种实现线程的方式。
实现线程只有一种方式
关于这个问题,我们先不聚焦为什么说创建线程只有一种方式,先认为有两种创建线程的方式,而其他的创建方式,比如线程池或是定时器,它们仅仅是在 new Thread() 外做了一层封装,如果我们把这些都叫作一种新的方式,那么创建线程的方式便会千变万化、层出不穷,比如 JDK 更新了,它可能会多出几个类,会把 new Thread() 重新封装,表面上看又会是一种新的实现线程的方式,透过现象看本质,打开封装后,会发现它们最终都是基于 Runnable 接口或继承 Thread 类实现的。
接下来,我们进行更深层次的探讨,为什么说这两种方式本质上是一种呢?
@Override
public void run() {
if (target != null) {
target.run();
}
}
首先,启动线程需要调用 start() 方法,而 start() 方法最终还会调用 run() 方法,我们先来看看第一种方式中 run() 方法究竟是怎么实现的,可以看出 run() 方法的代码非常短小精悍,第 1 行代码 if (target != null) ,判断 target 是否等于 null如果不等于 null就执行第 2 行代码 target.run(),而 target 实际上就是一个 Runnable即使用 Runnable 接口实现线程时传给Thread类的对象。
然后,我们来看第二种方式,也就是继承 Thread 方式,实际上,继承 Thread 类之后,会把上述的 run() 方法重写,重写后 run() 方法里直接就是所需要执行的任务,但它最终还是需要调用 thread.start() 方法来启动线程,而 start() 方法最终也会调用这个已经被重写的 run() 方法来执行它的任务,这时我们就可以彻底明白了,事实上创建线程只有一种方式,就是构造一个 Thread 类,这是创建线程的唯一方式。
我们上面已经了解了两种创建线程方式本质上是一样的,它们的不同点仅仅在于实现线程运行内容的不同,那么运行内容来自于哪里呢?
运行内容主要来自于两个地方,要么来自于 target要么来自于重写的 run() 方法,在此基础上我们进行拓展,可以这样描述:本质上,实现线程只有一种方式,而要想实现线程执行的内容,却有两种方式,也就是可以通过 实现 Runnable 接口的方式,或是继承 Thread 类重写 run() 方法的方式,把我们想要执行的代码传入,让线程去执行,在此基础上,如果我们还想有更多实现线程的方式,比如线程池和 Timer 定时器,只需要在此基础上进行封装即可。
实现 Runnable 接口比继承 Thread 类实现线程要好
下面我们来对刚才说的两种实现线程内容的方式进行对比,也就是为什么说实现 Runnable 接口比继承 Thread 类实现线程要好?好在哪里呢?
首先我们从代码的架构考虑实际上Runnable 里只有一个 run() 方法,它定义了需要执行的内容,在这种情况下,实现了 Runnable 与 Thread 类的解耦Thread 类负责线程启动和属性设置等内容,权责分明。
第二点就是在某些情况下可以提高性能,使用继承 Thread 类方式,每次执行一次任务,都需要新建一个独立的线程,执行完任务后线程走到生命周期的尽头被销毁,如果还想执行这个任务,就必须再新建一个继承了 Thread 类的类,如果此时执行的内容比较少,比如只是在 run() 方法里简单打印一行文字,那么它所带来的开销并不大,相比于整个线程从开始创建到执行完毕被销毁,这一系列的操作比 run() 方法打印文字本身带来的开销要大得多,相当于捡了芝麻丢了西瓜,得不偿失。如果我们使用实现 Runnable 接口的方式,就可以把任务直接传入线程池,使用一些固定的线程来完成任务,不需要每次新建销毁线程,大大降低了性能开销。
第三点好处在于 Java 语言不支持双继承,如果我们的类一旦继承了 Thread 类,那么它后续就没有办法再继承其他的类,这样一来,如果未来这个类需要继承其他类实现一些功能上的拓展,它就没有办法做到了,相当于限制了代码未来的可拓展性。
综上所述,我们应该优先选择通过实现 Runnable 接口的方式来创建线程。
好啦,本课时的全部内容就讲完了,在这一课时我们主要学习了 通过 Runnable 接口和继承 Thread 类等几种方式创建线程,又详细分析了为什么说本质上只有一种实现线程的方式,以及实现 Runnable 接口究竟比继承 Thread 类实现线程好在哪里?学习完本课时相信你一定对创建线程有了更深入的理解。

View File

@@ -0,0 +1,412 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
02 如何正确停止线程?为什么 volatile 标记位的停止方法是错误的?
在本课时我们主要学习如何正确停止一个线程?以及为什么用 volatile 标记位的停止方法是错误的?
首先,我们来复习如何启动一个线程,想要启动线程需要调用 Thread 类的 start() 方法,并在 run() 方法中定义需要执行的任务。启动一个线程非常简单,但如果想要正确停止它就没那么容易了。
原理介绍
通常情况下,我们不会手动停止一个线程,而是允许线程运行到结束,然后让它自然停止。但是依然会有许多特殊的情况需要我们提前停止线程,比如:用户突然关闭程序,或程序运行出错重启等。
在这种情况下即将停止的线程在很多业务场景下仍然很有价值。尤其是我们想写一个健壮性很好能够安全应对各种场景的程序时正确停止线程就显得格外重要。但是Java 并没有提供简单易用,能够直接安全停止线程的能力。
为什么不强制停止?而是通知、协作
对于 Java 而言,最正确的停止线程的方式是使用 interrupt。但 interrupt 仅仅起到通知被停止线程的作用。而对于被停止的线程而言,它拥有完全的自主权,它既可以选择立即停止,也可以选择一段时间后停止,也可以选择压根不停止。那么为什么 Java 不提供强制停止线程的能力呢?
事实上Java 希望程序间能够相互通知、相互协作地管理线程,因为如果不了解对方正在做的工作,贸然强制停止线程就可能会造成一些安全的问题,为了避免造成问题就需要给对方一定的时间来整理收尾工作。比如:线程正在写入一个文件,这时收到终止信号,它就需要根据自身业务判断,是选择立即停止,还是将整个文件写入成功后停止,而如果选择立即停止就可能造成数据不完整,不管是中断命令发起者,还是接收者都不希望数据出现问题。
如何用 interrupt 停止线程
while (!Thread.currentThread().isInterrupted() && more work to do) {
do more work
}
明白 Java 停止线程的设计原则之后,我们看看如何用代码实现停止线程的逻辑。我们一旦调用某个线程的 interrupt() 之后,这个线程的中断标记位就会被设置成 true。每个线程都有这样的标记位当线程执行时应该定期检查这个标记位如果标记位被设置成 true就说明有程序想终止该线程。回到源码可以看到在 while 循环体判断语句中,首先通过 Thread.currentThread().isInterrupt() 判断线程是否被中断,随后检查是否还有工作要做。&& 逻辑表示只有当两个判断条件同时满足的情况下,才会去执行下面的工作。
我们再看看具体例子。
public class StopThread implements Runnable {
@Override
public void run() {
int count = 0;
while (!Thread.currentThread().isInterrupted() && count < 1000) {
System.out.println("count = " + count++);
}
}
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(new StopThread());
thread.start();
Thread.sleep(5);
thread.interrupt();
}
}
在 StopThread 类的 run() 方法中,首先判断线程是否被中断,然后判断 count 值是否小于 1000。这个线程的工作内容很简单就是打印 0~999 的数字,每打印一个数字 count 值加 1可以看到线程会在每次循环开始之前检查是否被中断了。接下来在 main 函数中会启动该线程,然后休眠 5 毫秒后立刻中断线程该线程会检测到中断信号于是在还没打印完1000个数的时候就会停下来这种就属于通过 interrupt 正确停止线程的情况。
sleep 期间能否感受到中断
Runnable runnable = () -> {
int num = 0;
try {
while (!Thread.currentThread().isInterrupted() &&
num <= 1000) {
System.out.println(num);
num++;
Thread.sleep(1000000);
}
} catch (InterruptedException e) {
e.printStackTrace();
}
};
那么我们考虑一种特殊情况,改写上面的代码,如果线程在执行任务期间有休眠需求,也就是每打印一个数字,就进入一次 sleep ,而此时将 Thread.sleep() 的休眠时间设置为 1000 秒钟。
public class StopDuringSleep {
public static void main(String[] args) throws InterruptedException {
Runnable runnable = () -> {
int num = 0;
try {
while (!Thread.currentThread().isInterrupted() && num <= 1000) {
System.out.println(num);
num++;
Thread.sleep(1000000);
}
} catch (InterruptedException e) {
e.printStackTrace();
}
};
Thread thread = new Thread(runnable);
thread.start();
Thread.sleep(5);
thread.interrupt();
}
}
主线程休眠 5 毫秒后,通知子线程中断,此时子线程仍在执行 sleep 语句处于休眠中。那么就需要考虑一点在休眠中的线程是否能够感受到中断通知呢是否需要等到休眠结束后才能中断线程呢如果是这样就会带来严重的问题因为响应中断太不及时了。正因为如此Java 设计者在设计之初就考虑到了这一点。
如果 sleep、wait 等可以让线程进入阻塞的方法使线程休眠了,而处于休眠中的线程被中断,那么线程是可以感受到中断信号的,并且会抛出一个 InterruptedException 异常,同时清除中断信号,将中断标记位设置成 false。这样一来就不用担心长时间休眠中线程感受不到中断了因为即便线程还在休眠仍然能够响应中断通知并抛出异常。
两种最佳处理方式
在实际开发中肯定是团队协作的,不同的人负责编写不同的方法,然后相互调用来实现整个业务的逻辑。那么如果我们负责编写的方法需要被别人调用,同时我们的方法内调用了 sleep 或者 wait 等能响应中断的方法时,仅仅 catch 住异常是不够的。
void subTas() {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
// 在这里不处理该异常是非常不好的
}
}
我们可以在方法中使用 try/catch 或在方法签名中声明 throws InterruptedException。
方法签名抛异常run() 强制 try/catch
我们先来看下 try/catch 的处理逻辑。如上面的代码所示catch 语句块里代码是空的,它并没有进行任何处理。假设线程执行到这个方法,并且正在 sleep此时有线程发送 interrupt 通知试图中断线程,就会立即抛出异常,并清除中断信号。抛出的异常被 catch 语句块捕捉。
但是,捕捉到异常的 catch 没有进行任何处理逻辑,相当于把中断信号给隐藏了,这样做是非常不合理的,那么究竟应该怎么处理呢?首先,可以选择在方法签名中抛出异常。
void subTask2() throws InterruptedException {
Thread.sleep(1000);
}
正如代码所示,要求每一个方法的调用方有义务去处理异常。调用方要不使用 try/catch 并在 catch 中正确处理异常,要不将异常声明到方法签名中。如果每层逻辑都遵守规范,便可以将中断信号层层传递到顶层,最终让 run() 方法可以捕获到异常。而对于 run() 方法而言,它本身没有抛出 checkedException 的能力,只能通过 try/catch 来处理异常。层层传递异常的逻辑保障了异常不会被遗漏,而对 run() 方法而言,就可以根据不同的业务逻辑来进行相应的处理。
再次中断
private void reInterrupt() {
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
e.printStackTrace();
}
}
除了刚才推荐的将异常声明到方法签名中的方式外,还可以在 catch 语句中再次中断线程。如代码所示,需要在 catch 语句块中调用 Thread.currentThread().interrupt() 函数。因为如果线程在休眠期间被中断,那么会自动清除中断信号。如果这时手动添加中断信号,中断信号依然可以被捕捉到。这样后续执行的方法依然可以检测到这里发生过中断,可以做出相应的处理,整个线程可以正常退出。
我们需要注意,我们在实际开发中不能盲目吞掉中断,如果不在方法签名中声明,也不在 catch 语句块中再次恢复中断,而是在 catch 中不作处理,我们称这种行为是“屏蔽了中断请求”。如果我们盲目地屏蔽了中断请求,会导致中断信号被完全忽略,最终导致线程无法正确停止。
为什么用 volatile 标记位的停止方法是错误的
下面我们来看一看本课时的第二个问题,为什么用 volatile 标记位的停止方法是错误的?
错误的停止方法
首先,我们来看几种停止线程的错误方法。比如 stop()suspend() 和 resume(),这些方法已经被 Java 直接标记为 @Deprecated。如果再调用这些方法IDE 会友好地提示,我们不应该再使用它们了。但为什么它们不能使用了呢?是因为 stop() 会直接把线程停止,这样就没有给线程足够的时间来处理想要在停止前保存数据的逻辑,任务戛然而止,会导致出现数据完整性等问题。
而对于 suspend() 和 resume() 而言,它们的问题在于如果线程调用 suspend(),它并不会释放锁,就开始进入休眠,但此时有可能仍持有锁,这样就容易导致死锁问题,因为这把锁在线程被 resume() 之前,是不会被释放的。
假设线程 A 调用了 suspend() 方法让线程 B 挂起,线程 B 进入休眠,而线程 B 又刚好持有一把锁,此时假设线程 A 想访问线程 B 持有的锁,但由于线程 B 并没有释放锁就进入休眠了,所以对于线程 A 而言,此时拿不到锁,也会陷入阻塞,那么线程 A 和线程 B 就都无法继续向下执行。
正是因为有这样的风险,所以 suspend() 和 resume() 组合使用的方法也被废弃了。那么接下来我们来看看,为什么用 volatile 标记位的停止方法也是错误的?
volatile 修饰标记位适用的场景
public class VolatileCanStop implements Runnable {
private volatile boolean canceled = false;
@Override
public void run() {
int num = 0;
try {
while (!canceled && num <= 1000000) {
if (num % 10 == 0) {
System.out.println(num + "是10的倍数。");
}
num++;
Thread.sleep(1);
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public static void main(String[] args) throws InterruptedException {
VolatileCanStop r = new VolatileCanStop();
Thread thread = new Thread(r);
thread.start();
Thread.sleep(3000);
r.canceled = true;
}
}
什么场景下 volatile 修饰标记位可以让线程正常停止呢如代码所示声明了一个叫作 VolatileStopThread 的类 它实现了 Runnable 接口然后在 run() 中进行 while 循环在循环体中又进行了两层判断首先判断 canceled 变量的值canceled 变量是一个被 volatile 修饰的初始值为 false 的布尔值当该值变为 true while 跳出循环while 的第二个判断条件是 num 值小于1000000一百万在while 循环体里只要是 10 的倍数就打印出来然后 num++。
接下来首先启动线程然后经过 3 秒钟的时间把用 volatile 修饰的布尔值的标记位设置成 true这样正在运行的线程就会在下一次 while 循环中判断出 canceled 的值已经变成 true 这样就不再满足 while 的判断条件跳出整个 while 循环线程就停止了这种情况是演示 volatile 修饰的标记位可以正常工作的情况但是如果我们说某个方法是正确的那么它应该不仅仅是在一种情况下适用而在其他情况下也应该是适用的
volatile 修饰标记位不适用的场景
接下来我们就用一个生产者/消费者模式的案例来演示为什么说 volatile 标记位的停止方法是不完美的
class Producer implements Runnable {
public volatile boolean canceled = false;
BlockingQueue storage;
public Producer(BlockingQueue storage) {
this.storage = storage;
}
@Override
public void run() {
int num = 0;
try {
while (num <= 100000 && !canceled) {
if (num % 50 == 0) {
storage.put(num);
System.out.println(num + "是50的倍数,被放到仓库中了。");
}
num++;
}
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
System.out.println("生产者结束运行");
}
}
}
首先声明了一个生产者 Producer通过 volatile 标记的初始值为 false 的布尔值 canceled 来停止线程而在 run() 方法中while 的判断语句是 num 是否小于 100000 canceled 是否被标记while 循环体中判断 num 如果是 50 的倍数就放到 storage 仓库中storage 是生产者与消费者之间进行通信的存储器 num 大于 100000 或被通知停止时会跳出 while 循环并执行 finally 语句块告诉大家生产者结束运行”。
class Consumer {
BlockingQueue storage;
public Consumer(BlockingQueue storage) {
this.storage = storage;
}
public boolean needMoreNums() {
if (Math.random() > 0.97) {
return false;
}
return true;
}
}
而对于消费者 Consumer它与生产者共用同一个仓库 storage并且在方法内通过 needMoreNums() 方法判断是否需要继续使用更多的数字,刚才生产者生产了一些 50 的倍数供消费者使用,消费者是否继续使用数字的判断条件是产生一个随机数并与 0.97 进行比较,大于 0.97 就不再继续使用数字。
public static void main(String[] args) throws InterruptedException {
ArrayBlockingQueue storage = new ArrayBlockingQueue(8);
Producer producer = new Producer(storage);
Thread producerThread = new Thread(producer);
producerThread.start();
Thread.sleep(500);
Consumer consumer = new Consumer(storage);
while (consumer.needMoreNums()) {
System.out.println(consumer.storage.take() + "被消费了");
Thread.sleep(100);
}
System.out.println("消费者不需要更多数据了。");
//一旦消费不需要更多数据了,我们应该让生产者也停下来,但是实际情况却停不下来
producer.canceled = true;
System.out.println(producer.canceled);
}
}
下面来看下 main 函数,首先创建了生产者/消费者共用的仓库 BlockingQueue storage仓库容量是 8并且建立生产者并将生产者放入线程后启动线程启动后进行 500 毫秒的休眠休眠时间保障生产者有足够的时间把仓库塞满而仓库达到容量后就不会再继续往里塞这时生产者会阻塞500 毫秒后消费者也被创建出来,并判断是否需要使用更多的数字,然后每次消费后休眠 100 毫秒,这样的业务逻辑是有可能出现在实际生产中的。
当消费者不再需要数据,就会将 canceled 的标记位设置为 true理论上此时生产者会跳出 while 循环,并打印输出“生产者运行结束”。
然而结果却不是我们想象的那样,尽管已经把 canceled 设置成 true但生产者仍然没有停止这是因为在这种情况下生产者在执行 storage.put(num) 时发生阻塞,在它被叫醒之前是没有办法进入下一次循环判断 canceled 的值的,所以在这种情况下用 volatile 是没有办法让生产者停下来的,相反如果用 interrupt 语句来中断,即使生产者处于阻塞状态,仍然能够感受到中断信号,并做响应处理。
总结
好了,本课时的内容就全部讲完了,我们来总结下学到了什么,首先学习了如何正确停止线程,其次是掌握了为什么说 volatile 修饰标记位停止方法是错误的。
如果我们在面试中被问到“你知不知道如何正确停止线程”这样的问题,我想你一定可以完美地回答了,首先,从原理上讲应该用 interrupt 来请求中断,而不是强制停止,因为这样可以避免数据错乱,也可以让线程有时间结束收尾工作。
如果我们是子方法的编写者,遇到了 interruptedException应该如何处理呢
我们可以把异常声明在方法中,以便顶层方法可以感知捕获到异常,或者也可以在 catch 中再次声明中断,这样下次循环也可以感知中断,所以要想正确停止线程就要求我们停止方,被停止方,子方法的编写者相互配合,大家都按照一定的规范来编写代码,就可以正确地停止线程了。
最后我们再来看下有哪些方法是不够好的,比如说已经被舍弃的 stop()、suspend() 和 resume(),它们由于有很大的安全风险比如死锁风险而被舍弃,而 volatile 这种方法在某些特殊的情况下,比如线程被长时间阻塞的情况,就无法及时感受中断,所以 volatile 是不够全面的停止线程的方法。

View File

@@ -0,0 +1,129 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
03 线程是如何在 6 种状态之间转换的?
本课时我们主要学习线程是如何在 6 种状态之间转换的。
线程的 6 种状态
就像生物从出生到长大、最终死亡的过程一样,线程也有自己的生命周期,在 Java 中线程的生命周期中一共有 6 种状态。
New新创建
Runnable可运行
Blocked被阻塞
Waiting等待
Timed Waiting计时等待
Terminated被终止
如果想要确定线程当前的状态,可以通过 getState() 方法,并且线程在任何时刻只可能处于 1 种状态。
New 新创建
下面我们逐个介绍线程的 6 种状态,如图所示,首先来看下左上角的 New 状态。
New 表示线程被创建但尚未启动的状态:当我们用 new Thread() 新建一个线程时,如果线程没有开始运行 start() 方法,所以也没有开始执行 run() 方法里面的代码,那么此时它的状态就是 New。而一旦线程调用了 start(),它的状态就会从 New 变成 Runnable也就是状态转换图中中间的这个大方框里的内容。
Runnable 可运行
Java 中的 Runable 状态对应操作系统线程状态中的两种状态,分别是 Running 和 Ready也就是说Java 中处于 Runnable 状态的线程有可能正在执行,也有可能没有正在执行,正在等待被分配 CPU 资源。
所以,如果一个正在运行的线程是 Runnable 状态,当它运行到任务的一半时,执行该线程的 CPU 被调度去做其他事情,导致该线程暂时不运行,它的状态依然不变,还是 Runnable因为它有可能随时被调度回来继续执行任务。
阻塞状态
接下来,我们来看下 Runnable 下面的三个方框,它们统称为阻塞状态,在 Java 中阻塞状态通常不仅仅是 Blocked实际上它包括三种状态分别是 Blocked(被阻塞、Waiting(等待、Timed Waiting(计时等待),这三 种状态统称为阻塞状态,下面我们来看看这三种状态具体是什么含义。
Blocked 被阻塞
首先来看最简单的 Blocked从箭头的流转方向可以看出从 Runnable 状态进入 Blocked 状态只有一种可能,就是进入 synchronized 保护的代码时没有抢到 monitor 锁,无论是进入 synchronized 代码块,还是 synchronized 方法,都是一样。
我们再往右看,当处于 Blocked 的线程抢到 monitor 锁,就会从 Blocked 状态回到Runnable 状态。
Waiting 等待
我们再看看 Waiting 状态,线程进入 Waiting 状态有三种可能性。
没有设置 Timeout 参数的 Object.wait() 方法。
没有设置 Timeout 参数的 Thread.join() 方法。
LockSupport.park() 方法。
刚才强调过Blocked 仅仅针对 synchronized monitor 锁,可是在 Java 中还有很多其他的锁,比如 ReentrantLock如果线程在获取这种锁时没有抢到该锁就会进入 Waiting 状态,因为本质上它执行了 LockSupport.park() 方法,所以会进入 Waiting 状态。同样Object.wait() 和 Thread.join() 也会让线程进入 Waiting 状态。
Blocked 与 Waiting 的区别是 Blocked 在等待其他线程释放 monitor 锁,而 Waiting 则是在等待某个条件,比如 join 的线程执行完毕,或者是 notify()/notifyAll() 。
Timed Waiting 限期等待
在 Waiting 上面是 Timed Waiting 状态这两个状态是非常相似的区别仅在于有没有时间限制Timed Waiting 会等待超时,由系统自动唤醒,或者在超时前被唤醒信号唤醒。
以下情况会让线程进入 Timed Waiting 状态。
设置了时间参数的 Thread.sleep(long millis) 方法;
设置了时间参数的 Object.wait(long timeout) 方法;
设置了时间参数的 Thread.join(long millis) 方法;
设置了时间参数的 LockSupport.parkNanos(long nanos) 方法和 LockSupport.parkUntil(long deadline) 方法。
讲完如何进入这三种状态,我们再来看下如何从这三种状态流转到下一个状态。
想要从 Blocked 状态进入 Runnable 状态,要求线程获取 monitor 锁,而从 Waiting 状态流转到其他状态则比较特殊,因为首先 Waiting 是不限时的,也就是说无论过了多长时间它都不会主动恢复。
只有当执行了 LockSupport.unpark(),或者 join 的线程运行结束,或者被中断时才可以进入 Runnable 状态。
如果其他线程调用 notify() 或 notifyAll()来唤醒它,它会直接进入 Blocked 状态,这是为什么呢?因为唤醒 Waiting 线程的线程如果调用 notify() 或 notifyAll(),要求必须首先持有该 monitor 锁,所以处于 Waiting 状态的线程被唤醒时拿不到该锁,就会进入 Blocked 状态,直到执行了 notify()/notifyAll() 的唤醒它的线程执行完毕并释放 monitor 锁,才可能轮到它去抢夺这把锁,如果它能抢到,就会从 Blocked 状态回到 Runnable 状态。
同样在 Timed Waiting 中执行 notify() 和 notifyAll() 也是一样的道理,它们会先进入 Blocked 状态,然后抢夺锁成功后,再回到 Runnable 状态。
当然对于 Timed Waiting 而言,如果它的超时时间到了且能直接获取到锁/join的线程运行结束/被中断/调用了LockSupport.unpark(),会直接恢复到 Runnable 状态,而无需经历 Blocked 状态。
Terminated 终止
再来看看最后一种状态Terminated 终止状态,要想进入这个状态有两种可能。
run() 方法执行完毕,线程正常退出。
出现一个没有捕获的异常,终止了 run() 方法,最终导致意外终止。
注意点
最后我们再看线程转换的两个注意点。
线程的状态是需要按照箭头方向来走的,比如线程从 New 状态是不可以直接进入 Blocked 状态的,它需要先经历 Runnable 状态。
线程生命周期不可逆:一旦进入 Runnable 状态就不能回到 New 状态;一旦被终止就不可能再有任何状态的变化。所以一个线程只能有一次 New 和 Terminated 状态,只有处于中间状态才可以相互转换。

View File

@@ -0,0 +1,160 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
04 waitnotifynotifyAll 方法的使用注意事项?
本课时我们主要学习 wait/notify/notifyAll 方法的使用注意事项。
我们主要从三个问题入手:
为什么 wait 方法必须在 synchronized 保护的同步代码中使用?
为什么 wait/notify/notifyAll 被定义在 Object 类中,而 sleep 定义在 Thread 类中?
wait/notify 和 sleep 方法的异同?
为什么 wait 必须在 synchronized 保护的同步代码中使用?
首先,我们来看第一个问题,为什么 wait 方法必须在 synchronized 保护的同步代码中使用?
我们先来看看 wait 方法的源码注释是怎么写的。
“wait method should always be used in a loop:
synchronized (obj) {
while (condition does not hold)
obj.wait();
... // Perform action appropriate to condition
}
This method should only be called by a thread that is the owner of this objects monitor.”
英文部分的意思是说,在使用 wait 方法时,必须把 wait 方法写在 synchronized 保护的 while 代码块中,并始终判断执行条件是否满足,如果满足就往下继续执行,如果不满足就执行 wait 方法,而在执行 wait 方法之前,必须先持有对象的 monitor 锁,也就是通常所说的 synchronized 锁。那么设计成这样有什么好处呢?
我们逆向思考这个问题,如果不要求 wait 方法放在 synchronized 保护的同步代码中使用,而是可以随意调用,那么就有可能写出这样的代码。
class BlockingQueue {
Queue<String> buffer = new LinkedList<String>();
public void give(String data) {
buffer.add(data);
notify(); // Since someone may be waiting in take
}
public String take() throws InterruptedException {
while (buffer.isEmpty()) {
wait();
}
return buffer.remove();
}
}
在代码中可以看到有两个方法give 方法负责往 buffer 中添加数据,添加完之后执行 notify 方法来唤醒之前等待的线程,而 take 方法负责检查整个 buffer 是否为空,如果为空就进入等待,如果不为空就取出一个数据,这是典型的生产者消费者的思想。
但是这段代码并没有受 synchronized 保护,于是便有可能发生以下场景:
首先,消费者线程调用 take 方法并判断 buffer.isEmpty 方法是否返回 true若为 true 代表buffer是空的则线程希望进入等待但是在线程调用 wait 方法之前,就被调度器暂停了,所以此时还没来得及执行 wait 方法。
此时生产者开始运行,执行了整个 give 方法,它往 buffer 中添加了数据,并执行了 notify 方法,但 notify 并没有任何效果,因为消费者线程的 wait 方法没来得及执行,所以没有线程在等待被唤醒。
此时,刚才被调度器暂停的消费者线程回来继续执行 wait 方法并进入了等待。
虽然刚才消费者判断了 buffer.isEmpty 条件,但真正执行 wait 方法时,之前的 buffer.isEmpty 的结果已经过期了,不再符合最新的场景了,因为这里的“判断-执行”不是一个原子操作,它在中间被打断了,是线程不安全的。
假设这时没有更多的生产者进行生产,消费者便有可能陷入无穷无尽的等待,因为它错过了刚才 give 方法内的 notify 的唤醒。
我们看到正是因为 wait 方法所在的 take 方法没有被 synchronized 保护,所以它的 while 判断和 wait 方法无法构成原子操作,那么此时整个程序就很容易出错。
我们把代码改写成源码注释所要求的被 synchronized 保护的同步代码块的形式,代码如下。
public void give(String data) {
synchronized (this) {
buffer.add(data);
notify();
}
}
public String take() throws InterruptedException {
synchronized (this) {
while (buffer.isEmpty()) {
wait();
}
return buffer.remove();
}
}
这样就可以确保 notify 方法永远不会在 buffer.isEmpty 和 wait 方法之间被调用,提升了程序的安全性。
另外wait 方法会释放 monitor 锁,这也要求我们必须首先进入到 synchronized 内持有这把锁。
这里还存在一个“虚假唤醒”spurious wakeup的问题线程可能在既没有被notify/notifyAll也没有被中断或者超时的情况下被唤醒这种唤醒是我们不希望看到的。虽然在实际生产中虚假唤醒发生的概率很小但是程序依然需要保证在发生虚假唤醒的时候的正确性所以就需要采用while循环的结构。
while (condition does not hold)
obj.wait();
这样即便被虚假唤醒了也会再次检查while里面的条件如果不满足条件就会继续wait也就消除了虚假唤醒的风险。
为什么 wait/notify/notifyAll 被定义在 Object 类中,而 sleep 定义在 Thread 类中?
我们来看第二个问题,为什么 wait/notify/notifyAll 方法被定义在 Object 类中?而 sleep 方法定义在 Thread 类中?主要有两点原因:
因为 Java 中每个对象都有一把称之为 monitor 监视器的锁由于每个对象都可以上锁这就要求在对象头中有一个用来保存锁信息的位置。这个锁是对象级别的而非线程级别的wait/notify/notifyAll 也都是锁级别的操作,它们的锁属于对象,所以把它们定义在 Object 类中是最合适,因为 Object 类是所有对象的父类。
因为如果把 wait/notify/notifyAll 方法定义在 Thread 类中,会带来很大的局限性,比如一个线程可能持有多把锁,以便实现相互配合的复杂逻辑,假设此时 wait 方法定义在 Thread 类中,如何实现让一个线程持有多把锁呢?又如何明确线程等待的是哪把锁呢?既然我们是让当前线程去等待某个对象的锁,自然应该通过操作对象来实现,而不是操作线程。
wait/notify 和 sleep 方法的异同?
第三个问题是对比 wait/notify 和 sleep 方法的异同,主要对比 wait 和 sleep 方法,我们先说相同点:
它们都可以让线程阻塞。
它们都可以响应 interrupt 中断:在等待的过程中如果收到中断信号,都可以进行响应,并抛出 InterruptedException 异常。
但是它们也有很多的不同点:
wait 方法必须在 synchronized 保护的代码中使用,而 sleep 方法并没有这个要求。
在同步代码中执行 sleep 方法时,并不会释放 monitor 锁,但执行 wait 方法时会主动释放 monitor 锁。
sleep 方法中会要求必须定义一个时间,时间到期后会主动恢复,而对于没有参数的 wait 方法而言,意味着永久等待,直到被中断或被唤醒才能恢复,它并不会主动恢复。
wait/notify 是 Object 类的方法,而 sleep 是 Thread 类的方法。
以上就是关于 wait/notify 与 sleep 的异同点。

View File

@@ -0,0 +1,267 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
06 一共有哪 3 类线程安全问题?
本课时我们学习 3 类线程安全问题。
什么是线程安全
要想弄清楚有哪 3 类线程安全问题,首先需要了解什么是线程安全,线程安全经常在工作中被提到,比如:你的对象不是线程安全的,你的线程发生了安全错误,虽然线程安全经常被提到,但我们可能对线程安全并没有一个明确的定义。
《Java Concurrency In Practice》的作者 Brian Goetz 对线程安全是这样理解的,当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行问题,也不需要进行额外的同步,而调用这个对象的行为都可以获得正确的结果,那这个对象便是线程安全的。
事实上Brian Goetz 想表达的意思是,如果某个对象是线程安全的,那么对于使用者而言,在使用时就不需要考虑方法间的协调问题,比如不需要考虑不能同时写入或读写不能并行的问题,也不需要考虑任何额外的同步问题,比如不需要额外自己加 synchronized 锁,那么它才是线程安全的,可以看出对线程安全的定义还是非常苛刻的。
而我们在实际开发中经常会遇到线程不安全的情况,那么一共有哪 3 种典型的线程安全问题呢?
运行结果错误;
发布和初始化导致线程安全问题;
活跃性问题。
运行结果错误
首先,来看多线程同时操作一个变量导致的运行结果错误。
public class WrongResult {
volatile static int i;
public static void main(String[] args) throws InterruptedException {
Runnable r = new Runnable() {
@Override
public void run() {
for (int j = 0; j < 10000; j++) {
i++;
}
}
};
Thread thread1 = new Thread(r);
thread1.start();
Thread thread2 = new Thread(r);
thread2.start();
thread1.join();
thread2.join();
System.out.println(i);
}
}
如代码所示首先定义了一个 int 类型的静态变量 i然后启动两个线程分别对变量 i 进行 10000 i++ 操作理论上得到的结果应该是 20000但实际结果却远小于理论结果比如可能是12996也可能是13323每次的结果都还不一样这是为什么呢
是因为在多线程下CPU 的调度是以时间片为单位进行分配的每个线程都可以得到一定量的时间片但如果线程拥有的时间片耗尽它将会被暂停执行并让出 CPU 资源给其他线程这样就有可能发生线程安全问题比如 i++ 操作表面上看只是一行代码但实际上它并不是一个原子操作它的执行步骤主要分为三步而且在每步操作之间都有可能被打断
第一个步骤是读取
第二个步骤是增加
第三个步骤是保存
那么我们接下来看如何发生的线程不安全问题
我们根据箭头指向依次看线程 1 首先拿到 i=1 的结果然后进行 i+1 操作但此时 i+1 的结果并没有保存下来线程 1 就被切换走了于是 CPU 开始执行线程 2它所做的事情和线程 1 是一样的 i++ 操作但此时我们想一下它拿到的 i 是多少实际上和线程 1 拿到的 i 的结果一样都是 1为什么呢因为线程 1 虽然对 i 进行了 +1 操作但结果没有保存所以线程 2 看不到修改后的结果
然后假设等线程 2 i 进行 +1 操作后又切换到线程 1让线程 1 完成未完成的操作即将 i+1 的结果 2 保存下来然后又切换到线程 2 完成 i=2 的保存操作虽然两个线程都执行了对 i 进行 +1 的操作但结果却最终保存了 i=2 的结果而不是我们期望的 i=3这样就发生了线程安全问题导致了数据结果错误这也是最典型的线程安全问题。
发布和初始化导致线程安全问题
第二种是对象发布和初始化时导致的线程安全问题我们创建对象并进行发布和初始化供其他类或对象使用是常见的操作但如果我们操作的时间或地点不对就可能导致线程安全问题如代码所示
public class WrongInit {
private Map<Integer, String> students;
public WrongInit() {
new Thread(new Runnable() {
@Override
public void run() {
students = new HashMap<>();
students.put(1, "王小美");
students.put(2, "钱二宝");
students.put(3, "周三");
students.put(4, "赵四");
}
}).start();
}
public Map<Integer, String> getStudents() {
return students;
}
public static void main(String[] args) throws InterruptedException {
WrongInit multiThreadsError6 = new WrongInit();
System.out.println(multiThreadsError6.getStudents().get(1));
}
}
在类中,定义一个类型为 Map 的成员变量 studentsInteger 是学号String 是姓名。然后在构造函数中启动一个新线程,并在线程中为 students 赋值。
学号1姓名王小美
学号2姓名钱二宝
学号3姓名周三
学号4姓名赵四。
只有当线程运行完 run() 方法中的全部赋值操作后4 名同学的全部信息才算是初始化完毕,可是我们看在主函数 mian() 中,初始化 WrongInit 类之后并没有进行任何休息就直接打印 1 号同学的信息,试想这个时候程序会出现什么情况?实际上会发生空指针异常。
Exception in thread "main" java.lang.NullPointerException
at lesson6.WrongInit.main(WrongInit.java:32)
这又是为什么呢?因为 students 这个成员变量是在构造函数中新建的线程中进行的初始化和赋值操作,而线程的启动需要一定的时间,但是我们的 main 函数并没有进行等待就直接获取数据,导致 getStudents 获取的结果为 null这就是在错误的时间或地点发布或初始化造成的线程安全问题。
活跃性问题
第三种线程安全问题统称为活跃性问题,最典型的有三种,分别为死锁、活锁和饥饿。
什么是活跃性问题呢,活跃性问题就是程序始终得不到运行的最终结果,相比于前面两种线程安全问题带来的数据错误或报错,活跃性问题带来的后果可能更严重,比如发生死锁会导致程序完全卡死,无法向下运行。
死锁
最常见的活跃性问题是死锁,死锁是指两个线程之间相互等待对方资源,但同时又互不相让,都想自己先执行,如代码所示。
public class MayDeadLock {
Object o1 = new Object();
Object o2 = new Object();
public void thread1() throws InterruptedException {
synchronized (o1) {
Thread.sleep(500);
synchronized (o2) {
System.out.println("线程1成功拿到两把锁");
}
}
}
public void thread2() throws InterruptedException {
synchronized (o2) {
Thread.sleep(500);
synchronized (o1) {
System.out.println("线程2成功拿到两把锁");
}
}
}
public static void main(String[] args) {
MayDeadLock mayDeadLock = new MayDeadLock();
new Thread(new Runnable() {
@Override
public void run() {
try {
mayDeadLock.thread1();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}).start();
new Thread(new Runnable() {
@Override
public void run() {
try {
mayDeadLock.thread2();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}).start();
}
}
首先,代码中创建了两个 Object 作为 synchronized 锁的对象,线程 1 先获取 o1 锁sleep(500) 之后,获取 o2 锁;线程 2 与线程 1 执行顺序相反,先获取 o2 锁sleep(500) 之后,获取 o1 锁。 假设两个线程几乎同时进入休息,休息完后,线程 1 想获取 o2 锁,线程 2 想获取 o1 锁,这时便发生了死锁,两个线程不主动调和,也不主动退出,就这样死死地等待对方先释放资源,导致程序得不到任何结果也不能停止运行。
活锁
第二种活跃性问题是活锁,活锁与死锁非常相似,也是程序一直等不到结果,但对比于死锁,活锁是活的,什么意思呢?因为正在运行的线程并没有阻塞,它始终在运行中,却一直得不到结果。
举一个例子,假设有一个消息队列,队列里放着各种各样需要被处理的消息,而某个消息由于自身被写错了导致不能被正确处理,执行时会报错,可是队列的重试机制会重新把它放在队列头进行优先重试处理,但这个消息本身无论被执行多少次,都无法被正确处理,每次报错后又会被放到队列头进行重试,周而复始,最终导致线程一直处于忙碌状态,但程序始终得不到结果,便发生了活锁问题。
饥饿
第三个典型的活跃性问题是饥饿饥饿是指线程需要某些资源时始终得不到尤其是CPU 资源,就会导致线程一直不能运行而产生的问题。在 Java 中有线程优先级的概念Java 中优先级分为 1 到 101 最低10 最高。如果我们把某个线程的优先级设置为 1这是最低的优先级在这种情况下这个线程就有可能始终分配不到 CPU 资源,而导致长时间无法运行。或者是某个线程始终持有某个文件的锁,而其他线程想要修改文件就必须先获取锁,这样想要修改文件的线程就会陷入饥饿,长时间不能运行。
好了,今天的内容就全部讲完了,通过本课时的学习我们知道了线程安全问题主要有 3 种i++ 等情况导致的运行结果错误,通常是因为并发读写导致的,第二种是对象没有在正确的时间、地点被发布或初始化,而第三种线程安全问题就是活跃性问题,包括死锁、活锁和饥饿。

View File

@@ -0,0 +1,107 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
07 哪些场景需要额外注意线程安全问题?
在本课时我们主要学习哪些场景需要额外注意线程安全问题,在这里总结了四种场景。
访问共享变量或资源
第一种场景是访问共享变量或共享资源的时候,典型的场景有访问共享对象的属性,访问 static 静态变量,访问共享的缓存,等等。因为这些信息不仅会被一个线程访问到,还有可能被多个线程同时访问,那么就有可能在并发读写的情况下发生线程安全问题。比如我们上一课时讲过的多线程同时 i++ 的例子:
/**
* 描述: 共享的变量或资源带来的线程安全问题
*/
public class ThreadNotSafe1 {
static int i;
public static void main(String[] args) throws InterruptedException {
Runnable r = new Runnable() {
@Override
public void run() {
for (int j = 0; j < 10000; j++) {
i++;
}
}
};
Thread thread1 = new Thread(r);
Thread thread2 = new Thread(r);
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println(i);
}
}
如代码所示两个线程同时对 i 进行 i++ 操作最后的输出可能是 15875 等小于20000的数而不是我们期待的20000这便是非常典型的共享变量带来的线程安全问题
依赖时序的操作
第二个需要我们注意的场景是依赖时序的操作如果我们操作的正确性是依赖时序的而在多线程的情况下又不能保障执行的顺序和我们预想的一致这个时候就会发生线程安全问题如下面的代码所示
if (map.containsKey(key)) {
map.remove(obj)
}
代码中首先检查 map 中有没有 key 对应的元素如果有则继续执行 remove 操作此时这个组合操作就是危险的因为它是先检查后操作而执行过程中可能会被打断如果此时有两个线程同时进入 if() 语句然后它们都检查到存在 key 对应的元素于是都希望执行下面的 remove 操作随后一个线程率先把 obj 给删除了而另外一个线程它刚已经检查过存在 key 对应的元素if 条件成立所以它也会继续执行删除 obj 的操作但实际上集合中的 obj 已经被前面的线程删除了这种情况下就可能导致线程安全问题
类似的情况还有很多比如我们先检查 x=1如果 x=1 就修改 x 的值代码如下所示
if (x == 1) {
x = 7 * x;
}
这样类似的场景都是同样的道理,“检查与执行并非原子性操作在中间可能被打断而检查之后的结果也可能在执行时已经过期无效换句话说获得正确结果取决于幸运的时序这种情况下我们就需要对它进行加锁等保护措施来保障操作的原子性
不同数据之间存在绑定关系
第三种需要我们注意的线程安全场景是不同数据之间存在相互绑定关系的情况有时候我们的不同数据之间是成组出现的存在着相互对应或绑定的关系最典型的就是 IP 和端口号有时候我们更换了 IP往往需要同时更换端口号如果没有把这两个操作绑定在一起就有可能出现单独更换了 IP 或端口号的情况而此时信息如果已经对外发布信息获取方就有可能获取一个错误的 IP 与端口绑定情况这时就发生了线程安全问题在这种情况下我们也同样需要保障操作的原子性
对方没有声明自己是线程安全的
第四种值得注意的场景是在我们使用其他类时如果对方没有声明自己是线程安全的那么这种情况下对其他类进行多线程的并发操作就有可能会发生线程安全问题举个例子比如说我们定义了 ArrayList它本身并不是线程安全的如果此时多个线程同时对 ArrayList 进行并发读/那么就有可能会产生线程安全问题造成数据出错而这个责任并不在 ArrayList因为它本身并不是并发安全的正如源码注释所写的
Note that this implementation is not synchronized. If multiple threads
access an ArrayList instance concurrently, and at least one of the threads
modifies the list structurally, it must be synchronized externally.
这段话的意思是说如果我们把 ArrayList 用在了多线程的场景需要在外部手动用 synchronized 等方式保证并发安全
所以 ArrayList 默认不适合并发读写是我们错误地使用了它导致了线程安全问题所以我们在使用其他类时如果会涉及并发场景那么一定要首先确认清楚对方是否支持并发操作以上就是四种需要我们额外注意线程安全问题的场景分别是访问共享变量或资源依赖时序的操作不同数据之间存在绑定关系以及对方没有声明自己是线程安全的

View File

@@ -0,0 +1,37 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
08 为什么多线程会带来性能问题?
在本课时我们主要学习为什么多线程会带来性能问题?
什么是性能问题
在上一课时我们已经学习了多线程带来的线程安全问题,但对于多线程而言,它不仅可能会带来线程安全问题,还有可能会带来性能问题,也许你会奇怪,我们使用多线程的最大目的不就是为了提高性能吗?让多个线程同时工作,加快程序运行速度,为什么反而会带来性能问题呢?这是因为单线程程序是独立工作的,不需要与其他线程进行交互,但多线程之间则需要调度以及合作,调度与合作就会带来性能开销从而产生性能问题。
首先,我们来了解究竟什么是性能问题?其实性能问题有许多的表现形式,比如服务器的响应慢、吞吐量低、内存占用过多就属于性能问题。我们设计优秀的系统架构、购置更多的 CDN 服务器、购买更大的带宽等都是为了提高性能,提高用户体验,虽然运行速度慢不会带来严重的后果,通常只需要我们多等几秒就可以,但这会严重影响用户的体验。有研究表明,页面每多响应 1 秒,就会流失至少 7% 的用户,而超过 8 秒无法返回结果的话,几乎所有用户都不会选择继续等待。我们引入多线程的一大重要原因就是想提高程序性能,所以不能本末倒置,不能因为引入了多线程反而程序运行得更慢了,所以我们必须要解决多线程带来的性能问题。
为什么多线程会带来性能问题
那么什么情况下多线程编程会带来性能问题呢?主要有两个方面,一方面是线程调度,另一个方面是线程协作。
调度开销
上下文切换
首先,我们看一下线程调度,在实际开发中,线程数往往是大于 CPU 核心数的,比如 CPU 核心数可能是 8 核、16 核,等等,但线程数可能达到成百上千个。这种情况下,操作系统就会按照一定的调度算法,给每个线程分配时间片,让每个线程都有机会得到运行。而在进行调度时就会引起上下文切换,上下文切换会挂起当前正在执行的线程并保存当前的状态,然后寻找下一处即将恢复执行的代码,唤醒下一个线程,以此类推,反复执行。但上下文切换带来的开销是比较大的,假设我们的任务内容非常短,比如只进行简单的计算,那么就有可能发生我们上下文切换带来的性能开销比执行线程本身内容带来的开销还要大的情况。
缓存失效
不仅上下文切换会带来性能问题缓存失效也有可能带来性能问题。由于程序有很大概率会再次访问刚才访问过的数据所以为了加速整个程序的运行会使用缓存这样我们在使用相同数据时就可以很快地获取数据。可一旦进行了线程调度切换到其他线程CPU就会去执行不同的代码原有的缓存就很可能失效了需要重新缓存新的数据这也会造成一定的开销所以线程调度器为了避免频繁地发生上下文切换通常会给被调度到的线程设置最小的执行时间也就是只有执行完这段时间之后才可能进行下一次的调度由此减少上下文切换的次数。
那么什么情况会导致密集的上下文切换呢?如果程序频繁地竞争锁,或者由于 IO 读写等原因导致频繁阻塞,那么这个程序就可能需要更多的上下文切换,这也就导致了更大的开销,我们应该尽量避免这种情况的发生。
协作开销
除了线程调度之外,线程协作同样也有可能带来性能问题。因为线程之间如果有共享数据,为了避免数据错乱,为了保证线程安全,就有可能禁止编译器和 CPU 对其进行重排序等优化,也可能出于同步的目的,反复把线程工作内存的数据 flush 到主存中,然后再从主内存 refresh 到其他线程的工作内存中,等等。这些问题在单线程中并不存在,但在多线程中为了确保数据的正确性,就不得不采取上述方法,因为线程安全的优先级要比性能优先级更高,这也间接降低了我们的性能。

View File

@@ -0,0 +1,229 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
09 使用线程池比手动创建线程好在哪里?
在本课时我们主要学习为什么使用线程池比手动创建线程要好,并讲解具体好在哪里?
为什么要使用线程池
首先,回顾线程池的相关知识,在 Java 诞生之初是没有线程池的概念的,而是先有线程,随着线程数的不断增加,人们发现需要一个专门的类来管理它们,于是才诞生了线程池。没有线程池的时候,每发布一个任务就需要创建一个新的线程,这样在任务少时是没有问题的,如代码所示。
/**
* 描述: 单个任务的时候,新建线程来执行
*/
public class OneTask {
public static void main(String[] args) {
Thread thread0 = new Thread(new Task());
thread0.start();
}
static class Task implements Runnable {
public void run() {
System.out.println("Thread Name: " + Thread.currentThread().getName());
}
}
}
在这段代码中,我们发布了一个新的任务并放入子线程中,然后启动子线程执行任务,这时的任务也非常简单,只是打印出当前线程的名字,这种情况下,打印结果显示 Thread Name: Thread-0即我们当前子线程的默认名字。
我们来看一下任务执行流程,如图所示,主线程调用 start() 方法,启动了一个 t0 的子线程。这是在一个任务的场景下,随着我们的任务增多,比如现在有 10 个任务了,那么我们就可以使用 for 循环新建 10 个子线程,如代码所示。
/**
* 描述: for循环新建10个线程
*/
public class TenTask {
public static void main(String[] args) {
for (int i = 0; i < 10; i++) {
Thread thread = new Thread(new Task());
thread.start();
}
}
static class Task implements Runnable {
public void run() {
System.out.println("Thread Name: " + Thread.currentThread().getName());
}
}
}
执行结果
Thread Name: Thread-1
Thread Name: Thread-4
Thread Name: Thread-3
Thread Name: Thread-2
Thread Name: Thread-0
Thread Name: Thread-5
Thread Name: Thread-6
Thread Name: Thread-7
Thread Name: Thread-8
Thread Name: Thread-9
这里你会发现打印出来的顺序是错乱的比如 Thread-4 打印在了 Thread-3 之前这是因为虽然 Thread-3 Thread-4 先执行 start 方法但是这并不代表 Thread-3 就会先运行运行的顺序取决于线程调度器有很大的随机性这是需要我们注意的地方
我们再看来下线程的执行流程如图所示主线程通过 for 循环创建了 t0~t9 10 个子线程它们都可以正常的执行任务但如果此时我们的任务量突然飙升到 10000 会怎么样我们先来看看依然用 for 循环的实现方式
for (int i = 0; i < 10000; i++) {
Thread thread = new Thread(new Task());
thread.start();
}
如图所示我们创建了 10000 个子线程 Java 程序中的线程与操作系统中的线程是一一对应的此时假设线程中的任务需要一定的耗时才能够完成便会产生很大的系统开销与资源浪费
创建线程时会产生系统开销并且每个线程还会占用一定的内存等资源更重要的是我们创建如此多的线程也会给稳定性带来危害因为每个系统中可创建线程的数量是有一个上限的不可能无限的创建线程执行完需要被回收大量的线程又会给垃圾回收带来压力但我们的任务确实非常多如果都在主线程串行执行那效率也太低了那应该怎么办呢于是便诞生了线程池来平衡线程与系统资源之间的关系
我们来总结下如果每个任务都创建一个线程会带来哪些问题
第一点反复创建线程系统开销比较大每个线程创建和销毁都需要时间如果任务比较简单那么就有可能导致创建和销毁线程消耗的资源比线程执行任务本身消耗的资源还要大
第二点过多的线程会占用过多的内存等资源还会带来过多的上下文切换同时还会导致系统不稳定
线程池解决问题思路
针对上面的两点问题线程池有两个解决思路
首先针对反复创建线程开销大的问题线程池用一些固定的线程一直保持工作状态并反复执行任务
其次针对过多线程占用太多内存资源的问题解决思路更直接线程池会根据需要创建线程控制线程的总数量避免占用过多内存资源
如何使用线程池
线程池就好比一个池塘池塘里的水是有限且可控的比如我们选择线程数固定数量的线程池假设线程池有 5 个线程但此时的任务大于 5 线程池会让余下的任务进行排队而不是无限制的扩张线程数量保障资源不会被过度消耗如代码所示我们往 5 个线程的线程池中放入 10000 个任务并打印当前线程名字结果会是怎么样呢
/**
* 描述 用固定线程数的线程池执行10000个任务
*/
public class ThreadPoolDemo {
public static void main(String[] args) {
ExecutorService service = Executors.newFixedThreadPool(5);
for (int i = 0; i < 10000; i++) {
service.execute(new Task());
}
System.out.println(Thread.currentThread().getName());
}
static class Task implements Runnable {
public void run() {
System.out.println("Thread Name: " + Thread.currentThread().getName());
}
}
}
执行效果
Thread Name: pool-1-thread-1
Thread Name: pool-1-thread-2
Thread Name: pool-1-thread-3
Thread Name: pool-1-thread-4
Thread Name: pool-1-thread-5
Thread Name: pool-1-thread-5
Thread Name: pool-1-thread-5
Thread Name: pool-1-thread-5
Thread Name: pool-1-thread-5
Thread Name: pool-1-thread-2
Thread Name: pool-1-thread-1
Thread Name: pool-1-thread-5
Thread Name: pool-1-thread-3
Thread Name: pool-1-thread-5
...
如打印结果所示打印的线程名始终在 Thread Name: pool-1-thread-1~5 之间变化并没有超过这个范围也就证明了线程池不会无限制地扩张线程的数量始终是这5个线程在工作
执行流程如图所示首先创建了一个线程池线程池中有 5 个线程然后线程池将 10000 个任务分配给这 5 个线程 5 个线程反复领取任务并执行直到所有任务执行完毕这就是线程池的思想
使用线程池的好处
使用线程池比手动创建线程主要有三点好处
第一点线程池可以解决线程生命周期的系统开销问题同时还可以加快响应速度因为线程池中的线程是可以复用的我们只用少量的线程去执行大量的任务这就大大减小了线程生命周期的开销而且线程通常不是等接到任务后再临时创建而是已经创建好时刻准备执行任务这样就消除了线程创建所带来的延迟提升了响应速度增强了用户体验
第二点线程池可以统筹内存和 CPU 的使用避免资源使用不当线程池会根据配置和任务数量灵活地控制线程数量不够的时候就创建太多的时候就回收避免线程过多导致内存溢出或线程太少导致 CPU 资源浪费达到了一个完美的平衡
第三点线程池可以统一管理资源比如线程池可以统一管理任务队列和线程可以统一开始或结束任务比单个线程逐一处理任务要更方便更易于管理同时也有利于数据统计比如我们可以很方便地统计出已经执行过的任务的数量

View File

@@ -0,0 +1,60 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
10 线程池的各个参数的含义?
本课时我们主要学习线程池各个参数的含义,并重点掌握线程池中线程是在什么时机被创建和销毁的。
线程池的参数
首先,我们来看下线程池中各个参数的含义,如表所示线程池主要有 6 个参数,其中第 3 个参数由 keepAliveTime + 时间单位组成。我们逐一看下它们各自的含义corePoolSize 是核心线程数,也就是常驻线程池的线程数量,与它对应的是 maximumPoolSize表示线程池最大线程数量当我们的任务特别多而 corePoolSize 核心线程数无法满足需求的时候,就会向线程池中增加线程,以便应对任务突增的情况。
线程创建的时机
接下来,我们来具体看下这两个参数所代表的含义,以及线程池中创建线程的时机。如上图所示,当提交任务后,线程池首先会检查当前线程数,如果此时线程数小于核心线程数,比如最开始线程数量为 0则新建线程并执行任务随着任务的不断增加线程数会逐渐增加并达到核心线程数此时如果仍有任务被不断提交就会被放入 workQueue 任务队列中,等待核心线程执行完当前任务后重新从 workQueue 中提取正在等待被执行的任务。
此时,假设我们的任务特别的多,已经达到了 workQueue 的容量上限,这时线程池就会启动后备力量,也就是 maximumPoolSize 最大线程数,线程池会在 corePoolSize 核心线程数的基础上继续创建线程来执行任务,假设任务被不断提交,线程池会持续创建线程直到线程数达到 maximumPoolSize 最大线程数,如果依然有任务被提交,这就超过了线程池的最大处理能力,这个时候线程池就会拒绝这些任务,我们可以看到实际上任务进来之后,线程池会逐一判断 corePoolSize、workQueue、maximumPoolSize如果依然不能满足需求则会拒绝任务。
corePoolSize 与 maximumPoolSize
通过上面的流程图,我们了解了 corePoolSize 和 maximumPoolSize 的具体含义corePoolSize 指的是核心线程数,线程池初始化时线程数默认为 0当有新的任务提交后会创建新线程执行任务如果不做特殊设置此后线程数通常不会再小于 corePoolSize ,因为它们是核心线程,即便未来可能没有可执行的任务也不会被销毁。随着任务量的增加,在任务队列满了之后,线程池会进一步创建新线程,最多可以达到 maximumPoolSize 来应对任务多的场景,如果未来线程有空闲,大于 corePoolSize 的线程会被合理回收。所以正常情况下,线程池中的线程数量会处在 corePoolSize 与 maximumPoolSize 的闭区间内。
“长工”与“临时工”
我们可以把 corePoolSize 与 maximumPoolSize 比喻成长工与临时工,通常古代一个大户人家会有几个固定的长工,负责日常的工作,而大户人家起初肯定也是从零开始雇佣长工的。假如长工数量被老爷设定为 5 人,也就对应了 corePoolSize不管这 5 个长工是忙碌还是空闲,都会一直在大户人家待着,可到了农忙或春节,长工的人手显然就不够用了,这时就需要雇佣更多的临时工,这些临时工就相当于在 corePoolSize 的基础上继续创建新线程,但临时工也是有上限的,也就对应了 maximumPoolSize随着农忙或春节结束老爷考虑到人工成本便会解约掉这些临时工家里工人数量便会从 maximumPoolSize 降到 corePoolSize所以老爷家的工人数量会一致保持在 corePoolSize 和 maximumPoolSize 的区间。
在这里我们用一个动画把整个线程池变化过程生动地描述出来,比如线程池的 corePoolSize 为 5maximumPoolSize 为 10任务队列容量为 100随着任务被提交我们的线程数量会从 0 慢慢增长到 5然后就不再增长了新的任务会被放入队列中直到队列被塞满然后在 corePoolSize 的基础上继续创建新线程来执行队列中的任务,线程会逐渐增加到 maximumPoolSize 然后线程数不再增加,如果此时仍有任务被不断提交,线程池就会拒绝任务。随着队列中任务被执行完,被创建的 10 个线程现在无事可做了,这时线程池会根据 keepAliveTime 参数来销毁线程,已达到减少内存占用的目的。
通过对流程图的理解和动画演示,我们总结出线程池的几个特点。
线程池希望保持较少的线程数,并且只有在负载变得很大时才增加线程。
线程池只有在任务队列填满时才创建多于 corePoolSize 的线程,如果使用的是无界队列(例如 LinkedBlockingQueue那么由于队列不会满所以线程数不会超过 corePoolSize。
通过设置 corePoolSize 和 maximumPoolSize 为相同的值,就可以创建固定大小的线程池。
通过设置 maximumPoolSize 为很高的值,例如 Integer.MAX_VALUE就可以允许线程池创建任意多的线程。
keepAliveTime+时间单位
第三个参数是 keepAliveTime + 时间单位,当线程池中线程数量多于核心线程数时,而此时又没有任务可做,线程池就会检测线程的 keepAliveTime如果超过规定的时间无事可做的线程就会被销毁以便减少内存的占用和资源消耗。如果后期任务又多了起来线程池也会根据规则重新创建线程所以这是一个可伸缩的过程比较灵活我们也可以用 setKeepAliveTime 方法动态改变 keepAliveTime 的参数值。
ThreadFactory
第四个参数是 ThreadFactoryThreadFactory 实际上是一个线程工厂,它的作用是生产线程以便执行任务。我们可以选择使用默认的线程工厂,创建的线程都会在同一个线程组,并拥有一样的优先级,且都不是守护线程,我们也可以选择自己定制线程工厂,以方便给线程自定义命名,不同的线程池内的线程通常会根据具体业务来定制不同的线程名。
workQueue 和 Handler
最后两个参数是 workQueue 和 Handler它们分别对应阻塞队列和任务拒绝策略在后面的课时会对它们进行详细展开讲解。
在本课时,介绍了线程池的各个参数的含义,以及如果有任务提交,线程池是如何应对的,新线程是在什么时机下被创建和销毁等内容,你有没有觉得线程池的设计很巧妙呢?

View File

@@ -0,0 +1,53 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
11 线程池有哪 4 种拒绝策略?
本课时我们主要学习线程池有哪 4 种默认的拒绝策略。
拒绝时机
首先,新建线程池时可以指定它的任务拒绝策略,例如:
newThreadPoolExecutor(5, 10, 5, TimeUnit.SECONDS, new LinkedBlockingQueue<>(),
new ThreadPoolExecutor.DiscardOldestPolicy());
以便在必要的时候按照我们的策略来拒绝任务,那么拒绝任务的时机是什么呢?线程池会在以下两种情况下会拒绝新提交的任务。
第一种情况是当我们调用 shutdown 等方法关闭线程池后,即便此时可能线程池内部依然有没执行完的任务正在执行,但是由于线程池已经关闭,此时如果再向线程池内提交任务,就会遭到拒绝。
第二种情况是线程池没有能力继续处理新提交的任务,也就是工作已经非常饱和的时候。
我们具体讲一下第二种情况,也就是由于工作饱和导致的拒绝。比如新建一个线程池,使用容量上限为 10 的 ArrayBlockingQueue 作为任务队列,并且指定线程池的核心线程数为 5最大线程数为 10假设此时有 20 个耗时任务被提交在这种情况下线程池会首先创建核心数量的线程也就是5个线程来执行任务然后往队列里去放任务队列的 10 个容量被放满了之后,会继续创建新线程,直到达到最大线程数 10。此时线程池中一共有 20 个任务,其中 10 个任务正在被 10 个线程执行,还有 10 个任务在任务队列中等待,而且由于线程池的最大线程数量就是 10所以已经不能再增加更多的线程来帮忙处理任务了这就意味着此时线程池工作饱和这个时候再提交新任务时就会被拒绝。
我们结合图示来分析上述情况,首先看右侧上方的队列部分,你可以看到目前队列已经满了,而图中队列下方的每个线程都在工作,且线程数已经达到最大值 10如果此时再有新的任务提交线程池由于没有能力继续处理新提交的任务所以就会拒绝。
我们了解了线程池拒绝任务的时机那么我们如何正确地选择拒绝策略呢Java 在 ThreadPoolExecutor 类中为我们提供了 4 种默认的拒绝策略来应对不同的场景,都实现了 RejectedExecutionHandler 接口,如图所示:
接下来,我们将具体讲解这 4 种拒绝策略。
拒绝策略
第一种拒绝策略是 AbortPolicy这种拒绝策略在拒绝任务时会直接抛出一个类型为 RejectedExecutionException 的 RuntimeException让你感知到任务被拒绝了于是你便可以根据业务逻辑选择重试或者放弃提交等策略。
第二种拒绝策略是 DiscardPolicy这种拒绝策略正如它的名字所描述的一样当新任务被提交后直接被丢弃掉也不会给你任何的通知相对而言存在一定的风险因为我们提交的时候根本不知道这个任务会被丢弃可能造成数据丢失。
第三种拒绝策略是 DiscardOldestPolicy如果线程池没被关闭且没有能力执行则会丢弃任务队列中的头结点通常是存活时间最长的任务这种策略与第二种不同之处在于它丢弃的不是最新提交的而是队列中存活时间最长的这样就可以腾出空间给新提交的任务但同理它也存在一定的数据丢失风险。
第四种拒绝策略是 CallerRunsPolicy相对而言它就比较完善了当有新任务提交后如果线程池没被关闭且没有能力执行则把这个任务交于提交任务的线程执行也就是谁提交任务谁就负责执行任务。这样做主要有两点好处。
第一点新提交的任务不会被丢弃,这样也就不会造成业务损失。
第二点好处是,由于谁提交任务谁就要负责执行任务,这样提交任务的线程就得负责执行任务,而执行任务又是比较耗时的,在这段期间,提交任务的线程被占用,也就不会再提交新的任务,减缓了任务提交的速度,相当于是一个负反馈。在此期间,线程池中的线程也可以充分利用这段时间来执行掉一部分任务,腾出一定的空间,相当于是给了线程池一定的缓冲期。

View File

@@ -0,0 +1,248 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
12 有哪 6 种常见的线程池?什么是 Java8 的 ForkJoinPool
在本课时我们主要学习常见的 6 种线程池,并详细讲解 Java 8 新增的 ForkJoinPool 线程池6 种常见的线程池如下。
FixedThreadPool
CachedThreadPool
ScheduledThreadPool
SingleThreadExecutor
SingleThreadScheduledExecutor
ForkJoinPool
FixedThreadPool
第一种线程池叫作 FixedThreadPool它的核心线程数和最大线程数是一样的所以可以把它看作是固定线程数的线程池它的特点是线程池中的线程数除了初始阶段需要从 0 开始增加外,之后的线程数量就是固定的,就算任务数超过线程数,线程池也不会再创建更多的线程来处理任务,而是会把超出线程处理能力的任务放到任务队列中进行等待。而且就算任务队列满了,到了本该继续增加线程数的时候,由于它的最大线程数和核心线程数是一样的,所以也无法再增加新的线程了。
如图所示,线程池有 t0~t910 个线程,它们会不停地执行任务,如果某个线程任务执行完了,就会从任务队列中获取新的任务继续执行,期间线程数量不会增加也不会减少,始终保持在 10 个。
CachedThreadPool
第二种线程池是 CachedThreadPool可以称作可缓存线程池它的特点在于线程数是几乎可以无限增加的实际最大可以达到 Integer.MAX_VALUE为 2^31-1这个数非常大所以基本不可能达到而当线程闲置时还可以对线程进行回收。也就是说该线程池的线程数量不是固定不变的当然它也有一个用于存储提交任务的队列但这个队列是 SynchronousQueue队列的容量为0实际不存储任何任务它只负责对任务进行中转和传递所以效率比较高。
当我们提交一个任务后,线程池会判断已创建的线程中是否有空闲线程,如果有空闲线程则将任务直接指派给空闲线程,如果没有空闲线程,则新建线程去执行任务,这样就做到了动态地新增线程。让我们举个例子,如下方代码所示。
ExecutorService service = Executors.newCachedThreadPool();
for (int i = 0; i < 1000; i++) {
service.execute(new Task() {
});
}
使用 for 循环提交 1000 个任务给 CachedThreadPool假设这些任务处理的时间非常长会发生什么情况呢因为 for 循环提交任务的操作是非常快的但执行任务却比较耗时就可能导致 1000 个任务都提交完了但第一个任务还没有被执行完所以此时 CachedThreadPool 就可以动态的伸缩线程数量随着任务的提交不停地创建 1000 个线程来执行任务而当任务执行完之后假设没有新的任务了那么大量的闲置线程又会造成内存资源的浪费这时线程池就会检测线程在 60 秒内有没有可执行任务如果没有就会被销毁最终线程数量会减为 0
ScheduledThreadPool
第三个线程池是 ScheduledThreadPool它支持定时或周期性执行任务比如每隔 10 秒钟执行一次任务而实现这种功能的方法主要有 3 如代码所示
ScheduledExecutorService service = Executors.newScheduledThreadPool(10);
service.schedule(new Task(), 10, TimeUnit.SECONDS);
service.scheduleAtFixedRate(new Task(), 10, 10, TimeUnit.SECONDS);
service.scheduleWithFixedDelay(new Task(), 10, 10, TimeUnit.SECONDS);
那么这 3 种方法有什么区别呢
第一种方法 schedule 比较简单表示延迟指定时间后执行一次任务如果代码中设置参数为 10 也就是 10 秒后执行一次任务后就结束
第二种方法 scheduleAtFixedRate 表示以固定的频率执行任务它的第二个参数 initialDelay 表示第一次延时时间第三个参数 period 表示周期也就是第一次延时后每次延时多长时间执行一次任务
第三种方法 scheduleWithFixedDelay 与第二种方法类似也是周期执行任务区别在于对周期的定义之前的 scheduleAtFixedRate 是以任务开始的时间为时间起点开始计时时间到就开始执行第二次任务而不管任务需要花多久执行 scheduleWithFixedDelay 方法以任务结束的时间为下一次循环的时间起点开始计时
举个例子假设某个同学正在熬夜写代码需要喝咖啡来提神假设每次喝咖啡都需要花10分钟的时间如果此时采用第2种方法 scheduleAtFixedRate时间间隔设置为 1 小时那么他将会在每个整点喝一杯咖啡以下是时间表
00:00: 开始喝咖啡
00:10: 喝完了
01:00: 开始喝咖啡
01:10: 喝完了
02:00: 开始喝咖啡
02:10: 喝完了
但是假设他采用第3种方法 scheduleWithFixedDelay时间间隔同样设置为 1 小时那么由于每次喝咖啡需要10分钟 scheduleWithFixedDelay 是以任务完成的时间为时间起点开始计时的所以第2次喝咖啡的时间将会在1:10而不是1:00整以下是时间表
00:00: 开始喝咖啡
00:10: 喝完了
01:10: 开始喝咖啡
01:20: 喝完了
02:20: 开始喝咖啡
02:30: 喝完了
SingleThreadExecutor
第四种线程池是 SingleThreadExecutor它会使用唯一的线程去执行任务原理和 FixedThreadPool 是一样的只不过这里线程只有一个如果线程在执行任务的过程中发生异常线程池也会重新创建一个线程来执行后续的任务这种线程池由于只有一个线程所以非常适合用于所有任务都需要按被提交的顺序依次执行的场景而前几种线程池不一定能够保障任务的执行顺序等于被提交的顺序因为它们是多线程并行执行的
SingleThreadScheduledExecutor
第五个线程池是 SingleThreadScheduledExecutor它实际和第三种 ScheduledThreadPool 线程池非常相似它只是 ScheduledThreadPool 的一个特例内部只有一个线程如源码所示
new ScheduledThreadPoolExecutor(1)
它只是将 ScheduledThreadPool 的核心线程数设置为了 1
总结上述的五种线程池我们以核心线程数最大线程数以及线程存活时间三个维度进行对比如表格所示
第一个线程池 FixedThreadPool它的核心线程数和最大线程数都是由构造函数直接传参的而且它们的值是相等的所以最大线程数不会超过核心线程数也就不需要考虑线程回收的问题如果没有任务可执行线程仍会在线程池中存活并等待任务
第二个线程池 CachedThreadPool 的核心线程数是 0而它的最大线程数是 Integer 的最大值线程数一般是达不到这么多的所以如果任务特别多且耗时的话CachedThreadPool 就会创建非常多的线程来应对
同理你可以课后按照同样的方法来分析后面三种线程池的参数来加深对知识的理解
ForkJoinPool
最后我们来看下第六种线程池 ForkJoinPool这个线程池是在 JDK 7 加入的它的名字 ForkJoin 也描述了它的执行机制主要用法和之前的线程池是相同的也是把任务交给线程池去执行线程池中也有任务队列来存放任务但是 ForkJoinPool 线程池和之前的线程池有两点非常大的不同之处第一点它非常适合执行可以产生子任务的任务
如图所示我们有一个 Task这个 Task 可以产生三个子任务三个子任务并行执行完毕后将结果汇总给 Result比如说主任务需要执行非常繁重的计算任务我们就可以把计算拆分成三个部分这三个部分是互不影响相互独立的这样就可以利用 CPU 的多核优势并行计算然后将结果进行汇总这里面主要涉及两个步骤第一步是拆分也就是 Fork第二步是汇总也就是 Join到这里你应该已经了解到 ForkJoinPool 线程池名字的由来了
举个例子比如面试中经常考到的菲波那切数列你一定非常熟悉这个数列的特点就是后一项的结果等于前两项的和 0 项是 0 1 项是 1那么第 2 项就是 0+1=1以此类推。我们在写代码时应该首选效率更高的迭代形式或者更高级的乘方或者矩阵公式法等写法不过假设我们写成了最初版本的递归形式伪代码如下所示
if (n <= 1) {
return n;
} else {
Fib f1 = new Fib(n - 1);
Fib f2 = new Fib(n - 2);
f1.solve();
f2.solve();
number = f1.number + f2.number;
return number;
}
你可以看到如果 n<=1 则直接返回 n如果 n>1 ,先将前一项 f1 的值计算出来,然后往前推两项求出 f2 的值,然后将两值相加得到结果,所以我们看到在求和运算中产生了两个子任务。计算 f(4) 的流程如下图所示。
在计算 f(4) 时需要首先计算出 f(2) 和 f(3),而同理,计算 f(3) 时又需要计算 f(1) 和 f(2),以此类推。
这是典型的递归问题,对应到我们的 ForkJoin 模式,如图所示,子任务同样会产生子子任务,最后再逐层汇总,得到最终的结果。
ForkJoinPool 线程池有多种方法可以实现任务的分裂和汇总,其中一种用法如下方代码所示。
class Fibonacci extends RecursiveTask<Integer> {
int n;
public Fibonacci(int n) {
this.n = n;
}
@Override
public Integer compute() {
if (n <= 1) {
return n;
}
Fibonacci f1 = new Fibonacci(n - 1);
f1.fork();
Fibonacci f2 = new Fibonacci(n - 2);
f2.fork();
return f1.join() + f2.join();
}
}
我们看到它首先继承了 RecursiveTaskRecursiveTask 类是对ForkJoinTask 的一个简单的包装,这时我们重写 compute() 方法,当 n<=1 时直接返回,当 n>1 就创建递归任务,也就是 f1 和 f2然后我们用 fork() 方法分裂任务并分别执行,最后在 return 的时候,使用 join() 方法把结果汇总,这样就实现了任务的分裂和汇总。
public static void main(String[] args) throws ExecutionException, InterruptedException {
ForkJoinPool forkJoinPool = new ForkJoinPool();
for (int i = 0; i < 10; i++) {
ForkJoinTask task = forkJoinPool.submit(new Fibonacci(i));
System.out.println(task.get());
}
}
上面这段代码将会打印出斐波那契数列的第 0 9 项的值
0
1
1
2
3
5
8
13
21
34
这就是 ForkJoinPool 线程池和其他线程池的第一点不同
我们来看第二点不同第二点不同之处在于内部结构之前的线程池所有的线程共用一个队列 ForkJoinPool 线程池中每个线程都有自己独立的任务队列如图所示
ForkJoinPool 线程池内部除了有一个共用的任务队列之外每个线程还有一个对应的双端队列 deque这时一旦线程中的任务被 Fork 分裂了分裂出来的子任务放入线程自己的 deque 而不是放入公共的任务队列中如果此时有三个子任务放入线程 t1 deque 队列中对于线程 t1 而言获取任务的成本就降低了可以直接在自己的任务队列中获取而不必去公共队列中争抢也不会发生阻塞除了后面会讲到的 steal 情况外减少了线程间的竞争和切换是非常高效的
我们再考虑一种情况此时线程有多个而线程 t1 的任务特别繁重分裂了数十个子任务但是 t0 此时却无事可做它自己的 deque 队列为空这时为了提高效率t0 就会想办法帮助 t1 执行任务这就是work-stealing的含义
双端队列 deque 线程 t1 获取任务的逻辑是后进先出也就是LIFOLast In Frist Out而线程 t0 steal偷线程 t1 deque 中的任务的逻辑是先进先出也就是FIFOFast In Frist Out如图所示图中很好的描述了两个线程使用双端队列分别获取任务的情景你可以看到使用 work-stealing 算法和双端队列很好地平衡了各线程的负载
最后我们用一张全景图来描述 ForkJoinPool 线程池的内部结构你可以看到 ForkJoinPool 线程池和其他线程池很多地方都是一样的但重点区别在于它每个线程都有一个自己的双端队列来存储分裂出来的子任务ForkJoinPool 非常适合用于递归的场景例如树的遍历最优路径搜索等场景

View File

@@ -0,0 +1,46 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
13 线程池常用的阻塞队列有哪些?
在本课时我们主要学习线程池内部结构,以及线程池中最常见的阻塞队列类型。
线程池内部结构
线程池的内部结构主要由四部分组成,如图所示。
第一部分是线程池管理器,它主要负责管理线程池的创建、销毁、添加任务等管理操作,它是整个线程池的管家。
第二部分是工作线程,也就是图中的线程 t0~t9这些线程勤勤恳恳地从任务队列中获取任务并执行。
第三部分是任务队列,作为一种缓冲机制,线程池会把当下没有处理的任务放入任务队列中,由于多线程同时从任务队列中获取任务是并发场景,此时就需要任务队列满足线程安全的要求,所以线程池中任务队列采用 BlockingQueue 来保障线程安全。
第四部分是任务,任务要求实现统一的接口,以便工作线程可以处理和执行。
阻塞队列
线程池中的这四个主要组成部分最值得我们关注的就是阻塞队列了,如表格所示,不同的线程池会选用不同的阻塞队列。
表格左侧是线程池,右侧为它们对应的阻塞队列,你可以看到 5 种线程池对应了 3 种阻塞队列,我们接下来对它们进行逐一的介绍。
LinkedBlockingQueue
对于 FixedThreadPool 和 SingleThreadExector 而言,它们使用的阻塞队列是容量为 Integer.MAX_VALUE 的 LinkedBlockingQueue可以认为是无界队列。由于 FixedThreadPool 线程池的线程数是固定的,所以没有办法增加特别多的线程来处理任务,这时就需要 LinkedBlockingQueue 这样一个没有容量限制的阻塞队列来存放任务。这里需要注意,由于线程池的任务队列永远不会放满,所以线程池只会创建核心线程数量的线程,所以此时的最大线程数对线程池来说没有意义,因为并不会触发生成多于核心线程数的线程。
SynchronousQueue
第二种阻塞队列是 SynchronousQueue对应的线程池是 CachedThreadPool。线程池 CachedThreadPool 的最大线程数是 Integer 的最大值可以理解为线程数是可以无限扩展的。CachedThreadPool 和上一种线程池 FixedThreadPool 的情况恰恰相反FixedThreadPool 的情况是阻塞队列的容量是无限的,而这里 CachedThreadPool 是线程数可以无限扩展,所以 CachedThreadPool 线程池并不需要一个任务队列来存储任务,因为一旦有任务被提交就直接转发给线程或者创建新线程来执行,而不需要另外保存它们。
我们自己创建使用 SynchronousQueue 的线程池时,如果不希望任务被拒绝,那么就需要注意设置最大线程数要尽可能大一些,以免发生任务数大于最大线程数时,没办法把任务放到队列中也没有足够线程来执行任务的情况。
DelayedWorkQueue
第三种阻塞队列是DelayedWorkQueue它对应的线程池分别是 ScheduledThreadPool 和 SingleThreadScheduledExecutor这两种线程池的最大特点就是可以延迟执行任务比如说一定时间后执行任务或是每隔一定的时间执行一次任务。DelayedWorkQueue 的特点是内部元素并不是按照放入的时间排序,而是会按照延迟的时间长短对任务进行排序,内部采用的是“堆”的数据结构。之所以线程池 ScheduledThreadPool 和 SingleThreadScheduledExecutor 选择 DelayedWorkQueue是因为它们本身正是基于时间执行任务的而延迟队列正好可以把任务按时间进行排序方便任务的执行。

View File

@@ -0,0 +1,74 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
14 为什么不应该自动创建线程池?
在本课时我们主要学习为什么不应该自动创建线程池,所谓的自动创建线程池就是直接调用 Executors 的各种方法来生成前面学过的常见的线程池,例如 Executors.newCachedThreadPool()。但这样做是有一定风险的,接下来我们就来逐一分析自动创建线程池可能带来哪些问题。
FixedThreadPool
首先我们来看第一种线程池 FixedThreadPool 它是线程数量固定的线程池如源码所示newFixedThreadPool 内部实际还是调用了 ThreadPoolExecutor 构造函数。
public static ExecutorService newFixedThreadPool(int nThreads) {
return new ThreadPoolExecutor(nThreads, nThreads,0L, TimeUnit.MILLISECONDS,new LinkedBlockingQueue<Runnable>());
}
通过往构造函数中传参,创建了一个核心线程数和最大线程数相等的线程池,它们的数量也就是我们传入的参数,这里的重点是使用的队列是容量没有上限的 LinkedBlockingQueue如果我们对任务的处理速度比较慢那么随着请求的增多队列中堆积的任务也会越来越多最终大量堆积的任务会占用大量内存并发生 OOM 也就是OutOfMemoryError这几乎会影响到整个程序会造成很严重的后果。
SingleThreadExecutor
第二种线程池是 SingleThreadExecutor我们来分析下创建它的源码。
public static ExecutorService newSingleThreadExecutor() {
return new FinalizableDelegatedExecutorService (new ThreadPoolExecutor(1, 1,0L, TimeUnit.MILLISECONDS,new LinkedBlockingQueue<Runnable>()));
}
你可以看出newSingleThreadExecutor 和 newFixedThreadPool 的原理是一样的,只不过把核心线程数和最大线程数都直接设置成了 1但是任务队列仍是无界的 LinkedBlockingQueue所以也会导致同样的问题也就是当任务堆积时可能会占用大量的内存并导致 OOM。
CachedThreadPool
第三种线程池是 CachedThreadPool创建它的源码下所示。
public static ExecutorService newCachedThreadPool() {
return new ThreadPoolExecutor(0, Integer.MAX_VALUE,60L, TimeUnit.SECONDS,new SynchronousQueue<Runnable>());
}
这里的 CachedThreadPool 和前面两种线程池不一样的地方在于任务队列使用的是 SynchronousQueueSynchronousQueue 本身并不存储任务,而是对任务直接进行转发,这本身是没有问题的,但你会发现构造函数的第二个参数被设置成了 Integer.MAX_VALUE这个参数的含义是最大线程数所以由于 CachedThreadPool 并不限制线程的数量,当任务数量特别多的时候,就可能会导致创建非常多的线程,最终超过了操作系统的上限而无法创建新线程,或者导致内存不足。
ScheduledThreadPool 和 SingleThreadScheduledExecutor
第四种线程池 ScheduledThreadPool 和第五种线程池 SingleThreadScheduledExecutor 的原理是一样的,创建 ScheduledThreadPool 的源码如下所示。
public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize) {
return new ScheduledThreadPoolExecutor(corePoolSize);
}
而这里的 ScheduledThreadPoolExecutor 是 ThreadPoolExecutor 的子类,调用的它的构造方法如下所示。
public ScheduledThreadPoolExecutor(int corePoolSize) {
super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS,new DelayedWorkQueue());
}
我们通过源码可以看出,它采用的任务队列是 DelayedWorkQueue这是一个延迟队列同时也是一个无界队列所以和 LinkedBlockingQueue 一样,如果队列中存放过多的任务,就可能导致 OOM。
你可以看到,这几种自动创建的线程池都存在风险,相比较而言,我们自己手动创建会更好,因为我们可以更加明确线程池的运行规则,不仅可以选择适合自己的线程数量,更可以在必要的时候拒绝新任务的提交,避免资源耗尽的风险。

View File

@@ -0,0 +1,42 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
15 合适的线程数量是多少CPU 核心数和线程数的关系?
在本课时我们主要学习合适的线程数量是多少,以及 CPU 核心数和线程数的关系。
你可能经常在面试中被问到这两个问题,如果想要很好地回答它们首先你需要了解,我们调整线程池中的线程数量的最主要的目的是为了充分并合理地使用 CPU 和内存等资源,从而最大限度地提高程序的性能。在实际工作中,我们需要根据任务类型的不同选择对应的策略。
CPU 密集型任务
首先,我们来看 CPU 密集型任务,比如加密、解密、压缩、计算等一系列需要大量耗费 CPU 资源的任务。对于这样的任务最佳的线程数为 CPU 核心数的 1~2 倍,如果设置过多的线程数,实际上并不会起到很好的效果。此时假设我们设置的线程数量是 CPU 核心数的 2 倍以上,因为计算任务非常重,会占用大量的 CPU 资源,所以这时 CPU 的每个核心工作基本都是满负荷的,而我们又设置了过多的线程,每个线程都想去利用 CPU 资源来执行自己的任务,这就会造成不必要的上下文切换,此时线程数的增多并没有让性能提升,反而由于线程数量过多会导致性能下降。
针对这种情况,我们最好还要同时考虑在同一台机器上还有哪些其他会占用过多 CPU 资源的程序在运行,然后对资源使用做整体的平衡。
耗时 IO 型任务
第二种任务是耗时 IO 型,比如数据库、文件的读写,网络通信等任务,这种任务的特点是并不会特别消耗 CPU 资源,但是 IO 操作很耗时,总体会占用比较多的时间。对于这种任务最大线程数一般会大于 CPU 核心数很多倍,因为 IO 读写速度相比于 CPU 的速度而言是比较慢的,如果我们设置过少的线程数,就可能导致 CPU 资源的浪费。而如果我们设置更多的线程数,那么当一部分线程正在等待 IO 的时候,它们此时并不需要 CPU 来计算,那么另外的线程便可以利用 CPU 去执行其他的任务,互不影响,这样的话在任务队列中等待的任务就会减少,可以更好地利用资源。
《Java并发编程实战》的作者 Brain Goetz 推荐的计算方法:
线程数 = CPU 核心数 *1+平均等待时间/平均工作时间)
通过这个公式,我们可以计算出一个合理的线程数量,如果任务的平均等待时间长,线程数就随之增加,而如果平均工作时间长,也就是对于我们上面的 CPU 密集型任务,线程数就随之减少。
太少的线程数会使得程序整体性能降低,而过多的线程也会消耗内存等其他资源,所以如果想要更准确的话,可以进行压测,监控 JVM 的线程情况以及 CPU 的负载情况,根据实际情况衡量应该创建的线程数,合理并充分利用资源。
结论
综上所述我们就可以得出一个结论:
线程的平均工作时间所占比例越高,就需要越少的线程;
线程的平均等待时间所占比例越高,就需要越多的线程;
针对不同的程序,进行对应的实际测试就可以得到最合适的选择。

View File

@@ -0,0 +1,55 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
16 如何根据实际需要,定制自己的线程池?
在本课时我们主要学习如何根据自己的实际需求设置线程池的各个参数来定制自己的线程池。
核心线程数
第一个需要设置的参数往往是 corePoolSize 核心线程数,在上一课时我们讲过,合理的线程数量和任务类型,以及 CPU 核心数都有关系,基本结论是线程的平均工作时间所占比例越高,就需要越少的线程;线程的平均等待时间所占比例越高,就需要越多的线程。而对于最大线程数而言,如果我们执行的任务类型不是固定的,比如可能一段时间是 CPU 密集型,另一段时间是 IO 密集型,或是同时有两种任务相互混搭。那么在这种情况下,我们可以把最大线程数设置成核心线程数的几倍,以便应对任务突发情况。当然更好的办法是用不同的线程池执行不同类型的任务,让任务按照类型区分开,而不是混杂在一起,这样就可以按照上一课时估算的线程数或经过压测得到的结果来设置合理的线程数了,达到更好的性能。
阻塞队列
对于阻塞队列这个参数而言,我们可以选择之前介绍过的 LinkedBlockingQueue 或者 SynchronousQueue 或者 DelayedWorkQueue不过还有一种常用的阻塞队列叫 ArrayBlockingQueue它也经常被用于线程池中这种阻塞队列内部是用数组实现的在新建对象的时候要求传入容量值且后期不能扩容所以 ArrayBlockingQueue 的最大的特点就是容量是有限的。这样一来,如果任务队列放满了任务,而且线程数也已经达到了最大值,线程池根据规则就会拒绝新提交的任务,这样一来就可能会产生一定的数据丢失。
但相比于无限增加任务或者线程数导致内存不足,进而导致程序崩溃,数据丢失还是要更好一些的,如果我们使用了 ArrayBlockingQueue 这种阻塞队列,再加上我们限制了最大线程数量,就可以非常有效地防止资源耗尽的情况发生。此时的队列容量大小和 maxPoolSize 是一个 trade-off如果我们使用容量更大的队列和更小的最大线程数就可以减少上下文切换带来的开销但也可能因此降低整体的吞吐量如果我们的任务是 IO 密集型,则可以选择稍小容量的队列和更大的最大线程数,这样整体的效率就会更高,不过也会带来更多的上下文切换。
线程工厂
对于线程工厂 threadFactory 这个参数,我们可以使用默认的 defaultThreadFactory也可以传入自定义的有额外能力的线程工厂因为我们可能有多个线程池而不同的线程池之间有必要通过不同的名字来进行区分所以可以传入能根据业务信息进行命名的线程工厂以便后续可以根据线程名区分不同的业务进而快速定位问题代码。比如可以通过com.google.common.util.concurrent.ThreadFactory
Builder 来实现,如代码所示。
ThreadFactoryBuilder builder = new ThreadFactoryBuilder();
ThreadFactory rpcFactory = builder.setNameFormat("rpc-pool-%d").build();
我们生成了名字为 rpcFactory 的 ThreadFactory它的 nameFormat 为 “rpc-pool-%d” 那么它生成的线程的名字是有固定格式的它生成的线程的名字分别为”rpc-pool-1””rpc-pool-2” ,以此类推。
拒绝策略
最后一个参数是拒绝策略,我们可以根据业务需要,选择第 11 讲里的四种拒绝策略之一来使用AbortPolicyDiscardPolicyDiscardOldestPolicy 或者 CallerRunsPolicy。除此之外我们还可以通过实现 RejectedExecutionHandler 接口来实现自己的拒绝策略,在接口中我们需要实现 rejectedExecution 方法,在 rejectedExecution 方法中,执行例如打印日志、暂存任务、重新执行等自定义的拒绝策略,以便满足业务需求。如代码所示。
private static class CustomRejectionHandler implements RejectedExecutionHandler {
@Override
public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
//打印日志、暂存任务、重新执行等拒绝策略
}
}
总结
所以定制自己的线程池和我们的业务是强相关的,首先我们需要掌握每个参数的含义,以及常见的选项,然后根据实际需要,比如说并发量、内存大小、是否接受任务被拒绝等一系列因素去定制一个非常适合自己业务的线程池,这样既不会导致内存不足,同时又可以用合适数量的线程来保障任务执行的效率,并在拒绝任务时有所记录方便日后进行追溯。

View File

@@ -0,0 +1,97 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
17 如何正确关闭线程池shutdown 和 shutdownNow 的区别?
在本课时我们主要学习如何正确关闭线程池?以及 shutdown() 与 shutdownNow() 方法的区别?首先,我们创建一个线程数固定为 10 的线程池,并且往线程池中提交 100 个任务,如代码所示。
ExecutorService service = Executors.newFixedThreadPool(10);
for (int i = 0; i < 100; i++) {
service.execute(new Task());
}
那么如果现在我们想关闭该线程池该如何做呢本课时主要介绍 5 种在 ThreadPoolExecutor 中涉及关闭线程池的方法如下所示
void shutdown;
boolean isShutdown;
boolean isTerminated;
boolean awaitTermination(long timeout, TimeUnit unit) throws InterruptedException;
List shutdownNow;
下面我们就对这些方法逐一展开
shutdown()
第一种方法叫作 shutdown()它可以安全地关闭一个线程池调用 shutdown() 方法之后线程池并不是立刻就被关闭因为这时线程池中可能还有很多任务正在被执行或是任务队列中有大量正在等待被执行的任务调用 shutdown() 方法后线程池会在执行完正在执行的任务和队列中等待的任务后才彻底关闭但这并不代表 shutdown() 操作是没有任何效果的调用 shutdown() 方法后如果还有新的任务被提交线程池则会根据拒绝策略直接拒绝后续新提交的任务
isShutdown()
第二个方法叫作 isShutdown()它可以返回 true 或者 false 来判断线程池是否已经开始了关闭工作也就是是否执行了 shutdown 或者 shutdownNow 方法这里需要注意如果调用 isShutdown() 方法的返回的结果为 true 并不代表线程池此时已经彻底关闭了这仅仅代表线程池开始了关闭的流程也就是说此时可能线程池中依然有线程在执行任务队列里也可能有等待被执行的任务
isTerminated()
第三种方法叫作 isTerminated()这个方法可以检测线程池是否真正终结这不仅代表线程池已关闭同时代表线程池中的所有任务都已经都执行完毕了因为我们刚才说过调用 shutdown 方法之后线程池会继续执行里面未完成的任务不仅包括线程正在执行的任务还包括正在任务队列中等待的任务比如此时已经调用了 shutdown 方法但是有一个线程依然在执行任务那么此时调用 isShutdown 方法返回的是 true 而调用 isTerminated 方法返回的便是 false 因为线程池中还有任务正在在被执行线程池并没有真正终结直到所有任务都执行完毕了调用 isTerminated() 方法才会返回 true这表示线程池已关闭并且线程池内部是空的所有剩余的任务都执行完毕了
awaitTermination()
第四个方法叫作 awaitTermination()它本身并不是用来关闭线程池的而是主要用来判断线程池状态的比如我们给 awaitTermination 方法传入的参数是 10 那么它就会陷入 10 秒钟的等待直到发生以下三种情况之一
等待期间包括进入等待状态之前线程池已关闭并且所有已提交的任务包括正在执行的和队列中等待的都执行完毕相当于线程池已经终结方法便会返回 true
等待超时时间到后第一种线程池终结的情况始终未发生方法返回 false
等待期间线程被中断方法会抛出 InterruptedException 异常
也就是说调用 awaitTermination 方法后当前线程会尝试等待一段指定的时间如果在等待时间内线程池已关闭并且内部的任务都执行完毕了也就是说线程池真正终结那么方法就返回 true否则超时返回 fasle
我们则可以根据 awaitTermination() 返回的布尔值来判断下一步应该执行的操作
shutdownNow()
最后一个方法是 shutdownNow()也是 5 种方法里功能最强大的它与第一种 shutdown 方法不同之处在于名字中多了一个单词 Now也就是表示立刻关闭的意思在执行 shutdownNow 方法之后首先会给所有线程池中的线程发送 interrupt 中断信号尝试中断这些任务的执行然后会将任务队列中正在等待的所有任务转移到一个 List 中并返回我们可以根据返回的任务 List 来进行一些补救的操作例如记录在案并在后期重试shutdownNow() 的源码如下所示
public List<Runnable> shutdownNow() {
List<Runnable> tasks;
final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
checkShutdownAccess();
advanceRunState(STOP);
interruptWorkers();
tasks = drainQueue();
} finally {
mainLock.unlock();
}
tryTerminate();
return tasks;
}
你可以看到源码中有一行 interruptWorkers() 代码,这行代码会让每一个已经启动的线程都中断,这样线程就可以在执行任务期间检测到中断信号并进行相应的处理,提前结束任务。这里需要注意的是,由于 Java 中不推荐强行停止线程的机制的限制,即便我们调用了 shutdownNow 方法,如果被中断的线程对于中断信号不理不睬,那么依然有可能导致任务不会停止。可见我们在开发中落地最佳实践是很重要的,我们自己编写的线程应当具有响应中断信号的能力,正确停止线程的方法在第 2 讲有讲过,应当利用中断信号来协同工作。
在掌握了这 5 种关闭线程池相关的方法之后,我们就可以根据自己的业务需要,选择合适的方法来停止线程池,比如通常我们可以用 shutdown() 方法来关闭,这样可以让已提交的任务都执行完毕,但是如果情况紧急,那我们就可以用 shutdownNow 方法来加快线程池“终结”的速度。

View File

@@ -0,0 +1,166 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
18 线程池实现“线程复用”的原理?
在本课时我们主要学习线程复用的原理,以及对线程池的 execute 这个非常重要的方法进行源码解析。
线程复用原理
我们知道线程池会使用固定数量或可变数量的线程来执行任务,但无论是固定数量或可变数量的线程,其线程数量都远远小于任务数量,面对这种情况线程池可以通过线程复用让同一个线程去执行不同的任务,那么线程复用背后的原理是什么呢?
线程池可以把线程和任务进行解耦,线程归线程,任务归任务,摆脱了之前通过 Thread 创建线程时的一个线程必须对应一个任务的限制。在线程池中,同一个线程可以从 BlockingQueue 中不断提取新任务来执行,其核心原理在于线程池对 Thread 进行了封装,并不是每次执行任务都会调用 Thread.start() 来创建新线程,而是让每个线程去执行一个“循环任务”,在这个“循环任务”中,不停地检查是否还有任务等待被执行,如果有则直接去执行这个任务,也就是调用任务的 run 方法,把 run 方法当作和普通方法一样的地位去调用,相当于把每个任务的 run() 方法串联了起来,所以线程数量并不增加。
我们首先来复习一下线程池创建新线程的时机和规则:
如流程图所示,当提交任务后,线程池首先会检查当前线程数,如果此时线程数小于核心线程数,比如最开始线程数量为 0则新建线程并执行任务随着任务的不断增加线程数会逐渐增加并达到核心线程数此时如果仍有任务被不断提交就会被放入 workQueue 任务队列中,等待核心线程执行完当前任务后重新从 workQueue 中提取正在等待被执行的任务。此时,假设我们的任务特别的多,已经达到了 workQueue 的容量上限,这时线程池就会启动后备力量,也就是 maxPoolSize 最大线程数,线程池会在 corePoolSize 核心线程数的基础上继续创建线程来执行任务,假设任务被不断提交,线程池会持续创建线程直到线程数达到 maxPoolSize 最大线程数,如果依然有任务被提交,这就超过了线程池的最大处理能力,这个时候线程池就会拒绝这些任务,我们可以看到实际上任务进来之后,线程池会逐一判断 corePoolSize 、workQueue 、maxPoolSize ,如果依然不能满足需求,则会拒绝任务。
我们接下来具体看看代码是如何实现的,我们从 execute 方法开始分析,源码如下所示。
public void execute(Runnable command) {
if (command == null)
throw new NullPointerException();
int c = ctl.get();
if (workerCountOf(c) < corePoolSize) {
if (addWorker(command, true))
return;
c = ctl.get();
}
if (isRunning(c) && workQueue.offer(command)) {
int recheck = ctl.get();
if (! isRunning(recheck) && remove(command))
reject(command);
else if (workerCountOf(recheck) == 0)
addWorker(null, false);
}
else if (!addWorker(command, false))
reject(command);
}
线程复用源码解析
这段代码短小精悍内容丰富接下来我们具体分析代码中的逻辑首先看下前几行
//如果传入的Runnable的空就抛出异常
if (command == null)
throw new NullPointerException();
execute 方法中通过 if 语句判断 command 也就是 Runnable 任务是否等于 null如果为 null 就抛出异常
接下来判断当前线程数是否小于核心线程数如果小于核心线程数就调用 addWorker() 方法增加一个 Worker这里的 Worker 就可以理解为一个线程
if (workerCountOf(c) < corePoolSize) {
if (addWorker(command, true))
return;
c = ctl.get();
}
addWorker 方法又是做什么用的呢addWorker 方法的主要作用是在线程池中创建一个线程并执行第一个参数传入的任务它的第二个参数是个布尔值如果布尔值传入 true 代表增加线程时判断当前线程是否少于 corePoolSize小于则增加新线程大于等于则不增加同理如果传入 false 代表增加线程时判断当前线程是否少于 maxPoolSize小于则增加新线程大于等于则不增加所以这里的布尔值的含义是以核心线程数为界限还是以最大线程数为界限进行是否新增线程的判断addWorker() 方法如果返回 true 代表添加成功如果返回 false 代表添加失败
我们接下来看下一部分代码
if (isRunning(c) && workQueue.offer(command)) {
int recheck = ctl.get();
if (! isRunning(recheck) && remove(command))
reject(command);
else if (workerCountOf(recheck) == 0)
addWorker(null, false);
}
如果代码执行到这里说明当前线程数大于或等于核心线程数或者 addWorker 失败了那么就需要通过 if (isRunning© && workQueue.offer(command)) 检查线程池状态是否为 Running如果线程池状态是 Running 就把任务放入任务队列中也就是 workQueue.offer(command)如果线程池已经不处于 Running 状态说明线程池被关闭那么就移除刚刚添加到任务队列中的任务并执行拒绝策略代码如下所示
if (! isRunning(recheck) && remove(command))
reject(command);
下面我们再来看后一个 else 分支
else if (workerCountOf(recheck) == 0)
addWorker(null, false);
能进入这个 else 说明前面判断到线程池状态为 Running那么当任务被添加进来之后就需要防止没有可执行线程的情况发生比如之前的线程被回收了或意外终止了所以此时如果检查当前线程数为 0也就是 workerCountOf(recheck**) == 0那就执行 addWorker() 方法新建线程
我们再来看最后一部分代码
else if (!addWorker(command, false))
reject(command);
执行到这里说明线程池不是 Running 状态或线程数大于或等于核心线程数并且任务队列已经满了根据规则此时需要添加新线程直到线程数达到最大线程数所以此时就会再次调用 addWorker 方法并将第二个参数传入 false传入 false 代表增加线程时判断当前线程数是否少于 maxPoolSize小于则增加新线程大于等于则不增加也就是以 maxPoolSize 为上限创建新的 workeraddWorker 方法如果返回 true 代表添加成功如果返回 false 代表任务添加失败说明当前线程数已经达到 maxPoolSize然后执行拒绝策略 reject 方法如果执行到这里线程池的状态不是 Running那么 addWorker 会失败并返回 false所以也会执行拒绝策略 reject 方法
可以看出 execute 方法中多次调用 addWorker 方法把任务传入addWorker 方法会添加并启动一个 Worker这里的 Worker 可以理解为是对 Thread 的包装Worker 内部有一个 Thread 对象它正是最终真正执行任务的线程所以一个 Worker 就对应线程池中的一个线程addWorker 就代表增加线程线程复用的逻辑实现主要在 Worker 类中的 run 方法里执行的 runWorker 方法中简化后的 runWorker 方法代码如下所示
runWorker(Worker w) {
Runnable task = w.firstTask;
while (task != null || (task = getTask()) != null) {
try {
task.run();
} finally {
task = null;
}
}
}
可以看出实现线程复用的逻辑主要在一个不停循环的 while 循环体中
通过取 Worker firstTask 或者通过 getTask 方法从 workQueue 中获取待执行的任务
直接调用 task run 方法来执行具体的任务而不是新建线程
在这里我们找到了最终的实现通过取 Worker firstTask 或者 getTask方法从 workQueue 中取出了新任务并直接调用 Runnable run 方法来执行任务也就是如之前所说的每个线程都始终在一个大循环中反复获取任务然后执行任务从而实现了线程的复用

View File

@@ -0,0 +1,85 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
19 你知道哪几种锁?分别有什么特点?
本课时我们首先会对锁的分类有一个整体的概念,了解锁究竟有哪些分类标准。然后在后续的课程中,会对其中重要的锁进行详细讲解。
锁的 7 大分类
需要首先指出的是,这些多种多样的分类,是评价一个事物的多种标准,比如评价一个城市,标准有人口多少、经济发达与否、城市面积大小等。而一个城市可能同时占据多个标准,以北京而言,人口多,经济发达,同时城市面积还很大。
同理,对于 Java 中的锁而言,一把锁也有可能同时占有多个标准,符合多种分类,比如 ReentrantLock 既是可中断锁,又是可重入锁。
根据分类标准我们把锁分为以下 7 大类别,分别是:
偏向锁/轻量级锁/重量级锁;
可重入锁/非可重入锁;
共享锁/独占锁;
公平锁/非公平锁;
悲观锁/乐观锁;
自旋锁/非自旋锁;
可中断锁/不可中断锁。
以上是常见的分类标准,下面我们来逐一介绍它们的含义。
偏向锁/轻量级锁/重量级锁
第一种分类是偏向锁/轻量级锁/重量级锁,这三种锁特指 synchronized 锁的状态,通过在对象头中的 mark word 来表明锁的状态。
偏向锁
如果自始至终,对于这把锁都不存在竞争,那么其实就没必要上锁,只需要打个标记就行了,这就是偏向锁的思想。一个对象被初始化后,还没有任何线程来获取它的锁时,那么它就是可偏向的,当有第一个线程来访问它并尝试获取锁的时候,它就将这个线程记录下来,以后如果尝试获取锁的线程正是偏向锁的拥有者,就可以直接获得锁,开销很小,性能最好。
轻量级锁
JVM 开发者发现在很多情况下synchronized 中的代码是被多个线程交替执行的,而不是同时执行的,也就是说并不存在实际的竞争,或者是只有短时间的锁竞争,用 CAS 就可以解决,这种情况下,用完全互斥的重量级锁是没必要的。轻量级锁是指当锁原来是偏向锁的时候,被另一个线程访问,说明存在竞争,那么偏向锁就会升级为轻量级锁,线程会通过自旋的形式尝试获取锁,而不会陷入阻塞。
重量级锁
重量级锁是互斥锁,它是利用操作系统的同步机制实现的,所以开销相对比较大。当多个线程直接有实际竞争,且锁竞争时间长的时候,轻量级锁不能满足需求,锁就会膨胀为重量级锁。重量级锁会让其他申请却拿不到锁的线程进入阻塞状态。
你可以发现锁升级的路径:无锁→偏向锁→轻量级锁→重量级锁。
综上所述,偏向锁性能最好,可以避免执行 CAS 操作。而轻量级锁利用自旋和 CAS 避免了重量级锁带来的线程阻塞和唤醒,性能中等。重量级锁则会把获取不到锁的线程阻塞,性能最差。
可重入锁/非可重入锁
第 2 个分类是可重入锁和非可重入锁。可重入锁指的是线程当前已经持有这把锁了,能在不释放这把锁的情况下,再次获取这把锁。同理,不可重入锁指的是虽然线程当前持有了这把锁,但是如果想再次获取这把锁,也必须要先释放锁后才能再次尝试获取。
对于可重入锁而言,最典型的就是 ReentrantLock 了正如它的名字一样reentrant 的意思就是可重入,它也是 Lock 接口最主要的一个实现类。
共享锁/独占锁
第 3 种分类标准是共享锁和独占锁。共享锁指的是我们同一把锁可以被多个线程同时获得,而独占锁指的就是,这把锁只能同时被一个线程获得。我们的读写锁,就最好地诠释了共享锁和独占锁的理念。读写锁中的读锁,是共享锁,而写锁是独占锁。读锁可以被同时读,可以同时被多个线程持有,而写锁最多只能同时被一个线程持有。
公平锁/非公平锁
第 4 种分类是公平锁和非公平锁。公平锁的公平的含义在于如果线程现在拿不到这把锁,那么线程就都会进入等待,开始排队,在等待队列里等待时间长的线程会优先拿到这把锁,有先来先得的意思。而非公平锁就不那么“完美”了,它会在一定情况下,忽略掉已经在排队的线程,发生插队现象。
悲观锁/乐观锁
第 5 种分类是悲观锁,以及与它对应的乐观锁。悲观锁的概念是在获取资源之前,必须先拿到锁,以便达到“独占”的状态,当前线程在操作资源的时候,其他线程由于不能拿到锁,所以其他线程不能来影响我。而乐观锁恰恰相反,它并不要求在获取资源前拿到锁,也不会锁住资源;相反,乐观锁利用 CAS 理念,在不独占资源的情况下,完成了对资源的修改。
自旋锁/非自旋锁
第 6 种分类是自旋锁与非自旋锁。自旋锁的理念是如果线程现在拿不到锁,并不直接陷入阻塞或者释放 CPU 资源,而是开始利用循环,不停地尝试获取锁,这个循环过程被形象地比喻为“自旋”,就像是线程在“自我旋转”。相反,非自旋锁的理念就是没有自旋的过程,如果拿不到锁就直接放弃,或者进行其他的处理逻辑,例如去排队、陷入阻塞等。
可中断锁/不可中断锁
第 7 种分类是可中断锁和不可中断锁。在 Java 中synchronized 关键字修饰的锁代表的是不可中断锁,一旦线程申请了锁,就没有回头路了,只能等到拿到锁以后才能进行其他的逻辑处理。而我们的 ReentrantLock 是一种典型的可中断锁,例如使用 lockInterruptibly 方法在获取锁的过程中,突然不想获取了,那么也可以在中断之后去做其他的事情,不需要一直傻等到获取到锁才离开。

View File

@@ -0,0 +1,114 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
20 悲观锁和乐观锁的本质是什么?
本课时我们会讲讲悲观锁和乐观锁。
首先我们看下悲观锁与乐观锁是如何进行分类的,悲观锁和乐观锁是从是否锁住资源的角度进行分类的。
悲观锁
悲观锁比较悲观,它认为如果不锁住这个资源,别的线程就会来争抢,就会造成数据结果错误,所以悲观锁为了确保结果的正确性,会在每次获取并修改数据时,都把数据锁住,让其他线程无法访问该数据,这样就可以确保数据内容万无一失。
这也和我们人类中悲观主义者的性格是一样的,悲观主义者做事情之前总是担惊受怕,所以会严防死守,保证别人不能来碰我的东西,这就是悲观锁名字的含义。
我们举个例子,假设线程 A 和 B 使用的都是悲观锁,所以它们在尝试获取同步资源时,必须要先拿到锁。
假设线程 A 拿到了锁,并且正在操作同步资源,那么此时线程 B 就必须进行等待。
而当线程 A 执行完毕后CPU 才会唤醒正在等待这把锁的线程 B 再次尝试获取锁。
如果线程 B 现在获取到了锁,才可以对同步资源进行自己的操作。这就是悲观锁的操作流程。
乐观锁
乐观锁比较乐观,认为自己在操作资源的时候不会有其他线程来干扰,所以并不会锁住被操作对象,不会不让别的线程来接触它,同时,为了确保数据正确性,在更新之前,会去对比在我修改数据期间,数据有没有被其他线程修改过:如果没被修改过,就说明真的只有我自己在操作,那我就可以正常的修改数据;如果发现数据和我一开始拿到的不一样了,说明其他线程在这段时间内修改过数据,那说明我迟了一步,所以我会放弃这次修改,并选择报错、重试等策略。
这和我们生活中乐天派的人的性格是一样的,乐观的人并不会担忧还没有发生的事情,相反,他会认为未来是美好的,所以他在修改数据之前,并不会把数据给锁住。当然,乐天派也不会盲目行动,如果他发现事情和他预想的不一样,也会有相应的处理办法,他不会坐以待毙,这就是乐观锁的思想。
乐观锁的实现一般都是利用 CAS 算法实现的。我们举个例子,假设线程 A 此时运用的是乐观锁。那么它去操作同步资源的时候,不需要提前获取到锁,而是可以直接去读取同步资源,并且在自己的线程内进行计算。
当它计算完毕之后、准备更新同步资源之前,会先判断这个资源是否已经被其他线程所修改过。
如果这个时候同步资源没有被其他线程修改更新,也就是说此时的数据和线程 A 最开始拿到的数据是一致的话,那么此时线程 A 就会去更新同步资源,完成修改的过程。
而假设此时的同步资源已经被其他线程修改更新了,线程 A 会发现此时的数据已经和最开始拿到的数据不一致了,那么线程 A 不会继续修改该数据,而是会根据不同的业务逻辑去选择报错或者重试。
悲观锁和乐观锁概念并不是 Java 中独有的,这是一种广义的思想,这种思想可以应用于其他领域,比如说在数据库中,同样也有对悲观锁和乐观锁的应用。
典型案例
悲观锁synchronized 关键字和 Lock 接口
Java 中悲观锁的实现包括 synchronized 关键字和 Lock 相关类等,我们以 Lock 接口为例,例如 Lock 的实现类 ReentrantLock类中的 lock() 等方法就是执行加锁,而 unlock() 方法是执行解锁。处理资源之前必须要先加锁并拿到锁,等到处理完了之后再解开锁,这就是非常典型的悲观锁思想。
乐观锁:原子类
乐观锁的典型案例就是原子类,例如 AtomicInteger 在更新数据时,就使用了乐观锁的思想,多个线程可以同时操作同一个原子变量。
大喜大悲:数据库
数据库中同时拥有悲观锁和乐观锁的思想。例如,我们如果在 MySQL 选择 select for update 语句,那就是悲观锁,在提交之前不允许第三方来修改该数据,这当然会造成一定的性能损耗,在高并发的情况下是不可取的。
相反,我们可以利用一个版本 version 字段在数据库中实现乐观锁。在获取及修改数据时都不需要加锁,但是我们在获取完数据并计算完毕,准备更新数据时,会检查版本号和获取数据时的版本号是否一致,如果一致就直接更新,如果不一致,说明计算期间已经有其他线程修改过这个数据了,那我就可以选择重新获取数据,重新计算,然后再次尝试更新数据。
SQL语句示例如下假设取出数据的时候 version 为1
UPDATE student
SET
name = ‘小李’,
version= 2
WHERE id= 100
AND version= 1
“汝之蜜糖,彼之砒霜”
有一种说法认为,悲观锁由于它的操作比较重量级,不能多个线程并行执行,而且还会有上下文切换等动作,所以悲观锁的性能不如乐观锁好,应该尽量避免用悲观锁,这种说法是不正确的。
因为虽然悲观锁确实会让得不到锁的线程阻塞,但是这种开销是固定的。悲观锁的原始开销确实要高于乐观锁,但是特点是一劳永逸,就算一直拿不到锁,也不会对开销造成额外的影响。
反观乐观锁虽然一开始的开销比悲观锁小,但是如果一直拿不到锁,或者并发量大,竞争激烈,导致不停重试,那么消耗的资源也会越来越多,甚至开销会超过悲观锁。
所以,同样是悲观锁,在不同的场景下,效果可能完全不同,可能在今天的这种场景下是好的选择,在明天的另外的场景下就是坏的选择,这恰恰是“汝之蜜糖,彼之砒霜”。
因此,我们就来看一下两种锁各自的使用场景,把合适的锁用到合适的场景中去,把合理的资源分配到合理的地方去。
两种锁各自的使用场景
悲观锁适合用于并发写入多、临界区代码复杂、竞争激烈等场景,这种场景下悲观锁可以避免大量的无用的反复尝试等消耗。
乐观锁适用于大部分是读取,少部分是修改的场景,也适合虽然读写都很多,但是并发并不激烈的场景。在这些场景下,乐观锁不加锁的特点能让性能大幅提高。

View File

@@ -0,0 +1,172 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
21 如何看到 synchronized 背后的“monitor 锁”?
本课时我们研究下 synchronized 背后的 monitor 锁。
获取和释放 monitor 锁的时机
我们都知道,最简单的同步方式就是利用 synchronized 关键字来修饰代码块或者修饰一个方法,那么这部分被保护的代码,在同一时刻就最多只有一个线程可以运行,而 synchronized 的背后正是利用 monitor 锁实现的。所以首先我们来看下获取和释放 monitor 锁的时机,每个 Java 对象都可以用作一个实现同步的锁,这个锁也被称为内置锁或 monitor 锁,获得 monitor 锁的唯一途径就是进入由这个锁保护的同步代码块或同步方法,线程在进入被 synchronized 保护的代码块之前,会自动获取锁,并且无论是正常路径退出,还是通过抛出异常退出,在退出的时候都会自动释放锁。
我们首先来看一个 synchronized 修饰方法的代码的例子:
public synchronized void method() {
method body
}
我们看到 method() 方法是被 synchronized 修饰的,为了方便理解其背后的原理,我们把上面这段代码改写为下面这种等价形式的伪代码。
public void method() {
this.intrinsicLock.lock();
try{
method body
}
finally {
this.intrinsicLock.unlock();
}
}
在这种写法中,进入 method 方法后,立刻添加内置锁,并且用 try 代码块把方法保护起来,最后用 finally 释放这把锁,这里的 intrinsicLock 就是 monitor 锁。经过这样的伪代码展开之后,相信你对 synchronized 的理解就更加清晰了。
用 javap 命令查看反汇编的结果
JVM 实现 synchronized 方法和 synchronized 代码块的细节是不一样的,下面我们就分别来看一下两者的实现。
同步代码块
首先我们来看下同步代码块的实现,如代码所示。
public class SynTest {
public void synBlock() {
synchronized (this) {
System.out.println("lagou");
}
}
}
在 SynTest 类中的 synBlock 方法包含一个同步代码块synchronized 代码块中有一行代码打印了 lagou 字符串,下面我们来通过命令看下 synchronized 关键字到底做了什么事情:首先用 cd 命令切换到 SynTest.java 类所在的路径,然后执行 javac SynTest.java于是就会产生一个名为 SynTest.class 的字节码文件,然后我们执行 javap -verbose SynTest.class就可以看到对应的反汇编内容。
关键信息如下:
public void synBlock();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=2, locals=3, args_size=1
0: aload_0
1: dup
2: astore_1
3: monitorenter
4: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
7: ldc #3 // String lagou
9: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
12: aload_1
13: monitorexit
14: goto 22
17: astore_2
18: aload_1
19: monitorexit
20: aload_2
21: athrow
22: return
从里面可以看出synchronized 代码块实际上多了 monitorenter 和 monitorexit 指令标红的第3、13、19行指令分别对应的是 monitorenter 和 monitorexit。这里有一个 monitorenter却有两个 monitorexit 指令的原因是JVM 要保证每个 monitorenter 必须有与之对应的 monitorexitmonitorenter 指令被插入到同步代码块的开始位置,而 monitorexit 需要插入到方法正常结束处和异常处两个地方,这样就可以保证抛异常的情况下也能释放锁
可以把执行 monitorenter 理解为加锁,执行 monitorexit 理解为释放锁,每个对象维护着一个记录着被锁次数的计数器。未被锁定的对象的该计数器为 0我们来具体看一下 monitorenter 和 monitorexit 的含义:
monitorenter
执行 monitorenter 的线程尝试获得 monitor 的所有权,会发生以下这三种情况之一:
a. 如果该 monitor 的计数为 0则线程获得该 monitor 并将其计数设置为 1。然后该线程就是这个 monitor 的所有者。
b. 如果线程已经拥有了这个 monitor ,则它将重新进入,并且累加计数。
c. 如果其他线程已经拥有了这个 monitor那个这个线程就会被阻塞直到这个 monitor 的计数变成为 0代表这个 monitor 已经被释放了,于是当前这个线程就会再次尝试获取这个 monitor。
monitorexit
monitorexit 的作用是将 monitor 的计数器减 1直到减为 0 为止。代表这个 monitor 已经被释放了,已经没有任何线程拥有它了,也就代表着解锁,所以,其他正在等待这个 monitor 的线程,此时便可以再次尝试获取这个 monitor 的所有权。
同步方法
从上面可以看出,同步代码块是使用 monitorenter 和 monitorexit 指令实现的。而对于 synchronized 方法,并不是依靠 monitorenter 和 monitorexit 指令实现的,被 javap 反汇编后可以看到synchronized 方法和普通方法大部分是一样的,不同在于,这个方法会有一个叫作 ACC_SYNCHRONIZED 的 flag 修饰符,来表明它是同步方法。
同步方法的代码如下所示。
public synchronized void synMethod() {
}
对应的反汇编指令如下所示。
public synchronized void synMethod();
descriptor: ()V
flags: ACC_PUBLIC, ACC_SYNCHRONIZED
Code:
stack=0, locals=1, args_size=1
0: return
LineNumberTable:
line 16: 0
可以看出,被 synchronized 修饰的方法会有一个 ACC_SYNCHRONIZED 标志。当某个线程要访问某个方法的时候,会首先检查方法是否有 ACC_SYNCHRONIZED 标志,如果有则需要先获得 monitor 锁,然后才能开始执行方法,方法执行之后再释放 monitor 锁。其他方面, synchronized 方法和刚才的 synchronized 代码块是很类似的,例如这时如果其他线程来请求执行方法,也会因为无法获得 monitor 锁而被阻塞。
好了,本课时的内容就全部讲完了,本课时我们讲解了获取和释放 monitor 的时机,以及被 synchronized 修饰的等价代码,然后我们还利用 javac 和 javap 命令查看了 synchronized 代码块以及 synchronized 方法所对应的的反汇编指令,其中同步代码块是利用 monitorenter 和 monitorexit 指令实现的,而同步方法则是利用 flags 实现的。

View File

@@ -0,0 +1,128 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
22 synchronized 和 Lock 孰优孰劣,如何选择?
本课时我们主要学习 synchronized 和 Lock 的异同点,以及该如何选择。
相同点
synchronized 和 Lock 的相同点非常多,我们这里重点讲解 3 个比较大的相同点。
synchronized 和 Lock 都是用来保护资源线程安全的。
这一点毋庸置疑,这是它们的基本作用。
都可以保证可见性。
对于 synchronized 而言,线程 A 在进入 synchronized 块之前或在 synchronized 块内进行操作,对于后续的获得同一个 monitor 锁的线程 B 是可见的,也就是线程 B 是可以看到线程 A 之前的操作的,这也体现了 happens-before 针对 synchronized 的一个原则。
而对于 Lock 而言,它和 synchronized 是一样,都可以保证可见性,如图所示,在解锁之前的所有操作对加锁之后的所有操作都是可见的。
如果你之前不了解什么是可见性,此时理解可能会有一定的困难,可以在学习本专栏的 Java 内存模型相关内容后,再复习本课时,就会豁然开朗。
synchronized 和 ReentrantLock 都拥有可重入的特点。
这里的 ReentrantLock 是 Lock 接口的一个最主要的实现类,在对比 synchronized 和 Lock 的时候,也会选择 Lock 的主要实现类来进行对比。可重入指的是某个线程如果已经获得了一个锁,现在试图再次请求这个它已经获得的锁,如果它无需提前释放这个锁,而是直接可以继续使用持有的这个锁,那么就是可重入的。如果必须释放锁后才能再次申请这个锁,就是不可重入的。而 synchronized 和 ReentrantLock 都具有可重入的特性。
不同点
下面我们来看下 synchronized 和 Lock 的区别,和相同点一样,它们之间也有非常多的区别,这里讲解其中比较大的 7 点不同。
用法区别
synchronized 关键字可以加在方法上,不需要指定锁对象(此时的锁对象为 this也可以新建一个同步代码块并且自定义 monitor 锁对象;而 Lock 接口必须显示用 Lock 锁对象开始加锁 lock() 和解锁 unlock(),并且一般会在 finally 块中确保用 unlock() 来解锁,以防发生死锁。
与 Lock 显式的加锁和解锁不同的是 synchronized 的加解锁是隐式的,尤其是抛异常的时候也能保证释放锁,但是 Java 代码中并没有相关的体现。
加解锁顺序不同
对于 Lock 而言如果有多把 Lock 锁Lock 可以不完全按照加锁的反序解锁,比如我们可以先获取 Lock1 锁,再获取 Lock2 锁,解锁时则先解锁 Lock1再解锁 Lock2加解锁有一定的灵活度如代码所示。
lock1.lock();
lock2.lock();
...
lock1.unlock();
lock2.unlock();
但是 synchronized 无法做到synchronized 解锁的顺序和加锁的顺序必须完全相反,例如:
synchronized(obj1){
synchronized(obj2){
...
}
}
那么在这里,顺序就是先对 obj1 加锁,然后对 obj2 加锁,然后对 obj2 解锁,最后解锁 obj1。这是因为 synchronized 加解锁是由 JVM 实现的,在执行完 synchronized 块后会自动解锁,所以会按照 synchronized 的嵌套顺序加解锁,不能自行控制。
synchronized 锁不够灵活
一旦 synchronized 锁已经被某个线程获得了,此时其他线程如果还想获得,那它只能被阻塞,直到持有锁的线程运行完毕或者发生异常从而释放这个锁。如果持有锁的线程持有很长时间才释放,那么整个程序的运行效率就会降低,而且如果持有锁的线程永远不释放锁,那么尝试获取锁的线程只能永远等下去。
相比之下Lock 类在等锁的过程中,如果使用的是 lockInterruptibly 方法,那么如果觉得等待的时间太长了不想再继续等待,可以中断退出,也可以用 tryLock() 等方法尝试获取锁,如果获取不到锁也可以做别的事,更加灵活。
synchronized 锁只能同时被一个线程拥有,但是 Lock 锁没有这个限制
例如在读写锁中的读锁,是可以同时被多个线程持有的,可是 synchronized 做不到。
原理区别
synchronized 是内置锁,由 JVM 实现获取锁和释放锁的原理,还分为偏向锁、轻量级锁、重量级锁。
Lock 根据实现不同,有不同的原理,例如 ReentrantLock 内部是通过 AQS 来获取和释放锁的。
是否可以设置公平/非公平
公平锁是指多个线程在等待同一个锁时根据先来后到的原则依次获得锁。ReentrantLock 等 Lock 实现类可以根据自己的需要来设置公平或非公平synchronized 则不能设置。
性能区别
在 Java 5 以及之前synchronized 的性能比较低,但是到了 Java 6 以后,发生了变化,因为 JDK 对 synchronized 进行了很多优化,比如自适应自旋、锁消除、锁粗化、轻量级锁、偏向锁等,所以后期的 Java 版本里的 synchronized 的性能并不比 Lock 差。
如何选择
讲完了 synchronized 和 Lock 的相同点和区别,最后我们再来看下如何选择它们,在 Java 并发编程实战和 Java 核心技术里都认为:
如果能不用最好既不使用 Lock 也不使用 synchronized。因为在许多情况下你可以使用 java.util.concurrent 包中的机制,它会为你处理所有的加锁和解锁操作,也就是推荐优先使用工具类来加解锁。
如果 synchronized 关键字适合你的程序, 那么请尽量使用它,这样可以减少编写代码的数量,减少出错的概率。因为一旦忘记在 finally 里 unlock代码可能会出很大的问题而使用 synchronized 更安全。
如果特别需要 Lock 的特殊功能,比如尝试获取锁、可中断、超时功能等,才使用 Lock。

View File

@@ -0,0 +1,190 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
23 Lock 有哪几个常用方法?分别有什么用?
本课时我们主要讲解 Lock 有哪几种常用的方法,以及它们分别都是干什么用的。
简介
Lock 接口是 Java 5 引入的,最常见的实现类是 ReentrantLock可以起到“锁”的作用。
Lock 和 synchronized 是两种最常见的锁,锁是一种工具,用于控制对共享资源的访问,而 Lock 和 synchronized 都可以达到线程安全的目的,但是在使用上和功能上又有较大的不同。所以 Lock 并不是用来代替 synchronized 的,而是当使用 synchronized 不合适或不足以满足要求的时候Lock 可以用来提供更高级功能的。
通常情况下Lock 只允许一个线程来访问这个共享资源。不过有的时候,一些特殊的实现也可允许并发访问,比如 ReadWriteLock 里面的 ReadLock。
方法纵览
我们首先看下 Lock 接口的各个方法,如代码所示。
public interface Lock {
void lock();
void lockInterruptibly() throws InterruptedException;
boolean tryLock();
boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
void unlock();
Condition newCondition();
}
我们可以看到与 Lock 接口加解锁相关的主要有 5 个方法,我们接下来重点分析这 5 种方法的作用和用法,这 5 种方法分别是 lock()、tryLock()、tryLock(long time, TimeUnit unit) 和 lockInterruptibly()、unlock()。
lock() 方法
在 Lock 接口中声明了 4 种方法来获取锁lock()、tryLock()、tryLock(long time, TimeUnit unit)和lockInterruptibly()),那么这 4 种方法具体有什么区别呢?
首先lock() 是最基础的获取锁的方法。在线程获取锁时如果锁已被其他线程获取,则进行等待,是最初级的获取锁的方法。
对于 Lock 接口而言,获取锁和释放锁都是显式的,不像 synchronized 那样是隐式的,所以 Lock 不会像 synchronized 一样在异常时自动释放锁synchronized 即使不写对应的代码也可以释放lock 的加锁和释放锁都必须以代码的形式写出来,所以使用 lock() 时必须由我们自己主动去释放锁,因此最佳实践是执行 lock() 后,首先在 try{} 中操作同步资源,如果有必要就用 catch{} 块捕获异常,然后在 finally{} 中释放锁,以保证发生异常时锁一定被释放,示例代码如下所示。
Lock lock = ...;
lock.lock();
try{
//获取到了被本锁保护的资源,处理任务
//捕获异常
}finally{
lock.unlock(); //释放锁
}
在这段代码中我们创建了一个 Lock并且用 Lock 方法加锁,然后立刻在 try 代码块中进行相关业务逻辑的处理,如果有需要还可以进行 catch 来捕获异常,但是最重要的是 finally大家一定不要忘记在 finally 中添加 unlock() 方法,以便保障锁的绝对释放。
如果我们不遵守在 finally 里释放锁的规范,就会让 Lock 变得非常危险,因为你不知道未来什么时候由于异常的发生,导致跳过了 unlock() 语句,使得这个锁永远不能被释放了,其他线程也无法再获得这个锁,这就是 Lock 相比于 synchronized 的一个劣势,使用 synchronized 时不需要担心这个问题。
与此同时lock() 方法不能被中断这会带来很大的隐患一旦陷入死锁lock() 就会陷入永久等待,所以一般我们用 tryLock() 等其他更高级的方法来代替 lock(),下面我们就看一看 tryLock() 方法。
tryLock()
tryLock() 用来尝试获取锁,如果当前锁没有被其他线程占用,则获取成功,返回 true否则返回 false代表获取锁失败。相比于 lock(),这样的方法显然功能更强大,我们可以根据是否能获取到锁来决定后续程序的行为。
因为该方法会立即返回,即便在拿不到锁时也不会一直等待,所以通常情况下,我们用 if 语句判断 tryLock() 的返回结果,根据是否获取到锁来执行不同的业务逻辑,典型使用方法如下。
Lock lock = ...;
if(lock.tryLock()) {
try{
//处理任务
}finally{
lock.unlock(); //释放锁
}
}else {
//如果不能获取锁,则做其他事情
}
我们创建 lock() 方法之后使用 tryLock() 方法并用 if 语句判断它的结果,如果 if 语句返回 true就使用 try finally 完成相关业务逻辑的处理,如果 if 语句返回 false 就会进入 else 语句,代表它暂时不能获取到锁,可以先去做一些其他事情,比如等待几秒钟后重试,或者跳过这个任务,有了这个强大的 tryLock() 方法我们便可以解决死锁问题,代码如下所示。
public void tryLock(Lock lock1, Lock lock2) throws InterruptedException {
while (true) {
if (lock1.tryLock()) {
try {
if (lock2.tryLock()) {
try {
System.out.println("获取到了两把锁,完成业务逻辑");
return;
} finally {
lock2.unlock();
}
}
} finally {
lock1.unlock();
}
} else {
Thread.sleep(new Random().nextInt(1000));
}
}
}
如果代码中我们不用 tryLock() 方法,那么便可能会产生死锁,比如有两个线程同时调用这个方法,传入的 lock1 和 lock2 恰好是相反的,那么如果第一个线程获取了 lock1 的同时,第二个线程获取了 lock2它们接下来便会尝试获取对方持有的那把锁但是又获取不到于是便会陷入死锁但是有了 tryLock() 方法之后,我们便可以避免死锁的发生,首先会检测 lock1 是否能获取到,如果能获取到再尝试获取 lock2但如果 lock1 获取不到也没有关系,我们会在下面进行随机时间的等待,这个等待的目标是争取让其他的线程在这段时间完成它的任务,以便释放其他线程所持有的锁,以便后续供我们使用,同理如果获取到了 lock1 但没有获取到 lock2那么也会释放掉 lock1随即进行随机的等待只有当它同时获取到 lock1 和 lock2 的时候,才会进入到里面执行业务逻辑,比如在这里我们会打印出“获取到了两把锁,完成业务逻辑”,然后方法便会返回。
tryLock(long time, TimeUnit unit)
tryLock() 的重载方法是 tryLock(long time, TimeUnit unit),这个方法和 tryLock() 很类似,区别在于 tryLock(long time, TimeUnit unit) 方法会有一个超时时间,在拿不到锁时会等待一定的时间,如果在时间期限结束后,还获取不到锁,就会返回 false如果一开始就获取锁或者等待期间内获取到锁则返回 true。
这个方法解决了 lock() 方法容易发生死锁的问题,使用 tryLock(long time, TimeUnit unit) 时,在等待了一段指定的超时时间后,线程会主动放弃这把锁的获取,避免永久等待;在等待的期间,也可以随时中断线程,这就避免了死锁的发生。本方法和下面介绍的 lockInterruptibly() 是非常类似的,让我们来看一下 lockInterruptibly() 方法。
lockInterruptibly()
这个方法的作用就是去获取锁,如果这个锁当前是可以获得的,那么这个方法会立刻返回,但是如果这个锁当前是不能获得的(被其他线程持有),那么当前线程便会开始等待,除非它等到了这把锁或者是在等待的过程中被中断了,否则这个线程便会一直在这里执行这行代码。一句话总结就是,除非当前线程在获取锁期间被中断,否则便会一直尝试获取直到获取到为止。
顾名思义lockInterruptibly() 是可以响应中断的。相比于不能响应中断的 synchronized 锁lockInterruptibly() 可以让程序更灵活,可以在获取锁的同时,保持对中断的响应。我们可以把这个方法理解为超时时间是无穷长的 tryLock(long time, TimeUnit unit),因为 tryLock(long time, TimeUnit unit) 和 lockInterruptibly() 都能响应中断,只不过 lockInterruptibly() 永远不会超时。
这个方法本身是会抛出 InterruptedException 的,所以使用的时候,如果不在方法签名声明抛出该异常,那么就要写两个 try 块,如下所示。
public void lockInterruptibly() {
try {
lock.lockInterruptibly();
try {
System.out.println("操作资源");
} finally {
lock.unlock();
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
在这个方法中我们首先执行了 lockInterruptibly 方法,并且对它进行了 try catch 包装,然后同样假设我们能够获取到这把锁,和之前一样,就必须要使用 try finall 来保障锁的绝对释放。
unlock()
最后要介绍的方法是 unlock() 方法是用于解锁的u方法比较简单对于 ReentrantLock 而言,执行 unlock() 的时候,内部会把锁的“被持有计数器”减 1直到减到 0 就代表当前这把锁已经完全释放了,如果减 1 后计数器不为 0说明这把锁之前被“重入”了那么锁并没有真正释放仅仅是减少了持有的次数。

View File

@@ -0,0 +1,463 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
24 讲一讲公平锁和非公平锁,为什么要“非公平”?
本课时我们主要讲一讲公平锁和非公平锁,以及为什么要“非公平”?
什么是公平和非公平
首先,我们来看下什么是公平锁和非公平锁,公平锁指的是按照线程请求的顺序,来分配锁;而非公平锁指的是不完全按照请求的顺序,在一定情况下,可以允许插队。但需要注意这里的非公平并不是指完全的随机,不是说线程可以任意插队,而是仅仅“在合适的时机”插队。
那么什么时候是合适的时机呢?假设当前线程在请求获取锁的时候,恰巧前一个持有锁的线程释放了这把锁,那么当前申请锁的线程就可以不顾已经等待的线程而选择立刻插队。但是如果当前线程请求的时候,前一个线程并没有在那一时刻释放锁,那么当前线程还是一样会进入等待队列。
为了能够更好的理解公平锁和非公平锁,我们举一个生活中的例子,假设我们还在学校读书,去食堂排队买饭,我排在队列的第二个,我前面还有一位同学,但此时我脑子里想的不是午饭,而是上午的一道数学题并陷入深思,所以当前面的同学打完饭之后轮到我时我走神了,并也没注意到现在轮到我了,此时前面的同学突然又回来插队,说“不好意思,阿姨麻烦给我加个鸡腿”,像这样的行为就可以类比我们的公平锁和非公平锁。
看到这里,你可能不解,为什么要设置非公平策略呢,而且非公平还是 ReentrantLock的默认策略如果我们不加以设置的话默认就是非公平的难道我的这些排队的时间都白白浪费了吗为什么别人比我有优先权呢毕竟公平是一种很好的行为而非公平是一种不好的行为。
让我们考虑一种情况,假设线程 A 持有一把锁,线程 B 请求这把锁,由于线程 A 已经持有这把锁了,所以线程 B 会陷入等待,在等待的时候线程 B 会被挂起,也就是进入阻塞状态,那么当线程 A 释放锁的时候,本该轮到线程 B 苏醒获取锁,但如果此时突然有一个线程 C 插队请求这把锁,那么根据非公平的策略,会把这把锁给线程 C这是因为唤醒线程 B 是需要很大开销的,很有可能在唤醒之前,线程 C 已经拿到了这把锁并且执行完任务释放了这把锁。相比于等待唤醒线程 B 的漫长过程,插队的行为会让线程 C 本身跳过陷入阻塞的过程,如果在锁代码中执行的内容不多的话,线程 C 就可以很快完成任务,并且在线程 B 被完全唤醒之前,就把这个锁交出去,这样是一个双赢的局面,对于线程 C 而言,不需要等待提高了它的效率,而对于线程 B 而言,它获得锁的时间并没有推迟,因为等它被唤醒的时候,线程 C 早就释放锁了,因为线程 C 的执行速度相比于线程 B 的唤醒速度,是很快的,所以 Java 设计者设计非公平锁,是为了提高整体的运行效率。
公平的场景
下面我们用图示来说明公平和非公平的场景,先来看公平的情况。假设我们创建了一个公平锁,此时有 4 个线程按顺序来请求公平锁,线程 1 在拿到这把锁之后,线程 2、3、4 会在等待队列中开始等待,然后等线程 1 释放锁之后,线程 2、3、4 会依次去获取这把锁,线程 2 先获取到的原因是它等待的时间最长。
不公平的场景
下面我们再来看看非公平的情况,假设线程 1 在解锁的时候,突然有线程 5 尝试获取这把锁,那么根据我们的非公平策略,线程 5 是可以拿到这把锁的,尽管它没有进入等待队列,而且线程 2、3、4 等待的时间都比线程 5 要长,但是从整体效率考虑,这把锁此时还是会交给线程 5 持有。
代码案例:演示公平和非公平的效果
下面我们来用代码演示看下公平和非公平的实际效果,代码如下:
/**
* 描述演示公平锁分别展示公平和不公平的情况非公平锁会让现在持有锁的线程优先再次获取到锁。代码借鉴自Java并发编程实战手册2.7。
*/
public class FairAndUnfair {
public static void main(String args[]) {
PrintQueue printQueue = new PrintQueue();
Thread thread[] = new Thread[10];
for (int i = 0; i < 10; i++) {
thread[i] = new Thread(new Job(printQueue), "Thread " + i);
}
for (int i = 0; i < 10; i++) {
thread[i].start();
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
class Job implements Runnable {
private PrintQueue printQueue;
public Job(PrintQueue printQueue) {
this.printQueue = printQueue;
}
@Override
public void run() {
System.out.printf("%s: Going to print a job\n", Thread.currentThread().getName());
printQueue.printJob(new Object());
System.out.printf("%s: The document has been printed\n", Thread.currentThread().getName());
}
}
class PrintQueue {
private final Lock queueLock = new ReentrantLock(false);
public void printJob(Object document) {
queueLock.lock();
try {
Long duration = (long) (Math.random() * 10000);
System.out.printf("%s: PrintQueue: Printing a Job during %d seconds\n",
Thread.currentThread().getName(), (duration / 1000));
Thread.sleep(duration);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
queueLock.unlock();
}
queueLock.lock();
try {
Long duration = (long) (Math.random() * 10000);
System.out.printf("%s: PrintQueue: Printing a Job during %d seconds\n",
Thread.currentThread().getName(), (duration / 1000));
Thread.sleep(duration);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
queueLock.unlock();
}
}
}
我们可以通过改变 new ReentrantLock(false) 中的参数来设置公平/非公平锁以上代码在公平的情况下的输出
Thread 0: Going to print a job
Thread 0: PrintQueue: Printing a Job during 5 seconds
Thread 1: Going to print a job
Thread 2: Going to print a job
Thread 3: Going to print a job
Thread 4: Going to print a job
Thread 5: Going to print a job
Thread 6: Going to print a job
Thread 7: Going to print a job
Thread 8: Going to print a job
Thread 9: Going to print a job
Thread 1: PrintQueue: Printing a Job during 3 seconds
Thread 2: PrintQueue: Printing a Job during 4 seconds
Thread 3: PrintQueue: Printing a Job during 3 seconds
Thread 4: PrintQueue: Printing a Job during 9 seconds
Thread 5: PrintQueue: Printing a Job during 5 seconds
Thread 6: PrintQueue: Printing a Job during 7 seconds
Thread 7: PrintQueue: Printing a Job during 3 seconds
Thread 8: PrintQueue: Printing a Job during 9 seconds
Thread 9: PrintQueue: Printing a Job during 5 seconds
Thread 0: PrintQueue: Printing a Job during 8 seconds
Thread 0: The document has been printed
Thread 1: PrintQueue: Printing a Job during 1 seconds
Thread 1: The document has been printed
Thread 2: PrintQueue: Printing a Job during 8 seconds
Thread 2: The document has been printed
Thread 3: PrintQueue: Printing a Job during 2 seconds
Thread 3: The document has been printed
Thread 4: PrintQueue: Printing a Job during 0 seconds
Thread 4: The document has been printed
Thread 5: PrintQueue: Printing a Job during 7 seconds
Thread 5: The document has been printed
Thread 6: PrintQueue: Printing a Job during 3 seconds
Thread 6: The document has been printed
Thread 7: PrintQueue: Printing a Job during 9 seconds
Thread 7: The document has been printed
Thread 8: PrintQueue: Printing a Job during 5 seconds
Thread 8: The document has been printed
Thread 9: PrintQueue: Printing a Job during 9 seconds
Thread 9: The document has been printed
可以看出线程直接获取锁的顺序是完全公平的先到先得
而以上代码在非公平的情况下的输出是这样的
Thread 0: Going to print a job
Thread 0: PrintQueue: Printing a Job during 6 seconds
Thread 1: Going to print a job
Thread 2: Going to print a job
Thread 3: Going to print a job
Thread 4: Going to print a job
Thread 5: Going to print a job
Thread 6: Going to print a job
Thread 7: Going to print a job
Thread 8: Going to print a job
Thread 9: Going to print a job
Thread 0: PrintQueue: Printing a Job during 8 seconds
Thread 0: The document has been printed
Thread 1: PrintQueue: Printing a Job during 9 seconds
Thread 1: PrintQueue: Printing a Job during 8 seconds
Thread 1: The document has been printed
Thread 2: PrintQueue: Printing a Job during 6 seconds
Thread 2: PrintQueue: Printing a Job during 4 seconds
Thread 2: The document has been printed
Thread 3: PrintQueue: Printing a Job during 9 seconds
Thread 3: PrintQueue: Printing a Job during 8 seconds
Thread 3: The document has been printed
Thread 4: PrintQueue: Printing a Job during 4 seconds
Thread 4: PrintQueue: Printing a Job during 2 seconds
Thread 4: The document has been printed
Thread 5: PrintQueue: Printing a Job during 2 seconds
Thread 5: PrintQueue: Printing a Job during 5 seconds
Thread 5: The document has been printed
Thread 6: PrintQueue: Printing a Job during 2 seconds
Thread 6: PrintQueue: Printing a Job during 6 seconds
Thread 6: The document has been printed
Thread 7: PrintQueue: Printing a Job during 6 seconds
Thread 7: PrintQueue: Printing a Job during 4 seconds
Thread 7: The document has been printed
Thread 8: PrintQueue: Printing a Job during 3 seconds
Thread 8: PrintQueue: Printing a Job during 6 seconds
Thread 8: The document has been printed
Thread 9: PrintQueue: Printing a Job during 3 seconds
Thread 9: PrintQueue: Printing a Job during 5 seconds
Thread 9: The document has been printed
可以看出非公平情况下存在抢锁插队的现象比如Thread 0 在释放锁后又能优先获取到锁虽然此时在等待队列中已经有 Thread 1 ~ Thread 9 在排队了
对比公平和非公平的优缺点
我们接下来对比公平和非公平的优缺点如表格所示
公平锁的优点在于各个线程公平平等每个线程等待一段时间后都有执行的机会而它的缺点就在于整体执行速度更慢吞吐量更小相反非公平锁的优势就在于整体执行速度更快吞吐量更大但同时也可能产生线程饥饿问题也就是说如果一直有线程插队那么在等待队列中的线程可能长时间得不到运行
源码分析
下面我们来分析公平和非公平锁的源码具体看下它们是怎样实现的可以看到在 ReentrantLock 类包含一个 Sync 这个类继承自AQSAbstractQueuedSynchronizer代码如下
public class ReentrantLock implements Lock, java.io.Serializable {
private static final long serialVersionUID = 7373984872572414699L;
/** Synchronizer providing all implementation mechanics */
private final Sync sync;
Sync 类的代码
abstract static class Sync extends AbstractQueuedSynchronizer {...}
根据代码可知Sync 有公平锁 FairSync 和非公平锁 NonfairSync两个子类
static final class NonfairSync extends Sync {...}
static final class FairSync extends Sync {...}
下面我们来看一下公平锁与非公平锁的加锁方法的源码
公平锁的锁获取源码如下
protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
if (!hasQueuedPredecessors() && //这里判断了 hasQueuedPredecessors()
compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
} else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0) {
throw new Error("Maximum lock count exceeded");
}
setState(nextc);
return true;
}
return false;
}
非公平锁的锁获取源码如下
final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
if (compareAndSetState(0, acquires)) { //这里没有判断 hasQueuedPredecessors()
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0) // overflow
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
通过对比我们可以明显的看出公平锁与非公平锁的 lock() 方法唯一的区别就在于公平锁在获取锁时多了一个限制条件hasQueuedPredecessors() false这个方法就是判断在等待队列中是否已经有线程在排队了这也就是公平锁和非公平锁的核心区别如果是公平锁那么一旦已经有线程在排队了当前线程就不再尝试获取锁对于非公平锁而言无论是否已经有线程在排队都会尝试获取一下锁获取不到的话再去排队
这里有一个特例需要我们注意针对 tryLock() 方法它不遵守设定的公平原则
例如当有线程执行 tryLock() 方法的时候一旦有线程释放了锁那么这个正在 tryLock 的线程就能获取到锁即使设置的是公平锁模式即使在它之前已经有其他正在等待队列中等待的线程简单地说就是 tryLock 可以插队
看它的源码就会发现
public boolean tryLock() {
return sync.nonfairTryAcquire(1);
}
这里调用的就是 nonfairTryAcquire()表明了是不公平的和锁本身是否是公平锁无关
综上所述公平锁就是会按照多个线程申请锁的顺序来获取锁从而实现公平的特性非公平锁加锁时不考虑排队等待情况直接尝试获取锁所以存在后申请却先获得锁的情况但由此也提高了整体的效率

View File

@@ -0,0 +1,147 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
25 读写锁 ReadWriteLock 获取锁有哪些规则?
在本课时我们主要讲解读写锁 ReadWriteLock 获取锁有哪些规则呢?
在没有读写锁之前,我们假设使用普通的 ReentrantLock那么虽然我们保证了线程安全但是也浪费了一定的资源因为如果多个读操作同时进行其实并没有线程安全问题我们可以允许让多个读操作并行以便提高程序效率。
但是写操作不是线程安全的,如果多个线程同时写,或者在写的同时进行读操作,便会造成线程安全问题。
我们的读写锁就解决了这样的问题,它设定了一套规则,既可以保证多个线程同时读的效率,同时又可以保证有写入操作时的线程安全。
整体思路是它有两把锁,第 1 把锁是写锁,获得写锁之后,既可以读数据又可以修改数据,而第 2 把锁是读锁,获得读锁之后,只能查看数据,不能修改数据。读锁可以被多个线程同时持有,所以多个线程可以同时查看数据。
在读的地方合理使用读锁,在写的地方合理使用写锁,灵活控制,可以提高程序的执行效率。
读写锁的获取规则
我们在使用读写锁时遵守下面的获取规则:
如果有一个线程已经占用了读锁,则此时其他线程如果要申请读锁,可以申请成功。
如果有一个线程已经占用了读锁,则此时其他线程如果要申请写锁,则申请写锁的线程会一直等待释放读锁,因为读写不能同时操作。
如果有一个线程已经占用了写锁,则此时其他线程如果申请写锁或者读锁,都必须等待之前的线程释放写锁,同样也因为读写不能同时,并且两个线程不应该同时写。
所以我们用一句话总结:要么是一个或多个线程同时有读锁,要么是一个线程有写锁,但是两者不会同时出现。也可以总结为:读读共享、其他都互斥(写写互斥、读写互斥、写读互斥)。
使用案例
下面我们举个例子来应用读写锁ReentrantReadWriteLock 是 ReadWriteLock 的实现类最主要的有两个方法readLock() 和 writeLock() 用来获取读锁和写锁。
代码如下:
/**
* 描述: 演示读写锁用法
*/
public class ReadWriteLockDemo {
private static final ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock(
false);
private static final ReentrantReadWriteLock.ReadLock readLock = reentrantReadWriteLock
.readLock();
private static final ReentrantReadWriteLock.WriteLock writeLock = reentrantReadWriteLock
.writeLock();
private static void read() {
readLock.lock();
try {
System.out.println(Thread.currentThread().getName() + "得到读锁,正在读取");
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
System.out.println(Thread.currentThread().getName() + "释放读锁");
readLock.unlock();
}
}
private static void write() {
writeLock.lock();
try {
System.out.println(Thread.currentThread().getName() + "得到写锁,正在写入");
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
System.out.println(Thread.currentThread().getName() + "释放写锁");
writeLock.unlock();
}
}
public static void main(String[] args) throws InterruptedException {
new Thread(() -> read()).start();
new Thread(() -> read()).start();
new Thread(() -> write()).start();
new Thread(() -> write()).start();
}
}
程序的运行结果是:
Thread-0得到读锁正在读取
Thread-1得到读锁正在读取
Thread-0释放读锁
Thread-1释放读锁
Thread-2得到写锁正在写入
Thread-2释放写锁
Thread-3得到写锁正在写入
Thread-3释放写锁
可以看出,读锁可以同时被多个线程获得,而写锁不能。
读写锁适用场合
最后我们来看下读写锁的适用场合,相比于 ReentrantLock 适用于一般场合ReadWriteLock 适用于读多写少的情况,合理使用可以进一步提高并发效率。

View File

@@ -0,0 +1,343 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
26 读锁应该插队吗?什么是读写锁的升降级?
在本课时我们主要讲解读锁应该插队吗?以及什么是读写锁的升降级。
读锁插队策略
首先,我们来看一下读锁的插队策略,在这里先快速回顾一下在 24 课时公平与非公平锁中讲到的 ReentrantLock如果锁被设置为非公平那么它是可以在前面线程释放锁的瞬间进行插队的而不需要进行排队。在读写锁这里策略也是这样的吗
首先,我们看到 ReentrantReadWriteLock 可以设置为公平或者非公平,代码如下:
公平锁:
ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock(true);
非公平锁:
ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock(false);
如果是公平锁,我们就在构造函数的参数中传入 true如果是非公平锁就在构造函数的参数中传入 false默认是非公平锁。在获取读锁之前线程会检查 readerShouldBlock() 方法,同样,在获取写锁之前,线程会检查 writerShouldBlock() 方法,来决定是否需要插队或者是去排队。
首先看公平锁对于这两个方法的实现:
final boolean writerShouldBlock() {
return hasQueuedPredecessors();
}
final boolean readerShouldBlock() {
return hasQueuedPredecessors();
}
很明显,在公平锁的情况下,只要等待队列中有线程在等待,也就是 hasQueuedPredecessors() 返回 true 的时候,那么 writer 和 reader 都会 block也就是一律不允许插队都乖乖去排队这也符合公平锁的思想。
下面让我们来看一下非公平锁的实现:
final boolean writerShouldBlock() {
return false; // writers can always barge
}
final boolean readerShouldBlock() {
return apparentlyFirstQueuedIsExclusive();
}
在 writerShouldBlock() 这个方法中始终返回 false可以看出对于想获取写锁的线程而言由于返回值是 false所以它是随时可以插队的这就和我们的 ReentrantLock 的设计思想是一样的,但是读锁却不一样。这里实现的策略很有意思,先让我们来看下面这种场景:
假设线程 2 和线程 4 正在同时读取,线程 3 想要写入,但是由于线程 2 和线程 4 已经持有读锁了,所以线程 3 就进入等待队列进行等待。此时,线程 5 突然跑过来想要插队获取读锁:
面对这种情况有两种应对策略:
第一种策略:允许插队
由于现在有线程在读,而线程 5 又不会特别增加它们读的负担,因为线程们可以共用这把锁,所以第一种策略就是让线程 5 直接加入到线程 2 和线程 4 一起去读取。
这种策略看上去增加了效率,但是有一个严重的问题,那就是如果想要读取的线程不停地增加,比如线程 6那么线程 6 也可以插队,这就会导致读锁长时间内不会被释放,导致线程 3 长时间内拿不到写锁,也就是那个需要拿到写锁的线程会陷入“饥饿”状态,它将在长时间内得不到执行。
第二种策略:不允许插队
这种策略认为由于线程 3 已经提前等待了,所以虽然线程 5 如果直接插队成功,可以提高效率,但是我们依然让线程 5 去排队等待:
按照这种策略线程 5 会被放入等待队列中,并且排在线程 3 的后面,让线程 3 优先于线程 5 执行,这样可以避免“饥饿”状态,这对于程序的健壮性是很有好处的,直到线程 3 运行完毕,线程 5 才有机会运行,这样谁都不会等待太久的时间。
所以我们可以看出,即便是非公平锁,只要等待队列的头结点是尝试获取写锁的线程,那么读锁依然是不能插队的,目的是避免“饥饿”。
策略选择演示
策略的选择取决于具体锁的实现ReentrantReadWriteLock 的实现选择了策略 2 ,是很明智的。
下面我们就用实际的代码来演示一下上面这种场景。
策略演示代码如下所示:
/**
* 描述: 演示读锁不插队
*/
public class ReadLockJumpQueue {
private static final ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock();
private static final ReentrantReadWriteLock.ReadLock readLock = reentrantReadWriteLock
.readLock();
private static final ReentrantReadWriteLock.WriteLock writeLock = reentrantReadWriteLock
.writeLock();
private static void read() {
readLock.lock();
try {
System.out.println(Thread.currentThread().getName() + "得到读锁,正在读取");
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
System.out.println(Thread.currentThread().getName() + "释放读锁");
readLock.unlock();
}
}
private static void write() {
writeLock.lock();
try {
System.out.println(Thread.currentThread().getName() + "得到写锁,正在写入");
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
System.out.println(Thread.currentThread().getName() + "释放写锁");
writeLock.unlock();
}
}
public static void main(String[] args) throws InterruptedException {
new Thread(() -> read(),"Thread-2").start();
new Thread(() -> read(),"Thread-4").start();
new Thread(() -> write(),"Thread-3").start();
new Thread(() -> read(),"Thread-5").start();
}
}
以上代码的运行结果是:
Thread-2得到读锁正在读取
Thread-4得到读锁正在读取
Thread-2释放读锁
Thread-4释放读锁
Thread-3得到写锁正在写入
Thread-3释放写锁
Thread-5得到读锁正在读取
Thread-5释放读锁
从这个结果可以看出ReentrantReadWriteLock 的实现选择了“不允许插队”的策略,这就大大减小了发生“饥饿”的概率。(如果运行结果和课程不一致,可以在每个线程启动后增加 100ms 的睡眠时间,以便保证线程的运行顺序)。
锁的升降级
读写锁降级功能代码演示
下面我们再来看一下锁的升降级,首先我们看一下这段代码,这段代码演示了在更新缓存的时候,如何利用锁的降级功能。
public class CachedData {
Object data;
volatile boolean cacheValid;
final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
void processCachedData() {
rwl.readLock().lock();
if (!cacheValid) {
//在获取写锁之前,必须首先释放读锁。
rwl.readLock().unlock();
rwl.writeLock().lock();
try {
//这里需要再次判断数据的有效性,因为在我们释放读锁和获取写锁的空隙之内,可能有其他线程修改了数据。
if (!cacheValid) {
data = new Object();
cacheValid = true;
}
//在不释放写锁的情况下,直接获取读锁,这就是读写锁的降级。
rwl.readLock().lock();
} finally {
//释放了写锁,但是依然持有读锁
rwl.writeLock().unlock();
}
}
try {
System.out.println(data);
} finally {
//释放读锁
rwl.readLock().unlock();
}
}
}
在这段代码中有一个读写锁,最重要的就是中间的 processCachedData 方法在这个方法中会首先获取到读锁也就是rwl.readLock().lock(),它去判断当前的缓存是否有效,如果有效那么就直接跳过整个 if 语句,如果已经失效,代表我们需要更新这个缓存了。由于我们需要更新缓存,所以之前获取到的读锁是不够用的,我们需要获取写锁。
在获取写锁之前,我们首先释放读锁,然后利用 rwl.writeLock().lock() 来获取到写锁,然后是经典的 try finally 语句,在 try 语句中我们首先判断缓存是否有效,因为在刚才释放读锁和获取写锁的过程中,可能有其他线程抢先修改了数据,所以在此我们需要进行二次判断。
如果我们发现缓存是无效的,就用 new Object() 这样的方式来示意,获取到了新的数据内容,并把缓存的标记位设置为 ture让缓存变得有效。由于我们后续希望打印出 data 的值所以不能在此处释放掉所有的锁。我们的选择是在不释放写锁的情况下直接获取读锁也就是rwl.readLock().lock() 这行语句所做的事情,然后,在持有读锁的情况下释放写锁,最后,在最下面的 try 中把 data 的值打印出来。
这就是一个非常典型的利用锁的降级功能的代码。
你可能会想,我为什么要这么麻烦进行降级呢?我一直持有最高等级的写锁不就可以了吗?这样谁都没办法来影响到我自己的工作,永远是线程安全的。
为什么需要锁的降级?
如果我们在刚才的方法中,一直使用写锁,最后才释放写锁的话,虽然确实是线程安全的,但是也是没有必要的,因为我们只有一处修改数据的代码:
data = new Object();
后面我们对于 data 仅仅是读取。如果还一直使用写锁的话,就不能让多个线程同时来读取了,持有写锁是浪费资源的,降低了整体的效率,所以这个时候利用锁的降级是很好的办法,可以提高整体性能。
支持锁的降级,不支持升级
如果我们运行下面这段代码,在不释放读锁的情况下直接尝试获取写锁,也就是锁的升级,会让线程直接阻塞,程序是无法运行的。
final static ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
public static void main(String[] args) {
upgrade();
}
public static void upgrade() {
rwl.readLock().lock();
System.out.println("获取到了读锁");
rwl.writeLock().lock();
System.out.println("成功升级");
}
这段代码会打印出“获取到了读锁”,但是却不会打印出“成功升级”,因为 ReentrantReadWriteLock 不支持读锁升级到写锁。
为什么不支持锁的升级?
我们知道读写锁的特点是如果线程都申请读锁,是可以多个线程同时持有的,可是如果是写锁,只能有一个线程持有,并且不可能存在读锁和写锁同时持有的情况。
正是因为不可能有读锁和写锁同时持有的情况,所以升级写锁的过程中,需要等到所有的读锁都释放,此时才能进行升级。
假设有 AB 和 C 三个线程,它们都已持有读锁。假设线程 A 尝试从读锁升级到写锁。那么它必须等待 B 和 C 释放掉已经获取到的读锁。如果随着时间推移B 和 C 逐渐释放了它们的读锁,此时线程 A 确实是可以成功升级并获取写锁。
但是我们考虑一种特殊情况。假设线程 A 和 B 都想升级到写锁,那么对于线程 A 而言,它需要等待其他所有线程,包括线程 B 在内释放读锁。而线程 B 也需要等待所有的线程,包括线程 A 释放读锁。这就是一种非常典型的死锁的情况。谁都愿不愿意率先释放掉自己手中的锁。
但是读写锁的升级并不是不可能的,也有可以实现的方案,如果我们保证每次只有一个线程可以升级,那么就可以保证线程安全。只不过最常见的 ReentrantReadWriteLock 对此并不支持。
总结
对于 ReentrantReadWriteLock 而言。
插队策略
公平策略下,只要队列里有线程已经在排队,就不允许插队。
非公平策略下:
如果允许读锁插队,那么由于读锁可以同时被多个线程持有,所以可能造成源源不断的后面的线程一直插队成功,导致读锁一直不能完全释放,从而导致写锁一直等待,为了防止“饥饿”,在等待队列的头结点是尝试获取写锁的线程的时候,不允许读锁插队。
写锁可以随时插队,因为写锁并不容易插队成功,写锁只有在当前没有任何其他线程持有读锁和写锁的时候,才能插队成功,同时写锁一旦插队失败就会进入等待队列,所以很难造成“饥饿”的情况,允许写锁插队是为了提高效率。
升降级策略:只能从写锁降级为读锁,不能从读锁升级为写锁。

View File

@@ -0,0 +1,236 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
27 什么是自旋锁?自旋的好处和后果是什么呢?
在本课时我们主要讲解什么是自旋锁?以及使用自旋锁的好处和后果分别是什么呢?
什么是自旋
首先,我们了解什么叫自旋?“自旋”可以理解为“自我旋转”,这里的“旋转”指“循环”,比如 while 循环或者 for 循环。“自旋”就是自己在这里不停地循环,直到目标达成。而不像普通的锁那样,如果获取不到锁就进入阻塞。
对比自旋和非自旋的获取锁的流程
下面我们用这样一张流程图来对比一下自旋锁和非自旋锁的获取锁的过程。
首先,我们来看自旋锁,它并不会放弃 CPU 时间片,而是通过自旋等待锁的释放,也就是说,它会不停地再次地尝试获取锁,如果失败就再次尝试,直到成功为止。
我们再来看下非自旋锁,非自旋锁和自旋锁是完全不一样的,如果它发现此时获取不到锁,它就把自己的线程切换状态,让线程休眠,然后 CPU 就可以在这段时间去做很多其他的事情,直到之前持有这把锁的线程释放了锁,于是 CPU 再把之前的线程恢复回来,让这个线程再去尝试获取这把锁。如果再次失败,就再次让线程休眠,如果成功,一样可以成功获取到同步资源的锁。
可以看出,非自旋锁和自旋锁最大的区别,就是如果它遇到拿不到锁的情况,它会把线程阻塞,直到被唤醒。而自旋锁会不停地尝试。那么,自旋锁这样不停尝试的好处是什么呢?
自旋锁的好处
首先,阻塞和唤醒线程都是需要高昂的开销的,如果同步代码块中的内容不复杂,那么可能转换线程带来的开销比实际业务代码执行的开销还要大。
在很多场景下,可能我们的同步代码块的内容并不多,所以需要的执行时间也很短,如果我们仅仅为了这点时间就去切换线程状态,那么其实不如让线程不切换状态,而是让它自旋地尝试获取锁,等待其他线程释放锁,有时我只需要稍等一下,就可以避免上下文切换等开销,提高了效率。
用一句话总结自旋锁的好处,那就是自旋锁用循环去不停地尝试获取锁,让线程始终处于 Runnable 状态,节省了线程状态切换带来的开销。
AtomicLong 的实现
在 Java 1.5 版本及以上的并发包中,也就是 java.util.concurrent 的包中,里面的原子类基本都是自旋锁的实现。
比如我们看一个 AtomicLong 的实现,里面有一个 getAndIncrement 方法,源码如下:
public final long getAndIncrement() {
return unsafe.getAndAddLong(this, valueOffset, 1L);
}
可以看到它调用了一个 unsafe.getAndAddLong所以我们再来看这个方法
public final long getAndAddLong (Object var1,long var2, long var4){
long var6;
do {
var6 = this.getLongVolatile(var1, var2);
} while (!this.compareAndSwapLong(var1, var2, var6, var6 + var4));
return var6;
}
在这个方法中,它用了一个 do while 循环。这里就很明显了:
do {
var6 = this.getLongVolatile(var1, var2);
}
while (!this.compareAndSwapLong(var1, var2, var6, var6 + var4));
这里的 do-while 循环就是一个自旋操作,如果在修改过程中遇到了其他线程竞争导致没修改成功的情况,就会 while 循环里进行死循环,直到修改成功为止。
自己实现一个可重入的自旋锁
下面我们来看一个自己实现可重入的自旋锁。
代码如下所示:
package lesson27;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.locks.Lock;
/**
* 描述: 实现一个可重入的自旋锁
*/
public class ReentrantSpinLock {
private AtomicReference<Thread> owner = new AtomicReference<>();
//重入次数
private int count = 0;
public void lock() {
Thread t = Thread.currentThread();
if (t == owner.get()) {
++count;
return;
}
//自旋获取锁
while (!owner.compareAndSet(null, t)) {
System.out.println("自旋了");
}
}
public void unlock() {
Thread t = Thread.currentThread();
//只有持有锁的线程才能解锁
if (t == owner.get()) {
if (count > 0) {
--count;
} else {
//此处无需CAS操作因为没有竞争因为只有线程持有者才能解锁
owner.set(null);
}
}
}
public static void main(String[] args) {
ReentrantSpinLock spinLock = new ReentrantSpinLock();
Runnable runnable = new Runnable() {
@Override
public void run() {
System.out.println(Thread.currentThread().getName() + "开始尝试获取自旋锁");
spinLock.lock();
try {
System.out.println(Thread.currentThread().getName() + "获取到了自旋锁");
Thread.sleep(4000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
spinLock.unlock();
System.out.println(Thread.currentThread().getName() + "释放了了自旋锁");
}
}
};
Thread thread1 = new Thread(runnable);
Thread thread2 = new Thread(runnable);
thread1.start();
thread2.start();
}
}
这段代码的运行结果是:
...
自旋了
自旋了
自旋了
自旋了
自旋了
自旋了
自旋了
自旋了
Thread-0释放了了自旋锁
Thread-1获取到了自旋锁
前面会打印出很多“自旋了”说明自旋期间CPU依然在不停运转。
缺点
那么自旋锁有没有缺点呢?其实自旋锁是有缺点的。它最大的缺点就在于虽然避免了线程切换的开销,但是它在避免线程切换开销的同时也带来了新的开销,因为它需要不停得去尝试获取锁。如果这把锁一直不能被释放,那么这种尝试只是无用的尝试,会白白浪费处理器资源。也就是说,虽然一开始自旋锁的开销低于线程切换,但是随着时间的增加,这种开销也是水涨船高,后期甚至会超过线程切换的开销,得不偿失。
适用场景
所以我们就要看一下自旋锁的适用场景。首先,自旋锁适用于并发度不是特别高的场景,以及临界区比较短小的情况,这样我们可以利用避免线程切换来提高效率。
可是如果临界区很大,线程一旦拿到锁,很久才会释放的话,那就不合适用自旋锁,因为自旋会一直占用 CPU 却无法拿到锁,白白消耗资源。

View File

@@ -0,0 +1,199 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
28 JVM 对锁进行了哪些优化?
本课时我们主要讲解 JVM 对锁进行了哪些优化呢?
相比于 JDK 1.5,在 JDK 1.6 中 HotSopt 虚拟机对 synchronized 内置锁的性能进行了很多优化包括自适应的自旋、锁消除、锁粗化、偏向锁、轻量级锁等。有了这些优化措施后synchronized 锁的性能得到了大幅提高,下面我们分别介绍这些具体的优化。
自适应的自旋锁
首先,我们来看一下自适应的自旋锁。先来复习一下自旋的概念和自旋的缺点。“自旋”就是不释放 CPU一直循环尝试获取锁如下面这段代码所
public final long getAndAddLong(Object var1, long var2, long var4) {
long var6;
do {
var6 = this.getLongVolatile(var1, var2);
} while(!this.compareAndSwapLong(var1, var2, var6, var6 + var4));
return var6;
}
代码中使用一个 do-while 循环来一直尝试修改 long 的值。自旋的缺点在于如果自旋时间过长,那么性能开销是很大的,浪费了 CPU 资源。
在 JDK 1.6 中引入了自适应的自旋锁来解决长时间自旋的问题。自适应意味着自旋的时间不再固定,而是会根据最近自旋尝试的成功率、失败率,以及当前锁的拥有者的状态等多种因素来共同决定。自旋的持续时间是变化的,自旋锁变“聪明”了。比如,如果最近尝试自旋获取某一把锁成功了,那么下一次可能还会继续使用自旋,并且允许自旋更长的时间;但是如果最近自旋获取某一把锁失败了,那么可能会省略掉自旋的过程,以便减少无用的自旋,提高效率。
锁消除
第二个优化是锁消除。首先我们来看下面的代码:
public class Person {
private String name;
private int age;
public Person(String personName, int personAge) {
name = personName;
age = personAge;
}
public Person(Person p) {
this(p.getName(), p.getAge());
}
public String getName() {
return name;
}
public int getAge() {
return age;
}
}
class Employee {
private Person person;
// makes a defensive copy to protect against modifications by caller
public Person getPerson() {
return new Person(person);
}
public void printEmployeeDetail(Employee emp) {
Person person = emp.getPerson();
// this caller does not modify the object, so defensive copy was unnecessary
System.out.println("Employee's name: " + person.getName() + "; age: " + person.getAge());
}
}
在这段代码中,我们看到下方的 Employee 类中的 getPerson() 方法,这个方法中使用了类里面的 person 对象,并且新建一个和它属性完全相同的新的 person 对象,目的是防止方法调用者修改原来的 person 对象。但是在这个例子中,其实是没有任何必要新建对象的,因为我们的 printEmployeeDetail() 方法没有对这个对象做出任何的修改,仅仅是打印,既然如此,我们其实可以直接打印最开始的 person 对象,而无须新建一个新的。
如果编译器可以确定最开始的 person 对象不会被修改的话,它可能会优化并且消除这个新建 person 的过程。
根据这样的思想,接下来我们就来举一个锁消除的例子,经过逃逸分析之后,如果发现某些对象不可能被其他线程访问到,那么就可以把它们当成栈上数据,栈上数据由于只有本线程可以访问,自然是线程安全的,也就无需加锁,所以会把这样的锁给自动去除掉。
例如,我们的 StringBuffer 的 append 方法如下所示:
@Override
public synchronized StringBuffer append(Object obj) {
toStringCache = null;
super.append(String.valueOf(obj));
return this;
}
从代码中可以看出,这个方法是被 synchronized 修饰的同步方法,因为它可能会被多个线程同时使用。
但是在大多数情况下,它只会在一个线程内被使用,如果编译器能确定这个 StringBuffer 对象只会在一个线程内被使用,就代表肯定是线程安全的,那么我们的编译器便会做出优化,把对应的 synchronized 给消除,省去加锁和解锁的操作,以便增加整体的效率。
锁粗化
接下来,我们来介绍一下锁粗化。如果我们释放了锁,紧接着什么都没做,又重新获取锁,例如下面这段代码所示:
public void lockCoarsening() {
synchronized (this) {
//do something
}
synchronized (this) {
//do something
}
synchronized (this) {
//do something
}
}
那么其实这种释放和重新获取锁是完全没有必要的,如果我们把同步区域扩大,也就是只在最开始加一次锁,并且在最后直接解锁,那么就可以把中间这些无意义的解锁和加锁的过程消除,相当于是把几个 synchronized 块合并为一个较大的同步块。这样做的好处在于在线程执行这些代码时,就无须频繁申请与释放锁了,这样就减少了性能开销。
不过,我们这样做也有一个副作用,那就是我们会让同步区域变大。如果在循环中我们也这样做,如代码所示:
for (int i = 0; i < 1000; i++) {
synchronized (this) {
//do something
}
}
也就是我们在第一次循环的开始就开始扩大同步区域并持有锁直到最后一次循环结束才结束同步代码块释放锁的话这就会导致其他线程长时间无法获得锁所以这里的锁粗化不适用于循环的场景仅适用于非循环的场景
锁粗化功能是默认打开的 -XX:-EliminateLocks 可以关闭该功能
偏向锁/轻量级锁/重量级锁
下面我们来介绍一下偏向锁轻量级锁和重量级锁这个锁在我们之前介绍锁的种类的时候也介绍过这三种锁是特指 synchronized 锁的状态的通过在对象头中的 mark word 来表明锁的状态
偏向锁
对于偏向锁而言它的思想是如果自始至终对于这把锁都不存在竞争那么其实就没必要上锁只要打个标记就行了一个对象在被初始化后如果还没有任何线程来获取它的锁时它就是可偏向的当有第一个线程来访问它尝试获取锁的时候它就记录下来这个线程如果后面尝试获取锁的线程正是这个偏向锁的拥有者就可以直接获取锁开销很小
轻量级锁
JVM 的开发者发现在很多情况下synchronized 中的代码块是被多个线程交替执行的也就是说并不存在实际的竞争或者是只有短时间的锁竞争 CAS 就可以解决这种情况下重量级锁是没必要的轻量级锁指当锁原来是偏向锁的时候被另一个线程所访问说明存在竞争那么偏向锁就会升级为轻量级锁线程会通过自旋的方式尝试获取锁不会阻塞
重量级锁
这种锁利用操作系统的同步机制实现所以开销比较大当多个线程直接有实际竞争并且锁竞争时间比较长的时候此时偏向锁和轻量级锁都不能满足需求锁就会膨胀为重量级锁重量级锁会让其他申请却拿不到锁的线程进入阻塞状态
锁升级的路径
最后我们看下锁的升级路径如图所示从无锁到偏向锁再到轻量级锁最后到重量级锁结合前面我们讲过的知识偏向锁性能最好避免了 CAS 操作而轻量级锁利用自旋和 CAS 避免了重量级锁带来的线程阻塞和唤醒性能中等重量级锁则会把获取不到锁的线程阻塞性能最差
JVM 默认会优先使用偏向锁如果有必要的话才逐步升级这大幅提高了锁的性能

View File

@@ -0,0 +1,144 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
29 HashMap 为什么是线程不安全的?
本课时我们主要讲解为什么 HashMap 是线程不安全的?而对于 HashMap相信你一定并不陌生HashMap 是我们平时工作和学习中用得非常非常多的一个容器,也是 Map 最主要的实现类之一,但是它自身并不具备线程安全的特点,可以从多种情况中体现出来,下面我们就对此进行具体的分析。
源码分析
第一步,我们来看一下 HashMap 中 put 方法的源码:
public V put(K key, V value) {
if (key == null)
return putForNullKey(value);
int hash = hash(key.hashCode());
int i = indexFor(hash, table.length);
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
//modCount++ 是一个复合操作
modCount++;
addEntry(hash, key, value, i);
return null;
}
在 HashMap 的 put() 方法中,可以看出里面进行了很多操作,那么在这里,我们把目光聚焦到标记出来的 modCount++ 这一行代码中相信有经验的小伙伴一定发现了这相当于是典型的“i++”操作,正是我们在 06 课时讲过的线程不安全的“运行结果错误”的情况。从表面上看 i++ 只是一行代码,但实际上它并不是一个原子操作,它的执行步骤主要分为三步,而且在每步操作之间都有可能被打断。
第一个步骤是读取;
第二个步骤是增加;
第三个步骤是保存。
那么我们接下来具体看一下如何发生的线程不安全问题。
我们根据箭头指向依次看,假设线程 1 首先拿到 i=1 的结果,然后进行 i+1 操作,但此时 i+1 的结果并没有保存下来,线程 1 就被切换走了,于是 CPU 开始执行线程 2它所做的事情和线程 1 是一样的 i++ 操作,但此时我们想一下,它拿到的 i 是多少?实际上和线程 1 拿到的 i 的结果一样都是 1为什么呢因为线程 1 虽然对 i 进行了 +1 操作,但结果没有保存,所以线程 2 看不到修改后的结果。
然后假设等线程 2 对 i 进行 +1 操作后,又切换到线程 1让线程 1 完成未完成的操作,即将 i + 1 的结果 2 保存下来,然后又切换到线程 2 完成 i = 2 的保存操作,虽然两个线程都执行了对 i 进行 +1 的操作,但结果却最终保存了 i = 2 的结果,而不是我们期望的 i = 3这样就发生了线程安全问题导致了数据结果错误这也是最典型的线程安全问题。
所以,从源码的角度,或者说从理论上来讲,这完全足以证明 HashMap 是线程非安全的了。因为如果有多个线程同时调用 put() 方法的话,它很有可能会把 modCount 的值计算错(上述的源码分析针对的是 Java 7 版本的源码,而在 Java 8 版本的 HashMap 的 put 方法中会调用 putVal 方法,里面同样有 ++modCount 语句,所以原理是一样的)。
实验:扩容期间取出的值不准确
刚才我们分析了源码,你可能觉得不过瘾,下面我们就打开代码编辑器,用一个实验来证明 HashMap 是线程不安全的。
为什么说 HashMap 不是线程安全的呢我们先来讲解下原理。HashMap 本身默认的容量不是很大,如果不停地往 map 中添加新的数据,它便会在合适的时机进行扩容。而在扩容期间,它会新建一个新的空数组,并且用旧的项填充到这个新的数组中去。那么,在这个填充的过程中,如果有线程获取值,很可能会取到 null 值,而不是我们所希望的、原来添加的值。所以我们程序就想演示这种情景,我们来看一下这段代码:
public class HashMapNotSafe {
public static void main(String[] args) {
final Map<Integer, String> map = new HashMap<>();
final Integer targetKey = 0b1111_1111_1111_1111; // 65 535
final String targetValue = "v";
map.put(targetKey, targetValue);
new Thread(() -> {
IntStream.range(0, targetKey).forEach(key -> map.put(key, "someValue"));
}).start();
while (true) {
if (null == map.get(targetKey)) {
throw new RuntimeException("HashMap is not thread safe.");
}
}
}
}
代码中首先建立了一个 HashMap并且定义了 key 和 value key 的值是一个二进制的 1111_1111_1111_1111对应的十进制是 65535。之所以选取这样的值就是为了让它在扩容往回填充数据的时候尽量不要填充得太快比便于我们能捕捉到错误的发生。而对应的 value 是无所谓的,我们随意选取了一个非 null 的 “v” 来表示它,并且把这个值放到了 map 中。
接下来,我们就用一个新的线程不停地往我们的 map 中去填入新的数据,我们先来看是怎么填入的。首先它用了一个 IntStream这个 range 是从 0 到之前所讲过的 65535这个 range 是一个左闭右开的区间,所以会从 0、1、2、3……一直往上加并且每一次加的时候这个 0、1、2、3、4 都会作为 key 被放到 map 中去。而它的 value 是统一的,都是 “someValue”因为 value 不是我们所关心的。
然后,我们就会把这个线程启动起来,随后就进入一个 while 循环,这个 while 循环是关键,在 while 循环中我们会不停地检测之前放入的 key 所对应的 value 还是不是我们所期望的字符串 “v”。我们在 while 循环中会不停地从 map 中取 key 对应的值。如果 HashMap 是线程安全的,那么无论怎样它所取到的值都应该是我们最开始放入的字符串 “v”可是如果取出来是一个 null就会满足这个 if 条件并且随即抛出一个异常,因为如果取出 null 就证明它所取出来的值和我们一开始放入的值是不一致的,也就证明了它是线程不安全的,所以在此我们要抛出一个 RuntimeException 提示我们。
下面就让我们运行这个程序来看一看是否会抛出这个异常。一旦抛出就代表它是线程不安全的,这段代码的运行结果:
Exception in thread "main" java.lang.RuntimeException: HashMap is not thread safe.
at lesson29.HashMapNotSafe.main(HashMapNotSafe.java:25)
很明显,很快这个程序就抛出了我们所希望看到的 RuntimeException并且我们把它描述为HashMap is not thread safe一旦它能进入到这个 if 语句,就已经证明它所取出来的值是 null而不是我们期望的字符串 “v”。
通过以上这个例子我们也证明了HashMap 是线程非安全的。
除了刚才的例子之外,还有很多种线程不安全的情况,例如:
同时 put 碰撞导致数据丢失
比如,有多个线程同时使用 put 来添加元素,而且恰好两个 put 的 key 是一样的,它们发生了碰撞,也就是根据 hash 值计算出来的 bucket 位置一样,并且两个线程又同时判断该位置是空的,可以写入,所以这两个线程的两个不同的 value 便会添加到数组的同一个位置,这样最终就只会保留一个数据,丢失一个数据。
可见性问题无法保证
我们再从可见性的角度去考虑一下。可见性也是线程安全的一部分,如果某一个数据结构声称自己是线程安全的,那么它同样需要保证可见性,也就是说,当一个线程操作这个容器的时候,该操作需要对另外的线程都可见,也就是其他线程都能感知到本次操作。可是 HashMap 对此是做不到的,如果线程 1 给某个 key 放入了一个新值,那么线程 2 在获取对应的 key 的值的时候,它的可见性是无法保证的,也就是说线程 2 可能可以看到这一次的更改但也有可能看不到。所以从可见性的角度出发HashMap 同样是线程非安全的。
死循环造成 CPU 100%
下面我们再举一个死循环造成 CPU 100% 的例子。HashMap 有可能会发生死循环并且造成 CPU 100% ,这种情况发生最主要的原因就是在扩容的时候,也就是内部新建新的 HashMap 的时候,扩容的逻辑会反转散列桶中的节点顺序,当有多个线程同时进行扩容的时候,由于 HashMap 并非线程安全的,所以如果两个线程同时反转的话,便可能形成一个循环,并且这种循环是链表的循环,相当于 A 节点指向 B 节点B 节点又指回到 A 节点,这样一来,在下一次想要获取该 key 所对应的 value 的时候,便会在遍历链表的时候发生永远无法遍历结束的情况,也就发生 CPU 100% 的情况。
所以综上所述HashMap 是线程不安全的,在多线程使用场景中如果需要使用 Map应该尽量避免使用线程不安全的 HashMap。同时虽然 Collections.synchronizedMap(new HashMap()) 是线程安全的,但是效率低下,因为内部用了很多的 synchronized多个线程不能同时操作。推荐使用线程安全同时性能比较好的 ConcurrentHashMap。关于 ConcurrentHashMap 我们会在下一个课时中介绍。

View File

@@ -0,0 +1,358 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
30 ConcurrentHashMap 在 Java7 和 8 有何不同?
在 Java 8 中,对于 ConcurrentHashMap 这个常用的工具类进行了很大的升级,对比之前 Java 7 版本在诸多方面都进行了调整和变化。不过,在 Java 7 中的 Segment 的设计思想依然具有参考和学习的价值所以在很多情况下面试官都会问你ConcurrentHashMap 在 Java 7 和 Java 8 中的结构分别是什么?它们有什么相同点和不同点?所以本课时就对 ConcurrentHashMap 在这两个版本的特点和性质进行对比和介绍。
Java 7 版本的 ConcurrentHashMap
我们首先来看一下 Java 7 版本中的 ConcurrentHashMap 的结构示意图:
从图中我们可以看出,在 ConcurrentHashMap 内部进行了 Segment 分段Segment 继承了 ReentrantLock可以理解为一把锁各个 Segment 之间都是相互独立上锁的,互不影响。相比于之前的 Hashtable 每次操作都需要把整个对象锁住而言,大大提高了并发效率。因为它的锁与锁之间是独立的,而不是整个对象只有一把锁。
每个 Segment 的底层数据结构与 HashMap 类似,仍然是数组和链表组成的拉链法结构。默认有 0~15 共 16 个 Segment所以最多可以同时支持 16 个线程并发操作(操作分别分布在不同的 Segment 上。16 这个默认值可以在初始化的时候设置为其他值,但是一旦确认初始化以后,是不可以扩容的。
Java 8 版本的 ConcurrentHashMap
在 Java 8 中,几乎完全重写了 ConcurrentHashMap代码量从原来 Java 7 中的 1000 多行,变成了现在的 6000 多行,所以也大大提高了源码的阅读难度。而为了方便我们理解,我们还是先从整体的结构示意图出发,看一看总体的设计思路,然后再去深入细节。
图中的节点有三种类型。
第一种是最简单的,空着的位置代表当前还没有元素来填充。
第二种就是和 HashMap 非常类似的拉链法结构,在每一个槽中会首先填入第一个节点,但是后续如果计算出相同的 Hash 值,就用链表的形式往后进行延伸。
第三种结构就是红黑树结构,这是 Java 7 的 ConcurrentHashMap 中所没有的结构,在此之前我们可能也很少接触这样的数据结构。
当第二种情况的链表长度大于某一个阈值(默认为 8且同时满足一定的容量要求的时候ConcurrentHashMap 便会把这个链表从链表的形式转化为红黑树的形式目的是进一步提高它的查找性能。所以Java 8 的一个重要变化就是引入了红黑树的设计,由于红黑树并不是一种常见的数据结构,所以我们在此简要介绍一下红黑树的特点。
红黑树是每个节点都带有颜色属性的二叉查找树,颜色为红色或黑色,红黑树的本质是对二叉查找树 BST 的一种平衡策略,我们可以理解为是一种平衡二叉查找树,查找效率高,会自动平衡,防止极端不平衡从而影响查找效率的情况发生。
由于自平衡的特点,即左右子树高度几乎一致,所以其查找性能近似于二分查找,时间复杂度是 O(log(n)) 级别;反观链表,它的时间复杂度就不一样了,如果发生了最坏的情况,可能需要遍历整个链表才能找到目标元素,时间复杂度为 O(n),远远大于红黑树的 O(log(n))尤其是在节点越来越多的情况下O(log(n)) 体现出的优势会更加明显。
红黑树的一些其他特点:
每个节点要么是红色,要么是黑色,但根节点永远是黑色的。
红色节点不能连续,也就是说,红色节点的子和父都不能是红色的。
从任一节点到其每个叶子节点的路径都包含相同数量的黑色节点。
正是由于这些规则和要求的限制,红黑树保证了较高的查找效率,所以现在就可以理解为什么 Java 8 的 ConcurrentHashMap 要引入红黑树了。好处就是避免在极端的情况下冲突链表变得很长,在查询的时候,效率会非常慢。而红黑树具有自平衡的特点,所以,即便是极端情况下,也可以保证查询效率在 O(log(n))。
分析 Java 8 版本的 ConcurrentHashMap 的重要源码
前面我们讲解了 Java 7 和 Java 8 中 ConcurrentHashMap 的主体结构,下面我们深入源码分析。由于 Java 7 版本已经过时了,所以我们把重点放在 Java 8 版本的源码分析上。
Node 节点
我们先来看看最基础的内部存储结构 Node这就是一个一个的节点如这段代码所示
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
volatile V val;
volatile Node<K,V> next;
// ...
}
可以看出,每个 Node 里面是 key-value 的形式,并且把 value 用 volatile 修饰,以便保证可见性,同时内部还有一个指向下一个节点的 next 指针,方便产生链表结构。
下面我们看两个最重要、最核心的方法。
put 方法源码分析
put 方法的核心是 putVal 方法,为了方便阅读,我把重要步骤的解读用注释的形式补充在下面的源码中。我们逐步分析这个最重要的方法,这个方法相对有些长,我们一步一步把它看清楚。
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null) {
throw new NullPointerException();
}
//计算 hash 值
int hash = spread(key.hashCode());
int binCount = 0;
for (Node<K, V>[] tab = table; ; ) {
Node<K, V> f;
int n, i, fh;
//如果数组是空的,就进行初始化
if (tab == null || (n = tab.length) == 0) {
tab = initTable();
}
// 找该 hash 值对应的数组下标
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
//如果该位置是空的,就用 CAS 的方式放入新值
if (casTabAt(tab, i, null,
new Node<K, V>(hash, key, value, null))) {
break;
}
}
//hash值等于 MOVED 代表在扩容
else if ((fh = f.hash) == MOVED) {
tab = helpTransfer(tab, f);
}
//槽点上是有值的情况
else {
V oldVal = null;
//用 synchronized 锁住当前槽点,保证并发安全
synchronized (f) {
if (tabAt(tab, i) == f) {
//如果是链表的形式
if (fh >= 0) {
binCount = 1;
//遍历链表
for (Node<K, V> e = f; ; ++binCount) {
K ek;
//如果发现该 key 已存在,就判断是否需要进行覆盖,然后返回
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
oldVal = e.val;
if (!onlyIfAbsent) {
e.val = value;
}
break;
}
Node<K, V> pred = e;
//到了链表的尾部也没有发现该 key说明之前不存在就把新值添加到链表的最后
if ((e = e.next) == null) {
pred.next = new Node<K, V>(hash, key,
value, null);
break;
}
}
}
//如果是红黑树的形式
else if (f instanceof TreeBin) {
Node<K, V> p;
binCount = 2;
//调用 putTreeVal 方法往红黑树里增加数据
if ((p = ((TreeBin<K, V>) f).putTreeVal(hash, key,
value)) != null) {
oldVal = p.val;
if (!onlyIfAbsent) {
p.val = value;
}
}
}
}
}
if (binCount != 0) {
//检查是否满足条件并把链表转换为红黑树的形式,默认的 TREEIFY_THRESHOLD 阈值是 8
if (binCount >= TREEIFY_THRESHOLD) {
treeifyBin(tab, i);
}
//putVal 的返回是添加前的旧值,所以返回 oldVal
if (oldVal != null) {
return oldVal;
}
break;
}
}
}
addCount(1L, binCount);
return null;
}
通过以上的源码分析,我们对于 putVal 方法有了详细的认识,可以看出,方法中会逐步根据当前槽点是未初始化、空、扩容、链表、红黑树等不同情况做出不同的处理。
get 方法源码分析
get 方法比较简单,我们同样用源码注释的方式来分析一下:
public V get(Object key) {
Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
//计算 hash 值
int h = spread(key.hashCode());
//如果整个数组是空的,或者当前槽点的数据是空的,说明 key 对应的 value 不存在,直接返回 null
if ((tab = table) != null && (n = tab.length) > 0 &&
(e = tabAt(tab, (n - 1) & h)) != null) {
//判断头结点是否就是我们需要的节点,如果是则直接返回
if ((eh = e.hash) == h) {
if ((ek = e.key) == key || (ek != null && key.equals(ek)))
return e.val;
}
//如果头结点 hash 值小于 0说明是红黑树或者正在扩容就用对应的 find 方法来查找
else if (eh < 0)
return (p = e.find(h, key)) != null ? p.val : null;
//遍历链表来查找
while ((e = e.next) != null) {
if (e.hash == h &&
((ek = e.key) == key || (ek != null && key.equals(ek))))
return e.val;
}
}
return null;
}
总结一下 get 的过程
计算 Hash 并由此值找到对应的槽点
如果数组是空的或者该位置为 null那么直接返回 null 就可以了
如果该位置处的节点刚好就是我们需要的直接返回该节点的值
如果该位置节点是红黑树或者正在扩容就用 find 方法继续查找
否则那就是链表就进行遍历链表查找
对比Java7 和Java8 的异同和优缺点
数据结构
正如本课时最开始的两个结构示意图所示Java 7 采用 Segment 分段锁来实现 Java 8 中的 ConcurrentHashMap 使用数组 + 链表 + 红黑树在这一点上它们的差别非常大
并发度
Java 7 每个 Segment 独立加锁最大并发个数就是 Segment 的个数默认是 16
但是到了 Java 8 锁粒度更细理想情况下 table 数组元素的个数也就是数组长度就是其支持并发的最大个数并发度比之前有提高
保证并发安全的原理
Java 7 采用 Segment 分段锁来保证安全 Segment 是继承自 ReentrantLock
Java 8 中放弃了 Segment 的设计采用 Node + CAS + synchronized 保证线程安全
遇到 Hash 碰撞
Java 7 Hash 冲突时会使用拉链法也就是链表的形式
Java 8 先使用拉链法在链表长度超过一定阈值时将链表转换为红黑树来提高查找效率
查询时间复杂度
Java 7 遍历链表的时间复杂度是 O(n)n 为链表长度
Java 8 如果变成遍历红黑树那么时间复杂度降低为 O(log(n))n 为树的节点个数

View File

@@ -0,0 +1,137 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
31 为什么 Map 桶中超过 8 个才转为红黑树?
这一课时我们主要讲解为什么 Map 的桶中超过 8 个才转为红黑树?
JDK 1.8 的 HashMap 和 ConcurrentHashMap 都有这样一个特点:最开始的 Map 是空的,因为里面没有任何元素,往里放元素时会计算 hash 值,计算之后,第 1 个 value 会首先占用一个桶(也称为槽点)位置,后续如果经过计算发现需要落到同一个桶中,那么便会使用链表的形式往后延长,俗称“拉链法”,如图所示:
图中,有的桶是空的, 比如第 4 个;有的只有一个元素,比如 1、3、6有的就是刚才说的拉链法比如第 2 和第 5 个桶。
当链表长度大于或等于阈值(默认为 8的时候如果同时还满足容量大于或等于 MIN_TREEIFY_CAPACITY默认为 64的要求就会把链表转换为红黑树。同样后续如果由于删除或者其他原因调整了大小当红黑树的节点小于或等于 6 个以后,又会恢复为链表形态。
让我们回顾一下 HashMap 的结构示意图:
在图中我们可以看到,有一些槽点是空的,有一些是拉链,有一些是红黑树。
更多的时候我们会关注,为何转为红黑树以及红黑树的一些特点,可是,为什么转化的这个阈值要默认设置为 8 呢?要想知道为什么设置为 8那首先我们就要知道为什么要转换因为转换是第一步。
每次遍历一个链表,平均查找的时间复杂度是 O(n)n 是链表的长度。红黑树有和链表不一样的查找性能,由于红黑树有自平衡的特点,可以防止不平衡情况的发生,所以可以始终将查找的时间复杂度控制在 O(log(n))。最初链表还不是很长,所以可能 O(n) 和 O(log(n)) 的区别不大,但是如果链表越来越长,那么这种区别便会有所体现。所以为了提升查找性能,需要把链表转化为红黑树的形式。
那为什么不一开始就用红黑树,反而要经历一个转换的过程呢?其实在 JDK 的源码注释中已经对这个问题作了解释:
Because TreeNodes are about twice the size of regular nodes,
use them only when bins contain enough nodes to warrant use
(see TREEIFY_THRESHOLD). And when they become too small (due
removal or resizing) they are converted back to plain bins.
这段话的意思是:单个 TreeNode 需要占用的空间大约是普通 Node 的两倍,所以只有当包含足够多的 Nodes 时才会转成 TreeNodes而是否足够多就是由 TREEIFY_THRESHOLD 的值决定的。而当桶中节点数由于移除或者 resize 变少后,又会变回普通的链表的形式,以便节省空间。
通过查看源码可以发现,默认是链表长度达到 8 就转成红黑树,而当长度降到 6 就转换回去,这体现了时间和空间平衡的思想,最开始使用链表的时候,空间占用是比较少的,而且由于链表短,所以查询时间也没有太大的问题。可是当链表越来越长,需要用红黑树的形式来保证查询的效率。对于何时应该从链表转化为红黑树,需要确定一个阈值,这个阈值默认为 8并且在源码中也对选择 8 这个数字做了说明,原文如下:
In usages with well-distributed user hashCodes, tree bins
are rarely used. Ideally, under random hashCodes, the
frequency of nodes in bins follows a Poisson distribution
(http://en.wikipedia.org/wiki/Poisson_distribution) with a
parameter of about 0.5 on average for the default resizing
threshold of 0.75, although with a large variance because
of resizing granularity. Ignoring variance, the expected
occurrences of list size k are (exp(-0.5) * pow(0.5, k) /
factorial(k)). The first values are:
0: 0.60653066
1: 0.30326533
2: 0.07581633
3: 0.01263606
4: 0.00157952
5: 0.00015795
6: 0.00001316
7: 0.00000094
8: 0.00000006
more: less than 1 in ten million
上面这段话的意思是,如果 hashCode 分布良好,也就是 hash 计算的结果离散好的话,那么红黑树这种形式是很少会被用到的,因为各个值都均匀分布,很少出现链表很长的情况。在理想情况下,链表长度符合泊松分布,各个长度的命中概率依次递减,当长度为 8 的时候,概率仅为 0.00000006。这是一个小于千万分之一的概率,通常我们的 Map 里面是不会存储这么多的数据的,所以通常情况下,并不会发生从链表向红黑树的转换。
但是HashMap 决定某一个元素落到哪一个桶里,是和这个对象的 hashCode 有关的JDK 并不能阻止我们用户实现自己的哈希算法,如果我们故意把哈希算法变得不均匀,例如:
@Override
public int hashCode() {
return 1;
}
这里 hashCode 计算出来的值始终为 1那么就很容易导致 HashMap 里的链表变得很长。让我们来看下面这段代码:
public class HashMapDemo {
public static void main(String[] args) {
HashMap map = new HashMap<HashMapDemo,Integer>(1);
for (int i = 0; i < 1000; i++) {
HashMapDemo hashMapDemo1 = new HashMapDemo();
map.put(hashMapDemo1, null);
}
System.out.println("运行结束");
}
@Override
public int hashCode() {
return 1;
}
}
在这个例子中我们建了一个 HashMap并且不停地往里放入值所放入的 key 的对象它的 hashCode 是被重写过得并且始终返回 1这段代码运行时如果通过 debug 让程序暂停在 System.out.println(“运行结束”) 这行语句我们观察 map 内的节点可以发现已经变成了 TreeNode而不是通常的 Node这说明内部已经转为了红黑树
事实上链表长度超过 8 就转为红黑树的设计更多的是为了防止用户自己实现了不好的哈希算法时导致链表过长从而导致查询效率低而此时转为红黑树更多的是一种保底策略用来保证极端情况下查询的效率
通常如果 hash 算法正常的话那么链表的长度也不会很长那么红黑树也不会带来明显的查询时间上的优势反而会增加空间负担所以通常情况下并没有必要转为红黑树所以就选择了概率非常小小于千万分之一概率也就是长度为 8 的概率把长度 8 作为转化的默认阈值
所以如果平时开发中发现 HashMap 或是 ConcurrentHashMap 内部出现了红黑树的结构这个时候往往就说明我们的哈希算法出了问题需要留意是不是我们实现了效果不好的 hashCode 方法并对此进行改进以便减少冲突

View File

@@ -0,0 +1,71 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
32 同样是线程安全ConcurrentHashMap 和 Hashtable 的区别
在本课时我们主要讲解同样是线程安全ConcurrentHashMap 与 Hashtable 到底有什么区别呢?
我们都知道 HashMap 不是线程安全的,而 ConcurrentHashMap 和 Hashtable 它们两个确实都是线程安全的,那它们有哪些不同点呢?我们从以下四个角度出发,去分析它们的不同点。
出现的版本不同
我们先从表面的、显而易见的出现时间来分析。Hashtable 在 JDK1.0 的时候就存在了,并在 JDK1.2 版本中实现了 Map 接口,成为了集合框架的一员。而 ConcurrentHashMap 则是在 JDK1.5 中才出现的,也正是因为它们出现的年代不同,而后出现的往往是对前面出现的类的优化,所以它们在实现方式以及性能上,也存在着较大的不同。
实现线程安全的方式不同
虽然 ConcurrentHashMap 和 Hashtable 它们两个都是线程安全的但是从原理上分析Hashtable 实现并发安全的原理是通过 synchronized 关键字,让我们直接看下源码,以 clear() 方法为例,代码如下:
public synchronized void clear() {
Entry<?,?> tab[] = table;
modCount++;
for (int index = tab.length; --index >= 0; )
tab[index] = null;
count = 0;
}
可以看出这个 clear() 方法是被 synchronized 关键字所修饰的,同理其他的方法例如 put、get、size 等,也同样是被 synchronized 关键字修饰的。之所以 Hashtable 是线程安全的,是因为几乎每个方法都被 synchronized 关键字所修饰了,这也就保证了线程安全。
Collections.SynchronizedMap(new HashMap()) 的原理和 Hashtable 类似,也是利用 synchronized 实现的。而我们的 ConcurrentHashMap 实现的原理,却有大大的不同,让我们看一下它在 Java 8 中的结构示意图:
对于 ConcurrentHashMap 的原理,我们在第 30 课时的时候有过详细的介绍和源码分析,本质上它实现线程安全的原理是利用了 CAS + synchronized + Node 节点的方式,这和 Hashtable 的完全利用 synchronized 的方式有很大的不同。
性能不同
正因为它们在线程安全的实现方式上的不同导致它们在性能方面也有很大的不同。当线程数量增加的时候Hashtable 的性能会急剧下降,因为每一次修改都需要锁住整个对象,而其他线程在此期间是不能操作的。不仅如此,还会带来额外的上下文切换等开销,所以此时它的吞吐量甚至还不如单线程的情况。
而在 ConcurrentHashMap 中就算上锁也仅仅会对一部分上锁而不是全部都上锁所以多线程中的吞吐量通常都会大于单线程的情况也就是说在并发效率上ConcurrentHashMap 比 Hashtable 提高了很多。
迭代时修改的不同
Hashtable包括 HashMap不允许在迭代期间修改内容否则会抛出ConcurrentModificationException 异常,其原理是检测 modCount 变量,迭代器的 next() 方法的代码如下:
public T next() {
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
return nextElement();
}
可以看出在这个 next() 方法中,会首先判断 modCount 是否等于 expectedModCount。其中 expectedModCount 是在迭代器生成的时候随之生成的,并且不会改变。它所代表的含义是当前 Hashtable 被修改的次数,而每一次去调用 Hashtable 的包括 addEntry()、remove()、rehash() 等方法中,都会修改 modCount 的值。这样一来,如果我们在迭代的过程中,去对整个 Hashtable 的内容做了修改的话,也就同样会反映到 modCount 中。这样一来,迭代器在进行 next 的时候,也可以感知到,于是它就会发现 modCount 不等于 expectedModCount就会抛出 ConcurrentModificationException 异常。
所以对于 Hashtable 而言它是不允许在迭代期间对内容进行修改的。相反ConcurrentHashMap 即便在迭代期间修改内容也不会抛出ConcurrentModificationException。
本课时总结了 ConcurrentHashMap 与 Hashtable 的区别,虽然它们都是线程安全的,但是在出现的版本上、实现线程安全的方式上、性能上,以及迭代时是否支持修改等方面都有较大的不同,如果我们有并发的场景,那么使用 ConcurrentHashMap 是最合适的相反Hashtable 已经不再推荐使用。

View File

@@ -0,0 +1,346 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
33 CopyOnWriteArrayList 有什么特点?
本课时我们主要讲解 CopyOnWriteArrayList 有什么特点。
故事要从诞生 CopyOnWriteArrayList 之前说起。其实在 CopyOnWriteArrayList 出现之前,我们已经有了 ArrayList 和 LinkedList 作为 List 的数组和链表的实现,而且也有了线程安全的 Vector 和 Collections.synchronizedList() 可以使用。所以首先就让我们来看下线程安全的 Vector 的 size 和 get 方法的代码:
public synchronized int size() {
return elementCount;
}
public synchronized E get(int index) {
if (index >= elementCount)
throw new ArrayIndexOutOfBoundsException(index);
return elementData(index);
}
可以看出Vector 内部是使用 synchronized 来保证线程安全的并且锁的粒度比较大都是方法级别的锁在并发量高的时候很容易发生竞争并发效率相对比较低。在这一点上Vector 和 Hashtable 很类似。
并且,前面这几种 List 在迭代期间不允许编辑,如果在迭代期间进行添加或删除元素等操作,则会抛出 ConcurrentModificationException 异常,这样的特点也在很多情况下给使用者带来了麻烦。
所以从 JDK1.5 开始Java 并发包里提供了使用 CopyOnWrite 机制实现的并发容器 CopyOnWriteArrayList 作为主要的并发 ListCopyOnWrite 的并发集合还包括 CopyOnWriteArraySet其底层正是利用 CopyOnWriteArrayList 实现的。所以今天我们以 CopyOnWriteArrayList 为突破口,来看一下 CopyOnWrite 容器的特点。
适用场景
读操作可以尽可能的快,而写即使慢一些也没关系
在很多应用场景中,读操作可能会远远多于写操作。比如,有些系统级别的信息,往往只需要加载或者修改很少的次数,但是会被系统内所有模块频繁的访问。对于这种场景,我们最希望看到的就是读操作可以尽可能的快,而写即使慢一些也没关系。
读多写少
黑名单是最典型的场景,假如我们有一个搜索网站,用户在这个网站的搜索框中,输入关键字搜索内容,但是某些关键字不允许被搜索。这些不能被搜索的关键字会被放在一个黑名单中,黑名单并不需要实时更新,可能每天晚上更新一次就可以了。当用户搜索时,会检查当前关键字在不在黑名单中,如果在,则提示不能搜索。这种读多写少的场景也很适合使用 CopyOnWrite 集合。
读写规则
读写锁的规则
读写锁的思想是:读读共享、其他都互斥(写写互斥、读写互斥、写读互斥),原因是由于读操作不会修改原有的数据,因此并发读并不会有安全问题;而写操作是危险的,所以当写操作发生时,不允许有读操作加入,也不允许第二个写线程加入。
对读写锁规则的升级
CopyOnWriteArrayList 的思想比读写锁的思想又更进一步。为了将读取的性能发挥到极致CopyOnWriteArrayList 读取是完全不用加锁的,更厉害的是,写入也不会阻塞读取操作,也就是说你可以在写入的同时进行读取,只有写入和写入之间需要进行同步,也就是不允许多个写入同时发生,但是在写入发生时允许读取同时发生。这样一来,读操作的性能就会大幅度提升。
特点
CopyOnWrite的含义
从 CopyOnWriteArrayList 的名字就能看出它是满足 CopyOnWrite 的 ArrayListCopyOnWrite 的意思是说,当容器需要被修改的时候,不直接修改当前容器,而是先将当前容器进行 Copy复制出一个新的容器然后修改新的容器完成修改之后再将原容器的引用指向新的容器。这样就完成了整个修改过程。
这样做的好处是CopyOnWriteArrayList 利用了“不变性”原理,因为容器每次修改都是创建新副本,所以对于旧容器来说,其实是不可变的,也是线程安全的,无需进一步的同步操作。我们可以对 CopyOnWrite 容器进行并发的读,而不需要加锁,因为当前容器不会添加任何元素,也不会有修改。
CopyOnWriteArrayList 的所有修改操作addset等都是通过创建底层数组的新副本来实现的所以 CopyOnWrite 容器也是一种读写分离的思想体现,读和写使用不同的容器。
迭代期间允许修改集合内容
我们知道 ArrayList 在迭代期间如果修改集合的内容,会抛出 ConcurrentModificationException 异常。让我们来分析一下 ArrayList 会抛出异常的原因。
在 ArrayList 源码里的 ListItr 的 next 方法中有一个 checkForComodification 方法,代码如下:
final void checkForComodification() {
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
}
这里会首先检查 modCount 是否等于 expectedModCount。modCount 是保存修改次数,每次我们调用 add、remove 或 trimToSize 等方法时它会增加expectedModCount 是迭代器的变量,当我们创建迭代器时会初始化并记录当时的 modCount。后面迭代期间如果发现 modCount 和 expectedModCount 不一致,就说明有人修改了集合的内容,就会抛出异常。
和 ArrayList 不同的是CopyOnWriteArrayList 的迭代器在迭代的时候如果数组内容被修改了CopyOnWriteArrayList 不会报 ConcurrentModificationException 的异常,因为迭代器使用的依然是旧数组,只不过迭代的内容可能已经过时了。演示代码如下:
/**
* 描述: 演示CopyOnWriteArrayList迭代期间可以修改集合的内容
*/
public class CopyOnWriteArrayListDemo {
public static void main(String[] args) {
CopyOnWriteArrayList<Integer> list = new CopyOnWriteArrayList<>(new Integer[]{1, 2, 3});
System.out.println(list); //[1, 2, 3]
//Get iterator 1
Iterator<Integer> itr1 = list.iterator();
//Add one element and verify list is updated
list.add(4);
System.out.println(list); //[1, 2, 3, 4]
//Get iterator 2
Iterator<Integer> itr2 = list.iterator();
System.out.println("====Verify Iterator 1 content====");
itr1.forEachRemaining(System.out::println); //1,2,3
System.out.println("====Verify Iterator 2 content====");
itr2.forEachRemaining(System.out::println); //1,2,3,4
}
}
这段代码会首先创建一个 CopyOnWriteArrayList并且初始值被赋为 [1, 2, 3],此时打印出来的结果很明显就是 [1, 2, 3]。然后我们创建一个叫作 itr1 的迭代器,创建之后再添加一个新的元素,利用 list.add() 方法把元素 4 添加进去,此时我们打印出 List 自然是 [1, 2, 3, 4]。我们再创建一个叫作 itr2 的迭代器,在下方把两个迭代器迭代产生的内容打印出来,这段代码的运行结果是:
[1, 2, 3]
[1, 2, 3, 4]
====Verify Iterator 1 content====
1
2
3
====Verify Iterator 2 content====
1
2
3
4
可以看出,这两个迭代器打印出来的内容是不一样的。第一个迭代器打印出来的是 [1, 2, 3],而第二个打印出来的是 [1, 2, 3, 4]。虽然它们的打印时机都发生在第四个元素被添加之后,但它们的创建时机是不同的。由于迭代器 1 被创建时的 List 里面只有三个元素,后续无论 List 有什么修改,对它来说都是无感知的。
以上这个结果说明了CopyOnWriteArrayList 的迭代器一旦被建立之后,如果往之前的 CopyOnWriteArrayList 对象中去新增元素,在迭代器中既不会显示出元素的变更情况,同时也不会报错,这一点和 ArrayList 是有很大区别的。
缺点
这些缺点不仅是针对 CopyOnWriteArrayList其实同样也适用于其他的 CopyOnWrite 容器:
内存占用问题
因为 CopyOnWrite 的写时复制机制,所以在进行写操作的时候,内存里会同时驻扎两个对象的内存,这一点会占用额外的内存空间。
在元素较多或者复杂的情况下,复制的开销很大
复制过程不仅会占用双倍内存,还需要消耗 CPU 等资源,会降低整体性能。
数据一致性问题
由于 CopyOnWrite 容器的修改是先修改副本所以这次修改对于其他线程来说并不是实时能看到的只有在修改完之后才能体现出来。如果你希望写入的的数据马上能被其他线程看到CopyOnWrite 容器是不适用的。
源码分析
数据结构
/** 可重入锁对象 */
final transient ReentrantLock lock = new ReentrantLock();
/** CopyOnWriteArrayList底层由数组实现volatile修饰保证数组的可见性 */
private transient volatile Object[] array;
/**
* 得到数组
*/
final Object[] getArray() {
return array;
}
/**
* 设置数组
*/
final void setArray(Object[] a) {
array = a;
}
/**
* 初始化CopyOnWriteArrayList相当于初始化数组
*/
public CopyOnWriteArrayList() {
setArray(new Object[0]);
}
在这个类中首先会有一个 ReentrantLock 锁,用来保证修改操作的线程安全。下面被命名为 array 的 Object[] 数组是被 volatile 修饰的,可以保证数组的可见性,这正是存储元素的数组,同样,我们可以从 getArray()、setArray 以及它的构造方法看出CopyOnWriteArrayList 的底层正是利用数组实现的,这也符合它的名字。
add 方法
public boolean add(E e) {
// 加锁
final ReentrantLock lock = this.lock;
lock.lock();
try {
// 得到原数组的长度和元素
Object[] elements = getArray();
int len = elements.length;
// 复制出一个新数组
Object[] newElements = Arrays.copyOf(elements, len + 1);
// 添加时,将新元素添加到新数组中
newElements[len] = e;
// 将volatile Object[] array 的指向替换成新数组
setArray(newElements);
return true;
} finally {
lock.unlock();
}
}
add 方法的作用是往 CopyOnWriteArrayList 中添加元素,是一种修改操作。首先需要利用 ReentrantLock 的 lock 方法进行加锁,获取锁之后,得到原数组的长度和元素,也就是利用 getArray 方法得到 elements 并且保存 length。之后利用 Arrays.copyOf 方法复制出一个新的数组,得到一个和原数组内容相同的新数组,并且把新元素添加到新数组中。完成添加动作后,需要转换引用所指向的对象,利用 setArray(newElements) 操作就可以把 volatile Object[] array 的指向替换成新数组,最后在 finally 中把锁解除。
总结流程:在添加的时候首先上锁,并复制一个新数组,增加操作在新数组上完成,然后将 array 指向到新数组,最后解锁。
上面的步骤实现了 CopyOnWrite 的思想:写操作是在原来容器的拷贝上进行的,并且在读取数据的时候不会锁住 list。而且可以看到如果对容器拷贝操作的过程中有新的读线程进来那么读到的还是旧的数据因为在那个时候对象的引用还没有被更改。
下面我们来分析一下读操作的代码,也就是和 get 相关的三个方法,分别是 get 方法的两个重载和 getArray 方法,代码如下:
public E get(int index) {
return get(getArray(), index);
}
final Object[] getArray() {
return array;
}
private E get(Object[] a, int index) {
return (E) a[index];
}
可以看出get 相关的操作没有加锁,保证了读取操作的高速。
迭代器 COWIterator 类
这个迭代器有两个重要的属性,分别是 Object[] snapshot 和 int cursor。其中 snapshot 代表数组的快照,也就是创建迭代器那个时刻的数组情况,而 cursor 则是迭代器的游标。迭代器的构造方法如下:
private COWIterator(Object[] elements, int initialCursor) {
cursor = initialCursor;
snapshot = elements;
}
可以看出,迭代器在被构建的时候,会把当时的 elements 赋值给 snapshot而之后的迭代器所有的操作都基于 snapshot 数组进行的,比如:
public E next() {
if (! hasNext())
throw new NoSuchElementException();
return (E) snapshot[cursor++];
}
在 next 方法中可以看到,返回的内容是 snapshot 对象,所以,后续就算原数组被修改,这个 snapshot 既不会感知到,也不会受影响,执行迭代操作不需要加锁,也不会因此抛出异常。迭代器返回的结果,和创建迭代器的时候的内容一致。
以上我们对 CopyOnWriteArrayList 进行了介绍。我们分别介绍了在它诞生之前的 Vector 和 Collections.synchronizedList() 的特点CopyOnWriteArrayList 的适用场景、读写规则,还介绍了它的两个特点,分别是写时复制和迭代期间允许修改集合内容。我们还介绍了它的三个缺点,分别是内存占用问题,在元素较多或者复杂的情况下复制的开销大问题,以及数据一致性问题。最后我们对于它的重要源码进行了解析

View File

@@ -0,0 +1,78 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
34 什么是阻塞队列?
在本课时中我们主要讲解一下什么是阻塞队列。
阻塞队列的作用
阻塞队列,也就是 BlockingQueue它是一个接口如代码所示
public interface BlockingQueue<E> extends Queue<E>{...}
BlockingQueue 继承了 Queue 接口是队列的一种。Queue 和 BlockingQueue 都是在 Java 5 中加入的。
BlockingQueue 是线程安全的,我们在很多场景下都可以利用线程安全的队列来优雅地解决我们业务自身的线程安全问题。比如说,使用生产者/消费者模式的时候,我们生产者只需要往队列里添加元素,而消费者只需要从队列里取出它们就可以了,如图所示:
在图中,左侧有三个生产者线程,它会把生产出来的结果放到中间的阻塞队列中,而右侧的三个消费者也会从阻塞队列中取出它所需要的内容并进行处理。因为阻塞队列是线程安全的,所以生产者和消费者都可以是多线程的,不会发生线程安全问题。
既然队列本身是线程安全的,队列可以安全地从一个线程向另外一个线程传递数据,所以我们的生产者/消费者直接使用线程安全的队列就可以,而不需要自己去考虑更多的线程安全问题。这也就意味着,考虑锁等线程安全问题的重任从“你”转移到了“队列”上,降低了我们开发的难度和工作量。
同时,队列它还能起到一个隔离的作用。比如说我们开发一个银行转账的程序,那么生产者线程不需要关心具体的转账逻辑,只需要把转账任务,如账户和金额等信息放到队列中就可以,而不需要去关心银行这个类如何实现具体的转账业务。而作为银行这个类来讲,它会去从队列里取出来将要执行的具体的任务,再去通过自己的各种方法来完成本次转账。
这样就实现了具体任务与执行任务类之间的解耦,任务被放在了阻塞队列中,而负责放任务的线程是无法直接访问到我们银行具体实现转账操作的对象的,实现了隔离,提高了安全性。
主要并发队列关系图
上图展示了 Queue 最主要的实现类,可以看出 Java 提供的线程安全的队列(也称为并发队列)分为阻塞队列和非阻塞队列两大类。
阻塞队列的典型例子就是 BlockingQueue 接口的实现类BlockingQueue 下面有 6 种最主要的实现,分别是 ArrayBlockingQueue、LinkedBlockingQueue、SynchronousQueue、DelayQueue、PriorityBlockingQueue 和 LinkedTransferQueue它们各自有不同的特点对于这些常见的阻塞队列的特点我们会在第 36 课时中展开说明。
非阻塞并发队列的典型例子是 ConcurrentLinkedQueue这个类不会让线程阻塞利用 CAS 保证了线程安全。
我们可以根据需要自由选取阻塞队列或者非阻塞队列来满足业务需求。
还有一个和 Queue 关系紧密的 Deque 接口,它继承了 Queue如代码所示
public interface Deque<E> extends Queue<E> {//...}
Deque 的意思是双端队列,音标是 [dek],是 double-ended-queue 的缩写,它从头和尾都能添加和删除元素;而普通的 Queue 只能从一端进入,另一端出去。这是 Deque 和 Queue 的不同之处Deque 其他方面的性质都和 Queue 类似。
阻塞队列的特点
阻塞队列区别于其他类型的队列的最主要的特点就是“阻塞”这两个字,所以下面重点介绍阻塞功能:阻塞功能使得生产者和消费者两端的能力得以平衡,当有任何一端速度过快时,阻塞队列便会把过快的速度给降下来。实现阻塞最重要的两个方法是 take 方法和 put 方法。
take 方法
take 方法的功能是获取并移除队列的头结点,通常在队列里有数据的时候是可以正常移除的。可是一旦执行 take 方法的时候,队列里无数据,则阻塞,直到队列里有数据。一旦队列里有数据了,就会立刻解除阻塞状态,并且取到数据。过程如图所示:
put 方法
put 方法插入元素时,如果队列没有满,那就和普通的插入一样是正常的插入,但是如果队列已满,那么就无法继续插入,则阻塞,直到队列里有了空闲空间。如果后续队列有了空闲空间,比如消费者消费了一个元素,那么此时队列就会解除阻塞状态,并把需要添加的数据添加到队列中。过程如图所示:
以上过程中的阻塞和解除阻塞,都是 BlockingQueue 完成的,不需要我们自己处理。
是否有界(容量有多大)
此外,阻塞队列还有一个非常重要的属性,那就是容量的大小,分为有界和无界两种。
无界队列意味着里面可以容纳非常多的元素,例如 LinkedBlockingQueue 的上限是 Integer.MAX_VALUE约为 2 的 31 次方,是非常大的一个数,可以近似认为是无限容量,因为我们几乎无法把这个容量装满。
但是有的阻塞队列是有界的,例如 ArrayBlockingQueue 如果容量满了,也不会扩容,所以一旦满了就无法再往里放数据了。
以上就是本课时的全部内容,本课时讲解了什么是阻塞队列,首先我们讲解了阻塞队列的作用;然后看了 Java 8 中的并发队列,分为阻塞队列和非阻塞队列,并且在阻塞队列中有 6 种常见的实现;最后我们看了阻塞队列的特点,包括 take 方法、put 方法和是否有界。

View File

@@ -0,0 +1,220 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
35 阻塞队列包含哪些常用的方法add、offer、put 等方法的区别?
在本课时中我们主要讲解阻塞队列包含哪些常用的方法,以及 addofferput 等方法的区别。
在阻塞队列中有很多方法,而且它们都非常相似,所以非常有必要对这些类似的方法进行辨析,所以本课时会用分类的方式,和你一起,把阻塞队列中常见的方法进行梳理和讲解。
我们把 BlockingQueue 中最常用的和添加、删除相关的 8 个方法列出来,并且把它们分为三组,每组方法都和添加、移除元素相关。
这三组方法由于功能很类似,所以比较容易混淆。它们的区别仅在于特殊情况:当队列满了无法添加元素,或者是队列空了无法移除元素时,不同组的方法对于这种特殊情况会有不同的处理方式:
抛出异常add、remove、element
返回结果但不抛出异常offer、poll、peek
阻塞put、take
第一组add、remove、element
add 方法
add 方法是往队列里添加一个元素,如果队列满了,就会抛出异常来提示队列已满。示例代码如下:
private static void addTest() {
BlockingQueue<Integer> blockingQueue = new ArrayBlockingQueue<Integer>(2);
blockingQueue.add(1);
blockingQueue.add(1);
blockingQueue.add(1);
}
在这段代码中,我们创建了一个容量为 2 的 BlockingQueue并且尝试往里面放 3 个值,超过了容量上限,那么在添加第三个值的时候就会得到异常:
Exception in thread "main" java.lang.IllegalStateException:Queue full
remove 方法
remove 方法的作用是删除元素,如果我们删除的队列是空的,由于里面什么都没有,所以也无法删除任何元素,那么 remove 方法就会抛出异常。示例代码如下:
private static void removeTest() {
ArrayBlockingQueue<Integer> blockingQueue = new ArrayBlockingQueue<Integer>(2);
blockingQueue.add(1);
blockingQueue.add(1);
blockingQueue.remove();
blockingQueue.remove();
blockingQueue.remove();
}
在这段代码中,我们往一个容量为 2 的 BlockingQueue 里放入 2 个元素,并且删除 3 个元素。在删除前面两个元素的时候会正常执行,因为里面依然有元素存在,但是在删除第三个元素时,由于队列里面已经空了,所以便会抛出异常:
Exception in thread "main" java.util.NoSuchElementException
element 方法
element 方法是返回队列的头部节点,但是并不删除。和 remove 方法一样,如果我们用这个方法去操作一个空队列,想获取队列的头结点,可是由于队列是空的,我们什么都获取不到,会抛出和前面 remove 方法一样的异常NoSuchElementException。示例代码如下
private static void elementTest() {
ArrayBlockingQueue<Integer> blockingQueue = new ArrayBlockingQueue<Integer>(2);
blockingQueue.element();
}
我们新建了一个容量为 2 的 ArrayBlockingQueue直接调用 element 方法,由于之前没有往里面添加元素,默认为空,那么会得到异常:
Exception in thread "main" java.util.NoSuchElementException
第二组offer、poll、peek
实际上我们通常并不想看到第一组方法抛出的异常,这时我们可以优先采用第二组方法。第二组方法相比于第一组而言要友好一些,当发现队列满了无法添加,或者队列为空无法删除的时候,第二组方法会给一个提示,而不是抛出一个异常。
offer 方法
offer 方法用来插入一个元素,并用返回值来提示插入是否成功。如果添加成功会返回 true而如果队列已经满了此时继续调用 offer 方法的话它不会抛出异常只会返回一个错误提示false。示例代码如下
private static void offerTest() {
ArrayBlockingQueue<Integer> blockingQueue = new ArrayBlockingQueue<Integer>(2);
System.out.println(blockingQueue.offer(1));
System.out.println(blockingQueue.offer(1));
System.out.println(blockingQueue.offer(1));
}
我们创建了一个容量为 2 的 ArrayBlockingQueue并且调用了三次 offer方法尝试添加每次都把返回值打印出来运行结果如下
true
true
false
可以看出,前面两次添加成功了,但是第三次添加的时候,已经超过了队列的最大容量,所以会返回 false表明添加失败。
poll 方法
poll 方法和第一组的 remove 方法是对应的,作用也是移除并返回队列的头节点。但是如果当队列里面是空的,没有任何东西可以移除的时候,便会返回 null 作为提示。正因如此,我们是不允许往队列中插入 null 的,否则我们没有办法区分返回的 null 是一个提示还是一个真正的元素。示例代码如下:
private static void pollTest() {
ArrayBlockingQueue<Integer> blockingQueue = new ArrayBlockingQueue<Integer>(3);
blockingQueue.offer(1);
blockingQueue.offer(2);
blockingQueue.offer(3);
System.out.println(blockingQueue.poll());
System.out.println(blockingQueue.poll());
System.out.println(blockingQueue.poll());
System.out.println(blockingQueue.poll());
}
在这个代码中我们创建了一个容量为 3 的 ArrayBlockingQueue并且先往里面放入 3 个元素,然后四次调用 poll 方法,运行结果如下:
1
2
3
null
前面三次 poll 都运行成功了,并且返回了元素内容 1、2、3是先进先出的顺序。第四次的 poll 方法返回 null代表此时已经没有元素可以移除了。
peek 方法
peek 方法和第一组的 element 方法是对应的,意思是返回队列的头元素但并不删除。如果队列里面是空的,它便会返回 null 作为提示。示例代码如下:
private static void peekTest() {
ArrayBlockingQueue<Integer> blockingQueue = new ArrayBlockingQueue<Integer>(2);
System.out.println(blockingQueue.peek());
}
运行结果:
null
我们新建了一个空的 ArrayBlockingQueue然后直接调用 peek返回结果 null代表此时并没有东西可以取出。
带超时时间的 offer 和 poll
第二组还有一些额外值得讲解的内容offer 和 poll 都有带超时时间的重载方法。
offer(E e, long timeout, TimeUnit unit)
它有三个参数,分别是元素、超时时长和时间单位。通常情况下,这个方法会插入成功并返回 true如果队列满了导致插入不成功在调用带超时时间重载方法的 offer 的时候,则会等待指定的超时时间,如果时间到了依然没有插入成功,就会返回 false。
poll(long timeout, TimeUnit unit)
带时间参数的 poll 方法和 offer 类似:如果能够移除,便会立刻返回这个节点的内容;如果队列是空的就会进行等待,等待时间正是我们指定的时间,直到超时时间到了,如果队列里依然没有元素可供移除,便会返回 null 作为提示。
第三组put、take
第三组是我们比较熟悉的、阻塞队列最大特色的 put 和 take 方法,我们复习一下 34 课时里对于 put 和 take 方法的讲解。
put 方法
put 方法的作用是插入元素。通常在队列没满的时候是正常的插入,但是如果队列已满就无法继续插入,这时它既不会立刻返回 false 也不会抛出异常,而是让插入的线程陷入阻塞状态,直到队列里有了空闲空间,此时队列就会让之前的线程解除阻塞状态,并把刚才那个元素添加进去。
take 方法
take 方法的作用是获取并移除队列的头结点。通常在队列里有数据的时候会正常取出数据并删除;但是如果执行 take 的时候队列里无数据,则阻塞,直到队列里有数据;一旦队列里有数据了,就会立刻解除阻塞状态,并且取到数据。
总结
以上就是本课时的内容,本课时我们讲解了阻塞队列中常见的方法并且把它们分为了三组,每一组都有各自的特点。第一组的特点是在无法正常执行的情况下抛出异常;第二组的特点是在无法正常执行的情况下不抛出异常,但会用返回值提示运行失败;第三组的特点是在遇到特殊情况时让线程陷入阻塞状态,等到可以运行再继续执行。
我们用表格把上面 8 种方法总结如下:
有了这个表格之后,我们就可以非常清晰地理清这 8 个方法之间的关系了,课后你可以仔细对比表格以加深印象。

View File

@@ -0,0 +1,105 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
36 有哪几种常见的阻塞队列?
本课时我们主要讲解有哪几种常见的阻塞队列。
BlockingQueue 接口的实现类都被放在了 J.U.C 包中,本课时将对常见的和常用的实现类进行介绍,包括 ArrayBlockingQueue、LinkedBlockingQueue、SynchronousQueue、PriorityBlockingQueue以及 DelayQueue。
ArrayBlockingQueue
让我们先从最基础的 ArrayBlockingQueue 说起。ArrayBlockingQueue 是最典型的有界队列,其内部是用数组存储元素的,利用 ReentrantLock 实现线程安全。
我们在创建它的时候就需要指定它的容量,之后也不可以再扩容了,在构造函数中我们同样可以指定是否是公平的,代码如下:
ArrayBlockingQueue(int capacity, boolean fair)
第一个参数是容量,第二个参数是是否公平。正如 ReentrantLock 一样,如果 ArrayBlockingQueue 被设置为非公平的,那么就存在插队的可能;如果设置为公平的,那么等待了最长时间的线程会被优先处理,其他线程不允许插队,不过这样的公平策略同时会带来一定的性能损耗,因为非公平的吞吐量通常会高于公平的情况。
LinkedBlockingQueue
正如名字所示,这是一个内部用链表实现的 BlockingQueue。如果我们不指定它的初始容量那么它容量默认就为整型的最大值 Integer.MAX_VALUE由于这个数非常大我们通常不可能放入这么多的数据所以 LinkedBlockingQueue 也被称作无界队列,代表它几乎没有界限。
SynchronousQueue
如图所示SynchronousQueue 最大的不同之处在于,它的容量为 0所以没有一个地方来暂存元素导致每次取数据都要先阻塞直到有数据被放入同理每次放数据的时候也会阻塞直到有消费者来取。
需要注意的是SynchronousQueue 的容量不是 1 而是 0因为 SynchronousQueue 不需要去持有元素它所做的就是直接传递direct handoff。由于每当需要传递的时候SynchronousQueue 会把元素直接从生产者传给消费者,在此期间并不需要做存储,所以如果运用得当,它的效率是很高的。
另外,由于它的容量为 0所以相比于一般的阻塞队列SynchronousQueue 的很多方法的实现是很有意思的,我们来举几个例子:
SynchronousQueue 的 peek 方法永远返回 null代码如下
public E peek() {
return null;
}
因为 peek 方法的含义是取出头结点,但是 SynchronousQueue 的容量是 0所以连头结点都没有peek 方法也就没有意义,所以始终返回 null。同理element 始终会抛出 NoSuchElementException 异常。
而 SynchronousQueue 的 size 方法始终返回 0因为它内部并没有容量代码如下
public int size() {
return 0;
}
直接 return 0同理isEmpty 方法始终返回 true
public boolean isEmpty() {
return true;
}
因为它始终都是空的。
PriorityBlockingQueue
前面我们所说的 ArrayBlockingQueue 和 LinkedBlockingQueue 都是采用先进先出的顺序进行排序,可是如果有的时候我们需要自定义排序怎么办呢?这时就需要使用 PriorityBlockingQueue。
PriorityBlockingQueue 是一个支持优先级的无界阻塞队列,可以通过自定义类实现 compareTo() 方法来指定元素排序规则,或者初始化时通过构造器参数 Comparator 来指定排序规则。同时,插入队列的对象必须是可比较大小的,也就是 Comparable 的,否则会抛出 ClassCastException 异常。
它的 take 方法在队列为空的时候会阻塞,但是正因为它是无界队列,而且会自动扩容,所以它的队列永远不会满,所以它的 put 方法永远不会阻塞,添加操作始终都会成功,也正因为如此,它的成员变量里只有一个 Condition
private final Condition notEmpty;
这和之前的 ArrayBlockingQueue 拥有两个 Condition分别是 notEmpty 和 notFull形成了鲜明的对比我们的 PriorityBlockingQueue 不需要 notFull因为它永远都不会满真是“有空间就可以任性”。
DelayQueue
DelayQueue 这个队列比较特殊,具有“延迟”的功能。我们可以设定让队列中的任务延迟多久之后执行,比如 10 秒钟之后执行这在例如“30 分钟后未付款自动取消订单”等需要延迟执行的场景中被大量使用。
它是无界队列,放入的元素必须实现 Delayed 接口,而 Delayed 接口又继承了 Comparable 接口,所以自然就拥有了比较和排序的能力,代码如下:
public interface Delayed extends Comparable<Delayed> {
long getDelay(TimeUnit unit);
}
可以看出这个 Delayed 接口继承自 Comparable里面有一个需要实现的方法就是 getDelay。这里的 getDelay 方法返回的是“还剩下多长的延迟时间才会被执行”,如果返回 0 或者负数则代表任务已过期。
元素会根据延迟时间的长短被放到队列的不同位置,越靠近队列头代表越早过期。
DelayQueue 内部使用了 PriorityQueue 的能力来进行排序,而不是自己从头编写,我们在工作中可以学习这种思想,对已有的功能进行复用,不但可以减少开发量,同时避免了“重复造轮子”,更重要的是,对学到的知识进行合理的运用,让知识变得更灵活,做到触类旁通。
总结
以上就是本课时的内容,我们对于 ArrayBlockingQueue、LinkedBlockingQueue、SynchronousQueue、PriorityBlockingQueue 以及 DelayQueue 这些常见的和常用的阻塞队列的特点进行了讲解。

View File

@@ -0,0 +1,191 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
37 阻塞和非阻塞队列的并发安全原理是什么?
本课时我们主要研究阻塞和非阻塞队列的并发安全原理。
之前我们探究了常见的阻塞队列的特点,在本课时,我们以 ArrayBlockingQueue 为例,首先分析 BlockingQueue 即阻塞队列的线程安全原理,然后再看看它的兄弟——非阻塞队列的并发安全原理。通过本课时的学习,我们就可以了解到关于并发队列的底层原理了。
ArrayBlockingQueue 源码分析
我们首先看一下 ArrayBlockingQueue 的源码ArrayBlockingQueue 有以下几个重要的属性:
// 用于存放元素的数组
final Object[] items;
// 下一次读取操作的位置
int takeIndex;
// 下一次写入操作的位置
int putIndex;
// 队列中的元素数量
int count;
第一个就是最核心的、用于存储元素的 Object 类型的数组;然后它还会有两个位置变量,分别是 takeIndex 和 putIndex这两个变量就是用来标明下一次读取和写入位置的另外还有一个 count 用来计数,它所记录的就是队列中的元素个数。
另外,我们再来看下面这三个变量:
// 以下3个是控制并发用的工具
final ReentrantLock lock;
private final Condition notEmpty;
private final Condition notFull;
这三个变量也非常关键,第一个就是一个 ReentrantLock而下面两个 Condition 分别是由 ReentrantLock 产生出来的,这三个变量就是我们实现线程安全最核心的工具。
ArrayBlockingQueue 实现并发同步的原理就是利用 ReentrantLock 和它的两个 Condition读操作和写操作都需要先获取到 ReentrantLock 独占锁才能进行下一步操作。进行读操作时如果队列为空,线程就会进入到读线程专属的 notEmpty 的 Condition 的队列中去排队,等待写线程写入新的元素;同理,如果队列已满,这个时候写操作的线程会进入到写线程专属的 notFull 队列中去排队,等待读线程将队列元素移除并腾出空间。
下面,我们来分析一下最重要的 put 方法:
public void put(E e) throws InterruptedException {
checkNotNull(e);
final ReentrantLock lock = this.lock;
lock.lockInterruptibly();
try {
while (count == items.length)
notFull.await();
enqueue(e);
} finally {
lock.unlock();
}
}
在 put 方法中,首先用 checkNotNull 方法去检查插入的元素是不是 null。如果不是 null我们会用 ReentrantLock 上锁,并且上锁方法是 lock.lockInterruptibly()。这个方法我们在第 23 课时的时候讲过,在获取锁的同时是可以响应中断的,这也正是我们的阻塞队列在调用 put 方法时,在尝试获取锁但还没拿到锁的期间可以响应中断的底层原因。
紧接着 ,是一个非常经典的 try finally 代码块finally 中会去解锁try 中会有一个 while 循环,它会检查当前队列是不是已经满了,也就是 count 是否等于数组的长度。如果等于就代表已经满了,于是我们便会进行等待,直到有空余的时候,我们才会执行下一步操作,调用 enqueue 方法让元素进入队列,最后用 unlock 方法解锁。
你看到这段代码不知道是否眼熟,在第 5 课时我们讲过,用 Condition 实现生产者/消费者模式的时候,写过一个 put 方法,代码如下:
public void put(Object o) throws InterruptedException {
lock.lock();
try {
while (queue.size() == max) {
notFull.await();
}
queue.add(o);
notEmpty.signalAll();
} finally {
lock.unlock();
}
}
可以看出,这两个方法几乎是一模一样的,所以当时在第 5 课时的时候我们就说过,我们自己用 Condition 实现生产者/消费者模式,实际上其本质就是自己实现了简易版的 BlockingQueue。你可以对比一下这两个 put 方法的实现,这样对 Condition 的理解就会更加深刻。
和 ArrayBlockingQueue 类似,其他各种阻塞队列如 LinkedBlockingQueue、PriorityBlockingQueue、DelayQueue、DelayedWorkQueue 等一系列 BlockingQueue 的内部也是利用了 ReentrantLock 来保证线程安全,只不过细节有差异,比如 LinkedBlockingQueue 的内部有两把锁,分别锁住队列的头和尾,比共用同一把锁的效率更高,不过总体思想都是类似的。
非阻塞队列ConcurrentLinkedQueue
看完阻塞队列之后,我们就来看看非阻塞队列 ConcurrentLinkedQueue。顾名思义ConcurrentLinkedQueue 是使用链表作为其数据结构的,我们来看一下关键方法 offer 的源码:
public boolean offer(E e) {
checkNotNull(e);
final Node<E> newNode = new Node<E>(e);
for (Node<E> t = tail, p = t;;) {
Node<E> q = p.next;
if (q == null) {
// p is last node
if (p.casNext(null, newNode)) {
// Successful CAS is the linearization point
// for e to become an element of this queue,
// and for newNode to become "live".
if (p != t) // hop two nodes at a time
casTail(t, newNode); // Failure is OK.
return true;
}
// Lost CAS race to another thread; re-read next
}
else if (p == q)
// We have fallen off list. If tail is unchanged, it
// will also be off-list, in which case we need to
// jump to head, from which all live nodes are always
// reachable. Else the new tail is a better bet.
p = (t != (t = tail)) ? t : head;
else
// Check for tail updates after two hops.
p = (p != t && t != (t = tail)) ? t : q;
}
}
在这里我们不去一行一行分析具体的内容,而是把目光放到整体的代码结构上,在检查完空判断之后,可以看到它整个是一个大的 for 循环,而且是一个非常明显的死循环。在这个循环中有一个非常亮眼的 p.casNext 方法,这个方法正是利用了 CAS 来操作的,而且这个死循环去配合 CAS 也就是典型的乐观锁的思想。我们就来看一下 p.casNext 方法的具体实现,其方法代码如下:
boolean casNext(Node<E> cmp, Node<E> val) {
return UNSAFE.compareAndSwapObject(this, nextOffset, cmp, val);
}
可以看出这里运用了 UNSAFE.compareAndSwapObject 方法来完成 CAS 操作,而 compareAndSwapObject 是一个 native 方法,最终会利用 CPU 的 CAS 指令保证其不可中断。
可以看出,非阻塞队列 ConcurrentLinkedQueue 使用 CAS 非阻塞算法 + 不停重试,来实现线程安全,适合用在不需要阻塞功能,且并发不是特别剧烈的场景。
总结
最后我们来做一下总结。本课时我们分析了阻塞队列和非阻塞队列的并发安全原理,其中阻塞队列最主要是利用了 ReentrantLock 以及它的 Condition 来实现,而非阻塞队列则是利用 CAS 方法实现线程安全。

View File

@@ -0,0 +1,101 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
38 如何选择适合自己的阻塞队列?
本课时我们主要讲解如何选择适合自己的阻塞队列。
他山之石,可以攻玉。对于如何选择最合适的阻塞队列这个问题,实际上线程池已经率先给我们做了表率。线程池有很多种,不同种类的线程池会根据自己的特点,来选择适合自己的阻塞队列。
所以我们就首先来复习一下这些非常经典的线程池是如何挑选阻塞队列的,借鉴它们的经验之后,我们再去总结一套规则,来归纳出自己在选取阻塞队列时可以对哪些点进行考虑。
线程池对于阻塞队列的选择
下面我们来看线程池的选择要诀。上面表格左侧是线程池,右侧为它们对应的阻塞队列,你可以看到 5 种线程池只对应了 3 种阻塞队列,下面我们对它们进行逐一的介绍。
FixedThreadPoolSingleThreadExecutor 同理)选取的是 LinkedBlockingQueue
因为 LinkedBlockingQueue 不同于 ArrayBlockingQueueArrayBlockingQueue 的容量是有限的,而 LinkedBlockingQueue 是链表长度默认是可以无限延长的。
由于 FixedThreadPool 的线程数是固定的,在任务激增的时候,它无法增加更多的线程来帮忙处理 Task所以需要像 LinkedBlockingQueue 这样没有容量上限的 Queue 来存储那些还没处理的 Task。
如果所有的 corePoolSize 线程都正在忙,那么新任务将会进入阻塞队列等待,由于队列是没有容量上限的,队列永远不会被填满,这样就保证了对于线程池 FixedThreadPool 和 SingleThreadExecutor 而言,不会拒绝新任务的提交,也不会丢失数据。
CachedThreadPool 选取的是 SynchronousQueue
对于 CachedThreadPool 而言,为了避免新提交的任务被拒绝,它选择了无限制的 maximumPoolSize在专栏中maxPoolSize 等同于 maximumPoolSize所以既然它的线程的最大数量是无限的也就意味着它的线程数不会受到限制那么它就不需要一个额外的空间来存储那些 Task因为每个任务都可以通过新建线程来处理。
SynchronousQueue 会直接把任务交给线程,而不需要另外保存它们,效率更高,所以 CachedThreadPool 使用的 Queue 是 SynchronousQueue。
ScheduledThreadPoolSingleThreadScheduledExecutor同理选取的是延迟队列
对于 ScheduledThreadPool 而言,它使用的是 DelayedWorkQueue。延迟队列的特点是不是先进先出而是会按照延迟时间的长短来排序下一个即将执行的任务会排到队列的最前面。
我们来举个例子:例如我们往这个队列中,放一个延迟 10 分钟执行的任务,然后再放一个延迟 10 秒钟执行的任务。通常而言,如果不是延迟队列,那么按照先进先出的排列规则,也就是延迟 10 分钟执行的那个任务是第一个放置的,会放在最前面。但是由于我们此时使用的是阻塞队列,阻塞队列在排放各个任务的位置的时候,会根据延迟时间的长短来排放。所以,我们第二个放置的延迟 10 秒钟执行的那个任务,反而会排在延迟 10 分钟的任务的前面,因为它的执行时间更早。
我们选择使用延迟队列的原因是ScheduledThreadPool 处理的是基于时间而执行的 Task而延迟队列有能力把 Task 按照执行时间的先后进行排序,这正是我们所需要的功能。
ArrayBlockingQueue
除了线程池选择的 3 种阻塞队列外,还有一种常用的阻塞队列叫作 ArrayBlockingQueue它也经常被用于我们手动创建的线程池中。
这种阻塞队列内部是用数组实现的,在新建对象的时候要求传入容量值,且后期不能扩容,所以 ArrayBlockingQueue的最大特点就是容量是有限且固定的。这样一来使用 ArrayBlockingQueue 且设置了合理大小的最大线程数的线程池,在任务队列放满了以后,如果线程数也已经达到了最大值,那么线程池根据规则就会拒绝新提交的任务,而不会无限增加任务或者线程数导致内存不足,可以非常有效地防止资源耗尽的情况发生。
归纳
下面让我们总结一下经验,通常我们可以从以下 5 个角度考虑,来选择合适的阻塞队列:
功能
第 1 个需要考虑的就是功能层面,比如是否需要阻塞队列帮我们排序,如优先级排序、延迟执行等。如果有这个需要,我们就必须选择类似于 PriorityBlockingQueue 之类的有排序能力的阻塞队列。
容量
第 2 个需要考虑的是容量,或者说是否有存储的要求,还是只需要“直接传递”。在考虑这一点的时候,我们知道前面介绍的那几种阻塞队列,有的是容量固定的,如 ArrayBlockingQueue有的默认是容量无限的如 LinkedBlockingQueue而有的里面没有任何容量如 SynchronousQueue而对于 DelayQueue 而言,它的容量固定就是 Integer.MAX_VALUE。
所以不同阻塞队列的容量是千差万别的,我们需要根据任务数量来推算出合适的容量,从而去选取合适的 BlockingQueue。
能否扩容
第 3 个需要考虑的是能否扩容。因为有时我们并不能在初始的时候很好的准确估计队列的大小,因为业务可能有高峰期、低谷期。
如果一开始就固定一个容量,可能无法应对所有的情况,也是不合适的,有可能需要动态扩容。如果我们需要动态扩容的话,那么就不能选择 ArrayBlockingQueue 因为它的容量在创建时就确定了无法扩容。相反PriorityBlockingQueue 即使在指定了初始容量之后,后续如果有需要,也可以自动扩容。
所以我们可以根据是否需要扩容来选取合适的队列。
内存结构
第 4 个需要考虑的点就是内存结构。在上一课时我们分析过 ArrayBlockingQueue 的源码,看到了它的内部结构是“数组”的形式。
和它不同的是LinkedBlockingQueue 的内部是用链表实现的所以这里就需要我们考虑到ArrayBlockingQueue 没有链表所需要的“节点”,空间利用率更高。所以如果我们对性能有要求可以从内存的结构角度去考虑这个问题。
性能
第 5 点就是从性能的角度去考虑。比如 LinkedBlockingQueue 由于拥有两把锁,它的操作粒度更细,在并发程度高的时候,相对于只有一把锁的 ArrayBlockingQueue 性能会更好。
另外SynchronousQueue 性能往往优于其他实现,因为它只需要“直接传递”,而不需要存储的过程。如果我们的场景需要直接传递的话,可以优先考虑 SynchronousQueue。
在本课时,我们首先回顾了线程池对于阻塞队列的选取规则,然后又看到了 ArrayBlockingQueue 的特点,接下来我们总结归纳了通常情况下,可以从功能、容量、能否扩容、内存结构和性能这 5 个角度考虑问题,结合业务选取最适合我们的阻塞队列。

View File

@@ -0,0 +1,340 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
39 原子类是如何利用 CAS 保证线程安全的?
本课时主要讲解原子类是如何利用 CAS 保证线程安全的。
什么是原子类?原子类有什么作用?
要想回答这个问题,首先我们需要知道什么是原子类,以及它有什么作用。
在编程领域里,原子性意味着“一组操作要么全都操作成功,要么全都失败,不能只操作成功其中的一部分”。而 java.util.concurrent.atomic 下的类,就是具有原子性的类,可以原子性地执行添加、递增、递减等操作。比如之前多线程下的线程不安全的 i++ 问题,到了原子类这里,就可以用功能相同且线程安全的 getAndIncrement 方法来优雅地解决。
原子类的作用和锁有类似之处,是为了保证并发情况下线程安全。不过原子类相比于锁,有一定的优势:
粒度更细:原子变量可以把竞争范围缩小到变量级别,通常情况下,锁的粒度都要大于原子变量的粒度。
效率更高:除了高度竞争的情况之外,使用原子类的效率通常会比使用同步互斥锁的效率更高,因为原子类底层利用了 CAS 操作,不会阻塞线程。
6 类原子类纵览
下面我们来看下一共有哪些原子类,原子类一共可以分为以下这 6 类,我们来逐一介绍:
类型
具体类
Atomic* 基本类型原子类
AtomicInteger、AtomicLong、AtomicBoolean
Atomic*Array 数组类型原子类
AtomicIntegerArray、AtomicLongArray、AtomicReferenceArray
Atomic*Reference 引用类型原子类
AtomicReference、AtomicStampedReference、AtomicMarkableReference
Atomic*FieldUpdater 升级类型原子类
AtomicIntegerfieldupdater、AtomicLongFieldUpdater、AtomicReferenceFieldUpdater
Adder 累加器
LongAdder、DoubleAdder
Accumulator 积累器
LongAccumulator、DoubleAccumulator
Atomic\ 基本类型原子类
首先看到第一类 Atomic*,我们把它称为基本类型原子类,它包括三种,分别是 AtomicInteger、AtomicLong 和 AtomicBoolean。
我们来介绍一下最为典型的 AtomicInteger。对于这个类型而言它是对于 int 类型的封装,并且提供了原子性的访问和更新。也就是说,我们如果需要一个整型的变量,并且这个变量会被运用在并发场景之下,我们可以不用基本类型 int也不使用包装类型 Integer而是直接使用 AtomicInteger这样一来就自动具备了原子能力使用起来非常方便。
AtomicInteger 类常用方法
AtomicInteger 类有以下几个常用的方法:
public final int get() //获取当前的值
因为它本身是一个 Java 类,而不再是一个基本类型,所以要想获取值还是需要一些方法,比如通过 get 方法就可以获取到当前的值。
public final int getAndSet(int newValue) //获取当前的值,并设置新的值
接下来的几个方法和它平时的操作相关:
public final int getAndIncrement() //获取当前的值,并自增
public final int getAndDecrement() //获取当前的值,并自减
public final int getAndAdd(int delta) //获取当前的值,并加上预期的值
这个参数就是我想让当前这个原子类改变多少值,可以是正数也可以是负数,如果是正数就是增加,如果是负数就是减少。而刚才的 getAndIncrement 和 getAndDecrement 修改的数值默认为 +1 或 -1如果不能满足需求我们就可以使用 getAndAdd 方法来直接一次性地加减我们想要的数值。
boolean compareAndSet(int expect, int update) //如果输入的数值等于预期值则以原子方式将该值更新为输入值update
这个方法也是 CAS 的一个重要体现。
Array 数组类型原子类
下面我们来看第二大类 Atomic*Array 数组类型原子类,数组里的元素,都可以保证其原子性,比如 AtomicIntegerArray 相当于把 AtomicInteger 聚合起来,组合成一个数组。这样一来,我们如果想用一个每一个元素都具备原子性的数组的话, 就可以使用 Atomic*Array。
它一共分为 3 种,分别是:
AtomicIntegerArray整形数组原子类
AtomicLongArray长整形数组原子类
AtomicReferenceArray :引用类型数组原子类。
Atomic\Reference 引用类型原子类
下面我们介绍第三种 AtomicReference 引用类型原子类。AtomicReference 类的作用和AtomicInteger 并没有本质区别, AtomicInteger 可以让一个整数保证原子性而AtomicReference 可以让一个对象保证原子性。这样一来AtomicReference 的能力明显比 AtomicInteger 强,因为一个对象里可以包含很多属性。
在这个类别之下,除了 AtomicReference 之外,还有:
AtomicStampedReference它是对 AtomicReference 的升级,在此基础上还加了时间戳,用于解决 CAS 的 ABA 问题。
AtomicMarkableReference和 AtomicReference 类似,多了一个绑定的布尔值,可以用于表示该对象已删除等场景。
Atomic\FieldUpdater 原子更新器
第四类我们将要介绍的是 Atomic\FieldUpdater我们把它称为原子更新器一共有三种分别是。
AtomicIntegerFieldUpdater原子更新整形的更新器
AtomicLongFieldUpdater原子更新长整形的更新器
AtomicReferenceFieldUpdater原子更新引用的更新器。
如果我们之前已经有了一个变量,比如是整型的 int实际它并不具备原子性。可是木已成舟这个变量已经被定义好了此时我们有没有办法可以让它拥有原子性呢办法是有的就是利用 Atomic*FieldUpdater如果它是整型的就使用 AtomicIntegerFieldUpdater 把已经声明的变量进行升级,这样一来这个变量就拥有了 CAS 操作的能力。
这里的非互斥同步手段,是把我们已经声明好的变量进行 CAS 操作以达到同步的目的。那么你可能会想,既然想让这个变量具备原子性,为什么不在一开始就声明为 AtomicInteger这样也免去了升级的过程难道是一开始设计的时候不合理吗这里有以下几种情况
第一种情况是出于历史原因考虑,那么如果出于历史原因的话,之前这个变量已经被声明过了而且被广泛运用,那么修改它成本很高,所以我们可以利用升级的原子类。
另外还有一个使用场景,如果我们在大部分情况下并不需要使用到它的原子性,只在少数情况,比如每天只有定时一两次需要原子操作的话,我们其实没有必要把原来的变量声明为原子类型的变量,因为 AtomicInteger 比普通的变量更加耗费资源。所以如果我们有成千上万个原子类的实例的话,它占用的内存也会远比我们成千上万个普通类型占用的内存高。所以在这种情况下,我们可以利用 AtomicIntegerFieldUpdater 进行合理升级,节约内存。
下面我们看一段代码:
public class AtomicIntegerFieldUpdaterDemo implements Runnable{
static Score math;
static Score computer;
public static AtomicIntegerFieldUpdater<Score> scoreUpdater = AtomicIntegerFieldUpdater
.newUpdater(Score.class, "score");
@Override
public void run() {
for (int i = 0; i < 1000; i++) {
computer.score++;
scoreUpdater.getAndIncrement(math);
}
}
public static class Score {
volatile int score;
}
public static void main(String[] args) throws InterruptedException {
math =new Score();
computer =new Score();
AtomicIntegerFieldUpdaterDemo2 r = new AtomicIntegerFieldUpdaterDemo2();
Thread t1 = new Thread(r);
Thread t2 = new Thread(r);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("普通变量的结果"+ computer.score);
System.out.println("升级后的结果"+ math.score);
}
}
这段代码就演示了这个类的用法比如说我们有两个类它们都是 Score 类型的Score 类型内部会有一个分数也叫作 core那么这两个分数的实例分别叫作数学 math 和计算机 computer然后我们还声明了一个 AtomicIntegerFieldUpdater在它构造的时候传入了两个参数第一个是 Score.class这是我们的类名第二个是属性名叫作 score
接下来我们看一下 run 方法run 方法里面会对这两个实例分别进行自加操作
第一个是 computer这里的 computer 我们调用的是它内部的 score也就是说我们直接调用了 int 变量的自加操作这在多线程下是线程非安全的
第二个自加是利用了刚才声明的 scoreUpdater 并且使用了它的 getAndIncrement 方法并且传入了 math这是一种正确使用AtomicIntegerFieldUpdater 的用法这样可以线程安全地进行自加操作
接下来我们看下 main 函数 main 函数中我们首先把 math computer 定义了出来然后分别启动了两个线程每个线程都去执行我们刚才所介绍过的 run 方法这样一来两个 score也就是 math computer 都会分别被加 2000 最后我们在 join 等待之后把结果打印了出来这个程序的运行结果如下
普通变量的结果1942
升级后的结果2000
可以看出正如我们所预料的那样普通变量由于不具备线程安全性所以在多线程操作的情况下它虽然看似进行了 2000 次操作但有一些操作被冲突抵消了所以最终结果小于 2000可是使用 AtomicIntegerFieldUpdater 这个工具之后就可以做到把一个普通类型的 score 变量进行原子的自加操作最后的结果也和加的次数是一样的也就是 2000可以看出这个类的功能还是非常强大的
下面我们继续看最后两种原子类
Adder 加法器
它里面有两种加法器分别叫作 LongAdder DoubleAdder
Accumulator 积累器
最后一种叫 Accumulator 积累器分别是 LongAccumulator DoubleAccumulator
这两种原子类我们会在后面的课时中展开介绍
AtomicInteger 为例分析在 Java 中如何利用 CAS 实现原子操作
让我们回到标题中的问题在充分了解了原子类的作用和种类之后我们来看下 AtomicInteger 是如何通过 CAS 操作实现并发下的累加操作的以其中一个重要方法 getAndAdd 方法为突破口
getAndAdd方法
这个方法的代码在 Java 1.8 中的实现如下
//JDK 1.8实现
public final int getAndAdd(int delta) {
return unsafe.getAndAddInt(this, valueOffset, delta);
}
可以看出里面使用了 Unsafe 这个类并且调用了 unsafe.getAndAddInt 方法所以这里需要简要介绍一下 Unsafe
Unsafe
Unsafe 其实是 CAS 的核心类由于 Java 无法直接访问底层操作系统而是需要通过 native 方法来实现不过尽管如此JVM 还是留了一个后门 JDK 中有一个 Unsafe 它提供了硬件级别的原子操作我们可以利用它直接操作内存数据
那么我们就来看一下 AtomicInteger 的一些重要代码如下所示
public class AtomicInteger extends Number implements java.io.Serializable {
// setup to use Unsafe.compareAndSwapInt for updates
private static final Unsafe unsafe = Unsafe.getUnsafe();
private static final long valueOffset;
static {
try {
valueOffset = unsafe.objectFieldOffset
(AtomicInteger.class.getDeclaredField("value"));
} catch (Exception ex) { throw new Error(ex); }
}
private volatile int value;
public final int get() {return value;}
...
}
可以看出在数据定义的部分首先还获取了 Unsafe 实例并且定义了 valueOffset我们往下看到 static 代码块这个代码块会在类加载的时候执行执行时我们会调用 Unsafe objectFieldOffset 方法从而得到当前这个原子类的 value 的偏移量并且赋给 valueOffset 变量这样一来我们就获取到了 value 的偏移量它的含义是在内存中的偏移地址因为 Unsafe 就是根据内存偏移地址获取数据的原值的这样我们就能通过 Unsafe 来实现 CAS
value 是用 volatile 修饰的它就是我们原子类存储的值的变量由于它被 volatile 修饰我们就可以保证在多线程之间看到的 value 是同一份保证了可见性
接下来继续看 Unsafe getAndAddInt 方法的实现代码如下
public final int getAndAddInt(Object var1, long var2, int var4) {
int var5;
do {
var5 = this.getIntVolatile(var1, var2);
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
return var5;
}
首先我们看一下结构它是一个 do-while 循环所以这是一个死循环直到满足循环的退出条件时才可以退出
那么我们来看一下 do 后面的这一行代码 var5 = this.getIntVolatile(var1, var2) 是什么意思这是个 native 方法作用就是获取在 var1 中的 var2 偏移处的值
那传入的是什么呢传入的两个参数第一个就是当前原子类第二个是我们最开始获取到的 offset这样一来我们就可以获取到当前内存中偏移量的值并且保存到 var5 里面此时 var5 实际上代表当前时刻下的原子类的数值
现在再来看 while 的退出条件也就是 compareAndSwapInt 这个方法它一共传入了 4 个参数 4 个参数是 var1var2var5var5 + var4为了方便理解我们给它们取了新了变量名分别 objectoffsetexpectedValuenewValue具体含义如下
第一个参数 object 就是将要操作的对象传入的是 this也就是 atomicInteger 这个对象本身
第二个参数是 offset也就是偏移量借助它就可以获取到 value 的数值
第三个参数 expectedValue代表期望值”,传入的是刚才获取到的 var5
而最后一个参数 newValue 是希望修改的数值 等于之前取到的数值 var5 再加上 var4 var4 就是我们之前所传入的 deltadelta 就是我们希望原子类所改变的数值比如可以传入 +1也可以传入 -1
所以 compareAndSwapInt 方法的作用就是判断如果现在原子类里 value 的值和之前获取到的 var5 相等的话那么就把计算出来的 var5 + var4 给更新上去所以说这行代码就实现了 CAS 的过程
一旦 CAS 操作成功就会退出这个 while 循环但是也有可能操作失败如果操作失败就意味着在获取到 var5 之后并且在 CAS 操作之前value 的数值已经发生变化了证明有其他线程修改过这个变量
这样一来就会再次执行循环体里面的代码重新获取 var5 的值也就是获取最新的原子变量的数值并且再次利用 CAS 去尝试更新直到更新成功为止所以这是一个死循环
我们总结一下Unsafe getAndAddInt 方法是通过循环 + CAS 的方式来实现的在此过程中它会通过 compareAndSwapInt 方法来尝试更新 value 的值如果更新失败就重新获取然后再次尝试更新直到更新成功
总结
在本课时我们首先介绍了原子类的作用然后对 6 类原子类进行了介绍分别是 Atomic* 基本类型原子类Atomic*Array 数组类型原子类Atomic*Reference 引用类型原子类Atomic*FieldUpdater 升级类型原子类Adder 加法器和 Accumulator 积累器
然后我们对它们逐一进行了展开介绍了解了它们的基本作用和用法接下来我们以 AtomicInteger 为例分析了在 Java 中是如何利用 CAS 实现原子操作的
我们从 getAndAdd 方法出发逐步深入最后到了 Unsafe getAndAddInt 方法所以通过源码分析之后我们也清楚地看到了它实现的原理是利用自旋去不停地尝试直到成功为止

View File

@@ -0,0 +1,198 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
40 AtomicInteger 在高并发下性能不好,如何解决?为什么?
本课时我们主要讲解 AtomicInteger 在高并发下性能不好,如何解决?以及为什么会出现这种情况?
我们知道在 JDK1.5 中新增了并发情况下使用的 Integer/Long 所对应的原子类 AtomicInteger 和 AtomicLong。
在并发的场景下,如果我们需要实现计数器,可以利用 AtomicInteger 和 AtomicLong这样一来就可以避免加锁和复杂的代码逻辑有了它们之后我们只需要执行对应的封装好的方法例如对这两个变量进行原子的增操作或原子的减操作就可以满足大部分业务场景的需求。
不过,虽然它们很好用,但是如果你的业务场景是并发量很大的,那么你也会发现,这两个原子类实际上会有较大的性能问题,这是为什么呢?就让我们从一个例子看起。
AtomicLong 存在的问题
首先我们来看一段代码:
/**
* 描述: 在16个线程下使用AtomicLong
*/
public class AtomicLongDemo {
public static void main(String[] args) throws InterruptedException {
AtomicLong counter = new AtomicLong(0);
ExecutorService service = Executors.newFixedThreadPool(16);
for (int i = 0; i < 100; i++) {
service.submit(new Task(counter));
}
Thread.sleep(2000);
System.out.println(counter.get());
}
static class Task implements Runnable {
private final AtomicLong counter;
public Task(AtomicLong counter) {
this.counter = counter;
}
@Override
public void run() {
counter.incrementAndGet();
}
}
}
在这段代码中可以看出我们新建了一个原始值为 0 AtomicLong然后有一个线程数为 16 的线程池并且往这个线程池中添加了 100 次相同的一个任务
那我们往下看这个任务是什么在下面的 Task 类中可以看到这个任务实际上就是每一次去调用 AtomicLong incrementAndGet 方法相当于一次自加操作这样一来整个类的作用就是把这个原子类从 0 开始添加 100 个任务每个任务自加一次
这段代码的运行结果毫无疑问是 100虽然是多线程并发访问但是 AtomicLong 依然可以保证 incrementAndGet 操作的原子性所以不会发生线程安全问题
不过如果我们深入一步去看内部情景的话你可能会感到意外我们把模型简化成只有两个线程在同时工作的并发场景因为两个线程和更多个线程本质上是一样的如图所示
我们可以看到在这个图中每一个线程是运行在自己的 core 中的并且它们都有一个本地内存是自己独用的在本地内存下方有两个 CPU 核心共用的共享内存
对于 AtomicLong 内部的 value 属性而言也就是保存当前 AtomicLong 数值的属性它是被 volatile 修饰的所以它需要保证自身可见性
这样一来每一次它的数值有变化的时候它都需要进行 flush refresh比如说如果开始时ctr 的数值为 0 的话那么如图所示一旦 core 1 把它改成 1 的话它首先会在左侧把这个 1 的最新结果给 flush 到下方的共享内存然后再到右侧去往上 refresh 到核心 2 的本地内存这样一来对于核心 2 而言它才能感知到这次变化
由于竞争很激烈这样的 flush refresh 操作耗费了很多资源而且 CAS 也会经常失败
LongAdder 带来的改进和原理
JDK 8 中又新增了 LongAdder 这个类这是一个针对 Long 类型的操作工具类那么既然已经有了 AtomicLong为何又要新增 LongAdder 这么一个类呢
我们同样是用一个例子来说明下面这个例子和刚才的例子很相似只不过我们把工具类从 AtomicLong 变成了 LongAdder其他的不同之处还在于最终打印结果的时候调用的方法从原来的 get 变成了现在的 sum 方法而其他的逻辑都一样
我们来看一下使用 LongAdder 的代码示例
/**
* 描述 在16个线程下使用LongAdder
*/
public class LongAdderDemo {
public static void main(String[] args) throws InterruptedException {
LongAdder counter = new LongAdder();
ExecutorService service = Executors.newFixedThreadPool(16);
for (int i = 0; i < 100; i++) {
service.submit(new Task(counter));
}
Thread.sleep(2000);
System.out.println(counter.sum());
}
static class Task implements Runnable {
private final LongAdder counter;
public Task(LongAdder counter) {
this.counter = counter;
}
@Override
public void run() {
counter.increment();
}
}
}
代码的运行结果同样是 100但是运行速度比刚才 AtomicLong 的实现要快下面我们解释一下为什么高并发下 LongAdder AtomicLong 效率更高
因为 LongAdder 引入了分段累加的概念内部一共有两个参数参与计数第一个叫作 base它是一个变量第二个是 Cell[] 是一个数组
其中的 base 是用在竞争不激烈的情况下的可以直接把累加结果改到 base 变量上
那么当竞争激烈的时候就要用到我们的 Cell[] 数组了一旦竞争激烈各个线程会分散累加到自己所对应的那个 Cell[] 数组的某一个对象中而不会大家共用同一个
这样一来LongAdder 会把不同线程对应到不同的 Cell 上进行修改降低了冲突的概率这是一种分段的理念提高了并发性这就和 Java 7 ConcurrentHashMap 16 Segment 的思想类似
竞争激烈的时候LongAdder 会通过计算出每个线程的 hash 值来给线程分配到不同的 Cell 上去每个 Cell 相当于是一个独立的计数器这样一来就不会和其他的计数器干扰Cell 之间并不存在竞争关系所以在自加的过程中就大大减少了刚才的 flush refresh以及降低了冲突的概率这就是为什么 LongAdder 的吞吐量比 AtomicLong 大的原因本质是空间换时间因为它有多个计数器同时在工作所以占用的内存也要相对更大一些
那么 LongAdder 最终是如何实现多线程计数的呢答案就在最后一步的求和 sum 方法执行 LongAdder.sum() 的时候会把各个线程里的 Cell 累计求和并加上 base形成最终的总和代码如下
public long sum() {
Cell[] as = cells; Cell a;
long sum = base;
if (as != null) {
for (int i = 0; i < as.length; ++i) {
if ((a = as[i]) != null)
sum += a.value;
}
}
return sum;
}
在这个 sum 方法中可以看到思路非常清晰先取 base 的值然后遍历所有 Cell把每个 Cell 的值都加上去形成最终的总和由于在统计的时候并没有进行加锁操作所以这里得出的 sum 不一定是完全准确的因为有可能在计算 sum 的过程中 Cell 的值被修改了
那么我们已经了解了为什么 AtomicLong 或者说 AtomicInteger 它在高并发下性能不好也同时看到了性能更好的 LongAdder下面我们就分析一下对它们应该如何选择
如何选择
在低竞争的情况下AtomicLong LongAdder 这两个类具有相似的特征吞吐量也是相似的因为竞争不高但是在竞争激烈的情况下LongAdder 的预期吞吐量要高得多经过试验LongAdder 的吞吐量大约是 AtomicLong 的十倍不过凡事总要付出代价LongAdder 在保证高效的同时也需要消耗更多的空间
AtomicLong 可否被 LongAdder 替代
那么我们就要考虑了有了更高效的 LongAdder AtomicLong 可否不使用了呢是否凡是用到 AtomicLong 的地方都可以用 LongAdder 替换掉呢答案是不是的这需要区分场景
LongAdder 只提供了 addincrement 等简单的方法适合的是统计求和计数的场景场景比较单一 AtomicLong 还具有 compareAndSet 等高级方法可以应对除了加减之外的更复杂的需要 CAS 的场景
结论如果我们的场景仅仅是需要用到加和减操作的话那么可以直接使用更高效的 LongAdder但如果我们需要利用 CAS 比如 compareAndSet 等操作的话就需要使用 AtomicLong 来完成

View File

@@ -0,0 +1,73 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
41 原子类和 volatile 有什么异同?
本课时我们主要讲解原子类和 volatile 有什么异同。
案例说明 volatile 和原子类的异同
我们首先看一个案例。如图所示,我们有两个线程。
在图中左上角可以看出,有一个公共的 boolean flag 标记位,最开始赋值为 true然后线程 2 会进入一个 while 循环,并且根据这个 flag 也就是标记位的值来决定是否继续执行或着退出。
最开始由于 flag 的值是 true所以首先会在这里执行一定时期的循环。然后假设在某一时刻线程 1 把这个 flag 的值改为 false 了,它所希望的是,线程 2 看到这个变化后停止运行。
但是这样做其实是有风险的,线程 2 可能并不能立刻停下来,也有可能过一段时间才会停止,甚至在最极端的情况下可能永远都不会停止。
为了理解发生这种情况的原因,我们首先来看一下 CPU 的内存结构,这里是一个双核的 CPU 的简单示意图:
可以看出,线程 1 和线程 2 分别在不同的 CPU 核心上运行,每一个核心都有自己的本地内存,并且在下方也有它们共享的内存。
最开始它们都可以读取到 flag 为 true ,不过当线程 1 这个值改为 false 之后,线程 2 并不能及时看到这次修改,因为线程 2 不能直接访问线程 1 的本地内存,这样的问题就是一个非常典型的可见性问题。
要想解决这个问题,我们只需要在变量的前面加上 volatile 关键字修饰,只要我们加上这个关键字,那么每一次变量被修改的时候,其他线程对此都可见,这样一旦线程 1 改变了这个值,那么线程 2 就可以立刻看到,因此就可以退出 while 循环了。
之所以加了关键字之后就就可以让它拥有可见性,原因在于有了这个关键字之后,线程 1 的更改会被 flush 到共享内存中,然后又会被 refresh 到线程 2 的本地内存中,这样线程 2 就能感受到这个变化了,所以 volatile 这个关键字最主要是用来解决可见性问题的,可以一定程度上保证线程安全。
现在让我们回顾一下很熟悉的多线程同时进行 value++ 的场景,如图所示:
如果它被初始化为每个线程都加 1000 次,最终的结果很可能不是 2000。由于 value++ 不是原子的,所以在多线程的情况下,会出现线程安全问题。但是如果我们在这里使用 volatile 关键字,能不能解决问题呢?
很遗憾,答案是即便使用了 volatile 也是不能保证线程安全的,因为这里的问题不单单是可见性问题,还包含原子性问题。
我们有多种办法可以解决这里的问题,第 1 种是使用 synchronized 关键字,如图所示:
这样一来,两个线程就不能同时去更改 value 的数值,保证了 value++ 语句的原子性,并且 synchronized 同样保证了可见性,也就是说,当第 1 个线程修改了 value 值之后,第 2 个线程可以立刻看见本次修改的结果。
解决这个问题的第 2 个方法,就是使用我们的原子类,如图所示:
比如用一个 AtomicInteger然后每个线程都调用它的 incrementAndGet 方法。
在利用了原子变量之后就无需加锁,我们可以使用它的 incrementAndGet 方法,这个操作底层由 CPU 指令保证原子性,所以即便是多个线程同时运行,也不会发生线程安全问题。
原子类和 volatile 的使用场景
那下面我们就来说一下原子类和 volatile 各自的使用场景。
我们可以看出volatile 和原子类的使用场景是不一样的,如果我们有一个可见性问题,那么可以使用 volatile 关键字,但如果我们的问题是一个组合操作,需要用同步来解决原子性问题的话,那么可以使用原子变量,而不能使用 volatile 关键字。
通常情况下volatile 可以用来修饰 boolean 类型的标记位,因为对于标记位来讲,直接的赋值操作本身就是具备原子性的,再加上 volatile 保证了可见性,那么就是线程安全的了。
而对于会被多个线程同时操作的计数器 Counter 的场景,这种场景的一个典型特点就是,它不仅仅是一个简单的赋值操作,而是需要先读取当前的值,然后在此基础上进行一定的修改,再把它给赋值回去。这样一来,我们的 volatile 就不足以保证这种情况的线程安全了。我们需要使用原子类来保证线程安全。

View File

@@ -0,0 +1,182 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
42 AtomicInteger 和 synchronized 的异同点?
在上一课时中,我们说明了原子类和 synchronized 关键字都可以用来保证线程安全,在本课时中,我们首先分别用原子类和 synchronized 关键字来解决一个经典的线程安全问题,给出具体的代码对比,然后再分析它们背后的区别。
代码对比
首先,原始的线程不安全的情况的代码如下所示:
public class Lesson42 implements Runnable {
static int value = 0;
public static void main(String[] args) throws InterruptedException {
Runnable runnable = new Lesson42();
Thread thread1 = new Thread(runnable);
Thread thread2 = new Thread(runnable);
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println(value);
}
@Override
public void run() {
for (int i = 0; i < 10000; i++) {
value++;
}
}
}
在代码中我们新建了一个 value 变量并且在两个线程中对它进行同时的自加操作每个线程加 10000 然后我们用 join 来确保它们都执行完毕最后打印出最终的数值
因为 value++ 不是一个原子操作所以上面这段代码是线程不安全的具体分析详见第 6 所以代码的运行结果会小于 20000例如会输出 14611 等各种数字
我们首先给出方法一也就是用原子类来解决这个问题代码如下所示
public class Lesson42Atomic implements Runnable {
static AtomicInteger atomicInteger = new AtomicInteger();
public static void main(String[] args) throws InterruptedException {
Runnable runnable = new Lesson42Atomic();
Thread thread1 = new Thread(runnable);
Thread thread2 = new Thread(runnable);
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println(atomicInteger.get());
}
@Override
public void run() {
for (int i = 0; i < 10000; i++) {
atomicInteger.incrementAndGet();
}
}
}
用原子类之后我们的计数变量就不再是一个普通的 int 变量了而是 AtomicInteger 类型的对象并且自加操作也变成了 incrementAndGet 由于原子类可以确保每一次的自加操作都是具备原子性的所以这段程序是线程安全的所以以上程序的运行结果会始终等于 20000
下面我们给出方法二我们用 synchronized 来解决这个问题代码如下所示
public class Lesson42Syn implements Runnable {
static int value = 0;
public static void main(String[] args) throws InterruptedException {
Runnable runnable = new Lesson42Syn();
Thread thread1 = new Thread(runnable);
Thread thread2 = new Thread(runnable);
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println(value);
}
@Override
public void run() {
for (int i = 0; i < 10000; i++) {
synchronized (this) {
value++;
}
}
}
}
它与最开始的线程不安全的代码的区别在于 run 方法中加了 synchronized 代码块就可以非常轻松地解决这个问题由于 synchronized 可以保证代码块内部的原子性所以以上程序的运行结果也始终等于 20000是线程安全的
方案对比
下面我们就对这两种不同的方案进行分析
第一点我们来看一下它们背后原理的不同
在第 21 课时中我们详细分析了 synchronized 背后的 monitor 也就是 synchronized 原理同步方法和同步代码块的背后原理会有少许差异但总体思想是一致的在执行同步代码之前需要首先获取到 monitor 执行完毕后再释放锁
而我们在第 39 课时中介绍了原子类它保证线程安全的原理是利用了 CAS 操作从这一点上看虽然原子类和 synchronized 都能保证线程安全但是其实现原理是大有不同的
第二点不同是使用范围的不同
对于原子类而言它的使用范围是比较局限的因为一个原子类仅仅是一个对象不够灵活 synchronized 的使用范围要广泛得多比如说 synchronized 既可以修饰一个方法又可以修饰一段代码相当于可以根据我们的需要非常灵活地去控制它的应用范围
所以仅有少量的场景例如计数器等场景我们可以使用原子类而在其他更多的场景下如果原子类不适用那么我们就可以考虑用 synchronized 来解决这个问题
第三个区别是粒度的区别
原子变量的粒度是比较小的它可以把竞争范围缩小到变量级别通常情况下synchronized 锁的粒度都要大于原子变量的粒度如果我们只把一行代码用 synchronized 给保护起来的话有一点杀鸡焉用牛刀的感觉
第四点是它们性能的区别同时也是悲观锁和乐观锁的区别
因为 synchronized 是一种典型的悲观锁而原子类恰恰相反它利用的是乐观锁所以我们在比较 synchronized AtomicInteger 的时候其实也就相当于比较了悲观锁和乐观锁的区别
从性能上来考虑的话悲观锁的操作相对来讲是比较重量级的因为 synchronized 在竞争激烈的情况下会让拿不到锁的线程阻塞而原子类是永远不会让线程阻塞的不过虽然 synchronized 会让线程阻塞但是这并不代表它的性能就比原子类差
因为悲观锁的开销是固定的也是一劳永逸的随着时间的增加这种开销并不会线性增长
而乐观锁虽然在短期内的开销不大但是随着时间的增加它的开销也是逐步上涨的
所以从性能的角度考虑它们没有一个孰优孰劣的关系而是要区分具体的使用场景在竞争非常激烈的情况下推荐使用 synchronized而在竞争不激烈的情况下使用原子类会得到更好的效果
值得注意的是synchronized 的性能随着 JDK 的升级也得到了不断的优化synchronized 会从无锁升级到偏向锁再升级到轻量级锁最后才会升级到让线程阻塞的重量级锁因此synchronized 在竞争不激烈的情况下性能也是不错的不需要谈虎色变”。

View File

@@ -0,0 +1,129 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
43 Java 8 中 Adder 和 Accumulator 有什么区别?
本课时主要介绍在 Java 8 中 Adder 和 Accumulator 有什么区别。
Adder 的介绍
我们要知道 Adder 和 Accumulator 都是 Java 8 引入的,是相对比较新的类。对于 Adder 而言,比如最典型的 LongAdder我们在第 40 讲的时候已经讲解过了,在高并发下 LongAdder 比 AtomicLong 效率更高,因为对于 AtomicLong 而言,它只适合用于低并发场景,否则在高并发的场景下,由于 CAS 的冲突概率大,会导致经常自旋,影响整体效率。
而 LongAdder 引入了分段锁的概念,当竞争不激烈的时候,所有线程都是通过 CAS 对同一个 Base 变量进行修改但是当竞争激烈的时候LongAdder 会把不同线程对应到不同的 Cell 上进行修改,降低了冲突的概率,从而提高了并发性。
Accumulator 的介绍
那么 Accumulator 又是做什么的呢Accumulator 和 Adder 非常相似,实际上 Accumulator 就是一个更通用版本的 Adder比如 LongAccumulator 是 LongAdder 的功能增强版,因为 LongAdder 的 API 只有对数值的加减,而 LongAccumulator 提供了自定义的函数操作。
我这样讲解可能有些同学还是不太理解,那就让我们用一个非常直观的代码来举例说明一下,代码如下:
public class LongAccumulatorDemo {
public static void main(String[] args) throws InterruptedException {
LongAccumulator accumulator = new LongAccumulator((x, y) -> x + y, 0);
ExecutorService executor = Executors.newFixedThreadPool(8);
IntStream.range(1, 10).forEach(i -> executor.submit(() -> accumulator.accumulate(i)));
Thread.sleep(2000);
System.out.println(accumulator.getThenReset());
}
}
在这段代码中:
首先新建了一个 LongAccumulator同时给它传入了两个参数
然后又新建了一个 8 线程的线程池,并且利用整形流也就是 IntStream 往线程池中提交了从 1 ~ 9 这 9 个任务;
之后等待了两秒钟,这两秒钟的作用是等待线程池的任务执行完毕;
最后把 accumulator 的值打印出来。
这段代码的运行结果是 45代表 0+1+2+3+…+8+9=45 的结果,这个结果怎么理解呢?我们先重点看看新建的 LongAccumulator 的这一行语句:
LongAccumulator accumulator = new LongAccumulator((x, y) -> x + y, 0);
在这个语句中我们传入了两个参数LongAccumulator 的构造函数的第一个参数是二元表达式;第二个参数是 x 的初始值,传入的是 0。在二元表达式中x 是上一次计算的结果除了第一次的时候需要传入y 是本次新传入的值。
案例分析
我们来看一下上面这段代码执行的过程,当执行 accumulator.accumulate(1) 的时候,首先要知道这时候 x 和 y 是什么,第一次执行时, x 是 LongAccumulator 构造函数中的第二个参数,也就是 0而第一次执行时的 y 值就是本次 accumulator.accumulate(1) 方法所传入的 1然后根据表达式 x+y计算出 0+1=1这个结果会赋值给下一次计算的 x而下一次计算的 y 值就是 accumulator.accumulate(2) 传入的 2所以下一次的计算结果是 1+2=3。
我们在 IntStream.range(1, 10).forEach(i -> executor.submit(() -> accumulator.accumulate(i))); 这一行语句中实际上利用了整型流,分别给线程池提交了从 1 ~ 9 这 9 个任务,相当于执行了:
accumulator.accumulate(1);
accumulator.accumulate(2);
accumulator.accumulate(3);
...
accumulator.accumulate(8);
accumulator.accumulate(9);
那么根据上面的这个推演就可以得出它的内部运行这也就意味着LongAccumulator 执行了:
0+1=1;
1+2=3;
3+3=6;
6+4=10;
10+5=15;
15+6=21;
21+7=28;
28+8=36;
36+9=45;
这里需要指出的是,这里的加的顺序是不固定的,并不是说会按照顺序从 1 开始逐步往上累加,它也有可能会变,比如说先加 5、再加 3、再加 6。但总之由于加法有交换律所以最终加出来的结果会保证是 45。这就是这个类的一个基本的作用和用法。
拓展功能
我们继续看一下它的功能强大之处。举几个例子,刚才我们给出的表达式是 x + y其实同样也可以传入 x * y或者写一个 Math.min(x, y),相当于求 x 和 y 的最小值。同理,也可以去求 Math.max(x, y),相当于求一个最大值。根据业务的需求来选择就可以了。代码如下:
LongAccumulator counter = new LongAccumulator((x, y) -> x + y, 0);
LongAccumulator result = new LongAccumulator((x, y) -> x * y, 0);
LongAccumulator min = new LongAccumulator((x, y) -> Math.min(x, y), 0);
LongAccumulator max = new LongAccumulator((x, y) -> Math.max(x, y), 0);
这时你可能会有一个疑问:在这里为什么不用 for 循环呢?比如说我们之前的例子,从 0 加到 9我们直接写一个 for 循环不就可以了吗?
确实,用 for 循环也能满足需求,但是用 for 循环的话,它执行的时候是串行,它一定是按照 0+1+2+3+…+8+9 这样的顺序相加的,但是 LongAccumulator 的一大优势就是可以利用线程池来为它工作。一旦使用了线程池,那么多个线程之间是可以并行计算的,效率要比之前的串行高得多。这也是为什么刚才说它加的顺序是不固定的,因为我们并不能保证各个线程之间的执行顺序,所能保证的就是最终的结果是确定的。
适用场景
接下来我们说一下 LongAccumulator 的适用场景。
第一点需要满足的条件,就是需要大量的计算,并且当需要并行计算的时候,我们可以考虑使用 LongAccumulator。
当计算量不大,或者串行计算就可以满足需求的时候,可以使用 for 循环;如果计算量大,需要提高计算的效率时,我们则可以利用线程池,再加上 LongAccumulator 来配合的话,就可以达到并行计算的效果,效率非常高。
第二点需要满足的要求,就是计算的执行顺序并不关键,也就是说它不要求各个计算之间的执行顺序,也就是说线程 1 可能在线程 5 之后执行,也可能在线程 5 之前执行,但是执行的先后并不影响最终的结果。
一些非常典型的满足这个条件的计算,就是类似于加法或者乘法,因为它们是有交换律的。同样,求最大值和最小值对于顺序也是没有要求的,因为最终只会得出所有数字中的最大值或者最小值,无论先提交哪个或后提交哪个,都不会影响到最终的结果。

View File

@@ -0,0 +1,604 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
44 ThreadLocal 适合用在哪些实际生产的场景中?
本课时主要介绍 ThreadLocal 适合用在哪些实际生产的场景中。
我们在学习一个工具之前,首先应该知道这个工具的作用,能带来哪些好处,而不是一上来就闷头进入工具的 API、用法等否则就算我们把某个工具的用法学会了也不知道应该在什么场景下使用。所以我们先来看看究竟哪些场景下需要用到 ThreadLocal。
在通常的业务开发中ThreadLocal 有两种典型的使用场景。
场景1ThreadLocal 用作保存每个线程独享的对象,为每个线程都创建一个副本,这样每个线程都可以修改自己所拥有的副本, 而不会影响其他线程的副本,确保了线程安全。
场景2ThreadLocal 用作每个线程内需要独立保存信息,以便供其他方法更方便地获取该信息的场景。每个线程获取到的信息可能都是不一样的,前面执行的方法保存了信息后,后续方法可以通过 ThreadLocal 直接获取到,避免了传参,类似于全局变量的概念。
典型场景1
这种场景通常用于保存线程不安全的工具类,典型的需要使用的类就是 SimpleDateFormat。
场景介绍
在这种情况下,每个 Thread 内都有自己的实例副本,且该副本只能由当前 Thread 访问到并使用,相当于每个线程内部的本地变量,这也是 ThreadLocal 命名的含义。因为每个线程独享副本,而不是公用的,所以不存在多线程间共享的问题。
我们来做一个比喻,比如饭店要做一道菜,但是有 5 个厨师一起做,这样的话就很乱了,因为如果一个厨师已经放过盐了,假如其他厨师都不知道,于是就都各自放了一次盐,导致最后的菜很咸。这就好比多线程的情况,线程不安全。我们用了 ThreadLocal 之后,相当于每个厨师只负责自己的一道菜,一共有 5 道菜,这样的话就非常清晰明了了,不会出现问题。
SimpleDateFormat 的进化之路
1. 2 个线程都要用到 SimpleDateFormat
下面我们用一个案例来说明这种典型的第一个场景。假设有个需求,即 2 个线程都要用到 SimpleDateFormat。代码如下所示
public class ThreadLocalDemo01 {
public static void main(String[] args) throws InterruptedException {
new Thread(() -> {
String date = new ThreadLocalDemo01().date(1);
System.out.println(date);
}).start();
Thread.sleep(100);
new Thread(() -> {
String date = new ThreadLocalDemo01().date(2);
System.out.println(date);
}).start();
}
public String date(int seconds) {
Date date = new Date(1000 * seconds);
SimpleDateFormat simpleDateFormat = new SimpleDateFormat("mm:ss");
return simpleDateFormat.format(date);
}
}
在以上代码中可以看出,两个线程分别创建了一个自己的 SimpleDateFormat 对象,如图所示:
这样一来,有两个线程,那么就有两个 SimpleDateFormat 对象,它们之间互不干扰,这段代码是可以正常运转的,运行结果是:
00:01
00:02
2. 10 个线程都要用到 SimpleDateFormat
假设我们的需求有了升级,不仅仅需要 2 个线程,而是需要 10 个,也就是说,有 10 个线程同时对应 10 个 SimpleDateFormat 对象。我们就来看下面这种写法:
public class ThreadLocalDemo02 {
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 10; i++) {
int finalI = i;
new Thread(() -> {
String date = new ThreadLocalDemo02().date(finalI);
System.out.println(date);
}).start();
Thread.sleep(100);
}
}
public String date(int seconds) {
Date date = new Date(1000 * seconds);
SimpleDateFormat simpleDateFormat = new SimpleDateFormat("mm:ss");
return simpleDateFormat.format(date);
}
}
上面的代码利用了一个 for 循环来完成这个需求。for 循环一共循环 10 次,每一次都会新建一个线程,并且每一个线程都会在 date 方法中创建一个 SimpleDateFormat 对象,示意图如下:
可以看出一共有 10 个线程,对应 10 个 SimpleDateFormat 对象。
代码的运行结果:
00:00
00:01
00:02
00:03
00:04
00:05
00:06
00:07
00:08
00:09
3. 需求变成了 1000 个线程都要用到 SimpleDateFormat
但是线程不能无休地创建下去,因为线程越多,所占用的资源也会越多。假设我们需要 1000 个任务,那就不能再用 for 循环的方法了,而是应该使用线程池来实现线程的复用,否则会消耗过多的内存等资源。
在这种情况下,我们给出下面这个代码实现的方案:
public class ThreadLocalDemo03 {
public static ExecutorService threadPool = Executors.newFixedThreadPool(16);
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 1000; i++) {
int finalI = i;
threadPool.submit(new Runnable() {
@Override
public void run() {
String date = new ThreadLocalDemo03().date(finalI);
System.out.println(date);
}
});
}
threadPool.shutdown();
}
public String date(int seconds) {
Date date = new Date(1000 * seconds);
SimpleDateFormat dateFormat = new SimpleDateFormat("mm:ss");
return dateFormat.format(date);
}
}
可以看出我们用了一个 16 线程的线程池并且给这个线程池提交了 1000 次任务每个任务中它做的事情和之前是一样的还是去执行 date 方法并且在这个方法中创建一个 simpleDateFormat 对象程序的一种运行结果是多线程下运行结果不唯一
00:00
00:07
00:04
00:02
...
16:29
16:28
16:27
16:26
16:39
程序运行结果正确把从 00:00 16:39 1000 个时间给打印了出来并且没有重复的时间我们把这段代码用图形化给表示出来如图所示
图的左侧是一个线程池右侧是 1000 个任务我们刚才所做的就是每个任务都创建了一个 simpleDateFormat 对象也就是说1000 个任务对应 1000 simpleDateFormat 对象
但是这样做是没有必要的因为这么多对象的创建是有开销的并且在使用完之后的销毁同样是有开销的而且这么多对象同时存在在内存中也是一种内存的浪费
现在我们就来优化一下既然不想要这么多的 simpleDateFormat 对象最简单的就是只用一个就可以了
4. 所有的线程都共用一个 simpleDateFormat 对象
我们用下面的代码来演示只用一个 simpleDateFormat 对象的情况
public class ThreadLocalDemo04 {
public static ExecutorService threadPool = Executors.newFixedThreadPool(16);
static SimpleDateFormat dateFormat = new SimpleDateFormat("mm:ss");
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 1000; i++) {
int finalI = i;
threadPool.submit(new Runnable() {
@Override
public void run() {
String date = new ThreadLocalDemo04().date(finalI);
System.out.println(date);
}
});
}
threadPool.shutdown();
}
public String date(int seconds) {
Date date = new Date(1000 * seconds);
return dateFormat.format(date);
}
}
在代码中可以看出其他的没有变化变化之处就在于我们把这个 simpleDateFormat 对象给提取了出来变成 static 静态变量需要用的时候直接去获取这个静态对象就可以了看上去省略掉了创建 1000 simpleDateFormat 对象的开销看上去没有问题我们用图形的方式把这件事情给表示出来
从图中可以看出我们有不同的线程并且线程会执行它们的任务但是不同的任务所调用的 simpleDateFormat 对象都是同一个所以它们所指向的那个对象都是同一个但是这样一来就会有线程不安全的问题
5. 线程不安全出现了并发安全问题
控制台会打印出多线程下运行结果不唯一
00:04
00:04
00:05
00:04
...
16:15
16:14
16:13
执行上面的代码就会发现控制台所打印出来的和我们所期待的是不一致的我们所期待的是打印出来的时间是不重复的但是可以看出在这里出现了重复比如第一行和第二行都是 04 这就代表它内部已经出错了
6. 加锁
出错的原因就在于simpleDateFormat 这个对象本身不是一个线程安全的对象不应该被多个线程同时访问所以我们就想到了一个解决方案 synchronized 来加锁于是代码就修改成下面的样子
public class ThreadLocalDemo05 {
public static ExecutorService threadPool = Executors.newFixedThreadPool(16);
static SimpleDateFormat dateFormat = new SimpleDateFormat("mm:ss");
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 1000; i++) {
int finalI = i;
threadPool.submit(new Runnable() {
@Override
public void run() {
String date = new ThreadLocalDemo05().date(finalI);
System.out.println(date);
}
});
}
threadPool.shutdown();
}
public String date(int seconds) {
Date date = new Date(1000 * seconds);
String s = null;
synchronized (ThreadLocalDemo05.class) {
s = dateFormat.format(date);
}
return s;
}
}
可以看出在 date 方法中加入了 synchronized 关键字 simpleDateFormat 的调用给上了锁
运行这段代码的结果多线程下运行结果不唯一
00:00
00:01
00:06
...
15:56
16:37
16:36
这样的结果是正常的没有出现重复的时间但是由于我们使用了 synchronized 关键字就会陷入一种排队的状态多个线程不能同时工作这样一来整体的效率就被大大降低了有没有更好的解决方案呢
我们希望达到的效果是既不浪费过多的内存同时又想保证线程安全经过思考得出可以让每个线程都拥有一个自己的 simpleDateFormat 对象来达到这个目的这样就能两全其美了
7. 使用 ThreadLocal
那么要想达到这个目的我们就可以使用 ThreadLocal示例代码如下所示
public class ThreadLocalDemo06 {
public static ExecutorService threadPool = Executors.newFixedThreadPool(16);
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 1000; i++) {
int finalI = i;
threadPool.submit(new Runnable() {
@Override
public void run() {
String date = new ThreadLocalDemo06().date(finalI);
System.out.println(date);
}
});
}
threadPool.shutdown();
}
public String date(int seconds) {
Date date = new Date(1000 * seconds);
SimpleDateFormat dateFormat = ThreadSafeFormatter.dateFormatThreadLocal.get();
return dateFormat.format(date);
}
}
class ThreadSafeFormatter {
public static ThreadLocal<SimpleDateFormat> dateFormatThreadLocal = new ThreadLocal<SimpleDateFormat>() {
@Override
protected SimpleDateFormat initialValue() {
return new SimpleDateFormat("mm:ss");
}
};
}
在这段代码中,我们使用了 ThreadLocal 帮每个线程去生成它自己的 simpleDateFormat 对象,对于每个线程而言,这个对象是独享的。但与此同时,这个对象就不会创造过多,一共只有 16 个,因为线程只有 16 个。
代码运行结果(多线程下,运行结果不唯一):
00:05
00:04
00:01
...
16:37
16:36
16:32
这个结果是正确的,不会出现重复的时间。
我们用图来看一下当前的这种状态:
在图中的左侧可以看到,这个线程池一共有 16 个线程,对应 16 个 simpleDateFormat 对象。而在这个图画的右侧是 1000 个任务,任务是非常多的,和原来一样有 1000 个任务。但是这里最大的变化就是,虽然任务有 1000 个,但是我们不再需要去创建 1000 个 simpleDateFormat 对象了。即便任务再多,最终也只会有和线程数相同的 simpleDateFormat 对象。这样既高效地使用了内存,又同时保证了线程安全。
以上就是第一种非常典型的适合使用 ThreadLocal 的场景。
典型场景2
每个线程内需要保存类似于全局变量的信息(例如在拦截器中获取的用户信息),可以让不同方法直接使用,避免参数传递的麻烦却不想被多线程共享(因为不同线程获取到的用户信息不一样)。
例如,用 ThreadLocal 保存一些业务内容用户权限信息、从用户系统获取到的用户名、用户ID 等),这些信息在同一个线程内相同,但是不同的线程使用的业务内容是不相同的。
在线程生命周期内,都通过这个静态 ThreadLocal 实例的 get() 方法取得自己 set 过的那个对象,避免了将这个对象(如 user 对象)作为参数传递的麻烦。
我们用图画的形式举一个实例:
比如说我们是一个用户系统。假设不使用 ThreadLocal那么当一个请求进来的时候一个线程会负责执行这个请求然后这个请求就会依次调用 service-1()、service-2()、service-3()、service-4(),这 4 个方法可能是分布在不同的类中的。
在 service-1() 的时候它会创建一个 user 的对象,用于保存比如说这个用户的用户名等信息,后面 service-2/3/4() 都需要用到这个对象的信息,比如说 service-2() 代表下订单、service-3() 代表发货、service-4() 代表完结订单,在这种情况下,每一个方法都需要用户信息,所以就需要把这个 user 对象层层传递下去,从 service-1() 传到 service-2(),再从 service-2() 传到 service-3(),以此类推。
这样做会导致代码非常冗余,那有没有什么办法可以解决这个问题呢?我们首先想到的方法就是使用一个 HashMap如下图所示
比如说我们使用了这样的 Map 之后,就不需要把 user 对象层层传递了,而是在执行 service-1() 的时候,把这个用户信息给 put 进去,然后后面需要拿用户信息的时候,直接从静态的 User map 里面 get 就可以了。这样一来,无论你执行哪个方法,都可以直接获取到这个用户信息。当然,我们也要考虑到 web 服务器通常都是多线程的,当多个线程同时工作的时候,我们也需要保证线程安全。
所以在这里,如果我们使用 HashMap 是不够的,因为它是线程不安全的,那我们就可以使用 synchronized或者直接把 HashMap 替换成 ConcurrentHashMap用类似的方法来保证线程安全这样的改进如下图所示
在这个图中,可以看出有两个线程,并且每个线程所做的事情都是访问 service-1/2/3/4()。那么当它们同时运行的时候,都会同时访问这个 User map于是就需要 User map 是线程安全的。
无论我们使用 synchronized 还是使用 ConcurrentHashMap它对性能都是有所影响的因为即便是使用性能比较好的 ConcurrentHashMap它也是包含少量的同步或者是 cas 等过程。相比于完全没有同步,它依然是有性能损耗的。所以在此一个更好的办法就是使用 ThreadLocal。
这样一来,我们就可以在不影响性能的情况下,也无需层层传递参数,就可以达到保存当前线程所对应的用户信息的目的。如下图所示:
在这个图中可以看出,同样是多个线程同时去执行,但是这些线程同时去访问这个 ThreadLocal 并且能利用 ThreadLocal 拿到只属于自己的独享对象。这样的话,就无需任何额外的措施,保证了线程安全,因为每个线程是独享 user 对象的。代码如下所示:
public class ThreadLocalDemo07 {
public static void main(String[] args) {
new Service1().service1();
}
}
class Service1 {
public void service1() {
User user = new User("拉勾教育");
UserContextHolder.holder.set(user);
new Service2().service2();
}
}
class Service2 {
public void service2() {
User user = UserContextHolder.holder.get();
System.out.println("Service2拿到用户名" + user.name);
new Service3().service3();
}
}
class Service3 {
public void service3() {
User user = UserContextHolder.holder.get();
System.out.println("Service3拿到用户名" + user.name);
UserContextHolder.holder.remove();
}
}
class UserContextHolder {
public static ThreadLocal<User> holder = new ThreadLocal<>();
}
class User {
String name;
public User(String name) {
this.name = n
}
}
在这个代码中我们可以看出,我们有一个 UserContextHolder里面保存了一个 ThreadLocal在调用 Service1 的方法的时候,就往里面存入了 user 对象,而在后面去调用的时候,直接从里面用 get 方法取出来就可以了。没有参数层层传递的过程,非常的优雅、方便。
代码运行结果:
Service2拿到用户名拉勾教育
Service3拿到用户名拉勾教育
总结
下面我们进行总结。
本讲主要介绍了 ThreadLocal 的两个典型的使用场景。
场景1ThreadLocal 用作保存每个线程独享的对象,为每个线程都创建一个副本,每个线程都只能修改自己所拥有的副本, 而不会影响其他线程的副本,这样就让原本在并发情况下,线程不安全的情况变成了线程安全的情况。
场景2ThreadLocal 用作每个线程内需要独立保存信息的场景,供其他方法更方便得获取该信息,每个线程获取到的信息都可能是不一样的,前面执行的方法设置了信息后,后续方法可以通过 ThreadLocal 直接获取到,避免了传参。

View File

@@ -0,0 +1,120 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
45 ThreadLocal 是用来解决共享资源的多线程访问的问题吗?
本课时主要讲解一个问题ThreadLocal 是不是用来解决共享资源的多线程访问的。
这是一个常见的面试问题,如果被问到了 ThreadLocal则有可能在你介绍完它的作用、注意点等内容之后再问你ThreadLocal 是不是用来解决共享资源的多线程访问的呢?假如遇到了这样的问题,其思路一定要清晰。这里我给出一个参考答案。
面试时被问到应如何回答
这道题的答案很明确——不是ThreadLocal 并不是用来解决共享资源问题的。虽然 ThreadLocal 确实可以用于解决多线程情况下的线程安全问题,但其资源并不是共享的,而是每个线程独享的。所以这道题其实是有一定陷阱成分在内的。
ThreadLocal 解决线程安全问题的时候,相比于使用“锁”而言,换了一个思路,把资源变成了各线程独享的资源,非常巧妙地避免了同步操作。具体而言,它可以在 initialValue 中 new 出自己线程独享的资源,而多个线程之间,它们所访问的对象本身是不共享的,自然就不存在任何并发问题。这是 ThreadLocal 解决并发问题的最主要思路。
如果我们把放到 ThreadLocal 中的资源用 static 修饰,让它变成一个共享资源的话,那么即便使用了 ThreadLocal同样也会有线程安全问题。比如我们对第 44 讲中的例子进行改造,如果我们在 SimpleDateFormat 之前加上一个 static 关键字来修饰,并且把这个静态对象放到 ThreadLocal 中去存储的话,代码如下所示:
public class ThreadLocalStatic {
public static ExecutorService threadPool = Executors.newFixedThreadPool(16);
static SimpleDateFormat dateFormat = new SimpleDateFormat("mm:ss");
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 1000; i++) {
int finalI = i;
threadPool.submit(new Runnable() {
@Override
public void run() {
String date = new ThreadLocalStatic().date(finalI);
System.out.println(date);
}
});
}
threadPool.shutdown();
}
public String date(int seconds) {
Date date = new Date(1000 * seconds);
SimpleDateFormat dateFormat = ThreadSafeFormatter.dateFormatThreadLocal.get();
return dateFormat.format(date);
}
}
class ThreadSafeFormatter {
public static ThreadLocal<SimpleDateFormat> dateFormatThreadLocal = new ThreadLocal<SimpleDateFormat>() {
@Override
protected SimpleDateFormat initialValue() {
return ThreadLocalStatic.dateFormat;
}
}
}
那么在多线程中去获取这个资源并且同时使用的话,同样会出现时间重复的问题,运行结果如下。
00:15
00:15
00:05
00:16
...
可以看出00:15 被多次打印了,发生了线程安全问题。也就是说,如果我们需要放到 ThreadLocal 中的这个对象是共享的,是被 static 修饰的,那么此时其实根本就不需要用到 ThreadLocal即使用了 ThreadLocal 并不能解决线程安全问题。
相反,我们对于这种共享的变量,如果想要保证它的线程安全,应该用其他的方法,比如说可以使用 synchronized 或者是加锁等其他的方法来解决线程安全问题,而不是使用 ThreadLocal因为这不是 ThreadLocal 应该使用的场景。
这个问题回答到这里,可能会引申出下面这个问题。
ThreadLocal 和 synchronized 是什么关系
面试官可能会问:你既然说 ThreadLocal 和 synchronized 它们两个都能解决线程安全问题,那么 ThreadLocal 和 synchronized 是什么关系呢?
我们先说第一种情况。当 ThreadLocal 用于解决线程安全问题的时候也就是把一个对象给每个线程都生成一份独享的副本的在这种场景下ThreadLocal 和 synchronized 都可以理解为是用来保证线程安全的手段。例如,在第 44 讲 SimpleDateFormat 的例子中,我们既使用了 synchronized 来达到目的,也使用了 ThreadLocal 作为实现方案。但是效果和实现原理不同:
ThreadLocal 是通过让每个线程独享自己的副本,避免了资源的竞争。
synchronized 主要用于临界资源的分配,在同一时刻限制最多只有一个线程能访问该资源。
相比于 ThreadLocal 而言synchronized 的效率会更低一些但是花费的内存也更少。在这种场景下ThreadLocal 和 synchronized 虽然有不同的效果,不过都可以达到线程安全的目的。
但是对于 ThreadLocal 而言,它还有不同的使用场景。比如当 ThreadLocal 用于让多个类能更方便地拿到我们希望给每个线程独立保存这个信息的场景下时(比如每个线程都会对应一个用户信息,也就是 user 对象在这种场景下ThreadLocal 侧重的是避免传参,所以此时 ThreadLocal 和 synchronized 是两个不同维度的工具。
以上就是本课时的内容。
在本课时中,首先介绍了 ThreadLocal 是不是用来解决共享资源的多线程访问的问题的,答案是“不是”,因为对于 ThreadLocal 而言,每个线程中的资源并不共享;然后我们又介绍了 ThreadLocal 和 synchronized 的关系。

View File

@@ -0,0 +1,164 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
46 多个 ThreadLocal 在 Thread 中的 threadlocals 里是怎么存储的?
本课时我们主要分析一下在 Thread 中多个 ThreadLocal 是怎么存储的。
Thread、 ThreadLocal 及 ThreadLocalMap 三者之间的关系
在讲解本课时之前,先要搞清楚 Thread、 ThreadLocal 及 ThreadLocalMap 三者之间的关系。我们用最直观、最容易理解的图画的方式来看看它们三者的关系:
我们看到最左下角的 Thread 1这是一个线程它的箭头指向了 ThreadLocalMap 1其要表达的意思是每个 Thread 对象中都持有一个 ThreadLocalMap 类型的成员变量,在这里 Thread 1 所拥有的成员变量就是 ThreadLocalMap 1。
而这个 ThreadLocalMap 自身类似于是一个 Map里面会有一个个 key value 形式的键值对。那么我们就来看一下它的 key 和 value 分别是什么。可以看到这个表格的左侧是 ThreadLocal 1、ThreadLocal 2…… ThreadLocal n能看出这里的 key 就是 ThreadLocal 的引用。
而在表格的右侧是一个一个的 value这就是我们希望 ThreadLocal 存储的内容,例如 user 对象等。
这里需要重点看到它们的数量对应关系:一个 Thread 里面只有一个ThreadLocalMap ,而在一个 ThreadLocalMap 里面却可以有很多的 ThreadLocal每一个 ThreadLocal 都对应一个 value。因为一个 Thread 是可以调用多个 ThreadLocal 的,所以 Thread 内部就采用了 ThreadLocalMap 这样 Map 的数据结构来存放 ThreadLocal 和 value。
通过这张图片,我们就可以搞清楚 Thread、 ThreadLocal 及 ThreadLocalMap 三者在宏观上的关系了。
源码分析
知道了它们的关系之后,我们再来进行源码分析,来进一步地看到它们内部的实现。
get 方法
首先我们来看一下 get 方法,源码如下所示:
public T get() {
//获取到当前线程
Thread t = Thread.currentThread();
//获取到当前线程内的 ThreadLocalMap 对象,每个线程内都有一个 ThreadLocalMap 对象
ThreadLocalMap map = getMap(t);
if (map != null) {
//获取 ThreadLocalMap 中的 Entry 对象并拿到 Value
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
//如果线程内之前没创建过 ThreadLocalMap就创建
return setInitialValue();
}
这是 ThreadLocal 的 get 方法,可以看出它利用了 Thread.currentThread 来获取当前线程的引用,并且把这个引用传入到了 getMap 方法里面,来拿到当前线程的 ThreadLocalMap。
然后就是一个 if ( map != null ) 条件语句,那我们先来看看 if (map == null) 的情况,如果 map == null则说明之前这个线程中没有创建过 ThreadLocalMap于是就去调用 setInitialValue 来创建;如果 map != null我们就应该通过 this 这个引用(也就是当前的 ThreadLocal 对象的引用)来获取它所对应的 Entry同时再通过这个 Entry 拿到里面的 value最终作为结果返回。
值得注意的是,这里的 ThreadLocalMap 是保存在线程 Thread 类中的,而不是保存在 ThreadLocal 中的。
getMap 方法
下面我们来看一下 getMap 方法,源码如下所示:
ThreadLocalMap getMap(Thread t) {
return t.threadLocals;
}
可以看到,这个方法很清楚地表明了 Thread 和 ThreadLocalMap 的关系,可以看出 ThreadLocalMap 是线程的一个成员变量。这个方法的作用就是获取到当前线程内的 ThreadLocalMap 对象,每个线程都有 ThreadLocalMap 对象,而这个对象的名字就叫作 threadLocals初始值为 null代码如下
ThreadLocal.ThreadLocalMap threadLocals = null;
set 方法
下面我们再来看一下 set 方法,源码如下所示:
public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
}
set 方法的作用是把我们想要存储的 value 给保存进去。可以看出,首先,它还是需要获取到当前线程的引用,并且利用这个引用来获取到 ThreadLocalMap ;然后,如果 map == null 则去创建这个 map而当 map != null 的时候就利用 map.set 方法,把 value 给 set 进去。
可以看出map.set(this, value) 传入的这两个参数中,第一个参数是 this就是当前 ThreadLocal 的引用,这也再次体现了,在 ThreadLocalMap 中,它的 key 的类型是 ThreadLocal而第二个参数就是我们所传入的 value这样一来就可以把这个键值对保存到 ThreadLocalMap 中去了。
ThreadLocalMap 类,也就是 Thread.threadLocals
下面我们来看一下 ThreadLocalMap 这个类,下面这段代码截取自定义在 ThreadLocal 类中的 ThreadLocalMap 类:
static class ThreadLocalMap {
static class Entry extends WeakReference<ThreadLocal<?>> {
/** The value associated with this ThreadLocal. */
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
private Entry[] table;
//...
}
ThreadLocalMap 类是每个线程 Thread 类里面的一个成员变量,其中最重要的就是截取出的这段代码中的 Entry 内部类。在 ThreadLocalMap 中会有一个 Entry 类型的数组,名字叫 table。我们可以把 Entry 理解为一个 map其键值对为
键,当前的 ThreadLocal
值,实际需要存储的变量,比如 user 用户对象或者 simpleDateFormat 对象等。
ThreadLocalMap 既然类似于 Map所以就和 HashMap 一样,也会有包括 set、get、rehash、resize 等一系列标准操作。但是,虽然思路和 HashMap 是类似的,但是具体实现会有一些不同。
比如其中一个不同点就是,我们知道 HashMap 在面对 hash 冲突的时候,采用的是拉链法。它会先把对象 hash 到一个对应的格子中,如果有冲突就用链表的形式往下链,如下图所示:
但是 ThreadLocalMap 解决 hash 冲突的方式是不一样的,它采用的是线性探测法。如果发生冲突,并不会用链表的形式往下链,而是会继续寻找下一个空的格子。这是 ThreadLocalMap 和 HashMap 在处理冲突时不一样的点。
以上就是本节课的内容。
在本节课中,我们主要分析了 Thread、 ThreadLocal 和 ThreadLocalMap 这三个非常重要的类的关系。用图画的方式表明了它们之间的关系:一个 Thread 有一个 ThreadLocalMap而 ThreadLocalMap 的 key 就是一个个的 ThreadLocal它们就是用这样的关系来存储并维护内容的。之后我们对于 ThreadLocal 的一些重要方法进行了源码分析。

View File

@@ -0,0 +1,112 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
47 内存泄漏——为何每次用完 ThreadLocal 都要调用 remove()
在本课时我们主要讲解为什么用完 ThreadLocal 之后都要求调用 remove 方法?
首先,我们要知道这个事情和内存泄漏有关,所以就让我们先来看一下什么是内存泄漏。
什么是内存泄漏
内存泄漏指的是,当某一个对象不再有用的时候,占用的内存却不能被回收,这就叫作内存泄漏。
因为通常情况下,如果一个对象不再有用,那么我们的垃圾回收器 GC就应该把这部分内存给清理掉。这样的话就可以让这部分内存后续重新分配到其他的地方去使用否则如果对象没有用但一直不能被回收这样的垃圾对象如果积累的越来越多则会导致我们可用的内存越来越少最后发生内存不够用的 OOM 错误。
下面我们来分析一下,在 ThreadLocal 中这样的内存泄漏是如何发生的。
Key 的泄漏
在上一讲中,我们分析了 ThreadLocal 的内部结构,知道了每一个 Thread 都有一个 ThreadLocal.ThreadLocalMap 这样的类型变量,该变量的名字叫作 threadLocals。线程在访问了 ThreadLocal 之后,都会在它的 ThreadLocalMap 里面的 Entry 中去维护该 ThreadLocal 变量与具体实例的映射。
我们可能会在业务代码中执行了 ThreadLocal instance = null 操作,想清理掉这个 ThreadLocal 实例,但是假设我们在 ThreadLocalMap 的 Entry 中强引用了 ThreadLocal 实例,那么,虽然在业务代码中把 ThreadLocal 实例置为了 null但是在 Thread 类中依然有这个引用链的存在。
GC 在垃圾回收的时候会进行可达性分析,它会发现这个 ThreadLocal 对象依然是可达的,所以对于这个 ThreadLocal 对象不会进行垃圾回收,这样的话就造成了内存泄漏的情况。
JDK 开发者考虑到了这一点,所以 ThreadLocalMap 中的 Entry 继承了 WeakReference 弱引用,代码如下所示:
static class Entry extends WeakReference<ThreadLocal<?>> {
/** The value associated with this ThreadLocal. */
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
可以看到,这个 Entry 是 extends WeakReference。弱引用的特点是如果这个对象只被弱引用关联而没有任何强引用关联那么这个对象就可以被回收所以弱引用不会阻止 GC。因此这个弱引用的机制就避免了 ThreadLocal 的内存泄露问题。
这就是为什么 Entry 的 key 要使用弱引用的原因。
Value 的泄漏
可是,如果我们继续研究的话会发现,虽然 ThreadLocalMap 的每个 Entry 都是一个对 key 的弱引用,但是这个 Entry 包含了一个对 value 的强引用,还是刚才那段代码:
static class Entry extends WeakReference<ThreadLocal<?>> {
/** The value associated with this ThreadLocal. */
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
可以看到value = v 这行代码就代表了强引用的发生。
正常情况下当线程终止key 所对应的 value 是可以被正常垃圾回收的,因为没有任何强引用存在了。但是有时线程的生命周期是很长的,如果线程迟迟不会终止,那么可能 ThreadLocal 以及它所对应的 value 早就不再有用了。在这种情况下,我们应该保证它们都能够被正常的回收。
为了更好地分析这个问题,我们用下面这张图来看一下具体的引用链路(实线代表强引用,虚线代表弱引用):
可以看到,左侧是引用栈,栈里面有一个 ThreadLocal 的引用和一个线程的引用,右侧是我们的堆,在堆中是对象的实例。
我们重点看一下下面这条链路Thread Ref → Current Thread → ThreadLocalMap → Entry → Value → 可能泄漏的value实例。
这条链路是随着线程的存在而一直存在的,如果线程执行耗时任务而不停止,那么当垃圾回收进行可达性分析的时候,这个 Value 就是可达的,所以不会被回收。但是与此同时可能我们已经完成了业务逻辑处理,不再需要这个 Value 了,此时也就发生了内存泄漏问题。
JDK 同样也考虑到了这个问题,在执行 ThreadLocal 的 set、remove、rehash 等方法时,它都会扫描 key 为 null 的 Entry如果发现某个 Entry 的 key 为 null则代表它所对应的 value 也没有作用了,所以它就会把对应的 value 置为 null这样value 对象就可以被正常回收了。
但是假设 ThreadLocal 已经不被使用了,那么实际上 set、remove、rehash 方法也不会被调用,与此同时,如果这个线程又一直存活、不终止的话,那么刚才的那个调用链就一直存在,也就导致了 value 的内存泄漏。
如何避免内存泄露
分析完这个问题之后,该如何解决呢?解决方法就是我们本课时的标题:调用 ThreadLocal 的 remove 方法。调用这个方法就可以删除对应的 value 对象,可以避免内存泄漏。
我们来看一下 remove 方法的源码:
public void remove() {
ThreadLocalMap m = getMap(Thread.currentThread());
if (m != null)
m.remove(this);
}
可以看出,它是先获取到 ThreadLocalMap 这个引用的,并且调用了它的 remove 方法。这里的 remove 方法可以把 key 所对应的 value 给清理掉这样一来value 就可以被 GC 回收了。
所以,在使用完了 ThreadLocal 之后,我们应该手动去调用它的 remove 方法,目的是防止内存泄漏的发生。

View File

@@ -0,0 +1,123 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
48 Callable 和 Runnable 的不同?
你好,欢迎来到第 48 课时,在本课时我们将讲解 Callable 和 Runnable 的不同。
为什么需要 CallableRunnable 的缺陷
先来看一下,为什么需要 Callable要想回答这个问题我们先来看看现有的 Runnable 有哪些缺陷?
不能返回一个返回值
第一个缺陷,对于 Runnable 而言,它不能返回一个返回值,虽然可以利用其他的一些办法,比如在 Runnable 方法中写入日志文件或者修改某个共享的对象的办法,来达到保存线程执行结果的目的,但这种解决问题的行为千曲百折,属于曲线救国,效率着实不高。
实际上,在很多情况下执行一个子线程时,我们都希望能得到执行的任务的结果,也就是说,我们是需要得到返回值的,比如请求网络、查询数据库等。可是 Runnable 不能返回一个返回值,这是它第一个非常严重的缺陷。
不能抛出 checked Exception
第二个缺陷就是不能抛出 checked Exception如下面这段代码所示
public class RunThrowException {
/**
* 普通方法内可以 throw 异常,并在方法签名上声明 throws
*/
public void normalMethod() throws Exception {
throw new IOException();
}
Runnable runnable = new Runnable() {
/**
* run方法上无法声明 throws 异常且run方法内无法 throw 出 checked Exception除非使用try catch进行处理
*/
@Override
public void run() {
try {
throw new IOException();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
在这段代码中,有两个方法,第一个方法是一个普通的方法,叫作 normalMethod可以看到在它的方法签名中有 throws Exception并且在它的方法内也 throw 了一个 new IOException()。
然后在下面的的代码中,我们新建了一个 Runnable 对象,同时重写了它的 run 方法,我们没有办法在这个 run 方法的方法签名上声明 throws 一个异常出来。同时,在这个 run 方法里面也没办法 throw 一个 checked Exception除非如代码所示用 try catch 包裹起来,但是如果不用 try catch 是做不到的。
这就是对于 Runnable 而言的两个重大缺陷。
为什么有这样的缺陷
为什么有这样的缺陷呢?我们来看一下 Runnable 接口的定义:
public interface Runnable {
public abstract void run();
}
代码比较短小Runnable 是一个 interface并且里面只有一个方法叫作 public abstract void run()。这个方法已经规定了 run() 方法的返回类型是 void而且这个方法没有声明抛出任何异常。所以当实现并重写这个方法时我们既不能改返回值类型也不能更改对于异常抛出的描述因为在实现方法的时候语法规定是不允许对这些内容进行修改的。
回顾课程之前小节的众多代码,从来没有出现过可以在 run 方法中返回一个返回值这样的情况。
Runnable 为什么设计成这样
我们再深入思考一层,为什么 Java 要把它设计成这个样子呢?
假设 run() 方法可以返回返回值,或者可以抛出异常,也无济于事,因为我们并没有办法在外层捕获并处理,这是因为调用 run() 方法的类(比如 Thread 类和线程池)是 Java 直接提供的,而不是我们编写的。
所以就算它能有一个返回值,我们也很难把这个返回值利用到,如果真的想弥补 Runnable 的这两个缺陷,可以用下面的补救措施——使用 Callable。
Callable 接口
Callable 是一个类似于 Runnable 的接口,实现 Callable 接口的类和实现 Runnable 接口的类都是可以被其他线程执行的任务。 我们看一下 Callable 的源码:
public interface Callable<V> {
V call() throws Exception;
}
可以看出它也是一个 interface并且它的 call 方法中已经声明了 throws Exception前面还有一个 V 泛型的返回值,这就和之前的 Runnable 有很大的区别。实现 Callable 接口,就要实现 call 方法,这个方法的返回值是泛型 V如果把 call 中计算得到的结果放到这个对象中,就可以利用 call 方法的返回值来获得子线程的执行结果了。
Callable 和 Runnable 的不同之处
最后总结一下 Callable 和 Runnable 的不同之处:
方法名Callable 规定的执行方法是 call(),而 Runnable 规定的执行方法是 run()
返回值Callable 的任务执行后有返回值,而 Runnable 的任务执行后是没有返回值的;
抛出异常call() 方法可抛出异常,而 run() 方法是不能抛出受检查异常的;
和 Callable 配合的有一个 Future 类,通过 Future 可以了解任务执行情况,或者取消任务的执行,还可获取任务执行的结果,这些功能都是 Runnable 做不到的Callable 的功能要比 Runnable 强大。
以上就是本课时的内容了。首先介绍了 Runnable 的两个缺陷,第一个是没有返回值,第二个是不能抛出受检查异常;然后分析了为什么会有这样的缺陷,以及为什么设计成这样;接下来分析了 Callable 接口,并且把 Callable 接口和 Runnable 接口的区别进行了对比和总结。

Some files were not shown because too many files have changed in this diff Show More