深刻分析——HashSet是否真的无序?(JDK8)

HashSet 是否无序

(一) 问题原由:

《Core Java Volume I—Fundamentals》中对HashSet的描述是这样的:java

HashSet:一种没有重复元素的无序集合算法

解释:咱们通常说HashSet是无序的,它既不能保证存储和取出顺序一致,更不能保证天然顺序(a-z)数组

下面是《Thinking in Java》中的使用Integer对象的HashSet的示例dom

import java.util.*;ide

public class SetOfInteger { public static void main(String[] args) { Random rand = new Random(47); Set intset = new HashSet(); for (int i = 0; i<10000; i++) intset.add(rand.nextInt(30)); System.out.println(intset); } } /* Output:函数

[15, 8, 23, 16, 7, 22, 9, 21, 6, 1 , 29 , 14, 24, 4, 19, 26, 11, 18, 3, 12, 27, 17, 2, 13, 28, 20, 25, 10, 5, 0]this

在0-29之间的10000个随机数被添加到了Set中,大量的数据是重复的,但输出结果却每个数只有一个实例出如今结果中,而且输出的结果没有任何规律可循。 这正与其不重复,且无序的特色相吻合。spa

看来两本书的结果,以及咱们以前所学的知识,看起来都是一致的,一切就是这么美好。code

随手运行了一下这段书中的代码,结果却让人大吃一惊cdn

//JDK1.8下 Idea中运行
import java.util.*;

public class SetOfInteger {
    public static void main(String[] args) {
        Random rand = new Random(47);
        Set<Integer> intset = new HashSet<Integer>();
        for (int i = 0; i<10000; i++)
            intset.add(rand.nextInt(30));
        System.out.println(intset);
    }
}

//运行结果
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29]
复制代码

嗯!不重复的特色依旧吻合,可是为何遍历输出结果倒是有序的???

写一个最简单的程序再验证一下:

import java.util.*;

public class HashSetDemo {
    public static void main(String[] args) {

        Set<Integer> hs = new HashSet<Integer>();

        hs.add(1);
        hs.add(2);
        hs.add(3);

        //加强for遍历
        for (Integer s : hs) {
            System.out.print(s + " ");
        }
    }
}

//运行结果
1 2 3 
复制代码

我还不死心,是否是元素数据不够多,有序这只是一种巧合的存在,增长元素数量试试

import java.util.*;

public class HashSetDemo {
    public static void main(String[] args) {
        
        Set<Integer> hs = new HashSet<Integer>();

        for (int i = 0; i < 10000; i++) {
            hs.add(i);
        }

        //加强for遍历
        for (Integer s : hs) {
            System.out.print(s + " ");
        }
    }
}

//运行结果
1 2 3 ... 9997 9998 9999 
复制代码

能够看到,遍历后输出依旧是有序的

(二) 过程

经过一步一步分析源码,咱们来看一看,这到底是怎么一回事,首先咱们先从程序的第一步——集合元素的存储开始看起,先看一看HashSet的add方法源码:

// HashSet 源码节选-JKD8
public boolean add(E e) {
    return map.put(e, PRESENT)==null;
}
复制代码

咱们能够看到,HashSet直接调用HashMap的put方法,而且将元素e放到map的key位置(保证了惟一性 )

顺着线索继续查看HashMap的put方法源码:

//HashMap 源码节选-JDK8
public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}
复制代码

而咱们的值在返回前须要通过HashMap中的hash方法

接着定位到hash方法的源码:

//HashMap 源码节选-JDK8
static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
复制代码

hash方法的返回结果中是一句三目运算符,键 (key) 为null即返回 0,存在则返回后一句的内容

(h = key.hashCode()) ^ (h >>> 16)
复制代码

JDK8中 HashMap——hash 方法中的这段代码叫作 “扰动函数

咱们来分析一下:

hashCode是Object类中的一个方法,在子类中通常都会重写,而根据咱们以前本身给出的程序,暂以Integer类型为例,咱们来看一下Integer中hashCode方法的源码:

/** * Returns a hash code for this {@code Integer}. * * @return a hash code value for this object, equal to the * primitive {@code int} value represented by this * {@code Integer} object. */
@Override
public int hashCode() {
    return Integer.hashCode(value);
}

