开发者博客 – IT技术 尽在开发者博客

开发者博客 – 科技是第一生产力


  • 首页

  • 归档

  • 搜索

使用Netty,我们到底在开发些什么? 协议开发 连接管理功

发表于 2019-02-27

更多精彩文章。

《微服务不是全部,只是特定领域的子集》

《“分库分表” ?选型和流程要慎重,否则会失控》

这么多监控组件,总有一款适合你

《使用Netty,我们到底在开发些什么?》

《这可能是最中肯的Redis规范了》

《程序员画像,十年沉浮》

最有用系列:

《Linux生产环境上,最常用的一套“vim“技巧》

《Linux生产环境上,最常用的一套“Sed“技巧》

《Linux生产环境上,最常用的一套“AWK“技巧》


在java界,netty无疑是开发网络应用的拿手菜。你不需要太多关注复杂的nio模型和底层网络的细节,使用其丰富的接口,可以很容易的实现复杂的通讯功能。

和golang的网络模块相比,netty还是太过臃肿。不过java类框架就是这样,属于那种离了IDE就无法存活的编码语言。

最新的netty版本将模块分的非常细,如果不清楚每个模块都有什么内容,直接使用netty-all即可。

单纯从使用方面来说,netty是非常简单的,掌握ByteBuf、Channel、Pipeline、Event模型等,就可以进行开发了。你会发现面试netty相关知识,没得聊。但Netty与其他开发模式很大不同,最主要的就是其异步化。异步化造成的后果就是编程模型的不同,同时有调试上的困难,对编码的要求比较高,因为bug的代价与业务代码的bug代价不可同日而语。

但从项目来说,麻雀虽小五脏俱全,从业务层到服务网关,以及各种技术保障,包括监控和配置,都是需要考虑的因素。netty本身占比很小。

本文将说明使用netty开发,都关注哪些通用的内容,然后附上单机支持100w连接的linux配置。本文并不关注netty的基础知识。
协议开发
====

网络开发中最重要的就是其通讯格式,协议。我们常见的protobuf、json、avro、mqtt等,都属于此列。协议有语法、语义、时序三个要素。

我见过很多中间件应用,采用的是redis协议,而后端落地的却是mysql;也见过更多的采用mysql协议实现的各种自定义存储系统,比如proxy端的分库分表中间件、tidb等。

我们常用的redis,使用的是文本协议;mysql等实现的是二进制协议。放在netty中也是一样,实现一套codec即可(继承Decoder或Encoder系列)。netty默认实现了dns、haproxy、http、http2、memcache、mqtt、redis、smtp、socks、stomp、xml等协议,可以说是很全了,直接拿来用很爽。

一个可能的产品结构会是这样的,对外提供一致的外观,核心存储却不同:

文本协议在调试起来是比较直观和容易的,但安全性欠佳;而二进制协议就需要依赖日志、wireshark等其他方式进行分析,增加了开发难度。传说中的粘包拆包,就在这里处理。而造成粘包的原因,主要是由于缓冲区的介入,所以需要约定双方的传输概要等信息,netty在一定程度上解决了这个问题。
每一个想要开发网络应用的同学,心里都埋了一颗重新设计协议的梦想种子。但协议的设计可以说是非常困难了,要深耕相应业务,还要考虑其扩展性。如没有特别的必要,建议使用现有的协议。

连接管理功能

做Netty开发,连接管理功能是非常重要的。通信质量、系统状态,以及一些黑科技功能,都是依赖连接管理功能。

无论是作为服务端还是客户端,netty在创建连接之后,都会得到一个叫做Channel的对象。我们所要做的,就是对它的管理,我习惯给它起名叫做ConnectionManager。
管理类会通过缓存一些内存对象,用来统计运行中的数据。比如面向连接的功能:包发送、接收数量;包发送、接收速率;错误计数;连接重连次数;调用延迟;连接状态等。这会频繁用到java中concurrent包的相关类,往往也是bug集中地。

但我们还需要更多,管理类会给予每个连接更多的功能。比如,连接创建后,想要预热一些功能,那这些状态就可以参与路由的决策。通常情况下,将用户或其他元信息也attach到连接上,能够多维度的根据条件筛选一些连接,进行批量操作,比如灰度、过载保护等,是一个非常重要的功能。

管理后台可以看到每个连接的信息,筛选到一个或多个连接后,能够开启对这些连接的流量录制、信息监控、断点调试,你能体验到掌控一切的感觉。

管理功能还能够看到系统的整个运行状态,及时调整负载均衡策略;同时对扩容、缩容提供数据依据。

心跳检测

应用协议层的心跳是必须的,它和tcp keepalive是完全不同的概念。

应用层协议层的心跳检测的是连接双方的存活性,兼而连接质量,而keepalive检测的是连接本身的存活性。而且后者的超时时间默认过长,完全不能适应现代的网络环境。

心跳就是靠轮训,无论是服务端,还是客户端比如GCM等。保活机制会在不同的应用场景进行动态的切换,比如程序唤起和在后台,轮训的策略是不一样的。
Netty内置通过增加IdleStateHandler产生IDLE事件进行便捷的心跳控制。你要处理的,就是心跳超时的逻辑,比如延迟重连。但它的轮训时间是固定的,无法动态修改,高级功能需要自己定制。

在一些客户端比如Android,频繁心跳的唤起会浪费大量的网络和电量,它的心跳策略会更加复杂一些。

边界

优雅退出机制

Java的优雅停机通常通过注册JDK ShutdownHook来实现。

1
复制代码Runtime.getRuntime().addShutdownHook();

一般通过kill -15进行java进程的关闭,以便在进程死亡之前进行一些清理工作。

注意:kill -9 会立马杀死进程,不给遗言的机会,比较危险。

虽然netty做了很多优雅退出的工作,通过EventLoopGroup的shutdownGracefully方法对nio进行了一些状态设置,但在很多情况下,这还不够多。它只负责单机环境的优雅关闭。

流量可能还会通过外层的路由持续进入,造成无效请求。我的通常做法是首先在外层路由进行一次本地实例的摘除,把流量截断,然后再进行netty本身的优雅关闭。这种设计非常简单,即使没有重试机制也会运行的很好,前提是在路由层需要提前暴露相关接口。

异常处理功能

netty由于其异步化的开发方式,以及其事件机制,在异常处理方面就显得异常重要。为了保证连接的高可靠性,许多异常需要静悄悄的忽略,或者在用户态没有感知。

netty的异常会通过pipeline进行传播,所以在任何一层进行处理都是可行的,但编程习惯上,习惯性抛到最外层集中处理。

为了最大限度的区别异常信息,通常会定义大量的异常类,不同的错误会抛出不同的异常。发生异常后,可以根据不同的类型选择断线重连(比如一些二进制协议的编解码紊乱问题),或者调度到其他节点。

功能限制

指令模式

网络应用就该干网络应用的事,任何通讯都是昂贵的。在《Linux之《荒岛余生》(五)网络篇》中,我们谈到百万连接的服务器,广播一个1kb消息,就需要1000M的带宽,所以并不是什么都可以放在网络应用里的。

一个大型网络应用的合理的思路就是值发送相关指令。客户端在收到指令以后,通过其他方式,比如http,进行大型文件到获取。很多IM的设计思路就是如此。

指令模式还会让通讯系统的扩展性和稳定性得到保证。增加指令可以是配置式的,立即生效,服务端不需要编码重启。

稳定性保证

网络应用的流量一般都是非常大的,并不适合全量日志的开启。应用应该只关注主要事件的日志,关注异常情况下的处理流程,日志要打印有度。

网络应用也不适合调用其他缓慢的api,或者任何阻塞I/O的接口。一些实时的事件,也不应该通过调用接口吐出数据,可以走高速mq等其他异步通道。

缓存可能是网络应用里用的最多的组件。jvm内缓存可以存储一些单机的统计数据,redis等存储一些全局性的统计和中间态数据。

网络应用中会大量使用redis、kv、高吞吐的mq,用来快速响应用户请求。总之,尽量保持通讯层的清爽,你会省去很多忧虑。
单机支持100万连接的Linux配置
==================

单机支持100万连接是可行的,但带宽问题会成为显著的瓶颈。启用压缩的二进制协议会节省部分带宽,但开发难度增加。

和《LWP进程资源耗尽,Resource temporarily unavailable》中提到的ES配置一样,优化都有类似的思路。这份配置,可以节省你几天的时间,请收下!

操作系统优化

更改进程最大文件句柄数

1
复制代码ulimit -n 1048576

修改单个进程可分配的最大文件数

1
复制代码echo 2097152 > /proc/sys/fs/nr_open

修改/etc/security/limits.conf文件

1
2
3
4
复制代码*   soft nofile  1048576
* hard nofile 1048576
* soft nproc unlimited
root soft nproc unlimited

记得清理掉/etc/security/limits.d/*下的配置

网络优化

打开/etc/sysctl.conf,添加配置
然后执行,使用sysctl生效

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
复制代码#单个进程可分配的最大文件数
fs.nr_open=2097152

#系统最大文件句柄数
fs.file-max = 1048576

#backlog 设置
net.core.somaxconn=32768
net.ipv4.tcp_max_syn_backlog=16384
net.core.netdev_max_backlog=16384

#可用知名端口范围配置
net.ipv4.ip_local_port_range='1000 65535'

#TCP Socket 读写 Buffer 设置
net.core.rmem_default=262144
net.core.wmem_default=262144
net.core.rmem_max=16777216
net.core.wmem_max=16777216
net.core.optmem_max=16777216
net.ipv4.tcp_rmem='1024 4096 16777216'
net.ipv4.tcp_wmem='1024 4096 16777216'

#TCP 连接追踪设置
net.nf_conntrack_max=1000000
net.netfilter.nf_conntrack_max=1000000
net.netfilter.nf_conntrack_tcp_timeout_time_wait=30

#TIME-WAIT Socket 最大数量、回收与重用设置
net.ipv4.tcp_max_tw_buckets=1048576

# FIN-WAIT-2 Socket 超时设置
net.ipv4.tcp_fin_timeout = 15

总结

netty的开发工作并不集中在netty本身,更多体现在保证服务的高可靠性和稳定性上。同时有大量的工作集中在监控和调试,减少bug修复的成本。

深入了解netty是在系统遇到疑难问题时能够深入挖掘进行排查,或者对苛刻的性能进行提升。但对于广大应用开发者来说,netty的上手成本小,死挖底层并不会产生太多收益。

它只是个工具,你还能让它怎样啊。
0.jpeg

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Runloop与performSelector

发表于 2019-02-23

自己平常开发中比较少用到performSelector相关的API,但是平常看些第三方的时候,发现第三方作者用到performSelector相关的API比较多。自己理解的是,可以在一定程度上解耦,不必引入相关类。但是最近在用到时,遇到了一些问题。由此,查看了一些博客,自己也做了验证,在此记录一下。

先看一段代码:

1
2
3
4
5
6
7
8
9
10
11
12
复制代码- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
[super touchesBegan:touches withEvent:event];
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
NSLog(@"1");
[self performSelector:@selector(testPerform) withObject:nil afterDelay:0];//
NSLog(@"3");
});
}
- (void)testPerform{
NSLog(@"2");
}

执行结果如下:没有打印出2,只打印出了1和3。

结果

看文档中对这个API的注释是说,这个方法调用后,在当前runloop里设置了一个timer,来触发这个方法执行。而当前这个方法是在子线程中调用的,在子线程中runloop不是自动创建并跑起来的,需要手动调用,才会创建。因为这个在子线程中的调用没有创建runloop,所以就没有执行testPerform。
官方注释:

那按照官方文档说明在子线程中加入runloop,看下执行效果。

1
2
3
4
5
6
7
8
9
10
复制代码- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
[super touchesBegan:touches withEvent:event];
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
NSLog(@"1");
NSRunLoop *runloop = [NSRunLoop currentRunLoop];
[self performSelector:@selector(testPerform) withObject:nil afterDelay:0];//
NSLog(@"3");
});
}

通过获取当前的runloop,系统就会返回当前的runloop,如果没有的话,会创建后返回,但是加入了runloop的时候,执行结果,依然是只有打印出来1和3,没有打印2。在[self performSelector:@selector(testPerform) withObject:nil afterDelay:0]方法调用前后,通过控制台打印runloop对象,确实看到了调用方法后,runloop里多了一个timer源。

前后runloop对比:

有了runloop也有了触发方法testPerform执行的timer,为什么还依然没有执行。因为runloop没有跑起来。
所以创建完runloop后,还需要runloop跑起来。【通过给当前runloop添加观察者,查看runloop的状态,runloop没有跑起来】当我们调用[runloop run];方法后,将runloop跑起来后,testPerform才会执行。打印结果为1,2,3。
但,问题又来了,既然加入了runloop,并且跑起来了,为什么3还会打印出来,runloop不是相当于死循环吗?循环外的3为什么会打印出来?这个问题,通过加入的runloop的观察者的打印情况可以看出来,是因为,runloop在执行完testPerform后,就退出了。所以下边的3页打印出来了。

观察者打印:

可以看出,3是在runloop退出后,打印出来的。【在testPerform方法内打印runloop,看到此时runloop对象的timers数组里边已经是空的了。runloop的mode里没有source1、没有source0、也没有timer源,所以就退出了】由此,也可以猜测:在runloop里设置的timer触发[self performSelector:@selector(testPerform) withObject:nil afterDelay:0]方法后,该timer就销毁了。
怎样让runloop不退出呢?给当前runloop加入事件源或定时器temers,当前runloop就不会退出了,只是在不需要执行任务的时候进入休眠。

我在子线程中加入了timer后,通过观察者的打印结果来看,该runloop一直没有退出,所以3也就没有打印出来。【注意,repeats参数要设置为YES,否则执行完timer之后,runloop就不再持有timer,runloop就退出来了。还可以通过[runloop addPort:[NSPort port] forMode:NSDefaultRunLoopMode];加入事件源的方法,使runloop一直不退出。】
还有一个方法是- (void)performSelector:(SEL)aSelector withObject:(nullable id)anArgument afterDelay:(NSTimeInterval)delay inModes:(NSArray<NSRunLoopMode> *)modes;这个方法多了一个设置mode的参数,可以通过这个参数设置在timer在哪个mode下执行,读者可自己检测。

添加runloop观察者的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
复制代码- (void)addObserver
{
/*
kCFRunLoopEntry = (1UL << 0),1
kCFRunLoopBeforeTimers = (1UL << 1),2
kCFRunLoopBeforeSources = (1UL << 2), 4
kCFRunLoopBeforeWaiting = (1UL << 5), 32
kCFRunLoopAfterWaiting = (1UL << 6), 64
kCFRunLoopExit = (1UL << 7),128
kCFRunLoopAllActivities = 0x0FFFFFFFU
*/
CFRunLoopObserverRef observer = CFRunLoopObserverCreateWithHandler(CFAllocatorGetDefault(), kCFRunLoopAllActivities, YES, 0, ^(CFRunLoopObserverRef observer, CFRunLoopActivity activity) {
switch (activity) {
case 1:
{
NSLog(@"进入runloop");
}
break;
case 2:
{
NSLog(@"timers");
}
break;
case 4:
{
NSLog(@"sources");
}
break;
case 32:
{
NSLog(@"即将进入休眠");
}
break;
case 64:
{
NSLog(@"唤醒");
}
break;
case 128:
{
NSLog(@"退出");
}
break;
default:
break;
}
});
CFRunLoopAddObserver(CFRunLoopGetCurrent(), observer, kCFRunLoopCommonModes);//将观察者添加到common模式下,这样当default模式和UITrackingRunLoopMode两种模式下都有回调。
self.obsever = observer;
CFRelease(observer);
}

本篇记录算是自己的理解,水平有限,如果有错误的地方,请批评指正,会尽快修改。


参考致谢:

iOS底层原理总结 - RunLoop

关于 performSelector 的一些小探讨

NSRunLoop的退出方式

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

JVM(六)为什么新生代有两个Survivor分区?

发表于 2019-02-22

本文会使用排除法的手段,来讲解新生代的区域划分,从而让读者能够更清晰的理解分代回收器的原理,在开始之前我们先来整体认识一下分代收集器。

分代收集器会把内存空间分为:老生代和新生代两个区域,而新生代又会分为:Eden 区和两个 Survivor区(From Survivor、To Survivor),来看内存空间分布图,如下:

分代图

(图片来自 fancydeepin)

