Java工程师成神之路 基础篇 Java基础知识 集合类

集合类

Collection 和 Collections 区别

Collection是一个集合接口,它提供了对集合对象进行基本操作的通用接口方法。Collection 接口在Java类库中很多具体的实现。是list、set等的父接口。

Collections 是一个包装类。它包含有各种有关集合操作的静态多态方法。此类不能实例化,就像一个工具类,服务于Java的Collection框架。

日常开发中,不仅要了解Java的Collection及其子类的用法,还要了解Collection用法。可以提升很多处理集合类的效率。


常用集合类的使用

Set 和 List的区别?

List,Set都是继承自Collection接口。都是用来存储一组相同类型的元素的。

List特点:元素有放入顺序,元素可重复。

有顺序,即先放入的元素排在前面。

Set 特点:元素无放入顺序,元素不可重复。

无顺序,即先放入的元素不一定排在前面。 不可重复,即相同元素在set中只会保留一份。所以,有些场景下,set可以用来去重。 不过需要注意的是,set在元素插入时是要有一定的方法来判断元素是否重复的。这个方法很重要,决定了set中可以保存哪些元素。


ArrayList和LinkedList和Vector的区别

List 主要有ArrayList、LinkedList 与 Vector 几种实现。

这三者都实现了List 接口,使用方式也很相似,主要区别在于因为实现方式的不同,所以对不同的操作具有不同的效率。

ArrayList 是一个可改变大小的数组,当更多的元素加入到ArrayList 中时,其大小将会动态地增长,内部的元素可以直接通过get与set方法进行访问,因为ArrayList本质上就是一个数组。

LinkedList 是一个双链表,在添加和删除元素时具有比ArrayList 更好的性能,但在get 与 set 方面弱于ArrayList。

当然,这些对比都是指数据量很大或者操作很频繁的情况下的对比,如果数据和运算量很小,那么对比将失去意义。

Vector 和 ArrayList 类似,但属于强同步类。如果你的程序本身是线程安全的(thread-safe,没有在多个线程之间共享一个集合/对象),那么使用ArrayList 是更好的选择。

Vector 和 ArrayList 在更多元素添加进来时会请求更大的空间。Vector 每次请求其大小的两倍空间,而ArrayList 每次对size 增长50%。

而LinkedList 还实现了 Queue 接口,该接口比List提供了更多的方法,包括offer(),peek()、poll()等

注意:默认情况下ArrayList 的初始容量非常小,所以如果可以预估数据量的话,分配一个较大的初始值属于最佳实践,这样可以减少调整大小的开销。


SynchronizedList 和 Vector 的区别

Vector 是java.util包中的一个类。SynchronizedList 是java.util.Collections 中的一个静态内部类。

在多线程的场景中可以直接使用Vector类,也可以使用Collections.synchronizedList(List list)返回一个线程安全的List。

那么,到底SynchronizedList和Vector有没有区别,为什么java api要提供这两种线程安全的List的实现方式呢?

首先,我们知道Vector 和 ArrayList 都是List 的子类,它们底层的实现都是一样的。所以在这里比较如下两个list1list2的区别:


List<String> list = new ArrayList<String>();
List list2 =  Collections.synchronizedList(list);
Vector<String> list1 = new Vector<String>();

一、比较几个重要的方法

1.1 add 方法

Vector 的实现:

public void add(int index, E element) {
    insertElementAt(element, index);
}

public synchronized void insertElementAt(E obj, int index) {
    modCount++;
    if (index > elementCount) {
        throw new ArrayIndexOutOfBoundsException(index
                                                 + " > " + elementCount);
    }
    ensureCapacityHelper(elementCount + 1);
    System.arraycopy(elementData, index, elementData, index + 1, elementCount - index);
    elementData[index] = obj;
    elementCount++;
}

private void ensureCapacityHelper(int minCapacity) {
    // overflow-conscious code
    if (minCapacity - elementData.length > 0)
        grow(minCapacity);
}

synchronizedList 的实现:

public void add(int index, E element) {
   synchronized (mutex) {
       list.add(index, element);
   }
}

这里,使用同步代码块的方式调用ArrayList的add()方法。ArrayList的add方法内容如下:

public void add(int index, E element) {
    rangeCheckForAdd(index);
    ensureCapacityInternal(size + 1);  // Increments modCount!!
    System.arraycopy(elementData, index, elementData, index + 1,
                     size - index);
    elementData[index] = element;
    size++;
}
private void rangeCheckForAdd(int index) {
    if (index > size || index < 0)
        throw new IndexOutOfBoundsException(outOfBoundsMsg(index));
}
private void ensureCapacityInternal(int minCapacity) {
    if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
        minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity);
    }
    ensureExplicitCapacity(minCapacity);
}

