Java 8中Map相关的红黑树的引用背景、原理等

HashMap的容量、扩容

很多人在通过阅读源码的方式学习Java,这是个很好的方式。而JDK的源码自然是首选。在JDK的众多类中,我觉得HashMap及其相关的类是设计的比较好的。很多人读过HashMap的代码,不知道你们有没有和我一样,觉得HashMap中关于容量相关的参数定义的太多了,傻傻分不清楚。

其实,这篇文章介绍的内容比较简单,只要认真的看看HashMap的原理还是可以理解的,单独写一篇文章的原因是因为我后面还有几篇关于HashMap源码分析的文章,这些概念不熟悉的话阅读后面的文章会很吃力。

先来看一下,HashMap中都定义了哪些成员变量。

上面是一张HashMap中主要的成员变量的图,其中有一个是我们本文主要关注的: size、loadFactor、threshold、DEFAULT_LOAD_FACTOR和DEFAULT_INITIAL_CAPACITY。

我们先来简单解释一下这些参数的含义,然后再分析他们的作用。

HashMap类中有以下主要成员变量:

  • transient int size;

记录了Map中KV对的个数

  • loadFactor

装载因子,用来衡量HashMap满的程度。loadFactor的默认值为0.75f(static final float DEFAULT_LOAD_FACTOR = 0.75f;)。

  • int threshold;

临界值,当实际KV个数超过threshold时,HashMap会将容量扩容,threshold=容量*装载因子

  • 除了以上这些重要成员变量外,HashMap中还有一个和他们紧密相关的概念:capacity

容量,如果不指定,默认容量是16(static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;)

可能看完了你还是有点蒙,size和capacity之间有啥关系?为啥要定义这两个变量。loadFactor和threshold又是干啥的?

size 和 capacity

HashMap中的size和capacity之间的区别其实解释起来也挺简单的。我们知道,HashMap就像一个“桶”,那么capacity就是这个桶“当前”最多可以装多少元素,而size表示这个桶已经装了多少元素。来看下以下代码:

    Map<String, String> map = new HashMap<String, String>();map.put("hollis", "hollischuang");Class<?> mapType = map.getClass();Method capacity = mapType.getDeclaredMethod("capacity");capacity.setAccessible(true);System.out.println("capacity : " + capacity.invoke(map));Field size = mapType.getDeclaredField("size");size.setAccessible(true);System.out.println("size : " + size.get(map));

我们定义了一个新的HashMap,并想其中put了一个元素,然后通过反射的方式打印capacity和size。输出结果为:capacity : 16、size : 1

默认情况下,一个HashMap的容量(capacity)是16,设计成16的好处主要是可以使用按位与替代取模来提升hash的效率。

为什么我刚刚说capacity就是这个桶“当前”最多可以装多少元素呢?当前怎么理解呢。其实,HashMap是具有扩容机制的。在一个HashMap第一次初始化的时候,默认情况下他的容量是16,当达到扩容条件的时候,就需要进行扩容了,会从16扩容成32。

我们知道,HashMap的重载的构造函数中,有一个是支持传入initialCapacity的,那么我们尝试着设置一下,看结果如何。

    Map<String, String> map = new HashMap<String, String>(1);Class<?> mapType = map.getClass();Method capacity = mapType.getDeclaredMethod("capacity");capacity.setAccessible(true);System.out.println("capacity : " + capacity.invoke(map));Map<String, String> map = new HashMap<String, String>(7);Class<?> mapType = map.getClass();Method capacity = mapType.getDeclaredMethod("capacity");capacity.setAccessible(true);System.out.println("capacity : " + capacity.invoke(map));Map<String, String> map = new HashMap<String, String>(9);Class<?> mapType = map.getClass();Method capacity = mapType.getDeclaredMethod("capacity");capacity.setAccessible(true);System.out.println("capacity : " + capacity.invoke(map));

分别执行以上3段代码,分别输出:capacity : 1、capacity : 8、capacity : 16。

也就是说,默认情况下HashMap的容量是16,但是,如果用户通过构造函数指定了一个数字作为容量,那么Hash会选择大于该数字的第一个2的幂作为容量。(1->1、7->8、9->16)

这里有一个小建议:在初始化HashMap的时候,应该尽量指定其大小。尤其是当你已知map中存放的元素个数时。

loadFactor 和 threshold

前面我们提到过,HashMap有扩容机制,就是当达到扩容条件时会进行扩容,从16扩容到32、64、128…

那么,这个扩容条件指的是什么呢?

其实,HashMap的扩容条件就是当HashMap中的元素个数(size)超过临界值(threshold)时就会自动扩容。

在HashMap中,threshold = loadFactor * capacity。

loadFactor是装载因子,表示HashMap满的程度,默认值为0.75f,设置成0.75有一个好处,那就是0.75正好是3/4,而capacity又是2的幂。所以,两个数的乘积都是整数。

对于一个默认的HashMap来说,默认情况下,当其size大于12(16*0.75)时就会触发扩容。

验证代码如下:

    Map<String, String> map = new HashMap<>();map.put("hollis1", "hollischuang");map.put("hollis2", "hollischuang");map.put("hollis3", "hollischuang");map.put("hollis4", "hollischuang");map.put("hollis5", "hollischuang");map.put("hollis6", "hollischuang");map.put("hollis7", "hollischuang");map.put("hollis8", "hollischuang");map.put("hollis9", "hollischuang");map.put("hollis10", "hollischuang");map.put("hollis11", "hollischuang");map.put("hollis12", "hollischuang");Class<?> mapType = map.getClass();Method capacity = mapType.getDeclaredMethod("capacity");capacity.setAccessible(true);System.out.println("capacity : " + capacity.invoke(map));Field size = mapType.getDeclaredField("size");size.setAccessible(true);System.out.println("size : " + size.get(map));Field threshold = mapType.getDeclaredField("threshold");threshold.setAccessible(true);System.out.println("threshold : " + threshold.get(map));Field loadFactor = mapType.getDeclaredField("loadFactor");loadFactor.setAccessible(true);System.out.println("loadFactor : " + loadFactor.get(map));map.put("hollis13", "hollischuang");Method capacity = mapType.getDeclaredMethod("capacity");capacity.setAccessible(true);System.out.println("capacity : " + capacity.invoke(map));Field size = mapType.getDeclaredField("size");size.setAccessible(true);System.out.println("size : " + size.get(map));Field threshold = mapType.getDeclaredField("threshold");threshold.setAccessible(true);System.out.println("threshold : " + threshold.get(map));Field loadFactor = mapType.getDeclaredField("loadFactor");loadFactor.setAccessible(true);System.out.println("loadFactor : " + loadFactor.get(map));

输出结果:

capacity : 16
size : 12
threshold : 12
loadFactor : 0.75capacity : 32
size : 13
threshold : 24
loadFactor : 0.75

当HashMap中的元素个数达到13的时候,capacity就从16扩容到32了。

HashMap中还提供了一个支持传入initialCapacity,loadFactor两个参数的方法,来初始化容量和装载因子。不过,一般不建议修改loadFactor的值。

总结

HashMap中size表示当前共有多少个KV对,capacity表示当前HashMap的容量是多少,默认值是16,每次扩容都是成倍的。loadFactor是装载因子,当Map中元素个数超过loadFactor* capacity的值时,会触发扩容。loadFactor* capacity可以用threshold表示。

PS:文中分析基于JDK1.8.0_73

HashMap中hash方法的原理

你知道HashMap中hash方法的具体实现吗?你知道HashTable、ConcurrentHashMap中hash方法的实现以及原因吗?你知道为什么要这么实现吗?你知道为什么JDK 7和JDK 8中hash方法实现的不同以及区别吗?如果你不能很好的回答这些问题,那么你需要好好看看这篇文章。文中涉及到大量代码和计算机底层原理知识。绝对的干货满满。整个互联网,把hash()分析的如此透彻的,别无二家。

哈希

Hash,一般翻译做“散列”,也有直接音译为“哈希”的,就是把任意长度的输入,通过散列算法,变换成固定长度的输出,该输出就是散列值。这种转换是一种压缩映射,也就是,散列值的空间通常远小于输入的空间,不同的输入可能会散列成相同的输出,所以不可能从散列值来唯一的确定输入值。简单的说就是一种将任意长度的消息压缩到某一固定长度的消息摘要的函数。

所有散列函数都有如下一个基本特性:根据同一散列函数计算出的散列值如果不同,那么输入值肯定也不同。但是,根据同一散列函数计算出的散列值如果相同,输入值不一定相同。

两个不同的输入值,根据同一散列函数计算出的散列值相同的现象叫做碰撞。

常见的Hash函数有以下几个:

直接定址法:直接以关键字k或者k加上某个常数(k+c)作为哈希地址。
数字分析法:提取关键字中取值比较均匀的数字作为哈希地址。
除留余数法:用关键字k除以某个不大于哈希表长度m的数p,将所得余数作为哈希表地址。
分段叠加法:按照哈希表地址位数将关键字分成位数相等的几部分,其中最后一部分可以比较短。然后将这几部分相加,舍弃最高进位后的结果就是该关键字的哈希地址。
平方取中法:如果关键字各个部分分布都不均匀的话,可以先求出它的平方值,然后按照需求取中间的几位作为哈希地址。
伪随机数法:采用一个伪随机数当作哈希函数。