可以看出 Eden 和 Survivor 分区的默认比例是 8:1:1,这个值可以通过:–XX:SurvivorRatio 设定,默认值: –XX:SurvivorRatio=8。

顺便说一下,新生代和老生代默认情况下的内存占比是 1:2,该值可以通过:-XX:NewRatio 来设定。

为什么 Survivor 分区不能是 0 个?

如果 Survivor 是 0 的话,也就是说新生代只有一个 Eden 分区,每次垃圾回收之后,存活的对象都会进入老生代,这样老生代的内存空间很快就被占满了,从而触发最耗时的 Full GC ,显然这样的收集器的效率是我们完全不能接受的。

为什么 Survivor 分区不能是 1 个?

如果 Survivor 分区是 1 个的话,假设我们把两个区域分为 1:1,那么任何时候都有一半的内存空间是闲置的,显然空间利用率太低不是最佳的方案。

但如果设置内存空间的比例是 8:2 ,只是看起来似乎“很好”,假设新生代的内存为 100 MB( Survivor 大小为 20 MB ),现在有 70 MB 对象进行垃圾回收之后,剩余活跃的对象为 15 MB 进入 Survivor 区,这个时候新生代可用的内存空间只剩了 5 MB,这样很快又要进行垃圾回收操作,显然这种垃圾回收器最大的问题就在于,需要频繁进行垃圾回收。

为什么 Survivor 分区是 2 个?

如果 Survivor 分区有 2 个分区,我们就可以把 Eden、From Survivor、To Survivor 分区内存比例设置为 8:1:1 ,那么任何时候新生代内存的利用率都 90% ,这样空间利用率基本是符合预期的。再者就是虚拟机的大部分对象都符合“朝生夕死”的特性,所以每次新对象的产生都在空间占比比较大的 Eden 区,垃圾回收之后再把存活的对象方法存入 Survivor 区,如果是 Survivor 区存活的对象,那么“年龄”就 +1 ,当年龄增长到 15 (可通过 -XX:+MaxTenuringThreshold 设定)对象就升级到老生代。

总结

根据上面的分析可以得知,当新生代的 Survivor 分区为 2 个的时候,不论是空间利用率还是程序运行的效率都是最优的,所以这也是为什么 Survivor 分区是 2 个的原因了。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Java 集合(2)之 Iterator 迭代器

发表于 2019-02-20

Iterator 与 ListIterator

凡是实现 Collection 接口的集合类都有一个 iterator 方法,会返回一个实现了 Iterator 接口的对象,用于遍历集合。Iterator 接口主要有三个方法,分别是 hasNext、next、remove 方法。

ListIterator 继承自 Iterator,专门用于实现 List 接口对象,除了 Iterator 接口的方法外,还有其他几个方法。

基于顺序存储集合的 Iterator 可以直接按位置访问数据。基于链式存储集合的 Iterator,一般都是需要保存当前遍历的位置,然后根据当前位置来向前或者向后移动指针。

Iterator 与 ListIterator 的区别:

  • Iterator 可用于遍历 Set、List;ListIterator 只可用于遍历 List。
  • Iterator 只能向后遍历;ListIterator 可向前或向后遍历。
  • ListIterator 实现了 Iterator 的接口,并增加了
    add、set、hasPrevious、previous、previousIndex、nextIndex 方法。

快速失败(fail—fast)

快速失败机制(fail—fast)就是在使用迭代器遍历一个集合对象时,如果遍历过程中对集合进行修改(增删改),则会抛出 ConcurrentModificationException 异常。

例如以下代码,就会抛出 ConcurrentModificationException:

1
2
3
4
5
6
7
8
9
复制代码List<String> stringList = new ArrayList<>();
stringList.add("abc");
stringList.add("def");

Iterator<String> iterator = stringList.iterator();
while (iterator.hasNext()) {
System.out.println(iterator.next());
stringList.add("ghi");
}

查看 ArrayList 源码,就可以知道为什么会抛出异常。原因是在 ArrayList 类的内部类迭代器 Itr 中有一个 expectedModCount 变量。在 AbstracList 抽象类有一个 modCount 变量,集合在被遍历期间如果内容发生变化,就会改变 modCount 的值。每当迭代器使用 next() 遍历下一个元素之前,都会检测 modCount 变量是否等于 expectedmodCount ,如果相等就继续遍历;否则就会抛出异常。

1
2
3
4
复制代码final void checkForComodification() {
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
}

注意:这里异常的抛出条件是检测到 modCount != expectedmodCount。如果集合发生变化时将 modCount 的值又刚好设置为 expectedmodCount,那么就不会抛出异常。因此,不能依赖于这个异常是否抛出而进行并发操作,这个异常只建议使用于检测并发修改的 bug。

在 java.util 包下的集合类都采用快速失败机制,所以在多线程下,不能发生并发修改,也就是在迭代过程中不能被修改。

安全失败(fail—safe)

采用安全失败机制(fail—safe)的集合类,在遍历集合时不是直接访问原有集合,而是先将原有集合的内容复制一份,然后在拷贝的集合上进行遍历。由于是对拷贝的集合进行遍历,所以在遍历过程中对原集合的修改并不会被迭代器检测到,所以不会抛出 ConcurrentModificationException 异常。

虽然基于拷贝内容的安全失败机制避免了 ConcurrentModificationException,但是迭代器并不能访问到修改后的内容,而仍然是开始遍历那一刻拿到的集合拷贝。

在 java.util.concurrent 包下的集合都采用安全失败机制,所以可以在多线程场景下进行并发使用和修改操作。

如何在遍历集合的同时删除元素

在遍历集合时,正确的删除方式有以下几种:

普通 for 循环

在使用普通 for 循环时,如果从前往后遍历:

1
2
3
4
5
6
7
8
9
10
11
12
复制代码ArrayList<String> stringList = new ArrayList<>();
stringList.add("abc");
stringList.add("def");
stringList.add("def");
stringList.add("ghi");

for (int i = 0;i < stringList.size(); i++) {
String str = stringList.get(i);
if ("def".equals(str)) {
stringList.remove(str);
}
}

打印结果为:

1
复制代码abc def ghi

可以看到,这里跳过了第二个 "def"。原因是开始时 List 的 size 为 4,从前往后,循环到了索引 #1,发现符合条件,于是删除了 #1 的元素。此时 List 的 size 变为 3,索引 #1 就指向了之前 #2 的元素(就是 #2 的元素移动了 #1,#3 移动到了 #2)。

而下一次循环会从索引 #2 开始,查看的是删除之前 #3 的元素,于是之前 #2 的元素(左移到了 #1)就被跳过了。

而如果从后往前遍历,就可以避免元素移动造成的影响。

1
2
3
4
5
6
7
8
9
10
11
12
13
复制代码ArrayList<String> stringList = new ArrayList<>();
stringList.add("abc");
stringList.add("def");
stringList.add("def");
stringList.add("ghi");

for (int i = stringList.size() - 1;i >= 0; i--) {
String str = stringList.get(i);
if ("abc".equals(str)) {
stringList.remove(str);
}
}
// abc ghi

foreach 删除后跳出循环

在使用 foreach 迭代器遍历集合时,在删除元素后使用 break 跳出循环,则不会触发 fail-fast。

1
2
3
4
5
6
复制代码for (String str : stringList) {
if ("abc".equals(str)) {
stringList.remove(str);
break;
}
}

使用迭代器

使用迭代器自带的 remove 方法删除元素,也不会抛出异常。

1
2
3
4
5
6
7
复制代码Iterator<String> iterator = stringList.iterator();
while (iterator.hasNext()) {
String str = iterator.next();
if ("abc".equals(str)) {
iterator.remove(); // 这里是 iterator,而不是 stringList
}
}

Enumeration

Enumeration 是 JDK1.0 引入的接口,为集合提供遍历的接口,使用它的集合包括 Vector、HashTable 等。Enumeration 迭代器不支持 fail-fast 机制。

它只有两个接口方法:hasMoreElements、nextElement 用来判断是否有元素和获取元素,但不能对数据进行修改。

但需要注意的是 Enumeration 迭代器只能遍历 Vector、HashTable 这种古老的集合,因此通常情况下不要使用。

Java中遍历 Map 的几种方式

方法一 在 for-each 循环中使用 entries 来遍历

这是最常见的,并且在大多数情况下也是最可取的遍历方式,在键和值都需要时使用。

1
2
3
4
复制代码Map<Integer, Integer> map = new HashMap<>();  
for (Map.Entry<Integer, Integer> entry : map.entrySet()) {
System.out.println("Key = " + entry.getKey() + ", Value = " + entry.getValue());
}

注意:如果遍历一个空 map 对象,for-each 循环将抛出 NullPointerException,因此在遍历前应该检查是否为空引用。

方法二 在 for-each 循环中遍历 keys 或 values

如果只需要 map 中的键或者值,可以通过 keySet 或 values 来实现遍历,而不是用 entrySet。

1
2
3
4
5
6
7
8
9
10
11
复制代码Map<Integer, Integer> map = new HashMap<Integer, Integer>();  

//遍历 map 中的键
for (Integer key : map.keySet()) {
System.out.println("Key = " + key);
}

//遍历 map 中的值
for (Integer value : map.values()) {
System.out.println("Value = " + value);
}

该方法比 entrySet 遍历在性能上稍好,而且代码更加干净。

方法三 使用 Iterator 遍历

1
2
3
4
5
6
7
8
复制代码Map<Integer, Integer> map = new HashMap<Integer, Integer>();  

Iterator<Map.Entry<Integer, Integer>> entries = map.entrySet().iterator();

while (entries.hasNext()) {
Map.Entry<Integer, Integer> entry = entries.next();
System.out.println("Key = " + entry.getKey() + ", Value = " + entry.getValue());
}

这种方式看起来冗余却有其优点所在,可以在遍历时调用 iterator.remove() 来删除 entries,另两个方法则不能。

从性能方面看,该方法类同于 for-each 遍历(即方法二)的性能。

总结

  • 如果仅需要键(keys)或值(values),则使用方法二;
  • 如果需要在遍历时删除 entries,则使用方法三;
  • 如果键值都需要,则使用方法一。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

面试官问:JS的继承

发表于 2019-02-20

前言

你好,我是若川。这是面试官问系列的第五篇,旨在帮助读者提升JS基础知识,包含new、call、apply、this、继承相关知识。

面试官问系列文章如下:感兴趣的读者可以点击阅读。

1.面试官问:能否模拟实现JS的new操作符

2.面试官问:能否模拟实现JS的bind方法

3.面试官问:能否模拟实现JS的call和apply方法

4.面试官问:JS的this指向

5.面试官问:JS的继承

用过React的读者知道,经常用extends继承React.Component。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
scala复制代码// 部分源码
function Component(props, context, updater) {
// ...
}
Component.prototype.setState = function(partialState, callback){
// ...
}
const React = {
Component,
// ...
}
// 使用
class index extends React.Component{
// ...
}

点击这里查看 React github源码

面试官可以顺着这个问JS继承的相关问题,比如:ES6的class继承用ES5如何实现。据说很多人答得不好。

构造函数、原型对象和实例之间的关系

要弄懂extends继承之前,先来复习一下构造函数、原型对象和实例之间的关系。
代码表示:

1
2
3
4
5
6
7
8
9
10
11
12
javascript复制代码function F(){}
var f = new F();
// 构造器
F.prototype.constructor === F; // true
F.__proto__ === Function.prototype; // true
Function.prototype.__proto__ === Object.prototype; // true
Object.prototype.__proto__ === null; // true

// 实例
f.__proto__ === F.prototype; // true
F.prototype.__proto__ === Object.prototype; // true
Object.prototype.__proto__ === null; // true

笔者画了一张图表示:
构造函数-原型对象-实例关系图By@若川

ES6 extends 继承做了什么操作

我们先看看这段包含静态方法的ES6继承代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
javascript复制代码// ES6
class Parent{
constructor(name){
this.name = name;
}
static sayHello(){
console.log('hello');
}
sayName(){
console.log('my name is ' + this.name);
return this.name;
}
}
class Child extends Parent{
constructor(name, age){
super(name);
this.age = age;
}
sayAge(){
console.log('my age is ' + this.age);
return this.age;
}
}
let parent = new Parent('Parent');
let child = new Child('Child', 18);
console.log('parent: ', parent); // parent: Parent {name: "Parent"}
Parent.sayHello(); // hello
parent.sayName(); // my name is Parent
console.log('child: ', child); // child: Child {name: "Child", age: 18}
Child.sayHello(); // hello
child.sayName(); // my name is Child
child.sayAge(); // my age is 18

其中这段代码里有两条原型链,不信看具体代码。

1
2
3
4
5
6
7
8
9
10
javascript复制代码// 1、构造器原型链
Child.__proto__ === Parent; // true
Parent.__proto__ === Function.prototype; // true
Function.prototype.__proto__ === Object.prototype; // true
Object.prototype.__proto__ === null; // true
// 2、实例原型链
child.__proto__ === Child.prototype; // true
Child.prototype.__proto__ === Parent.prototype; // true
Parent.prototype.__proto__ === Object.prototype; // true
Object.prototype.__proto__ === null; // true

一图胜千言,笔者也画了一张图表示,如图所示:

ES6继承(extends)关系图By@若川
结合代码和图可以知道。
ES6 extends 继承,主要就是:

    1. 把子类构造函数(Child)的原型(__proto__)指向了父类构造函数(Parent),
    1. 把子类实例child的原型对象(Child.prototype) 的原型(__proto__)指向了父类parent的原型对象(Parent.prototype)。

这两点也就是图中用不同颜色标记的两条线。

    1. 子类构造函数Child继承了父类构造函数Parent的里的属性。使用super调用的(ES5则用call或者apply调用传参)。
      也就是图中用不同颜色标记的两条线。

看过《JavaScript高级程序设计-第3版》 章节6.3继承的读者应该知道,这2和3小点,正是寄生组合式继承,书中例子没有第1小点。
1和2小点都是相对于设置了__proto__链接。那问题来了,什么可以设置了__proto__链接呢。

new、Object.create和Object.setPrototypeOf可以设置__proto__

说明一下,__proto__这种写法是浏览器厂商自己的实现。
再结合一下图和代码看一下的new,new出来的实例的__proto__指向构造函数的prototype,这就是new做的事情。
摘抄一下之前写过文章的一段。面试官问:能否模拟实现JS的new操作符,有兴趣的读者可以点击查看。

new做了什么:

  1. 创建了一个全新的对象。
  2. 这个对象会被执行[[Prototype]](也就是__proto__)链接。
  3. 生成的新对象会绑定到函数调用的this。
  4. 通过new创建的每个对象将最终被[[Prototype]]链接到这个函数的prototype对象上。
  5. 如果函数没有返回对象类型Object(包含Functoin, Array, Date, RegExg, Error),那么new表达式中的函数调用会自动返回这个新的对象。

Object.create ES5提供的

Object.create(proto, [propertiesObject])
方法创建一个新对象,使用现有的对象来提供新创建的对象的__proto__。
它接收两个参数,不过第二个可选参数是属性描述符(不常用,默认是undefined)。对于不支持ES5的浏览器,MDN上提供了ployfill方案。
MDN Object.create()

1
2
3
4
5
6
7
8
javascript复制代码// 简版:也正是应用了new会设置__proto__链接的原理。
if(typeof Object.create !== 'function'){
Object.create = function(proto){
function F() {}
F.prototype = proto;
return new F();
}
}

Object.setPrototypeOf ES6提供的

Object.setPrototypeOf MDN

Object.setPrototypeOf() 方法设置一个指定的对象的原型 ( 即, 内部[[Prototype]]属性)到另一个对象或 null。
Object.setPrototypeOf(obj, prototype)

1
2
3
4
5
6
javascript复制代码`ployfill`
// 仅适用于Chrome和FireFox,在IE中不工作:
Object.setPrototypeOf = Object.setPrototypeOf || function (obj, proto) {
obj.__proto__ = proto;
return obj;
}

nodejs源码就是利用这个实现继承的工具函数的。
nodejs utils inherits

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
javascript复制代码function inherits(ctor, superCtor) {
if (ctor === undefined || ctor === null)
throw new ERR_INVALID_ARG_TYPE('ctor', 'Function', ctor);

if (superCtor === undefined || superCtor === null)
throw new ERR_INVALID_ARG_TYPE('superCtor', 'Function', superCtor);

if (superCtor.prototype === undefined) {
throw new ERR_INVALID_ARG_TYPE('superCtor.prototype',
'Object', superCtor.prototype);
}
Object.defineProperty(ctor, 'super_', {
value: superCtor,
writable: true,
configurable: true
});
Object.setPrototypeOf(ctor.prototype, superCtor.prototype);
}