从上面两段代码中发现有两处不同:1.Vector 使用同步方法实现,synchronizedList使用同步代码块实现。2. 两者的扩容数组容量方式不一样(两者的add方法在扩容方面的差别也就是ArrayList和Vector的差别)。


1.2 remove 方法

synchronizedList 的实现:

public E remove(int index) {
    synchronized (mutex) {return list.remove(index);}
}

ArrayList类的remove方法内容如下:

public E remove(int index) {
    rangeCheck(index);

    modCount++;
    E oldValue = elementData(index);

    int numMoved = size - index - 1;
    if (numMoved > 0)
        System.arraycopy(elementData, index+1, elementData, index,
                         numMoved);
    elementData[--size] = null; // clear to let GC do its work

    return oldValue;
}

Vector的实现:

public synchronized E remove(int index) {
        modCount++;
        if (index >= elementCount)
            throw new ArrayIndexOutOfBoundsException(index);
        E oldValue = elementData(index);

        int numMoved = elementCount - index - 1;
        if (numMoved > 0)
            System.arraycopy(elementData, index+1, elementData, index,
                             numMoved);
        elementData[--elementCount] = null; // Let gc do its work

        return oldValue;
    }

从remove方法中我们发现除了一个使用同步方法,一个使用同步代码块之外几乎无任何区别。

通过比较其他方法,我们发现,SynchronizedList 里面实现的方法几乎都是使用同步代码块包上List的方法。如果该List是ArrayList,那么,Synchronized 和 Vector 的一个比较明显的区别就是一个使用了同步代码块,一个使用了同步方法。


区别分析

数据增长区别

从内部实现机制来讲ArrayList 和 Vector 都是使用数组(Array)来控制集合中的对象。当你向这两种类型中增加元素的时候,如果元素的数目超出了内部数组目前的长度它们都需要扩展内部数组的长度,Vector缺省情况下自动增长原来的一倍的数组长度,ArrayList是原来的50%,所以最后你获得的这个集合所占的空间总是比你实际需要的要大。所以如果你要在集合中保存大量的数据那么使用Vector有一些优势,因为你可以通过设置集合的初始化大小来避免不必要的资源开销。

同步代码块和同步方法的区别

  1. 同步代码块在锁定的范围上可能比同步方法小,一般来说锁的范围大小和性能成反比的。
  2. 同步代码块可以更加精准的控制锁的作用域(锁的作用域就是从锁被获取到其被释放的时间),同步方法的锁的作用域就是整个方法。
  3. 静态代码块可以选择哪个对象加锁,但是静态方法只能给this对象加锁。

因为SynchronizedList只是使用同步代码块包裹了ArrayList的方法,而ArrayList和Vector中同名方法的方法体内容并无太大差异,所以在锁定范围和锁的作用域上两者并无却别。 在锁定的对象区别上,SynchronizedList的同步代码块锁定的是mutex对象,Vector锁定的是this对象。那么mutex对象又是什么呢? 其实SynchronizedList有一个构造函数可以传入一个Object,如果在调用的时候显示的传入一个对象,那么锁定的就是用户传入的对象。如果没有指定,那么锁定的也是this对象。

所以,SynchronizedList 和 Vector 的区别目前为止有两点:1. 如果使用add方法,那么它们的扩容机制不一样。2. SynchronizedList可以指定锁定的对象。

但是,凡事都有但是。SynchronizedList中实现的类并没有都使用synchronized同步代码块。其中有listIterator 和 listIterator(int index) 并没有做同步处理。但是Vector却对该方法加了方法锁。 所以说,在使用SynchronizedList进行遍历的时候要手动加锁。

但是,但是之后还有但是。

之前的比较都是基于我们将ArrayList转成SynchronizedList。那么如果我们想把LinkedList变成线程安全的,或者说我想要方便在中间插入和删除的同步链表,那么我们可以将已有的LinkedList直接转成SynchronizedList,而不用改变他的底层数据结构。而这一点是Vector无法做到的,因为他的底层结构就是使用数组实现的,这个是无法改变的。

所以,最后,SynchroizedList 和 Vector 最重要的区别:1. SynchronizedList有很好的扩展和兼容功能。他可以将所有的List的子类转成线程安全的类。2. 使用SynchronizedList的时候,进行遍历时要手动进行同步处理。3. SynchronizedList可以指定锁定的对象。


Set 是如何保证元素不重复?

在Java的Set体系中,根据实现方式不同主要分为两大类。HashSet和TreeSet。

  1. TreeSet 是二叉树实现的,TreeSet 中的数据是自动排好序的,不允许放入null值。
  2. HashSet 是哈希表实现的,HashSet的数据是无序的,可以放入null值,但只能放入一个null,两者中的值都不能重复,就如数据库中唯一约束。