上面介绍过碰撞。衡量一个哈希函数的好坏的重要指标就是发生碰撞的概率以及发生碰撞的解决方案。任何哈希函数基本都无法彻底避免碰撞,常见的解决碰撞的方法有以下几种:

  • 开放定址法:

开放定址法就是一旦发生了冲突,就去寻找下一个空的散列地址,只要散列表足够大,空的散列地址总能找到,并将记录存入。

  • 链地址法

将哈希表的每个单元作为链表的头结点,所有哈希地址为i的元素构成一个同义词链表。即发生冲突时就把该关键字链在以该单元为头结点的链表的尾部。

  • 再哈希法

当哈希地址发生冲突用其他的函数计算另一个哈希函数地址,直到冲突不再产生为止。

  • 建立公共溢出区

将哈希表分为基本表和溢出表两部分,发生冲突的元素都放入溢出表中。

HashMap 的数据结构

在Java中,保存数据有两种比较简单的数据结构:数组和链表。数组的特点是:寻址容易,插入和删除困难;而链表的特点是:寻址困难,插入和删除容易。上面我们提到过,常用的哈希函数的冲突解决办法中有一种方法叫做链地址法,其实就是将数组和链表组合在一起,发挥了两者的优势,我们可以将其理解为链表的数组。

我们可以从上图看到,左边很明显是个数组,数组的每个成员是一个链表。该数据结构所容纳的所有元素均包含一个指针,用于元素间的链接。我们根据元素的自身特征把元素分配到不同的链表中去,反过来我们也正是通过这些特征找到正确的链表,再从链表中找出正确的元素。其中,根据元素特征计算元素数组下标的方法就是哈希算法,即本文的主角hash()函数(当然,还包括indexOf()函数)。

hash方法

我们拿JDK 1.7的HashMap为例,其中定义了一个final int hash(Object k) 方法,其主要被以下方法引用。

上面的方法主要都是增加和删除方法,这不难理解,当我们要对一个链表数组中的某个元素进行增删的时候,首先要知道他应该保存在这个链表数组中的哪个位置,即他在这个数组中的下标。而hash()方法的功能就是根据Key来定位其在HashMap中的位置。HashTable、ConcurrentHashMap同理。

源码解析

首先,在同一个版本的Jdk中,HashMap、HashTable以及ConcurrentHashMap里面的hash方法的实现是不同的。在不同的版本的JDK中(Java7 和 Java8)中也是有区别的。我会尽量全部介绍到。相信,看完这篇文章,你会彻底理解hash方法。

在上代码之前,我们先来做个简单分析。我们知道,hash方法的功能是根据Key来定位这个K-V在链表数组中的位置的。也就是hash方法的输入应该是个Object类型的Key,输出应该是个int类型的数组下标。如果让你设计这个方法,你会怎么做?

其实简单,我们只要调用Object对象的hashCode()方法,该方法会返回一个整数,然后用这个数对HashMap或者HashTable的容量进行取模就行了。没错,其实基本原理就是这个,只不过,在具体实现上,由两个方法int hash(Object k)和int indexFor(int h, int length)来实现。但是考虑到效率等问题,HashMap的实现会稍微复杂一点。

hash :该方法主要是将Object转换成一个整型。
indexFor :该方法主要是将hash生成的整型转换成链表数组中的下标。

HashMap In Java 7

final int hash(Object k) {int h = hashSeed;if (0 != h && k instanceof String) {return sun.misc.Hashing.stringHash32((String) k);}h ^= k.hashCode();h ^= (h >>> 20) ^ (h >>> 12);return h ^ (h >>> 7) ^ (h >>> 4);
}static int indexFor(int h, int length) {return h & (length-1);
}

前面我说过,indexFor方法其实主要是将hash生成的整型转换成链表数组中的下标。那么return h & (length-1);是什么意思呢?其实,他就是取模。Java之所有使用位运算(&)来代替取模运算(%),最主要的考虑就是效率。位运算(&)效率要比代替取模运算(%)高很多,主要原因是位运算直接对内存数据进行操作,不需要转成十进制,因此处理速度非常快。

那么,为什么可以使用位运算(&)来实现取模运算(%)呢?这实现的原理如下:

X % 2^n = X & (2^n - 1)
2n表示2的n次方,也就是说,一个数对2n取模 == 一个数和(2^n - 1)做按位与运算 。
假设n为3,则2^3 = 8,表示成2进制就是1000。2^3 -1 = 7 ,即0111。
此时X & (2^3 - 1) 就相当于取X的2进制的最后三位数。
从2进制角度来看,X / 8相当于 X >> 3,即把X右移3位,此时得到了X / 8的商,而被移掉的部分(后三位),则是X % 8,也就是余数。

上面的解释不知道你有没有看懂,没看懂的话其实也没关系,你只需要记住这个技巧就可以了。或者你可以找几个例子试一下。

6 % 8 = 6 ,6 & 7 = 6
10 & 8 = 2 ,10 & 7 = 2


所以,return h & (length-1);只要保证length的长度是2^n的话,就可以实现取模运算了。而HashMap中的length也确实是2的倍数,初始值是16,之后每次扩充为原来的2倍。

分析完indexFor方法后,我们接下来准备分析hash方法的具体原理和实现。在深入分析之前,至此,先做个总结。

HashMap的数据是存储在链表数组里面的。在对HashMap进行插入/删除等操作时,都需要根据K-V对的键值定位到他应该保存在数组的哪个下标中。而这个通过键值求取下标的操作就叫做哈希。HashMap的数组是有长度的,Java中规定这个长度只能是2的倍数,初始值为16。简单的做法是先求取出键值的hashcode,然后在将hashcode得到的int值对数组长度进行取模。为了考虑性能,Java总采用按位与操作实现取模操作。

接下来我们会发现,无论是用取模运算还是位运算都无法直接解决冲突较大的问题。比如:CA11 0000和0001 0000在对0000 1111进行按位与运算后的值是相等的。

两个不同的键值,在对数组长度进行按位与运算后得到的结果相同,这不就发生了冲突吗。那么如何解决这种冲突呢,来看下Java是如何做的。

其中的主要代码部分如下:

h ^= k.hashCode();
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);

这段代码是为了对key的hashCode进行扰动计算,防止不同hashCode的高位不同但低位相同导致的hash冲突。简单点说,就是为了把高位的特征和低位的特征组合起来,降低哈希冲突的概率,也就是说,尽量做到任何一位的变化都能对最终得到的结果产生影响。

举个例子来说,我们现在想向一个HashMap中put一个K-V对,Key的值为“hollischuang”,经过简单的获取hashcode后,得到的值为“1011000110101110011111010011011”,如果当前HashTable的大小为16,即在不进行扰动计算的情况下,他最终得到的index结果值为11。由于15的二进制扩展到32位为“00000000000000000000000000001111”,所以,一个数字在和他进行按位与操作的时候,前28位无论是什么,计算结果都一样(因为0和任何数做与,结果都为0)。如下图所示。

可以看到,后面的两个hashcode经过位运算之后得到的值也是11 ,虽然我们不知道哪个key的hashcode是上面例子中的那两个,但是肯定存在这样的key,这就产生了冲突。

那么,接下来,我看看一下经过扰动的算法最终的计算结果会如何。

从上面图中可以看到,之前会产生冲突的两个hashcode,经过扰动计算之后,最终得到的index的值不一样了,这就很好的避免了冲突。

其实,使用位运算代替取模运算,除了性能之外,还有一个好处就是可以很好的解决负数的问题。因为我们知道,hashcode的结果是int类型,而int的取值范围是-2^31 ~ 2^31 - 1,即[ -2147483648, 2147483647];这里面是包含负数的,我们知道,对于一个负数取模还是有些麻烦的。如果使用二进制的位运算的话就可以很好的避免这个问题。首先,不管hashcode的值是正数还是负数。length-1这个值一定是个正数。那么,他的二进制的第一位一定是0(有符号数用最高位作为符号位,“0”代表“+”,“1”代表“-”),这样里两个数做按位与运算之后,第一位一定是个0,也就是,得到的结果一定是个正数。

HashTable In Java 7
上面是Java 7中HashMap的hash方法以及indexOf方法的实现,那么接下来我们要看下,线程安全的HashTable是如何实现的,和HashMap有何不同,并试着分析下不同的原因。以下是Java 7中HashTable的hash方法的实现。

private int hash(Object k) {// hashSeed will be zero if alternative hashing is disabled.return hashSeed ^ k.hashCode();
}

我们可以发现,很简单,相当于只是对k做了个简单的hash,取了一下其hashCode。而HashTable中也没有indexOf方法,取而代之的是这段代码:int index = (hash & 0x7FFFFFFF) % tab.length;。也就是说,HashMap和HashTable对于计算数组下标这件事,采用了两种方法。HashMap采用的是位运算,而HashTable采用的是直接取模。