/** * Returns a hash code for a {@code int} value; compatible with * {@code Integer.hashCode()}. * * @param value the value to hash * @since 1.8 * * @return a hash code value for a {@code int} value. */
public static int hashCode(int value) {
    return value;
}
复制代码

Integer中hashCode方法的返回值就是这个数自己

注:整数的值由于与整数自己同样惟一,因此它是一个足够好的散列

因此,下面的A、B两个式子就是等价的

//注:key为 hash(Object key)参数

A:(h = key.hashCode()) ^ (h >>> 16)

B:key ^ (key >>> 16)
复制代码

分析到这一步,咱们的式子只剩下位运算了,先不急着算什么,咱们先理清思路

HashSet由于底层使用**哈希表(链表结合数组)**实现,存储时key经过一些运算后得出本身在数组中所处的位置。

咱们在hashCoe方法中返回到了一个等同于自己值的散列值,可是考虑到int类型数据的范围:-2147483648~2147483647 ,着很显然,这些散列值不能直接使用,由于内存是没有办法放得下,一个40亿长度的数组的。因此它使用了对数组长度进行取模运算,得余后再做为其数组下标,indexFor( ) ——JDK7中,就这样出现了,在JDK8中 indexFor()就消失了,而所有使用下面的语句代替,原理是同样的。

//JDK8中
(tab.length - 1) & hash;
复制代码
//JDK7中
bucketIndex = indexFor(hash, table.length);

static int indexFor(int h, int length) {
    return h & (length - 1);
}
复制代码

提一句,为何取模运算时咱们用 & 而不用 % 呢,由于位运算直接对内存数据进行操做,不须要转成十进制,所以处理速度很是快,这样就致使位运算 & 效率要比取模运算 % 高不少。

看到这里咱们就知道了,存储时key须要经过hash方法和**indexFor( )**运算,来肯定本身的对应下标

(取模运算,应以JDK8为准,但为了称呼方便,仍是按照JDK7的叫法来讲,下面的例子均为此,特此提早声明)

可是先直接看与运算(&),好像又出现了一些问题,咱们举个例子:

HashMap中初始长度为16,length - 1 = 15;其二进制表示为 00000000 00000000 00000000 00001111

而与运算计算方式为:遇0则0,咱们随便举一个key值

1111 1111 1010 0101 1111 0000 0011 1100
&		0000 0000 0000 0000 0000 0000 0000 1111
----------------------------------------------------
		0000 0000 0000 0000 0000 0000 0000 1100
复制代码

咱们将这32位从中分开,左边16位称做高位,右边16位称做低位,能够看到通过&运算后 结果就是高位所有归0,剩下了低位的最后四位。可是问题就来了,咱们按照当前初始长度为默认的16,HashCode值为下图两个,能够看到,在不通过扰动计算时,只进行与(&)运算后 Index值均为 12 这也就致使了哈希冲突

哈希冲突的简单理解:计划把一个对象插入到散列表(哈希表)中,可是发现这个位置已经被别的对象所占据了

例子中,两个不一样的HashCode值却通过运算后,获得了相同的值,也就表明,他们都须要被放在下标为2的位置

通常来讲,若是数据分布比较普遍,并且存储数据的数组长度比较大,那么哈希冲突就会比较少,不然很高。

可是,若是像上例中只取最后几位的时候,这可不是什么好事,即便个人数据分布很散乱,可是哈希冲突仍然会很严重。

别忘了,咱们的扰动函数还在前面搁着呢,这个时候它就要发挥强大的做用了,仍是使用上面两个发生了哈希冲突的数据,这一次咱们加入扰动函数再进行与(&)运算

补充 :>>> 按位右移补零操做符,左操做数的值按右操做数指定的为主右移,移动获得的空位以零填充

​ ^ 位异或运算,相同则0,不一样则1

能够看到,本发生了哈希冲突的两组数据,通过扰动函数处理后,数值变得再也不同样了,也就避免了冲突

其实在扰动函数中,将数据右位移16位,哈希码的高位和低位混合了起来,这也正解决了前面所讲 高位归0,计算只依赖低位最后几位的状况, 这使得高位的一些特征也对低位产生了影响,使得低位的随机性增强,能更好的避免冲突