在HashSet中,基本的操作都是有HashMap底层实现的,因为HashSet底层是用HashMap存储数据的。当向HashSet中添加元素的时候,首先计算元素的hashcode值,然后通过扰动计算和按位与的方式计算出这个元素的存储位置,如果这个位置为空,就将元素添加进去;如果不为空,则用equals方法比较元素是否相等,相等就不添加,否则找一个空位添加。

TreeSet 的底层是TreeMap的keySet(),而TreeMap是基于红黑树实现的,红黑树是一种平衡二叉查找树,它能保证任何一个节点的左右子树的高度不会超过比较矮的那棵一倍。

TreeSet 是按key排序的,元素在插入TreeSet 时compareTo()方法要被调用。所以TreeSet中的元素要实现Comparable接口。TreeSet作为一种Set,它不允许出现重复元素。TreeSet是用compareTo()来判断重复元素的。


HashMap、HashTable、ConcurrentHashMap区别

HashMap 和 HashTable 有何不同?

线程安全:

HashTable中的方法是同步的,而HashMap中的方法在默认情况下是非同步的。在多线程并发的环境下,可以直接使用HashTable,但是要使用HashMap的话就要自己增加同步处理了。

继承关系:

HashTable是基于陈旧的Dictionary类继承来的。HashMap继承的抽象类AbstractMap实现了Map接口。

允不允许null值:

HashTable中,key和value都不允许出现null值,否则会抛出NullPointerException异常。HashMap中,null可以作为键,这样的键只有一个;可以有一个或多个键所对应的值为null。

默认初始容量和扩容机制:

HashTable 中的hash数组初始化11,增加的方式是 old * 2 + 1.HashMap中的hash数组的默认大小是16,而且一定是2的指数。

哈希值是使用不同:

HashTable直接使用对象的hashCode。HashMap重写计算hash值。

遍历方式的内部实现上不同:

Hashtable,HashMap都使用了Iterator。而由于历史原因,HashTable还是用了Enumeration方式,HashMap实现了Iterator,支持fast-fail,HashTable的 Iterator 遍历支持fast-fail,用 Enumeration 不支持 fast-fail


HashMap 和 ConcurrentHashMap 的区别?

ConcurrentHashMap 和 HashMap 的实现方式不一样,虽然都是使用桶数组实现的,但是还是有区别的,ConcurrentHashMap对桶数组进行了分段,而HashMap并没有。

ConcurrentHashMap 在每一个分段上都用锁进行了保护。HashMap 没有锁机制。所以,前者线程安全的,后者不是线程安全的。

ps:以上区别均基于 jdk1.8以前的版本。


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

HashMap的容量、扩容

先看一下,HashMap中都定义了哪些成员变量在这里插入图片描述
上面是一张HashMap中主要的成员变量的图,其中有一个是我们本文主要关注的:sizeloadFactorthresholdDEFAULT_LOAD_FACTORDEFAULT_INITIAL_CAPACITY

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

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

  • transient int size:记录了Map中 KV的个数
  • loadFactor:装载因子,用来衡量HashMap满的程度。loadFactory的默认值为0.75f(static final float DEFAULT_LOCAL_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的重载的构造函数中,有一个是支持传入initCapacity的,那么我们尝试着设置一下,看结果如何。

    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 : 2、capacity : 8、capacity : 16。

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

这里有个小建议:在初始化HashMap的时候,应该尽量指定其大小。尤其是当你已知map中存放的元素个数时。(《阿里巴巴Java开发规约》)


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.75

capacity : 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函数有以下几个:

直接定址法:直接以关键字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方法的功能是根据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生成的整型转换成链表数组中的下标。


Hash 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);
2^n 表示2的n次方,也就是说,一个数对2^n取模 == 一个数和(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倍。

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

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

接下来我们会发现,无论是用取模运算还是位运算都无法直接解决冲突较大的问题。比如:CA11 00000001 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做一次按位与操作呢,主要是为了保证得到inex的第一位为0,也就是为了得到一个正数,因为有符号数第一位0代表正数,1代表负数。

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

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

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

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

  • HashMap默认的初始化大小为16,之后每次扩充为原来的2倍。
  • HashMap默认的初始化大小为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实现其实和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)

如果恶意程序知道我们用的是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 = key.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;
}

Java 8的ConcurrentHashMap同样是通过Key的哈希值与数组长度取模确定该Key在数组中的索引。同样为了避免不太好的Key的hashCode设计,它通过如下方法计算得到Key的最终哈希值。不同的是,Java 8的ConcurrentHashMap作者认为引入红黑树后,即使哈希冲突比较严重,寻址效率也足够高,所以作者并未在哈希值的计算上做过多设计,只是将Key的hashCode值与其高16位作异或并保证最高位为0(从而保证最终结果为正整数)。


总结

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

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值