为啥要把hash值和0x7FFFFFFF做一次按位与操作呢,主要是为了保证得到的index的第一位为0,也就是为了得到一个正数。因为有符号数第一位0代表正数,1代表负数。

我们前面说过,HashMap之所以不用取模的原因是为了提高效率。有人认为,因为HashTable是个线程安全的类,本来就慢,所以Java并没有考虑效率问题,就直接使用取模算法了呢?但是其实并不完全是,Java这样设计还是有一定的考虑在的,虽然这样效率确实是会比HashMap慢一些。

其实,HashTable采用简单的取模是有一定的考虑在的。这就要涉及到HashTable的构造函数和扩容函数了。由于篇幅有限,这里就不贴代码了,直接给出结论:

HashTable默认的初始大小为11,之后每次扩充为原来的2n+1。
也就是说,HashTable的链表数组的默认大小是一个素数、奇数。之后的每次扩充结果也都是奇数。
由于HashTable会尽量使用素数、奇数作为容量的大小。当哈希表的大小为素数时,简单的取模哈希的结果会更加均匀。

至此,我们看完了Java 7中HashMap和HashTable中对于hash的实现,我们来做个简单的总结。

  • HashMap默认的初始化大小为16,之后每次扩充为原来的2倍。
  • HashTable默认的初始大小为11,之后每次扩充为原来的2n+1。
  • 当哈希表的大小为素数时,简单的取模哈希的结果会更加均匀,所以单从这一点上看,HashTable的哈希表大小选择,似乎更高明些。因为hash结果越分散效果越好。
  • 在取模计算时,如果模数是2的幂,那么我们可以直接使用位运算来得到结果,效率要大大高于做除法。所以从hash计算的效率上,又是HashMap更胜一筹。
  • 但是,HashMap为了提高效率使用位运算代替哈希,这又引入了哈希分布不均匀的问题,所以HashMap为解决这问题,又对hash算法做了一些改进,进行了扰动计算。

ConcurrentHashMap In Java 7

private int hash(Object k) {int h = hashSeed;if ((0 != h) && (k instanceof String)) {return sun.misc.Hashing.stringHash32((String) k);}h ^= k.hashCode();// Spread bits to regularize both segment and index locations,// using variant of single-word Wang/Jenkins hash.h += (h <<  15) ^ 0xffffcd7d;h ^= (h >>> 10);h += (h <<   3);h ^= (h >>>  6);h += (h <<   2) + (h << 14);return h ^ (h >>> 16);
}int j = (hash >>> segmentShift) & segmentMask;

上面这段关于ConcurrentHashMap的hash实现其实和HashMap如出一辙。都是通过位运算代替取模,然后再对hashcode进行扰动。区别在于,ConcurrentHashMap 使用了一种变种的Wang/Jenkins 哈希算法,其主要目的也是为了把高位和低位组合在一起,避免发生冲突。至于为啥不和HashMap采用同样的算法进行扰动,我猜这只是程序员自由意志的选择吧。至少我目前没有办法证明哪个更优。

HashMap In Java 8

在Java 8 之前,HashMap和其他基于map的类都是通过链地址法解决冲突,它们使用单向链表来存储相同索引值的元素。在最坏的情况下,这种方式会将HashMap的get方法的性能从O(1)降低到O(n)。为了解决在频繁冲突时hashmap性能降低的问题,Java 8中使用平衡树来替代链表存储冲突的元素。这意味着我们可以将最坏情况下的性能从O(n)提高到O(logn)。关于HashMap在Java 8中的优化,我后面会有文章继续深入介绍。

如果恶意程序知道我们用的是Hash算法,则在纯链表情况下,它能够发送大量请求导致哈希碰撞,然后不停访问这些key导致HashMap忙于进行线性查找,最终陷入瘫痪,即形成了拒绝服务攻击(DoS)。

关于Java 8中的hash函数,原理和Java 7中基本类似。Java 8中这一步做了优化,只做一次16位右位移异或混合,而不是四次,但原理是不变的。