ES6的extends的ES5版本实现

知道了ES6 extends继承做了什么操作和设置__proto__的知识点后,把上面ES6例子的用ES5就比较容易实现了,也就是说实现寄生组合式继承,简版代码就是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
javascript复制代码// ES5 实现ES6 extends的例子
function Parent(name){
this.name = name;
}
Parent.sayHello = function(){
console.log('hello');
}
Parent.prototype.sayName = function(){
console.log('my name is ' + this.name);
return this.name;
}

function Child(name, age){
// 相当于super
Parent.call(this, name);
this.age = age;
}
// new
function object(){
function F() {}
F.prototype = proto;
return new F();
}
function _inherits(Child, Parent){
// Object.create
Child.prototype = Object.create(Parent.prototype);
// __proto__
// Child.prototype.__proto__ = Parent.prototype;
Child.prototype.constructor = Child;
// ES6
// Object.setPrototypeOf(Child, Parent);
// __proto__
Child.__proto__ = Parent;
}
_inherits(Child, Parent);
Child.prototype.sayAge = function(){
console.log('my age is ' + this.age);
return this.age;
}
var parent = new Parent('Parent');
var child = new Child('Child', 18);
console.log('parent: ', parent); // parent: Parent {name: "Parent"}
Parent.sayHello(); // hello
parent.sayName(); // my name is Parent
console.log('child: ', child); // child: Child {name: "Child", age: 18}
Child.sayHello(); // hello
child.sayName(); // my name is Child
child.sayAge(); // my age is 18

我们完全可以把上述ES6的例子通过babeljs转码成ES5来查看,更严谨的实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
javascript复制代码// 对转换后的代码进行了简要的注释
"use strict";
// 主要是对当前环境支持Symbol和不支持Symbol的typeof处理
function _typeof(obj) {
if (typeof Symbol === "function" && typeof Symbol.iterator === "symbol") {
_typeof = function _typeof(obj) {
return typeof obj;
};
} else {
_typeof = function _typeof(obj) {
return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj;
};
}
return _typeof(obj);
}
// _possibleConstructorReturn 判断Parent。call(this, name)函数返回值 是否为null或者函数或者对象。
function _possibleConstructorReturn(self, call) {
if (call && (_typeof(call) === "object" || typeof call === "function")) {
return call;
}
return _assertThisInitialized(self);
}
// 如何 self 是void 0 (undefined) 则报错
function _assertThisInitialized(self) {
if (self === void 0) {
throw new ReferenceError("this hasn't been initialised - super() hasn't been called");
}
return self;
}
// 获取__proto__
function _getPrototypeOf(o) {
_getPrototypeOf = Object.setPrototypeOf ? Object.getPrototypeOf : function _getPrototypeOf(o) {
return o.__proto__ || Object.getPrototypeOf(o);
};
return _getPrototypeOf(o);
}
// 寄生组合式继承的核心
function _inherits(subClass, superClass) {
if (typeof superClass !== "function" && superClass !== null) {
throw new TypeError("Super expression must either be null or a function");
}
// Object.create()方法创建一个新对象,使用现有的对象来提供新创建的对象的__proto__。
// 也就是说执行后 subClass.prototype.__proto__ === superClass.prototype; 这条语句为true
subClass.prototype = Object.create(superClass && superClass.prototype, {
constructor: {
value: subClass,
writable: true,
configurable: true
}
});
if (superClass) _setPrototypeOf(subClass, superClass);
}
// 设置__proto__
function _setPrototypeOf(o, p) {
_setPrototypeOf = Object.setPrototypeOf || function _setPrototypeOf(o, p) {
o.__proto__ = p;
return o;
};
return _setPrototypeOf(o, p);
}
// instanceof操作符包含对Symbol的处理
function _instanceof(left, right) {
if (right != null && typeof Symbol !== "undefined" && right[Symbol.hasInstance]) {
return right[Symbol.hasInstance](left);
} else {
return left instanceof right;
}
}

function _classCallCheck(instance, Constructor) {
if (!_instanceof(instance, Constructor)) {
throw new TypeError("Cannot call a class as a function");
}
}
// 按照它们的属性描述符 把方法和静态属性赋值到构造函数的prototype和构造器函数上
function _defineProperties(target, props) {
for (var i = 0; i < props.length; i++) {
var descriptor = props[i];
descriptor.enumerable = descriptor.enumerable || false;
descriptor.configurable = true;
if ("value" in descriptor) descriptor.writable = true;
Object.defineProperty(target, descriptor.key, descriptor);
}
}
// 把方法和静态属性赋值到构造函数的prototype和构造器函数上
function _createClass(Constructor, protoProps, staticProps) {
if (protoProps) _defineProperties(Constructor.prototype, protoProps);
if (staticProps) _defineProperties(Constructor, staticProps);
return Constructor;
}

// ES6
var Parent = function () {
function Parent(name) {
_classCallCheck(this, Parent);
this.name = name;
}
_createClass(Parent, [{
key: "sayName",
value: function sayName() {
console.log('my name is ' + this.name);
return this.name;
}
}], [{
key: "sayHello",
value: function sayHello() {
console.log('hello');
}
}]);
return Parent;
}();

var Child = function (_Parent) {
_inherits(Child, _Parent);
function Child(name, age) {
var _this;
_classCallCheck(this, Child);
// Child.__proto__ => Parent
// 所以也就是相当于Parent.call(this, name); 是super(name)的一种转换
// _possibleConstructorReturn 判断Parent.call(this, name)函数返回值 是否为null或者函数或者对象。
_this = _possibleConstructorReturn(this, _getPrototypeOf(Child).call(this, name));
_this.age = age;
return _this;
}
_createClass(Child, [{
key: "sayAge",
value: function sayAge() {
console.log('my age is ' + this.age);
return this.age;
}
}]);
return Child;
}(Parent);

var parent = new Parent('Parent');
var child = new Child('Child', 18);
console.log('parent: ', parent); // parent: Parent {name: "Parent"}
Parent.sayHello(); // hello
parent.sayName(); // my name is Parent
console.log('child: ', child); // child: Child {name: "Child", age: 18}
Child.sayHello(); // hello
child.sayName(); // my name is Child
child.sayAge(); // my age is 18

如果对JS继承相关还是不太明白的读者,推荐阅读以下书籍的相关章节,可以自行找到相应的pdf版本。

推荐阅读JS继承相关的书籍章节

《JavaScript高级程序设计第3版》-第6章 面向对象的程序设计,6种继承的方案,分别是原型链继承、借用构造函数继承、组合继承、原型式继承、寄生式继承、寄生组合式继承。图灵社区本书地址,后文放出github链接,里面包含这几种继承的代码demo。

《JavaScript面向对象编程第2版》-第6章 继承,12种继承的方案。1.原型链法(仿传统)、2.仅从原型继承法、3.临时构造器法、4.原型属性拷贝法、5.全属性拷贝法(即浅拷贝法)、6.深拷贝法、7.原型继承法、8.扩展与增强模式、9.多重继承法、10.寄生继承法、11.构造器借用法、12.构造器借用与属性拷贝法。

ES6标准入门-第21章class的继承

《深入理解ES6》-第9章 JavaScript中的类

《你不知道的JavaScript-上卷》第6章 行为委托和附录A ES6中的class

总结

继承对于JS来说就是父类拥有的方法和属性、静态方法等,子类也要拥有。子类中可以利用原型链查找,也可以在子类调用父类,或者从父类拷贝一份到子类等方案。
继承方法可以有很多,重点在于必须理解并熟
悉这些对象、原型以及构造器的工作方式,剩下的就简单了。寄生组合式继承是开发者使用比较多的。
回顾寄生组合式继承。主要就是三点:

    1. 子类构造函数的__proto__指向父类构造器,继承父类的静态方法
    1. 子类构造函数的prototype的__proto__指向父类构造器的prototype,继承父类的方法。
    1. 子类构造器里调用父类构造器,继承父类的属性。
      行文到此,文章就基本写完了。文章代码和图片等资源放在这里github inhert和demo展示es6-extends,结合console、source面板查看更佳。

读者发现有不妥或可改善之处,欢迎评论指出。另外觉得写得不错,可以点赞、评论、转发,也是对笔者的一种支持。

笔者精选文章

学习 sentry 源码整体架构,打造属于自己的前端异常监控SDK

学习 lodash 源码整体架构,打造属于自己的函数式编程类库

学习 underscore 源码整体架构,打造属于自己的函数式编程类库

学习 jQuery 源码整体架构,打造属于自己的 js 类库

面试官问:JS的继承

面试官问:JS的this指向

面试官问:能否模拟实现JS的call和apply方法

面试官问:能否模拟实现JS的bind方法

面试官问:能否模拟实现JS的new操作符

前端使用puppeteer 爬虫生成《React.js 小书》PDF并合并

关于

作者:常以若川为名混迹于江湖。前端路上 | PPT爱好者 | 所知甚少,唯善学。

个人博客

segmentfault前端视野专栏,开通了前端视野专栏,欢迎关注~

掘金专栏,欢迎关注~

知乎前端视野专栏,开通了前端视野专栏,欢迎关注~

github blog,求个star^_^~

微信公众号 若川视野

可能比较有趣的微信公众号,长按扫码关注。也可以加微信 ruochuan12,注明来源,拉您进【前端视野交流群】。

若川视野

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

少年,想线上热更新代码不?

发表于 2019-02-19

背景

尽管在生产环境热更新代码,并不是很好的行为,很可能导致:热更不规范,同事两行泪。

但很多时候我们的确希望能热更新代码,比如:

线上排查问题,找到修复思路了,但应用重启之后,环境现场就变了,难以复现。怎么验证修复方案?

又比如:

本地开发时,发现某个开源组件有bug,希望修改验证。如果是自己编译开源组件再发布,流程非常的长,还不一定能编译成功。有没有办法快速测试?

Arthas是阿里巴巴开源的Java应用诊断利器,深受开发者喜爱。

下面介绍利用Arthas 3.1.0版本的 jad/mc/redefine 一条龙来热更新代码。

  • Arthas: github.com/alibaba/art…
  • jad命令:alibaba.github.io/arthas/jad.…
  • mc命令:alibaba.github.io/arthas/mc.h…
  • redefine命令:alibaba.github.io/arthas/rede…

Arthas在线教程

下面通过Arthas在线教程演示热更新代码的过程。

  • Arthas进阶教程

arthas-online-hotswap

在例子里,访问 curl http://localhost/user/0,会返回500错误:

1
2
3
4
5
6
7
8
复制代码{
"timestamp": 1550223186170,
"status": 500,
"error": "Internal Server Error",
"exception": "java.lang.IllegalArgumentException",
"message": "id < 1",
"path": "/user/0"
}

下面通过热更新代码,修改这个逻辑。

jad反编译代码

反编译UserController,保存到 /tmp/UserController.java文件里。

1
复制代码jad --source-only com.example.demo.arthas.user.UserController > /tmp/UserController.java

修改反编绎出来的代码

用文本编辑器修改/tmp/UserController.java,把抛出异常改为正常返回:

1
2
3
4
5
6
7
8
9
复制代码    @GetMapping(value={"/user/{id}"})
public User findUserById(@PathVariable Integer id) {
logger.info("id: {}", (Object)id);
if (id != null && id < 1) {
return new User(id, "name" + id);
// throw new IllegalArgumentException("id < 1");
}
return new User(id.intValue(), "name" + id);
}

sc查找加载UserController的ClassLoader

1
2
复制代码$ sc -d *UserController | grep classLoaderHash
classLoaderHash 1be6f5c3

可以发现是spring boot的 LaunchedURLClassLoader@1be6f5c3 加载的。

mc内存编绎代码

保存好/tmp/UserController.java之后,使用mc(Memory Compiler)命令来编译,并且通过-c参数指定ClassLoader:

1
2
3
4
复制代码$ mc -c 1be6f5c3 /tmp/UserController.java -d /tmp
Memory compiler output:
/tmp/com/example/demo/arthas/user/UserController.class
Affect(row-cnt:1) cost in 346 ms

redefine热更新代码

再使用redefine命令重新加载新编译好的UserController.class:

1
2
复制代码$ redefine /tmp/com/example/demo/arthas/user/UserController.class
redefine success, size: 1

检验热更新结果

再次访问 curl http://localhost/user/0,会正常返回:

1
2
3
4
复制代码{
"id": 0,
"name": "name0"
}

总结

Arthas里 jad/mc/redefine 一条龙来线上热更新代码,非常强大,但也很危险,需要做好权限管理。

比如,线上应用启动帐号是 admin,当用户可以切换到admin,那么

  • 用户可以修改,获取到应用的任意内存值(不管是否java应用)
  • 用户可以attach jvm
  • attach jvm之后,利用jvm本身的api可以redefine class

所以:

  • 应用的安全主要靠用户权限本身的管理
  • Arthas主要是让jvm redefine更容易了。用户也可以利用其它工具达到同样的效果

最后,Arthas提醒您: 诊断千万条,规范第一条,热更不规范,同事两行泪。

Arthas实践系列

  • Alibaba Arthas实践–获取到Spring Context,然后为所欲为
  • Arthas实践–快速排查Spring Boot应用404/401问题
  • 当Dubbo遇上Arthas:排查问题的实践
  • Arthas实践–使用redefine排查应用奇怪的日志来源
  • 使用Arthas抽丝剥茧排查线上应用日志打满问题
  • 深入Spring Boot:利用Arthas排查NoSuchMethodError

公众号

欢迎关注横云断岭的专栏,专注Java,Spring Boot,Arthas,Dubbo。

横云断岭的专栏

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

从CPU Cache出发彻底弄懂volatile/synch

发表于 2019-02-19

个人技术博客:www.zhenganwen.top

变量可见吗

共享变量可见吗

首先引入一段代码指出Java内存模型存在的问题:启动两个线程t1,t2访问共享变量sharedVariable,t2线程逐渐将sharedVariable自增到MAX,每自增一次就休眠500ms放弃CPU执行权,期望此间另外一个线程t1能够在第7-12行轮询过程中发现到sharedVariable的改变并将其打印

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
复制代码private static int sharedVariable = 0;
private static final int MAX = 10;

