Map是一种用于快速查找的数据结构,它以键值对的形式存储数据,每一个键都是唯一的,且对应着一个值,如果想要查找Map中的数据,只需要传入一个键,Map会对键进行匹配并返回键所对应的值,可以说Map其实就是一个存放键值对的集合。Map被各种编程语言广泛使用,只不过在名称上可能会有些混淆,像Python中叫做字典(Dictionary),也有些语言称其为关联数组(Associative Array),但其实它们都是一样的,都是一个存放键值对的集合。至于Java中经常用到的HashMap也是Map的一种,它被称为散列表,关于散列表的细节我会在本文中解释HashMap的源码时提及。
Java还提供了一种与Map密切相关的数据结构:Set,它是数学意义上的集合,特性如下:
无序性:一个集合中,每个元素的地位都是相同的,元素之间也都是无序的。不过Java中也提供了有序的Set,这点倒是没有完全遵循。
互异性:一个集合中,任何两个元素都是不相同的。
确定性:给定一个集合以及其任一元素,该元素属于或者不属于该集合是必须可以确定的。
很明显,Map中的key就很符合这些特性,Set的实现其实就是在内部使用Map。例如,HashSet就定义了一个类型为HashMap的成员变量,向HashSet添加元素a,等同于向它内部的HashMap添加了一个key为a,value为一个Object对象的键值对,这个Object对象是HashSet的一个常量,它是一个虚拟值,没有什么实际含义,源码如下:
|
|
小插曲过后,让我们接着说Map,它是JDK的一个顶级接口,提供了三种集合视图(Collection Views):包含所有key的集合、包含所有value的集合以及包含所有键值对的集合,Map中的元素顺序与它所返回的集合视图中的元素的迭代顺序相关,也就是说,Map本身是不保证有序性的,当然也有例外,比如TreeMap就对有序性做出了保证,这主要因为它是基于红黑树实现的。
所谓的集合视图就是由集合本身提供的一种访问数据的方式,同时对视图的任何修改也会影响到集合。好比Map.keySet()
返回了它包含的key的集合,如果你调用了Map.remove(key)
那么keySet.contains(key)
也将返回false
,再比如说Arrays.asList(T)
可以把一个数组封装成一个List,这样你就可以通过List的API来访问和操作这些数据,如下列示例代码:
|
|
是不是感觉很神奇,其实Arrays.asList()
只是将传入的数组与Arrays
中的一个内部类ArrayList
(注意,它与java.util
包下的ArrayList
不是同一个)做了一个”绑定“,在调用get()
时会直接根据下标返回数组中的元素,而调用set()
时也会直接修改数组中对应下标的元素。相对于直接复制来说,集合视图的优点是内存利用率更高,假设你有一个数组,又很想使用List的API来操作它,那么你不用new一个ArrayList
以拷贝数组中的元素,只需要一点额外的内存(通过Arrays.ArrayList
对数组进行封装),原始数据依然是在数组中的,并不会复制成多份。
Map接口规范了Map数据结构的通用API(也含有几个用于简化操作的default方法,default是JDK8的新特性,它是接口中声明的方法的默认实现,即非抽象方法)并且还在内部定义了Entry接口(键值对的实体类),在JDK中提供的所有Map数据结构都实现了Map接口,下面为Map接口的源码(代码中的注释太长了,基本都是些实现的规范,为了篇幅我就尽量省略了)。
|
|
需要注意一点,这些default方法都是非线程安全的,任何保证线程安全的扩展类都必须重写这些方法,例如ConcurrentHashMap。
下图为Map的继承关系结构图,它也是本文接下来将要分析的Map实现类的大纲,这些实现类都是比较常用的,在JDK中Map的实现类有几十个,大部分都是我们用不到的,限于篇幅原因就不一一讲解了(本文包含许多源码与对实现细节的分析,建议读者抽出一段连续的空闲时间静下心来慢慢阅读)。
本文作者为SylvanasSun(sylvanas.sun@gmail.com),首发于SylvanasSun’s Blog。
原文链接:https://sylvanassun.github.io/2018/03/16/2018-03-16-map_family/
(转载请务必保留本段声明,并且保留超链接。)
AbstractMap是一个抽象类,它是Map接口的一个骨架实现,最小化实现了此接口提供的抽象函数。在Java的Collection框架中基本都遵循了这一规定,骨架实现在接口与实现类之间构建了一层抽象,其目的是为了复用一些比较通用的函数以及方便扩展,例如List接口拥有骨架实现AbstractList、Set接口拥有骨架实现AbstractSet等。
下面我们按照不同的操作类型来看看AbstractMap都实现了什么,首先是查询操作:
|
|
可以发现这些操作都是依赖于函数entrySet()
的,它返回了一个键值对的集合视图,由于不同的实现子类的Entry实现可能也是不同的,所以一般是在内部实现一个继承于AbstractSet且泛型为Map.Entry
的内部类作为EntrySet,接下来是修改操作与批量操作:
|
|
AbstractMap并没有实现put()
函数,这样做是为了考虑到也许会有不可修改的Map实现子类继承它,而对于一个可修改的Map实现子类则必须重写put()
函数。
AbstractMap没有提供entrySet()
的实现,但是却提供了keySet()
与values()
集合视图的默认实现,它们都是依赖于entrySet()
返回的集合视图实现的,源码如下:
|
|
它还提供了两个Entry的实现类:SimpleEntry与SimpleImmutableEntry,这两个类的实现非常简单,区别也只是前者是可变的,而后者是不可变的。
|
|
我们通过阅读上述的源码不难发现,AbstractMap实现的操作都依赖于entrySet()
所返回的集合视图。剩下的函数就没什么好说的了,有兴趣的话可以自己去看看。
TreeMap是基于红黑树(一种自平衡的二叉查找树)实现的一个保证有序性的Map,在继承关系结构图中可以得知TreeMap实现了NavigableMap接口,而该接口又继承了SortedMap接口,我们先来看看这两个接口定义了一些什么功能。
首先是SortedMap接口,实现该接口的实现类应当按照自然排序保证key的有序性,所谓自然排序即是根据key的compareTo()
函数(需要实现Comparable接口)或者在构造函数中传入的Comparator实现类来进行排序,集合视图遍历元素的顺序也应当与key的顺序一致。SortedMap接口还定义了以下几个有效利用有序性的函数:
|
|
然后是SortedMap的子接口NavigableMap,该接口扩展了一些用于导航(Navigation)的方法,像函数lowerEntry(key)
会根据传入的参数key返回一个小于key的最大的一对键值对,例如,我们如下调用lowerEntry(6)
,那么将返回key为5的键值对,如果没有key为5,则会返回key为4的键值对,以此类推,直到返回null(实在找不到的情况下)。
|
|
NavigableMap定义的都是一些类似于lowerEntry(key)
的方法和以逆序、升序排序的集合视图,这些方法利用有序性实现了相比SortedMap接口更加灵活的操作。
|
|
NavigableMap接口相对于SortedMap接口来说灵活了许多,正因为TreeMap也实现了该接口,所以在需要数据有序而且想灵活地访问它们的时候,使用TreeMap就非常合适了。
上文我们提到TreeMap的内部实现基于红黑树,而红黑树又是二叉查找树的一种。二叉查找树是一种有序的树形结构,优势在于查找、插入的时间复杂度只有O(log n)
,特性如下:
任意节点最多含有两个子节点。
任意节点的左、右节点都可以看做为一棵二叉查找树。
如果任意节点的左子树不为空,那么左子树上的所有节点的值均小于它的根节点的值。
如果任意节点的右子树不为空,那么右子树上的所有节点的值均大于它的根节点的值。
任意节点的key都是不同的。
尽管二叉查找树看起来很美好,但事与愿违,二叉查找树在极端情况下会变得并不是那么有效率,假设我们有一个有序的整数序列:1,2,3,4,5,6,7,8,9,10,...
,如果把这个序列按顺序全部插入到二叉查找树时会发生什么呢?二叉查找树会产生倾斜,序列中的每一个元素都大于它的根节点(前一个元素),左子树永远是空的,那么这棵二叉查找树就跟一个普通的链表没什么区别了,查找操作的时间复杂度只有O(n)
。
为了解决这个问题需要引入自平衡的二叉查找树,所谓自平衡,即是在树结构将要倾斜的情况下进行修正,这个修正操作被称为旋转,通过旋转操作可以让树趋于平衡。
红黑树是平衡二叉查找树的一种实现,它的名字来自于它的子节点是着色的,每个子节点非黑即红,由于只有两种颜色(两种状态),一般使用boolean来表示,下面为TreeMap中实现的Entry,它代表红黑树中的一个节点:
|
|
任何平衡二叉查找树的查找操作都是与二叉查找树是一样的,因为查找操作并不会影响树的结构,也就不需要进行修正,代码如下:
|
|
而插入和删除操作与平衡二叉查找树的细节是息息相关的,关于红黑树的实现细节,我之前写过的一篇博客红黑树的那点事儿已经讲的很清楚了,对这方面不了解的读者建议去阅读一下,就不在这里重复叙述了。
最后看一下TreeMap的集合视图的实现,集合视图一般都是实现了一个封装了当前实例的类,所以对集合视图的修改本质上就是在修改当前实例,TreeMap也不例外。
TreeMap的headMap()
、tailMap()
以及subMap()
函数都返回了一个静态内部类AscendingSubMap
|
|
一个局部视图最重要的是要能够判断出传入的key是否属于该视图的范围内,在上面的代码中可以发现NavigableSubMap提供了非常多的辅助函数用于判断范围,接下来我们看看NavigableSubMap的迭代器是如何实现的:
|
|
到目前为止,我们已经针对集合视图讨论了许多,想必大家也能够理解集合视图的概念了,由于SortedMap与NavigableMap的缘故,TreeMap中的集合视图是非常多的,包括各种局部视图和不同排序的视图,有兴趣的读者可以自己去看看源码,后面的内容不会再对集合视图进行过多的解释了。
光从名字上应该也能猜到,HashMap肯定是基于hash算法实现的,这种基于hash实现的map叫做散列表(hash table)。
散列表中维护了一个数组,数组的每一个元素被称为一个桶(bucket),当你传入一个key = "a"
进行查询时,散列表会先把key传入散列(hash)函数中进行寻址,得到的结果就是数组的下标,然后再通过这个下标访问数组即可得到相关联的值。
我们都知道数组中数据的组织方式是线性的,它会直接分配一串连续的内存地址序列,要找到一个元素只需要根据下标来计算地址的偏移量即可(查找一个元素的起始地址为:数组的起始地址加上下标乘以该元素类型占用的地址大小)。因此散列表在理想的情况下,各种操作的时间复杂度只有O(1)
,这甚至超过了二叉查找树,虽然理想的情况并不总是满足的,关于这点之后我们还会提及。
hash算法是一种可以从任何数据中提取出其“指纹”的数据摘要算法,它将任意大小的数据(输入)映射到一个固定大小的序列(输出)上,这个序列被称为hash code、数据摘要或者指纹。比较出名的hash算法有MD5、SHA。
hash是具有唯一性且不可逆的,唯一性指的是相同的输入产生的hash code永远是一样的,而不可逆也比较容易理解,数据摘要算法并不是压缩算法,它只是生成了一个该数据的摘要,没有将数据进行压缩。压缩算法一般都是使用一种更节省空间的编码规则将数据重新编码,解压缩只需要按着编码规则解码就是了,试想一下,一个几百MB甚至几GB的数据生成的hash code都只是一个拥有固定长度的序列,如果再能逆向解压缩,那么其他压缩算法该情何以堪?
我们上述讨论的仅仅是在密码学中的hash算法,而在散列表中所需要的散列函数是要能够将key寻址到buckets中的一个位置,散列函数的实现影响到整个散列表的性能。
一个完美的散列函数要能够做到均匀地将key分布到buckets中,每一个key分配到一个bucket,但这是不可能的。虽然hash算法具有唯一性,但同时它还具有重复性,唯一性保证了相同输入的输出是一致的,却没有保证不同输入的输出是不一致的,也就是说,完全有可能两个不同的key被分配到了同一个bucket(因为它们的hash code可能是相同的),这叫做碰撞冲突。总之,理想很丰满,现实很骨感,散列函数只能尽可能地减少冲突,没有办法完全消除冲突。
散列函数的实现方法非常多,一个优秀的散列函数要看它能不能将key分布均匀。首先介绍一种最简单的方法:除留余数法,先对key进行hash得到它的hash code,然后再用该hash code对buckets数组的元素数量取余,得到的结果就是bucket的下标,这种方法简单高效,也可以当做对集群进行负载均衡的路由算法。
|
|
要注意一点,只有整数才能进行取余运算,如果hash code是一个字符串或别的类型,那么你需要将它转换为整数才能使用除留余数法,不过Java在Object对象中提供了hashCode()
函数,该函数返回了一个int值,所以任何你想要放入HashMap的自定义的抽象数据类型,都必须实现该函数和equals()
函数,这两个函数之间也遵守着一种约定:如果a.equals(b) == true
,那么a与b的hashCode()
也必须是相同的。
下面为String类的hashCode()
函数,它先遍历了内部的字符数组,然后在每一次循环中计算hash code(将hash code乘以一个素数并加上当前循环项的字符):
|
|
HashMap没有采用这么简单的方法,有一个原因是HashMap中的buckets数组的长度永远为一个2的幂,而不是一个素数,如果长度为素数,那么可能会更适合简单暴力的除留余数法(当然除留余数法虽然简单却并不是那么高效的),顺便一提,时代的眼泪Hashtable就使用了除留余数法,它没有强制约束buckets数组的长度。
HashMap在内部实现了一个hash()
函数,首先要对hashCode()
的返回值进行处理:
|
|
该函数将key.hashCode()
的低16位和高16位做了个异或运算,其目的是为了扰乱低位的信息以实现减少碰撞冲突。之后还需要把hash()
的返回值与table.length - 1
做与运算(table
为buckets数组),得到的结果即是数组的下标。
table.length - 1
就像是一个低位掩码(这个设计也优化了扩容操作的性能),它和hash()
做与操作时必然会将高位屏蔽(因为一个HashMap不可能有特别大的buckets数组,至少在不断自动扩容之前是不可能的,所以table.length - 1
的大部分高位都为0),只保留低位,看似没什么毛病,但这其实暗藏玄机,它会导致总是只有最低的几位是有效的,这样就算你的hashCode()
实现得再好也难以避免发生碰撞。这时,hash()
函数的价值就体现出来了,它对hash code的低位添加了随机性并且混合了高位的部分特征,显著减少了碰撞冲突的发生(关于hash()
函数的效果如何,可以参考这篇文章An introduction to optimising a hashing strategy)。
HashMap的散列函数具体流程如下图:
在上文中我们已经多次提到碰撞冲突,但是散列函数不可能是完美的,key分布完全均匀的情况是不存在的,所以碰撞冲突总是难以避免。
那么发生碰撞冲突时怎么办?总不能丢弃数据吧?必须要有一种合理的方法来解决这个问题,HashMap使用了叫做分离链接(Separate chaining,也有人翻译成拉链法)的策略来解决冲突。它的主要思想是每个bucket都应当是一个互相独立的数据结构,当发生冲突时,只需要把数据放入bucket中(因为bucket本身也是一个可以存放数据的数据结构),这样查询一个key所消耗的时间为访问bucket所消耗的时间加上在bucket中查找的时间。
HashMap的buckets数组其实就是一个链表数组,在发生冲突时只需要把Entry(还记得Entry吗?HashMap的Entry实现就是一个简单的链表节点,它包含了key和value以及hash code)放到链表的尾部,如果未发生冲突(位于该下标的bucket为null),那么就把该Entry做为链表的头部。而且HashMap还使用了Lazy策略,buckets数组只会在第一次调用put()
函数时进行初始化,这是一种防止内存浪费的做法,像ArrayList也是Lazy的,它在第一次调用add()
时才会初始化内部的数组。
不过链表虽然实现简单,但是在查找的效率上只有O(n)
,而且我们大部分的操作都是在进行查找,在hashCode()
设计的不是非常良好的情况下,碰撞冲突可能会频繁发生,链表也会变得越来越长,这个效率是非常差的。Java 8对其实现了优化,链表的节点数量在到达阈值时会转化为红黑树,这样查找所需的时间就只有O(log n)
了,阈值的定义如下:
|
|
如果在插入Entry时发现一条链表超过阈值,就会执行以下的操作,对该链表进行树化;相对的,如果在删除Entry(或进行扩容)时发现红黑树的节点太少(根据阈值UNTREEIFY_THRESHOLD),也会把红黑树退化成链表。
|
|
解决碰撞冲突的另一种策略叫做开放寻址法(Open addressing),它与分离链接法的思想截然不同。在开放寻址法中,所有Entry都会存储在buckets数组,一个明显的区别是,分离链接法中的每个bucket都是一个链表或其他的数据结构,而开放寻址法中的每个bucket就仅仅只是Entry本身。
开放寻址法是基于数组中的空位来解决冲突的,它的想法很简单,与其使用链表等数据结构,不如直接在数组中留出空位来当做一个标记,反正都要占用额外的内存。
当你查找一个key的时候,首先会从起始位置(通过散列函数计算出的数组索引)开始,不断检查当前bucket是否为目标Entry(通过比较key来判断),如果当前bucket不是目标Entry,那么就向后查找(查找的间隔取决于实现),直到碰见一个空位(null),这代表你想要找的key不存在。
如果你想要put一个全新的Entry(Map中没有这个key存在),依然会从起始位置开始进行查找,如果起始位置不是空的,则代表发生了碰撞冲突,只好不断向后查找,直到发现一个空位。
开放寻址法的名字也是来源于此,一个Entry的位置并不是完全由hash值决定的,所以也叫做Closed hashing,相对的,分离链接法也被称为Open hashing或Closed addressing。
根据向后探测(查找)的算法不同,开放寻址法有多种不同的实现,我们介绍一种最简单的算法:线性探测法(Linear probing),在发生碰撞时,简单地将索引加一,如果到达了数组的尾部就折回到数组的头部,直到找到目标或一个空位。
基于线性探测法的查找操作如下:
|
|
插入操作稍微麻烦一些,需要在插入之前判断当前数组的剩余容量,然后决定是否扩容。数组的剩余容量越多,代表Entry之间的间隔越大以及越早碰见空位(向后探测的次数就越少),效率自然就会变高。代价就是额外消耗的内存较多,这也是在用空间换取时间。
|
|
接下来是删除操作,需要注意一点,我们不能简单地把目标key所在的位置(keys和vals数组)设置为null,这样会导致此位置之后的Entry无法被探测到,所以需要将目标右侧的所有Entry重新插入到散列表中:
|
|
散列表以数组的形式组织bucket,问题在于数组是静态分配的,为了保证查找的性能,需要在Entry数量大于一个临界值时进行扩容,否则就算散列函数的效果再好,也难免产生碰撞。
所谓扩容,其实就是用一个容量更大(在原容量上乘以二)的数组来替换掉当前的数组,这个过程需要把旧数组中的数据重新hash到新数组,所以扩容也能在一定程度上减缓碰撞。
HashMap通过负载因子(Load Factor)乘以buckets数组的长度来计算出临界值,算法:threshold = load_factor * capacity
。比如,HashMap的默认初始容量为16(capacity = 16
),默认负载因子为0.75(load_factor = 0.75
),那么临界值就为threshold = 0.75 * 16 = 12
,只要Entry的数量大于12,就会触发扩容操作。
还可以通过下列的构造函数来自定义负载因子,负载因子越小查找的性能就会越高,但同时额外占用的内存就会越多,如果没有特殊需要不建议修改默认值。
|
|
buckets数组的大小约束对于整个HashMap都至关重要,为了防止传入一个不是2次幂的整数,必须要有所防范。tableSizeFor()
函数会尝试修正一个整数,并转换为离该整数最近的2次幂。
|
|
还记得数组索引的计算方法吗?index = (table.length - 1) & hash
,这其实是一种优化手段,由于数组的大小永远是一个2次幂,在扩容之后,一个元素的新索引要么是在原位置,要么就是在原位置加上扩容前的容量。这个方法的巧妙之处全在于&运算,之前提到过&运算只会关注n - 1(n = 数组长度)的有效位,当扩容之后,n的有效位相比之前会多增加一位(n会变成之前的二倍,所以确保数组长度永远是2次幂很重要),然后只需要判断hash在新增的有效位的位置是0还是1就可以算出新的索引位置,如果是0,那么索引没有发生变化,如果是1,索引就为原索引加上扩容前的容量。
这样在每次扩容时都不用重新计算hash,省去了不少时间,而且新增有效位是0还是1是带有随机性的,之前两个碰撞的Entry又有可能在扩容时再次均匀地散布开。下面是resize()
的源码:
|
|
使用HashMap时还需要注意一点,它不会动态地进行缩容,也就是说,你不应该保留一个已经删除过大量Entry的HashMap(如果不打算继续添加元素的话),此时它的buckets数组经过多次扩容已经变得非常大了,这会占用非常多的无用内存,这样做的好处是不用多次对数组进行扩容或缩容操作。不过一般也不会出现这种情况,如果遇见了,请毫不犹豫地丢掉它,或者把数据转移到一个新的HashMap。
我们已经了解了HashMap的内部实现与工作原理,它在内部维护了一个数组,每一个key都会经过散列函数得出在数组的索引,如果两个key的索引相同,那么就使用分离链接法解决碰撞冲突,当Entry的数量大于临界值时,对数组进行扩容。
接下来以一个添加元素(put()
)的过程为例来梳理一下知识,下图是put()
函数的流程图:
然后是源码:
|
|
WeakHashMap是一个基于Map接口实现的散列表,实现细节与HashMap类似(都有负载因子、散列函数等等,但没有HashMap那么多优化手段),它的特殊之处在于每个key都是一个弱引用。
首先我们要明白什么是弱引用,Java将引用分为四类(从JDK1.2开始),强度依次逐渐减弱:
强引用: 就是平常使用的普通引用对象,例如Object obj = new Object()
,这就是一个强引用,强引用只要还存在,就不会被垃圾收集器回收。
软引用: 软引用表示一个还有用但并非必需的对象,不像强引用,它还需要通过SoftReference类来间接引用目标对象(除了强引用都是如此)。被软引用关联的对象,在将要发生内存溢出异常之前,会被放入回收范围之中以进行第二次回收(如果第二次回收之后依旧没有足够的内存,那么就会抛出OOM异常)。
弱引用: 同样是表示一个非必需的对象,但要比软引用的强度还要弱,需要通过WeakReference类来间接引用目标对象。被弱引用关联的对象只能存活到下一次垃圾回收发生之前,当触发垃圾回收时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象(如果这个对象还被强引用所引用,那么就不会被回收)。
虚引用: 这是一种最弱的引用关系,需要通过PhantomReference类来间接引用目标对象。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来获得对象实例。虚引用的唯一作用就是能在这个对象被回收时收到一个系统通知(结合ReferenceQueue使用)。基于这点可以通过虚引用来实现对象的析构函数,这比使用finalize()
函数是要靠谱多了。
WeakHashMap适合用来当做一个缓存来使用。假设你的缓存系统是基于强引用实现的,那么你就必须以手动(或者用一条线程来不断轮询)的方式来删除一个无效的缓存项,而基于弱引用实现的缓存项只要没被其他强引用对象关联,就会被直接放入回收队列。
需要注意的是,只有key是被弱引用关联的,而value一般都是一个强引用对象。因此,需要确保value没有关联到它的key,否则会对key的回收产生阻碍。在极端的情况下,一个value对象A引用了另一个key对象D,而与D相对应的value对象C又反过来引用了与A相对应的key对象B,这就会产生一个引用循环,导致D与B都无法被正常回收。想要解决这个问题,就只能把value也变成一个弱引用,例如m.put(key, new WeakReference(value))
,弱引用之间的互相引用不会产生影响。
查找操作的实现跟HashMap相比简单了许多,只要读懂了HashMap,基本都能看懂,源码如下:
|
|
尽管key是一个弱引用,但仍需手动地回收那些已经无效的Entry。这个操作会在getTable()
函数中执行,不管是查找、添加还是删除,都需要调用getTable()
来获得buckets数组,所以这是种防止内存泄漏的被动保护措施。
|
|
然后是插入操作与删除操作,实现都比较简单:
|
|
我们并没有在put()
函数中发现key被转换成弱引用,这是怎么回事?key只有在第一次被放入buckets数组时才需要转换成弱引用,也就是new Entry<>(k, value, queue, h, e)
,WeakHashMap的Entry实现其实就是WeakReference的子类。
|
|
有关使用WeakReference的一个典型案例是ThreadLocal,感兴趣的读者可以参考我之前写的博客聊一聊Spring中的线程安全性。
LinkedHashMap继承HashMap并实现了Map接口,同时具有可预测的迭代顺序(按照插入顺序排序)。它与HashMap的不同之处在于,维护了一条贯穿其全部Entry的双向链表(因为额外维护了链表的关系,性能上要略差于HashMap,不过集合视图的遍历时间与元素数量成正比,而HashMap是与buckets数组的长度成正比的),可以认为它是散列表与链表的结合。
|
|
LinkedHashMap的Entry实现也继承自HashMap,只不过多了指向前后的两个指针。
|
|
你也可以通过构造函数来构造一个迭代顺序为访问顺序(accessOrder设为true)的LinkedHashMap,这个访问顺序指的是按照最近被访问的Entry的顺序进行排序(从最近最少访问到最近最多访问)。基于这点可以简单实现一个采用LRU(Least Recently Used)策略的缓存。
|
|
LinkedHashMap复用了HashMap的大部分代码,所以它的查找实现是非常简单的,唯一稍微复杂点的操作是保证访问顺序。
|
|
还记得这些afterNodeXXXX命名格式的函数吗?我们之前已经在HashMap中见识过了,这些函数在HashMap中只是一个空实现,是专门用来让LinkedHashMap重写实现的hook函数。
|
|
注意removeEldestEntry()
默认永远返回false,这时它的行为与普通的Map无异。如果你把removeEldestEntry()
重写为永远返回true,那么就有可能使LinkedHashMap处于一个永远为空的状态(每次put()
或者putAll()
都会删除头节点)。
一个比较合理的实现示例:
|
|
LinkedHashMap重写了newNode()
等函数,以初始化或连接节点到它内部的双向链表:
|
|
遍历LinkedHashMap所需要的时间与Entry数量成正比,这是因为迭代器直接对双向链表进行迭代,而链表中只会含有Entry节点。迭代的顺序是从头节点开始一直到尾节点,插入操作会将新节点链接到尾部,所以保证了插入顺序,而访问顺序会通过afterNodeAccess()
来保证,访问次数越多的节点越接近尾部。
|
|
我们上述所讲的Map都是非线程安全的,这意味着不应该在多个线程中对这些Map进行修改操作,轻则会产生数据不一致的问题,甚至还会因为并发插入元素而导致链表成环(插入会触发扩容,而扩容操作需要将原数组中的元素rehash到新数组,这时并发操作就有可能产生链表的循环引用从而成环),这样在查找时就会发生死循环,影响到整个应用程序。
Collections.synchronizedMap(Map<K,V> m)
可以将一个Map转换成线程安全的实现,其实也就是通过一个包装类,然后把所有功能都委托给传入的Map实现,而且包装类是基于synchronized
关键字来保证线程安全的(时代的眼泪Hashtable也是基于synchronized
关键字),底层使用的是互斥锁(同一时间内只能由持有锁的线程访问,其他竞争线程进入睡眠状态),性能与吞吐量差强人意。
|
|
然而ConcurrentHashMap的实现细节远没有这么简单,因此性能也要高上许多。它没有使用一个全局锁来锁住自己,而是采用了减少锁粒度的方法,尽量减少因为竞争锁而导致的阻塞与冲突,而且ConcurrentHashMap的检索操作是不需要锁的。
在Java 7中,ConcurrentHashMap把内部细分成了若干个小的HashMap,称之为段(Segment),默认被分为16个段。对于一个写操作而言,会先根据hash code进行寻址,得出该Entry应被存放在哪一个Segment,然后只要对该Segment加锁即可。
理想情况下,一个默认的ConcurrentHashMap可以同时接受16个线程进行写操作(如果都是对不同Segment进行操作的话)。
分段锁对于size()
这样的全局操作来说就没有任何作用了,想要得出Entry的数量就需要遍历所有Segment,获得所有的锁,然后再统计总数。事实上,ConcurrentHashMap会先试图使用无锁的方式统计总数,这个尝试会进行3次,如果在相邻的2次计算中获得的Segment的modCount次数一致,代表这两次计算过程中都没有发生过修改操作,那么就可以当做最终结果返回,否则,就要获得所有Segment的锁,重新计算size。
本文主要讨论的是Java 8的ConcurrentHashMap,它与Java 7的实现差别较大。完全放弃了段的设计,而是变回与HashMap相似的设计,使用buckets数组与分离链接法(同样会在超过阈值时树化,对于构造红黑树的逻辑与HashMap差别不大,只不过需要额外使用CAS来保证线程安全),锁的粒度也被细分到每个数组元素(个人认为这样做的原因是因为HashMap在Java 8中也实现了不少优化,即使碰撞严重,也能保证一定的性能,而且Segment不仅臃肿还有弱一致性的问题存在),所以它的并发级别与数组长度相关(Java 7则是与段数相关)。
|
|
ConcurrentHashMap的散列函数与HashMap并没有什么区别,同样是把key的hash code的高16位与低16位进行异或运算(因为ConcurrentHashMap的buckets数组长度也永远是一个2的N次方),然后将扰乱后的hash code与数组的长度减一(实际可访问到的最大索引)进行与运算,得出的结果即是目标所在的位置。
|
|
下面是查找操作的源码,实现比较简单。
|
|
一个普通的节点(链表节点)的hash不可能小于0(已经在spread()
函数中修正过了),所以小于0的只可能是一个特殊节点,它不能用while循环中遍历链表的方式来进行遍历。
TreeBin是红黑树的头部节点(红黑树的节点为TreeNode),它本身不含有key与value,而是指向一个TreeNode节点的链表与它们的根节点,同时使用CAS(ConcurrentHashMap并不是完全基于互斥锁实现的,而是与CAS这种乐观策略搭配使用,以提高性能)实现了一个读写锁,迫使Writer(持有这个锁)在树重构操作之前等待Reader完成。
ForwardingNode是一个在数据转移过程(由扩容引起)中使用的临时节点,它会被插入到头部。它与TreeBin(和TreeNode)都是Node类的子类。
为了判断出哪些是特殊节点,TreeBin和ForwardingNode的hash域都只是一个虚拟值:
|
|
我们在get()
函数中并没有发现任何与锁相关的代码,那么它是怎么保证线程安全的呢?一个操作ConcurrentHashMap.get("a")
,它的步骤基本分为以下几步:
根据散列函数计算出的索引访问table。
从table中取出头节点。
遍历头节点直到找到目标节点。
从目标节点中取出value并返回。
所以只要保证访问table与节点的操作总是能够返回最新的数据就可以了。ConcurrentHashMap并没有采用锁的方式,而是通过volatile
关键字来保证它们的可见性。在上文贴出的代码中可以发现,table、Node.val和Node.next都是被volatile
关键字所修饰的。
volatile
关键字保证了多线程环境下变量的可见性与有序性,底层实现基于内存屏障(Memory Barrier)。
为了优化性能,现代CPU工作时的指令执行顺序与应用程序的代码顺序其实是不一致的(有些编译器也会进行这种优化),也就是所谓的乱序执行技术。乱序执行可以提高CPU流水线的工作效率,只要保证数据符合程序逻辑上的正确性即可(遵循happens-before
原则)。不过如今是多核时代,如果随便乱序而不提供防护措施那是会出问题的。每一个cpu上都会进行乱序优化,单cpu所保证的逻辑次序可能会被其他cpu所破坏。
内存屏障就是针对此情况的防护措施。可以认为它是一个同步点(但它本身也是一条cpu指令)。例如在IA32
指令集架构中引入的SFENCE
指令,在该指令之前的所有写操作必须全部完成,读操作仍可以乱序执行。LFENCE
指令则保证之前的所有读操作必须全部完成,另外还有粒度更粗的MFENCE
指令保证之前的所有读写操作都必须全部完成。
内存屏障就像是一个保护指令顺序的栅栏,保护后面的指令不被前面的指令跨越。将内存屏障插入到写操作与读操作之间,就可以保证之后的读操作可以访问到最新的数据,因为屏障前的写操作已经把数据写回到内存(根据缓存一致性协议,不会直接写回到内存,而是改变该cpu私有缓存中的状态,然后通知给其他cpu这个缓存行已经被修改过了,之后另一个cpu在读操作时就可以发现该缓存行已经是无效的了,这时它会从其他cpu中读取最新的缓存行,然后之前的cpu才会更改状态并写回到内存)。
例如,读一个被volatile
修饰的变量V总是能够从JMM(Java Memory Model)主内存中获得最新的数据。因为内存屏障的原因,每次在使用变量V(通过JVM指令use
,后面说的也都是JVM中的指令而不是cpu)之前都必须先执行load
指令(把从主内存中得到的数据放入到工作内存),根据JVM的规定,load
指令必须发生在read
指令(从主内存中读取数据)之后,所以每次访问变量V都会先从主内存中读取。相对的,写操作也因为内存屏障保证的指令顺序,每次都会直接写回到主内存。
不过volatile
关键字并不能保证操作的原子性,对该变量进行并发的连续操作是非线程安全的,所幸ConcurrentHashMap只是用来确保访问到的变量是最新的,所以也不会发生什么问题。
出于性能考虑,Doug Lea(java.util.concurrent
包的作者)直接通过Unsafe类来对table进行操作。
Java号称是安全的编程语言,而保证安全的代价就是牺牲程序员自由操控内存的能力。像在C/C++中可以通过操作指针变量达到操作内存的目的(其实操作的是虚拟地址),但这种灵活性在新手手中也经常会带来一些愚蠢的错误,比如内存访问越界。
Unsafe从字面意思可以看出是不安全的,它包含了许多本地方法(在JVM平台上运行的其他语言编写的程序,主要为C/C++,由JNI
实现),这些方法支持了对指针的操作,所以它才被称为是不安全的。虽然不安全,但毕竟是由C/C++实现的,像一些与操作系统交互的操作肯定是快过Java的,毕竟Java与操作系统之间还隔了一层抽象(JVM),不过代价就是失去了JVM所带来的多平台可移植性(本质上也只是一个c/cpp文件,如果换了平台那就要重新编译)。
对table进行操作的函数有以下三个,都使用到了Unsafe(在java.util.concurrent
包随处可见):
|
|
如果对Unsafe感兴趣,可以参考这篇文章:Java Magic. Part 4: sun.misc.Unsafe
ConcurrentHashMap与HashMap一样是Lazy的,buckets数组会在第一次访问put()
函数时进行初始化,它的默认构造函数甚至是个空函数。
|
|
但是有一点需要注意,ConcurrentHashMap是工作在多线程并发环境下的,如果有多个线程同时调用了put()
函数该怎么办?这会导致重复初始化,所以必须要有对应的防护措施。
ConcurrentHashMap声明了一个用于控制table的初始化与扩容的实例变量sizeCtl,默认值为0。当它是一个负数的时候,代表table正处于初始化或者扩容的状态。-1
表示table正在进行初始化,-N
则表示当前有N-1个线程正在进行扩容。
在其他情况下,如果table还未初始化(table == null
),sizeCtl表示table进行初始化的数组大小(所以从构造函数传入的initialCapacity在经过计算后会被赋给它)。如果table已经初始化过了,则表示下次触发扩容操作的阈值,算法stzeCtl = n - (n >>> 2)
,也就是n的75%,与默认负载因子(0.75)的HashMap一致。
|
|
初始化table的操作位于函数initTable()
,源码如下:
|
|
sizeCtl是一个volatile
变量,只要有一个线程CAS操作成功,sizeCtl就会被暂时地修改为-1,这样其他线程就能够根据sizeCtl得知table是否已经处于初始化状态中,最后sizeCtl会被设置成阈值,用于触发扩容操作。
ConcurrentHashMap触发扩容的时机与HashMap类似,要么是在将链表转换成红黑树时判断table数组的长度是否小于阈值(64),如果小于就进行扩容而不是树化,要么就是在添加元素的时候,判断当前Entry数量是否超过阈值,如果超过就进行扩容。
|
|
可以看到有关sizeCtl的操作牵涉到了大量的位运算,我们先来理解这些位运算的意义。首先是resizeStamp()
,该函数返回一个用于数据校验的标志位,意思是对长度为n的table进行扩容。它将n的前导零(最高有效位之前的零的数量)和1 << 15
做或运算,这时低16位的最高位为1,其他都为n的前导零。
|
|
初始化sizeCtl(扩容操作被第一个线程首次进行)的算法为(rs << RESIZE_STAMP_SHIFT) + 2
,首先RESIZE_STAMP_SHIFT = 32 - RESIZE_STAMP_BITS = 16
,那么rs << 16
等于将这个标志位移动到了高16位,这时最高位为1,所以sizeCtl此时是个负数,然后加二(至于为什么是2,还记得有关sizeCtl的说明吗?1代表初始化状态,所以实际的线程个数是要减去1的)代表当前有一个线程正在进行扩容,
这样sizeCtl就被分割成了两部分,高16位是一个对n的数据校验的标志位,低16位表示参与扩容操作的线程个数 + 1。
可能会有读者有所疑惑,更新进行扩容的线程数量的操作为什么是sc + 1
而不是sc - 1
,这是因为对sizeCtl的操作都是基于位运算的,所以不会关心它本身的数值是多少,只关心它在二进制上的数值,而sc + 1
会在低16位上加1。
tryPresize()
函数跟addCount()
的后半段逻辑类似,不断地根据sizeCtl判断当前的状态,然后选择对应的策略。
|
|
扩容操作的核心在于数据的转移,在单线程环境下数据的转移很简单,无非就是把旧数组中的数据迁移到新的数组。但是这在多线程环境下是行不通的,需要保证线程安全性,在扩容的时候其他线程也可能正在添加元素,这时又触发了扩容怎么办?有人可能会说,这不难啊,用一个互斥锁把数据转移操作的过程锁住不就好了?这确实是一种可行的解决方法,但同样也会带来极差的吞吐量。
互斥锁会导致所有访问临界区的线程陷入阻塞状态,这会消耗额外的系统资源,内核需要保存这些线程的上下文并放到阻塞队列,持有锁的线程耗时越长,其他竞争线程就会一直被阻塞,因此吞吐量低下,导致响应时间缓慢。而且锁总是会伴随着死锁问题,一旦发生死锁,整个应用程序都会因此受到影响,所以加锁永远是最后的备选方案。
Doug Lea没有选择直接加锁,而是基于CAS实现无锁的并发同步策略,令人佩服的是他不仅没有把其他线程拒之门外,甚至还邀请它们一起来协助工作。
那么如何才能让多个线程协同工作呢?Doug Lea把整个table数组当做多个线程之间共享的任务队列,然后只需维护一个指针,当有一个线程开始进行数据转移,就会先移动指针,表示指针划过的这片bucket区域由该线程负责。
这个指针被声明为一个volatile
整型变量,它的初始位置位于table的尾部,即它等于table.length
,很明显这个任务队列是逆向遍历的。
|
|
一个已经迁移完毕的bucket会被替换成ForwardingNode节点,用来标记此bucket已经被其他线程迁移完毕了。我们之前提到过ForwardingNode,它是一个特殊节点,可以通过hash域的虚拟值来识别它,它同样重写了find()
函数,用来在新数组中查找目标。
数据迁移的操作位于transfer()
函数,多个线程之间依靠sizeCtl与transferIndex指针来协同工作,每个线程都有自己负责的区域,一个完成迁移的bucket会被设置为ForwardingNode,其他线程遇见这个特殊节点就跳过该bucket,处理下一个bucket。
transfer()
函数可以大致分为三部分,第一部分对后续需要使用的变量进行初始化:
|
|
第二部分为当前线程分配任务和控制当前线程的任务进度,这部分是transfer()
的核心逻辑,描述了如何与其他线程协同工作:
|
|
最后一部分是具体的迁移过程(对当前指向的bucket),这部分的逻辑与HashMap类似,拿旧数组的容量当做一个掩码,然后与节点的hash进行与操作,可以得出该节点的新增有效位,如果新增有效位为0就放入一个链表A,如果为1就放入另一个链表B,链表A在新数组中的位置不变(跟在旧数组的索引一致),链表B在新数组中的位置为原索引加上旧数组容量。
这个方法减少了rehash的计算量,而且还能达到均匀分布的目的,如果不能理解请去看本文中HashMap扩容操作的解释。
|
|
在Java 7中ConcurrentHashMap对每个Segment单独计数,想要得到总数就需要获得所有Segment的锁,然后进行统计。由于Java 8抛弃了Segment,显然是不能再这样做了,而且这种方法虽然简单准确但也舍弃了性能。
Java 8声明了一个volatile
变量baseCount用于记录元素的个数,对这个变量的修改操作是基于CAS的,每当插入元素或删除元素时都会调用addCount()
函数进行计数。
|
|
counterCells是一个元素为CounterCell的数组,该数组的大小与当前机器的CPU数量有关,并且它不会被主动初始化,只有在调用fullAddCount()
函数时才会进行初始化。
CounterCell是一个简单的内部静态类,每个CounterCell都是一个用于记录数量的单元:
|
|
注解@sun.misc.Contended
用于解决伪共享问题。所谓伪共享,即是在同一缓存行(CPU缓存的基本单位)中存储了多个变量,当其中一个变量被修改时,就会影响到同一缓存行内的其他变量,导致它们也要跟着被标记为失效,其他变量的缓存命中率将会受到影响。解决伪共享问题的方法一般是对该变量填充一些无意义的占位数据,从而使它独享一个缓存行。
ConcurrentHashMap的计数设计与LongAdder类似。在一个低并发的情况下,就只是简单地使用CAS操作来对baseCount进行更新,但只要这个CAS操作失败一次,就代表有多个线程正在竞争,那么就转而使用CounterCell数组进行计数,数组内的每个ConuterCell都是一个独立的计数单元。
每个线程都会通过ThreadLocalRandom.getProbe() & m
寻址找到属于它的CounterCell,然后进行计数。ThreadLocalRandom是一个线程私有的伪随机数生成器,每个线程的probe都是不同的(这点基于ThreadLocalRandom的内部实现,它在内部维护了一个probeGenerator,这是一个类型为AtomicInteger的静态常量,每当初始化一个ThreadLocalRandom时probeGenerator都会先自增一个常量然后返回的整数即为当前线程的probe,probe变量被维护在Thread对象中),可以认为每个线程的probe就是它在CounterCell数组中的hash code。
这种方法将竞争数据按照线程的粒度进行分离,相比所有竞争线程对一个共享变量使用CAS不断尝试在性能上要效率多了,这也是为什么在高并发环境下LongAdder要优于AtomicInteger的原因。
fullAddCount()
函数根据当前线程的probe寻找对应的CounterCell进行计数,如果CounterCell数组未被初始化,则初始化CounterCell数组和CounterCell。该函数的实现与Striped64类(LongAdder的父类)的longAccumulate()
函数是一样的,把CounterCell数组当成一个散列表,每个线程的probe就是hash code,散列函数也仅仅是简单的(n - 1) & probe
。
CounterCell数组的大小永远是一个2的n次方,初始容量为2,每次扩容的新容量都是之前容量乘以二,处于性能考虑,它的最大容量上限是机器的CPU数量。
所以说CounterCell数组的碰撞冲突是很严重的,因为它的bucket基数太小了。而发生碰撞就代表着一个CounterCell会被多个线程竞争,为了解决这个问题,Doug Lea使用无限循环加上CAS来模拟出一个自旋锁来保证线程安全,自旋锁的实现基于一个被volatile
修饰的整数变量,该变量只会有两种状态:0和1,当它被设置为0时表示没有加锁,当它被设置为1时表示已被其他线程加锁。这个自旋锁用于保护初始化CounterCell、初始化CounterCell数组以及对CounterCell数组进行扩容时的安全。
CounterCell更新计数是依赖于CAS的,每次循环都会尝试通过CAS进行更新,如果成功就退出无限循环,否则就调用ThreadLocalRandom.advanceProbe()
函数为当前线程更新probe,然后重新开始循环,以期望下一次寻址到的CounterCell没有被其他线程竞争。
如果连着两次CAS更新都没有成功,那么会对CounterCell数组进行一次扩容,这个扩容操作只会在当前循环中触发一次,而且只能在容量小于上限时触发。
fullAddCount()
函数的主要流程如下:
首先检查当前线程有没有初始化过ThreadLocalRandom,如果没有则进行初始化。ThreadLocalRandom负责更新线程的probe,而probe又是在数组中进行寻址的关键。
检查CounterCell数组是否已经初始化,如果已初始化,那么就根据probe找到对应的CounterCell。
如果这个CounterCell等于null,需要先初始化CounterCell,通过把计数增量传入构造函数,所以初始化只要成功就说明更新计数已经完成了。初始化的过程需要获取自旋锁。
如果不为null,就按上文所说的逻辑对CounterCell实施更新计数。
CounterCell数组未被初始化,尝试获取自旋锁,进行初始化。数组初始化的过程会附带初始化一个CounterCell来记录计数增量,所以只要初始化成功就表示更新计数完成。
如果自旋锁被其他线程占用,无法进行数组的初始化,只好通过CAS更新baseCount。
|
|
对于统计总数,只要能够理解CounterCell的思想,就很简单了。仔细想一想,每次计数的更新都会被分摊在baseCount和CounterCell数组中的某一CounterCell,想要获得总数,把它们统计相加就是了。
|
|
其实size()
函数返回的总数可能并不是百分百精确的,试想如果前一个遍历过的CounterCell又进行了更新会怎么样?尽管只是一个估算值,但在大多数场景下都还能接受,而且性能上是要比Java 7好上太多了。
添加元素的主要逻辑与HashMap没什么区别,有所区别的复杂操作如扩容和计数我们上文都已经深入解析过了,所以整体来说putVal()
函数还是比较简单的,可能唯一需要注意的就是在对节点进行操作的时候需要通过互斥锁保证线程安全,这个互斥锁的粒度很小,只对需要操作的这个bucket加锁。
|
|
至于删除元素的操作位于函数replaceNode(Object key, V value, Object cv)
,当table[key].val
等于期望值cv时(或cv等于null),更新节点的值为value,如果value等于null,那么删除该节点。
remove()
函数通过调用replaceNode(key, null, null)
来达成删除目标节点的目的,replaceNode()
的具体实现与putVal()
没什么差别,只不过对链表的操作有所不同而已,所以就不多叙述了。
Java 8除了对ConcurrentHashMap重新设计以外,还引入了基于Lambda表达式的Stream API。它是对集合对象功能上的增强(所以不止ConcurrentHashMap,其他集合也都实现了该API),以一种优雅的方式来批量操作、聚合或遍历集合中的数据。
最重要的是,它还提供了并行模式,充分利用了多核CPU的优势实现并行计算。让我们看看如下的示例代码:
|
|
这段代码通过两个线程(包括主线程)并行地遍历map中的元素,然后输出到控制台,输出如下:
|
|
很明显,有两个线程在进行工作,那么这是怎么实现的呢?我们先来看看forEach()
函数:
|
|
parallelismThreshold
是需要并行执行该操作的线程数量,action
则是回调函数(我们想要执行的操作)。action
的类型为BiConsumer,是一个用于支持Lambda表达式的FunctionalInterface,它接受两个输入参数并返回0个结果。
|
|
看来实现并行计算的关键在于ForEachMappingTask对象,通过它的继承关系结构图可以发现,ForEachMappingTask其实就是ForkJoinTask。
集合的并行计算是基于Fork/Join框架实现的,工作线程交由ForkJoinPool线程池维护。它推崇分而治之的思想,将一个大的任务分解成多个小的任务,通过fork()
函数(有点像Linux的fork()
系统调用来创建子进程)来开启一个工作线程执行其中一个小任务,通过join()
函数等待工作线程执行完毕(需要等所有工作线程执行完毕才能合并最终结果),只要所有的小任务都已经处理完成,就代表这个大的任务也完成了。
像上文中的示例代码就是将遍历这个大任务分解成了N个小任务,然后交由两个工作线程进行处理。
|
|
其他并行计算函数的实现也都差不多,只不过具体的Task实现不同,例如search()
:
|
|
为了节省篇幅(说实话现在似乎很少有人能耐心看完一篇长文(:з」∠)),有关Stream API是如何使用Fork/Join框架进行工作以及实现细节就不多讲了,以后有机会再说吧。
内置了嵌入式的Tomcat、Jetty等Servlet容器,应用可以不用打包成War格式,而是可以直接以Jar格式运行。
提供了多个可选择的”starter”以简化Maven的依赖管理(也支持Gradle),让您可以按需加载需要的功能模块。
尽可能地进行自动配置,减少了用户需要动手写的各种冗余配置项,Spring Boot提倡无XML配置文件的理念,使用Spring Boot生成的应用完全不会生成任何配置代码与XML配置文件。
提供了一整套的对应用状态的监控与管理的功能模块(通过引入spring-boot-starter-actuator),包括应用的线程信息、内存信息、应用是否处于健康状态等,为了满足更多的资源监控需求,Spring Cloud中的很多模块还对其进行了扩展。
有关Spring Boot的使用方法就不做多介绍了,如有兴趣请自行阅读官方文档Spring Boot或其他文章。
如今微服务的概念愈来愈热,转型或尝试微服务的团队也在如日渐增,而对于技术选型,Spring Cloud是一个比较好的选择,它提供了一站式的分布式系统解决方案,包含了许多构建分布式系统与微服务需要用到的组件,例如服务治理、API网关、配置中心、消息总线以及容错管理等模块。可以说,Spring Cloud”全家桶”极其适合刚刚接触微服务的团队。似乎有点跑题了,不过说了这么多,我想要强调的是,Spring Cloud中的每个组件都是基于Spring Boot构建的,而理解了Spring Boot的自动配置的原理,显然也是有好处的。
Spring Boot的自动配置看起来神奇,其实原理非常简单,背后全依赖于@Conditional注解来实现的。
本文作者为SylvanasSun(sylvanas.sun@gmail.com),首发于SylvanasSun’s Blog。
原文链接:https://sylvanassun.github.io/2018/01/08/2018-01-08-spring_boot_auto_configure/
(转载请务必保留本段声明,并且保留超链接。)
@Conditional是由Spring 4提供的一个新特性,用于根据特定条件来控制Bean的创建行为。而在我们开发基于Spring的应用的时候,难免会需要根据条件来注册Bean。
例如,你想要根据不同的运行环境,来让Spring注册对应环境的数据源Bean,对于这种简单的情况,完全可以使用@Profile注解实现,就像下面代码所示:
|
|
剩下只需要设置对应的Profile属性即可,设置方法有如下三种:
通过context.getEnvironment().setActiveProfiles("PROD")
来设置Profile属性。
通过设定jvm的spring.profiles.active
参数来设置环境(Spring Boot中可以直接在application.properties
配置文件中设置该属性)。
通过在DispatcherServlet的初始参数中设置。
|
|
但这种方法只局限于简单的情况,而且通过源码我们可以发现@Profile自身也使用了@Conditional注解。
|
|
在业务复杂的情况下,显然需要使用到@Conditional注解来提供更加灵活的条件判断,例如以下几个判断条件:
在类路径中是否存在这样的一个类。
在Spring容器中是否已经注册了某种类型的Bean(如未注册,我们可以让其自动注册到容器中,上一条同理)。
一个文件是否在特定的位置上。
一个特定的系统属性是否存在。
在Spring的配置文件中是否设置了某个特定的值。
举个栗子,假设我们有两个基于不同数据库实现的DAO,它们全都实现了UserDao,其中JdbcUserDAO与MySql进行连接,MongoUserDAO与MongoDB进行连接。现在,我们有了一个需求,需要根据命令行传入的系统参数来注册对应的UserDao,就像java -jar app.jar -DdbType=MySQL
会注册JdbcUserDao,而java -jar app.jar -DdbType=MongoDB
则会注册MongoUserDao。使用@Conditional可以很轻松地实现这个功能,仅仅需要在你自定义的条件类中去实现Condition接口,让我们来看下面的代码。(以下案例来自:https://dzone.com/articles/how-springboot-autoconfiguration-magic-works)
|
|
现在,我们又有了一个新需求,我们想要根据当前工程的类路径中是否存在MongoDB的驱动类来确认是否注册MongoUserDAO。为了实现这个需求,可以创建检查MongoDB驱动是否存在的两个条件类。
|
|
假如,你想要在UserDAO没有被注册的情况下去注册一个UserDAOBean,那么我们可以定义一个条件类来检查某个类是否在容器中已被注册。
|
|
如果你想根据配置文件中的某项属性来决定是否注册MongoDAO,例如app.dbType
是否等于MongoDB
,我们可以实现以下的条件类。
|
|
我们已经尝试并实现了各种类型的条件判断,接下来,我们可以选择一种更为优雅的方式,就像@Profile一样,以注解的方式来完成条件判断。首先,我们需要定义一个注解类。
|
|
具体的条件判断逻辑在DatabaseTypeCondition类中,它会根据系统参数dbType
来判断注册哪一个Bean。
|
|
最后,在配置类应用该注解即可。
|
|
通过了解@Conditional注解的机制其实已经能够猜到自动配置是如何实现的了,接下来我们通过源码来看看它是怎么做的。本文中讲解的源码基于Spring Boot 1.5.9版本(最新的正式版本)。
使用过Spring Boot的童鞋应该都很清楚,它会替我们生成一个入口类,其命名规格为ArtifactNameApplication
,通过这个入口类,我们可以发现一些信息。
|
|
首先该类被@SpringBootApplication注解修饰,我们可以先从它开始分析,查看源码后可以发现它是一个包含许多注解的组合注解。
|
|
该注解相当于同时声明了@Configuration、@EnableAutoConfiguration与@ComponentScan三个注解(如果我们想定制自定义的自动配置实现,声明这三个注解就足够了),而@EnableAutoConfiguration是我们的关注点,从它的名字可以看出来,它是用来开启自动配置的,源码如下:
|
|
我们发现@Import(Spring 提供的一个注解,可以导入配置类或者Bean到当前类中)导入了EnableAutoConfigurationImportSelector类,根据名字来看,它应该就是我们要找到的目标了。不过查看它的源码发现它已经被Deprecated了,而官方API中告知我们去查看它的父类AutoConfigurationImportSelector。
|
|
由于AutoConfigurationImportSelector的源码太长了,这里我只截出关键的地方,显然方法selectImports是选择自动配置的主入口,它调用了其他的几个方法来加载元数据等信息,最后返回一个包含许多自动配置类信息的字符串数组。
|
|
重点在于方法getCandidateConfigurations()返回了自动配置类的信息列表,而它通过调用SpringFactoriesLoader.loadFactoryNames()来扫描加载含有META-INF/spring.factories文件的jar包,该文件记录了具有哪些自动配置类。(建议还是用IDE去看源码吧,这些源码单行实在太长了,估计文章中的观看效果很差)
|
|
接下来,我们在spring.factories文件中随便找一个自动配置类,来看看是怎样实现的。我查看了MongoDataAutoConfiguration的源码,发现它声明了@ConditionalOnClass注解,通过看该注解的源码后可以发现,这是一个组合了@Conditional的组合注解,它的条件类是OnClassCondition。
|
|
然后,我们开始看OnClassCondition的源码,发现它并没有直接实现Condition接口,只好往上找,发现它的父类SpringBootCondition实现了Condition接口。
|
|
SpringBootCondition实现的matches方法依赖于一个抽象方法this.getMatchOutcome(context, metadata),我们在它的子类OnClassCondition中可以找到这个方法的具体实现。
|
|
关于match的具体实现在MatchType中,它是一个枚举类,提供了PRESENT和MISSING两种实现,前者返回类路径中是否存在该类,后者相反。
|
|
现在终于真相大白,@ConditionalOnClass的含义是指定的类必须存在于类路径下,MongoDataAutoConfiguration类中声明了类路径下必须含有Mongo.class, MongoTemplate.class这两个类,否则该自动配置类不会被加载。
在Spring Boot中到处都有类似的注解,像@ConditionalOnBean(容器中是否有指定的Bean),@ConditionalOnWebApplication(当前工程是否为一个Web工程)等等,它们都只是@Conditional注解的扩展。当你揭开神秘的面纱,去探索本质时,发现其实Spring Boot自动配置的原理就是如此简单,在了解这些知识后,你完全可以自己去实现自定义的自动配置类,然后编写出自定义的starter。
]]>Skip List(跳跃表)是一种支持快速查找的数据结构,插入、查找和删除操作都仅仅只需要O(log n)
对数级别的时间复杂度,它的效率甚至可以与红黑树等二叉平衡树相提并论,而且实现的难度要比红黑树简单多了。
Skip List主要思想是将链表与二分查找相结合,它维护了一个多层级的链表结构(用空间换取时间),可以把Skip List看作一个含有多个行的链表集合,每一行就是一条链表,这样的一行链表被称为一层,每一层都是下一层的”快速通道”,即如果x层和y层都含有元素a,那么x层的a会与y层的a相互连接(垂直)。最底层的链表是含有所有节点的普通序列,而越接近顶层的链表,含有的节点则越少。
对一个目标元素的搜索会从顶层链表的头部元素开始,然后遍历该链表,直到找到元素大于或等于目标元素的节点,如果当前元素正好等于目标,那么就直接返回它。如果当前元素小于目标元素,那么就垂直下降到下一层继续搜索,如果当前元素大于目标或到达链表尾部,则移动到前一个节点的位置,然后垂直下降到下一层。正因为Skip List的搜索过程会不断地从一层跳跃到下一层的,所以被称为跳跃表。
Skip List还有一个明显的特征,即它是一个不准确的概率性结构,这是因为Skip List在决定是否将节点冗余复制到上一层的时候(而在到达或超过顶层时,需要构建新的顶层)依赖于一个概率函数,举个栗子,我们使用一个最简单的概率函数:丢硬币,即概率P
为0.5
,那么依赖于该概率函数实现的Skip List会不断地”丢硬币”,如果硬币为正面就将节点复制到上一层,直到硬币为反。
理解Skip List的原理并不困难,下面我们将使用Java来动手实现一个支持基本需求(查找,插入和删除)的Skip List。
本文作者为SylvanasSun(sylvanas.sun@gmail.com),首发于SylvanasSun’s Blog。
原文链接:https://sylvanassun.github.io/2017/12/31/2017-12-31-skip_list/
(转载请务必保留本段声明,并且保留超链接。)
对于一个普通的链表节点一般只含有一个指向后续节点的指针(双向链表的节点含有两个指针,一个指向前节点,一个指向后节点),由于Skip List是一个多层级的链表结构,我们的设计要让节点拥有四个指针,分别对应该节点的前后左右,为了方便地将头链表永远置于顶层,还需要设置一个int属性表示该链表所处的层级。
|
|
接下来是SkipList的基本实现,为了能够让Key进行比较,我们规定Key的类型必须实现了Comparable接口,同时为了支持ForEach循环,该类还实现了Iterable接口。
|
|
我们还需要定义几个辅助方法,如下所示(都很简单):
|
|
查找一个节点的过程如下:
从顶层链表的头部开始进行遍历,比较每一个节点的元素与目标元素的大小。
如果当前元素小于目标元素,则继续遍历。
如果当前元素等于目标元素,返回该节点。
如果当前元素大于目标元素,移动到前一个节点(必须小于等于目标元素),然后跳跃到下一层继续遍历。
如果遍历至链表尾部,跳跃到下一层继续遍历。
|
|
插入操作的过程要稍微复杂些,主要在于复制节点到上一层与构建新层的操作上。
|
|
对于删除一个节点,需要先找到节点所在的位置(位于最底层链表中的位置),之后再自底向上地删除该节点在每一行中的冗余复制。
|
|
由于我们的SkipList实现了Iterable接口,所以还需要实现一个迭代器。对于迭代一个Skip List,只需要找到最底层的链表并且移动到它的首节点,然后进行遍历即可。
|
|
朴素贝叶斯假设了样本的每个特征之间是互相独立、互不影响的,比方说,如果有一个水果是红色的,形状为圆形,并且直径大约为70毫米,那么它就有可能被认为是苹果(具有最高概率的类将会被认为是最有可能的类,这被称为最大后验概率 Maximum A Posteriori),即使上述的这些特征可能会有依赖关系或有其他特征存在,朴素贝叶斯都会认为这些特征都独立地贡献了这个水果是一个苹果的概率,这种假设关系太过于理想,所以这也是朴素贝叶斯的”Naive”之处。
朴素贝叶斯的原名为Naive Bayes Classifier,朴素本身并不是一个正确的翻译,之所以这样翻译是因为朴素贝叶斯虽然Naive,但不代表它的效率会差,相反它的优点正在于实现简单与只需要少量的训练数据,还有另一个原因是它与贝叶斯网络等算法相比,确实是“朴素”了些。
在继续探讨朴素贝叶斯之前,我们先需要理解贝叶斯定理与它的前置理论条件概率与全概率公式。
本文作者为SylvanasSun(sylvanas.sun@gmail.com),首发于SylvanasSun’s Blog。
原文链接:https://sylvanassun.github.io/2017/12/20/2017-12-20-naive_bayes/
(转载请务必保留本段声明,并且保留超链接。)
条件概率(Conditional Probability)是指在事件B发生的情况下,事件A发生的概率,用$P(A|B)$表示,读作在B条件下的A的概率。
在上方的文氏图中,描述了两个事件A和B,与它们的交集A ∩ B
,代入条件概率公式,可推出事件A发生的概率为$P(A|B) = \frac{P({A}\bigcap{B})}{P(B)}$。
对该公式稍作变换可推得${P({A}\bigcap{B})} = {P(A|B)}{P(B)}$与${P({A}\bigcap{B})} = {P(B|A)}{P(A)}$(P(B|A)
为在A条件下的B的概率)。
然后根据这个关系可推得${P(A|B)}{P(B)} = {P(B|A)}{P(A)}$。
让我们举个栗子,假设有两个人在扔两个个六面的骰子D1
与D2
,我们来预测D1
与D2
的向上面的结果的概率。
在Table1
中描述了一个含有36个结果的样本空间,标红处为D1
的向上面为2的6个结果,概率为$P(D1=2) = \frac{6}{36} = \frac{1}{6}$。
Table2
描述了D1 + D2 <= 5
的概率,一共10个结果,用条件概率公式表示为${P(D1+D2\leq5)} = \frac{10}{36}$。
Table3
描述了满足Table2
的条件同时也满足D1 = 2
的结果,它选中了Table2
中的3个结果,用条件概率公式表示为${P(D1=2 | D1+D2\leq5)} = \frac{3}{10} = 0.3$。
全概率公式是将边缘概率与条件概率关联起来的基本规则,它表示了一个结果的总概率,可以通过几个不同的事件来实现。
全概率公式将对一复杂事件的概率求解问题转化为了在不同情况下发生的简单事件的概率的求和问题,公式为$P(B) = {\sum_{i=1}^n}P(A_i)P(B|A_i)$。
假定一个样本空间S,它是两个事件A与C之和,同时事件B与它们两个都有交集,如下图所示:
那么事件B的概率可以表示为$P(B) = P({B}\bigcap{A}) + P({B}\bigcap{C})$
通过条件概率,可以推断出$P({B}\bigcap{A}) = P(B|A)P(A)$,所以$P(B) = P(B|A)P(A) + P(B|C)P(C)$
这就是全概率公式,即事件B的概率等于事件A与事件C的概率分别乘以B对这两个事件的条件概率之和。
同样举个栗子来应用这个公式,假设有两家工厂生产并对外提供电灯泡,工厂X生产的电灯泡在99%的情况下能够工作超过5000小时,工厂Y生产的电灯泡在95%的情况下能够工作超过5000小时。工厂X在市场的占有率为60%,工厂Y为40%,如何推测出购买的灯泡的工作时间超过5000小时的概率是多少呢?
运用全概率公式,可以得出:
$$
\begin{equation}\begin{split}
Pr(A) &=Pr(A | B_x) \cdot Pr(B_x) + Pr(A|B_y) \cdot Pr(B_y)\
&= \frac{99}{100} \cdot \frac{6}{10} + \frac{95}{100} \cdot \frac{4}{10}\
&= \frac{594 + 380}{1000}\
&= \frac{974}{1000}
\end{split}\end{equation}
$$
$Pr(B_x) = \frac{6}{10}$:购买到工厂X制造的电灯泡的概率。
$Pr(B_y) = \frac{4}{10}$:购买到工厂y制造的电灯泡的概率。
$Pr(A|B_x) = \frac{99}{100}$:工厂x制造的电灯泡工作时间超过5000小时的概率。
$Pr(A|B_y) = \frac{95}{100}$:工厂y制造的电灯泡工作时间超过5000小时的概率。
因此,可以得知购买一个工作时间超过5000小时的电灯泡的概率为97.4%。
贝叶斯定理最早由英国数学家(同时也是神学家和哲学家)Thomas Bayes(1701-1761)提出,有趣的是他生前并没有发表过什么有关数学的学术文章,就连他最著名的成就贝叶斯定理也是由他的朋友Richard Price从他死后的遗物(笔记)中找到并发表的。
Thomas Bayes在晚年对概率学产生了兴趣,所谓的贝叶斯定理只是他生前为了解决一个逆概率问题(为了证明上帝是否存在,似乎哲学家们都很喜欢这个问题啊)所写的一篇文章。在那个时期,人们已经能够计算出正向概率问题,比方说,有一个袋子中有X个白球,Y个黑球,你伸手进去摸到黑球的概率是多少?这就是一个正向概率问题,而逆概率问题正好反过来,我们事先并不知道袋子中球的比例,而是不断伸手去摸好几个球,然后根据它们的颜色来推测黑球与白球的比例。
贝叶斯定理是关于随机事件A和B的条件概率的一则定理。通常,事件A在事件B(发生)的条件下的概率,与事件B在事件A(发生)的条件下的概率是不一样的,但它们两者之间是有确定的关系的,贝叶斯定理陈述了这个关系。
贝叶斯定理的一个主要应用为贝叶斯推理,它是一种建立在主观判断基础之上的推理方法,也就是说,你只需要先预估一个值,然后再去根据实际结果去不断修正,不需要任何客观因素。这种推理方式需要大量的计算,因此一直遭到其他人的诟病,无法得到广泛的应用,直到计算机的高速发展,并且人们发现很多事情都是无法事先进行客观判断的,因此贝叶斯推理才得以东山再起。
说了这么多理论知识(很多数学理论都像是在说绕口令),让我们来看一看公式吧,其实只需要把我们在上面推导出的条件概率公式继续进行推理,就可以得出贝叶斯公式。
$$P(A|B) = \frac{P(B|A)P(A)}{P(B)}$$
$P(A|B)$:在B条件下的事件A的概率,在贝叶斯定理中,条件概率也被称为后验概率,即在事件B发生之后,我们对事件A概率的重新评估。
$P(B|A)$:在A条件下的事件B的概率,与上一条同理。
$P(A)$与$P(B)$被称为先验概率(也被称为边缘概率),即在事件B发生之前,我们对事件A概率的一个推断(不考虑任何事件B方面的因素),后面同理。
$\frac{P(B|A)}{P(B)}$被称为标准相似度,它是一个调整因子,主要是为了保证预测概率更接近真实概率。
根据这些术语,贝叶斯定理表述为: 后验概率 = 标准相似度 * 先验概率。
让我们以著名的假阳性问题为例,假设某种疾病的发病率为0.001(1000个人中会有一个人得病),现有一种试剂在患者确实得病的情况下,有99%的几率呈现为阳性,而在患者没有得病的情况下,它有5%的几率呈现为阳性(也就是假阳性),如有一位病人的检验成果为阳性,那么他的得病概率是多少呢?
代入贝叶斯定理,假定事件A表示为得病的概率(P(A) = 0.001
),这是我们的先验概率,它是在病人在实际注射试剂(缺乏实验的结果)之前预计的发病率,再假定事件B为试剂结果为阳性的概率,我们需要计算的是条件概率P(A|B)
,即在事件B条件下的A概率,这就是后验概率,也就是病人在注射试剂之后(得到实验结果)得出的发病率。
由于还有未得病的概率,所以还需要假设事件C为未得病的先验概率(P(C) = 1 - 0.001 = 0.999
),那么P(B|C)
后验概率表示的是未得病条件下的试剂结果为阳性的概率,之后再代入全概率公式就可得出最终结果。
$$
\begin{equation}\begin{split}
P(A|B)&=\frac{P(B|A)P(A)}{P(B)}\
&= \frac{P(B|A)P(A)}{P(B|A)P(A) + P(B|C)P(C)}\
&= \frac{0.99 \times 0.001}{0.99 \times 0.001 + 0.05 \times 0.999}\approx 0.019
\end{split}\end{equation}
$$
最终结果约等于2%,即使一个病人的试剂结果为阳性,他的患病几率也只有2%而已。
我们设一个待分类项$X = {f_1,f_2,\cdots,f_n}$,其中每个f
为X
的一个特征属性,然后设一个类别集合$C_1,C_2,\cdots,C_m$。
然后需要计算$P(C_1|X),P(C_2|X),\cdots,P(C_m|X)$,我们可以根据一个训练样本集合(已知分类的待分类项集合),然后统计得到在各类别下各个特征属性的条件概率:
$P(f_1|C_1),P(f_2|C_1),\cdots,P(f_n|C_1),\cdots,P(f_1|C_2),P(f_2|C_2),\cdots,P(f_n|C_2),\cdots,P(f_1|C_m),P(f_2|C_m),\cdots,P(f_n|C_m)$
如果$P(C_k|X) = MAX(P(C_1|X),P(C_2|X),\cdots,P(C_m|X))$,则${X}\in{C_k}$(贝叶斯分类其实就是取概率最大的那一个)。
朴素贝叶斯会假设每个特征都是独立的,根据贝叶斯定理可推得:$P(C_i|X) = \frac{P(X|C_i)P(C_i)}{P(X)}$,由于分母对于所有类别为常数,因此只需要将分子最大化即可,又因为各特征是互相独立的,所以最终推得:
根据上述的公式推导,朴素贝叶斯的流程可如下图所示:
接下来我们通过一个案例来过一遍上图的流程。
现有一网站想要通过程序自动识别出账号的真实性(将账号分类为真实账号与不真实账号,所谓不真实账号即带有虚假信息或恶意注册的小号)。
首先需要确定特征属性和类别,然后获取训练样本。假设一个账号具有三个特征:日志数量/注册天数(F1
)、好友数量/注册天数(F2
)、是否使用了真实的头像(True为1,False为0)。
该网站使用曾经人工检测过的10000个账号作为训练样本,那么计算每个类别的概率为$P(C_0) = 8900 \div 10000 = 0.89, P(C_1) = 1100 \div 10000 = 0.11$,C0
为真实账号的类别概率也就是89%,C1
为虚假账号的类别概率也就是11%。
之后需要计算每个类别下的各个特征的条件概率,代入朴素贝叶斯分类器,可得$P(F_1|C)P(F_2|C)P(F_3|C)P(C)$,不过有一个问题是,F1
与F2
是连续变量,不适宜按照某个特定值计算概率。解决方法为将连续值转化为离散值,然后计算区间的概率,比如将F1
分解为[0,0.05]、[0.05,0.2]、[0.2,+∞]
三个区间,然后计算每个区间的概率即可。
已知某一账号的数据如下:$F_1 = 0.1,F_2 = 0.2,F_3 = 0$,推测该账号是真实账号还是虚假账号。在此例中,F1
为0.1,落在第二个区间内,所以在计算的时候,就使用第二个区间的发生概率。根据训练样本可得出结果为:
$$
\begin{equation}\begin{split}
P(F_1|C_0) = 0.5, P(F_1|C_1) = 0.1\
P(F_2|C_0) = 0.7, P(F_2|C_1) = 0.2\
P(F_3|C_0) = 0.2, P(F_3|C_1) = 0.9
\end{split}\end{equation}
$$
$$
\begin{equation}\begin{split}
P(F_1|C_0)P(F_2|C_0)P(F_3|C_0)P(C_0) &= 0.5 \times 0.7 \times 0.2 \times 0.89\
&= 0.0623
\end{split}\end{equation}
$$
$$
\begin{equation}\begin{split}
P(F_1|C_1)P(F_2|C_1)P(F_3|C_1)P(C_1) &= 0.1 \times 0.2 \times 0.9 \times 0.11\
&= 0.00198
\end{split}\end{equation}
$$
最终结果为该账号是一个真实账号。
在朴素贝叶斯中含有以下三种算法模型:
Gaussian Naive Bayes:适合在特征变量具有连续性的时候使用,同时它还假设特征遵从于高斯分布(正态分布)。举个栗子,假设我们有一组人体特征的统计资料,该数据中的特征:身高、体重和脚掌长度等都为连续变量,很明显我们不能采用离散变量的方法来计算概率,由于样本太少,也无法分成区间计算,那么要怎么办呢?解决方法是假设特征项都是正态分布,然后通过样本计算出均值与标准差,这样就得到了正态分布的密度函数,有了密度函数,就可以代入值,进而算出某一点的密度函数的值。
MultiNomial Naive Bayes:与Gaussian Naive Bayes相反,多项式模型更适合处理特征是离散变量的情况,该模型会在计算先验概率$P(C_m)$和条件概率$P(F_n|Cm)$时会做一些平滑处理。具体公式为,其中T
为总的样本数,m
为总类别数,$T{cm}$即类别为$C_m$的样本个数,a
是一个平滑值。条件概率的公式为,n
为特征的个数,T_cmfn
为类别为C_m
特征为F_n
的样本个数。当平滑值a = 1
与0 < a < 1
时,被称作为Laplace
平滑,当a = 0
时不做平滑。它的思想其实就是对每类别下所有划分的计数加1,这样如果训练样本数量足够大时,就不会对结果产生影响,并且解决了$P(F|C)$的频率为0的现象(某个类别下的某个特征划分没有出现,这会严重影响分类器的质量)。
Bernoulli Naive Bayes:Bernoulli适用于在特征属性为二进制的场景下,它对每个特征的取值是基于布尔值的,一个典型例子就是判断单词有没有在文本中出现。
了解了足够多的理论,接下来我们要动手使用python来实现一个Gaussian Naive Bayes,目的是解决皮马人(一个印第安人部落)的糖尿病问题,样本数据(请从该超链接中获取)是一个csv格式的文件,每个值都是一个数字,该文件描述了从患者的年龄、怀孕次数和验血结果等方面的即时测量数据。每个记录都有一个类别值(一个布尔值,以0或1表示),该值表述了患者是否在五年内得过糖尿病。这是一个在机器学习文献中被大量研究过的数据集,一个比较好的预测精度应该在70%~76%。样本数据的每列含义如下:
|
|
首先要做的是读取这个csv文件,并解析成我们可以直接使用的数据结构。由于样本数据文件中没有任何的空行和标记符号,每行都是对应的一行数据,只需要简单地把每一行封装到一个list中即可(返回结果为一个list,它的每一项元素都是包含一行数据的list),注意该文件中的数据都为数字,需要先做类型转换。
|
|
获得了样本数据后,为了评估模型的准确性还需要将它切分为训练数据集(朴素贝叶斯需要使用它来进行预测)与测试数据集。数据在切分过程中是随机选取的,但我们会选择一个比率来控制训练数据集与测试数据集的大小,一般为67%:33%,这是一个比较常见的比率。
|
|
切分了样本数据后,还要对训练数据集进行更细致的处理,由于Gaussian Naive Bayes假设了每个特征都遵循正态分布,所以需要从训练数据集中抽取出摘要,它包含了均值与标准差,摘要的数量由类别和特征属性的组合数决定,例如,如果有3个类别与7个特征属性,那么就需要对每个特征属性和类别计算出均值和标准差,这就是21个摘要。
在计算训练数据集的摘要之前,我们的第一个任务是要将训练数据集中的特征与类别进行分离,也就是说,构造出一个key
为类别,值为所属该类别的数据行的散列表。
|
|
由于已经知道了类别只有一个,而且在每行数据的最后一个,所以只需要将-1传入到class_index参数即可。然后就是计算训练数据集的摘要(每个类别中的每个特征属性的均值与标准差),均值会被作为正态分布的中间值,而标准差则描述了数据的离散程度,在计算概率时,它会被作为正态分布中每个特征属性的期望分布。
标准差就是方差的平方根,只要先求出方差(每个特征值与平均值的差的平方之和的平均值)就可以得出标准差。
|
|
有了这些辅助函数,计算摘要就很简单了,具体步骤就是先从训练数据集中构造出key
为类别的散列表,然后根据类别与每个特征进行计算求出均值与标准差即可。
|
|
数据的处理阶段已经完成了,下面的任务是要去根据训练数据集来进行预测,该阶段需要计算类概率与每个特征与类别的条件概率,然后选出概率最大的类别作为分类结果。关键在于计算条件概率,需要用到正态分布的密度函数,而它所依赖的参数(特征,均值,标准差)我们已经准备好了。
|
|
函数calculate_conditional_probabilities()
返回了一个key
为类别,值为其概率的散列表,这个散列表记录了每个特征类别的条件概率,之后只需要选出其中最大概率的类别即可。
|
|
最后我们定义一个函数来对测试数据集中的每个数据实例进行预测以预估模型的准确性,该函数返回了一个预测值列表,包含了每个数据实例的预测值。根据这个返回值,就可以对预测结果进行准确性的评估了。
|
|
完整代码如下:
|
|
Netty是一个基于异步与事件驱动的网络应用程序框架,它支持快速与简单地开发可维护的高性能的服务器与客户端。
所谓事件驱动就是由通过各种事件响应来决定程序的流程,在Netty中到处都充满了异步与事件驱动,这种特点使得应用程序可以以任意的顺序响应在任意的时间点产生的事件,它带来了非常高的可伸缩性,让你的应用可以在需要处理的工作不断增长时,通过某种可行的方式或者扩大它的处理能力来适应这种增长。
Netty提供了高性能与易用性,它具有以下特点:
拥有设计良好且统一的API,支持NIO与OIO(阻塞IO)等多种传输类型,支持真正的无连接UDP Socket。
简单而强大的线程模型,可高度定制线程(池)。
良好的模块化与解耦,支持可扩展和灵活的事件模型,可以很轻松地分离关注点以复用逻辑组件(可插拔的)。
性能高效,拥有比Java核心API更高的吞吐量,通过zero-copy功能以实现最少的内存复制消耗。
内置了许多常用的协议编解码器,如HTTP、SSL、WebScoket等常见协议可以通过Netty做到开箱即用。用户也可以利用Netty简单方便地实现自己的应用层协议。
大多数人使用Netty主要还是为了提高应用的性能,而高性能则离不开非阻塞IO。Netty的非阻塞IO是基于Java NIO的,并且对其进行了封装(直接使用Java NIO API在高复杂度下的应用中是一项非常繁琐且容易出错的操作,而Netty帮你封装了这些复杂操作)。
NIO可以称为New IO也可以称为Non-blocking IO,它比Java旧的阻塞IO在性能上要高效许多(如果让每一个连接中的IO操作都单独创建一个线程,那么阻塞IO并不会比NIO在性能上落后,但不可能创建无限多的线程,在连接数非常多的情况下会很糟糕)。
ByteBuffer:NIO的数据传输是基于缓冲区的,ByteBuffer正是NIO数据传输中所使用的缓冲区抽象。ByteBuffer支持在堆外分配内存,并且尝试避免在执行I/O操作中的多余复制。一般的I/O操作都需要进行系统调用,这样会先切换到内核态,内核态要先从文件读取数据到它的缓冲区,只有等数据准备完毕后,才会从内核态把数据写到用户态,所谓的阻塞IO其实就是说的在等待数据准备好的这段时间内进行阻塞。如果想要避免这个额外的内核操作,可以通过使用mmap(虚拟内存映射)的方式来让用户态直接操作文件。
Channel:它类似于文件描述符,简单地来说它代表了一个实体(如一个硬件设备、文件、Socket或者一个能够执行一个或多个不同的I/O操作的程序组件)。你可以从一个Channel中读取数据到缓冲区,也可以将一个缓冲区中的数据写入到Channel。
Selector:选择器是NIO实现的关键,NIO采用的是I/O多路复用的方式来实现非阻塞,Selector通过在一个线程中监听每个Channel的IO事件来确定有哪些已经准备好进行IO操作的Channel,因此可以在任何时间检查任意的读操作或写操作的完成状态。这种方式避免了等待IO操作准备数据时的阻塞,使用较少的线程便可以处理许多连接,减少了线程切换与维护的开销。
了解了NIO的实现思想之后,我觉得还很有必要了解一下Unix中的I/O模型,Unix中拥有以下5种I/O模型:
阻塞I/O(Blocking I/O)
非阻塞I/O(Non-blocking I/O)
I/O多路复用(I/O multiplexing (select and poll))
信号驱动I/O(signal driven I/O (SIGIO))
异步I/O(asynchronous I/O (the POSIX aio_functions))
阻塞I/O模型是最常见的I/O模型,通常我们使用的InputStream/OutputStream都是基于阻塞I/O模型。在上图中,我们使用UDP作为例子,recvfrom()函数是UDP协议用于接收数据的函数,它需要使用系统调用并一直阻塞到内核将数据准备好,之后再由内核缓冲区复制数据到用户态(即是recvfrom()接收到数据),所谓阻塞就是在等待内核准备数据的这段时间内什么也不干。
举个生活中的例子,阻塞I/O就像是你去餐厅吃饭,在等待饭做好的时间段中,你只能在餐厅中坐着干等(如果你在玩手机那么这就是非阻塞I/O了)。
在非阻塞I/O模型中,内核在数据尚未准备好的情况下回返回一个错误码EWOULDBLOCK
,而recvfrom并没有在失败的情况下选择阻塞休眠,而是不断地向内核询问是否已经准备完毕,在上图中,前三次内核都返回了EWOULDBLOCK
,直到第四次询问时,内核数据准备完毕,然后开始将内核中缓存的数据复制到用户态。这种不断询问内核以查看某种状态是否完成的方式被称为polling(轮询)
。
非阻塞I/O就像是你在点外卖,只不过你非常心急,每隔一段时间就要打电话问外卖小哥有没有到。
I/O多路复用的思想跟非阻塞I/O是一样的,只不过在非阻塞I/O中,是在recvfrom的用户态(或一个线程)中去轮询内核,这种方式会消耗大量的CPU时间。而I/O多路复用则是通过select()或poll()系统调用来负责进行轮询,以实现监听I/O读写事件的状态。如上图中,select监听到一个datagram可读时,就交由recvfrom去发送系统调用将内核中的数据复制到用户态。
这种方式的优点很明显,通过I/O多路复用可以监听多个文件描述符,且在内核中完成监控的任务。但缺点是至少需要两个系统调用(select()与recvfrom())。
I/O多路复用同样适用于点外卖这个例子,只不过你在等外卖的期间完全可以做自己的事情,当外卖到的时候会通过外卖APP或者由外卖小哥打电话来通知你。
Unix中提供了两种I/O多路复用函数,select()和poll()。select()的兼容性更好,但它在单个进程中所能监控的文件描述符是有限的,这个值与FD_SETSIZE
相关,32位系统中默认为1024,64位系统中为2048。select()还有一个缺点就是他轮询的方式,它采取了线性扫描的轮询方式,每次都要遍历FD_SETSIZE个文件描述符,不管它们是否活不活跃的。poll()本质上与select()的实现没有区别,不过在数据结构上区别很大,用户必须分配一个pollfd结构数组,该数组维护在内核态中,正因如此,poll()并不像select()那样拥有大小上限的限制,但缺点同样也很明显,大量的fd数组会在用户态与内核态之间不断复制,不管这样的复制是否有意义。
还有一种比select()与poll()更加高效的实现叫做epoll(),它是由Linux内核2.6推出的可伸缩的I/O多路复用实现,目的是为了替代select()与poll()。epoll()同样没有文件描述符上限的限制,它使用一个文件描述符来管理多个文件描述符,并使用一个红黑树来作为存储结构。同时它还支持边缘触发(edge-triggered)与水平触发(level-triggered)两种模式(poll()只支持水平触发),在边缘触发模式下,epoll_wait
仅会在新的事件对象首次被加入到epoll时返回,而在水平触发模式下,epoll_wait
会在事件状态未变更前不断地触发。也就是说,边缘触发模式只会在文件描述符变为就绪状态时通知一次,水平触发模式会不断地通知该文件描述符直到被处理。
关于epoll_wait
请参考如下epoll API。
|
|
epoll另一亮点是采用了事件驱动的方式而不是轮询,在epoll_ctl
中注册的文件描述符在事件触发的时候会通过一个回调机制来激活该文件描述符,epoll_wait
便可以收到通知。这样效率就不会与文件描述符的数量成正比。epoll还采用了mmap来减少内核态与用户态之间的数据传输开销。
在Java NIO2(从JDK1.7开始引入)中,只要Linux内核版本在2.6以上,就会采用epoll,如下源码所示(DefaultSelectorProvider.java)。
|
|
信号驱动I/O模型使用到了信号,内核在数据准备就绪时会通过信号来进行通知。我们首先开启了一个信号驱动I/O套接字,并使用sigaction系统调用来安装信号处理程序,内核直接返回,不会阻塞用户态。当datagram准备好时,内核会发送SIGIO信号,recvfrom接收到信号后会发送系统调用开始进行I/O操作。
这种模型的优点是主进程(线程)不会被阻塞,当数据准备就绪时,通过信号处理程序来通知主进程(线程)准备进行I/O操作与对数据的处理。
我们之前讨论的各种I/O模型无论是阻塞还是非阻塞,它们所说的阻塞都是指的数据准备阶段。异步I/O模型同样依赖于信号处理程序来进行通知,但与以上I/O模型都不相同的是,异步I/O模型通知的是I/O操作已经完成,而不是数据准备完成。
可以说异步I/O模型才是真正的非阻塞,主进程只管做自己的事情,然后在I/O操作完成时调用回调函数来完成一些对数据的处理操作即可。
闲扯了这么多,想必大家已经对I/O模型有了一个深刻的认识。之后,我们将会结合部分源码(Netty4.X)来探讨Netty中的各大核心组件,以及如何使用Netty,你会发现实现一个Netty程序是多么简单(而且还伴随了高性能与可维护性)。
本文作者为SylvanasSun(sylvanas.sun@gmail.com),首发于SylvanasSun’s Blog。
原文链接:https://sylvanassun.github.io/2017/11/30/2017-11-30-netty_introduction/
(转载请务必保留本段声明,并且保留超链接。)
网络传输的基本单位是字节,在Java NIO中提供了ByteBuffer作为字节缓冲区容器,但该类的API使用起来不太方便,所以Netty实现了ByteBuf作为其替代品,下面是使用ByteBuf的优点:
相比ByteBuffer使用起来更加简单。
通过内置的复合缓冲区类型实现了透明的zero-copy。
容量可以按需增长。
读和写使用了不同的索引指针。
支持链式调用。
支持引用计数与池化。
可以被用户自定义的缓冲区类型扩展。
在讨论ByteBuf之前,我们先需要了解一下ByteBuffer的实现,这样才能比较深刻地明白它们之间的区别。
ByteBuffer继承于abstract class Buffer
(所以还有LongBuffer、IntBuffer等其他类型的实现),本质上它只是一个有限的线性的元素序列,包含了三个重要的属性。
Capacity:缓冲区中元素的容量大小,你只能将capacity个数量的元素写入缓冲区,一旦缓冲区已满就需要清理缓冲区才能继续写数据。
Position:指向下一个写入数据位置的索引指针,初始位置为0,最大为capacity-1。当写模式转换为读模式时,position需要被重置为0。
Limit:在写模式中,limit是可以写入缓冲区的最大索引,也就是说它在写模式中等价于缓冲区的容量。在读模式中,limit表示可以读取数据的最大索引。
由于Buffer中只维护了position一个索引指针,所以它在读写模式之间的切换需要调用一个flip()方法来重置指针。使用Buffer的流程一般如下:
写入数据到缓冲区。
调用flip()方法。
从缓冲区中读取数据
调用buffer.clear()或者buffer.compact()清理缓冲区,以便下次写入数据。
|
|
Buffer中核心方法的实现也非常简单,主要就是在操作指针position。
|
|
Java NIO中的Buffer API操作的麻烦之处就在于读写转换需要手动重置指针。而ByteBuf没有这种繁琐性,它维护了两个不同的索引,一个用于读取,一个用于写入。当你从ByteBuf读取数据时,它的readerIndex将会被递增已经被读取的字节数,同样的,当你写入数据时,writerIndex则会递增。readerIndex的最大范围在writerIndex的所在位置,如果试图移动readerIndex超过该值则会触发异常。
ByteBuf中名称以read或write开头的方法将会递增它们其对应的索引,而名称以get或set开头的方法则不会。ByteBuf同样可以指定一个最大容量,试图移动writerIndex超过该值则会触发异常。
|
|
ByteBuf同样支持在堆内和堆外进行分配。在堆内分配也被称为支撑数组模式,它能在没有使用池化的情况下提供快速的分配和释放。
|
|
另一种模式为堆外分配,Java NIO ByteBuffer类在JDK1.4时就已经允许JVM实现通过JNI调用来在堆外分配内存(调用malloc()函数在JVM堆外分配内存),这主要是为了避免额外的缓冲区复制操作。
|
|
ByteBuf还支持第三种模式,它被称为复合缓冲区,为多个ByteBuf提供了一个聚合视图。在这个视图中,你可以根据需要添加或者删除ByteBuf实例,ByteBuf的子类CompositeByteBuf实现了该模式。
一个适合使用复合缓冲区的场景是HTTP协议,通过HTTP协议传输的消息都会被分成两部分——头部和主体,如果这两部分由应用程序的不同模块产生,将在消息发送时进行组装,并且该应用程序还会为多个消息复用相同的消息主体,这样对于每个消息都将会创建一个新的头部,产生了很多不必要的内存操作。使用CompositeByteBuf是一个很好的选择,它消除了这些额外的复制,以帮助你复用这些消息。
|
|
CompositeByteBuf透明的实现了zero-copy,zero-copy其实就是避免数据在两个内存区域中来回的复制。从操作系统层面上来讲,zero-copy指的是避免在内核态与用户态之间的数据缓冲区复制(通过mmap避免),而Netty中的zero-copy更偏向于在用户态中的数据操作的优化,就像使用CompositeByteBuf来复用多个ByteBuf以避免额外的复制,也可以使用wrap()方法来将一个字节数组包装成ByteBuf,又或者使用ByteBuf的slice()方法把它分割为多个共享同一内存区域的ByteBuf,这些都是为了优化内存的使用率。
那么如何创建ByteBuf呢?在上面的代码中使用到了Unpooled,它是Netty提供的一个用于创建与分配ByteBuf的工具类,建议都使用这个工具类来创建你的缓冲区,不要自己去调用构造函数。经常使用的是wrappedBuffer()与copiedBuffer(),它们一个是用于将一个字节数组或ByteBuffer包装为一个ByteBuf,一个是根据传入的字节数组与ByteBuffer/ByteBuf来复制出一个新的ByteBuf。
|
|
相对底层的分配方法是使用ByteBufAllocator,Netty实现了PooledByteBufAllocator和UnpooledByteBufAllocator,前者使用了jemalloc(一种malloc()的实现)来分配内存,并且实现了对ByteBuf的池化以提高性能。后者分配的是未池化的ByteBuf,其分配方式与之前讲的一致。
|
|
为了优化内存使用率,Netty提供了一套手动的方式来追踪不活跃对象,像UnpooledHeapByteBuf这种分配在堆内的对象得益于JVM的GC管理,无需额外操心,而UnpooledDirectByteBuf是在堆外分配的,它的内部基于DirectByteBuffer,DirectByteBuffer会先向Bits类申请一个额度(Bits还拥有一个全局变量totalCapacity,记录了所有DirectByteBuffer总大小),每次申请前都会查看是否已经超过-XX:MaxDirectMemorySize所设置的上限,如果超限就会尝试调用Sytem.gc(),以试图回收一部分内存,然后休眠100毫秒,如果内存还是不足,则只能抛出OOM异常。堆外内存的回收虽然有了这么一层保障,但为了提高性能与使用率,主动回收也是很有必要的。由于Netty还实现了ByteBuf的池化,像PooledHeapByteBuf和PooledDirectByteBuf就必须依赖于手动的方式来进行回收(放回池中)。
Netty使用了引用计数器的方式来追踪那些不活跃的对象。引用计数的接口为ReferenceCounted,它的思想很简单,只要ByteBuf对象的引用计数大于0,就保证该对象不会被释放回收,可以通过手动调用release()与retain()方法来操作该对象的引用计数值递减或递增。用户也可以通过自定义一个ReferenceCounted的实现类,以满足自定义的规则。
|
|
Netty中的Channel与Java NIO的概念一样,都是对一个实体或连接的抽象,但Netty提供了一套更加通用的API。就以网络套接字为例,在Java中OIO与NIO是截然不同的两套API,假设你之前使用的是OIO而又想更改为NIO实现,那么几乎需要重写所有代码。而在Netty中,只需要更改短短几行代码(更改Channel与EventLoop的实现类,如把OioServerSocketChannel替换为NioServerSocketChannel),就可以完成OIO与NIO(或其他)之间的转换。
每个Channel最终都会被分配一个ChannelPipeline和ChannelConfig,前者持有所有负责处理入站与出站数据以及事件的ChannelHandler,后者包含了该Channel的所有配置设置,并且支持热更新,由于不同的传输类型可能具有其特别的配置,所以该类可能会实现为ChannelConfig的不同子类。
Channel是线程安全的(与之后要讲的线程模型有关),因此你完全可以在多个线程中复用同一个Channel,就像如下代码所示。
|
|
Netty除了支持常见的NIO与OIO,还内置了其他的传输类型。
Nmae | Package | Description |
---|---|---|
NIO | io.netty.channel.socket.nio | 以Java NIO为基础实现 |
OIO | io.netty.channel.socket.oio | 以java.net为基础实现,使用阻塞I/O模型 |
Epoll | io.netty.channel.epoll | 由JNI驱动epoll()实现的更高性能的非阻塞I/O,它只能使用在Linux |
Local | io.netty.channel.local | 本地传输,在JVM内部通过管道进行通信 |
Embedded | io.netty.channel.embedded | 允许在不需要真实网络传输的环境下使用ChannelHandler,主要用于对ChannelHandler进行测试 |
NIO、OIO、Epoll我们应该已经很熟悉了,下面主要说说Local与Embedded。
Local传输用于在同一个JVM中运行的客户端和服务器程序之间的异步通信,与服务器Channel相关联的SocketAddress并没有绑定真正的物理网络地址,它会被存储在注册表中,并在Channel关闭时注销。因此Local传输不会接受真正的网络流量,也就是说它不能与其他传输实现进行互操作。
Embedded传输主要用于对ChannelHandler进行单元测试,ChannelHandler是用于处理消息的逻辑组件,Netty通过将入站消息与出站消息都写入到EmbeddedChannel中的方式(提供了write/readInbound()与write/readOutbound()来读写入站与出站消息)来实现对ChannelHandler的单元测试。
ChannelHandler充当了处理入站和出站数据的应用程序逻辑的容器,该类是基于事件驱动的,它会响应相关的事件然后去调用其关联的回调函数,例如当一个新的连接被建立时,ChannelHandler的channelActive()方法将会被调用。
关于入站消息和出站消息的数据流向定义,如果以客户端为主视角来说的话,那么从客户端流向服务器的数据被称为出站,反之为入站。
入站事件是可能被入站数据或者相关的状态更改而触发的事件,包括:连接已被激活、连接失活、读取入站数据、用户事件、发生异常等。
出站事件是未来将会触发的某个动作的结果的事件,这些动作包括:打开或关闭远程节点的连接、将数据写(或冲刷)到套接字。
ChannelHandler的主要用途包括:
对入站与出站数据的业务逻辑处理
记录日志
将数据从一种格式转换为另一种格式,实现编解码器。以一次HTTP协议(或者其他应用层协议)的流程为例,数据在网络传输时的单位为字节,当客户端发送请求到服务器时,服务器需要通过解码器(处理入站消息)将字节解码为协议的消息内容,服务器在发送响应的时候(处理出站消息),还需要通过编码器将消息内容编码为字节。
捕获异常
提供Channel生命周期内的通知,如Channel活动时与非活动时
Netty中到处都充满了异步与事件驱动,而回调函数正是用于响应事件之后的操作。由于异步会直接返回一个结果,所以Netty提供了ChannelFuture(实现了java.util.concurrent.Future)来作为异步调用返回的占位符,真正的结果会在未来的某个时刻完成,到时候就可以通过ChannelFuture对其进行访问,每个Netty的出站I/O操作都将会返回一个ChannelFuture。
Netty还提供了ChannelFutureListener接口来监听ChannelFuture是否成功,并采取对应的操作。
|
|
ChannelFutureListener接口中还提供了几个简单的默认实现,方便我们使用。
|
|
ChannelHandler接口定义了对它生命周期进行监听的回调函数,在ChannelHandler被添加到ChannelPipeline或者被移除时都会调用这些函数。
|
|
入站消息与出站消息由其对应的接口ChannelInboundHandler与ChannelOutboundHandler负责,这两个接口定义了监听Channel的生命周期的状态改变事件的回调函数。
|
|
通过实现ChannelInboundHandler或者ChannelOutboundHandler就可以完成用户自定义的应用逻辑处理程序,不过Netty已经帮你实现了一些基本操作,用户只需要继承并扩展ChannelInboundHandlerAdapter或ChannelOutboundHandlerAdapter来作为自定义实现的起始点。
ChannelInboundHandlerAdapter与ChannelOutboundHandlerAdapter都继承于ChannelHandlerAdapter,该抽象类简单实现了ChannelHandler接口。
|
|
ChannelInboundHandlerAdapter与ChannelOutboundHandlerAdapter默认只是简单地将请求传递给ChannelPipeline中的下一个ChannelHandler,源码如下:
|
|
对于处理入站消息,另外一种选择是继承SimpleChannelInboundHandler,它是Netty的一个继承于ChannelInboundHandlerAdapter的抽象类,并在其之上实现了自动释放资源的功能。
我们在了解ByteBuf时就已经知道了Netty使用了一套自己实现的引用计数算法来主动释放资源,假设你的ChannelHandler继承于ChannelInboundHandlerAdapter或ChannelOutboundHandlerAdapter,那么你就有责任去管理你所分配的ByteBuf,一般来说,一个消息对象(ByteBuf)已经被消费(或丢弃)了,并且不会传递给ChannelHandler链中的下一个处理器(如果该消息到达了实际的传输层,那么当它被写入或Channel关闭时,都会被自动释放),那么你就需要去手动释放它。通过一个简单的工具类ReferenceCountUtil的release方法,就可以做到这一点。
|
|
为了模块化与解耦合,不可能由一个ChannelHandler来完成所有应用逻辑,所以Netty采用了拦截器链的设计。ChannelPipeline就是用来管理ChannelHandler实例链的容器,它的职责就是保证实例链的流动。
每一个新创建的Channel都将会被分配一个新的ChannelPipeline,这种关联关系是永久性的,一个Channel一生只能对应一个ChannelPipeline。
一个入站事件被触发时,它会先从ChannelPipeline的最左端(头部)开始一直传播到ChannelPipeline的最右端(尾部),而出站事件正好与入站事件顺序相反(从最右端一直传播到最左端)。这个顺序是定死的,Netty总是将ChannelPipeline的入站口作为头部,而将出站口作为尾部。在事件传播的过程中,ChannelPipeline会判断下一个ChannelHandler的类型是否和事件的运动方向相匹配,如果不匹配,就跳过该ChannelHandler并继续检查下一个(保证入站事件只会被ChannelInboundHandler处理),一个ChannelHandler也可以同时实现ChannelInboundHandler与ChannelOutboundHandler,它在入站事件与出站事件中都会被调用。
在阅读ChannelHandler的源码时,发现很多方法需要一个ChannelHandlerContext类型的参数,该接口是ChannelPipeline与ChannelHandler之间相关联的关键。ChannelHandlerContext可以通知ChannelPipeline中的当前ChannelHandler的下一个ChannelHandler,还可以动态地改变当前ChannelHandler在ChannelPipeline中的位置(通过调用ChannelPipeline中的各种方法来修改)。
ChannelHandlerContext负责了在同一个ChannelPipeline中的ChannelHandler与其他ChannelHandler之间的交互,每个ChannelHandlerContext都对应了一个ChannelHandler。在DefaultChannelPipeline的源码中,已经表现的很明显了。
|
|
ChannelHandlerContext还定义了许多与Channel和ChannelPipeline重合的方法(像read()、write()、connect()这些用于出站的方法或者如fireChannelXXXX()这样用于入站的方法),不同之处在于调用Channel或者ChannelPipeline上的这些方法,它们将会从头沿着整个ChannelHandler实例链进行传播,而调用位于ChannelHandlerContext上的相同方法,则会从当前所关联的ChannelHandler开始,且只会传播给实例链中的下一个ChannelHandler。而且,事件之间的移动(从一个ChannelHandler到下一个ChannelHandler)也是通过ChannelHandlerContext中的方法调用完成的。
|
|
为了最大限度地提供高性能和可维护性,Netty设计了一套强大又易用的线程模型。在一个网络框架中,最重要的能力是能够快速高效地处理在连接的生命周期内发生的各种事件,与之相匹配的程序构造被称为事件循环,Netty定义了接口EventLoop来负责这项工作。
如果是经常用Java进行多线程开发的童鞋想必经常会使用到线程池,也就是Executor这套API。Netty就是从Executor(java.util.concurrent)之上扩展了自己的EventExecutorGroup(io.netty.util.concurrent),同时为了与Channel的事件进行交互,还扩展了EventLoopGroup接口(io.netty.channel)。在io.netty.util.concurrent包下的EventExecutorXXX负责实现线程并发相关的工作,而在io.netty.channel包下的EventLoopXXX负责实现网络编程相关的工作(处理Channel中的事件)。
在Netty的线程模型中,一个EventLoop将由一个永远不会改变的Thread驱动,而一个Channel一生只会使用一个EventLoop(但是一个EventLoop可能会被指派用于服务多个Channel),在Channel中的所有I/O操作和事件都由EventLoop中的线程处理,也就是说一个Channel的一生之中都只会使用到一个线程。不过在Netty3,只有入站事件会被EventLoop处理,所有出站事件都会由调用线程处理,这种设计导致了ChannelHandler的线程安全问题。Netty4简化了线程模型,通过在同一个线程处理所有事件,既解决了这个问题,还提供了一个更加简单的架构。
|
|
为了确保一个Channel的整个生命周期中的I/O事件会被一个EventLoop负责,Netty通过inEventLoop()方法来判断当前执行的线程的身份,确定它是否是分配给当前Channel以及它的EventLoop的那一个线程。如果当前(调用)线程正是EventLoop中的线程,那么所提交的任务将会被直接执行,否则,EventLoop将调度该任务以便稍后执行,并将它放入内部的任务队列(每个EventLoop都有它自己的任务队列,从SingleThreadEventLoop的源码就能发现很多用于调度内部任务队列的方法),在下次处理它的事件时,将会执行队列中的那些任务。这种设计可以让任何线程与Channel直接交互,而无需在ChannelHandler中进行额外的同步。
从性能上来考虑,千万不要将一个需要长时间来运行的任务放入到任务队列中,它会影响到该队列中的其他任务的执行。解决方案是使用一个专门的EventExecutor来执行它(ChannelPipeline提供了带有EventExecutorGroup参数的addXXX()方法,该方法可以将传入的ChannelHandler绑定到你传入的EventExecutor之中),这样它就会在另一条线程中执行,与其他任务隔离。
|
|
EventLoopGroup负责管理和分配EventLoop(创建EventLoop和为每个新创建的Channel分配EventLoop),根据不同的传输类型,EventLoop的创建和分配方式也不同。例如,使用NIO传输类型,EventLoopGroup就会只使用较少的EventLoop(一个EventLoop服务于多个Channel),这是因为NIO基于I/O多路复用,一个线程可以处理多个连接,而如果使用的是OIO,那么新创建一个Channel(连接)就需要分配一个EventLoop(线程)。
在深入了解地Netty的核心组件之后,发现它们的设计都很模块化,如果想要实现你自己的应用程序,就需要将这些组件组装到一起。Netty通过Bootstrap类,以对一个Netty应用程序进行配置(组装各个组件),并最终使它运行起来。对于客户端程序和服务器程序所使用到的Bootstrap类是不同的,后者需要使用ServerBootstrap,这样设计是因为,在如TCP这样有连接的协议中,服务器程序往往需要一个以上的Channel,通过父Channel来接受来自客户端的连接,然后创建子Channel用于它们之间的通信,而像UDP这样无连接的协议,它不需要每个连接都创建子Channel,只需要一个Channel即可。
一个比较明显的差异就是Bootstrap与ServerBootstrap的group()方法,后者提供了一个接收2个EventLoopGroup的版本。
|
|
Bootstrap其实没有什么可以好说的,它就只是一个装配工,将各个组件拼装组合到一起,然后进行一些配置,有关它的详细API请参考Netty JavaDoc。下面我们将通过一个经典的Echo客户端与服务器的例子,来梳理一遍创建Netty应用的流程。
首先实现的是服务器,我们先实现一个EchoServerInboundHandler,处理入站消息。
|
|
服务器的应用逻辑只有这么多,剩下就是用ServerBootstrap进行配置了。
|
|
接下来实现客户端,同样需要先实现一个入站消息处理器。
|
|
然后配置客户端。
|
|
实现一个Netty应用程序就是如此简单,用户大多数都是在编写各种应用逻辑的ChannelHandler(或者使用Netty内置的各种实用ChannelHandler),然后只需要将它们全部添加到ChannelPipeline即可。
Docker是一个基于轻量级虚拟化技术的容器,整个项目基于Go语言开发,并采用了Apache 2.0协议。Docker可以将我们的应用程序打包封装到一个容器中,该容器包含了应用程序的代码、运行环境、依赖库、配置文件等必需的资源,通过容器就可以实现方便快速并且与平台解耦的自动化部署方式,无论你部署时的环境如何,容器中的应用程序都会运行在同一种环境下。
举个栗子,小明写了一个CMS系统,该系统的技术栈非常广,需要依赖于各种开源库和中间件。如果按照纯手动的部署方式,小明需要安装各种开源软件,还需要写好每个开源软件的配置文件。如果只是部署一次,这点时间开销还是可以接受的,但如果小明每隔几天就需要换个服务器去部署他的程序,那么这些繁琐的重复工作无疑是会令人发狂的。这时候,Docker的用处就派上场了,小明只需要根据应用程序的部署步骤编写一份Dockerfile文件(将安装、配置等操作交由Docker自动化处理),然后构建并发布他的镜像,这样,不管在什么机器上,小明都只需要拉取他需要的镜像,然后就可以直接部署运行了,这正是Docker的魅力所在。
那么镜像又是什么呢?镜像是Docker中的一个重要概念:
Image(镜像):它类似于虚拟机中使用到的镜像,由于任何应用程序都需要有它自己的运行环境,Image就是用来提供所需运行环境的一个模板。
Container(容器):Container是Docker提供的一个抽象层,它就像一个轻量级的沙盒,其中包含了一个极简的Linux系统环境与运行在其中的应用程序。Container是Image的运行实例(Image本身是只读的,Container启动时,Docker会在Image的上层创建一个可写层,任何在Container中的修改都不会影响到Image,如果想要在Image保存Container中的修改,Docker采用了基于Container生成新的Image层的策略),Docker引擎利用Container来操作并隔离每个应用(也就是说,每个容器中的应用都是互相独立的)。
其实从Docker与Container的英文单词原意中就可以体会出Docker的思想。Container可以释义为集装箱,集装箱是一个可以便于机械设备装卸的封装货物的通用标准规格,它的发明简化了物流运输的机械化过程,使其建立起了一套标准化的物流运输体系。而Docker的意思为码头工人,可以认为,Docker就像是在码头上辛勤工作的工人,把应用打包成一个个具有某种标准化规格的”集装箱”(其实这里指出的集装箱对应的是Image,在Docker中Container更像是一个运行中的沙盒),当货物运输到目的地后,码头工人们(Docker)就可以把集装箱拆开取出其中的货物(基于Image来创建Container并运行)。这种标准化与隔离性可以很方便地组合使用多个Image来构建你的应用环境(Docker也提倡每个Image都遵循单一职责原则,也就是只做好一件事),或者与其他人共享你的Image。
本文作者为SylvanasSun(sylvanas.sun@gmail.com),首发于SylvanasSun’s Blog。
原文链接:https://sylvanassun.github.io/2017/11/19/2017-11-19-docker_introduction/
(转载请务必保留本段声明,并且保留超链接。)
在上文中我们提到了Docker是基于轻量级虚拟化技术的,所以它与我们平常使用的虚拟机是不一样的。虚拟机技术可以分成以下两类:
系统虚拟机:通过软件对计算机系统的模拟来提供一个真实计算机的替代品。它是物理硬件的抽象并提供了运行完整操作系统所需的功能。虚拟机通过物理机器来管理和共享硬件,这样实现了多个虚拟机环境彼此之间的隔离,一台机器上可以运行多个虚拟机,每个虚拟机包括一个操作系统的完整副本。在系统虚拟机中,所运行的所有软件或操作都只会影响到该虚拟机的环境。我们经常使用的VMWare就是系统虚拟机的实现。
程序虚拟机:允许程序独立运行在平台之外。比较典型的例子就是JVM,Java通过JVM这一抽象层使得Java程序与操作系统和硬件平台解耦(因为每个Java程序都是运行在JVM中的),因此实现了所谓的compile once, run everywhere。
Docker所用到的技术与上述两种都不相同,它使用了更轻量级的虚拟化技术,多个Container共享了同一个操作系统内核,并且就像运行在本地上一样。Container技术相对于虚拟机来说,只是一个应用程序层的抽象,它将代码与依赖关系打包到一起,多个Container可以在同一台机器上运行(意味着一个虚拟机上也可以运行多个Container),并与其它Container共享操作系统内核,每一个Container都在用户空间中作为一个独立的进程运行,这些特性都证明了Container要比虚拟机更加灵活与轻量(一般都是结合虚拟机与Docker一起使用)。
Container技术其实并不是个新鲜事物,最早可以追溯到UNIX中的chroot(在1979年的V7 Unix中引入),它可以改变当前正在运行的进程及其子目录的根目录,在这种修改过的环境下运行的程序不能在指定的目录树之外访问文件,从而限制用户的活动范围,为进程提供了隔离空间。
之后各种Unix版本涌现出很多Container技术,在2006年,Google提出了”Process Containers”期望在Linux内核中实现进程资源隔离的相关特性,由于Container在Linux内核中的定义过于宽泛混乱,后来该项目改名为CGroups(Control Groups),实现了对进程的资源限制。
2008年,LXC(Linux Containers)发布,它是一种在操作系统层级上的虚拟化方法,用于在Linux系统上通过共享一个内核来运行多个互相隔离的程序(Container)。LXC正是结合了Linux内核中的CGroups和对分离的名称空间的支持来为应用程序提供了一个隔离的环境。而Docker也是基于LXC实现的(Docker的前身是dotClound公司中的内部项目,它是一家提供PaaS服务的公司。),并作出了许多改进。
在使用Docker之前你需要先安装Docker(这好像是一句废话。。。),根据不同的平台安装方法都不相同,可以去参考Install Docker | Docker Documentation或者自行Google。
安装完毕之后,输入docker --version
来确认是否安装成功。
|
|
从Docker Hub中可以pull到其他人发布的Image,我们也可以注册一个账号去发布自己的Image与他人共享。
|
|
有了Image,之后就可以在其之上运行一个Container了,命令如下。
|
|
我们对Container做出了修改,如果想要保留这个修改,可以通过commit命令来生成一个新的Image。
|
|
想删除一个容器或镜像也很简单,但在删除镜像前需要先删除依赖于它的容器。
|
|
如果想要自己构建一个镜像,那么需要编写Dockerfile文件,该文件描述了镜像的依赖环境以及如何配置你的应用环境。
|
|
然后就可以通过docker build -t xxx/xxxx .
命令来构建镜像,-t
后面是镜像名与tag等信息,注意.
表示在当前目录下寻找Dockerfile文件。
学会如何构建自己的镜像之后,你是否也想将它发布到Docker Hub上与他人分享呢?要想做到这一点,需要先注册一个Docker Hub账号,之后通过docker login
命令登录,然后再docker push image name
,就像在使用Git一样简单。
关于Docker的更多命令与使用方法,请参考Docker Documentation | Docker Documentation,另外我还推荐使用Docker Compose来构建镜像,它可以很方便地组合管理多个镜像。
Docker提供了非常强大的自动化部署方式与灵活性,对多个应用程序之间做到了解耦,提供了开发上的敏捷性、可控性以及可移植性。同时,Docker也在不断地帮助越来越多的企业实现了向云端迁移、向微服务转型以及向DevOps模式的实践。
如今,微服务与DevOps火爆程度日益渐高,你又有何理由选择拒绝Docker呢?让我们一起选择拥抱Docker,拥抱未来!
]]>Spring作为一个IOC/DI容器,帮助我们管理了许许多多的“bean”。但其实,Spring并没有保证这些对象的线程安全,需要由开发者自己编写解决线程安全问题的代码。
Spring对每个bean提供了一个scope属性来表示该bean的作用域。它是bean的生命周期。例如,一个scope为singleton的bean,在第一次被注入时,会创建为一个单例对象,该对象会一直被复用到应用结束。
singleton:默认的scope,每个scope为singleton的bean都会被定义为一个单例对象,该对象的生命周期是与Spring IOC容器一致的(但在第一次被注入时才会创建)。
prototype:bean被定义为在每次注入时都会创建一个新的对象。
request:bean被定义为在每个HTTP请求中创建一个单例对象,也就是说在单个请求中都会复用这一个单例对象。
session:bean被定义为在一个session的生命周期内创建一个单例对象。
application:bean被定义为在ServletContext的生命周期中复用一个单例对象。
websocket:bean被定义为在websocket的生命周期中复用一个单例对象。
我们交由Spring管理的大多数对象其实都是一些无状态的对象,这种不会因为多线程而导致状态被破坏的对象很适合Spring的默认scope,每个单例的无状态对象都是线程安全的(也可以说只要是无状态的对象,不管单例多例都是线程安全的,不过单例毕竟节省了不断创建对象与GC的开销)。
无状态的对象即是自身没有状态的对象,自然也就不会因为多个线程的交替调度而破坏自身状态导致线程安全问题。无状态对象包括我们经常使用的DO、DTO、VO这些只作为数据的实体模型的贫血对象,还有Service、DAO和Controller,这些对象并没有自己的状态,它们只是用来执行某些操作的。例如,每个DAO提供的函数都只是对数据库的CRUD,而且每个数据库Connection都作为函数的局部变量(局部变量是在用户栈中的,而且用户栈本身就是线程私有的内存区域,所以不存在线程安全问题),用完即关(或交还给连接池)。
有人可能会认为,我使用request作用域不就可以避免每个请求之间的安全问题了吗?这是完全错误的,因为Controller默认是单例的,一个HTTP请求是会被多个线程执行的,这就又回到了线程的安全问题。当然,你也可以把Controller的scope改成prototype,实际上Struts2就是这么做的,但有一点要注意,Spring MVC对请求的拦截粒度是基于每个方法的,而Struts2是基于每个类的,所以把Controller设为多例将会频繁的创建与回收对象,严重影响到了性能。
通过阅读上文其实已经说的很清楚了,Spring根本就没有对bean的多线程安全问题做出任何保证与措施。对于每个bean的线程安全问题,根本原因是每个bean自身的设计。不要在bean中声明任何有状态的实例变量或类变量,如果必须如此,那么就使用ThreadLocal把变量变为线程私有的,如果bean的实例变量或类变量需要在多个线程之间共享,那么就只能使用synchronized、lock、CAS等这些实现线程同步的方法了。
下面将通过解析ThreadLocal的源码来了解它的实现与作用,ThreadLocal是一个很好用的工具类,它在某些情况下解决了线程安全问题(在变量不需要被多个线程共享时)。
本文作者为SylvanasSun(sylvanas.sun@gmail.com),首发于SylvanasSun’s Blog。
原文链接:https://sylvanassun.github.io/2017/11/06/2017-11-06-spring_and_thread-safe/
(转载请务必保留本段声明,并且保留超链接。)
ThreadLocal是一个为线程提供线程局部变量的工具类。它的思想也十分简单,就是为线程提供一个线程私有的变量副本,这样多个线程都可以随意更改自己线程局部的变量,不会影响到其他线程。不过需要注意的是,ThreadLocal提供的只是一个浅拷贝,如果变量是一个引用类型,那么就要考虑它内部的状态是否会被改变,想要解决这个问题可以通过重写ThreadLocal的initialValue()函数来自己实现深拷贝,建议在使用ThreadLocal时一开始就重写该函数。
ThreadLocal与像synchronized这样的锁机制是不同的。首先,它们的应用场景与实现思路就不一样,锁更强调的是如何同步多个线程去正确地共享一个变量,ThreadLocal则是为了解决同一个变量如何不被多个线程共享。从性能开销的角度上来讲,如果锁机制是用时间换空间的话,那么ThreadLocal就是用空间换时间。
ThreadLocal中含有一个叫做ThreadLocalMap的内部类,该类为一个采用线性探测法实现的HashMap。它的key为ThreadLocal对象而且还使用了WeakReference,ThreadLocalMap正是用来存储变量副本的。
|
|
ThreadLocal中只含有三个成员变量,这三个变量都是与ThreadLocalMap的hash策略相关的。
|
|
唯一的实例变量threadLocalHashCode是用来进行寻址的hashcode,它由函数nextHashCode()生成,该函数简单地通过一个增量HASH_INCREMENT来生成hashcode。至于为什么这个增量为0x61c88647,主要是因为ThreadLocalMap的初始大小为16,每次扩容都会为原来的2倍,这样它的容量永远为2的n次方,该增量选为0x61c88647也是为了尽可能均匀地分布,减少碰撞冲突。
|
|
要获得当前线程私有的变量副本需要调用get()函数。首先,它会调用getMap()函数去获得当前线程的ThreadLocalMap,这个函数需要接收当前线程的实例作为参数。如果得到的ThreadLocalMap为null,那么就去调用setInitialValue()函数来进行初始化,如果不为null,就通过map来获得变量副本并返回。
setInitialValue()函数会去先调用initialValue()函数来生成初始值,该函数默认返回null,我们可以通过重写这个函数来返回我们想要在ThreadLocal中维护的变量。之后,去调用getMap()函数获得ThreadLocalMap,如果该map已经存在,那么就用新获得value去覆盖旧值,否则就调用createMap()函数来创建新的map。
|
|
ThreadLocal的set()与remove()函数要比get()的实现还要简单,都只是通过getMap()来获得ThreadLocalMap然后对其进行操作。
|
|
getMap()函数与createMap()函数的实现也十分简单,但是通过观察这两个函数可以发现一个秘密:ThreadLocalMap是存放在Thread中的。
|
|
仔细想想其实就能够理解这种设计的思想。有一种普遍的方法是通过一个全局的线程安全的Map来存储各个线程的变量副本,但是这种做法已经完全违背了ThreadLocal的本意,设计ThreadLocal的初衷就是为了避免多个线程去并发访问同一个对象,尽管它是线程安全的。而在每个Thread中存放与它关联的ThreadLocalMap是完全符合ThreadLocal的思想的,当想要对线程局部变量进行操作时,只需要把Thread作为key来获得Thread中的ThreadLocalMap即可。这种设计相比采用一个全局Map的方法会多占用很多内存空间,但也因此不需要额外的采取锁等线程同步方法而节省了时间上的消耗。
我们要考虑一种会发生内存泄漏的情况,如果ThreadLocal被设置为null后,而且没有任何强引用指向它,根据垃圾回收的可达性分析算法,ThreadLocal将会被回收。这样一来,ThreadLocalMap中就会含有key为null的Entry,而且ThreadLocalMap是在Thread中的,只要线程迟迟不结束,这些无法访问到的value会形成内存泄漏。为了解决这个问题,ThreadLocalMap中的getEntry()、set()和remove()函数都会清理key为null的Entry,以下面的getEntry()函数的源码为例。
|
|
在上文中我们发现了ThreadLocalMap的key是一个弱引用,那么为什么使用弱引用呢?使用强引用key与弱引用key的差别如下:
强引用key:ThreadLocal被设置为null,由于ThreadLocalMap持有ThreadLocal的强引用,如果不手动删除,那么ThreadLocal将不会回收,产生内存泄漏。
弱引用key:ThreadLocal被设置为null,由于ThreadLocalMap持有ThreadLocal的弱引用,即便不手动删除,ThreadLocal仍会被回收,ThreadLocalMap在之后调用set()、getEntry()和remove()函数时会清除所有key为null的Entry。
但要注意的是,ThreadLocalMap仅仅含有这些被动措施来补救内存泄漏问题。如果你在之后没有调用ThreadLocalMap的set()、getEntry()和remove()函数的话,那么仍然会存在内存泄漏问题。
在使用线程池的情况下,如果不及时进行清理,内存泄漏问题事小,甚至还会产生程序逻辑上的问题。所以,为了安全地使用ThreadLocal,必须要像每次使用完锁就解锁一样,在每次使用完ThreadLocal后都要调用remove()来清理无用的Entry。
我们都知道一个进程是与其他进程共享CPU和内存资源的。正因如此,操作系统需要有一套完善的内存管理机制才能防止进程之间内存泄漏的问题。
为了更加有效地管理内存并减少出错,现代操作系统提供了一种对主存的抽象概念,即是虚拟内存(Virtual Memory)。虚拟内存为每个进程提供了一个一致的、私有的地址空间,它让每个进程产生了一种自己在独享主存的错觉(每个进程拥有一片连续完整的内存空间)。
理解不深刻的人会认为虚拟内存只是“使用硬盘空间来扩展内存“的技术,这是不对的。虚拟内存的重要意义是它定义了一个连续的虚拟地址空间,使得程序的编写难度降低。并且,把内存扩展到硬盘空间只是使用虚拟内存的必然结果,虚拟内存空间会存在硬盘中,并且会被内存缓存(按需),有的操作系统还会在内存不够的情况下,将某一进程的内存全部放入硬盘空间中,并在切换到该进程时再从硬盘读取(这也是为什么Windows会经常假死的原因…)。
虚拟内存主要提供了如下三个重要的能力:
它把主存看作为一个存储在硬盘上的虚拟地址空间的高速缓存,并且只在主存中缓存活动区域(按需缓存)。
它为每个进程提供了一个一致的地址空间,从而降低了程序员对内存管理的复杂性。
它还保护了每个进程的地址空间不会被其他进程破坏。
介绍了虚拟内存的基本概念之后,接下来的内容将会从虚拟内存在硬件中如何运作逐渐过渡到虚拟内存在操作系统(Linux)中的实现。
本文作者为SylvanasSun(sylvanas.sun@gmail.com),首发于SylvanasSun’s Blog。
原文链接:https://sylvanassun.github.io/2017/10/29/2017-10-29-virtual_memory/
(转载请务必保留本段声明,并且保留超链接。)
内存通常被组织为一个由M个连续的字节大小的单元组成的数组,每个字节都有一个唯一的物理地址(Physical Address PA),作为到数组的索引。CPU访问内存最简单直接的方法就是使用物理地址,这种寻址方式被称为物理寻址。
现代处理器使用的是一种称为虚拟寻址(Virtual Addressing)的寻址方式。使用虚拟寻址,CPU需要将虚拟地址翻译成物理地址,这样才能访问到真实的物理内存。
虚拟寻址需要硬件与操作系统之间互相合作。CPU中含有一个被称为内存管理单元(Memory Management Unit, MMU)的硬件,它的功能是将虚拟地址转换为物理地址。MMU需要借助存放在内存中的页表来动态翻译虚拟地址,该页表由操作系统管理。
虚拟内存空间被组织为一个存放在硬盘上的M个连续的字节大小的单元组成的数组,每个字节都有一个唯一的虚拟地址,作为到数组的索引(这点其实与物理内存是一样的)。
操作系统通过将虚拟内存分割为大小固定的块来作为硬盘和内存之间的传输单位,这个块被称为虚拟页(Virtual Page, VP),每个虚拟页的大小为P=2^p
字节。物理内存也会按照这种方法分割为物理页(Physical Page, PP),大小也为P
字节。
CPU在获得虚拟地址之后,需要通过MMU将虚拟地址翻译为物理地址。而在翻译的过程中还需要借助页表,所谓页表就是一个存放在物理内存中的数据结构,它记录了虚拟页与物理页的映射关系。
页表是一个元素为页表条目(Page Table Entry, PTE)的集合,每个虚拟页在页表中一个固定偏移量的位置上都有一个PTE。下面是PTE仅含有一个有效位标记的页表结构,该有效位代表这个虚拟页是否被缓存在物理内存中。
虚拟页VP 0
、VP 4
、VP 6
、VP 7
被缓存在物理内存中,虚拟页VP 2
和VP 5
被分配在页表中,但并没有缓存在物理内存,虚拟页VP 1
和VP 3
还没有被分配。
在进行动态内存分配时,例如malloc()
函数或者其他高级语言中的new
关键字,操作系统会在硬盘中创建或申请一段虚拟内存空间,并更新到页表(分配一个PTE,使该PTE指向硬盘上这个新创建的虚拟页)。
由于CPU每次进行地址翻译的时候都需要经过PTE,所以如果想控制内存系统的访问,可以在PTE上添加一些额外的许可位(例如读写权限、内核权限等),这样只要有指令违反了这些许可条件,CPU就会触发一个一般保护故障,将控制权传递给内核中的异常处理程序。一般这种异常被称为“段错误(Segmentation Fault)”。
如上图所示,MMU根据虚拟地址在页表中寻址到了PTE 4
,该PTE的有效位为1,代表该虚拟页已经被缓存在物理内存中了,最终MMU得到了PTE中的物理内存地址(指向PP 1
)。
如上图所示,MMU根据虚拟地址在页表中寻址到了PTE 2
,该PTE的有效位为0,代表该虚拟页并没有被缓存在物理内存中。虚拟页没有被缓存在物理内存中(缓存未命中)被称为缺页。
当CPU遇见缺页时会触发一个缺页异常,缺页异常将控制权转向操作系统内核,然后调用内核中的缺页异常处理程序,该程序会选择一个牺牲页,如果牺牲页已被修改过,内核会先将它复制回硬盘(采用写回机制而不是直写也是为了尽量减少对硬盘的访问次数),然后再把该虚拟页覆盖到牺牲页的位置,并且更新PTE。
当缺页异常处理程序返回时,它会重新启动导致缺页的指令,该指令会把导致缺页的虚拟地址重新发送给MMU。由于现在已经成功处理了缺页异常,所以最终结果是页命中,并得到物理地址。
这种在硬盘和内存之间传送页的行为称为页面调度(paging):页从硬盘换入内存和从内存换出到硬盘。当缺页异常发生时,才将页面换入到内存的策略称为按需页面调度(demand paging),所有现代操作系统基本都使用的是按需页面调度的策略。
虚拟内存跟CPU高速缓存(或其他使用缓存的技术)一样依赖于局部性原则。虽然处理缺页消耗的性能很多(毕竟还是要从硬盘中读取),而且程序在运行过程中引用的不同虚拟页的总数可能会超出物理内存的大小,但是局部性原则保证了在任意时刻,程序将趋向于在一个较小的活动页面(active page)集合上工作,这个集合被称为工作集(working set)。根据空间局部性原则(一个被访问过的内存地址以及其周边的内存地址都会有很大几率被再次访问)与时间局部性原则(一个被访问过的内存地址在之后会有很大几率被再次访问),只要将工作集缓存在物理内存中,接下来的地址翻译请求很大几率都在其中,从而减少了额外的硬盘流量。
如果一个程序没有良好的局部性,将会使工作集的大小不断膨胀,直至超过物理内存的大小,这时程序会产生一种叫做抖动(thrashing)的状态,页面会不断地换入换出,如此多次的读写硬盘开销,性能自然会十分“恐怖”。所以,想要编写出性能高效的程序,首先要保证程序的时间局部性与空间局部性。
我们目前为止讨论的只是单页表,但在实际的环境中虚拟空间地址都是很大的(一个32位系统的地址空间有2^32 = 4GB
,更别说64位系统了)。在这种情况下,使用一个单页表明显是效率低下的。
常用方法是使用层次结构的页表。假设我们的环境为一个32位的虚拟地址空间,它有如下形式:
虚拟地址空间被分为4KB的页,每个PTE都是4字节。
内存的前2K个页面分配给了代码和数据。
之后的6K个页面还未被分配。
再接下来的1023个页面也未分配,其后的1个页面分配给了用户栈。
下图是为该虚拟地址空间构造的二级页表层次结构(真实情况中多为四级或更多),一级页表(1024个PTE正好覆盖4GB的虚拟地址空间,同时每个PTE只有4字节,这样一级页表与二级页表的大小也正好与一个页面的大小一致都为4KB)的每个PTE负责映射虚拟地址空间中一个4MB的片(chunk),每一片都由1024个连续的页面组成。二级页表中的每个PTE负责映射一个4KB的虚拟内存页面。
这个结构看起来很像是一个B-Tree
,这种层次结构有效的减缓了内存要求:
如果一个一级页表的一个PTE是空的,那么相应的二级页表也不会存在。这代表一种巨大的潜在节约(对于一个普通的程序来说,虚拟地址空间的大部分都会是未分配的)。
只有一级页表才总是需要缓存在内存中的,这样虚拟内存系统就可以在需要时创建、页面调入或调出二级页表(只有经常使用的二级页表才会被缓存在内存中),这就减少了内存的压力。
从形式上来说,地址翻译是一个N元素的虚拟地址空间中的元素和一个M元素的物理地址空间中元素之间的映射。
下图为MMU利用页表进行寻址的过程:
页表基址寄存器(PTBR)指向当前页表。一个n位的虚拟地址包含两个部分,一个p位的虚拟页面偏移量(Virtual Page Offset, VPO)和一个(n - p)位的虚拟页号(Virtual Page Number, VPN)。
MMU根据VPN来选择对应的PTE,例如VPN 0
代表PTE 0
、VPN 1
代表PTE 1
….因为物理页与虚拟页的大小是一致的,所以物理页面偏移量(Physical Page Offset, PPO)与VPO是相同的。那么之后只要将PTE中的物理页号(Physical Page Number, PPN)与虚拟地址中的VPO串联起来,就能得到相应的物理地址。
多级页表的地址翻译也是如此,只不过因为有多个层次,所以VPN需要分成多段。假设有一个k级页表,虚拟地址会被分割成k个VPN和1个VPO,每个VPN i
都是一个到第i级页表的索引。为了构造物理地址,MMU需要访问k个PTE才能拿到对应的PPN。
页表是被缓存在内存中的,尽管内存的速度相对于硬盘来说已经非常快了,但与CPU还是有所差距。为了防止每次地址翻译操作都需要去访问内存,CPU使用了高速缓存与TLB来缓存PTE。
在最糟糕的情况下(不包括缺页),MMU需要访问内存取得相应的PTE,这个代价大约为几十到几百个周期,如果PTE凑巧缓存在L1高速缓存中(如果L1没有还会从L2中查找,不过我们忽略多级缓冲区的细节),那么性能开销就会下降到1个或2个周期。然而,许多系统甚至需要消除即使这样微小的开销,TLB由此而生。
TLB(Translation Lookaside Buffer, TLB)被称为翻译后备缓冲器或翻译旁路缓冲器,它是MMU中的一个缓冲区,其中每一行都保存着一个由单个PTE组成的块。用于组选择和行匹配的索引与标记字段是从VPN中提取出来的,如果TLB中有T = 2^t
个组,那么TLB索引(TLBI)是由VPN的t个最低位组成的,而TLB标记(TLBT)是由VPN中剩余的位组成的。
下图为地址翻译的流程(TLB命中的情况下):
第一步,CPU将一个虚拟地址交给MMU进行地址翻译。
第二步和第三步,MMU通过TLB取得相应的PTE。
第四步,MMU通过PTE翻译出物理地址并将它发送给高速缓存/内存。
第五步,高速缓存返回数据到CPU(如果缓存命中的话,否则还需要访问内存)。
当TLB未命中时,MMU必须从高速缓存/内存中取出相应的PTE,并将新取得的PTE存放到TLB(如果TLB已满会覆盖一个已经存在的PTE)。
Linux为每个进程维护了一个单独的虚拟地址空间。虚拟地址空间分为内核空间与用户空间,用户空间包括代码、数据、堆、共享库以及栈,内核空间包括内核中的代码和数据结构,内核空间的某些区域被映射到所有进程共享的物理页面。Linux也将一组连续的虚拟页面(大小等于内存总量)映射到相应的一组连续的物理页面,这种做法为内核提供了一种便利的方法来访问物理内存中任何特定的位置。
Linux将虚拟内存组织成一些区域(也称为段)的集合,区域的概念允许虚拟地址空间有间隙。一个区域就是已经存在着的已分配的虚拟内存的连续片(chunk)。例如,代码段、数据段、堆、共享库段,以及用户栈都属于不同的区域,每个存在的虚拟页都保存在某个区域中,而不属于任何区域的虚拟页是不存在的,也不能被进程所引用。
内核为系统中的每个进程维护一个单独的任务结构(task_struct)。任务结构中的元素包含或者指向内核运行该进程所需的所有信息(PID、指向用户栈的指针、可执行目标文件的名字、程序计数器等)。
mm_struct:描述了虚拟内存的当前状态。pgd指向一级页表的基址(当内核运行这个进程时,pgd会被存放在CR3控制寄存器,也就是页表基址寄存器中),mmap指向一个vm_area_structs的链表,其中每个vm_area_structs都描述了当前虚拟地址空间的一个区域。
vm_starts:指向这个区域的起始处。
vm_end:指向这个区域的结束处。
vm_prot:描述这个区域内包含的所有页的读写许可权限。
vm_flags:描述这个区域内的页面是与其他进程共享的,还是这个进程私有的以及一些其他信息。
vm_next:指向链表的下一个区域结构。
Linux通过将一个虚拟内存区域与一个硬盘上的文件关联起来,以初始化这个虚拟内存区域的内容,这个过程称为内存映射(memory mapping)。这种将虚拟内存系统集成到文件系统的方法可以简单而高效地把程序和数据加载到内存中。
一个区域可以映射到一个普通硬盘文件的连续部分,例如一个可执行目标文件。文件区(section)被分成页大小的片,每一片包含一个虚拟页的初始内容。由于按需页面调度的策略,这些虚拟页面没有实际交换进入物理内存,直到CPU引用的虚拟地址在该区域的范围内。如果区域比文件区要大,那么就用零来填充这个区域的余下部分。
一个区域也可以映射到一个匿名文件,匿名文件是由内核创建的,包含的全是二进制零。当CPU第一次引用这样一个区域内的虚拟页面时,内核就在物理内存中找到一个合适的牺牲页面,如果该页面被修改过,就先将它写回到硬盘,之后用二进制零覆盖牺牲页并更新页表,将这个页面标记为已缓存在内存中的。
简单的来说:普通文件映射就是将一个文件与一块内存建立起映射关系,对该文件进行IO操作可以绕过内核直接在用户态完成(用户态在该虚拟地址区域读写就相当于读写这个文件)。匿名文件映射一般在用户空间需要分配一段内存来存放数据时,由内核创建匿名文件并与内存进行映射,之后用户态就可以通过操作这段虚拟地址来操作内存了。匿名文件映射最熟悉的应用场景就是动态内存分配(malloc()函数)。
Linux很多地方都采用了“懒加载”机制,自然也包括内存映射。不管是普通文件映射还是匿名映射,Linux只会先划分虚拟内存地址。只有当CPU第一次访问该区域内的虚拟地址时,才会真正的与物理内存建立映射关系。
只要虚拟页被初始化了,它就在一个由内核维护的交换文件(swap file)之间换来换去。交换文件又称为交换空间(swap space)或交换区域(swap area)。swap区域不止用于页交换,在物理内存不够的情况下,还会将部分内存数据交换到swap区域(使用硬盘来扩展内存)。
虚拟内存系统为每个进程提供了私有的虚拟地址空间,这样可以保证进程之间不会发生错误的读写。但多个进程之间也含有相同的部分,例如每个C程序都使用到了C标准库,如果每个进程都在物理内存中保持这些代码的副本,那会造成很大的内存资源浪费。
内存映射提供了共享对象的机制,来避免内存资源的浪费。一个对象被映射到虚拟内存的一个区域,要么是作为共享对象,要么是作为私有对象的。
如果一个进程将一个共享对象映射到它的虚拟地址空间的一个区域内,那么这个进程对这个区域的任何写操作,对于那些也把这个共享对象映射到它们虚拟内存的其他进程而言,也是可见的。相对的,对一个映射到私有对象的区域的任何写操作,对于其他进程来说是不可见的。一个映射到共享对象的虚拟内存区域叫做共享区域,类似地,也有私有区域。
为了节约内存,私有对象开始的生命周期与共享对象基本上是一致的(在物理内存中只保存私有对象的一份副本),并使用写时复制的技术来应对多个进程的写冲突。
只要没有进程试图写它自己的私有区域,那么多个进程就可以继续共享物理内存中私有对象的一个单独副本。然而,只要有一个进程试图对私有区域的某一页面进行写操作,就会触发一个保护异常。在上图中,进程B试图对私有区域的一个页面进行写操作,该操作触发了保护异常。异常处理程序会在物理内存中创建这个页面的一个新副本,并更新PTE指向这个新的副本,然后恢复这个页的可写权限。
还有一个典型的例子就是fork()
函数,该函数用于创建子进程。当fork()
函数被当前进程调用时,内核会为新进程创建各种必要的数据结构,并分配给它一个唯一的PID。为了给新进程创建虚拟内存,它复制了当前进程的mm_struct
、vm_area_struct
和页表的原样副本。并将两个进程的每个页面都标为只读,两个进程中的每个区域都标记为私有区域(写时复制)。
这样,父进程和子进程的虚拟内存空间完全一致,只有当这两个进程中的任一个进行写操作时,再使用写时复制来保证每个进程的虚拟地址空间私有的抽象概念。
虽然可以使用内存映射(mmap()
函数)来创建和删除虚拟内存区域来满足运行时动态内存分配的问题。然而,为了更好的移植性与便利性,还需要一个更高层面的抽象,也就是动态内存分配器(dynamic memory allocator)。
动态内存分配器维护着一个进程的虚拟内存区域,也就是我们所熟悉的“堆(heap)”,内核中还维护着一个指向堆顶的指针brk(break)。动态内存分配器将堆视为一个连续的虚拟内存块(chunk)的集合,每个块有两种状态,已分配和空闲。已分配的块显式地保留为供应用程序使用,空闲块则可以用来进行分配,它的空闲状态直到它显式地被应用程序分配为止。已分配的块要么被应用程序显式释放,要么被垃圾回收器所释放。
本文只讲解动态内存分配的一些概念,关于动态内存分配器的实现已经超出了本文的讨论范围。如果有对它感兴趣的同学,可以去参考dlmalloc的源码,它是由Doug Lea(就是写Java并发包的那位)实现的一个设计巧妙的内存分配器,而且源码中的注释十分多。
造成堆的空间利用率很低的主要原因是一种被称为碎片(fragmentation)的现象,当虽然有未使用的内存但这块内存并不能满足分配请求时,就会产生碎片。有以下两种形式的碎片:
内部碎片:在一个已分配块比有效载荷大时发生。例如,程序请求一个5字(这里我们不纠结字的大小,假设一个字为4字节,堆的大小为16字并且要保证边界双字对齐)的块,内存分配器为了保证空闲块是双字边界对齐的(具体实现中对齐的规定可能略有不同,但对齐是肯定会有的),只好分配一个6字的块。在本例中,已分配块为6字,有效载荷为5字,内部碎片为已分配块减去有效载荷,为1字。
外部碎片:当空闲内存合计起来足够满足一个分配请求,但是没有一个单独的空闲块足够大到可以来处理这个请求时发生。外部碎片难以量化且不可预测,所以分配器通常采用启发式策略来试图维持少量的大空闲块,而不是维持大量的小空闲块。分配器也会根据策略与分配请求的匹配来分割空闲块与合并空闲块(必须相邻)。
分配器将堆组织为一个连续的已分配块和空闲块的序列,该序列被称为空闲链表。空闲链表分为隐式空闲链表与显式空闲链表。
隐式空闲链表,是一个单向链表,并且每个空闲块仅仅是通过头部中的大小字段隐含地连接着的。
显式空闲链表,即是将空闲块组织为某种形式的显式数据结构(为了更加高效地合并与分割空闲块)。例如,将堆组织为一个双向空闲链表,在每个空闲块中,都包含一个前驱节点的指针与后继节点的指针。
查找一个空闲块一般有如下几种策略:
首次适配:从头开始搜索空闲链表,选择第一个遇见的合适的空闲块。它的优点在于趋向于将大的空闲块保留在链表的后面,缺点是它趋向于在靠近链表前部处留下碎片。
下一次适配:每次从上一次查询结束的地方开始进行搜索,直到遇见合适的空闲块。这种策略通常比首次适配效率高,但是内存利用率则要低得多了。
最佳适配:检查每个空闲块,选择适合所需请求大小的最小空闲块。最佳适配的内存利用率是三种策略中最高的,但它需要对堆进行彻底的搜索。
对一个链表进行查找操作的效率是线性的,为了减少分配请求对空闲块匹配的时间,分配器通常采用分离存储(segregated storage)的策略,即是维护多个空闲链表,其中每个链表的块有大致相等的大小。
一种简单的分离存储策略:分配器维护一个空闲链表数组,然后将所有可能的块分成一些等价类(也叫做大小类(size class)),每个大小类代表一个空闲链表,并且每个大小类的空闲链表包含大小相等的块,每个块的大小就是这个大小类中最大元素的大小(例如,某个大小类的范围定义为(17~32),那么这个空闲链表全由大小为32的块组成)。
当有一个分配请求时,我们检查相应的空闲链表。如果链表非空,那么就分配其中第一块的全部。如果链表为空,分配器就向操作系统请求一个固定大小的额外内存片,将这个片分成大小相等的块,然后将这些块链接起来形成新的空闲链表。
要释放一个块,分配器只需要简单地将这个块插入到相应的空闲链表的头部。
在编写C程序时,一般只能显式地分配与释放堆中的内存(malloc()
与free()
),程序员不仅需要分配内存,还需要负责内存的释放。
许多现代编程语言都内置了自动内存管理机制(通过引入自动内存管理库也可以让C/C++实现自动内存管理),所谓自动内存管理,就是自动判断不再需要的堆内存(被称为垃圾内存),然后自动释放这些垃圾内存。
自动内存管理的实现是垃圾收集器(garbage collector),它是一种动态内存分配器,它会自动释放应用程序不再需要的已分配块。
垃圾收集器一般采用以下两种(之一)的策略来判断一块堆内存是否为垃圾内存:
引用计数器:在数据的物理空间中添加一个计数器,当有其他数据与其相关时(引用),该计数器加一,反之则减一。通过定期检查计数器的值,只要为0则认为是垃圾内存,可以释放它所占用的已分配块。使用引用计数器,实现简单直接,但缺点也很明显,它无法回收循环引用的两个对象(假设有对象A与对象B,它们2个互相引用,但实际上对象A与对象B都已经是没用的对象了)。
可达性分析:垃圾收集器将堆内存视为一张有向图,然后选出一组根节点(例如,在Java中一般为类加载器、全局变量、运行时常量池中的引用类型变量等),根节点必须是足够“活跃“的对象。然后计算从根节点集合出发的可达路径,只要从根节点出发不可达的节点,都视为垃圾内存。
垃圾收集器进行回收的算法有如下几种:
标记-清除:该算法分为标记(mark)和清除(sweep)两个阶段。首先标记出所有需要回收的对象,然后在标记完成后统一回收所有被标记的对象。标记-清除算法实现简单,但它的效率不高,而且会产生许多内存碎片。
标记-整理:标记-整理与标记-清除算法基本一致,只不过后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉边界以外的内存。
复制:将程序所拥有的内存空间划分为大小相等的两块,每次都只使用其中的一块。当这一块的内存用完了,就把还存活着的对象复制到另一块内存上,然后将已使用过的内存空间进行清理。这种方法不必考虑内存碎片问题,但内存利用率很低。这个比例不是绝对的,像HotSpot虚拟机为了避免浪费,将内存划分为Eden空间与两个Survivor空间,每次都只使用Eden和其中一个Survivor。当回收时,将Eden和Survivor中还存活着的对象一次性地复制到另外一个Survivor空间上,然后清理掉Eden和刚才使用过的Survivor空间。HotSpot虚拟机默认的Eden和Survivor的大小比例为8:1,只有10%的内存空间会被闲置浪费。
分代:分代算法根据对象的存活周期的不同将内存划分为多块,这样就可以对不同的年代采用不同的回收算法。一般分为新生代与老年代,新生代存放的是存活率较低的对象,可以采用复制算法;老年代存放的是存活率较高的对象,如果使用复制算法,那么内存空间会不够用,所以必须使用标记-清除或标记-整理算法。
虚拟内存是对内存的一个抽象。支持虚拟内存的CPU需要通过虚拟寻址的方式来引用内存中的数据。CPU加载一个虚拟地址,然后发送给MMU进行地址翻译。地址翻译需要硬件与操作系统之间紧密合作,MMU借助页表来获得物理地址。
首先,MMU先将虚拟地址发送给TLB以获得PTE(根据VPN寻址)。
如果恰好TLB中缓存了该PTE,那么就返回给MMU,否则MMU需要从高速缓存/内存中获得PTE,然后更新缓存到TLB。
MMU获得了PTE,就可以从PTE中获得对应的PPN,然后结合VPO构造出物理地址。
如果在PTE中发现该虚拟页没有缓存在内存,那么会触发一个缺页异常。缺页异常处理程序会把虚拟页缓存进物理内存,并更新PTE。异常处理程序返回后,CPU会重新加载这个虚拟地址,并进行翻译。
虚拟内存系统简化了内存管理、链接、加载、代码和数据的共享以及访问权限的保护:
简化链接,独立的地址空间允许每个进程的内存映像使用相同的基本格式,而不管代码和数据实际存放在物理内存的何处。
简化加载,虚拟内存使向内存中加载可执行文件和共享对象文件变得更加容易。
简化共享,独立的地址空间为操作系统提供了一个管理用户进程和内核之间共享的一致机制。
访问权限保护,每个虚拟地址都要经过查询PTE的过程,在PTE中设定访问权限的标记位从而简化内存的权限保护。
操作系统通过将虚拟内存与文件系统结合的方式,来初始化虚拟内存区域,这个过程称为内存映射。应用程序显式分配内存的区域叫做堆,通过动态内存分配器来直接操作堆内存。
注解是JDK1.5
引入的一个语法糖,它主要用来当作元数据,简单的说就是用于解释数据的数据。在Java中,类、方法、变量、参数、包都可以被注解。很多开源框架都使用了注解,例如Spring
、MyBatis
、Junit
。我们平常最常见的注解可能就是@Override
了,该注解用来标识一个重写的函数。
注解的作用:
配置文件:替代xml
等文本文件格式的配置文件。使用注解作为配置文件可以在代码中实现动态配置,相比外部配置文件,注解的方式会减少很多文本量。但缺点也很明显,更改配置需要对代码进行重新编译,无法像外部配置文件一样进行集中管理(所以现在基本都是外部配置文件+注解混合使用)。
数据的标记:注解可以作为一个标记(例如:被@Override
标记的方法代表被重写的方法)。
减少重复代码:注解可以减少重复且乏味的代码。比如我们定义一个@ValidateInt
,然后通过反射来获得类中所有成员变量,只要是含有@ValidateInt
注解的成员变量,我们就可以对其进行数据的规则校验。
定义一个注解非常简单,只需要遵循以下的语法规则:
|
|
我们发现上面的代码在定义注解时也使用了注解,这些注解被称为元注解。作用于注解上的注解称为元注解(元注解其实就是注解的元数据),Java
中一共有以下元注解。
@Target
:用于描述注解的使用范围(注解可以用在什么地方)。
ElementType.CONSTRUCTOR:构造器。
ElementType.FIELD:成员变量。
ElementType.LOCAL_VARIABLE:局部变量。
ElementType.PACKAGE:包。
ElementType.PARAMETER:参数。
ElementType.METHOD:方法。
ElementType.TYPE:类、接口(包括注解类型) 或enum声明。
@Retention
:注解的生命周期,用于表示该注解会在什么时期保留。
RetentionPolicy.RUNTIME:运行时保留,这样就可以通过反射获得了。
RetentionPolicy.CLASS:在class文件中保留。
RetentionPolicy.SOURCE:在源文件中保留。
@Documented
:表示该注解会被作为被标注的程序成员的公共API,因此可以被例如javadoc此类的工具文档化。
@Inherited
:表示该注解是可被继承的(如果一个使用了@Inherited修饰的annotation类型被用于一个class,则这个annotation将被用于该class的子类)。
了解了这些基础知识之后,接着完成上述定义的@ValidateInt
,我们定义一个Cat
类然后在它的成员变量中使用@ValidateInt
,并通过反射进行数据校验。
|
|
本文作者为:SylvanasSun(sylvanas.sun@gmail.com),首发于SylvanasSun’s Blog。
原文链接:https://sylvanassun.github.io/2017/10/15/2017-10-15-JavaAnnotation/
(转载请务必保留本段声明,并且保留超链接。)
注解其实只是Java
的一颗语法糖(语法糖是一种方便程序员使用的语法规则,但它其实并没有表面上那么神奇的功能,只不过是由编译器帮程序员生成那些繁琐的代码)。在Java
中这样的语法糖还有很多,例如enum
、泛型、forEach
等。
通过阅读JLS(Java Language Specification(当你想了解一个语言特性的实现时,最好的方法就是阅读官方规范)发现,注解是一个继承自java.lang.annotation.Annotation
接口的特殊接口,原文如下:
|
|
|
|
我们将上节定义的@ValidateInt
注解进行反编译来验证这个说法。
|
|
public interface com.sun.annotation.ValidateInt extends java.lang.annotation.Annotation
,很明显ValidateInt
继承自java.lang.annotation.Annotation
。
那么,如果注解只是一个接口,又是如何实现对属性的设置呢?这是因为Java
使用了动态代理对我们定义的注解接口生成了一个代理类,而对注解的属性设置其实都是在对这个代理类中的变量进行赋值。所以我们才能用反射获得注解中的各种属性。
为了证实注解其实是个动态代理对象,接下来我们使用CLHSDB(Command-Line HotSpot Debugger)
来查看JVM
的运行时数据。如果有童鞋不了解怎么使用的话,可以参考R大的文章借HSDB来探索HotSpot VM的运行时数据 - Script Ahead, Code Behind - ITeye博客。
|
|
注解的类型为com/sun/proxy/$Proxy1
,这正是动态代理生成代理类的默认类型,com/sun/proxy
为默认包名,$Proxy
是默认的类名,1
为自增的编号。
我们在使用Spring
的时候,只需要指定一个包名,框架就会去扫描该包下所有带有Spring
中的注解的类。实现一个包扫描器很简单,主要思路如下:
先将传入的包名通过类加载器获得项目内的路径。
然后遍历并获得该路径下的所有class文件路径(需要处理为包名的格式)。
得到了class文件的路径就可以使用反射生成Class对象并获得其中的各种信息了。
定义包扫描器接口:
|
|
函数2需要传入一个ScannedClassHandler
接口,该接口是我们定义的回调函数,用于在扫描所有类文件之后执行的处理操作。
|
|
我想要包扫描器可以识别和支持不同的文件类型,定义一个枚举类ResourceType
:
|
|
PathUtils
是一个用来处理路径和包转换等操作的工具类:
|
|
定义了这些辅助类之后,就可以去实现包扫描器了。
|
|
函数getResource()
会根据包名来通过类加载器获得当前项目下的URL对象,如果这个URL为空则直接返回一个空的ArrayList
。
|
|
函数parseUrlThenScan()
会解析URL对象并进行扫描,最终返回一个类列表。
|
|
函数getClassListFromFile()
会扫描路径下的所有class文件,并拼接包名生成Class对象。
|
|
函数getClassListFromJar()
会扫描Jar中的class文件。
|
|
函数invokeCallback()
遍历类对象列表,然后执行回调函数。
|
|
本节中实现的包扫描器源码地址:https://gist.github.com/SylvanasSun/6ab31dcfd9670f29a46917decdba36d1
]]>很多网页都使用了看起来效果非常酷炫的动画与用户进行交互,这些动画效果显著提高了用户的体验,但如果因为性能原因导致动画的每秒帧数太低,反而会让用户体验变得更差(如果一个酷炫的动画效果运行起来总是经常卡顿或者看起来反应很慢,这些都会让用户感觉糟透了)。
一个流畅的动画需要保持在每秒60帧,换算成毫秒浏览器需要在10毫秒左右完成渲染任务(每秒有1000毫秒,1000/60 约等于 16毫秒一帧,但浏览器还有其他工作需要占用时间,所以估算为10毫秒),如果能够理解浏览器的渲染过程并发现性能瓶颈对其优化,可以使你的项目变得具有交互性且动画效果如飘柔般顺滑。
本文作者为: SylvanasSun(sylvanas.sun@gmail.com).转载请务必将本段话置于文章开头处(保留超链接).
本文首发自SylvanasSun Blog,原文链接: https://sylvanassun.github.io/2017/10/08/2017-10-08-BrowserRenderOptimization/
所谓像素管道其实就是浏览器将渲染树绘制成像素的流程。管道的每个区域都有可能产生卡顿,即管道中的某一区域如果发生变化,浏览器将会进行自动重排,然后重新绘制受影响的区域。
JavaScript:该区域其实指的是实现动画效果的方法,一般使用JavaScript
来实现动画,例如JQuery
的animate
函数、对一个数据集进行排序或动态添加一些DOM
节点等。当然,也可以使用其他的方法来实现动画效果,像CSS
的Animation
、Transition
和Transform
。
Style:该区域为样式计算阶段,浏览器会根据选择器(就是CSS
选择器,如.td
)计算出哪些节点应用哪些CSS
规则,然后计算出每个节点的最终样式并应用到节点上。
Layout:该区域为布局计算阶段,浏览器会在该过程中根据节点的样式规则来计算它要占据的空间大小以及在屏幕中的位置。
Paint:该区域为绘制阶段,浏览器会先创建绘图调用的列表,然后填充像素。绘制阶段会涉及到文本、颜色、图像、边框和阴影,基本上包括了每个可视部分。绘制一般是在多个图层(用过Photoshop
等图片编辑软件的童鞋一定很眼熟图层这个词,这里的图层的含义其实是差不多的)上完成的。
Composite:该区域为合成阶段,浏览器将多个图层按照正确顺序绘制到屏幕上。
假设我们修改了一个几何属性(例如宽度、高度等影响布局的属性),这时Layout阶段受到了影响,浏览器必须检查所有其他区域的元素,然后自动重排页面,任何受到影响的部分都需要重新绘制,并且最终绘制的元素还需要重新进行合成(简单地说就是整个像素管道都要重新执行一遍)。
如果我们只修改了不会影响页面布局的属性,例如背景图片、文字颜色等,那么浏览器会跳过布局阶段,但仍需要重新绘制。
又或者,我们只修改了一个不影响布局也不影响绘制的属性,那么浏览器将跳过布局与绘制阶段,显然这种改动是性能开销最小的。
如果想要知道每个CSS
属性将会对哪个阶段产生怎样的影响,请去CSS Triggers,该网站详细地说明了每个CSS
属性会影响到哪个阶段。
我们经常使用JavaScript
来实现动画效果,然而时机不当或长时间运行的JavaScript
可能就是导致你性能下降的原因。
避免使用setTimeout()
或者setInterval()
函数来实现动画效果,这种做法的主要问题是回调将会在帧中的某个时间点运行,这可能会刚好在末尾(会丢失帧导致发生卡顿)。
有些第三方库仍在使用setTimeout()&setInterval()
函数来实现动画效果,这会产生很多不必要的性能下降,例如老版本的JQuery
,如果你使用的是JQuery3
,那么不必为此担心,JQuery3
已经全面改写了动画模块,采用了requestAnimationFrame()
函数来实现动画效果。但如果你使用的是之前版本的JQuery
,那么就需要jquery-requestAnimationFrame来将setTimeout()
替换为requestAnimationFrame()
函数。
读到这里,想必一定会对requestAnimationFrame()
产生好奇。要想得到一个流畅的动画,我们希望让视觉变化发生在每一帧的开头,而保证JavaScript
在帧开始时运行的方式则是使用requestAnimationFrame()
函数,本质上它与setTimeout()
没有什么区别,都是在递归调用同一个回调函数来不断更新画面以达到动画的效果,requestAnimationFrame()
的使用方法如下:
|
|
并不是所有浏览器都支持requestAnimationFrame()
函数,如IE9
(又是万恶的IE
),但基本上现代浏览器都会支持这个功能的,如果你需要兼容老旧版本的浏览器,可以使用以下函数。
|
|
我们知道JavaScript
是单线程的,但浏览器可不是单线程的。JavaScript
在浏览器的主线程上运行,这恰好与样式计算、布局等许多其他情况下的渲染操作一起运行,如果JavaScript
的运行时间过长,就会阻塞这些后续工作,导致帧丢失。
使用Chrome
开发者工具的Timeline
功能可以帮助我们查看每个JavaScript
脚本的运行时间(包括子脚本),帮助我们发现并突破性能瓶颈。
在找到影响性能的JavaScript
脚本后,我们可以通过Web Workers
进行优化。Web Workers
是HTML5
提出的一个标准,它可以让JavaScript
脚本运行在后台线程(类似于创建一个子线程),而后台线程不会影响到主线程中的页面。不过,使用Web Workers
创建的线程是不能操作DOM
树的(这也是Web Workers
没有颠覆JavaScript
是单线程的原因,JavaScript
之所以一直是单线程设计主要也是因为为了避免多个脚本操作DOM
树的同步问题,这会提高很多复杂性),所以它只适合于做一些纯计算的工作(数据的排序、遍历等)。
如果你的JavaScript
必须要在主线程中执行,那么只能选择另一种方法。将一个大任务分割为多个小任务(每个占用时间不超过几毫秒),并且在每帧的requestAnimationFrame()
函数中运行:
|
|
创建一个Web Workers
对象很简单,只需要调用Worker()
构造器,然后传入指定脚本的URI
。现代主流浏览器均支持Web Workers
,除了Internet Explorer
(又是万恶的IE),所以我们在下面的示例代码中还需要检测浏览器是否兼容。
|
|
Web Workers
与主线程之间通过postMessage()
函数来发送信息,使用onmessage()
事件处理函数来响应消息(主线程与子线程之间并没有共享数据,只是通过复制数据来交互)。
|
|
如果你需要从主线程中立刻终止一个运行中的worker,可以调用worker的terminate()
函数:
|
|
myWorker会被立即杀死,不会有任何机会让它继续完成剩下的工作。而在worker线程中也可以调用close()
函数进行关闭:
|
|
有关更多的Web Workers
使用方法,请参考Using Web Workers - Web APIs | MDN。
每次修改DOM
和CSS
都会导致浏览器重新计算样式,在很多情况下还会对页面或页面的一部分重新进行布局计算。
计算样式的第一部分是创建一组匹配选择器(用于计算哪些节点应用哪些样式),第二部分涉及从匹配选择器中获取所有样式规则,并计算出节点的最终样式。
通过降低选择器的复杂性可以提升样式计算的速度。
下面是一个复杂的CSS
选择器:
|
|
浏览器如果想要找到应用该样式的节点,需要先找到有.title
类的节点,然后其父节点正好是负n个子元素+1个带.box
类的节点。浏览器计算此结果可能需要大量的时间,但我们可以把选择器的预期行为更改为一个类:
|
|
我们只是将CSS
的命名模块化(降低选择器的复杂性),然后只让浏览器简单地将选择器与节点进行匹配,这样浏览器计算样式的效率会提升许多。
BEM
是一种模块化的CSS
命名规范,使用这种方法组织CSS
不仅结构上十分清晰,也对浏览器的样式查找提供了帮助。
BEM
其实就是Block,Element,Modifier
,它是一种基于组件的开发方式,其背后的思想就是将用户界面划分为独立的块。这样即使是使用复杂的UI
也可以轻松快速地开发,并且模块化的方式可以提高代码的复用性。
Block
是一个功能独立的页面组件(可以被重用),Block
的命名方式就像写Class
名一样。如下面的.button
就是代表<button>
的Block
。
|
|
Element
是一个不能单独使用的Block
的复合部分。可以认为Element
是Block
的子节点。
|
|
Modifier
是用于定义Block
或Element
的外观、状态或行为的实体。假设,我们有了一个新的需求,对button
的背景颜色使用绿色,那么我们可以使用Modifier
对.button
进行一次扩展:
|
|
第一次接触BEM
的童鞋可能会对这种命名方式感到奇怪,但BEM
重要的是模块化与可维护性的思想,至于命名完全可以按照你所能接受的方式修改。限于篇幅,本文就不再继续探讨BEM
了,感兴趣的童鞋可以去看BEM的官方文档。
浏览器每次进行布局计算时几乎总是会作用到整个DOM
,如果有大量元素,那么将会需要很长时间才能计算出所有元素的位置与尺寸。
所以我们应当尽量避免在运行时动态地修改几何属性(宽度、高度等),因为这些改动都会导致浏览器重新进行布局计算。如果无法避免,那么要优先使用Flexbox
,它会尽量减少布局所需的开销。
强制同步布局就是使用JavaScript
强制浏览器提前执行布局。需要先明白一点,在JavaScript
运行时,来自上一帧的所有旧布局值都是已知的。
以下代码为例,它在每一帧的开头输出了元素的高度:
|
|
但如果在请求高度之前,修改了其样式,就会出现问题,浏览器必须先应用样式,然后进行布局计算,之后才能返回正确的高度。这是不必要的且会产生非常大的开销。
|
|
正确的做法,应该利用浏览器可以使用上一帧布局值的特性,然后再执行任何写操作:
|
|
如果接二连三地发生强制同步布局,那么就会产生布局抖动。以下代码循环处理一组段落,并设置每个段落的宽度以匹配一个名为“box”的元素的宽度。
|
|
这段代码的问题在于每次迭代都会读取box.offsetWidth
,然后立即使用此值来更新段落的宽度。在循环的下次迭代中,浏览器必须考虑样式更新这一事实(box.offsetWidth
是在上一次迭代中请求的),因此它必须应用样式更改,然后执行布局。这会导致每次迭代都会产生强制同步布局,正确的做法应该先读取值,然后再写入值。
|
|
要想轻松地解决这个问题,可以使用FastDOM进行批量读取与写入,它可以防止强制布局同步与布局抖动。
在像素管道一节中,我们发现有种属性修改后会跳过布局与绘制阶段,这显然会减少不少性能开销。目前只有两种属性符合这个条件:transform
和opacity
。
需要注意的是,使用transform
和opacity
时,更改这些属性所在的元素应处于其自身的图层,所以我们需要将设置动画的元素单独新建一个图层(这样做的好处是该图层上的重绘可以在不影响其他图层上元素的情况下进行处理。如果你用过Photoshop
,想必能够理解多图层工作的方便之处)。
创建新图层的最佳方式是使用will-change
属性,该属性告知浏览器该元素会有哪些变化,这样浏览器可以在元素属性真正发生变化之前提前做好对应的优化准备工作。
|
|
但不要认为will-change
可以提高性能就随便滥用,使用will-change
进行预优化与创建图层都需要额外的内存和管理开销,随便滥用只会得不偿失。
HTTP
协议,并且用浏览器作为入口访问网络上的资源。用户在使用浏览器访问一个网站时需要先通过HTTP
协议向服务器发送请求,之后服务器返回HTML
文件与响应信息。这时,浏览器会根据HTML
文件来进行解析与渲染(该阶段还包括向服务器请求非内联的CSS
文件与JavaScript
文件或者其他资源),最终再将页面呈现在用户面前。
现在知道了网页的渲染都是由浏览器完成的,那么如果一个网站的页面加载速度太慢会导致用户体验不够友好,本文通过详解浏览器渲染页面的过程来引入一些基本的浏览器性能优化方案。让浏览器更快地渲染你的网页并快速响应从而提高用户体验。
本文作者为: SylvanasSun(sylvanas.sun@gmail.com).转载请务必将下面这段话置于文章开头处(保留超链接).
本文首发自SylvanasSun Blog,原文链接: https://sylvanassun.github.io/2017/10/03/2017-10-03-BrowserCriticalRenderingPath
浏览器接收到服务器返回的HTML
、CSS
和JavaScript
字节数据并对其进行解析和转变成像素的渲染过程被称为关键渲染路径。通过优化关键渲染路径即可以缩短浏览器渲染页面的时间。
浏览器在渲染页面前需要先构建出DOM
树与CSSOM
树(如果没有DOM
树和CSSOM
树就无法确定页面的结构与样式,所以这两项是必须先构建出来的)。
DOM
树全称为Document Object Model
文档对象模型,它是HTML
和XML
文档的编程接口,提供了对文档的结构化表示,并定义了一种可以使程序对该结构进行访问的方式(比如JavaScript
就是通过DOM
来操作结构、样式和内容)。DOM
将文档解析为一个由节点和对象组成的集合,可以说一个WEB
页面其实就是一个DOM
。
CSSOM
树全称为Cascading Style Sheets Object Model
层叠样式表对象模型,它与DOM
树的含义相差不大,只不过它是CSS
的对象集合。
浏览器从网络或硬盘中获得HTML
字节数据后会经过一个流程将字节解析为DOM
树:
编码: 先将HTML
的原始字节数据转换为文件指定编码的字符。
令牌化: 然后浏览器会根据HTML
规范来将字符串转换成各种令牌(如<html>
、<body>
这样的标签以及标签中的字符串和属性等都会被转化为令牌,每个令牌具有特殊含义和一组规则)。令牌记录了标签的开始与结束,通过这个特性可以轻松判断一个标签是否为子标签(假设有<html>
与<body>
两个标签,当<html>
标签的令牌还未遇到它的结束令牌</html>
就遇见了<body>
标签令牌,那么<body>
就是<html>
的子标签)。
生成对象: 接下来每个令牌都会被转换成定义其属性和规则的对象(这个对象就是节点对象)。
构建完毕: DOM
树构建完成,整个对象集合就像是一棵树形结构。可能有人会疑惑为什么DOM
是一个树形结构,这是因为标签之间含有复杂的父子关系,树形结构正好可以诠释这个关系(CSSOS
同理,层叠样式也含有父子关系。例如: div p {font-size: 18px}
,会先寻找所有p
标签并判断它的父标签是否为div
之后才会决定要不要采用这个样式进行渲染)。
整个DOM
树的构建过程其实就是: 字节 -> 字符 -> 令牌 -> 节点对象 -> 对象模型,下面将通过一个示例HTML
代码与配图更形象地解释这个过程。
|
|
当上述HTML
代码遇见<link>
标签时,浏览器会发送请求获得该标签中标记的CSS
文件(使用内联CSS
可以省略请求的步骤提高速度,但没有必要为了这点速度而丢失了模块化与可维护性),style.css
中的内容如下:
|
|
浏览器获得外部CSS
文件的数据后,就会像构建DOM
树一样开始构建CSSOM
树,这个过程没有什么特别的差别。
如果想要更详细地去体验一下关键渲染路径的构建,可以使用Chrome
开发者工具中的Timeline
功能,它记录了浏览器从请求页面资源一直到渲染的各种操作过程,甚至还可以录制某一时间段的过程(建议不要去看太大的网站,信息会比较杂乱)。
在构建了DOM
树和CSSOM
树之后,浏览器只是拥有了两个互相独立的对象集合,DOM
树描述了文档的结构与内容,CSSOM
树则描述了对文档应用的样式规则,想要渲染出页面,就需要将DOM
树与CSSOM
树结合在一起,这就是渲染树。
浏览器会先从DOM
树的根节点开始遍历每个可见节点(不可见的节点自然就没必要渲染到页面了,不可见的节点还包括被CSS
设置了display: none
属性的节点,值得注意的是visibility: hidden
属性并不算是不可见属性,它的语义是隐藏元素,但元素仍然占据着布局空间,所以它会被渲染成一个空框)。
对每个可见节点,找到其适配的CSS
样式规则并应用。
渲染树构建完成,每个节点都是可见节点并且都含有其内容和对应规则的样式。
渲染树构建完毕后,浏览器得到了每个可见节点的内容与其样式,下一步工作则需要计算每个节点在窗口内的确切位置与大小,也就是布局阶段。
CSS
采用了一种叫做盒子模型的思维模型来表示每个节点与其他元素之间的距离,盒子模型包括外边距(Margin
),内边距(Padding
),边框(Border
),内容(Content
)。页面中的每个标签其实都是一个个盒子。
布局阶段会从渲染树的根节点开始遍历,然后确定每个节点对象在页面上的确切大小与位置,布局阶段的输出是一个盒子模型,它会精确地捕获每个元素在屏幕内的确切位置与大小,所有相对的测量值也都会被转换为屏幕内的绝对像素值。
|
|
当Layout
布局事件完成后,浏览器会立即发出Paint Setup
与Paint
事件,开始将渲染树绘制成像素,绘制所需的时间跟CSS
样式的复杂度成正比,绘制完成后,用户就可以看到页面的最终呈现效果了。
我们对一个网页发送请求并获得渲染后的页面可能也就经过了1~2秒,但浏览器其实已经做了上述所讲的非常多的工作,总结一下浏览器关键渲染路径的整个过程:
处理HTML
标记数据并生成DOM
树。
处理CSS
标记数据并生成CSSOM
树。
将DOM
树与CSSOM
树合并在一起生成渲染树。
遍历渲染树开始布局,计算每个节点的位置信息。
将每个节点绘制到屏幕。
浏览器想要渲染一个页面就必须先构建出DOM
树与CSSOM
树,如果HTML
与CSS
文件结构非常庞大与复杂,这显然会给页面加载速度带来严重影响。
所谓渲染阻塞资源,即是对该资源发送请求后还需要先构建对应的DOM
树或CSSOM
树,这种行为显然会延迟渲染操作的开始时间。HTML
、CSS
、JavaScript
都是会对渲染产生阻塞的资源,HTML
是必需的(没有DOM
还谈何渲染),但还可以从CSS
与JavaScript
着手优化,尽可能地减少阻塞的产生。
如果可以让CSS
资源只在特定条件下使用,这样这些资源就可以在首次加载时先不进行构建CSSOM
树,只有在符合特定条件时,才会让浏览器进行阻塞渲染然后构建CSSOM
树。
CSS
的媒体查询正是用来实现这个功能的,它由媒体类型以及零个或多个检查特定媒体特征状况的表达式组成。
|
|
使用媒体查询可以让CSS
资源不在首次加载中阻塞渲染,但不管是哪种CSS
资源它们的下载请求都不会被忽略,浏览器仍然会先下载CSS文件
当浏览器的HTML
解析器遇到一个script
标记时会暂停构建DOM
,然后将控制权移交至JavaScript
引擎,这时引擎会开始执行JavaScript
脚本,直到执行结束后,浏览器才会从之前中断的地方恢复,然后继续构建DOM
。每次去执行JavaScript
脚本都会严重地阻塞DOM
树的构建,如果JavaScript
脚本还操作了CSSOM
,而正好这个CSSOM
还没有下载和构建,浏览器甚至会延迟脚本执行和构建DOM
,直至完成其CSSOM
的下载和构建。显而易见,如果对JavaScript
的执行位置运用不当,这将会严重影响渲染的速度。
下面代码中的JavaScript
脚本并不会生效,这是因为DOM
树还没有构建到<p>
标签时,JavaScript
脚本就已经开始执行了。这也是为什么经常有人在HTML
文件的最下方写内联JavaScript
代码,又或者使用window.onload()
和JQuery
中的$(function(){})
(这两个函数有一些区别,window.onload()
是等待页面完全加载完毕后触发的事件,而$(function(){})
在DOM
树构建完毕后就会执行)。
|
|
使用async
可以通知浏览器该脚本不需要在引用位置执行,这样浏览器就可以继续构建DOM
,JavaScript
脚本会在就绪后开始执行,这样将显著提升页面首次加载的性能(async
只可以在src
标签中使用也就是外部引用的JavaScript
文件)。
|
|
上文已经完整讲述了浏览器是如何渲染页面的以及渲染之前的准备工作,接下来我们以下面的案例来总结一下优化关键渲染路径的方法。
假设有一个HTML
页面,它只引入了一个CSS
外部文件:
|
|
它的关键渲染路径如下:
首先浏览器要先对服务器发送请求获得HTML
文件,得到HTML
文件后开始构建DOM
树,在遇见<link>
标签时浏览器需要向服务器再次发出请求来获得CSS
文件,然后则是继续构建DOM
树和CSSOM
树,浏览器合并出渲染树,根据渲染树进行布局计算,执行绘制操作,页面渲染完成。
有以下几个用于描述关键渲染路径性能的词汇:
关键资源:可能阻塞网页首次渲染的资源(上图中为2个,HTML
文件与外部CSS
文件style.css
)。
关键路径长度: 获取关键资源所需的往返次数或总时间(上图为2次或以上,一次获取HTML
文件,一次获取CSS
文件,这个次数基于TCP
协议的最大拥塞窗口,一个文件不一定能在一次连接内传输完毕)。
关键字节:所有关键资源文件大小的总和(上图为9KB
)。
接下来,案例代码的需求发生了变化,它新增了一个JavaScript
文件。
|
|
JavaScript
文件阻塞了DOM
树的构建,并且在执行JavaScript
脚本时还需要先等待构建CSSOM
树,上图的关键渲染路径特性如下:
关键资源: 3(HTML
、style.css
、app.js
)
关键路径长度: 2或以上(浏览器会在一次连接中一起下载style.css
和app.js
)
关键字节:11KB
现在,我们要优化关键渲染路径,首先将<script>
标签添加异步属性async
,这样浏览器的HTML
解析器就不会阻塞这个JavaScript
文件了。
|
|
关键资源:2(app.js
为异步加载,不会成为阻塞渲染的资源)
关键路径长度: 2或以上
关键字节: 9KB(app.js
不再是关键资源,所以没有算上它的大小)
接下来对CSS
进行优化,比如添加上媒体查询。
|
|
关键资源:1(app.js
为异步加载,style.css
只有在打印时才会使用,所以只剩下HTML
一个关键资源,也就是说当DOM
树构建完毕,浏览器就会开始进行渲染)
关键路径长度:1或以上
关键字节:5KB
优化关键渲染路径就是在对关键资源、关键路径长度和关键字节进行优化。关键资源越少,浏览器在渲染前的准备工作就越少;同样,关键路径长度和关键字节关系到浏览器下载资源的效率,它们越少,浏览器下载资源的速度就越快。
除了异步加载JavaScript
和使用媒体查询外还有很多其他的优化方案可以使页面的首次加载变得更快,这些方案可以综合起来使用,但核心的思想还是针对关键渲染路径进行了优化。
服务端在接收到请求时先只响应回HTML
的初始部分,后续的HTML
内容在需要时再通过AJAX
获得。由于服务端只发送了部分HTML
文件,这让构建DOM
树的工作量减少很多,从而让用户感觉页面的加载速度很快。
注意,这个方法不能用在CSS
上,浏览器不允许CSSOM
只构建初始部分,否则会无法确定具体的样式。
通过对外部资源进行压缩可以大幅度地减少浏览器需要下载的资源量,它会减少关键路径长度与关键字节,使页面的加载速度变得更快。
对数据进行压缩其实就是使用更少的位数来对数据进行重编码。如今有非常多的压缩算法,且每一个的作用领域也各不相同,它们的复杂度也不相同,不过在这里我不会讲压缩算法的细节,感兴趣的朋友可以自己Google。
在对HTML
、CSS
和JavaScript
这些文件进行压缩之前,还需要先进行一次冗余压缩。所谓冗余压缩,就是去除多余的字符,例如注释、空格符和换行符。这些字符对于程序员是有用的,毕竟没有格式化的代码可读性是非常恐怖的,但它们对于浏览器是没有任何意义的,去除这些冗余可以减少文件的数据量。在进行完冗余压缩之后,再使用压缩算法进一步对数据本身进行压缩,例如GZIP
(GZIP
是一个可以作用于任何字节流的通用压缩算法,它会记忆之前已经看到的内容,然后再尝试查找并替换重复的内容。)。
通过网络来获取资源通常是缓慢的,如果资源文件过于膨大,浏览器还需要与服务器之间进行多次往返通信才能获得完整的资源文件。缓存可以复用之前获取的资源,既然后端可以使用缓存来减少访问数据库的开销,那前端自然也可以使用缓存来复用资源文件。
浏览器自带了HTTP
缓存的功能,只需要确保每个服务器响应的头部都包含了以下的属性:
ETag: ETag是一个传递验证令牌,它对资源的更新进行检查,如果资源未发生变化时不会传送任何数据。当浏览器发送一个请求时,会把ETag一起发送到服务器,服务器会根据当前资源核对令牌(ETag通常是对内容进行Hash
后得出的一个指纹),如果资源未发生变化,服务器将返回304 Not Modified
响应,这时浏览器不必再次下载资源,而是继续复用缓存。
Cache-Control: Cache-Control定义了缓存的策略,它规定在什么条件下可以缓存响应以及可以缓存多久。
no-cache: no-cache表示必须先与服务器确认返回的响应是否发生了变化,然后才能使用该响应来满足后续对同一网址的请求(每次都会根据ETag对服务器发送请求来确认变化,如果未发生变化,浏览器不会下载资源)。
no-store: no-store直接禁止浏览器以及所有中间缓存存储任何版本的返回响应。简单的说,该策略会禁止任何缓存,每次发送请求时,都会完整地下载服务器的响应。
public&private: 如果响应被标记为public,则即使它有关联的HTTP
身份验证,甚至响应状态代码通常无法缓存,浏览器也可以缓存响应。如果响应被标记为private,那么这个响应通常只为单个用户缓存,因此不允许任何中间缓存(CDN)对其进行缓存,private一般用在缓存用户私人信息页面。
max-age: max-age定义了从请求时间开始,缓存的最长时间,单位为秒。
Pre-fetching
是一种提示浏览器预先加载用户之后可能会使用到的资源的方法。
使用dns-prefetch
来提前进行DNS
解析,以便之后可以快速地访问另一个主机名(浏览器会在加载网页时对网页中的域名进行解析缓存,这样你在之后的访问时无需进行额外的DNS解析,减少了用户等待时间,提高了页面加载速度)。
|
|
使用prefetch
属性可以预先下载资源,不过它的优先级是最低的。
|
|
Chrome
允许使用subresource
属性指定优先级最高的下载资源(当所有属性为subresource
的资源下载完完毕后,才会开始下载属性为prefetch
的资源)。
|
|
prerender
可以预先渲染好页面并隐藏起来,之后打开这个页面会跳过渲染阶段直接呈现在用户面前(推荐对用户接下来必须访问的页面进行预渲染,否则得不偿失)。
|
|
如果是没有接触过爬虫的人可能会有些许疑惑,爬虫是个什么东西呢?其实爬虫的概念很简单,在互联网时代,万维网已然是大量信息的载体,如何有效地利用并提取这些信息是一个巨大的挑战。当我们使用浏览器对某个网站发送请求时,服务器会响应HTML
文本并由浏览器来进行渲染显示。爬虫正是利用了这一点,通过程序模拟用户的请求,来获得HTML
的内容,并从中提取需要的数据和信息。如果把网络想象成一张蜘蛛网,爬虫程序则像是蜘蛛网上的蜘蛛,不断地爬取数据与信息。
爬虫的概念非常简单易懂,利用python
内置的urllib
库都可以实现一个简单的爬虫,下面的代码是一个非常简单的爬虫,只要有基本的python
知识应该都能看懂。它会收集一个页面中的所有<a>
标签(没有做任何规则判断)中的链接,然后顺着这些链接不断地进行深度搜索。
|
|
但是我们如果想要实现一个性能高效的爬虫,那需要的复杂度也会增长,本文旨在快速实现,所以我们需要借助他人实现的爬虫框架来当做脚手架,在这之上来构建我们的图片爬虫(如果有时间的话当然也鼓励自己造轮子啦)。
本文作者为: SylvanasSun(sylvanas.sun@gmail.com).转载请务必将下面这段话置于文章开头处(保留超链接).
本文首发自SylvanasSun Blog,原文链接: https://sylvanassun.github.io/2017/09/20/2017-09-20-PictureSpider/
BeautifulSoup是一个用于从HTML
和XML
中提取数据的python
库。Beautiful Soup自动将输入文档转换为Unicode编码,输出文档转换为utf-8编码。你不需要考虑编码方式,除非文档没有指定一个编码方式,这时,Beautiful Soup就不能自动识别编码方式了。然后,你仅仅需要说明一下原始编码方式就可以了。
利用好BeautifulSoup可以为我们省去许多编写正则表达式的时间,如果当你需要更精准地进行搜索时,BeautifulSoup也支持使用正则表达式进行查询。
BeautifulSoup3已经停止维护了,现在基本使用的都是BeautifulSoup4,安装BeautifulSoup4很简单,只需要执行以下的命令。
|
|
然后从bs4
模块中导入BeautifulSoup对象,并创建这个对象。
|
|
创建BeautifulSoup对象需要传入两个参数,第一个是需要进行解析的HTML
内容,第二个参数为解析器的名字(如果不传入这个参数,BeautifulSoup会默认使用python
内置的解析器html.parser
)。BeautifulSoup支持多种解析器,有lxml
、html5lib
、html.parser
。
第三方解析器需要用户自己安装,本文中使用的是lxml
解析器,安装命令如下(它还需要先安装C语言库)。
|
|
下面以一个例子演示使用BeautifulSoup的基本方式,如果还想了解更多可以去参考BeautifulSoup文档。
|
|
Scrapy
是一个功能强大的爬虫框架,它已经实现了一个性能高效的爬虫结构,并提供了很多供程序员自定义的配置。使用Scrapy
只需要在它的规则上编写我们的爬虫逻辑即可。
首先需要先安装Scrapy
,执行命令pip install scrapy
。然后再执行命令scrapy startproject 你的项目名
来生成Scrapy
的基本项目文件夹。生成的项目结构如下。
|
|
scrapy.cfg
: 项目的配置文件。
items.py
:物品模块,用户需要在这个模块中定义数据封装的实体类。
pipelines.py
:管道模块,用户需要在这个模块中定义处理数据的逻辑(如存储到数据库等)。
settings.py
:这个模块定义了整个项目中的各种配置变量。
spiders/
:在这个包中定义用户自己的爬虫模块。
启动Scrapy
的爬虫也很简单,只需要执行命令scrapy crawl 你的爬虫名
。下面介绍Scrapy
中的关键模块的演示案例,如果想要了解有关Scrapy
的更多信息,请参考Scrapy官方文档。
items
模块主要是为了将爬取到的非结构化数据封装到一个结构化对象中,自定义的item
类必须继承自scrapy.Item
,且每个属性都要赋值为scrapy.Field()
。
|
|
操作item
对象就像操作一个dict
对象一样简单。
|
|
当一个Item
经由爬虫封装之后将会到达Pipeline
类,你可以定义自己的Pipeline
类来决定将Item
的处理策略。
每个Pipeline
可以实现以下函数。
process_item(item, spider)
: 每个Pipeline
都会调用此函数来处理Item
,这个函数必须返回一个Item
,如果在处理过程中遇见错误,可以抛出DropItem
异常。
open_spider(spider)
: 当spider
开始时将会调用此函数,可以利用这个函数进行打开文件等操作。
close_spider(spider)
:当spider
关闭时将会调用此函数,可以利用这个函数对IO
资源进行关闭。
from_crawler(cls, crawler)
: 这个函数用于获取settings.py
模块中的属性。注意这个函数是一个类方法。
|
|
当定义完你的Pipeline
后,还需要在settings.py
中对你的Pipeline
进行设置。
|
|
在spiders
模块中,用户可以通过自定义Spider
类来制定自己的爬虫逻辑与数据封装策略。每个Spider
都必须继承自class scrapy.spider.Spider
,这是Scrapy
中最简单的爬虫基类,它没有什么特殊功能,Scrapy
也提供了其他功能不同的Spider
类供用户选择,这里就不多叙述了,可以去参考官方文档。
用户可以通过以下属性来自定义配置Spider
:
name
: 这是Spider
的名称,Scrapy
需要通过这个属性来定位Spider
并启动爬虫,它是唯一且必需的。
allowed_domains
: 这个属性规定了Spider
允许爬取的域名。
start_urls
: Spider
开始时将抓取的网页列表。
start_requests()
: 该函数是Spider
开始抓取时启动的函数,它只会被调用一次,有的网站必须要求用户登录,可以使用这个函数先进行模拟登录。
make_requests_from_url(url)
: 该函数接收一个url
并返回Request
对象。除非重写该函数,否则它会默认以parse(response)
函数作为回调函数,并启用dont_filter
参数(这个参数是用于过滤重复url
的)。
parse(response)
: 当请求没有设置回调函数时,则会默认调用parse(response)
。
log(message[, level, component])
: 用于记录日志。
closed(reason)
: 当Spider
关闭时调用。
|
|
Requests
也是一个第三方python
库,它比python
内置的urllib
更加简单好用。只需要安装(pip install requests
),然后导包后,即可轻松对网站发起请求。
|
|
关于更多的参数与内容请参考Requests文档。
BloomFilter
是一个用于过滤重复数据的数据结构,我们可以使用它来对重复的url
进行过滤。本文使用的BloomFilter
来自于python-bloomfilter,其他操作系统用户请使用pip install pybloom
命令安装,windows用户请使用pip install pybloom-live
(原版对windows不友好)。
介绍了需要的依赖库之后,我们终于可以开始实现自己的图片爬虫了。我们的目标是爬https://www.deviantart.com/
网站中的图片,在写爬虫程序之前,还需要先分析一下页面的HTML
结构,这样才能针对性地找到图片的源地址。
为了保证爬到的图片的质量,我决定从热门页面开始爬,链接为https://www.deviantart.com/whats-hot/
。
打开浏览器的开发者工具后,可以发现每个图片都是由一个a
标签组成,每个a
标签的class
为torpedo-thumb-link
,而这个a
标签的href
正好就是这张图片的详情页面(如果我们从这里就开始爬图片的话,那么爬到的可都只是缩略图)。
进入到详情页后,不要马上爬取当前图片的源地址,因为当前页显示的图片并不是原始格式,我们对图片双击放大之后再使用开发者工具抓到这个图片所在的img
标签后,再让爬虫获取这个标签中的源地址。
在获得图片的源地址之后,我的策略是让爬虫继续爬取该页中推荐的更多图片,通过开发者工具,可以发现这些图片都被封装在一个class
为tt-crop thumb
的div
标签中,而该标签里的第一个a
子标签正好就是这个图片的详情页链接。
在对网页的HTML
进行分析之后,可以开始写程序了,首先先用Scrapy
的命令来初始化项目。之后在settings.py
中做如下配置。
|
|
然后定义我们的Item
。
|
|
创建自己的spider
模块与Spider
类。
|
|
DeviantArtImageSpider
继承自CrawlSpider
,该类是Scrapy
最常用的Spider
类,它通过Rule
类来定义爬取链接的规则,上述代码中使用了正则表达式https://www.deviantart.com/whats-hot/[\?\w+=\d+]*
,这个正则表达式将访问每一页的热门页面。
爬虫启动时将会先访问热门页面,请求得到响应之后会调用回调函数,我们需要在这个回调函数中获取上述分析中得到的<a class = 'torpedo-thumb-link'>
标签,然后抽取出每张图片的详情页链接。
|
|
parse_page()
函数会不断地发送请求到详情页链接,解析详情页的回调函数需要处理数据封装到Item
,还需要提取详情页中更多图片的详情链接然后发送请求。
|
|
对于Item
的处理,只是简单地将图片命名与下载到本地。我没有使用多进程或者多线程,也没有使用Scrapy
自带的ImagePipeline
(自由度不高),有兴趣的童鞋可以自己选择实现。
|
|
在settings.py
中注册该Pipeline
|
|
有些网站会有反爬虫机制,为了解决这个问题,每次请求都使用不同的IP
代理,有很多网站提供IP
代理服务,我们需要写一个爬虫从云代理中抓取它提供的免费IP
代理(免费IP
很不稳定,而且我用了代理之后反而各种请求失败了Orz…)。
|
|
得到了IP
代理池之后,还要在Scrapy
的middlewares.py
模块定义代理中间件类。
|
|
最后在settings.py
中进行注册。
|
|
我们的图片爬虫已经完成了,执行命令scrapy crawl deviant_art_image_spider
,然后尽情搜集图片吧!
想要获得本文中的完整源代码与P站爬虫请点我,顺便求个star…
]]>最近心血来潮想要写爬虫,所以花了点时间过了一遍
python
语法便匆匆上手了,代码写的有点丑也不够pythonic,各位看官求请吐槽。
冯诺依曼结构起源于EDVAC(Electronic Discrete Variable Automatic Computer)
离散变量自动电子计算机,当时冯诺依曼以技术顾问的身份加入EDVAC
项目组,负责总结和详细说明EDVAC
的逻辑设计,直到1945年6月发表了一份长达101页的报告,这就是计算机史上著名的”101页报告”,该报告明确规定用二进制替代十进制运算,并将计算机分成五大组件,这一卓越的思想为电子计算机的逻辑结构设计奠定了基础,已成为计算机设计的基本原则.
冯诺依曼结构具有以下特点:
数据由一个贯穿整个结构的总线来进行传输.
存储器是按地址访问、线性编址的空间
指令由操作码和地址码组成
数据以二进制编码
一个冯诺依曼结构的计算机必须有存储器,控制单元,运算单元,输入输出设备.
冯诺依曼结构将CPU
与存储器分开的做法也并非十全十美,CPU
和内存、硬盘等设备的数据传输速度不匹配成了整体效率的瓶颈,CPU
会在等待数据输入的时间中空置,许多技术都是为了解决这个瓶颈,例如DMA(直接内存访问)
,在CPU
中建立高速缓冲区等.
本文作者为: SylvanasSun(sylvanas.sun@gmail.com).转载请务必将下面这段话置于文章开头处(保留超链接).
本文首发自SylvanasSun Blog,原文链接: https://sylvanassun.github.io/2017/09/08/2017-09-08-ComputerStructure/
现代计算机是基于冯诺依曼结构的电子计算机.所谓电子计算机,就是是一种利用电子学原理,根据一系列指令对数据进行处理的机器.
晶体管是组成现代电子计算机的最原始的部件(集成电路中含有数以亿计的晶体管),它是一种半导体材料(导电性可受控制,范围可从绝缘体至导体之间),晶体管可以通过电流的变化,实现电路的切换,这种特性非常适合组成各种逻辑门(与或非)与表示二进制数据.值得一提的是,早期使用继电器实现逻辑门的计算机体积甚至大到要一整个屋子才能放下.
现代计算机的硬件结构如下图,虽然多了很多其他的硬件但与冯诺依曼结构的概念是一致的:
总线是一组贯穿所有硬件结构的电子管道,它携带数据并负责在各个部件间交互传递.总线传送的数据通常为一个定长的字节块,这个字节块的长度即是总线的位宽,总线位宽越大,数据传输的性能就越高,在32位机器中总线位宽为4个字节,64位机器中为8个字节.
有意思的是总线的英文单词是bus
,如果把主板想象成一座城市,那么总线就像是城市中的公共汽车,它按着多种固定线路不停地来回传输数据.
I/O(输入/输出)
设备是计算机与外部进行联系的桥梁,每个I/O
设备都要通过一个控制器或者适配器来与I/O
总线相连.
控制器与适配器的区别只在于它们的封装方式,它们的功能都是为了让I/O
设备与I/O
总线进行连接:
控制器是I/O
设备本身或者主板上自带的芯片组
适配器
则是插在主板上的外部设备,
在图中,I/O
设备包含鼠标、键盘(输入设备)、显示器(输出设备)、磁盘、网络.
内存也叫主存,它是一个临时的存储设备,存储了运行时的数据(程序与程序处理的数据),以供CPU进行处理.内存是由一组DRAM
(动态随机存取存储器)芯片组成的,DRAM
是RAM
(随机存取存储器)的一种,另一种为SRAM
(静态随机存取存储器),SRAM
比DRAM
速度更快,但造价也更贵,通常用来实现为高速缓存区.
32位操作系统中的CPU
的最大寻址空间只有2^32
字节,换算下来最高内存上限为4GB,但由于CPU
还要对BIOS
和其他硬件等进行寻址(这些优先级更高),所以用户实际可用的内存只有3GB左右.
64位操作系统的CPU
最大寻址空间足足有2^64
字节,也就是16EB(1024GB等于1TB,1024TB等于1PB,1024PB等于1EB),这已经是一个无法想象的数字了,不过这也不一定是够用的,毕竟谁又能知道未来的数据量会有多庞大呢?
内存具有以下特点:
RAM
中的数据就会全部丢失(磁盘可以将数据持久化地永久保存下来,就算断电也不会丢失数据).RAM
使用电容器来存储数据,当电容器充满电之后表示1
,未充电则表示0
.由于电容器或多或少有漏电的情形,若不作特别处理,电荷会渐渐随时间流失而使数据发生错误.刷新是指重新为电容器充电,弥补流失了的电荷.DRAM
的读取即有刷新的功效,但一般的定时刷新并不需要作完整的读取,只需作该芯片的一个列选择,整列的数据即可获得刷新,而同一时间内,所有相关记忆芯片均可同时作同一列选择,因此,在一段期间内逐一做完所有列的刷新,即可完成所有存储器的刷新.需要刷新正好解释了随机存取存储器的易失性.RAM
与集成电路一样,对环境的静电荷非常敏感,静电会干扰存储器内电容器的电荷,导致数据流失,甚至烧坏电路.Central Processing Unit
中央处理单元,简称CPU
或处理器,CPU
包含了冯诺依曼结构中的控制器与运算器,它是解释或执行存储在内存中的指令的引擎.CPU
好比计算机的大脑,从通电开始,直到断电,CPU
一直在不断地执行内存中存储的指令.如果没有CPU
,那么计算机就会是一台不会动的死机器了.
所谓指令就是进行指定操作的操作码,而指令集架构就是这些操作码的集合,至于微架构是一套用于执行指令集的微处理器设计方法,多个不同微架构的CPU
可以使用同一套指令集,一些常见的指令如下:
ALU
,ALU
对这2个数据进行算术运算,并将结果存放到一个寄存器中,以覆盖该寄存器中原有的内容.下面以一个简单的算术问题1 + 1
来大致了解一下CPU
的工作流程:
这两个变量首先会被存储在内存中.
CPU
从内存中读取指令并刷新程序计数器(每执行完一个指令都要刷新程序计数器).
CPU
执行加载指令,通过总线将这两个变量传输(复制)到寄存器.
CPU
执行运算指令,从寄存器中复制这两个变量进行算术运算,并将结果存到寄存器.
CPU
执行存储指令,寄存器通过总线将结果存储回内存(覆盖原有位置).
寄存器是CPU
中的一个存储部件,可以认为它是容量很小但速度飞快的内存,寄存器是与ALU
直接交互的存储设备(不管数据是在内存还是高速缓冲区,最终都要存到寄存器才能与ALU
交互).
在CPU
架构中,拥有多个寄存器,它们分别拥有各自的用途(指令寄存器,整数寄存器,浮点数寄存器等),且寄存器的数量和它的大小都与指令集架构和机器支持的位宽相关联(例如x86-64
指令集架构(64位指令集架构)中支持64位的通用寄存器与64位整数运算,而x86
指令集架构只能支持32位和16位).
程序计数器用于指示将要执行的指令序列,并且不断刷新指向新的指令地址,根据CPU
的实现不同,程序计数器可能会指向正在运行的指令地址也可能会是下一个指令的地址.
由于寄存器与内存的速度相差过大,为了避免性能上的浪费,在寄存器与内存之间建立数据的缓存区是很有必要的.
高速缓存是一个比内存更小但更快的存储设备,且使用SRAM
实现,现在的CPU
一般都配有三级缓存,L1
缓存速度最快但存储的容量也最小,L2
要比L1
慢但存储的容量也更大,以此类推(上一层的存储器作为下一层存储器的高速缓存,也就是说,寄存器就是L1
的高速缓存,L1
则是L2
的高速缓存,L2
是L3
的高速缓存…)….
当CPU
发起向内存加载数据的请求时,会先从缓存中查找,如果缓存未命中,才会从内存加载数据,并更新缓存.高速缓存之所以如此有效,主要是利用了局部性原理,即最近访问过的内存位置以及周边的内存位置很容易会被再次访问.而高速缓存中就存储着这些经常会被访问的数据.
DMA
全称为Direct Memory Access
直接内存访问,它允许其他硬件可以直接访问内存中的数据,而无需让CPU
介入处理.一般会使用到DMA
的硬件有显卡、网卡、声卡等.
DMA
会导致发生缓存不一致的问题,需要额外的进行同步操作保证数据安全.例如,当CPU
从内存中读取数据后,会暂时将新数据写入缓存中,但还没有将数据更新回内存,如果在这期间发生了DMA
,就会读取到旧的数据.
流水线又称管线,是现代CPU
中必不可少的优化技术,它将指令的处理过程拆分为多个步骤,并通过多个硬件处理单元并行执行这些步骤.
管线的具体执行过程很像工厂中的流水线(指令就像在流水线传送带上的产品,各个硬件处理单元就像是在流水线旁进行操作的工人),因此而得名为流水线.
流水线虽然提高了整体的吞吐量,但也是有其缺点的,这是由于流水线依赖于分支预测,如果CPU
预测的分支是错误的,那么整个流水线上的所有指令都要取消,然后重新向流水线填充指令,这项操作是很耗费性能的.
超线程是一种允许一个CPU
执行多个控制流的技术,它复制了CPU
中必要的硬件资源(程序计数器、寄存器),来让其在同一时间内处理两个线程的工作.
通过超线程技术,可以让一个CPU
核心去执行两个线程,所以一个带有4核(实体核心)的CPU
实际上可以执行8个线程(逻辑线程).
多核CPU
是指将多个核心(也就是CPU
)集成到一个集成电路芯片上.每个核心都可以独立的执行指令,也就是真正意义上的并行执行.
每个核心都拥有独立的寄存器,程序计数器,高速缓存等组件,一般还会有一个所有核心共享的缓存,它是直接与内存连通的缓冲区.
多核CPU
与多处理器不同,多处理器是将多个CPU
封装在多个独立的集成电路芯片中,而多核CPU
是所有核心都封装在同一个集成电路芯片中.
操作系统是用于管理计算机硬件与软件的程序,可以把操作系统看成是应用程序与硬件之间插入的一层软件,所有应用程序对硬件的操作尝试都必须通过操作系统.
操作系统需要负责管理与配置内存、调度系统资源的优先次序、管理进程与线程、控制I/O设备、操作网络与管理文件系统等事务.可以说操作系统是整个计算机系统中的灵魂所在.
操作系统的内核是操作系统最核心的地方,它是代码和数据的一个集合.当应用程序需要操作系统的某些操作时,会执行一条系统调用(system call
)指令,这时,控制权会被移交到内核,由内核执行被请求的操作并返回到应用程序.大多数系统的交互式操作都需要在内核完成,例如I/O
、进程管理等.
虚拟内存是计算机系统内存管理的一种技术,它为每个进程提供了一个假象,即每个进程都在独占地使用内存(一个连续的地址空间),而实际上,它通常被分割为多个物理内存碎片,还有部分暂时存储在磁盘存储器上,在需要时进行数据交换.使用虚拟内存会使程序的编写更加容易,对真实的物理内存的使用也会更加有效率.
每个进程所能看到的虚拟地址空间大致如上图所示,每个区域都有它专门的作用.
malloc()
和free()
这样的函数就是在堆内存中进行分配空间与释放,而类似Java
这种更高一级的语言提供了自动内存管理和垃圾回收,不需要程序员手动地分配与释放堆内存空间.进程是操作系统对一个正在运行的程序的一种抽象,它是程序的执行实体,是操作系统对资源进行调度的一个基本单位,同时也是线程的容器.
进程跟虚拟内存一样,也是操作系统提供的一种假象,它让每个程序看上去都是在独占地使用CPU
、内存和I/O
设备.但其实同一时间只有一个进程在运行,而我们能够边听歌边上网边码代码的原因其实是操作系统在对进程进行切换,一个进程和另一个进程其实是交错执行的,只不过计算机的速度极快,我们无法感受到而已.
操作系统会保持跟踪进程运行所需的所有状态信息,这种状态,被称为上下文(Context
),它包含了许多重要的信息,例如程序计数器和寄存器的当前值等.当操作系统需要对当前进程进行切换时(转移到另一个进程),会保存当前进程的上下文,然后恢复新进程的上下文,这时控制权会移交到新进程,新进程会从它上次停下来的地方开始执行,这个过程叫做上下文切换.
操作系统的进程空间可以分为用户空间与内核空间,也就是用户态与内核态.它们的执行权限不同,一般的应用程序是在用户态中运行的,而当应用程序执行系统调用时就需要切换到内核态,由内核执行.
线程是操作系统所能调度的最小单位,它被包含在进程之中,且一个进程中的所有线程共享进程的资源,一个线程一般被指为进程中的一条单一顺序的控制流.
线程都运行在进程的上下文中,虽然线程共享了进程的资源,但每条线程都拥有自己的独立空间,例如函数调用栈、寄存器、线程本地存储.
线程的实现主要有以下三种方式:
使用内核线程实现: 内核线程就是由操作系统内核直接支持的线程,这种线程由内核来完成线程切换调度,内核通过调度器对线程进行调度,并将线程的任务映射到各个处理器上.应用程序一般不会直接使用内核线程,而是使用内核线程的一个接口: 轻量级进程,每个轻量级进程都由一个内核线程支持,所以它们的关系是1:1的.这种线程的实现方式的缺点也很明显,应用程序想要进行任何线程操作都需要进行系统调用,应用程序会在用户态和内核态之间来回切换,消耗的性能资源较多.
使用用户线程实现: 这种方式将线程完全实现在用户空间中,相关的线程操作都在用户态中完成,这样可以避免切换到内核态,提高了性能.但正因为没有借助系统调用,操作系统只负责对进程分配资源,这些复杂的线程操作与线程调度都需要由用户线程自己处理实现,提高了程序的复杂性.这种实现方式下,一个进程对应多个用户线程,它们是1:N的关系.
混合实现: 这是一种将内核线程与用户线程一起使用的实现方式.在这种实现下,即存在用户线程,也存在轻量级进程.用户线程依旧是在用户空间中建立的(相关的线程操作也都是在用户空间中),但使用了轻量级进程来当作用户线程与内核线程之间的桥梁,让内核线程提供线程调度和对处理器的映射.这种实现方式下,用户线程与轻量级进程的数量比例是不定的,它们是N:M的关系.
文件也是一个非常重要的抽象概念,它向应用程序提供了一个统一的视图,来看待系统中可能含有的所有各式各样的I/O
设备.计算机文件系统通过文件与树形目录的抽象概念来屏蔽磁盘等物理设备所使用的数据块(chunk
),让用户在使用文件的时候无需关心它实际的物理地址,用户也不需要管理磁盘上的空间分配,这些都由文件系统负责.
所谓文件其实也就是一串字节序列,一个文件想要长期存储,就必须要存放于某种存储设备上,如本地磁盘、U盘.
如果用图论的方式来看待网络,其实网络就是一张无向图(需要双向通信),每台计算机都是图中的一个节点(指计算机网络),图的边就是计算机之间互相通信的连接.简单的说,计算机网络其实就是多台计算机进行通信的系统.
网络其实也可以看作是一个I/O
设备,当系统从内存中复制一串字节到网络适配器时,数据流经过网络传输到达另一台机器上(这其实就是输出操作),系统也可以读取从其他机器传输过来的数据,并把数据复制到内存中(输入).
互联网(Internet
)是计算机网络中的一种(如果按区域划分还有局域网、广域网等),互联网是网络与网络之间组成的巨大的国际网络,这些网络之间以TCP/IP
协议相连,连接了全世界上几十亿的设备.
我们日常生活中用浏览器上网浏览网页,其实使用的是万维网(World Wide Web
),它是运行在互联网之上提供的一个服务,万维网是一个基于超文本链接组成的系统,并且通过http
协议进行访问.
OSI
模型全称为开放式系统互联通信参考模型(Open System Interconnection Reference Model
),是由国际标准化组织提出的一个试图使各种计算机在世界范围内进行互联通信的标准框架.
在OSI
模型中,数据经过每一层都会添加该层的协议头(物理层除外),当一个数据从一端发送到另一端时,需要经过层层封装.
应用层: 应用层直接和应用程序通信并提供常见的网络应用服务.常见的应用层协议有:HTTP,HTTPS,FTP,TELNET,SSH,SMTP,POP3等.
表示层: 表示层为不同终端的上层用户提供数据和信息正确的语法表示变换方法.该层定义了数据格式及加解密,
会话层: 会话层负责在数据传输中设置和维护网络中两台电脑之间的通信连接.但会话层不参与具体的传输,它只提供包括访问验证和会话管理在内的建立和维护应用之间通信的机制.
传输层: 传输层将数据封装成数据包,提供端对端的数据通信服务.它还提供面向连接的数据流支持、可靠性、流量控制、多路复用等服务.最著名的传输层协议有TCP
与UDP
.
网络层: 网络层提供路由和寻址的功能,使两终端系统能够互连且决定最佳路径,并具有一定的拥塞控制和流量控制的能力.网络层将网络表头(包含网络地址等数据)加到数据包中,网络层协议中最出名的就是IP
协议.
数据链路层: 数据链路层在两个网络实体之间提供数据链路连接的创建、维持和释放管理.它将数据划分为数据帧从一个节点传输到临近的另一个节点,这些节点是通过MAC(主机的物理地址)来进行标识的.
物理层: 物理层是OSI
模型中最低的一层,物理层主要负责传输数据所需要的物理链路创建、维持、拆除,而提供具有机械的,电子的,功能的和规范的特性.简单来说,物理层负责了物理设备之间的通信传输.
TCP
协议全称为传输控制协议(Transmission Control Protocol
),由于它是基于IP
协议之上的,所以也有人称作为TCP/IP
协议.
TCP
协议是位于传输层的协议,它与同样位于传输层的UDP
协议差别很大,它保证了数据包在传输时的安全性(丢包重传),而UDP
则只负责发送数据,不保证数据的安全.
TCP
为了保证不发生丢包,给每个包标记了一个序号,同时序号也保证了接收端在接收数据包时的顺序.然后接收端对已成功收到的包发回一个相应的确认(ACK
);如果发送端在合理的往返时延(RTT
)内未收到确认,那么对应的数据包就被假设为已丢失将会被进行重传.TCP
用一个校验和函数来检验数据是否有错误,在发送和接收时都要计算校验和.
TCP
协议在连接建立与终止时需要经过三次握手与四次挥手,这个机制主要都是为了提高可靠性.
客户端发送SYN(SEQ=x)报文给服务器端,进入SYN_SEND状态,等待服务器端确认.
服务器端收到SYN报文,回应一个SYN (SEQ=y)ACK(ACK=x+1)报文,进入SYN_RECV状态.
客户端收到服务器端的SYN报文,回应一个ACK(ACK=y+1)报文,进入Established状态.
服务器接收到客户端发送的SYN报文,三次握手完成,连接建立.
某一端首先调用close,称该端执行“主动关闭”(active close).该端发送一个FIN报文,表示数据发送完毕(我们称它为A
端).
另一端接收到这个FIN信号执行 “被动关闭”(passive close ),并回应一个ACK报文.(我们称它为B
端)
一段时间后,B
端没有数据发送的任务了,这时它将调用close关闭套接字,然后向A
端发送一个FIN信号.
A
端接收到FIN信号,开始进行关闭连接,并对B
端返回一个ACK.
B
端接收到来自A
端的ACK信号,进行关闭连接,四次挥手完毕.
TCP/IP
将OSI
模型抽象成了四层,下图为以HTTP
为例的一个数据发送过程.
数据包在网络中进行传输时使用了分组交换.分组交换也称为包交换,它将用户通信的数据划分成多个更小的等长数据段,在每个数据段的前面加上必要的控制信息作为数据段的首部,每个带有首部的数据段就构成了一个分组.首部指明了该分组发送的地址,当交换机收到分组之后,将根据首部中的地址信息将分组转发到目的地,这个过程就是分组交换.能够进行分组交换的通信网被称为分组交换网.
分组交换的本质就是存储转发,它将所接受的分组暂时存储下来,在目的方向路由上排队,当它可以发送信息时,再将信息发送到相应的路由上,完成转发.其存储转发的过程就是分组交换的过程.
计算机编程语言拥有多种数据类型, 例如int
、char
、double
等.但不管是什么类型的数据,在计算机中其实都只是一个字节序列(以8位二进制为一个字节).每个机器中对字节序列的排序不大相同,有一些机器按照从最高有效字节到最低有效字节的顺序存储,这种规则被称为大端法;还有一些机器将最低有效字节排在最前面,这种规则被称为小端法.
计算机使用补码来表示数值,一个数的最高有效位为符号位(以整数为例,整数占有4字节32位,最高位即最左位,剩下31位用于表示数字,所以整数的有效范围为-2^31 ~ 2^31 - 1
),如果符号位为1,则代表这个值为负,如果符号位为0,则代表这个值为正.负数的补码即是它的反码(在保持符号位不变的前提下按位取反)+1,正数的补码不需要做其他操作,就是它本身的值.
当将一个较小类型的值强转为较大类型时(如byte
强转为int
),将会发生符号扩展,较小类型不包含的位会以符号位来进行填充(还是以byte
为例,当它强转为int
时,高24位会被填充为最高有效位中的数值,如果最高有效位为1,那么高24位都会为1,这时byte
原来要表示的值将产生变化,要避免这种情况,可以使用一个低8位为1高24位为0的数,将它与强转后的结果进行&
操作,来保留低8位,并消除高24位中的1).
对一个数进行移位操作时,也需要按规则填充丢失的位数.移位操作分为算术移位与逻辑移位,算术移位会填充符号位,而逻辑移位全部填充0.
当进行左移操作时,右边空出的位用0补充,高位左移溢出则舍弃该高位.
当进行右移操作时,左边空出的位用符号位来补充(正数补0,负数补1),右边溢出则舍弃.如果使用逻辑移位(Java
中为>>>
),左边空出的位会用0来补充.
读到这里,可能有人会有疑问,为什么计算机非得使用补码?这主要因为,计算机中没有减法器只有加法器,而减去一个数其实就是加上一个负数,使用补码进行计算会很方便快速.
我们假设一个指定n
为长度的二进制序列,那么它将会有2^n
个可能的值,加减法运算都存在上溢出与下溢出的情况,实际上都等价于模(≡) 2^n
的加减法运算.
把范围想象成一个时钟,假设现在时针指向数字3,若要得出6小时前时针指向的数字是几,有两种方法:
将时针逆时针拨动6格.
将时针顺时针拨动12 - 6 = 6格.
这里的12就是模,3小时-6小时 = 3小时 + (12 - 6)小时.
例如以下例子,模为2^8 = 256
一个8位无符号整数的值的范围是0到255.因此4+254将上溢出,结果为2: (4 + 254) ≡ 258 ≡ 258 - 256 ≡ 2
一个8位有符号整数的值的范围是−128到127,则126+125将上溢出,结果为-5: (126+125) ≡ 251 ≡ 251 - 256 ≡ -5
浮点数是一种对于实数的近似值数值表现法,由一个有效数字(即尾数)加上幂数来表示,通常是乘以某个基数的整数次指数得到.但浮点数计算通常伴随着因为无法精确表示而进行的近似或舍入.
在计算机使用的浮点数被电气电子工程师协会(IEEE)规范化为IEEE-754,任意一个二进制浮点数V都可以表示成下列形式:
V = (-1)^s * M * 2^E
${(-1)}^s$表示符号位,当s=0,V为正数;s=1,V为负数.
M 表示有效数字,$1≤M<2$.
$2^E$表示指数位.
这种表示方式有点类似于科学计数法,在计算机中,通常使用2为基数的幂数来表示.IEEE-754同时还规定了单精度(float
)与双精度(double
)的区别:
32位的单精度浮点数,最高1位是符号位s,接着的8位是指数E,剩下的23位是有效数字M.
64位的双精度浮点数,最高1位是符号位s,接着的11位是指数E,剩下的52位为有效数字M.
当调用一个函数时,系统会在栈上分配一个空间,存放了函数中的局部变量、函数参数、返回地址等,这样的一个结构被称为栈帧.
函数中的数据的存活状态是后进先出的,而栈正好是满足这一特性的数据结构,这也是为什么计算机使用栈来当作函数调用的存储结构.
|
|
在x86-64
架构中,栈是向低地址方向生长的,寄存器%rsp
指向栈顶,当一个函数被调用时,将会执行pushq
指令,栈帧入栈,栈指针减小(向下生长),当函数返回后,将会执行popq
指令,栈帧出栈,释放空间,栈指针增加.如果不断有函数进行调用,栈就会不断向下生长,最终会产生Stack Overflow
.
计算机编程语言是用来定义计算机程序的语言,它以一种标准化的语法规则来向计算机发出指令.最早的编程语言是在计算机发明之前产生的,当时是用来控制提花织布机及自动演奏钢琴的动作.如今已经有上千种不同的编程语言,不管是哪种语言,尽管它们的特性各有不同,但写程序的核心都是条件判断、循环、分支(这些也是机器指令的核心).
编程语言依赖于编译器或解释器(所以也分为编译型语言与解释型语言),如果没有对应的编译器/解释器来对语法与语义进行分析并生成对应的机器语言,那么我们所写的代码其实都只是普通的文本字符(编译器/解释器也会对源代码进行一系列优化提高性能).
编译型语言通过编译器直接将源代码翻译成机器语言并生成一个可执行文件(机器语言是不兼容的,如果要到另一台机器上运行,就需要对源代码重新编译);解释型语言通过解释器动态地翻译源代码并直接执行(性能上会比编译型语言直接运行可执行文件要差);虽然大多数的语言既可被编译又可被解译,但大多数仅在一种情况下能够良好运行.
Java
的编译机制比较特殊,它将Java
源代码编译成JVM
字节码(通过虚拟机来达到一次编译在所有平台可用),然后JVM
对字节码进行解释执行,但对于较热的代码块(频繁调用的函数等),JVM
会通过JIT
即时编译技术将这些频繁使用的代码块动态地编译成机器语言,提高程序的性能.
对于普通人来说,编码总是与一些秘密的东西相关联(加密与解密);对于程序员们来说,编码大多数是指一种用来在机器与人之间传递信息的方式.
但从广义上来讲,编码是从一种信息格式转换为另一种信息格式的过程,解码则是编码的逆向过程.接下来举几个使用到编码的例子:
当我们要把想表达的意思通过一种语言表达出来,其实就是在脑海中对信息进行了一次编码,而对方如果也懂得这门语言,那么就可以用这门语言的解码方法(语法规则)来获得信息(日常的说话交流其实就是在编码与解码).
程序员写程序时,其实就是在将自己的想法通过计算机语言进行编码,而编译器则通过生成抽象语法树,词义分析等操作进行解码,最终交给计算机执行程序(编译器产生的解码结果并不是最终结果,一般为汇编语言,但汇编语言只是CPU指令集的助记符,还需要再进行解码).
了解了编码的含义,我们接下来重点探究Java
中的字符编码.
本文作者为: SylvanasSun.转载请务必将下面这段话置于文章开头处(保留超链接).
本文首发自SylvanasSun Blog,原文链接: https://sylvanassun.github.io/2017/08/20/2017-08-20-Encode/
字符集就是字符与二进制的映射表,每一个字符集都有自己的编码规则,每个字符所占用的字节也不同(支持的字符越多每个字符占用的字节也就越多).
ASCII : 美国信息交换标准码(American Standard Code for Information Interchange).学过计算机的都知道大名鼎鼎的ASCII
码,它是基于拉丁字母的字符集,总共记有128个字符,主要目的是显示英语.其中每个字符占用一个字节(只用到了低7位).
ISO-8859-1 : 它是由国际标准化组织(International Standardization Organization)在ASCII
基础上制定的8位字符集(仍然是单字节编码).它在ASCII
空置的0xA0-0xFF
范围内加入了96个字母与符号,支持了欧洲部分国家的语言.
GBK : 如果我们想要让电脑上显示汉字就必须要有支持汉字的字符集,GBK就是这样一个支持汉字的字符集,全称为<<汉字内码扩展规范>>,它的编码方式分为单字节与双字节: 00–7F
范围内是第一个字节,与ASCII
保持一致,之后的双字节中,前一字节是双字节的第一位(范围在81–FE
,不包含80
和FF
),第二字节的一部分在40–7E
,其他部分在80–FE
.(这里不再介绍GB2313
与GB18030
,它们都是互相兼容的.)
UTF-16 : UTF-16
是Unicode(统一码,一种以支持世界上多国语言为目的的通用字符集)
的一种实现方式,它把Unicode
的抽象码位映射为2~4
个字节来表示,UTF-16
是变长编码(UTF-32是真正的定长编码
),但在最开始以前UTF-16
是用来配合UCS-2(UTF-16的子集,它是定长编码,用2个字节表示所有Unicode字符)
使用的,主要原因还是因为当时Unicode
只有不到65536个字符,2个字节就足以应对一切了.后来,Unicode
支持的字符不断膨胀,2个字节已经不够用了,导致一些只支持UCS-2
当做内码的产品很尴尬(Java
就是其中之一).
UTF-8 : UTF-8
也是基于Unicode
的变长编码表,它使用1~6
个字节来为每个字符进行编码(RFC 3629
对UTF-8
进行了重新规范,只能使用原来Unicode
定义的区域,U+0000~U+10FFFF
,也就是说最多只有4个字节),UTF-8
完全兼容ASCII
,它的编码规则如下:
在U+0000~U+007F
范围内,只需要一个字节(也就是ASCII
字符集中的字符).
在U+0080~U+07FF
范围内,需要两个字节(希腊文、阿拉伯文、希伯来文等).
在U+0800~U+FFFF
范围内,需要三个字节(亚洲汉字等).
其他的字符使用四个字节.
Java
提供了Charset
类来完成对字符的编码与解码,主要使用以下函数:
public static Charset forName(String charsetName)
: 这是一个静态工厂函数,它根据传入的字符集名称来返回对应字符集的Charset
类.public final ByteBuffer encode(CharBuffer cb) / public final ByteBuffer encode(String str)
: 编码函数,它将传入的字符串或者字符序列进行编码,返回的ByteBuffer
是一个字节缓冲区.public final CharBuffer decode(ByteBuffer bb)
: 解码函数,将传入的字节序列解码为字符序列.
|
|
有的读者可能会对以上代码中的b & 0xFF
产生疑惑,这是为了解决符号扩展问题.在Java
中,如果一个窄类型强转为一个宽类型时,会对多出来的空位进行符号扩展(如果符号位为1,就补1,为0则补0).只有char
类型除外,char
是没有符号位的,所以它永远都是补0.
代码中调用了函数Integer.toHexString()
,变量b
在运算之前就已经被强转为了int
类型,为了让数值不受到破坏,我们让b
对0xFF
进行了与运算,0xFF
是一个低八位都为1的值(其他位都为0),而byte
的有效范围只在低八位,所以结果为前24位(除符号位)都变为了0,低八位保留了原有的值.
如果不做这项操作,那么b
又恰好是个负数的话,那这个强转后的int
的前24位都会变为1,这个结果显然已经破坏了原有的值.
Reader
与Writer
是Java
中负责字符输入与输出的抽象基类,它们的子类实现了在各种场景中的字符输入输出功能.
在使用Reader
与Writer
进行IO
操作时,需要指定字符集,如果不显式指定的话会默认使用当前环境的字符集,但我还是推荐显式指定一致的字符集,这样才不会出现乱码问题(Reader
与Writer
指定的字符集不一致或更改了环境导致字符集不一致等).
|
|
在Web
开发中,乱码也是经常存在的一个问题,主要体现在请求的参数和返回的响应结果,最头疼的是不同的浏览器的默认编码甚至还不一致.
Java
以Http
的请求与响应抽象出了Request
和Response
两个对象,只要保持请求与响应的编码一致就能避免乱码问题.
Request
提供了setCharacterEncoding(String encode)
函数来改变请求体的编码,一般通过写一个过滤器来统一对所有请求设置编码.
|
|
Response
提供了setCharacterEncoding(String encode)
与setHeader(String name,String value)
两个函数,它们都可以设置响应的编码.
|
|
还有一种更简便的方式,直接使用Spring
提供的CharacterEncodingFilter
,该过滤器就是用来统一编码的.
|
|
CharacterEncodingFilter
的实现如下:
|
|
众所周知,在Java
中一个char
类型占用两个字节,那么这是为什么呢?这是因为Java
使用了UTF-16
当作内码.
内码(Internal Encoding
)就是程序内部所使用的编码,主要在于编程语言实现其char
和String
类型在内存中使用的内部编码.与之相对的就是外码(External Encoding
),它是程序与外部交互时使用的字符编码.
值得一提的是,当初UTF-16
是配合UCS-2
使用的,后来Unicode
支持的字符不断增多,UTF-16
也不再只当作一个定长的2字节编码使用了,也就是说,Java
中的一个char
其实并不一定能代表一个完整的UTF-16
字符.
String.getBytes()
可以将该String的内码转换为指定的外码并返回这个编完码的字节数组(无参数版使用当前平台的默认编码).
|
|
Java
还规定char
与String
类型的序列化是使用UTF-8
当作外码的,Java
中的Class
文件中的字符串常量与符号名也都规定使用UTF-8
.这种设计是为了平衡运行时的时间效率与外部存储的空间效率所做的取舍.
在SUN JDK6
中,有一条命令-XX:+UseCompressedString
.该命令可以让String
内部存储字符内容可能用byte[]
也可能用char[]
: 当整个字符串所有字符处于ASCII
字符集范围内时,就使用byte[]
(使用了ASCII
编码)来存储,如果有任一字符超过了ASCII
的范围,就退回到使用char[]
(UTF-16
编码)来存储.但是这个功能实现的并不理想,所以没有包含在Open JDK6
/Open JDK7
/Oracle JDK7
等后续版本中.
JavaScript
也使用了UTF-16
作为内码,其实现也广泛应用了CompressedString
的思想,主流的JavaScript
引擎中都会尽可能使用ASCII
内码的字符串,不过这些细节都是对外隐藏的..
B树(B-Tree
)是一种自平衡的树,能够保证数据有序.同时它还保证了在查找、插入、删除等操作时性能都能保持在$O(log\;n)$.需要注意的一点是,B-Tree
并不是一棵自平衡的二叉查找树,它拥有多个分叉,且为大块数据的读写操作做了优化,同时它也可以用来描述外部存储(支持对保存在磁盘或者网络上的符号表进行外部查找).
在当今的互联网环境下,数据量已经大到无法想象,而能够在巨型数据集合中快速地进行查找操作是非常重要的,而B-Tree
的神奇之处正在于: 只需要使用4~5个指向一小块数据的引用即可有效支持在数百亿甚至更多元素的符号表中进行查找和插入等操作.
B-Tree
的主要应用在于文件系统与数据库系统,例如Mysql
中的InnoDB
存储引擎就使用到了B-Tree
来实现索引.
本文作者为: SylvanasSun.转载请务必将下面这段话置于文章开头处(保留超链接).
本文转发自SylvanasSun Blog,原文链接: https://sylvanassun.github.io/2017/08/13/2017-08-13-BTrees/
我们使用页来表示一块连续的数据,访问一页的数据需要将它读入本地内存.一个页可能是本地计算机上的一个文件,也可能是服务器上的某个文件的一部分等等.页的访问次数(无论读写)即是外部查找算法的成本模型.
首先,构造一棵B-Tree
不会将数据保存在树中,而是会构造一棵由键的副本组成的树,每个副本都关联着一条链接.这种方法能够将索引与符号表进行分离,同时我们还需要遵循以下的规定:
M
来构造一棵多向树(M
一般为偶数),每个节点最多含有M - 1
对键和链接.M / 2
对键和链接,根节点例外(它最少可以含有2对).M
阶的B-Tree
来指定M
的值,例如: 在一棵4阶B-Tree
中,每个节点都含有至少2对至多3对.B-Tree
含有两种不同类型的节点,内部节点与外部节点.
|
|
在B-Tree
中进行查找操作每次都会结束于一个外部节点.在查找时,从根节点开始,根据被查找的键来选择当前节点中的适当区间并根据对应的链接从一个节点移动到下一层节点.最终,查找过程会到达树底的一个含有键的页(也就是外部节点),如果被查找的键在该页中,查找命中并结束,如果不在,则查找未命中.
|
|
插入操作也要先从根节点不断递归地查找到合适的区间,但需要注意一点,如果查找到的外部节点已经满了怎么办呢?
解决方法也很简单,我们允许被插入的节点暂时”溢出”,然后在递归调用自底向上不断地进行分裂.例如:当M
为5时,根节点溢出为6-节点
,只需要将它分裂为连接了两个3-节点
的2-节点
.即将一个M-
的父节点k
分裂为连接着两个(M / 2)-
节点的(k + 1)-
节点.
|
|
https
之前,我们先了解一下http
,以及为什么要使用https
.
http(Hyper Text Transfer Protocol)
超文本传输协议是一种用于分布式、协作式和超媒体信息系统的应用层协议,它是TCP/IP
的上层协议,同时它也是万维网(万维网不等同于互联网,它只是基于互联网的一个服务)的数据通信的基础.
http
协议是客户端浏览器与其他程序或Web
服务器之间交互的应用层通讯协议.但它也有一个致命的缺点:http
协议是明文传输协议,在传输信息的过程中并没有进行任何加密,通信的双方也没有任何的认证,这是非常不安全的,如果在通信过程中被中间人进行劫持、监听、篡改,会造成个人隐私泄露等严重的安全问题.
举一个现实中的例子来说,假设小李要给小张寄信,如果信件在运输的过程中没有任何安全保护,那么很可能会被邮递员(也就是中间人)窃取其中的内容,甚至于修改内容.
https
就是用于解决这样的安全问题的,它的全称为Hypertext Transfer Protocol Secure
,它在http
的基础上添加了SSL(安全套接字层)
层来保证传输数据的安全问题.
本文作者为: SylvanasSun.转载请务必将下面这段话置于文章开头处(保留超链接).
本文转发自SylvanasSun Blog,原文链接: https://sylvanassun.github.io/2017/08/06/2017-08-06-DigestHttps/
https
提供了端对端的加密,而且不仅对数据进行了加密,还对数据完整性提供了保护.不过在讲解https
的加密方式之前,我们需要先了解一下加密算法.
对称加密的基本思想是: 通信双方使用同一个密钥(或者是两个可以简单地互相推算的密钥)来对明文进行加密与解密.
常见的对称加密算法有DES、3DES、AES、Blowfish、IDEA、RC5、RC6.
对称加密看起来很美好,但是密钥要怎么发送过去呢?如果直接发送过去,被中间人截获了密钥岂不是白费工夫.
非对称加密也叫公开密钥加密,它使用了两个密钥,一个为公钥,一个为私钥,当一个用作于加密的时候,另一个则用作解密.
这两个密钥就算被其他人知道了其中一个也不能凭借它计算出另一个密钥,所以可以公开其中一个密钥(也就是公钥),不公开的密钥为私钥.
如果服务器想发送消息给客户端,只需要用客户端的公钥加密,然后客户端用它自己的私钥进行解密.
常见的非对称加密算法有RSA、DSA、ECDSA、 DH、ECDHE.
我们以DH
算法为例,了解一下非对称加密的魅力.
Alice
要与Bob
进行通信,他们协定了一组可以公开的质数$p=23$,$g=5$.
Alice
选择了一个不公开的秘密数$a=6$,并计算$A = {g^a} \; {mod} \; {p} = {5^6} \; {mod} \; {23} = 8$并发送给Bob
.
Bob
选择了一个不公开的秘密数$b=15$,并计算$B = {g^b} \; {mod} \; {p} = {5^{15}} \; {mod} \; {23} = 19$并发送给Alice
Alice
计算$S = {B^a} \; {mod} \; {p} = {19^6} \; {mod} \; {23} = 2$
Bob
计算$S = {A^b} \; {mod} \; {p} = {8^{15}} \; {mod} \; {23} = 2$
Alice
与Bob
得到了同样的值,因此${g^{ab}} \; {mod} \; {p} = {g^{ba}} \; {mod} \; {p}$
尽管非对称加密如此奇妙,但它加解密的效率比对称加密要慢多了.那我们就将对称加密与非对称加密结合起来,取其精华,去其槽粕.
方法很简单,其中一方先自己生成一个对称加密密钥,然后通过非对称加密的方式来发送这个密钥,这样双方之后的通信就可以用对称加密这种高效率的算法进行加解密了.
对称加密与非对称加密结合使用的方法虽然能够保证了通信过程的安全,但也引发了如下问题:
解决方法依是通过一个权威的CA(Certificate Authority)
证书中心,它来负责颁发证书,这个证书包含了如下等内容:
数字签名是用来验证数据完整性的,首先将公钥与个人信息用一个Hash
算法生成一个消息摘要,Hash
算法是不可逆的,且只要内容发生变化,那生成的消息摘要将会截然不同.然后CA
再用它的私钥对消息摘要加密,最终形成数字签名.
当客户端接收到证书时,只需要用同样的Hash
算法再次生成一个消息摘要,然后用CA
的公钥对证书进行解密,之后再对比两个消息摘要就能知道数据有没有被篡改过了.
那么CA
的公钥又要从哪里来呢?这似乎陷入了一个鸡生蛋,蛋生鸡的悖论,其实CA
也有证书来证明自己,而且CA
证书的信用体系就像一棵树的结构,上层节点是信用高的CA
同时它也会对底层的CA
做信用背书,操作系统中已经内置了一些根证书,所以相当于你已经自动信任了它们(需要注意误安装一些非法或不安全的证书).
CA
的公钥,然后对服务器发来的证书中的数字签名进行解密.Hash
算法计算出消息摘要,然后对数字签名中的消息摘要进行校对.现在国内外的大型网站基本都已经全站启用了Https
,虽然相对于Http
多了许多用于加密的流程,但为了数据的安全这点牺牲是必要的,Https
也将是未来互联网的发展趋势.
闭包一直都是Java
社区中争论不断的话题,很多语言例如JavaScript
,Ruby
,Python
等都支持闭包这个语言特性,闭包功能强大且灵活,Java
并没有显式地支持它,但其实Java
中也存在着所谓的”闭包”.
本文作者为: SylvanasSun.转载请务必将下面这段话置于文章开头处(保留超链接).
本文转发自SylvanasSun Blog,原文链接: https://sylvanassun.github.io/2017/07/30/2017-07-30-JavaClosure/
定义一个闭包的要点如下:
自由变量
的函数.自由变量
.也就是说,外部环境持有内部函数所依赖的自由变量
,由此对内部函数形成了闭包.
那么什么是自由变量
呢?自由变量
就是在函数自身作用域之外的变量,一个函数$f(x) = x + y$,其中y
就是自由变量
,它并不是这个函数自身的自变量,而是通过外部环境提供的.
下面以JavaScript
的一个闭包为例:
|
|
对于内部函数function(x)
来说,y
就是自由变量
.而y
是函数Add(y)
内的参数,所以Add(y)
对内部函数function(x)
形成了一个闭包.
这个闭包将自由变量y
与内部函数绑定在了一起,也就是说,当Add(y)
函数执行完毕后,它不会随着函数调用结束后被回收(不能在栈上分配空间).
|
|
Java
与JavaScript
又或者其他支持闭包的语言不同,它是一个基于类的面向对象语言,也就是说一个方法所用到的自由变量
永远都来自于其所在类的实例的.
|
|
这样一个方法add(x)
拥有一个参数x
与一个自由变量y
,它的返回值也依赖于这个自由变量y
.add(x)
想要正常工作的话,就必须依赖于AddUtils
类的一个实例,不然它无法知道自由变量y
的值是多少,也就是自由变量
未与add(x)
进行绑定.
严格上来说,add(x)
中的自由变量
应该为this
,这是因为y
也是通过this
关键字来访问的.
所以说,在Java
中闭包其实无处不在,只不过我们难以发现而已.但面向对象的语言一般都不把类叫成闭包,这是一种习惯.
Java
中的内部类就是一种典型的闭包结构.
|
|
内部类通过一个指向外部类的引用来访问外部环境中的自由变量
,由此形成了一个闭包.
|
|
getAnonInner(x)
方法返回了一个匿名内部类AnonInner
,匿名内部类不能显式地声明构造函数,也不能对构造函数传参,且返回的是一个AnonInner
接口,但它的add()
方法实现中用到了两个自由变量
(x
与y
),也就是说外部方法getAnonInner(x)
对这个匿名内部类构成了闭包.
但我们发现自由变量
都被加上了final
修饰符,这是因为Java
对闭包支持的不完整导致的.
对于自由变量
的捕获策略有以下两种:
Java
的匿名内部类和Java 8
新的lambda
表达式都是这样实现的.C#
的匿名函数(匿名委托/lambda表达式)就是这样实现的.Java
只实现了capture-by-value
,但又没有对外说明这一点,为了以后能进一步扩展成支持capture-by-reference
留后路,所以干脆就不允许向被捕获的变量赋值,所以这些自由变量
需要强制加上final
修饰符(在Jdk8
中似乎已经没有这种强制限制了).
本文作者为: SylvanasSun.转载请务必将下面这段话置于文章开头处(保留超链接).
本文转发自SylvanasSun Blog,原文链接: https://sylvanassun.github.io/2017/07/27/2017-07-27-Graph_WeightedDigraph
有向图
的实现比无向图
更加简单,要实现加权有向图
只需要在上一章讲到的加权无向图
的实现修改一下即可.
由于有向图
的边都是带有方向的,所以下面这个实现提供了from()
与to()
函数,用于获取代表v->w
的两个顶点
.
|
|
|
|
加权有向图
的实现与加权无向图
区别不大,而且因为有向图
中的边只会出现一次,实现代码要比无向图
更简单.
“找到一个顶点
到达另一个顶点
之间的最短路径
“是图论
研究中的经典算法问题.在加权有向图
中,每条有向路径
都有一个与之对应的路径权重
(路径中所有边的权重
之和),要找到一条最短路径
其实就是找到路径权重
最小的那条路径.
“从s
到目的地v
是否存在一条有向路径
,如果有,找出最短的那条路径”.类似这样的问题就是单点最短路径
问题,它是我们主要研究的问题.
单点最短路径
的结果是一棵最短路径树
,它是图
的一幅子图
,包含了从起点到所有可达顶点的最短路径
.
从起点到一个顶点可能存在两条长度相等的路径,如果出现这种情况,可以删除其中一条路径的最后一条边,直到从起点到每个顶点都只有一条路径相连.
要实现最短路径
的算法还需要借助以下数据结构:
由顶点索引
的DirectedEdge
对象的父链接数组,其中edgeTo[v]
的值为树中连接v
和它的父节点的边.由顶点索引
的double
数组,其中distTo[v]
代表从起点
到v
的已知最短路径的长度.edgeTo[s]
的值为null
(s
为起点),distTo[s]
的值为0.0
,从s
到不可达的顶点距离为Double.POSITIVE_INFINITY
.最短路径
算法都基于松弛(Relaxation)
操作,它在遇到新的边时,通过更新这些信息就可以得到新的最短路径.
假设对边v->w
进行松弛操作,意味着要先检查从s
到w
的最短路径
是否是先从s
到v
,然后再由v
到w
(也就是说v->w
是更短的一条路径),如果是,那么就进行更新.由v
到达w
的最短路径
是distTo[v]
与e.weight()
之和,如果这个值大于distTo[w]
,称这条边松弛失败,并将它忽略.
松弛操作就像用一根橡皮筋沿着连续两个顶点
的路径紧紧展开,放松一条边就像将这条橡皮筋转移到另一条更短的路径上,从而缓解橡皮筋的压力.
|
|
Dijkstra算法
类似于Prim算法
,它将distTo[s]
初始化为0.0
,distTo[]
中的其他元素初始化为Double.POSITIVE_INFINITY
.然后将distTo[]
中最小的非树顶点
放松并加入树中,一直重复直到所有的顶点都在树中或者所有的非树顶点
的distTo[]
值均为Double.POSITIVE_INFINITY
.
Dijkstra算法
与Prim算法
都是用添加边的方式构造一棵树:
Prim算法
每次添加的是距离树
最近的非树顶点
.Dijkstra算法
每次添加的都是离起点
最近的非树顶点
.从上述的步骤我们就能看出,Dijkstra算法
需要一个优先队列(也可以用斐波那契堆
)来保存需要被放松的顶点
并确认下一个被放松的顶点
(也就是取出最小的).
如此简单的Dijkstra算法
也有其缺点,那就是它只适用于解决权重非负
的图
.
|
|
上述的代码也可以用于处理加权无向图
,但需要修改传入的对象类型.不管是无向图
还是有向图
它们对于最短路径
问题是等价的.
如果是处理无环图
的情况下,还会有一种比Dijkstra算法
更快、更简单的算法.它的特点如下:
负权重
的边.在已知是一张无环图
的情况下,它是找出最短路径
效率最高的方法.
Dijkstra算法
更简单.只需要将所有顶点
按照拓扑排序
的顺序来松弛边
,就可以得到这个简单高效的算法.
|
|
要想找出一条最长路径
,只需要把distTo[]
的初始化变为Double.NEGATIVE_INFINITY
,并更改relax()
函数中的不等式的方向.
|
|
我们已经知道了处理权重
非负图的Dijkstra算法
与处理无环图
的算法,但如果遇见既含有环,权重
也是负数的加权有向图
该怎么办?
Bellman-Ford算法
就是用于处理有环
且含有负权重
的加权有向图
的,它的原理是对图进行V-1
次松弛操作,得到所有可能的最短路径.
要实现Bellman-Ford算法
还需要以下数据结构:
我们将起点放入队列中,然后进入一个循环,每次循环都会从队列中取出一个顶点并对其进行松弛.为了保证算法在V
轮后能够终止,需要能够动态地检测是否存在负权重环
,如果找到了这个环则结束运行(也可以用一个变量动态记录轮数).
如果存在了一个从起点可达的负权重环
,那么队列就永远不可能为空,为了从这个无尽的循环中解脱出来,算法需要能够动态地检测负权重环
.
Bellman-Ford算法
也使用了edgeTo[]
来存放最短路径树
中的每一条边,我们根据edgeTo[]
来复制一幅图并在该图中检测环.
|
|
|
|
解决最短路径
问题一直都是图论
的经典问题,本文中介绍的算法适用于不同的环境,在应用中应该根据不同的环境选择不同的算法.
算法 | 局限性 | 路径长度的比较次数(增长的数量级) | 空间复杂度 | 优势 |
---|---|---|---|---|
Dijkstra | 只能处理正权重 | ElogV | V | 最坏情况下仍有较好的性能 |
拓扑排序 | 只适用于无环图 | E+V | V | 实现简单,是无环图情况下的最优算法 |
Bellman-Ford | 不能存在负权重环 | E+V,最坏情况为VE | V | 适用广泛 |
本文作者为: SylvanasSun.转载请务必将下面这段话置于文章开头处(保留超链接).
本文转发自SylvanasSun Blog,原文链接: https://sylvanassun.github.io/2017/07/25/2017-07-25-Graph_WeightedUndirectedGraph/
所谓加权图
,即每条边
上都有着对应的权重
,这个权重
是正数也可以是负数,也不一定会和距离成正比.加权无向图
的表示方法只需要对无向图
的实现进行一下扩展.
邻接矩阵
的方法中,可以用边
的权重
代替布尔值来作为矩阵的元素.邻接表
的方法中,可以在链表
的节点
中添加一个权重域.邻接表
的方法中,将边
抽象为一个Edge
类,它包含了相连的两个顶点
和它们的权重
,链表
中的每个元素都是一个Edge
.我们使用第三种方法来实现加权无向图
,它的数据表示如下图:
|
|
Edge
类提供了either()
与other()
两个函数,在两个顶点
都未知的情况下,可以调用either()
获得顶点v
,然后再调用other(v)
来获得另一个顶点
.
|
|
上述代码是对无向图
的扩展,它将邻接表
中的元素从整数
变为了Edge
,函数edges()
返回了边
的集合,由于是无向图
所以每条边
会出现两次,需要注意处理.
加权无向图
的实现还拥有以下特点:
Edge
类实现了Comparable
接口,它使用了权重
来比较两条边
的大小,所以加权无向图
的自然次序就是权重次序.edges()
函数中对自环边进行了记录.最小生成树
是加权无向图
的重要应用.图
的生成树
是它的一棵含有其所有顶点
的无环连通子图
,最小生成树
是它的一棵权值
(所有边的权值之和)最小的生成树
.
在给定的一幅加权无向图
$G = (V,E)$中,$(u,v)$代表连接顶点u
与顶点v
的边
,也就是$(u,v) \in E$,而$w(u,v)$代表这条边的权重
,若存在T
为E
的子集,也就是$T \subseteq E$,且为无环图
,使得$w(T) = \sum_{(u,v) \in T}w(u,v)$ 的 $w(T)$ 最小,则T
为G
的最小生成树
.
最小生成树
在一些情况下可能会存在多个,例如,给定一幅图G
,当它的所有边的权重
都相同时,那么G
的所有生成树
都是最小生成树
,当所有边的权重
互不相同时,将会只有一个最小生成树
.
切分定理
将图中的所有顶点
切分为两个集合(两个非空且不重叠的集合),检查两个集合的所有边并识别哪条边应属于图的最小生成树
.
一种比较简单的切分方法即通过指定一个顶点集并隐式地认为它的补集为另一个顶点集来指定一个切分.
切分定理
也表明了对于每一种切分,权重
最小的横切边(一条连接两个属于不同集合的顶点的边)
必然属于最小生成树
.
切分定理
是解决最小生成树
问题的所有算法的基础,使用切分定理
找到最小生成树
的一条边,不断重复直到找到最小生成树
的所有边.
这些算法可以说都是贪心算法
,算法的每一步都是在找最优解(权值
最小的横切边
),而解决最小生成树
的各种算法不同之处仅在于保存切分和判定权重
最小的横切边
的方式.
Prim算法
是用于解决最小生成树
的算法之一,算法的每一步都会为一棵生长中的树
添加一条边.一开始这棵树只有一个顶点
,然后会一直添加到$V - 1$条边,每次总是将下一条连接树
中的顶点
与不在树
中的顶点
且权重
最小的边加入到树
中(也就是由树
中顶点
所定义的切分中的一条横切边
).
实现Prim算法
还需要借助以下数据结构:
顶点
是否已在树
中.最小生成树
中的边,也可以使用一个由顶点
索引的Edge
对象的数组.横切边
,优先队列的性质可以每次取出权值
最小的横切边
.当我们连接新加入树
中的顶点
与其他已经在树
中顶点
的所有边都失效了(由于两个顶点
都已在树
中,所以这是一条失效的横切边
).我们需要处理这种情况,即使实现对无效边采取忽略(不加入到优先队列中),而延时实现会把无效边留在优先队列中,等到要删除优先队列中的数据时再进行有效性检查.
上图为Prim算法
延时实现的轨迹图,它的步骤如下:
顶点0
添加到最小生成树
中,将它的邻接表
中的所有边添加到优先队列中(将横切边
添加到优先队列).顶点7
和边0-7
添加到最小生成树
中,将顶点
的邻接表
中的所有边添加到优先队列中.顶点1
和边1-7
添加到最小生成树
中,将顶点
的邻接表
中的所有边添加到优先队列中.顶点2
和边0-2
添加到最小生成树
中,将边2-3
和6-2
添加到优先队列中,边2-7
和1-2
失效.顶点3
和边2-3
添加到最小生成树
中,将边3-6
添加到优先队列之中,边1-3
失效.顶点5
和边5-7
添加到最小生成树
中,将边4-5
添加到优先队列中,边1-5
失效.1-3
,1-5
,2-7
.顶点4
和边4-5
添加到最小生成树
中,将边6-4
添加到优先队列中,边4-7
,0-4
失效.1-2
,4-7
,0-4
.顶点6
和边6-2
添加到最小生成树
中,和顶点6
关联的其他边失效.V
个顶点与V - 1
条边之后,最小生成树
就构造完成了,优先队列中剩余的边都为失效边.
|
|
在即时实现中,将v
添加到树中时,对于每个非树顶点w
,不需要在优先队列中保存所有从w
到树顶点
的边,而只需要保存其中权重
最小的边,所以在将v
添加到树
中后,要检查是否需要更新这条权重
最小的边(如果v-w
的权重
更小的话).
也可以认为只会在优先队列中保存每个非树顶点w
的一条边(也是权重
最小的那条边),将w
和树顶点
连接起来的其他权重
较大的边迟早都会失效,所以没必要在优先队列中保存它们.
要实现即时版的Prim算法
,需要使用两个顶点索引的数组edgeTo[]
和distTo[]
与一个索引优先队列,它们具有以下性质:
顶点v
不在树中但至少含有一条边和树相连,那么edgeTo[v]
是将v
和树连接的最短边,distTo[v]
为这条边的权重
.顶点v
都保存在索引优先队列中,索引v
关联的值是edgeTo[v]
的边的权重
.权重
最小的横切边
的权重
,而和它相关联的顶点v
就是下一个将要被添加到树
中的顶点
.顶点0
添加到最小生成树
之中,将它的邻接表
中的所有边添加到优先队列中(这些边是目前唯一已知的横切边).顶点7
和边0-7
添加到最小生成树
,将边1-7
和5-7
添加到优先队列中,将连接顶点4
与树的最小边由0-4
替换为4-7
.顶点1
和边1-7
添加到最小生成树
,将边1-3
添加到优先队列.顶点2
和边0-2
添加到最小生成树,将连接顶点6
与树的最小边由0-6
替换为6-2
,将连接顶点3
与树的最小边由1-3
替换为2-3
.顶点3
和边2-3
添加到最小生成树
.顶点5
和边5-7
添加到最小生成树
,将连接顶点4
与树的最小边4-7
替换为4-5
.顶点4
和边4-5
添加到最小生成树
.顶点6
和边6-2
添加到最小生成树
.V - 1
条边之后,最小生成树
构造完成并且优先队列为空.
|
|
不管是延迟实现
还是即时实现
,Prim算法
的规律就是: 在树
的生长过程中,都是通过连接一个和新加入的顶点
相邻的顶点
.当新加入的顶点
周围没有非树顶点
时,树的生长又会从另一部分开始.
Kruskal算法
的思想是按照边的权重
顺序由小到大处理它们,将边添加到最小生成树
,加入的边不会与已经在树
中的边构成环,直到树
中含有V - 1
条边为止.这些边会逐渐由一片森林
合并为一棵树
,也就是我们需要的最小生成树
.
Prim算法
是一条边一条边地来构造最小生成树
,每一步都会为树
中添加一条边.Kruskal算法
构造最小生成树
也是一条边一条边地添加,但不同的是它寻找的边会连接一片森林
中的两棵树
.从一片由V
棵单顶点
的树构成的森林
开始并不断地将两棵树
合并(可以找到的最短边)直到只剩下一棵树
,它就是最小生成树
.要实现Kruskal算法
需要借助Union-Find
数据结构,它是一种树型的数据结构,用于处理一些不相交集合的合并与查询问题.
关于Union-Find
的更多资料可以参考下面的链接:
|
|
上面代码实现的Kruskal算法
使用了一条队列来保存最小生成树
的边集,一条优先队列来保存还未检查的边,一个Union-Find
来判断失效边.
算法 | 空间复杂度 | 时间复杂度 |
---|---|---|
Prim(延时) | E | ElogE |
Prim(即时) | V | ElogV |
Kruskal | E | ElogE |
本文作者为: SylvanasSun.转载请务必将下面这段话置于文章开头处(保留超链接).
本文转发自SylvanasSun Blog,原文链接: https://sylvanassun.github.io/2017/07/23/2017-07-23-Graph_DirectedGraphs/
有向图
与无向图
不同,它的边
是单向的,每条边所连接的两个顶点都是一个有序对,它们的邻接性是单向的.
在有向图
中,一条有向边
由第一个顶点
指出并指向第二个顶点
,一个顶点
的出度
为由该顶点
指出的边
的总数;一个顶点
的入度
为指向该顶点
的边的总数.
v->w
表示一条由v
指向w
的边,在一幅有向图
中,两个顶点
的关系可能有以下四种(特殊图除外):
边
相连.v
到w
的边
: v->w
.w
到v
的边
: w->v
.v->w
,也存在w->v
,也就是一条双向边
.当存在从v
到w
的有向路径
时,称顶点w
能够由顶点v
达到.但在有向图
中,由v
能够到达w
并不意味着由w
也能到达v
(但每个顶点
都是能够到达它自己的).
有向图
的实现与无向图
差不多,只不过在边
的方向上有所不同.(本文中的所有完整代码可以在我的GitHub中查看)
|
|
对于”是否存在一条从集合中的任意顶点
到达给定顶点v
的有向路径?”等类似问题,可以使用深度优先搜索
或广度优先搜索
(与无向图
的实现一致,只不过传入的图
的类型不同),有向图
生成的搜索轨迹甚至要比无向图
还要简单.
对于可达性分析
的一个典型应用就是内存管理系统.例如,JVM
使用多点可达性分析
的方法来判断一个对象
是否可以进行回收: 所有对象
组成一幅有向图
,其中有多个Root顶点
(它是由JVM
自己决定的)作为起点
,如果一个对象
从Root顶点
不可达,那么这个对象
就可以进行回收了.
在与有向图
相关的实际应用中,有向环
特别的重要.我们需要知道一幅有向图
中是否包含有向环
.在任务调度问题或其他许多问题中会不允许存在有向环
,所以对于环
的检测是很重要的.
使用深度优先搜索
解决这个问题并不困难,递归调用隐式使用的栈表示的正是”当前”正在遍历的有向路径
,一旦找到了一条边v->w
且w
已经存在于栈中,就等于找到了一个环
(栈表示的是一条由w
到v
的有向路径
,而v->w
正好补全了这个环
).
|
|
拓扑排序
等价于计算优先级限制下的调度问题的,所谓优先级限制的调度问题即是在给定一组需要完成的任务与关于任务完成的先后次序的优先级限制,需要在满足限制条件的前提下来安排任务.
拓扑排序
需要的是一幅有向无环图
,如果这幅图
中含有环
,那么它肯定不是拓扑有序
的(一个带有环的调度问题是无解的).
在学习拓扑排序
之前,需要先知道顶点
的排序.
使用深度优先搜索
来记录顶点排序
是一个很好的选择(正好只会访问每个顶点
一次),我们借助一些数据结构
来保存顶点排序
的顺序:
顶点
加入队列.顶点
加入队列.顶点
压入栈.
|
|
所谓拓扑排序
就是无环有向图
的逆后序
,现在已经知道了如何检测环
与顶点排序
,那么实现拓扑排序
就很简单了.
|
|
在一幅无向图
中,如果有一条路径连接顶点v
和w
,则它们就是连通
的(既可以从w
到达v
,也可以从v
到达w
).但在有向图
中,如果从顶点v
有一条有向路径到达w
,则w
是从v
可达的,但从w
到达v
的路径可能存在也可能不存在.
强连通性
就是两个顶点v
和w
是互相可达的.有向图
中的强连通性
具有以下性质:
顶点v
和自己都是强连通性
的(有向图
中顶点都是自己可达的).v
和w
是强连通的,那么w
和v
也是强连通的.v
和w
是强连通的且w
和x
也是强连通的,那么v
和x
也是强连通的.强连通性
将所有顶点
分为了一些等价类,每个等价类都是由相互为强连通的顶点
的最大子集组成的.这些子集称为强连通分量
,它的定义是基于顶点的,而非边.
一个含有V
个顶点的有向图
含有1 ~ V
个强连通分量
.一个强连通图
只含有一个强连通分量
,而一个有向无环图
中则含有V
个强连通分量
.
Kosaraju算法是用于枚举图中每个强连通分量
内的所有顶点,它主要有以下步骤:
有向图
$G$中,取得它的反向图$G^R$.深度优先搜索
得到$G^R$的逆后序排列.深度优先搜索
深度优先搜索
递归子程序中访问的所有顶点
都在同一个强连通分量
内.
|
|
在一幅有向图G
中,传递闭包
是由相同的一组顶点
组成的另一幅有向图
,在传递闭包
中存在一条从v
指向w
的边且仅当在G
中w
是从v
可达的.
由于有向图
的性质,每个顶点
对于自己都是可达的,所以传递闭包
会含有V
个自环.
通常将传递闭包
表示为一个布尔值矩阵,其中v
行w
列的值为true
代表当且仅当w
是从v
可达的.
传递闭包
不适合于处理大型有向图
,因为构造函数所需的空间与$V^2$成正比,所需的时间和$V(V+E)$成正比.
|
|