static final int hash(Object key) {int h;return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

在JDK1.8的实现中,优化了高位运算的算法,通过hashCode()的高16位异或低16位实现的:(h = k.hashCode()) ^ (h >>> 16),主要是从速度、功效、质量来考虑的。以上方法得到的int的hash值,然后再通过h & (table.length -1)来得到该对象在数据中保存的位置。

HashTable In Java 8

在Java 8的HashTable中,已经不再有hash方法了。但是哈希的操作还是在的,比如在put方法中就有如下实现:

    int hash = key.hashCode();int index = (hash & 0x7FFFFFFF) % tab.length;

这其实和Java 7中的实现几乎无差别,就不做过多的介绍了。

ConcurrentHashMap In Java 8

Java 8 里面的求hash的方法从hash改为了spread。实现方式如下:

static final int spread(int h) {return (h ^ (h >>> 16)) & HASH_BITS;
}

总结

至此,我们已经分析完了HashMap、HashTable以及ConcurrentHashMap分别在Jdk 1.7 和 Jdk 1.8中的实现。我们可以发现,为了保证哈希的结果可以分散、为了提高哈希的效率,JDK在一个小小的hash方法上就有很多考虑,做了很多事情。当然,我希望我们不仅可以深入了解背后的原理,还要学会这种对代码精益求精的态度。

Jdk的源代码,每一行都很有意思,都值得花时间去钻研、推敲。

为什么HashMap的默认容量设置成16

集合是Java开发日常开发中经常会使用到的,而作为一种典型的K-V结构的数据结构,HashMap对于Java开发者一定不陌生。

在日常开发中,我们经常会像如下方式以下创建一个HashMap:

Map<String, String> map = new HashMap<String, String>();

但是,大家有没有想过,上面的代码中,我们并没有给HashMap指定容量,那么,这时候一个新创建的HashMap的默认容量是多少呢?为什么呢?

本文就来分析下这个问题。

什么是容量

在Java中,保存数据有两种比较简单的数据结构:数组和链表。数组的特点是:寻址容易,插入和删除困难;而链表的特点是:寻址困难,插入和删除容易。HashMap就是将数组和链表组合在一起,发挥了两者的优势,我们可以将其理解为链表的数组。

在HashMap中,有两个比较容易混淆的关键字段:size和capacity ,这其中capacity就是Map的容量,而size我们称之为Map中的元素个数。

简单打个比方你就更容易理解了:HashMap就是一个“桶”,那么容量(capacity)就是这个桶当前最多可以装多少元素,而元素个数(size)表示这个桶已经装了多少元素。

如以下代码:

Map<String, String> map = new HashMap<String, String>();
map.put("hollis", "hollischuang");Class<?> mapType = map.getClass();
Method capacity = mapType.getDeclaredMethod("capacity");
capacity.setAccessible(true);
System.out.println("capacity : " + capacity.invoke(map));Field size = mapType.getDeclaredField("size");
size.setAccessible(true);
System.out.println("size : " + size.get(map));

输出结果:

capacity : 16、size : 1

上面我们定义了一个新的HashMap,并想其中put了一个元素,然后通过反射的方式打印capacity和size,其容量是16,已经存放的元素个数是1。

通过前面的例子,我们发现了,当我们创建一个HashMap的时候,如果没有指定其容量,那么会得到一个默认容量为16的Map,那么,这个容量是怎么来的呢?又为什么是这个数字呢?

容量与哈希

要想讲清楚这个默认容量的缘由,我们要首先要知道这个容量有什么用?

我们知道,容量就是一个HashMap中"桶"的个数,那么,当我们想要往一个HashMap中put一个元素的时候,需要通过一定的算法计算出应该把他放到哪个桶中,这个过程就叫做哈希(hash),对应的就是HashMap中的hash方法。

我们知道,hash方法的功能是根据Key来定位这个K-V在链表数组中的位置的。也就是hash方法的输入应该是个Object类型的Key,输出应该是个int类型的数组下标。如果让你设计这个方法,你会怎么做?

其实简单,我们只要调用Object对象的hashCode()方法,该方法会返回一个整数,然后用这个数对HashMap的容量进行取模就行了。

如果真的是这么简单的话,那HashMap的容量设置就会简单很多了,但是考虑到效率等问题,HashMap的hash方法实现还是有一定的复杂的。

hash的实现

接下来就介绍下HashMap中hash方法的实现原理。

具体实现上,由两个方法int hash(Object k)和int indexFor(int h, int length)来实现。

hash :该方法主要是将Object转换成一个整型。
indexFor :该方法主要是将hash生成的整型转换成链表数组中的下标。

为了聚焦本文的重点,我们只来看一下indexFor方法。我们先来看下Java 7(Java8中虽然没有这样一个单独的方法,但是查询下标的算法也是和Java 7一样的)中该实现细节:

static int indexFor(int h, int length) {return h & (length-1);
}

indexFor方法其实主要是将hashcode换成链表数组中的下标。其中的两个参数h表示元素的hashcode值,length表示HashMap的容量。那么return h & (length-1) 是什么意思呢?

其实,他就是取模。Java之所有使用位运算(&)来代替取模运算(%),最主要的考虑就是效率。

位运算(&)效率要比代替取模运算(%)高很多,主要原因是位运算直接对内存数据进行操作,不需要转成十进制,因此处理速度非常快。

那么,为什么可以使用位运算(&)来实现取模运算(%)呢?这实现的原理如下:

X % 2^n = X & (2^n – 1)

假设n为3,则2^3 = 8,表示成2进制就是1000。2^3 -1 = 7 ,即0111。

此时X & (2^3 – 1) 就相当于取X的2进制的最后三位数。

从2进制角度来看,X / 8相当于 X >> 3,即把X右移3位,此时得到了X / 8的商,而被移掉的部分(后三位),则是X % 8,也就是余数。

上面的解释不知道你有没有看懂,没看懂的话其实也没关系,你只需要记住这个技巧就可以了。或者你可以找几个例子试一下。

6 % 8 = 6 ,6 & 7 = 610 % 8 = 2 ,10 & 7 = 2


所以,return h & (length-1);只要保证length的长度是2^n 的话,就可以实现取模运算了。

所以,因为位运算直接对内存数据进行操作,不需要转成十进制,所以位运算要比取模运算的效率更高,所以HashMap在计算元素要存放在数组中的index的时候,使用位运算代替了取模运算。之所以可以做等价代替,前提是要求HashMap的容量一定要是2^n 。

那么,既然是2^n ,为啥一定要是16呢?为什么不能是4、8或者32呢?

关于这个默认容量的选择,JDK并没有给出官方解释,笔者也没有在网上找到关于这个任何有价值的资料。(如果哪位有相关的权威资料或者想法,可以留言交流)

根据作者的推断,这应该就是个经验值(Experience Value),既然一定要设置一个默认的2^n 作为初始值,那么就需要在效率和内存使用上做一个权衡。这个值既不能太小,也不能太大。

太小了就有可能频繁发生扩容,影响效率。太大了又浪费空间,不划算。

所以,16就作为一个经验值被采用了。

在JDK 8中,关于默认容量的定义为:static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16 ,其故意把16写成1<<4,就是提醒开发者,这个地方要是2的幂。值得玩味的是:注释中的 aka 16也是1.8中新增的,

那么,接下来我们再来谈谈,HashMap是如何保证其容量一定可以是2^n 的呢?如果用户自己设置了的话又会怎么样呢?

关于这部分,HashMap在两个可能改变其容量的地方都做了兼容处理,分别是指定容量初始化时以及扩容时。

指定容量初始化
当我们通过HashMap(int initialCapacity)设置初始容量的时候,HashMap并不一定会直接采用我们传入的数值,而是经过计算,得到一个新值,目的是提高hash的效率。(1->1、3->4、7->8、9->16)

在JDK 1.7和JDK 1.8中,HashMap初始化这个容量的时机不同。JDK 1.8中,在调用HashMap的构造函数定义HashMap的时候,就会进行容量的设定。而在JDK 1.7中,要等到第一次put操作时才进行这一操作。

看一下JDK是如何找到比传入的指定值大的第一个2的幂的:

int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;

上面的算法目的挺简单,就是:根据用户传入的容量值(代码中的cap),通过计算,得到第一个比他大的2的幂并返回。

请关注上面的几个例子中,蓝色字体部分的变化情况,或许你会发现些规律。5->8、9->16、19->32、37->64都是主要经过了两个阶段。

Step 1,5->7
Step 2,7->8
Step 1,9->15
Step 2,15->16
Step 1,19->31
Step 2,31->32

对应到以上代码中,Step1:

n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;

对应到以上代码中,Step2:

return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;

Step 2 比较简单,就是做一下极限值的判断,然后把Step 1得到的数值+1。

Step 1 怎么理解呢?其实是对一个二进制数依次向右移位,然后与原值取或。其目的对于一个数字的二进制,从第一个不为0的位开始,把后面的所有位都设置成1。

随便拿一个二进制数,套一遍上面的公式就发现其目的了:

1100 1100 1100 >>>1 = 0110 0110 0110
1100 1100 1100 | 0110 0110 0110 = 1110 1110 1110
1110 1110 1110 >>>2 = 0011 1011 1011
1110 1110 1110 | 0011 1011 1011 = 1111 1111 1111
1111 1111 1111 >>>4 = 1111 1111 1111
1111 1111 1111 | 1111 1111 1111 = 1111 1111 1111

通过几次无符号右移和按位或运算,我们把1100 1100 1100转换成了1111 1111 1111 ,再把1111 1111 1111加1,就得到了1 0000 0000 0000,这就是大于1100 1100 1100的第一个2的幂。

好了,我们现在解释清楚了Step 1和Step 2的代码。就是可以把一个数转化成第一个比他自身大的2的幂。

但是还有一种特殊情况套用以上公式不行,这些数字就是2的幂自身。如果数字4套用公式的话。得到的会是 8。
总之,HashMap根据用户传入的初始化容量,利用无符号右移和按位或运算等方式计算出第一个大于该数的2的幂。

扩容

除了初始化的时候回指定HashMap的容量,在进行扩容的时候,其容量也可能会改变。

HashMap有扩容机制,就是当达到扩容条件时会进行扩容。HashMap的扩容条件就是当HashMap中的元素个数(size)超过临界值(threshold)时就会自动扩容。

在HashMap中,threshold = loadFactor * capacity。

loadFactor是装载因子,表示HashMap满的程度,默认值为0.75f,设置成0.75有一个好处,那就是0.75正好是3/4,而capacity又是2的幂。所以,两个数的乘积都是整数。

对于一个默认的HashMap来说,默认情况下,当其size大于12(16*0.75)时就会触发扩容。

下面是HashMap中的扩容方法(resize)中的一段:

if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&oldCap >= DEFAULT_INITIAL_CAPACITY)newThr = oldThr << 1; // double threshold
}

从上面代码可以看出,扩容后的table大小变为原来的两倍,这一步执行之后,就会进行扩容后table的调整,这部分非本文重点,省略。

可见,当HashMap中的元素个数(size)超过临界值(threshold)时就会自动扩容,扩容成原容量的2倍,即从16扩容到32、64、128 …

所以,通过保证初始化容量均为2的幂,并且扩容时也是扩容到之前容量的2倍,所以,保证了HashMap的容量永远都是2的幂。

总结

HashMap作为一种数据结构,元素在put的过程中需要进行hash运算,目的是计算出该元素存放在hashMap中的具体位置。

hash运算的过程其实就是对目标元素的Key进行hashcode,再对Map的容量进行取模,而JDK 的工程师为了提升取模的效率,使用位运算代替了取模运算,这就要求Map的容量一定得是2的幂。

而作为默认容量,太大和太小都不合适,所以16就作为一个比较合适的经验值被采用了。

为了保证任何情况下Map的容量都是2的幂,HashMap在两个地方都做了限制。

首先是,如果用户制定了初始容量,那么HashMap会计算出比该数大的第一个2的幂作为初始容量。

另外,在扩容的时候,也是进行成倍的扩容,即4变成8,8变成16。

本文,通过分析为什么HashMap的默认容量是16,我们深入HashMap的原理,分析了下背后的原理,从代码中我们可以发现,JDK 的工程师把各种位运算运用到了极致,想尽各种办法优化效率。值得我们学习!

为什么HashMap的默认负载因子设置成0.75

在Java基础中,集合类是很关键的一块知识点,也是日常开发的时候经常会用到的。比如List、Map这些在代码中也是很常见的。

个人认为,关于HashMap的实现,JDK的工程师其实是做了很多优化的,要说所有的JDK源码中,哪个类埋的彩蛋最多,那我想HashMap至少可以排前五。

也正是因为如此,很多细节都容易被忽视,今天我们就来关注其中一个问题,那就是:

为什么HashMap的负载因子设置成0.75,而不是1也不是0.5?这背后到底有什么考虑?

大家千万不要小看这个问题,因为负载因子是HashMap中很重要的一个概念,也是高端面试的一个常考点。

什么是loadFactory

首先我们来介绍下什么是负载因子(loadFactory),如果读者对这部分已经有了解,那么可以直接跨过这一段。

我们知道,第一次创建HashMap的时候,就会指定其容量(如果未显示指定,默认是16),那随着我们不断的向HashMap中put元素的时候,就有可能会超过其容量,那么就需要有一个扩容机制。

所谓扩容,就是扩大HashMap的容量:

void addEntry(int hash, K key, V value, int bucketIndex) {if ((size >= threshold) && (null != table[bucketIndex])) {resize(2 * table.length);hash = (null != key) ? hash(key) : 0;bucketIndex = indexFor(hash, table.length);        }createEntry(hash, key, value, bucketIndex);
}

从代码中我们可以看到,在向HashMap中添加元素过程中,如果 元素个数(size)超过临界值(threshold) 的时候,就会进行自动扩容(resize),并且,在扩容之后,还需要对HashMap中原有元素进行rehash,即将原来通中的元素重新分配到新的桶中。

在HashMap中,临界值(threshold) = 负载因子(loadFactor) * 容量(capacity)。