到这里,咱们一步步研究到了这一些知识

HashSet add() → HashMap put() → HashMap hash()HashMap (tab.length - 1) & hash; 复制代码

有了这些知识的铺垫,我对于刚开始本身举的例子又产生了一些疑惑,我使用for循环添加一些整型元素进入集合,难道就没有任何一个发生哈希冲突吗,为何遍历结果是有序输出的,通过简单计算 2 和18这两个值就都是2

(这个疑惑是有问题的,后面解释了错在了哪里)

//key = 2,(length -1) = 15

h = key.hashCode()	    0000 0000 0000 0000 0000 0000 0000 0010	
h >>> 16	            0000 0000 0000 0000 0000 0000 0000 0000
hash = h^(h >>> 16)	    0000 0000 0000 0000 0000 0000 0000 0010
(tab.length-1)&hash	    0000 0000 0000 0000 0000 0000 0000 1111
	                    0000 0000 0000 0000 0000 0000 0000 0010	
-------------------------------------------------------------
  		            0000 0000 0000 0000 0000 0000 0000 0010

//2的十进制结果:2
复制代码
//key = 18,(length -1) = 15

h = key.hashCode()	    0000 0000 0000 0000 0000 0000 0001 0010	
h >>> 16	            0000 0000 0000 0000 0000 0000 0000 0000
hash = h^(h >>> 16)	    0000 0000 0000 0000 0000 0000 0001 0010
(tab.length-1)&hash	    0000 0000 0000 0000 0000 0000 0000 1111
	                    0000 0000 0000 0000 0000 0000 0000 0010	
-------------------------------------------------------------
   		            0000 0000 0000 0000 0000 0000 0000 0010

//18的十进制结果:2
复制代码

按照咱们上面的知识,按理应该输出 1 2 18 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 但却仍有序输出了

import java.util.*;

public class HashSetDemo {
    public static void main(String[] args) {

        Set<Integer> hs = new HashSet<Integer>();

        for (int i = 0; i < 19; i++) {
            hs.add(i);
        }

        //加强for遍历
        for (Integer s : hs) {
            System.out.print(s + " ");
        }
    }
}

//运行结果:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 
复制代码

再试一试

import java.util.*;

public class HashSetDemo {
    public static void main(String[] args) {

        Set<Integer> hs = new HashSet<Integer>();
		
        hs.add(0)
        hs.add(1);
        hs.add(18);
        hs.add(2);
        hs.add(3);
        hs.add(4);
        ......
        hs.add(17)

        //加强for遍历
        for (Integer s : hs) {
            System.out.print(s + " ");
        }
    }
}

//运行结果:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
复制代码

真让人头大,不死心再试一试,由与偷懒,就只添加了几个,就是这个偷懒,让我发现了新大陆!

import java.util.*;

public class HashSetDemo {
    public static void main(String[] args) {

        Set<Integer> hs = new HashSet<Integer>();

        hs.add(1);
        hs.add(18);
        hs.add(2);
        hs.add(3);
        hs.add(4);

        //加强for遍历
        for (Integer s : hs) {
            System.out.print(s + " ");
        }
    }
}

//运行结果:
1 18 2 3 4
复制代码

这一段程序按照咱们认为应该出现的顺序出现了!!!

忽然恍然大悟,我忽略了最重要的一个问题,也就是数组长度问题

//HashMap 源码节选-JDK8

/** * The default initial capacity - MUST be a power of two. */
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

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

<< :按位左移运算符,作操做数按位左移右错做数指定的位数,即左边最高位丢弃,右边补齐0,计算的简便方法就是:把 << 左面的数据乘以2的移动次幂

为何初始长度为16:1 << 4 即 1 * 2 ^4 =16;

咱们还观察到一个叫作加载因子的东西,他默认值为0.75f,这是什么意思呢,咱们来补充一点它的知识:

加载因子就是表示哈希表中元素填满的程度,当表中元素过多,超过加载因子的值时,哈希表会自动扩容,通常是一倍,这种行为能够称做rehashing(再哈希)。