public static void main(String[] args) {
new Thread(() -> {
int oldValue = sharedVariable;
while (sharedVariable < MAX) {
if (sharedVariable != oldValue) {
System.out.println(Thread.currentThread().getName() + " watched the change : " + oldValue + "->" + sharedVariable);
oldValue = sharedVariable;
}
}
}, "t1").start();

new Thread(() -> {
int oldValue = sharedVariable;
while (sharedVariable < MAX) {
System.out.println(Thread.currentThread().getName() + " do the change : " + sharedVariable + "->" + (++oldValue));
sharedVariable = oldValue;
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "t2").start();

}

但上述程序的实际运行结果如下:

1
2
3
4
5
6
7
8
9
10
11
复制代码t2 do the change : 0->1
t1 watched the change : 0->1
t2 do the change : 1->2
t2 do the change : 2->3
t2 do the change : 3->4
t2 do the change : 4->5
t2 do the change : 5->6
t2 do the change : 6->7
t2 do the change : 7->8
t2 do the change : 8->9
t2 do the change : 9->10

volatile能够保证可见性

可以发现t1线程几乎察觉不到t2每次对共享变量sharedVariable所做的修改,这是为什么呢?也许会有人告诉你给sharedVariable加个volatile修饰就好了,确实,加了volatile之后的输出达到我们的预期了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
复制代码t2 do the change : 0->1
t1 watched the change : 0->1
t2 do the change : 1->2
t1 watched the change : 1->2
t2 do the change : 2->3
t1 watched the change : 2->3
t2 do the change : 3->4
t1 watched the change : 3->4
t2 do the change : 4->5
t1 watched the change : 4->5
t2 do the change : 5->6
t1 watched the change : 5->6
t2 do the change : 6->7
t1 watched the change : 6->7
t2 do the change : 7->8
t1 watched the change : 7->8
t2 do the change : 8->9
t1 watched the change : 8->9
t2 do the change : 9->10

这也比较好理解,官方说volatile能够保证共享变量在线程之间的可见性。

synchronized能保证可见性吗?

但是,也可能会有人跟你说,你使用synchronized + wait/notify模型就好了:将所有对共享变量操作都放入同步代码块,然后使用wait/notify协调共享变量的修改和读取

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
复制代码private static int sharedVariable = 0;
private static final int MAX = 10;
private static Object lock = new Object();
private static boolean changed = false;

public static void main(String[] args) {
new Thread(() -> {
synchronized (lock) {
int oldValue = sharedVariable;
while (sharedVariable < MAX) {
while (!changed) {
try {
lock.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println(Thread.currentThread().getName() +
" watched the change : " + oldValue + "->" + sharedVariable);
oldValue = sharedVariable;
changed = false;
lock.notifyAll();
}
}
}, "t1").start();

new Thread(() -> {
synchronized (lock) {
int oldValue = sharedVariable;
while (sharedVariable < MAX) {
while (changed) {
try {
lock.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println(Thread.currentThread().getName() +
" do the change : " + sharedVariable + "->" + (++oldValue));
sharedVariable = oldValue;
changed = true;
lock.notifyAll();
}
}
}, "t2").start();

}

你会发现这种方式即使没有给sharedVariable、changed加volatile,但他们在t1和t2之间似乎也是可见的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
复制代码t2 do the change : 0->1
t1 watched the change : 0->1
t2 do the change : 0->2
t1 watched the change : 0->2
t2 do the change : 0->3
t1 watched the change : 0->3
t2 do the change : 0->4
t1 watched the change : 0->4
t2 do the change : 0->5
t1 watched the change : 0->5
t2 do the change : 0->6
t1 watched the change : 0->6
t2 do the change : 0->7
t1 watched the change : 0->7
t2 do the change : 0->8
t1 watched the change : 0->8
t2 do the change : 0->9
t1 watched the change : 0->9
t2 do the change : 0->10
t1 watched the change : 0->10

CAS能保证可见性吗?

将sharedVariable的类型改为AtomicInteger,t2线程使用AtomicInteger提供的getAndSetCAS更新该变量,你会发现这样这能做到可见性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
复制代码private static AtomicInteger sharedVariable = new AtomicInteger(0);
private static final int MAX = 10;

public static void main(String[] args) {
new Thread(() -> {
int oldValue = sharedVariable.get();
while (sharedVariable.get() < MAX) {
if (sharedVariable.get() != oldValue) {
System.out.println(Thread.currentThread().getName() + " watched the change : " + oldValue + "->" + sharedVariable);
oldValue = sharedVariable.get();
}
}
}, "t1").start();

new Thread(() -> {
int oldValue = sharedVariable.get();
while (sharedVariable.get() < MAX) {
System.out.println(Thread.currentThread().getName() + " do the change : " + sharedVariable + "->" + (++oldValue));
sharedVariable.set(oldValue);
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "t2").start();

}

为什么synchronized和CAS也能做到可见性呢?其实这是因为synchronized的锁释放-获取和CAS修改-读取都有着和volatile域的写-读有相同的语义。既然这么神奇,那就让我们一起去Java内存模型、synchronized/volatile/CAS的底层实现一探究竟吧!

CPU Cache

要理解变量在线程间的可见性,首先我们要了解CPU的读写模型,虽然可能有些无聊,但这对并发编程的理解有很大的帮助!

主存RAM & 高速缓存Cache

在计算机技术发展过程中,主存储器存取速度一直比CPU操作速度慢得多,这使得CPU的高速处理能力不能充分发挥,整个计算机系统的工作效率受到影响,因此现代处理器一般都引入了高速缓冲存储器(简称高速缓存)。

高速缓存的存取速度能与CPU相匹配,但因造价高昂因此容量较主存小很多。据程序局部性原理,当CPU试图访问主存中的某一单元(一个存储单元对应一个字节)时,其邻近的那些单元在随后将被用到的可能性很大。因而,当CPU存取主存单元时,计算机硬件就自动地将包括该单元在内的那一组单元(称之为内存块block,通常是连续的64个字节)内容调入高速缓存,CPU即将存取的主存单元很可能就在刚刚调入到高速缓存的那一组单元内。于是,CPU就可以直接对高速缓存进行存取。在整个处理过程中,如果CPU绝大多数存取主存的操作能被存取高速缓存所代替,计算机系统处理速度就能显著提高。

Cache相关术语

以下术语在初次接触时可能会一知半解,but take it easy,后文的讲解将逐步揭开你心中的谜团。

Cache Line & Slot & Hot Data

前文说道,CPU请求访问主存中的某一存储单元时,会将包括该存储单元在内的那一组单元都调入高速缓存。这一组单元(我们通常称之为内存块block)将会被存放在高速缓存的缓存行中(cache line,也叫slot)。高速缓存会将其存储单元均分成若干等份,每一等份就是一个缓存行,如今主流CPU的缓存行一般都是64个字节(也就是说如果高速缓存大小为512字节,那么就对应有8个缓存行)。

另外,被缓存行缓存的数据称之为热点数据(hot data)。

Cache Hit

当CPU通过寄存器中存储的数据地址请求访问数据时(包括读操作和写操作),首先会在Cache中查找,如果找到了则直接返回Cache中存储的数据,这称为缓存命中(cache hit),根据操作类型又可分为读缓存命中和写缓存命中。

Cache Miss & Hit Latency

与cache hit相对应,如果没有找到那么将会通过系统总线(System Bus)到主存中找,这称为缓存缺失(cache miss)。如果发生了缓存缺失,那么原本应该直接存取主存的操作因为Cache的存在,浪费了一些时间,这称为命中延迟(hit latency)。确切地说,命中延迟是指判断Cache中是否缓存了目标数据所花的时间。

Cache分级

如果打开你的任务管理器查看CPU性能,你可能会发现笔者的高速缓存有三块区域:L1(一级缓存,128KB)、L2(二级缓存,512KB)、L3(共享缓存3.0MB):

image

起初Cache的实现只有一级缓存L1,后来随着科技的发展,一方面主存的增大导致需要缓存的热点数据变多,单纯的增大L1的容量所获取的性价比会很低;另一方面,L1的存取速度和主存的存取速度进一步拉大,需要一个基于两者存取速度之间的缓存做缓冲。基于以上两点考虑,引入了二级缓存L2,它的存取速度介于L1和主存之间且存取容量在L1的基础上进行了扩容。

上述的L1和L2一般都是处理器私有的,也就是说每个CPU核心都有它自己的L1和L2并且是不与其他核心共享的。这时,为了能有一块所有核心都共享的缓存区域,也为了防止L1和L2都发生缓存缺失而进一步提高缓存命中率,加入了L3。可以猜到L3比L1、L2的存取速度都慢,但容量较大。

Cache替换算法 & Cache Line Conflict

为了保证CPU访问时有较高的命中率,Cache中的内容应该按一定的算法替换。一种较常用的算法是“最近最少使用算法”(LRU算法),它是将最近一段时间内最少被访问过的行淘汰出局。因此需要为每行设置一个计数器,LRU算法是把命中行的计数器清零,其他各行计数器加1。当需要替换时淘汰行计数器计数值最大的数据行出局。这是一种高效、科学的算法,其计数器清零过程可以把一些频繁调用后再不需要的数据(对应计数值最大的数据)淘汰出Cache,提高Cache的利用率。

Cache相对于主存来说容量是极其有限的,因此无论如何实现Cache的存储机制(后文缓存关联系将会详细说明),如果不采取合适的替换算法,那么随着Cache的使用不可避免会出现Cache中所有Cache Line都被占用导致需要缓存新的内存块时无法分配Cache Line的情况;或者是根据Cache的存储机制,为该内存块分配的Cache Line正在使用中。以上两点均会导致新的内存块无Cache Line存放,这叫做Cache Line Conflict。

CPU缓存架构

至此,我们大致能够得到一个CPU缓存架构了:

k8Wd6P.png

如图当CPU试图通过某一存储单元地址访问数据时,它会自上而下依次从L1、L2、L3、主存中查找,若找到则直接返回对应Cache中的数据而不再向下查找,如果L1、L2、L3都cache miss了,那么CPU将不得不通过总线访问主存或者硬盘上的数据。且通过下图所示的各硬件存取操作所需的时钟周期(cycle,CPU主频的倒数就是一个时钟周期)可以知道,自上而下,存取开销越来越大,因此Cache的设计需尽可能地提高缓存命中率,否则如果到最后还是要到内存中存取将得不偿失。

k8hcMq.png

为了方便大家理解,笔者摘取了酷壳中的一篇段子:

我们知道计算机的计算数据需要从磁盘调度到内存,然后再调度到L2 Cache,再到L1 Cache,最后进CPU寄存器进行计算。

给老婆在电脑城买本本的时候向电脑推销人员问到这些参数,老婆听不懂,让我给她解释,解释完后,老婆说,“原来电脑内部这么麻烦,怪不得电脑总是那么慢,直接操作内存不就快啦”。我是那个汗啊。

我只得向她解释,这样做是为了更快速的处理,她不解,于是我打了下面这个比喻——这就像我们喂宝宝吃奶一样:

  • CPU就像是已经在宝宝嘴里的奶一样,直接可以咽下去了。需要1秒钟
  • L1缓存就像是已冲好的放在奶瓶里的奶一样,只要把孩子抱起来才能喂到嘴里。需要5秒钟。
  • L2缓存就像是家里的奶粉一样,还需要先热水冲奶,然后把孩子抱起来喂进去。需要2分钟。
  • 内存RAM就像是各个超市里的奶粉一样,这些超市在城市的各个角落,有的远,有的近,你先要寻址,然后还要去商店上门才能得到。需要1-2小时。
  • 硬盘DISK就像是仓库,可能在很远的郊区甚至工厂仓库。需要大卡车走高速公路才能运到城市里。需要2-10天。

所以,在这样的情况下——

  • 我们不可能在家里不存放奶粉。试想如果得到孩子饿了,再去超市买,这不更慢吗?
  • 我们不可以把所有的奶粉都冲好放在奶瓶里,因为奶瓶不够。也不可能把超市里的奶粉都放到家里,因为房价太贵,这么大的房子不可能买得起。
  • 我们不可能把所有的仓库里的东西都放在超市里,因为这样干成本太大。而如果超市的货架上正好卖完了,就需要从库房甚至厂商工厂里调,这在计算里叫换页,相当的慢。

Cache结构和缓存关联性

如果让你来设计这样一个Cache,你会如何设计?

如果你跟笔者一样非科班出身,也许会觉得使用哈希表是一个不错的选择,一个内存块对应一条记录,使用内存块的地址的哈希值作为键,使用内存块存储的数据作为值,时间复杂度O(1)内完成查找,简单又高效。

但是如果你每一次缓存内存块前都对地址做哈希运算,那么所需时间可能会远远大于Cache存取所需的几十个时钟周期时间,并且这可不是我们应用程序常用的memcache,这里的Cache是实实在在的硬件,在硬件层面上去实现一个对内存地址哈希的逻辑未免有些赶鸭子上架的味道。

以我们常见的X86芯片为例,Cache的结构下图所示:整个Cache被分为S个组,每个组又有E行个最小的存储单元——Cache Line所组成,而一个Cache Line中有B(B=64)个字节用来存储数据,即每个Cache Line能存储64个字节的数据,每个Cache Line又额外包含1个有效位(valid bit)、t个标记位(tag bit),其中valid bit用来表示该缓存行是否有效;tag bit用来协助寻址,唯一标识存储在Cache Line中的块;而Cache Line里的64个字节其实是对应内存地址中的数据拷贝。根据Cache的结构,我们可以推算出每一级Cache的大小为B×E×S。

缓存设计的一个关键决定是确保每个主存块(block)能够存储在任何一个缓存槽里,或者只是其中一些(此处一个槽位就是一个缓存行)。

有三种方式将缓存槽映射到主存块中:

  1. 直接映射(Direct mapped cache)
    每个内存块只能映射到一个特定的缓存槽。一个简单的方案是通过块索引block_index映射到对应的槽位(block_index % cache_slots)。被映射到同一内存槽上的两个内存块是不能同时换入缓存的。(注:block_index可以通过物理地址/缓存行字节计算得到)
  2. N路组关联(N-way set associative cache)
    每个内存块能够被映射到N路特定缓存槽中的任意一路。比如一个16路缓存,每个内存块能够被映射到16路不同的缓存槽。一般地,具有一定相同低bit位地址的内存块将共享16路缓存槽。(译者注:相同低位地址表明相距一定单元大小的连续内存)
  3. 完全关联(Fully associative cache)
    每个内存块能够被映射到任意一个缓存槽。操作效果上相当于一个散列表。

其中N路组关联是根据另外两种方式改进而来,是现在的主流实现方案。下面将对这三种方式举例说明。

Fully associative cache

Fully associative,顾名思义全关联。就是说对于要缓存的一个内存块,可以被缓存在Cache的任意一个Slot(即缓存行)中。以32位操作系统(意味着到内存寻址时是通过32位地址)为例,比如有一个0101...10 000000 - 0101...10 111111(为了节省版面省略了高26位中的部分bit位,这个区间代表高26位相同但低6位不同的64个地址,即64字节的内存块)内存块需要缓存,那么它将会被随机存放到一个可用的Slot中,并将高26位作为该Slot的tag bit(前文说到每行除了存储内存块的64字节Cache Line,还额外有1个bit标识该行是否有效和t个bit作为该行的唯一ID,本例中t就是26)。这样当内存需要存取这个地址范围内的数据地址时,首先会去Cache中找是否缓存了高26位(tag bit)为0101...10的Slot,如果找到了再根据数据地址的低6位定位到Cache Line的某个存储单元上,这个低6位称为字节偏移(word offset)

可能你会觉得这不就是散列表吗?的确,它在决定将内存块放入哪个可用的Slot时是随机的,但是它并没有将数据地址做哈希运算并以哈希值作为tag bit,因此和哈希表还是有本质的区别的。

此种方式没有得到广泛应用的原因是,内存块会被放入哪个Slot是未知的,因此CPU在根据数据地址查找Slot时需要将数据地址的高位(本例中是高26位)和Cache中的所有Slot的tag bit做线性查找,以我的L1 128KB为例,有128 * 1024 / 64 = 2048个Slot,虽然可以在硬件层面做并行处理,但是效率并不可观。

Direct Mapped Cache

这种方式就是首先将主存中的内存块和Cache中的Slot分别编码得到block_index和slot_index,然后将block_index对slot_index取模从而决定某内存块应该放入哪个Slot中,如下图所示:

image

下面将以我的L1 Cache 128KB,内存4GB为例进行分析:

4GB内存的寻址范围是000...000(32个0)到111...111(32个1),给定一个32位的数据地址,如何判断L1 Cache中是否缓存了该数据地址的数据?

首先将32位地址分成如下三个部分:

image

如此的话对于给定的32位数据地址,首先不管低6位,取出中间的slot offset个bit位,定位出是哪一个Slot,然后比较该Slot的tag bit是否和数据地址的剩余高位匹配,如果匹配那么表示Cache Hit,最后在根据低6位从该Slot的Cache Line中找到具体的存储单元进行存取数据。

Direct Mapped Cache的缺陷是,低位相同但高位不同的内存块会被映射到同一个Slot上(因为对SlotCount取模之后结果相同),如果碰巧CPU请求存取这些内存块,那么将只有一个内存块能够被缓存到Cache中对应的Slot上,也就是说容易发生Cache Line Conflict。

N-Way Set Associative Cache

N路组关联,是对Direct Mapped Cache和Full Associative Cache的一个结合,思路是不要对于给定的数据地址就定死了放在哪个Slot上。

如同上文给出的x86的Cache结构图那样,先将Cache均分成S个组,每个组都有E个Slot。假设将我的L1 Cache 128KB按16个Slot划分为一个组,那么组数为:128 * 1024 / 64(Slot数)/ 16 = 128 个组(我们将每个组称为一个Set,表示一组Slot的集合)。如此的话,对于给定的一个数据地址,仍将其分为以下三部分:

image

与Direct Mapped Cache不同的地方就是将原本表示映射到哪个Slot的11个中间bit位改成了用7个bit位表示映射到哪个Set上,在确定Set之后,内存块将被放入该Set的哪个Slot是随机的(可能当时哪个可以用就放到哪个了),然后以剩余的高位19个bit位作为最终存放该内存块的tag bit。

这样做的好处就是,对于一个给定的数据地址只会将其映射到特定的Set上,这样就大大减小了Cache Line Conflict的几率,并且CPU在查找Slot时只需在具体的某个Set中线性查找,而Set中的Slot个数较少(分组分得越多,每个组的Slot就越少),这样线性查找的时间复杂度也近似O(1)了。

如何编写对Cache Hit友好的程序

通过前面对CPU读写模型的理解,我们知道一旦CPU要从内存中访问数据就会产生一个较大的时延,程序性能显著降低,所谓远水救不了近火。为此我们不得不提高Cache命中率,也就是充分发挥局部性原理。

局部性包括时间局部性、空间局部性。

  • 时间局部性:对于同一数据可能被多次使用,自第一次加载到Cache Line后,后面的访问就可以多次从Cache Line中命中,从而提高读取速度(而不是从下层缓存读取)。
  • 空间局部性:一个Cache Line有64字节块,我们可以充分利用一次加载64字节的空间,把程序后续会访问的数据,一次性全部加载进来,从而提高Cache Line命中率(而不是重新去寻址读取)。

读取时尽量读取相邻的数据地址

首先来看一下遍历二维数组的两种方式所带来的不同开销:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
复制代码static int[][] arr = new int[10000][10000];
public static void main(String[] args) {
m1(); //输出 16
m2(); //输出 1202 每次测试的结果略有出入
}
public static void m1() {
long begin = System.currentTimeMillis();
int a;
for (int i = 0; i < arr.length; i++) {
for (int j = 0; j < arr[i].length; j++) {
a = arr[i][j];
}
}
long end = System.currentTimeMillis();
System.out.println(end - begin + "================");
}
public static void m2() {
long begin = System.currentTimeMillis();
int a;
for (int j = 0; j < arr[0].length; j++) {
for (int i = 0; i < arr.length; i++) {
a = arr[i][j];
}
}
long end = System.currentTimeMillis();
System.out.println(end - begin + "================");
}

经过多次测试发现逐列遍历的效率明显低于逐行遍历,这是因为按行遍历时数据地址是相邻的,因此可能会对连续16个int变量(16x4=64字节)的访问都是访问同一个Cache Line中的内容,在访问第一个int变量并将包括其在内连续64字节加入到Cache Line之后,对后续int变量的访问直接从该Cache Line中取就行了,不需要其他多余的操作。而逐列遍历时,如果列数超多16,意味着一行有超过16个int变量,每行的起始地址之间的间隔超过64字节,那么每行的int变量都不会在同一个Cache Line中,这会导致Cache Miss重新到内存中加载内存块,并且每次跨缓存行读取,都会比逐行读取多一个Hit Latency的开销。

上例中的i、j体现了时间局部性,i、j作为循环计数器被频繁操作,将被存放在寄存器中,CPU每次都能以最快的方式访问到他们,而不会从Cache、主存等其他地方访问。

而优先遍历一行中相邻的元素则利用了空间局部性,一次性加载地址连续的64个字节到Cache Line中有利于后续相邻地址元素的快速访问。

Cache Consistency & Cache Lock & False Sharing

那么是不是任何时候,操作同一缓存行比跨缓存行操作的性能都要好呢?没有万能的机制,只有针对某一场景最合适的机制,连续紧凑的内存分配(Cache的最小存储单位是Cache Line)也有它的弊端。

这个弊端就是缓存一致性引起的,由于每个CPU核心都有自己的Cache(通常是L1和L2),并且大多数情况下都是各自访问各自的Cache,这很有可能导致各Cache中的数据副本以及主存中的共享数据之间各不相同,有时我们需要调用各CPU相互协作,这时就不得不以主存中的共享数据为准并让各Cache保持与主存的同步,这时该怎么办呢?

这个时候缓存一致性协议就粉墨登场了:如果(各CPU)你们想让缓存行和主存保持同步,你们都要按我的规则来修改共享变量

这是一个跟踪每个缓存行的状态的缓存子系统。该系统使用一个称为 “总线动态监视” 或者称为*“总线嗅探”* 的技术来监视在系统总线上发生的所有事务,以检测缓存中的某个地址上何时发生了读取或写入操作。

当这个缓存子系统在系统总线上检测到对缓存中加载的内存区域进行的读取操作时,它会将该缓存行的状态更改为 “shared”。如果它检测到对该地址的写入操作时,会将缓存行的状态更改为 “invalid”。

该缓存子系统想知道,当该系统在监视系统总线时,系统是否在其缓存中包含数据的惟一副本。如果数据由它自己的 CPU 进行了更新,那么这个缓存子系统会将缓存行的状态从 “exclusive” 更改为 “modified”。如果该缓存子系统检测到另一个处理器对该地址的读取,它会阻止访问,更新系统内存中的数据,然后允许该处理的访问继续进行。它还允许将该缓存行的状态标记为 shared。

简而言之就是各CPU都会通过总线嗅探来监视其他CPU,一旦某个CPU对自己Cache中缓存的共享变量做了修改(能做修改的前提是共享变量所在的缓存行的状态不是无效的),那么就会导致其他缓存了该共享变量的CPU将该变量所在的Cache Line置为无效状态,在下次CPU访问无效状态的缓存行时会首先要求对共享变量做了修改的CPU将修改从Cache写回主存,然后自己再从主存中将最新的共享变量读到自己的缓存行中。

并且,缓存一致性协议通过缓存锁定来保证CPU修改缓存行中的共享变量并通知其他CPU将对应缓存行置为无效这一操作的原子性,即当某个CPU修改位于自己缓存中的共享变量时会禁止其他也缓存了该共享变量的CPU访问自己缓存中的对应缓存行,并在缓存锁定结束前通知这些CPU将对应缓存行置为无效状态。

在缓存锁定出现之前,是通过总线锁定来实现CPU之间的同步的,即CPU在回写主存时会锁定总线不让其他CPU访问主存,但是这种机制开销较大,一个CPU对共享变量的操作会导致其他CPU对其他共享变量的访问。

缓存一致性协议虽然保证了Cache和主存的同步,但是又引入了一个新的的问题:伪共享(False Sharing)。

如下图所示,数据X、Y、Z被加载到同一Cache Line中,线程A在Core1修改X,线程B在Core2上修改Y。根据MESI(可见文尾百科链接)大法,假设是Core1是第一个发起操作的CPU核,Core1上的L1 Cache Line由S(共享)状态变成M(修改,脏数据)状态,然后告知其他的CPU核,图例则是Core2,引用同一地址的Cache Line已经无效了;当Core2发起写操作时,首先导致Core1将X写回主存,Cache Line状态由M变为I(无效),而后才是Core2从主存重新读取该地址内容,Cache Line状态由I变成E(独占),最后进行修改Y操作, Cache Line从E变成M。可见多个线程操作在同一Cache Line上的不同数据,相互竞争同一Cache Line,导致线程彼此牵制影响(这一行为称为乒乓效应),变成了串行程序,降低了并发性。此时我们则需要将共享在多线程间的数据进行隔离,使他们不在同一个Cache Line上,从而提升多线程的性能。

Cache Line伪共享的两种解决方案:

  • 缓存行填充(Cache Line Padding),通过增加两个变量的地址距离使之位于两个不同的缓存行上,如此对共享变量X和Y的操作不会相互影响。
  • 线程不直接操作全局共享变量,而是将全局共享变量读取一份副本到自己的局部变量,局部变量在线程之间是不可见的因此随你线程怎么玩,最后线程再将玩出来的结果写回全局变量。

Cache Line Padding

著名的并发大师Doug Lea就曾在JDK7的LinkedTransferQueue中通过追加字节的方式提高队列的操作效率:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
复制代码public class LinkedTransferQueue<E>{
private PaddedAtomicReference<QNode> head;
private PaddedAtomicReference<QNode> tail;
static final class PaddedAtomicReference<E> extends AtomicReference<T{
//给对象追加了 15 * 4 = 60 个字节
Object p0, p1, p2, p3, p4, p5, p6, p7, p8, p9, pa, pb, pc, pd, pe;
PaddedAtomicReference(T r){
super(r);
}
}
}
public class AtomicReference<V> implements Serializable{
private volatile V value;
}

你能否看懂第6行的用意?这还要从对象的内存布局说起,读过《深入理解Java虚拟机(第二版)》的人应该知道非数组对象的内存布局是这样的

  • 对象头

对象头又分为一下三个部分:

+ Mark Word,根据JVM的位数不同表现为32位或64位,存放对象的hashcode、分代年龄、锁标志位等。该部分的数据可被复用,指向偏向线程的ID或指向栈中的Displaced Mark Word又或者指向重量级锁。
+ Class Mete Data,类型指针(也是32位或64位),指向该对象所属的类字节码在被加载到JVM之后存放在方法区中的类型信息。
+ Array Length,如果是数组对象会有这部分数据。
  • 实例数据

运行时对象所包含的数据,是可以动态变化的,而且也是为各线程所共享的,这部分的数据又由以下类型的数据组成:

+ byte, char, short, int, float,占四个字节(注意这是JVM中的数据类型,而不是Java语言层面的数据类型,两者还是有本质上的不同的,由于JVM指令有限,因此不足4个自己的数据都会使用int系列的指令操作)。
+ long,double,占8个字节。
+ reference,根据虚拟机的实现不同占4个或8个字节,但32位JVM中引用类型变量占4个字节。
  • 对齐填充

这部分数据没有实质性的作用,仅做占位目的。对于Hotspot JVM来说,它的内存管理是以8个字节为单位的,而非数组对象的对象头刚好是8个字节(32位JVM)或16个字节(64位JVM),因此当实例数据不是8个字节的倍数时用来做对齐填充。

搞清楚对象内存布局之后我们再来看一下上述中的代码,在性能较高的32位JVM中,引用变量占4个字节,如此的话PaddedAtomicReference类型的对象光实例数据部分就包含了p0-pe15个引用变量,再加上从父类AtomicReference中继承的一个引用变量一共是16个,也就是说光实例数据部分就占了64个字节,因此对象head和tail一定不会被加载到同一个缓存行,这样的话对队列头结点和为尾结点的操作不会因为缓存锁定而串行化,也不会发生互相牵制的乒乓效应,提高了队列的并发性能。

并发编程三要素

经过上述CPU Cache的洗礼,我们总算能够进入Java并发编程了,如果你真正理解了Cache,那么理解Java并发模型就很容易了。

并发编程的三要素是:原子性、可见性、有序性。

可见性

不可见问题是CPU Cache机制引起的,CPU不会直接访问主存而时大多数时候都在操作Cache,由于每个线程可能会在不同CPU核心上进行上下文切换,因此可以理解为每个线程都有自己的一份“本地内存”,当然这个本地内存不是真实存在的,它是对CPU Cache的一个抽象:

image

如果线程Thread-1在自己的本地内存中修改共享变量的副本时如果不及时刷新到主存并通知Thread-2从主存中重新读取的话,那么Thread-2将看不到Thread-1所做的改变并仍然我行我素的操作自己内存中的共享变量副本。这也就是我们常说的Java内存模型(JMM)。

那么线程该如何和主存交互呢?JMM定义了以下8种操作以满足线程和主存之间的交互,JVM实现必须满足对所有变量进行下列操作时都是原子的、不可再分的(对于double和long类型的变量来说,load、store、read、write操作在某些平台上允许例外)

  • lock,作用于主内存的变量,将一个对象标识为一条线程独占的状态
  • unlock,作用于主内存的变量,将一个对象从被锁定的状态中释放出来
  • read,从主存中读取变量
  • load,将read读取到的变量加载本地内存中
  • use,将本地内存中的变量传送给执行引擎,每当JVM执行到一个需要读取变量的值的字节码指令时会执行此操作
  • assign,把从执行引擎接收到的值赋给本地内存中的变量,每当JVM执行到一个需要为变量赋值的字节码指令时会执行此操作。
  • store,线程将本地内存中的变量写回主存
  • write,主存接受线程的写回请求更新主存中的变量

如果需要和主存进行交互,那么就要顺序执行read、load指令,或者store、write指令,注意,这里的顺序并不意味着连续,也就是说对于共享变量a、b可能会发生如下操作read a -> read b -> load b -> load。

如此也就能理解本文开头的第一个示例代码的运行结果了,因为t2线程的执行sharedVariable = oldValue需要分三步操作:assign -> store -> write,也就是说t2线程在自己的本地内存对共享变量副本做修改之后(assign)、执行store、write将修改写回主存之前,t2可以插进来读取共享变量。而且就算t2将修改写回到主存了,如果不通过某种机制通知t1重新从主存中读,t1还是会守着自己本地内存中的变量发呆。

为什么volatile能够保证变量在线程中的可见性?因为JVM就是通过volatile调动了缓存一致性机制,如果对使用了volatile的程序,查看JVM解释执行或者JIT编译后生成的汇编代码,你会发现对volatile域(被volatile修饰的共享变量)的写操作生成的汇编指令会有一个lock前缀,该lock前缀表示JVM会向CPU发送一个信号,这个信号有两个作用:

  • 对该变量的改写立即刷新到主存(也就是说对volatile域的写会导致assgin -> store -> write的原子性执行)
  • 通过总线通知其他CPU该共享变量已被更新,对于也缓存了该共享变量的CPU,如果接收到该通知,那么会在自己的Cache中将共享变量所在的缓存行置为无效状态。CPU在下次读取读取该共享变量时发现缓存行已被置为无效状态,他将重新到主存中读取。

你会发现这就是在底层启用了缓存一致性协议。也就是说对共享变量加上了volatile之后,每次对volatile域的写将会导致此次改写被立即刷新到主存并且后续任何对该volatile域的读操作都将重新从主存中读。

原子性

原子性是指一个或多个操作必须连续执行不可分解。上述已经提到,JMM提供了8个原子性操作,下面通过几个简单的示例来看一下在代码层面,哪些操作是原子的。

对于int类型的变量a和b:

  1. a = 1

这个操作是原子的,字节码指令为putField,属于assign操作
2. a = b

这个操作不是原子的,需要先执行getField读变量b,再执行putField对变量a进行赋值
3. a++

实质上是a = a + 1,首先getField读取变量a,然后执行add计算a + 1的值,最后通过putField将计算后的值赋值给a
4. Object obj = new Object()

首先会执行allocMemory为对象分配内存,然后调用<init>初始化对象,最后返回对象内存地址,更加复杂,自然也不是原子性的。

有序性

由于CPU具有多个不同类型的指令执行单元,因此一个时钟周期可以执行多条指令,为了尽可能地提高程序的并行度,CPU会将不同类型的指令分发到各个执行单元同时执行,编译器在编译过程中也可能会对指令进行重排序。

比如:

1
2
3
复制代码a = 1;
b = a;
flag = true;

flag = true可以重排序到b = a甚至a = 1前面,但是编译器不会对存在依赖关系的指令进行重排序,比如不会将b = a重排序到a = 1的前面,并且编译器将通过插入指令屏障的方式也禁止CPU对其重排序。

对于存在依赖关系两条指令,编译器能够确保他们执行的先后顺序。但是对于不存在依赖关系的指令,编译器只能确保书写在前面的先行发生于书写在后面的,比如a = 1先行发生于flag = true,但是a = 1在flag = true之前执行,先行发生仅表示a = 1这一行为对flag = true可见。

happens-before

在Java中,有一些天生的先行发生原则供我们参考,通过这些规则我们能够判断两条程序的有序性(即是否存在一个先行发生于另一个的关系),从而决定是否有必要对其采取同步。

  • 程序顺序规则:在单线程环境下,按照程序书写顺序,书写在前面的程序 happens-before 书写在后面的。
  • volatile变量规则:对一个volatile域的写 happens-before 随后对同一个volatile域的读。
  • 监视器规则:一个线程释放其持有的锁对象 happens-before 随后其他线程(包括这个刚释放锁的线程)对该对象的加锁。
  • 线程启动规则:对一个线程调用start方法 happens-before 执行这个线程的run方法
  • 线程终止规则:t1线程调用t2.join,检测到t2线程的执行终止 happens-before t1线程从join方法返回
  • 线程中断规则:对一个线程调用interrupt方法 happens-before 这个线程响应中断
  • 对象终结规则:对一个对象的创建new happens-before 这个对象的finalize方法被调用
  • 传递性:如果A happens-before B且B happens-before C,则有A happens-before C

通过以上规则我们解决本文开头提出的疑惑,为何synchronized锁释放、CAS更新和volatile写有着相同的语义(即都能够让对共享变量的改写立即对所有线程可见)。

锁释放有着volatile域写语义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
复制代码new Thread(() -> {
synchronized (lock) {
int oldValue = sharedVariable;
while (sharedVariable < MAX) {
while (!changed) {
try {
lock.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println(Thread.currentThread().getName() +
" watched the change : " + oldValue + "->" + sharedVariable);
oldValue = sharedVariable;
changed = false;
lock.notifyAll();
}
}
}, "t1").start();

new Thread(() -> {
synchronized (lock) {
int oldValue = sharedVariable;
while (sharedVariable < MAX) {
while (changed) {
try {
lock.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println(Thread.currentThread().getName() +
" do the change : " + sharedVariable + "->" + (++oldValue));
sharedVariable = oldValue;
changed = true;
lock.notifyAll();
}
}
}, "t2").start();
  1. 对于t2单个线程使用程序顺序规则,第34行对共享变量sharedVariable的写 happens-before 第 38行退出临界区释放锁。
  2. 对于t1、t2的并发运行,第38行t2对锁的释放 happens-before 第2行t1对锁的获取。
  3. 同样根据程序顺序规则,第2行锁获取 happens-before 第 13行对共享变量sharedVariable的读。
  4. 依据上述的1、2、3和传递性,可得第34行对共享变量sharedVariable的写 happens-before 第13行对共享变量sharedVariable的读。

总结:通过对共享变量写-读的前后加锁,是的普通域的写-读有了和volatile域写-读相同的语义。

原子类CAS更新有着volatile域写语义

前文已说过,对于基本类型或引用类型的读取(use)和赋值(assign),JMM要求JVM实现来确保原子性。因此这类操作的原子性不用我们担心,但是复杂操作的原子性该怎么保证呢?

一个很典型的例子,我们启动十个线程对共享变量i执行10000次i++操作,结果能达到我们预期的100000吗?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
复制代码private static volatile int i = 0;

public static void main(String[] args) throws InterruptedException {
ArrayList<Thread> threads = new ArrayList<>();
Stream.of("t0","t2","t3","t4","t5","t6","t7","t8","t9" ).forEach(
threadName -> {
Thread t = new Thread(() -> {
for (int j = 0; j < 100; j++) {
i++;
}
}, threadName);
threads.add(t);
t.start();
}
);
for (Thread thread : threads) {
thread.join();
}
System.out.println(i);
}

笔者测试了几次都没有达到预期。

也许你会说给i加上volatile就行了,真的吗?你不妨试一下。

如果你理性的分析一下即使是加上volatile也不行。因为volatile只能确保变量i的可见性,而不能保证对其复杂操作的原子性。i++就是一个复杂操作,它可被分解为三步:读取i、计算i+1、将计算结果赋值给i。

要想达到预期,必须使这一次的i++ happens-before 下一次的i++,既然这个程序无法满足这一条件,那么我们可以手动添加一些让程序满足这个条件的代码。比如将i++放入临界区,这是利用了监视器规则,我们不妨验证一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
复制代码private static int i = 0;
private static Object lock = new Object();
public static void main(String[] args) throws InterruptedException {
ArrayList<Thread> threads = new ArrayList<>();
Stream.of("t0","t1","t2","t3","t4","t5","t6","t7","t8","t9" ).forEach(
threadName -> {
Thread t = new Thread(() -> {
for (int j = 0; j < 10000; j++) {
synchronized (lock) {
i++;
}
}
}, threadName);
threads.add(t);
t.start();
}
);
for (Thread thread : threads) {
thread.join();
}
System.out.println(i); //10000
}

运行结果证明我们的逻辑没错,这就是有理论支撑的好处,让我们有方法可寻!并发不是玄学,只要我们有足够的理论支撑,也能轻易地写出高并准确的代码。正确性是并发的第一要素!在实现这一点的情况下,我们再谈并发效率。

于是我们重审下这段代码的并发效率有没有可以提升的地方?由于synchronized会导致同一时刻十个线程只有1个线程能获取到锁,其余九个都将被阻塞,而线程阻塞-被唤醒会导致用户态到内核态的转换(可参考笔者的 Java线程是如何实现的一文),开销较大,而这仅仅是为了执行以下i++?这会导致CPU资源的浪费,吞吐量整体下降。

为了解决这一问题,CAS诞生了。

CAS(Compare And Set)就是一种原子性的复杂操作,它有三个参数:数据地址、更新值、预期值。当需要更新某个共享变量时,CAS将先比较数据地址中的数据是否是预期的旧值,如果是就更新它,否则更新失败不会影响数据地址处的数据。

CAS自旋(循环CAS操作直至更新成功才退出循环)也被称为乐观锁,它总认为并发程度没有那么高,因此即使我这次没有更新成功多试几次也就成功了,这个多试几次的开销并没有线程阻塞的开销大,因此在实际并发程度并不高时比synchronized的性能高许多。但是如果并发程度真的很高,那么多个线程长时间的CAS自旋带来的CPU开销也不容乐观。由于80%的情况下并发都程度都较小,因此常用CAS替代synchronized以获取性能上的提升。

如下是Unsafe类中的CAS自旋:

1
2
3
4
5
6
7
8
复制代码public final int getAndSetInt(Object var1, long var2, int var4) {
int var5;
do {
var5 = this.getIntVolatile(var1, var2);
} while(!this.compareAndSwapInt(var1, var2, var5, var4));

return var5;
}

CAS操作在x86上是由cmpxchg(Compare Exchange)实现的(不同指令集有所不同)。而Java中并未公开CAS接口,CAS以``compareAndSetXxx的形式定义在Unsafe类(仅供Java核心类库调用)中。我们可以通过反射调用,但是JDK提供的AtomicXxx`系列原子操作类已能满足我们的大多数需求。

于是我们来看一下启动十个线程执行1000 000次i++在使用CAS和使用synchronized两种情况下的性能之差:

CAS大约在200左右:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
复制代码private static AtomicInteger i = new AtomicInteger(0);
public static void main(String[] args) throws InterruptedException {
ArrayList<Thread> threads = new ArrayList<>();
long begin = System.currentTimeMillis();
Stream.of("t0","t1","t2","t3","t4","t5","t6","t7","t8","t9" ).forEach(
threadName -> {
Thread t = new Thread(() -> {
for (int j = 0; j < 10000; j++) {
i.getAndIncrement();
}
}, threadName);
threads.add(t);
t.start();
}
);
for (Thread thread : threads) {
thread.join();
}
long end = System.currentTimeMillis();
System.out.println(end - begin); //70-90之间
}

使用synchronized大约在480左右:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
复制代码private static int i = 0;
private static Object lock = new Object();
public static void main(String[] args) throws InterruptedException {
ArrayList<Thread> threads = new ArrayList<>();
long begin = System.currentTimeMillis();
Stream.of("t0","t1","t2","t3","t4","t5","t6","t7","t8","t9" ).forEach(
threadName -> {
Thread t = new Thread(() -> {
for (int j = 0; j < 1000000; j++) {
synchronized (lock) {
i++;
}
}
}, threadName);
threads.add(t);
t.start();
}
);
for (Thread thread : threads) {
thread.join();
}
long end = System.currentTimeMillis();
System.out.println(end - begin);
}

但是我们的疑问还没解开,为什么原子类的CAS更新具有volatile写的语义?单单CAS只能确保use -> assgin是原子的啊。

看一下原子类的源码就知道了,以AtomicInteger,其他的都类同:

1
2
3
4
5
6
复制代码public class AtomicInteger extends Number implements java.io.Serializable {
private volatile int value;
public final int getAndSet(int newValue) {
return unsafe.getAndSetInt(this, valueOffset, newValue);
}
}

你会发现原子类封装了一个volatile域,豁然开朗吧。CAS更新的volatile域,我们知道volatile域的更新将会导致两件事发生:

  • 将改写立即刷新到主存
  • 通知其他CPU将缓存行置为无效

volatile禁止重排序

volatile的另一个语义就是禁止指令重排序,即volatile产生的汇编指令lock具有个指令屏障使得该屏障之前的指令不能重排序到屏障之后。这个作用使用单例模式的并发优化案例来说再好不过了。

懒加载模式

利用类加载过程的初始化(当类被主动引用时应当立即对其初始化)阶段会执行类构造器<clinit>按照显式声明为静态变量初始化的特点。(类的主动引用、被动引用、类构造器、类加载过程详见《深入理解Java虚拟机(第二版)》)

1
2
3
4
5
6
7
8
9
10
11
12
复制代码public class SingletonObject1 {

private static final SingletonObject1 instance = new SingletonObject1();

public static SingletonObject1 getInstance() {
return instance;
}

private SingletonObject1() {

}
}

什么是对类的主动引用:

  • new、getStatic、putStatic、invokeStatic四个字节码指令涉及到的类,对应语言层面就是创建该类实例、读取该类静态字段、修改该类静态字段、调用该类的静态方法
  • 通过java.lang.reflect包的方法对该类进行反射调用时
  • 当初始化一个类时,如果他的父类没被初始化,那么先初始化其父类
  • 当JVM启动时,首先会初始化main函数所在的类

什么是对类的被动引用:

  • 通过子类访问父类静态变量,子类不会被立即初始化
  • 通过数组定义引用的类不会被立即初始化
  • 访问某个类的常量,该类不会被立即初始化(因为经过编译阶段的常量传播优化,该常量已被复制一份到当前类的常量池中了)

饿汉模式1

需要的时候才去创建实例(这样就能避免暂时不用的大内存对象被提前加载):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
复制代码public class SingletonObject2 {

private static SingletonObject2 instance = null;

public static SingletonObject2 getInstance() {
if (SingletonObject2.instance == null) {
SingletonObject2.instance = new SingletonObject2();
}
return SingletonObject2.instance;
}

private SingletonObject2() {

}
}

饿汉模式2

上例中的饿汉模式在单线程下是没问题的,但是一旦并发调用getInstance,可能会出现t1线程刚执行完第6行还没来得及创建对象,t2线程就执行到第6行的判断了,这会导致多个线程来到第7行并执行,导致SingletonObject2被实例化多次,于是我们将第6-7行通过synchronized串行化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
复制代码public class SingletonObject3 {
private static SingletonObject3 instance = null;

public static SingletonObject3 getInstance() {
synchronized (SingletonObject3.class) {
if (SingletonObject3.instance == null) {
SingletonObject3.instance = new SingletonObject3();
}
}
return SingletonObject3.instance;
}

private SingletonObject3() {

}

}

DoubleCheckedLocking

我们已经知道synchronized是重量级锁,如果单例被实例化后,每次获取实例还需要获取锁,长期以往,开销不菲,因此我们在获取实例时加上一个判断,如果单例已被实例化则跳过获取锁的操作(仅在初始化单例时才可能发生冲突):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
复制代码public class SingletonObject4 {

private static SingletonObject4 instance = null;

public static SingletonObject4 getInstance() {
if (SingletonObject4.instance == null) {
synchronized (SingletonObject4.class){
if (SingletonObject4.instance == null) {
SingletonObject4.instance = new SingletonObject4();
}
}
}
return SingletonObject4.instance;
}

private SingletonObject4() {

}
}

DCL2

这样真的就OK了吗,确实同一时刻只有一个线程能够进入到第9行创建对象,但是你别忘了new Object()是可以被分解的!其对应的伪指令如下:

1
2
3
复制代码allocMemory 	//为对象分配内存
<init> //执行对象构造器
return reference //返回对象在堆中的地址

而且上述三步是没有依赖关系的,这意味着他们可能被重排序成下面的样子:

1
2
3
复制代码allocMemory 	//为对象分配内存
return reference //返回对象在堆中的地址
<init> //执行对象构造器

这时可能会导致t1线程执行到第2行时,t1线程判断instance引用地址不为null于是去使用这个instance,而这时对象还没构造完!!这意味着如果对象可能包含的引用变量为null而没被正确初始化,如果t1线程刚好访问了该变量那么将抛出空指针异常

于是我们利用volatile禁止<init>重排序到为instance赋值之后:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
复制代码public class SingletonObject5 {

private volatile static SingletonObject5 instance = null;

public static SingletonObject5 getInstance() {
if (SingletonObject5.instance == null) {
synchronized (SingletonObject5.class) {
if (SingletonObject5.instance == null) {
SingletonObject5.instance = new SingletonObject5();
}
}
}
return SingletonObject5.instance;
}

private SingletonObject5() {

}
}

InstanceHolder

我们还可以利用类只被初始化一次的特点将单例定义在内部类中,从而写出更加优雅的方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
复制代码public class SingletonObject6 {

private static class InstanceHolder{
public static SingletonObject6 instance = new SingletonObject6();
}

public static SingletonObject6 getInstance() {
return InstanceHolder.instance;
}

private SingletonObject6() {

}

}

枚举实例的构造器只会被调用一次

这是由JVM规范要求的,JVM实现必须保证的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
复制代码public class SingletonObject7 {

private static enum Singleton{
INSTANCE;

SingletonObject7 instance;
private Singleton() {
instance = new SingletonObject7();
}
}

public static SingletonObject7 getInstance() {
return Singleton.INSTANCE.instance;
}

private SingletonObject7() {

}

}

(全文完)

参考链接

  • 一篇对伪共享、缓存行填充和CPU缓存讲的很透彻的文章
  • 对于CPU Cache应该知道的事儿
  • 7个示例科普CPU Cache -> 英文原文
  • 伪共享

缓存一致性协议:

  • MSI
  • MESI

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Redis在Web项目中的应用与实践

发表于 2019-02-17

Redis作为一个开源的(BSD)基于内存的高性能存储系统,已经被各大互联网公司广泛使用,并且有着诸多的应用场景。本篇文章将基于PHP来详细讲解Redis在Web项目中的主要应用与实践。

缓存

这里所介绍的缓存是指可以丢失或过期的数据。常用的命令有 set, hset, get, hget,使用redis作为缓存时需要注意一下几个问题:

  • 由于redis的可用内存是有限的,不能容忍redis内存的无限增长,建议设置 maxmemory 最大内存。
  • 在开启maxmemory的情况下,可以启用lru机制,设置key的expire,当到达Redis最大内存时,Redis会根据最近最少用算法对key进行自动淘汰。
  • Redis的持久化策略和Redis故障恢复时间是一个博弈的过程,如果你希望在发生故障时能够尽快恢复,应该启用dump备份机制,但这样需要更多的可用内存空间来进行持久化。如果能够容忍Redis漫长的故障恢复时间,可以使用AOF持久化机制,同时关闭dump机制,这样不需要额外的内存空间。

存储

在web项目中,redis可存储读写非常频繁的数据来缓解MySQL等数据库的压力。redis如果作为存储系统的话,为了防止数据丢失,持久化必须开启。

典型场景

  • 计数器

计数器的需求非常普遍,例如微博点赞数、帖子收藏数、文章分享数、用户关注数等。

  • 社交列表

比如使用Sets结构存储关注列表、收藏列表、点赞列表等。

  • Session

借助redis高性能的key-value存储,可将用户登录状态保存到redis中。

  • …

队列

简单队列

一般使用redis的list结构作为队列,rpush 生产消息,lpop 消费消息,当 lpop 没有消息的时候,要进行适当的sleep操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
php复制代码$queueKey = "queue";

// 生产者
$redis->rpush($queueKey, $data)

// 消费者
while (true) {
$data = $redis->lpop($queueKey);
if (null === $data) {
usleep(100000);
continue;
}
// 业务逻辑
...
}

由于没有消息时使用的sleep事件不好控制,生产环境尽量不要使用sleep来休眠,可使用 blpop 来消费消息,在没有新消息的时候它会阻塞到消息到来。

延时队列

延时队列可使用redis的 sorted set 数据结构,使用时间戳作为 score ,消息内容作为 member,使用 zadd 命令来生产消息,消费者使用 zrangebyscore 命令获取指定时间之前的消息数据轮询进行处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
perl复制代码$queueKey = "queue";

// 生产消息

// 消费时间, 这里设置为1小时候
$consumeTimestamp = time() + 3600;
// $data需要添加随机串前缀(or后缀),防止出现重复member被丢弃
$data = $data . md5(uniqid(rand(), true));
$redis->zadd($queueKey, $consumeTimestamp, $data);

// 消费消息
while (tue) {
$arrData = $redis->zrangebyscore($queueKey, 0, time());
if (!$arrData) {
usleep(100000);
continue;
}
// 业务逻辑
foreach ($arrData as $data) {
$data = substr($data, 0, strlen($data) - 32);

// 消费$data

}
}

多消费者

使用pub/sub主题订阅者模式,可以实现1:N的消息队列。这种模式中在消费者下线的情况下,生产的消息会丢失,在这里不推荐使用。

需要强调的是不推荐使用redis作为消息队列服务,这不是redis的设计目标。如果一定要用可考虑 disque,是由redis的作者开发。

分布式锁

分布式锁主要解决的几个问题:

  • 互斥性: 同一时刻只能有一个服务(或应用)访问资源
  • 安全性: 锁只能被持有该锁的服务(或应用)释放
  • 容错: 在持有锁的服务crash时,锁仍能得到释放
  • 避免死锁

方案1

我们可能会考虑使用 setnx 和 expire 命令来实现加锁,即当没有key存在时才会成功写入value:

1
2
3
4
5
6
7
8
9
10
11
12
php复制代码$lockStatus = $redis->setnx($lockKey, 1);
if (1 === $lockStatus) {
// 加锁成功,为锁设置超时时间
$redis->expire($lockKey, 300);

// 进行后续操作

} elseif (0 === $lockStatus) {
// 加锁失败
} else {
// 其他异常
}

但这种操作不是原子性的,如果在进行setnx时服务崩溃,没有来得及对Key进行超时设置,该锁将一直无法释放。

方案2

我们推荐 set key value [EX seconds] [PX milliseconds] [NX|XX] 命令来进行加锁

  • EX: key在多少秒之后过期
  • PX:key在多少毫秒之后过期
  • NX: 当key不存在的时候,才创建key,效果等同于setnx
  • XX:当key存在的时候,覆盖key
1
2
3
4
5
6
7
8
9
10
php复制代码$lockStatus = $this->redis->set($lockKey, 1, "EX", 30, "NX");
if ("OK" === $lockStatus) {
// 加锁成功,可进行后续操作

//业务逻辑执行完毕,释放锁
$this->redis->del($lockKey);

} elseif (null === $lockStatus) {
// 加锁失败
}

如上代码所示,如果 set 命令返回OK,那么客户端就可以获得锁(如果返回null,那么应用服务可以在一段时间之后重新尝试获取锁),并且可以通过 del 命令来释放锁。

此方法需要注意的问题:

  • a服务获得的锁(键key)已经由于已到过期时间被redis服务器删除,但是这个时候a服务还去执行DEL命令。而b服务经在a设置的过期时间之后重新获取了这个同样key的锁,那么a执行 del 就会释放了b服务加好的锁。
  • 当同一时刻有大量的key过期的时候,删除key时会增加redis压力,会影响服务稳定。

可以通过如下优化使得上面的锁系统变得更加健壮:

  • 不要设置固定的字符串,而是设置为随机的大字符串,可以称为token。
  • 通过脚本删除指定锁的key,而不是 del 命令。
  • 在设置key过期时间的时候加上一个随机值。

优化后的代码可参考如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
ini复制代码$lockToken = md5(uniqid(rand(), true));
// 此处超时时间根据具体业务逻辑配置
$expire = rand(280, 320);
$lockStatus = $this->redis->set($lockKey, $lockToken, "EX", $expire, "NX");
if ("OK" === $lockStatus) {
// 加锁成功,可进行后续操作

// 业务逻辑执行完毕,释放锁
// 删除锁之前需要判断是否是自己上的锁
$currentToken = $this->redis->get($lockKey);
if ($currentToken === $lockToken) {
$this->redis->del($lockKey);
}

} elseif (null === $lockStatus) {
// 加锁失败
}

计算

redis提供的原子自增减方法以及有序集合结构等可以承担一些计算任务,例如浏览量统计等。

浏览计数

文章浏览量+1

1
bash复制代码$redis->incr($postsKey);

批量获取文章浏览量

1
2
3
4
ini复制代码$arrPostsKey = [
//...
];
$arrPostsViewNum = $redis->mget($arrPostsKey);

排行榜

可以使用redis的有序集合来实现排行榜的功能,score作为权重排序并取前n条记录。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
php复制代码// 存储数据
$sortKey = "sort_key";
$redis->zadd($sortKey, 100, "tom");
$redis->zadd($sortKey, 80, "Jon");
$redis->zadd($sortKey, 59, "Lilei");
$redis->zadd($sortKey, 87, "Hanmeimei");

// 获取排行

// 由大到小排序
$arrRet = $redis->zrevrange($sortKey, 0, -1, true);

// 由小到大排序
$arrRet = $redis->zrange($sortKey, 0, -1, true);

结尾

redis涉及的应用实践非常繁多的,由于篇幅所限无法全部顾及,本文只针对web应用中最常用的几个场景进行了展开介绍,渴望进一步拓展redis知识的同学可参考以下链接进一步学习。

  • Redis官网
  • Antirez

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

关于 performSelector 的一些小探讨

发表于 2019-02-13

本文首发在我的个人博客: blog.shenyuanluo.com,喜欢的朋友欢迎订阅。

考虑以下代码,最终会输出什么?

  1. 例子①:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    复制代码- (void)viewDidLoad
    {
    [super viewDidLoad];

    NSLog(@"1 - %@", [NSThread currentThread]);

    dispatch_async(dispatch_get_global_queue(0, 0), ^{

    NSLog(@"2 - %@", [NSThread currentThread]);
    [self performSelector:@selector(test)
    withObject:nil];
    NSLog(@"4 - %@", [NSThread currentThread]);
    });
    }

    - (void)test
    {
    NSLog(@"3 - %@", [NSThread currentThread]);
    }
    • 输出结果:1,2,3,4
    • 原因: 因为 performSelector:withObject: 会在当前线程立即执行指定的 selector 方法。
  2. 例子②:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    复制代码- (void)viewDidLoad
    {
    [super viewDidLoad];

    NSLog(@"1 - %@", [NSThread currentThread]);

    dispatch_async(dispatch_get_global_queue(0, 0), ^{

    NSLog(@"2 - %@", [NSThread currentThread]);
    [self performSelector:@selector(test)
    withObject:nil
    afterDelay:0];
    NSLog(@"4 - %@", [NSThread currentThread]);
    });
    }

    - (void)test
    {
    NSLog(@"3 - %@", [NSThread currentThread]);
    }
    • 输出结果:1,2,4
    • 原因: 因为 performSelector:withObject:afterDelay: 实际是往 RunLoop 里面注册一个定时器,而在子线程中,RunLoop 是没有开启(默认)的,所有不会输出 3。官网 API 作如下解释:
  3. 例子③:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    复制代码- (void)viewDidLoad
    {
    [super viewDidLoad];

    NSLog(@"1 - %@", [NSThread currentThread]);

    dispatch_async(dispatch_get_global_queue(0, 0), ^{

    NSLog(@"2 - %@", [NSThread currentThread]);
    [self performSelector:@selector(test)
    withObject:nil
    afterDelay:0];
    [[NSRunLoop currentRunLoop] run];
    NSLog(@"4 - %@", [NSThread currentThread]);
    });
    }

    - (void)test
    {
    NSLog(@"3 - %@", [NSThread currentThread]);
    }
    • 输出结果:1,2,3,4
    • 原因: 由于 [[NSRunLoop currentRunLoop] run]; 会创建的当前子线程对应的 RunLoop 对象并启动了,因此可以执行 test 方法;并且 test 执行完后,RunLoop 中注册的定时器已经无效,所以还可以输出 4 (对比 例子⑥例子)。
  4. 例子④:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    复制代码- (void)viewDidLoad
    {
    [super viewDidLoad];

    NSLog(@"1 - %@", [NSThread currentThread]);

    dispatch_async(dispatch_get_global_queue(0, 0), ^{

    NSLog(@"2 - %@", [NSThread currentThread]);
    [self performSelector:@selector(test)
    onThread:[NSThread currentThread]
    withObject:nil
    waitUntilDone:YES];
    NSLog(@"4 - %@", [NSThread currentThread]);
    });
    }

    - (void)test
    {
    NSLog(@"3 - %@", [NSThread currentThread]);
    }
    • 输出结果:1,2,3,4
    • 原因: 因为 performSelector:onThread:withObject:waitUntilDone: 会在指定的线程执行,而执行的策略根据参数 wait 处理,这里传 YES 表明将会立即阻断 指定的线程 并执行指定的 selector。官网 API 解释如下:
  5. 例子⑤:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    复制代码- (void)viewDidLoad
    {
    [super viewDidLoad];

    NSLog(@"1 - %@", [NSThread currentThread]);

    dispatch_async(dispatch_get_global_queue(0, 0), ^{

    NSLog(@"2 - %@", [NSThread currentThread]);
    [self performSelector:@selector(test)
    onThread:[NSThread currentThread]
    withObject:nil
    waitUntilDone:NO];
    NSLog(@"4 - %@", [NSThread currentThread]);
    });
    }

    - (void)test
    {
    NSLog(@"3 - %@", [NSThread currentThread]);
    }
    • 输出结果:1,2,4
    • 原因: 因为 performSelector:onThread:withObject:waitUntilDone: 会在指定的线程执行,而执行的策略根据参数 wait 处理,这里传 NO 表明不会立即阻断 指定的线程 而是将 selector 添加到指定线程的 RunLoop 中等待时机执行。(该例子中,子线程 RunLoop 没有启动,所有没有输出 3)官网 API 解释如下:
  6. 例子⑥:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    复制代码- (void)viewDidLoad
    {
    [super viewDidLoad];

    NSLog(@"1 - %@", [NSThread currentThread]);

    dispatch_async(dispatch_get_global_queue(0, 0), ^{

    NSLog(@"2 - %@", [NSThread currentThread]);
    [self performSelector:@selector(test)
    onThread:[NSThread currentThread]
    withObject:nil
    waitUntilDone:NO];
    [[NSRunLoop currentRunLoop] run];
    NSLog(@"4 - %@", [NSThread currentThread]);
    });
    }

    - (void)test
    {
    NSLog(@"3 - %@", [NSThread currentThread]);
    }
    • 输出结果:1,2,3
    • 原因: 由于 [[NSRunLoop currentRunLoop] run]; 已经创建的当前子线程对应的 RunLoop 对象并启动了,因此可以执行 test 方法;但是 test 方法执行完后,RunLoop 并没有结束(使用这种启动方式,RunLoop 会一直运行下去,在此期间会处理来自输入源的数据,并且会在 NSDefaultRunLoopMode 模式下重复调用 runMode:beforeDate: 方法)所以无法继续输出 4。
  7. 例子⑦:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    复制代码- (void)viewDidLoad
    {
    [super viewDidLoad];

    NSLog(@"1 - %@", [NSThread currentThread]);

    dispatch_async(dispatch_get_global_queue(0, 0), ^{

    NSLog(@"2 - %@", [NSThread currentThread]);
    [self performSelector:@selector(test)
    onThread:[NSThread currentThread]
    withObject:nil
    waitUntilDone:NO];
    [[NSRunLoop currentRunLoop] runUntilDate:[NSDate distantFuture]];
    NSLog(@"4 - %@", [NSThread currentThread]);
    });
    }

    - (void)test
    {
    NSLog(@"3 - %@", [NSThread currentThread]);
    }
    • 输出结果:1,2,3
    • 原因: 由于 [[NSRunLoop currentRunLoop] runUntilDate:[NSDate distantFuture]]; 已经创建的当前子线程对应的 RunLoop 对象并启动了,因此可以执行 test 方法;但是 test 方法执行完后,RunLoop 并没有结束(使用这种启动方式,可以设置超时时间,在超时时间到达之前,runloop会一直运行,在此期间runloop会处理来自输入源的数据,并且会在 NSDefaultRunLoopMode 模式下重复调用 runMode:beforeDate: 方法)所以无法继续输出 4。
  8. 例子⑧:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    复制代码- (void)viewDidLoad
    {
    [super viewDidLoad];

    NSLog(@"1 - %@", [NSThread currentThread]);

    dispatch_async(dispatch_get_global_queue(0, 0), ^{

    NSLog(@"2 - %@", [NSThread currentThread]);
    [self performSelector:@selector(test)
    onThread:[NSThread currentThread]
    withObject:nil
    waitUntilDone:NO];
    [[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode
    beforeDate:[NSDate distantFuture]];
    NSLog(@"4 - %@", [NSThread currentThread]);
    });
    }

    - (void)test
    {
    NSLog(@"3 - %@", [NSThread currentThread]);
    }
    • 输出结果:1,2,3,4
    • 原因: 由于 [[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode beforeDate:[NSDate distantFuture]]; 已经创建的当前子线程对应的 RunLoop 对象并启动了,因此可以执行 test 方法;而且 test 方法执行完后,RunLoop 立刻结束(使用这种启动方式 ,RunLoop 会运行一次,超时时间到达或者第一个 input source 被处理,则 RunLoop 就会退出)所以可以继续输出 4。

小结:

  1. 常用 performSelector 方法

    • 常用的 perform,是 NSObject.h 头文件下的方法:

      1
      2
      3
      复制代码- (id)performSelector:(SEL)aSelector;
      - (id)performSelector:(SEL)aSelector withObject:(id)object;
      - (id)performSelector:(SEL)aSelector withObject:(id)object1 withObject:(id)object2;
    • 可以 delay 的 perform,是 NSRunLoop.h 头文件下的方法:

      1
      2
      复制代码- (void)performSelector:(SEL)aSelector withObject:(nullable id)anArgument afterDelay:(NSTimeInterval)delay inModes:(NSArray<NSRunLoopMode> *)modes;
      - (void)performSelector:(SEL)aSelector withObject:(nullable id)anArgument afterDelay:(NSTimeInterval)delay;
    • 可以 指定线程 的 perform,是 NSThread 头文件下的方法:

      1
      2
      3
      4
      5
      复制代码- (void)performSelectorOnMainThread:(SEL)aSelector withObject:(nullable id)arg waitUntilDone:(BOOL)wait modes:(nullable NSArray<NSString *> *)array;
      - (void)performSelectorOnMainThread:(SEL)aSelector withObject:(nullable id)arg waitUntilDone:(BOOL)wait;
      - (void)performSelector:(SEL)aSelector onThread:(NSThread *)thr withObject:(nullable id)arg waitUntilDone:(BOOL)wait modes:(nullable NSArray<NSString *> *)array;
      - (void)performSelector:(SEL)aSelector onThread:(NSThread *)thr withObject:(nullable id)arg waitUntilDone:(BOOL)wait;
      - (void)performSelectorInBackground:(SEL)aSelector withObject:(nullable id)arg;
  2. RunLoop 退出方式:

    • 使用 - (void)run; 启动,RunLoop 会一直运行下去,在此期间会处理来自输入源的数据,并且会在 NSDefaultRunLoopMode 模式下重复调用 runMode:beforeDate: 方法;
    • 使用 - (void)runUntilDate:(NSDate *)limitDate; 启动,可以设置超时时间,在超时时间到达之前,RunLoop 会一直运行,在此期间 RunLoop 会处理来自输入源的数据,并且也会在 NSDefaultRunLoopMode 模式下重复调用 runMode:beforeDate: 方法;
    • 使用 - (void)runMode:(NSString *)mode beforeDate:(NSDate *)limitDate; 启动,RunLoop 会运行一次,超时时间到达或者第一个 input source 被处理,则 RunLoop 就会退出。
  3. 更多关于 NSRunLoop的退出方式 可以看这篇博文

参考

  1. NSRunLoop的退出方式

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

剑指Spring源码(一)

发表于 2019-02-13

Spring,相信每个Java开发都用过,而且是每天都在用,那强大又神秘的IoC,AOP,让我们的开发变得越来越简单,只需要一个注解搞定一切,但是它内部到底是什么样子的呢?跟着我,一起探究Spring源码把。

写在前面的话:Spring项目距今已有15年左右的历史了,是众多Java大神们的杰作,由于我个人水平有限,时间有限,不保证我说的全部都是正确的,但是我可以保证每一句话都是反复推敲,经过验证,绝没有复制粘贴。当然在这里,也不可能把每个方法都进行深层次的分析,只能把重点集中在重要的方法上,有些(其实是绝大部分)只能采取黑盒理论,即:不去探究方法内部到底做了什么,只大概的知道执行这个方法后发生了什么。

本文中采用的Spring版本是5.0.0

由于现在JavaConfig风格+注解的方式来使用Spring,是Spring官方主推的,也是现在的主流方式,所以我们从这里出发:

1
ini复制代码AnnotationConfigApplicationContext context=new AnnotationConfigApplicationContext(AppConfig.class);

让我们先来看看AnnotationConfigApplicationContext的关系图:

image.png

可以看到这个关系够复杂的,我们现在完全不需要特意全部记住,只要有一个大概的印象就可以了,后面随着源码分析的深入,自然而然会记住其中的一些关系。

创建AnnotationConfigApplicationContext对象,首先会跑到这里:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
scss复制代码//根据参数类型可以知道,其实可以传入多个annotatedClasses,但是这种情况出现的比较少
public AnnotationConfigApplicationContext(Class<?>... annotatedClasses) {
//调用无参构造函数,会先调用父类GenericApplicationContext的构造函数
//父类的构造函数里面就是初始化DefaultListableBeanFactory,并且赋值给beanFactory
//本类的构造函数里面,初始化了一个读取器:AnnotatedBeanDefinitionReader read,一个扫描器ClassPathBeanDefinitionScanner scanner
//scanner的用处不是很大,它仅仅是在我们外部手动调用 .scan 等方法才有用,常规方式是不会用到scanner对象的
this();
//把传入的类进行注册,这里有两个情况,
//传入传统的配置类
//传入bean(虽然一般没有人会这么做
//看到后面会知道spring把传统的带上@Configuration的配置类称之为FULL配置类,不带@Configuration的称之为Lite配置类
//但是我们这里先把带上@Configuration的配置类称之为传统配置类,不带的称之为普通bean
register(annotatedClasses);
//刷新
refresh();
}

这个方法第一眼看上去,很简单,无非就是三行代码,但是这三行代码包含了大千世界。

我们先来为构造方法做一个简单的说明:

  1. 这是一个有参的构造方法,可以接收多个配置类,不过一般情况下,只会传入一个配置类。
  2. 这个配置类有两种情况,一种是传统意义上的带上@Configuration注解的配置类,还有一种是没有带上@Configuration,但是带有@Component,@Import,@ImportResouce,@Service,@ComponentScan等注解的配置类,在Spring内部把前者称为Full配置类,把后者称之为Lite配置类。在本源码分析中,有些地方也把Lite配置类称为普通Bean。

我们先来看一下第一行代码:通过this()调用此类无参的构造方法,代码会跑到下面:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
scala复制代码public class AnnotationConfigApplicationContext extends GenericApplicationContext implements AnnotationConfigRegistry {

//注解bean定义读取器,主要作用是用来读取被注解的了bean
private final AnnotatedBeanDefinitionReader reader;

//扫描器,它仅仅是在我们外部手动调用 .scan 等方法才有用,常规方式是不会用到scanner对象的
private final ClassPathBeanDefinitionScanner scanner;

/**
* Create a new AnnotationConfigApplicationContext that needs to be populated
* through {@link #register} calls and then manually {@linkplain #refresh refreshed}.
*/
public AnnotationConfigApplicationContext() {
//会隐式调用父类的构造方法,初始化DefaultListableBeanFactory

//初始化一个Bean读取器
this.reader = new AnnotatedBeanDefinitionReader(this);

//初始化一个扫描器,它仅仅是在我们外部手动调用 .scan 等方法才有用,常规方式是不会用到scanner对象的
this.scanner = new ClassPathBeanDefinitionScanner(this);
}
}

首先映入眼帘的是reader和scanner,无参构造方法中就是对reader和scanner进行了实例化,reader的类型是AnnotatedBeanDefinitionReader,从字面意思就可以看出它是一个 “打了注解的Bean定义读取器”,scanner的类型是ClassPathBeanDefinitionScanner,其实这个字段并不重要,它仅仅是在我们外面手动调用.scan方法,或者调用参数为String的构造方法,传入需要扫描的包名,才会用到,像我们这样传入配置类是不会用到这个scanner对象的。

AnnotationConfigApplicationContext类是有继承关系的,会隐式调用父类的构造方法:

1
2
3
4
5
6
scala复制代码public class GenericApplicationContext extends AbstractApplicationContext implements BeanDefinitionRegistry {
private final DefaultListableBeanFactory beanFactory;
public GenericApplicationContext() {
this.beanFactory = new DefaultListableBeanFactory();
}
}

这个代码很简单,就是初始化了DefaultListableBeanFactory。

我们再来看看DefaultListableBeanFactory的关系图:

image.png

DefaultListableBeanFactory是相当重要的,从字面意思就可以看出它是一个Bean的工厂,什么是Bean的工厂?当然就是用来生产和获得Bean的。

让我们把目光回到AnnotationConfigApplicationContext的无参构造方法,让我们看看Spring在初始化AnnotatedBeanDefinitionReader的时候做了什么:

1
2
3
scss复制代码	public AnnotatedBeanDefinitionReader(BeanDefinitionRegistry registry) {
this(registry, getOrCreateEnvironment(registry));
}

这里的BeanDefinitionRegistry当然就是AnnotationConfigApplicationContext的实例了,这里又直接调用了此类其他的构造方法:

1
2
3
4
5
6
7
kotlin复制代码	public AnnotatedBeanDefinitionReader(BeanDefinitionRegistry registry, Environment environment) {
Assert.notNull(registry, "BeanDefinitionRegistry must not be null");
Assert.notNull(environment, "Environment must not be null");
this.registry = registry;
this.conditionEvaluator = new ConditionEvaluator(registry, environment, null);
AnnotationConfigUtils.registerAnnotationConfigProcessors(this.registry);
}

让我们把目光移动到这个方法的最后一行,进入registerAnnotationConfigProcessors方法:

1
2
3
typescript复制代码	public static void registerAnnotationConfigProcessors(BeanDefinitionRegistry registry) {
registerAnnotationConfigProcessors(registry, null);
}

这又是一个门面方法,再点进去,这个方法的返回值Set,但是上游方法并没有去接收这个返回值,所以这个方法的返回值也不是很重要了,当然方法内部给这个返回值赋值也不重要了。由于这个方法内容比较多,这里就把最核心的贴出来,这个方法的核心就是注册Spring内置的多个Bean:

1
2
3
4
5
ini复制代码if (!registry.containsBeanDefinition(CONFIGURATION_ANNOTATION_PROCESSOR_BEAN_NAME)) {
RootBeanDefinition def = new RootBeanDefinition(ConfigurationClassPostProcessor.class);
def.setSource(source);
beanDefs.add(registerPostProcessor(registry, def, CONFIGURATION_ANNOTATION_PROCESSOR_BEAN_NAME));
}
  1. 判断容器中是否已经存在了ConfigurationClassPostProcessor Bean
  2. 如果不存在(当然这里肯定是不存在的),就通过RootBeanDefinition的构造方法获得ConfigurationClassPostProcessor的BeanDefinition,RootBeanDefinition是BeanDefinition的子类:

image.png

  1. 执行registerPostProcessor方法,registerPostProcessor方法内部就是注册Bean。

当然这里注册其他Bean也是一样的流程。

BeanDefinition是什么,顾名思义,它是用来描述Bean的,里面存放着关于Bean的一系列信息,比如Bean的作用域,Bean所对应的Class,是否懒加载,是否Primary等等,这个BeanDefinition也相当重要,我们以后会常常和它打交道。

registerPostProcessor方法:

1
2
3
4
5
6
7
scss复制代码	private static BeanDefinitionHolder registerPostProcessor(
BeanDefinitionRegistry registry, RootBeanDefinition definition, String beanName) {

definition.setRole(BeanDefinition.ROLE_INFRASTRUCTURE);
registry.registerBeanDefinition(beanName, definition);
return new BeanDefinitionHolder(definition, beanName);
}

这方法为BeanDefinition设置了一个Role,ROLE_INFRASTRUCTURE代表这是spring内部的,并非用户定义的,然后又调用了registerBeanDefinition方法,再点进去,Oh No,你会发现它是一个接口,没办法直接点进去了,首先要知道registry实现类是什么,那么它的实现是什么呢?答案是DefaultListableBeanFactory:

1
2
3
4
arduino复制代码public void registerBeanDefinition(String beanName, BeanDefinition beanDefinition)
throws BeanDefinitionStoreException {
this.beanFactory.registerBeanDefinition(beanName, beanDefinition);
}

这又是一个门面方法,再点进去,核心在于下面两行代码:

1
2
3
4
5
6
kotlin复制代码//beanDefinitionMap是Map<String, BeanDefinition>,
//这里就是把beanName作为key,ScopedProxyMode作为value,推到map里面
this.beanDefinitionMap.put(beanName, beanDefinition);

//beanDefinitionNames就是一个List<String>,这里就是把beanName放到List中去
this.beanDefinitionNames.add(beanName);

从这里可以看出DefaultListableBeanFactory就是我们所说的容器了,里面放着beanDefinitionMap,beanDefinitionNames,beanDefinitionMap是一个hashMap,beanName作为Key,beanDefinition作为Value,beanDefinitionNames是一个集合,里面存放了beanName。打个断点,第一次运行到这里,监视这两个变量:
image.png
image.png

DefaultListableBeanFactory中的beanDefinitionMap,beanDefinitionNames也是相当重要的,以后会经常看到它,最好看到它,第一时间就可以反应出它里面放了什么数据

这里仅仅是注册,可以简单的理解为把一些原料放入工厂,工厂还没有真正的去生产。

上面已经介绍过,这里会一连串注册好几个Bean,在这其中最重要的一个Bean(没有之一)就是BeanDefinitionRegistryPostProcessor Bean。

ConfigurationClassPostProcessor实现BeanDefinitionRegistryPostProcessor接口,BeanDefinitionRegistryPostProcessor接口又扩展了BeanFactoryPostProcessor接口,BeanFactoryPostProcessor是Spring的扩展点之一,ConfigurationClassPostProcessor是Spring极为重要的一个类,必须牢牢的记住上面所说的这个类和它的继承关系。

image.png

除了注册了ConfigurationClassPostProcessor,还注册了其他Bean,其他Bean也都实现了其他接口,比如BeanPostProcessor接口。

BeanPostProcessor接口也是Spring的扩展点之一。

至此,实例化AnnotatedBeanDefinitionReader reader分析完毕。

由于常规使用方式是不会用到AnnotationConfigApplicationContext里面的scanner的,所以这里就不看scanner是如何被实例化的了。

把目光回到最开始,再分析第二行代码:

1
scss复制代码register(annotatedClasses);

这里传进去的是一个数组,最终会循环调用如下方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
less复制代码	<T> void doRegisterBean(Class<T> annotatedClass, @Nullable Supplier<T> instanceSupplier, @Nullable String name,
@Nullable Class<? extends Annotation>[] qualifiers, BeanDefinitionCustomizer... definitionCustomizers) {
//AnnotatedGenericBeanDefinition可以理解为一种数据结构,是用来描述Bean的,这里的作用就是把传入的标记了注解的类
//转为AnnotatedGenericBeanDefinition数据结构,里面有一个getMetadata方法,可以拿到类上的注解
AnnotatedGenericBeanDefinition abd = new AnnotatedGenericBeanDefinition(annotatedClass);

//判断是否需要跳过注解,spring中有一个@Condition注解,当不满足条件,这个bean就不会被解析
if (this.conditionEvaluator.shouldSkip(abd.getMetadata())) {
return;
}

abd.setInstanceSupplier(instanceSupplier);

//解析bean的作用域,如果没有设置的话,默认为单例
ScopeMetadata scopeMetadata = this.scopeMetadataResolver.resolveScopeMetadata(abd);
abd.setScope(scopeMetadata.getScopeName());

//获得beanName
String beanName = (name != null ? name : this.beanNameGenerator.generateBeanName(abd, this.registry));

//解析通用注解,填充到AnnotatedGenericBeanDefinition,解析的注解为Lazy,Primary,DependsOn,Role,Description
AnnotationConfigUtils.processCommonDefinitionAnnotations(abd);

//限定符处理,不是特指@Qualifier注解,也有可能是Primary,或者是Lazy,或者是其他(理论上是任何注解,这里没有判断注解的有效性),如果我们在外面,以类似这种
//AnnotationConfigApplicationContext annotationConfigApplicationContext = new AnnotationConfigApplicationContext(Appconfig.class);常规方式去初始化spring,
//qualifiers永远都是空的,包括上面的name和instanceSupplier都是同样的道理
//但是spring提供了其他方式去注册bean,就可能会传入了
if (qualifiers != null) {
//可以传入qualifier数组,所以需要循环处理
for (Class<? extends Annotation> qualifier : qualifiers) {
//Primary注解优先
if (Primary.class == qualifier) {
abd.setPrimary(true);
}
//Lazy注解
else if (Lazy.class == qualifier) {
abd.setLazyInit(true);
}
//其他,AnnotatedGenericBeanDefinition有个Map<String,AutowireCandidateQualifier>属性,直接push进去
else {
abd.addQualifier(new AutowireCandidateQualifier(qualifier));
}
}
}

for (BeanDefinitionCustomizer customizer : definitionCustomizers) {
customizer.customize(abd);
}

//这个方法用处不大,就是把AnnotatedGenericBeanDefinition数据结构和beanName封装到一个对象中
BeanDefinitionHolder definitionHolder = new BeanDefinitionHolder(abd, beanName);

definitionHolder = AnnotationConfigUtils.applyScopedProxyMode(scopeMetadata, definitionHolder, this.registry);

//注册,最终会调用DefaultListableBeanFactory中的registerBeanDefinition方法去注册,
//DefaultListableBeanFactory维护着一系列信息,比如beanDefinitionNames,beanDefinitionMap
//beanDefinitionNames是一个List<String>,用来保存beanName
//beanDefinitionMap是一个Map,用来保存beanName和beanDefinition
BeanDefinitionReaderUtils.registerBeanDefinition(definitionHolder, this.registry);
}

在这里又要说明下,以常规方式去注册配置类,此方法中除了第一个参数,其他参数都是默认值。

  1. 通过AnnotatedGenericBeanDefinition的构造方法,获得配置类的BeanDefinition,这里是不是似曾相似,在注册ConfigurationClassPostProcessor类的时候,也是通过构造方法去获得BeanDefinition的,只不过当时是通过RootBeanDefinition去获得,现在是通过AnnotatedGenericBeanDefinition去获得。
    image.png
  2. 判断需不需要跳过注册,Spring中有一个@Condition注解,如果不满足条件,就会跳过这个类的注册。
  3. 然后是解析作用域,如果没有设置的话,默认为单例。
  4. 获得BeanName。
  5. 解析通用注解,填充到AnnotatedGenericBeanDefinition,解析的注解为Lazy,Primary,DependsOn,Role,Description。
  6. 限定符处理,不是特指@Qualifier注解,也有可能是Primary,或者是Lazy,或者是其他(理论上是任何注解,这里没有判断注解的有效性)。
  7. 把AnnotatedGenericBeanDefinition数据结构和beanName封装到一个对象中(这个不是很重要,可以简单的理解为方便传参)。
  8. 注册,最终会调用DefaultListableBeanFactory中的registerBeanDefinition方法去注册:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
scss复制代码	public static void registerBeanDefinition(
BeanDefinitionHolder definitionHolder, BeanDefinitionRegistry registry)
throws BeanDefinitionStoreException {

//获取beanName
// Register bean definition under primary name.
String beanName = definitionHolder.getBeanName();

//注册bean
registry.registerBeanDefinition(beanName, definitionHolder.getBeanDefinition());

//Spring支持别名
// Register aliases for bean name, if any.
String[] aliases = definitionHolder.getAliases();
if (aliases != null) {
for (String alias : aliases) {
registry.registerAlias(beanName, alias);
}
}
}

这个registerBeanDefinition是不是又有一种似曾相似的感觉,没错,在上面注册Spring内置的Bean的时候,已经解析过这个方法了,这里就不重复了,此时,让我们再观察下beanDefinitionMap beanDefinitionNames两个变量,除了Spring内置的Bean,还有我们传进来的Bean,这里的Bean当然就是我们的配置类了:

image.png
image.png

到这里注册配置类也分析完毕了。

大家可以看到其实到这里,Spring还没有进行扫描,只是实例化了一个工厂,注册了一些内置的Bean和我们传进去的配置类,真正的大头是在第三行代码:

1
scss复制代码refresh();

不过,这就是下一章的内容了。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

1…878879880…956

开发者博客

9558 日志
1953 标签
RSS
© 2025 开发者博客
本站总访问量次
由 Hexo 强力驱动
|
主题 — NexT.Muse v5.1.4
0%