loadFactor是装载因子,表示HashMap满的程度,默认值为0.75f,也就是说默认情况下,当HashMap中元素个数达到了容量的3/4的时候就会进行自动扩容。

为什么要扩容

还记得前面我们说过,HashMap在扩容到过程中不仅要对其容量进行扩充,还需要进行rehash!所以,这个过程其实是很耗时的,并且Map中元素越多越耗时。

rehash的过程相当于对其中所有的元素重新做一遍hash,重新计算要分配到哪个桶中。

那么,有没有人想过一个问题,既然这么麻烦,为啥要扩容?HashMap不是一个数组链表吗?不扩容的话,也是可以无限存储的呀。为啥要扩容?

这其实和哈希碰撞有关。

哈希碰撞

我们知道,HashMap其实是底层基于哈希函数实现的,但是哈希函数都有如下一个基本特性:根据同一哈希函数计算出的散列值如果不同,那么输入值肯定也不同。但是,根据同一散列函数计算出的散列值如果相同,输入值不一定相同。

两个不同的输入值,根据同一散列函数计算出的散列值相同的现象叫做碰撞。

衡量一个哈希函数的好坏的重要指标就是发生碰撞的概率以及发生碰撞的解决方案。

而为了解决哈希碰撞,有很多办法,其中比较常见的就是链地址法,这也是HashMap采用的方法。详见全网把Map中的hash()分析的最透彻的文章,别无二家。

HashMap将数组和链表组合在一起,发挥了两者的优势,我们可以将其理解为链表的数组。

HashMap基于链表的数组的数据结构实现的

我们在向HashMap中put元素的时候,就需要先定位到是数组中的哪条链表,然后把这个元素挂在这个链表的后面。

当我们从HashMap中get元素的时候,也是需要定位到是数组中的哪条链表,然后再逐一遍历链表中的元素,直到查找到需要的元素为止。

可见,HashMap通过链表的数组这种结构,解决了hash冲突的问题。

但是,如果一个HashMap中冲突太高,那么数组的链表就会退化为链表。这时候查询速度会大大降低。

所以,为了保证HashMap的读取的速度,我们需要想办法尽量保证HashMap的冲突不要太高。

扩容避免哈希碰撞

那么如何能有效的避免哈希碰撞呢?

我们先反向思维一下,你认为什么情况会导致HashMap的哈希碰撞比较多?

无外乎两种情况:

1、容量太小。容量小,碰撞的概率就高了。狼多肉少,就会发生争抢。

2、hash算法不够好。算法不合理,就可能都分到同一个或几个桶中。分配不均,也会发生争抢。

所以,解决HashMap中的哈希碰撞也是从这两方面入手。

这两点在HashMap中都有很好的体现。两种方法相结合,在合适的时候扩大数组容量,再通过一个合适的hash算法计算元素分配到哪个数组中,就可以大大的减少冲突的概率。就能避免查询效率低下的问题。

为什么默认loadFactory是0.75

至此,我们知道了loadFactory是HashMap中的一个重要概念,他表示这个HashMap最大的满的程度。

为了避免哈希碰撞,HashMap需要在合适的时候进行扩容。那就是当其中的元素个数达到临界值的时候,而这个临界值前面说过和loadFactory有关,换句话说,设置一个合理的loadFactory,可以有效的避免​哈希冲突。

那么,到底loadFactory设置成多少算合适呢?

这个值现在在JDK的源码中是0.75:

/*** The load factor used when none specified in constructor.*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;

那么,为什么选择0.75呢?背后有什么考虑?为什么不是1,不是0.8?不是0.5,而是0.75呢?

在JDK的官方文档中,有这样一段描述描述:

As a general rule, the default load factor (.75) offers a good tradeoff between time and space costs. Higher values decrease the space overhead but increase the lookup cost (reflected in most of the operations of the HashMap class, including get and put).

大概意思是:一般来说,默认的负载因子(0.75)在时间和空间成本之间提供了很好的权衡。更高的值减少了空间开销,但增加了查找成本(反映在HashMap类的大多数操作中,包括get和put)。

试想一下,如果我们把负载因子设置成1,容量使用默认初始值16,那么表示一个HashMap需要在"满了"之后才会进行扩容。

那么在HashMap中,最好的情况是这16个元素通过hash算法之后分别落到了16个不同的桶中,否则就必然发生哈希碰撞。而且随着元素越多,哈希碰撞的概率越大,查找速度也会越低。

0.75的数学依据
另外,我们可以通过一种数学思维来计算下这个值是多少合适。​

我们假设一个bucket空和非空的概率为0.5,我们用s表示容量,n表示已添加元素个数。

用s表示添加的键的大小和n个键的数目。根据二项式定理,桶为空的概率为:

P(0) = C(n, 0) * (1/s)^0 * (1 - 1/s)^(n - 0)

因此,如果桶中元素个数小于以下数值,则桶可能是空的:

log(2)/log(s/(s - 1))

当s趋于无穷大时,如果增加的键的数量使P(0) = 0.5,那么n/s很快趋近于log(2):

log(2) ~ 0.693...

所以,合理值大概在0.7左右。

当然,这个数学计算方法,并不是在Java的官方文档中体现的,我们也无从考察到底有没有这层考虑,就像我们根本不知道鲁迅写文章时候怎么想的一样,只能推测。

0.75的必然因素

理论上我们认为负载因子不能太大,不然会导致大量的哈希冲突,也不能太小,那样会浪费空间。

通过一个数学推理,测算出这个数值在0.7左右是比较合理的。

那么,为什么最终选定了0.75呢?

还记得前面我们提到过一个公式吗,就是临界值(threshold) = 负载因子(loadFactor) * 容量(capacity)。

根据HashMap的扩容机制,他会保证capacity的值永远都是2的幂。

那么,为了保证负载因子(loadFactor) * 容量(capacity)的结果是一个整数,这个值是0.75(3/4)比较合理,因为这个数和任何2的幂乘积结果都是整数。

总结

HashMap是一种K-V结构,为了提升其查询及插入的速度,底层采用了链表的数组这种数据结构实现的。

但是因为在计算元素所在的位置的时候,需要使用hash算法,而HashMap采用的hash算法就是链地址法。这种方法有两个极端。

如果HashMap中哈希冲突概率高,那么HashMap就会退化成链表(不是真的退化,而是操作上像是直接操作链表),而我们知道,链表最大的缺点就是查询速度比较慢,他需要从表头开始逐一遍历。

所以,为了避免HashMap发生大量的哈希冲突,所以需要在适当的时候对其进行扩容。

而扩容的条件是元素个数达到临界值时。HashMap中临界值的计算方法:

临界值(threshold) = 负载因子(loadFactor) * 容量(capacity)

其中负载因子表示一个数组可以达到的最大的满的程度。这个值不宜太大,也不宜太小。

loadFactory太大,比如等于1,那么就会有很高的哈希冲突的概率,会大大降低查询速度。

loadFactory太小,比如等于0.5,那么频繁扩容,就会大大浪费空间。

所以,这个值需要介于0.5和1之间。根据数学公式推算。这个值在log(2)的时候比较合理。

另外,为了提升扩容效率,HashMap的容量(capacity)有一个固定的要求,那就是一定是2的幂。

所以,如果loadFactor是3/4的话,那么和capacity的乘积结果就可以是一个整数。

所以,一般情况下,我们不建议修改loadFactory的值,除非特殊原因。

比如我明确的知道我的Map只存5个kv,并且永远不会改变,那么可以考虑指定loadFactory。

但是其实我也不建议这样用。我们完全可以通过指定capacity达到这样的目的。

为什么建议设置HashMap的初始容量,设置多少合适

集合是Java开发日常开发中经常会使用到的,而作为一种典型的K-V结构的数据结构,HashMap对于Java开发者一定不陌生。

关于HashMap,很多人都对他有一些基本的了解,比如他和hashtable之间的区别、他和concurrentHashMap之间的区别等。这些都是比较常见的,关于HashMap的一些知识点和面试题,想来大家一定了熟于心了,并且在开发中也能有效的应用上。

但是,作者在很多次 CodeReview 以及面试中发现,有一个比较关键的小细节经常被忽视,那就是HashMap创建的时候,要不要指定容量?如果要指定的话,多少是合适的?为什么?

要设置HashMap的初始化容量

HashMap有扩容机制,就是当达到扩容条件时会进行扩容。HashMap的扩容条件就是当HashMap中的元素个数(size)超过临界值(threshold)时就会自动扩容。在HashMap中,threshold = loadFactor * capacity。

所以,如果我们没有设置初始容量大小,随着元素的不断增加,HashMap会发生多次扩容,而HashMap中的扩容机制决定了每次扩容都需要重建hash表,是非常影响性能的。

所以,首先可以明确的是,我们建议开发者在创建HashMap的时候指定初始化容量。并且《阿里巴巴开发手册》中也是这么建议的:

HashMap初始化容量设置多少合适
那么,既然建议我们集合初始化的时候,要指定初始值大小,那么我们创建HashMap的时候,到底指定多少合适呢?

有些人会自然想到,我准备塞多少个元素我就设置成多少呗。比如我准备塞7个元素,那就new HashMap(7)。

但是,这么做不仅不对,而且以上方式创建出来的Map的容量也不是7。

因为,当我们使用HashMap(int initialCapacity)来初始化容量的时候,HashMap并不会使用我们传进来的initialCapacity直接作为初始容量。

JDK会默认帮我们计算一个相对合理的值当做初始容量。所谓合理值,其实是找到第一个比用户传入的值大的2的幂。

也就是说,当我们new HashMap(7)创建HashMap的时候,JDK会通过计算,帮我们创建一个容量为8的Map;当我们new HashMap(9)创建HashMap的时候,JDK会通过计算,帮我们创建一个容量为16的Map。

但是,这个值看似合理,实际上并不尽然。因为HashMap在根据用户传入的capacity计算得到的默认容量,并没有考虑到loadFactor这个因素,只是简单机械的计算出第一个大约这个数字的2的幂。

loadFactor是负载因子,当HashMap中的元素个数(size)超过 threshold = loadFactor * capacity时,就会进行扩容。

也就是说,如果我们设置的默认值是7,经过JDK处理之后,HashMap的容量会被设置成8,但是,这个HashMap在元素个数达到 8*0.75 = 6的时候就会进行一次扩容,这明显是我们不希望见到的。

那么,到底设置成什么值比较合理呢?

这里我们可以参考JDK8中putAll方法中的实现的,这个实现在guava(21.0版本)也被采用。

这个值的计算方法就是:

return (int) ((float) expectedSize / 0.75F + 1.0F);

比如我们计划向HashMap中放入7个元素的时候,我们通过expectedSize / 0.75F + 1.0F计算,7/0.75 + 1 = 10 ,10经过JDK处理之后,会被设置成16,这就大大的减少了扩容的几率。

当HashMap内部维护的哈希表的容量达到75%时(默认情况下),会触发rehash,而rehash的过程是比较耗费时间的。所以初始化容量要设置成expectedSize/0.75 + 1的话,可以有效的减少冲突也可以减小误差。(大家结合这个公式,好好理解下这句话)

所以,我们可以认为,当我们明确知道HashMap中元素的个数的时候,把默认容量设置成expectedSize / 0.75F + 1.0F 是一个在性能上相对好的选择,但是,同时也会牺牲些内存。

这个算法在guava中有实现,开发的时候,可以直接通过Maps类创建一个HashMap:

Map<String, String> map = Maps.newHashMapWithExpectedSize(7);

其代码实现如下:

public static <K, V> HashMap<K, V> newHashMapWithExpectedSize(int expectedSize) {return new HashMap(capacity(expectedSize));
}static int capacity(int expectedSize) {if (expectedSize < 3) {CollectPreconditions.checkNonnegative(expectedSize, "expectedSize");return expectedSize + 1;} else {return expectedSize < 1073741824 ? (int)((float)expectedSize / 0.75F + 1.0F) : 2147483647;}
}

但是,以上的操作是一种用内存换性能的做法,真正使用的时候,要考虑到内存的影响。但是,大多数情况下,我们还是认为内存是一种比较富裕的资源。

但是话又说回来了,有些时候,我们到底要不要设置HashMap的初识值,这个值又设置成多少,真的有那么大影响吗?其实也不见得!

可是,大的性能优化,不就是一个一个的优化细节堆叠出来的吗?

再不济,以后你写代码的时候,使用Maps.newHashMapWithExpectedSize(7);的写法,也可以让同事和老板眼前一亮。

或者哪一天你碰到一个面试官问你一些细节的时候,你也能有个印象,或者某一天你也可以拿这个出去面试问其他人~!啊哈哈哈。

Java 8中stream相关用法

在Java中,集合和数组是我们经常会用到的数据结构,需要经常对他们做增、删、改、查、聚合、统计、过滤等操作。相比之下,关系型数据库中也同样有这些操作,但是在Java 8之前,集合和数组的处理并不是很便捷。

不过,这一问题在Java 8中得到了改善,Java 8 API添加了一个新的抽象称为流Stream,可以让你以一种声明的方式处理数据。本文就来介绍下如何使用Stream。特别说明一下,关于Stream的性能及原理不是本文的重点,如果大家感兴趣后面会出文章单独介绍。

Stream介绍

Stream 使用一种类似用 SQL 语句从数据库查询数据的直观方式来提供一种对 Java 集合运算和表达的高阶抽象。

Stream API可以极大提高Java程序员的生产力,让程序员写出高效率、干净、简洁的代码。

这种风格将要处理的元素集合看作一种流,流在管道中传输,并且可以在管道的节点上进行处理,比如筛选,排序,聚合等。

Stream有以下特性及优点:

  • 无存储。Stream不是一种数据结构,它只是某种数据源的一个视图,数据源可以是一个数组,Java容器或I/O channel等。
  • 为函数式编程而生。对Stream的任何修改都不会修改背后的数据源,比如对Stream执行过滤操作并不会删除被过滤的元素,而是会产生一个不包含被过滤元素的新Stream。
  • 惰式执行。Stream上的操作并不会立即执行,只有等到用户真正需要结果的时候才会执行。
  • 可消费性。Stream只能被“消费”一次,一旦遍历过就会失效,就像容器的迭代器那样,想要再次遍历必须重新生成。
    我们举一个例子,来看一下到底Stream可以做什么事情:

    上面的例子中,获取一些带颜色塑料球作为数据源,首先过滤掉红色的、把它们融化成随机的三角形。再过滤器并删除小的三角形。最后计算出剩余图形的周长。

如上图,对于流的处理,主要有三种关键性操作:分别是流的创建、中间操作(intermediate operation)以及最终操作(terminal operation)。

Stream的创建

在Java 8中,可以有多种方法来创建流。

1、通过已有的集合来创建流

在Java 8中,除了增加了很多Stream相关的类以外,还对集合类自身做了增强,在其中增加了stream方法,可以将一个集合类转换成流。

List<String> strings = Arrays.asList("Hollis", "HollisChuang", "hollis", "Hello", "HelloWorld", "Hollis");
Stream<String> stream = strings.stream();

以上,通过一个已有的List创建一个流。除此以外,还有一个parallelStream方法,可以为集合创建一个并行流。

这种通过集合创建出一个Stream的方式也是比较常用的一种方式。

2、通过Stream创建流

可以使用Stream类提供的方法,直接返回一个由指定元素组成的流。

Stream<String> stream = Stream.of("Hollis", "HollisChuang", "hollis", "Hello", "HelloWorld", "Hollis");

如以上代码,直接通过of方法,创建并返回一个Stream。

Stream中间操作

Stream有很多中间操作,多个中间操作可以连接起来形成一个流水线,每一个中间操作就像流水线上的一个工人,每人工人都可以对流进行加工,加工后得到的结果还是一个流。

以下是常用的中间操作列表:

filter

filter 方法用于通过设置的条件过滤出元素。以下代码片段使用 filter 方法过滤掉空字符串:

List<String> strings = Arrays.asList("Hollis", "", "HollisChuang", "H", "hollis");
strings.stream().filter(string -> !string.isEmpty()).forEach(System.out::println);
//Hollis, HollisChuang, H, hollis

map

map 方法用于映射每个元素到对应的结果,以下代码片段使用 map 输出了元素对应的平方数:

List<Integer> numbers = Arrays.asList(3, 2, 2, 3, 7, 3, 5);
numbers.stream().map(i -> i*i).forEach(System.out::println);
//9,4,4,9,49,9,25

limit/skip

limit 返回 Stream 的前面 n 个元素;skip 则是扔掉前 n 个元素。以下代码片段使用 limit 方法保留4个元素:

List<Integer> numbers = Arrays.asList(3, 2, 2, 3, 7, 3, 5);
numbers.stream().limit(4).forEach(System.out::println);
//3,2,2,3

sorted

sorted 方法用于对流进行排序。以下代码片段使用 sorted 方法进行排序:

List<Integer> numbers = Arrays.asList(3, 2, 2, 3, 7, 3, 5);
numbers.stream().sorted().forEach(System.out::println);
//2,2,3,3,3,5,7

distinct

distinct主要用来去重,以下代码片段使用 distinct 对元素进行去重:

List<Integer> numbers = Arrays.asList(3, 2, 2, 3, 7, 3, 5);
numbers.stream().distinct().forEach(System.out::println);
//3,2,7,5

接下来我们通过一个例子和一张图,来演示下,当一个Stream先后通过filter、map、sort、limit以及distinct处理后会发生什么。

代码如下:

List<String> strings = Arrays.asList("Hollis", "HollisChuang", "hollis", "Hello", "HelloWorld", "Hollis");
Stream s = strings.stream().filter(string -> string.length()<= 6).map(String::length).sorted().limit(3).distinct();

过程及每一步得到的结果如下图:

Stream最终操作

Stream的中间操作得到的结果还是一个Stream,那么如何把一个Stream转换成我们需要的类型呢?比如计算出流中元素的个数、将流转换成集合等。这就需要最终操作(terminal operation)

最终操作会消耗流,产生一个最终结果。也就是说,在最终操作之后,不能再次使用流,也不能再使用任何中间操作,否则将抛出异常:

java.lang.IllegalStateException: stream has already been operated upon or closed

俗话说,“你永远不会两次踏入同一条河”也正是这个意思。

常用的最终操作如下图:

forEach

Stream 提供了方法 ‘forEach’ 来迭代流中的每个数据。以下代码片段使用 forEach 输出了10个随机数:

Random random = new Random();
random.ints().limit(10).forEach(System.out::println);

count

count用来统计流中的元素个数。

List<String> strings = Arrays.asList("Hollis", "HollisChuang", "hollis", "Hollis666", "Hello", "HelloWorld", "Hollis");
System.out.println(strings.stream().count());
//7

collect

collect就是一个归约操作,可以接受各种做法作为参数,将流中的元素累积成一个汇总结果:

List<String> strings = Arrays.asList("Hollis", "HollisChuang", "hollis","Hollis666", "Hello", "HelloWorld", "Hollis");
strings  = strings.stream().filter(string -> string.startsWith("Hollis")).collect(Collectors.toList());
System.out.println(strings);
//Hollis, HollisChuang, Hollis666, Hollis

接下来,我们还是使用一张图,来演示下,前文的例子中,当一个Stream先后通过filter、map、sort、limit以及distinct处理后,在分别使用不同的最终操作可以得到怎样的结果:

下图,展示了文中介绍的所有操作的位置、输入、输出以及使用一个案例展示了其结果。

总结
本文介绍了Java 8中的Stream 的用途,优点等。还介绍了Stream的几种用法,分别是Stream创建、中间操作和最终操作。

Stream的创建有两种方式,分别是通过集合类的stream方法、通过Stream的of方法。

Stream的中间操作可以用来处理Stream,中间操作的输入和输出都是Stream,中间操作可以是过滤、转换、排序等。

Stream的最终操作可以将Stream转成其他形式,如计算出流中元素的个数、将流转换成集合、以及元素的遍历等。

Apache集合处理工具类的使用

Commons Collections增强了Java Collections Framework。 它提供了几个功能,使收集处理变得容易。 它提供了许多新的接口,实现和实用程序。 Commons Collections的主要功能如下

  • Bag - Bag界面简化了每个对象具有多个副本的集合。
  • BidiMap - BidiMap接口提供双向映射,可用于使用值使用键或键查找值。
  • MapIterator - MapIterator接口提供简单而容易的迭代迭代。
  • Transforming Decorators - 转换装饰器可以在将集合添加到集合时更改集合的每个对象。
  • Composite Collections - 在需要统一处理多个集合的情况下使用复合集合。
  • Ordered Map - 有序地图保留添加元素的顺序。
  • Ordered Set - 有序集保留了添加元素的顺序。
  • Reference map - 参考图允许在密切控制下对键/值进行垃圾收集。
  • Comparator implementations - 可以使用许多Comparator实现。
  • Iterator implementations - 许多Iterator实现都可用。
  • Adapter Classes - 适配器类可用于将数组和枚举转换为集合。
  • Utilities - 实用程序可用于测试测试或创建集合的典型集合论属性,例如union,intersection。 支持关闭。

Commons Collections - Bag

Bag定义了一个集合,用于计算对象在集合中出现的次数。 例如,如果Bag包含{a,a,b,c},则getCount(“a”)将返回2,而uniqueSet()将返回唯一值。


import org.apache.commons.collections4.Bag;
import org.apache.commons.collections4.bag.HashBag;
public class BagTester {public static void main(String[] args) {Bag<String> bag = new HashBag<>();//add "a" two times to the bag.bag.add("a" , 2);//add "b" one time to the bag.bag.add("b");//add "c" one time to the bag.bag.add("c");//add "d" three times to the bag.bag.add("d",3);//get the count of "d" present in bag.System.out.println("d is present " + bag.getCount("d") + " times.");System.out.println("bag: " +bag);//get the set of unique values from the bagSystem.out.println("Unique Set: " +bag.uniqueSet());//remove 2 occurrences of "d" from the bagbag.remove("d",2);System.out.println("2 occurences of d removed from bag: " +bag);System.out.println("d is present " + bag.getCount("d") + " times.");System.out.println("bag: " +bag);System.out.println("Unique Set: " +bag.uniqueSet());}
}

它将打印以下结果:


d is present 3 times.
bag: [2:a,1:b,1:c,3:d]
Unique Set: [a, b, c, d]
2 occurences of d removed from bag: [2:a,1:b,1:c,1:d]
d is present 1 times.
bag: [2:a,1:b,1:c,1:d]
Unique Set: [a, b, c, d]

Commons Collections - BidiMap

使用双向映射,可以使用值查找键,并且可以使用键轻松查找值。


import org.apache.commons.collections4.BidiMap;
import org.apache.commons.collections4.bidimap.TreeBidiMap;
public class BidiMapTester {public static void main(String[] args) {BidiMap<String, String> bidi = new TreeBidiMap<>();bidi.put("One", "1");bidi.put("Two", "2");bidi.put("Three", "3");System.out.println(bidi.get("One")); System.out.println(bidi.getKey("1"));System.out.println("Original Map: " + bidi);bidi.removeValue("1"); System.out.println("Modified Map: " + bidi);BidiMap<String, String> inversedMap = bidi.inverseBidiMap();  System.out.println("Inversed Map: " + inversedMap);}
}

它将打印以下结果。

1
One
Original Map: {One=1, Three=3, Two=2}
Modified Map: {Three=3, Two=2}
Inversed Map: {2=Two, 3=Three}

Commons Collections - MapIterator

JDK Map接口很难迭代,因为迭代要在EntrySet或KeySet对象上完成。 MapIterator提供了对Map的简单迭代。


import org.apache.commons.collections4.IterableMap;
import org.apache.commons.collections4.MapIterator;
import org.apache.commons.collections4.map.HashedMap;
public class MapIteratorTester {public static void main(String[] args) {IterableMap<String, String> map = new HashedMap<>();map.put("1", "One");map.put("2", "Two");map.put("3", "Three");map.put("4", "Four");map.put("5", "Five");MapIterator<String, String> iterator = map.mapIterator();while (iterator.hasNext()) {Object key = iterator.next();Object value = iterator.getValue();System.out.println("key: " + key);System.out.println("Value: " + value);iterator.setValue(value + "_");}System.out.println(map);}
}

它将打印以下结果。

key: 3
Value: Three
key: 5
Value: Five
key: 2
Value: Two
key: 4
Value: Four
key: 1
Value: One
{3=Three_, 5=Five_, 2=Two_, 4=Four_, 1=One_}

Commons Collections - OrderedMap

OrderedMap是地图的新接口,用于保留添加元素的顺序。 LinkedMap和ListOrderedMap是两个可用的实现。 此接口支持Map的迭代器,并允许在Map中向前或向后迭代两个方向。

import org.apache.commons.collections4.OrderedMap;
import org.apache.commons.collections4.map.LinkedMap;
public class OrderedMapTester {public static void main(String[] args) {OrderedMap<String, String> map = new LinkedMap<String, String>();map.put("One", "1");map.put("Two", "2");map.put("Three", "3");System.out.println(map.firstKey());System.out.println(map.nextKey("One"));System.out.println(map.nextKey("Two"));  }
}

它将打印以下结果。

One
Two
Three

Commons Collections - Ignore NULL

Apache Commons Collections库的CollectionUtils类为常见操作提供了各种实用方法,涵盖了广泛的用例。 它有助于避免编写样板代码。 这个库在jdk 8之前非常有用,因为Java 8的Stream API现在提供了类似的功能。

import java.util.LinkedList;
import java.util.List;
import org.apache.commons.collections4.CollectionUtils;
public class CollectionUtilsTester {public static void main(String[] args) {List<String> list = new LinkedList<String>();CollectionUtils.addIgnoreNull(list, null);CollectionUtils.addIgnoreNull(list, "a");System.out.println(list);if(list.contains(null)) {System.out.println("Null value is present");} else {System.out.println("Null value is not present");}}
}

它将打印以下结果。

[a]
Null value is not present

Merge & Sort

Apache Commons Collections库的CollectionUtils类为常见操作提供了各种实用方法,涵盖了广泛的用例。 它有助于避免编写样板代码。 这个库在jdk 8之前非常有用,因为Java 8的Stream API现在提供了类似的功能。


import java.util.Arrays;
import java.util.List;
import org.apache.commons.collections4.CollectionUtils;
public class CollectionUtilsTester {public static void main(String[] args) {List<String> sortedList1 = Arrays.asList("A","C","E");List<String> sortedList2 = Arrays.asList("B","D","F");List<String> mergedList = CollectionUtils.collate(sortedList1, sortedList2);System.out.println(mergedList); }
}

它将打印以下结果。

[A, B, C, D, E, F]

安全空检查(Safe Empty Checks)

Apache Commons Collections库的CollectionUtils类为常见操作提供了各种实用方法,涵盖了广泛的用例。 它有助于避免编写样板代码。 这个库在jdk 8之前非常有用,因为Java 8的Stream API现在提供了类似的功能。

import java.util.List;
import org.apache.commons.collections4.CollectionUtils;
public class CollectionUtilsTester {public static void main(String[] args) {List<String> list = getList();System.out.println("Non-Empty List Check: " + checkNotEmpty1(list));System.out.println("Non-Empty List Check: " + checkNotEmpty1(list));}static List<String> getList() {return null;} static boolean checkNotEmpty1(List<String> list) {return !(list == null || list.isEmpty());}static boolean checkNotEmpty2(List<String> list) {return CollectionUtils.isNotEmpty(list);}
}

它将打印以下结果。

Non-Empty List Check: false
Non-Empty List Check: false

Commons Collections - Inclusion

检查列表是否是另一个列表的一部分

import java.util.Arrays;
import java.util.List;
import org.apache.commons.collections4.CollectionUtils;
public class CollectionUtilsTester {public static void main(String[] args) {//checking inclusionList<String> list1 = Arrays.asList("A","A","A","C","B","B");List<String> list2 = Arrays.asList("A","A","B","B");System.out.println("List 1: " + list1);System.out.println("List 2: " + list2);System.out.println("Is List 2 contained in List 1: " + CollectionUtils.isSubCollection(list2, list1));}
}

它将打印以下结果。

List 1: [A, A, A, C, B, B]
List 2: [A, A, B, B]
Is List 2 contained in List 1: true

Commons Collections - Intersection

用于获取两个集合(交集)之间的公共对象

import java.util.Arrays;
import java.util.List;
import org.apache.commons.collections4.CollectionUtils;
public class CollectionUtilsTester {public static void main(String[] args) {//checking inclusionList<String> list1 = Arrays.asList("A","A","A","C","B","B");List<String> list2 = Arrays.asList("A","A","B","B");System.out.println("List 1: " + list1);System.out.println("List 2: " + list2);System.out.println("Commons Objects of List 1 and List 2: " + CollectionUtils.intersection(list1, list2));}
}

它将打印以下结果。

List 1: [A, A, A, C, B, B]
List 2: [A, A, B, B]
Commons Objects of List 1 and List 2: [A, A, B, B]

Commons Collections - Subtraction

通过从其他集合中减去一个集合的对象来获取新集合

import java.util.Arrays;
import java.util.List;
import org.apache.commons.collections4.CollectionUtils;
public class CollectionUtilsTester {public static void main(String[] args) {//checking inclusionList<String> list1 = Arrays.asList("A","A","A","C","B","B");List<String> list2 = Arrays.asList("A","A","B","B");System.out.println("List 1: " + list1);System.out.println("List 2: " + list2);System.out.println("List 1 - List 2: " + CollectionUtils.subtract(list1, list2));}
}

它将打印以下结果。

List 1: [A, A, A, C, B, B]
List 2: [A, A, B, B]
List 1 - List 2: [A, C]

Commons Collections - Union

用于获取两个集合的并集

import java.util.Arrays;
import java.util.List;
import org.apache.commons.collections4.CollectionUtils;
public class CollectionUtilsTester {public static void main(String[] args) {//checking inclusionList<String> list1 = Arrays.asList("A","A","A","C","B","B");List<String> list2 = Arrays.asList("A","A","B","B");System.out.println("List 1: " + list1);System.out.println("List 2: " + list2);System.out.println("Union of List 1 and List 2: " + CollectionUtils.union(list1, list2));}
}

它将打印以下结果。

List 1: [A, A, A, C, B, B]
List 2: [A, A, B, B]
Union of List 1 and List 2: [A, A, A, B, B, C]

参考资料

如果你正在入门学习Java或者即将学习,可以申请加入我的纯Java学习交流裙735057581 ,有什么问题都可以随手来交流分享,群文件我上传了我做Java这几年整理的一些学习手册,开发工具,PDF文档书籍教程,需要的话你们都可以自己下载,欢迎大家来一起学习哦!

Java工程师成神之路java基础知识之集合类(二)相关推荐

  1. Java详细学习路线及路线图(2020最新版) | Java工程师成神之路 | Java最全学习路线

    这篇文章主要是关于小白Java学习路线, 整个学习路线非常的清晰明确,适合各种层次的Java自学者,非常全面的Java学习路线. 整理不易,记得帮忙点个赞哟~ 第一阶段:Java基础 学习任何一门编程 ...

  2. 《Java工程师成神之路》终于免费开放下载了!

    很多Java程序员一直希望找到一份完整的学习路径,但是市面上很多书都是专注某一个领域的,没有一份完整的大图,以至于很多程序员很迷茫,不知道自己到底应该从哪里开始学,或者不知道自己学习些什么. 好在,很 ...

  3. GitHub上12k Star的《Java工程师成神之路》中终于开放阅读了!

    很多Java程序员一直希望找到一份完整的学习路径,但是市面上很多书都是专注某一个领域的,没有一分完整的大图,以至于很多程序员很迷茫,不知道自己到底应该从哪里开始学,或者不知道自己学习些什么. 好在,很 ...

  4. Java工程师成神之路思维导图

    前面看Hollis的微信公众号更新了Java工程师成神之路的文档,感觉里面的内容清晰.齐全,可以用来审视自己,也能够知道自己在那些方面可以继续前行,想着有时间把它画下来,画下来之后分享出来. 主要内容 ...

  5. Java 工程师成神之路 | 2019正式版 1

    Java 工程师成神之路 | 2019正式版 基础篇 01 面向对象→ 什么是面向对象 面向对象.面向过程 面向对象的三大基本特征和五大基本原则 → 平台无关性 Java 如何实现的平台无关 JVM ...

  6. Java工程师成神之路 | 2022正式版

    基础篇 面向对象 什么是面向对象 面向对象与面向过程 面向对象的三大基本特征 面向对象的五大基本原则 封装.继承.多态 什么是多态 方法重写与重载 Java的继承与实现 Java的继承与组合 构造函数 ...

  7. java工程师知识架构图图_阿里技术专家教你画架构图、Java 工程师成神之路 | 2019 年 2 月收藏排行...

    五一假期,头条君会推送 2019 年 1-4 月 开发者头条 上收藏最多的文章 点击链接或图片 即可阅读 喜欢请 分享到朋友圈 哦 Java 工程师成神之路(2019 正式版) 结构调整,更适合从入门 ...

  8. Java小白怎么学?2021年最强版Java工程师成神之路

    以下文章来源于爱笑的架构师 最近很多读者在问:Java 怎么学习啊 ?有没有什么学习路线 ?Java " 成神 " 之路怎么走? 当然「成神」是有些夸张了,我相信问这句话的读者或多 ...

  9. 阿里P8资深架构师耗时一年整理Java工程师成神之路

    1.基础篇 01:面向对象 → 什么是面向对象 面向对象.面向过程 面向对象的三大基本特征和五大基本原则 → 平台无关性 Java 如何实现的平台无关 JVM 还支持哪些语言(Kotlin.Groov ...

最新文章

  1. shell 下的运算表达
  2. Spring Boot 应用系列 5 -- Spring Boot 2 整合logback
  3. 京东最新点击率预估模型论文学习和分享
  4. mesageflow 集成spider 开发思路 手稿
  5. linux的内核有多小,Linux 内核有小bug?
  6. bootstrap 富文本_入坑吗?说说几个富文本编辑器
  7. BugkuCTF-Reverse题easy-100(LCTF)
  8. java生成验证码SWT_Java SWT Lable框中显示图片验证码
  9. SpringBoot 精通系列-创建SpringBoot的入门项目
  10. TypeError: '' not supported between instances of 'float' and 'str'
  11. 安装Esxi6.5时出现 menu.c32:not a COM32R image 的处理方法
  12. 罗技键盘连计算机,罗技键盘怎么连接电脑(罗技键盘使用教程及性能评测)
  13. Win32汇编:数组与标志位测试总结
  14. gtx1660是什么级别的_显卡天梯图秒懂GTX1660Ti性能 GTX1660Ti相当于什么显卡
  15. NumPy 数组的维度变换
  16. 2012-2-15雨
  17. 怎么看手机android底层,安卓手机中fastboot是一种比recovery更底层的模式
  18. 微软发布InstaLoad电池技术 不考虑极性
  19. Linux公平队列FQ接口实现
  20. 【渝粤教育】 国家开放大学2020年春季 2585城市轨道交通概论 参考试题

热门文章

  1. android wifi 广播吗,Android WIFI开发之广播监听
  2. google map结合数据库加载地图
  3. input输入框禁止输入但可以选择
  4. 适合计算机科学系毕业季的话,毕业季 | 用你的专业,说最美的情话......
  5. 企业宣传片制作的常见误区
  6. 朋友说话很酸安卓html,情商高的人聊天示例_恋爱话术不要钱的
  7. 观看慕课老师milanlover视频用servlet获取初始化参数+MVC
  8. 《加密经济学:引爆区块链新时代》笔记
  9. vue音乐 APP 9:歌曲列表组件开发
  10. 机器人锤石下路组合_英雄联盟下路对线,锤石克制什么下路组合,怕什么下路组合,为什么?...