加载因子的值设置的越大,添加的元素就会越多,确实空间利用率的到了很大的提高,可是毫无疑问,就面临着哈希冲突的可能性增大,反之,空间利用率形成了浪费,但哈希冲突也减小了,因此咱们但愿在空间利用率与哈希冲突之间找到一种咱们所能接受的平衡,通过一些试验,定在了0.75f

如今能够解决咱们上面的疑惑了

数组初始的实际长度 = 16 * 0.75 = 12

这表明当咱们元素数量增长到12以上时就会发生扩容,当咱们上例中for循环添加0-18, 这19个元素时,先保存到前12个到第十三个元素时,超过加载因子,致使数组发生了一次扩容,而扩容之后对应与(&)运算的(tab.length-1)就发生了变化,从16-1 变成了 32-1 即31

咱们来算一下

//key = 2,(length -1) = 31

h = key.hashCode()	    0000 0000 0000 0000 0000 0000 0001 0010	
h >>> 16	            0000 0000 0000 0000 0000 0000 0000 0000
hash = h^(h >>> 16)	    0000 0000 0000 0000 0000 0000 0001 0010
(tab.length-1)&hash	    0000 0000 0000 0000 0000 0000 0011 1111 
	                    0000 0000 0000 0000 0000 0000 0000 0010		
-------------------------------------------------------------
  			    0000 0000 0000 0000 0000 0000 0000 0010

//十进制结果:2
复制代码
//key = 18,(length -1) = 31

h = key.hashCode()	    0000 0000 0000 0000 0000 0000 0001 0010	
h >>> 16		    0000 0000 0000 0000 0000 0000 0000 0000
hash = h^(h >>> 16)	    0000 0000 0000 0000 0000 0000 0001 0010
(tab.length-1)&hash	    0000 0000 0000 0000 0000 0000 0011 1111 
	         	    0000 0000 0000 0000 0000 0000 0000 0010		
-------------------------------------------------------------
  	                    0000 0000 0000 0000 0000 0000 0001 0010

//十进制结果:18
复制代码

当length - 1 的值发生改变的时候,18的值也变成了自己。

到这里,才意识到本身以前用2和18计算时 均使用了 length -1 的值为 15是错误的,当时并不清楚加载因子及它的扩容机制,这才是致使提出有问题疑惑的根本缘由。

(三) 总结

JDK7到JDK8,其内部发生了一些变化,致使在不一样版本JDK下运行结果不一样,根据上面的分析,咱们从HashSet追溯到HashMap的hash算法、加载因子和默认长度。

因为咱们所建立的HashSet是Integer类型的,这也是最巧的一点,Integer类型hashCode()的返回值就是其int值自己,而存储的时候元素经过一些运算后会得出本身在数组中所处的位置。因为在这一步,其自己即下标(只考虑这一步),其实已经实现了排序功能,因为int类型范围太广,内存放不下,因此对其进行取模运算,为了减小哈希冲突,又在取模前进行了,扰动函数的计算,获得的数做为元素下标,按照JDK8下的hash算法,以及load factor及扩容机制,这就致使数据在通过 HashMap.hash()运算后仍然是本身自己的值,且没有发生哈希冲突。

补充:对于有序无序的理解

集合所说的序,是指元素存入集合的顺序,当元素存储顺序和取出顺序一致时就是有序,不然就是无序。

并非说存储数据的时候无序,没有规则,当咱们不论使用for循环随机数添加元素的时候,仍是for循环有序添加元素的时候,最后遍历输出的结果均为按照值的大小排序输出,随机添加元素,但结果仍有序输出,这就对照着上面那句,存储顺序和取出顺序是不一致的,因此咱们说HashSet是无序的,虽然咱们按照123的顺序添加元素,结果虽然仍为123,但这只是一种巧合而已。

因此HashSet只是不保证有序,并不是保证无序

结尾:

若是内容中有什么不足,或者错误的地方,欢迎你们给我留言提出意见, 蟹蟹你们 !^_^

若是能帮到你的话,那就来关注我吧!(系列文章均会在公众号第一时间更新)

在这里的咱们素不相识,却都在为了本身的梦而努力 ❤

一个坚持推送原创Java技术的公众号:理想二旬不止

相关文章
相关标签/搜索