面试中的HashMap、ConcurrentHashMap和Hashtable,你知道多少?

前言

学过数据结构的读者们想必其实也都学过HashMap,面试官问你的时候,想来你都是很清楚的知道HashMap是怎样的一个构成?确实很简单,就是数组加链表嘛。那再问你HashtableHashMap的区别是什么?脑子也不用想,又能出来一个答案线程安全和线程不安全,Hashtable不容许存在空值呗。那继续往深处问,HashMap是怎么作性能优化的?这个时候你是怎么样的反应呢?若是知道红黑树,那就能答出来;不知道的话那不是就凉了,由于这个时候连ConcurrentHashMap都须要放弃回答了!!!java

思惟导图

HashMap源码导读

其实思路大体都是相同的,因此这里只分析一个HashMap,先贴出他的几个常见用法。node

HashMap hashMap = new HashMap();
hashMap.put(key, value);
hashMap.get(key);
复制代码

主要从这个方面对HashMap的整个工做流程进行分析。面试

HashMap()

public HashMap(int initialCapacity, float loadFactor) {
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal initial capacity: " +
                                               initialCapacity);
        // 对数组的一个保护,不能超过int最大值范围
        if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            throw new IllegalArgumentException("Illegal load factor: " +
                                               loadFactor);
        this.loadFactor = loadFactor;
        this.threshold = tableSizeFor(initialCapacity);
    }

    public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }

    public HashMap() {
        this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
    }

    public HashMap(Map<? extends K, ? extends V> m) {
        this.loadFactor = DEFAULT_LOAD_FACTOR;
        putMapEntries(m, false);
    }
复制代码

其实在无参构造方法,咱们并无看到所谓的数组的初始化,他只对咱们的负载因子作了一个初始化,也就是咱们一直常说的0.75f,但为何是0.75f呢,只能说是一个经验值,也就是经验所致,由于0.5f时空间太浪费,1f时容易出现极端状况,固然也不是随便定的,设计师确定是作了不少的测试的,但依旧是一个经验值,或者说是测试后的最优解。数组

回到咱们以前的问题,既然咱们学习的时候学到过HashMap是一个数组+链表。那作第一个思考为何初始化不见了? 先带着这样的问题继续啊往下走。安全

先看看本身动手初始化容量构造函数,最后都会调用下方的tableSizeFor()方法。性能优化

static final int tableSizeFor(int cap) {
        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;
    }
复制代码

本质意思就是把数值变成2的指数倍,这样的好处是计算方便处理。可是出现一样的问题,没有初始化,这里也只看到了容量。问题继续保留。数据结构

put(key, value)

public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true); // 1
    }
// 由注释1直接调用的方法putVal()
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        // 第一次来判断的时候,显然的tab是一个空,由于在构造函数中,咱们并无看到他的初始化,那么必然要调用resize()方法。
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length; // 2,未能初始化而必然调用的方法
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        else {
            Node<K,V> e; K k;
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            else if (p instanceof TreeNode)
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            else {
                for (int binCount = 0; ; ++binCount) {
                    if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            treeifyBin(tab, hash);
                        break;
                    }
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
            if (e != null) { // existing mapping for key
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }
        ++modCount;
        if (++size > threshold)
            resize(); // 2
        afterNodeInsertion(evict);
        return null;
    }
// 由注释2直接调用的方法
// 由多种方法调用到这里:
// 1. 还没有初始化
// 2. 保存的数据超出 容量 * 负载因子
// 3. 数据被删的不足以支持树形的时候
final Node<K,V>[] resize() {
        Node<K,V>[] oldTab = table;
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        int oldThr = threshold;
        int newCap, newThr = 0;
        // 。。。。
        // 此处对容量大小作了一系列的断定,为定义初始化容量为16
        // 。。。。
        if (newThr == 0) {
            float ft = (float)newCap * loadFactor;
            newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                      (int)ft : Integer.MAX_VALUE);
        }
        threshold = newThr;
        // 进行了整个的table进行一个初始化
        // 而这个table就是一个Node的数组
        // Node也就是链表的一个个节点,读者本身点进去观察就能看到next节点
        @SuppressWarnings({"rawtypes","unchecked"})
        Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
        table = newTab;
        // 。。。。。
    }
复制代码

到这里咱们就已经明白了,原来初始化的过程已经在这里进行了定义,这也就解决了咱们的第一个问题了。可是随之而来第二个问题,为何要这样设计呢? 这里给出我思考的一个答案,若是只建立了,却没有进行使用呢?那至少就会占去16个数据类型大小的内存,而这样的建立方法,就是对内存的一种保护机制。app

第三个问题,为何要转变成树形(固然它是有好听的名字的,叫作红黑树)? 其实结构的转换为的不外乎几种缘由效率问题、空间占用问题。若是使用链表查询,他的查询速度是O(n) ,而红黑树的查询速度是O(logn)。可是红黑树带来的问题确实一个存储容量的问题,做为二叉树,他须要同时保存左右节点,而单链表只有一个节点,那么内存消耗的问题就出来了。树的构造问题能讲一篇博客,因此就再也不这里讲先了。函数

get(key)

public V get(Object key) {
        Node<K,V> e;
        return (e = getNode(hash(key), key)) == null ? null : e.value;
    }
复制代码

经过hash值来寻找咱们对应的节点,那咱们就须要先来看看这个hash是怎么计算的。post

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

答案也是一目了然的,得到hashCode()值,而后进行于0000_0000_0000_0000b进行异或运算。其实就是为了算出hashCode()的低16位。那咱们得到了hash值之后,就须要来找找咱们的节点了。

final Node<K,V> getNode(int hash, Object key) {
        Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (first = tab[(n - 1) & hash]) != null) {
            if (first.hash == hash && // always check first node
                ((k = first.key) == key || (key != null && key.equals(k))))
                return first;
            if ((e = first.next) != null) {
                if (first instanceof TreeNode)
                // 对树形中的数据进行查找
                    return ((TreeNode<K,V>)first).getTreeNode(hash, key);
                // 对链表中的数据进行查找
                do {
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        return e;
                } while ((e = e.next) != null);
            }
        }
        return null;
    }
复制代码

而后获取到咱们须要的数据,而后就返还给咱们了,哇哦!!原来总体就够就是这么简单的。(其实真正写起来不简单,分析起来简单一点罢了,嘿嘿。)

HashMap和Hashtable有什么不一样

既然咱们已经知道了整个的HashMap的构成,那主要要了解的对象就应该是Hashtable了。那咱们先来看看Hashtable的构造函数好了。

// 无参构造函数初始化,处理容量为11,负载因子为0,75
public Hashtable() {
        this(11, 0.75f);
    }
// 链表的建立在默认最后嗲用的构造函数中就已经建立
// 那这里咱们就发现了第一个不一样的地方。
public Hashtable(int initialCapacity, float loadFactor) {
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal Capacity: "+
                                               initialCapacity);
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            throw new IllegalArgumentException("Illegal Load: "+loadFactor);

        if (initialCapacity==0)
            initialCapacity = 1;
        this.loadFactor = loadFactor;
        table = new Entry<?,?>[initialCapacity];
        threshold = (int)Math.min(initialCapacity * loadFactor, MAX_ARRAY_SIZE + 1);
    }
复制代码

文内写了第一个不一样点,可是还有一个不一样点,你是否发现了? 就是容量的问题,在HashMap中的容量计算所有都是往2的指数倍进行靠近的,可是Hashtable并无作出这样的选择,可是在负载因子上又出奇的一致。

再看看Hashtableput(key, value)方法。

public synchronized V put(K key, V value) {
        // 判空机制的存在,和HashMap并没有判空,也就允许null做为key存在
        if (value == null) {
            throw new NullPointerException();
        }

        // Makes sure the key is not already in the hashtable.
        Entry<?,?> tab[] = table;
        int hash = key.hashCode();
        int index = (hash & 0x7FFFFFFF) % tab.length;
        @SuppressWarnings("unchecked")
        Entry<K,V> entry = (Entry<K,V>)tab[index];
        for(; entry != null ; entry = entry.next) {
            if ((entry.hash == hash) && entry.key.equals(key)) {
                V old = entry.value;
                entry.value = value;
                return old;
            }
        }

        addEntry(hash, key, value, index);
        return null;
    }
复制代码

咱们常说Hashtable是一个线程安全的类,而这里也给了咱们答案,他在方法上加了synchronized,也就是锁的机制,来完成咱们的同步。可是思前想后,我都存在一个疑惑,大家是否看到了他的resize()函数呢?,没错,并不存在resize()函数。

那咱们继续往下看看好了,由于在这个函数中还存在一个addEntry()方法,看看里面是否是有扩容机制呢。

private void addEntry(int hash, K key, V value, int index) {
        modCount++;

        Entry<?,?> tab[] = table;
        if (count >= threshold) {
            // Rehash the table if the threshold is exceeded
            rehash();

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

        // Creates the new entry.
        @SuppressWarnings("unchecked")
        Entry<K,V> e = (Entry<K,V>) tab[index];
        tab[index] = new Entry<>(hash, key, value, e);
        count++;
    }
复制代码

原来他改头换面了,在addEntry()方法中,咱们发现他的重构函数是一个叫作rehash()的函数。而扩容机制和HashMap相同都是放大两倍的操做来进行完成的。可是从效率上来说,由于一直数组+链表的形式存在,就算是没有线程安全的机制,效率上来讲整体仍是比HashMap差劲的。

ConcurrentHashMap就线程安全的性能优化

说到ConcurrentHashMap,其实他和HashMap同样都是存在JDK1.8先后的版本差别的。

网上能够查到不少关于version 1.8以前的机制,也就是分段锁,能够看作成多个Hashtable的组合。而version 1.8以后的机制,就是锁槽了。迟点作一个详细的解析。

既然是性能优化,那么就应该有性能优化的点。

(1)和HashMap的实现方式同样,数组+链表+红黑树,查找性能上优于Hashtable前提: 使用的容量大于8。

(2)分段锁机制 / 锁槽机制:再也不是整个数组加锁,而是对单条或者几条链表和红黑树进行加锁,也就同时可以就收多个不一样的hash操做了。

由于我本地使用的JDK1.8,因此咱们就先研究一下JDK1.8的作法好了。

final V putVal(K key, V value, boolean onlyIfAbsent) {
        if (key == null || value == null) throw new NullPointerException();
        int hash = spread(key.hashCode());
        int binCount = 0;
        for (Node<K,V>[] tab = table;;) {
            Node<K,V> f; int n, i, fh;
            if (tab == null || (n = tab.length) == 0)
                //。。。。。
            }
            else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
                // 引入了CAS机制
                if (casTabAt(tab, i, null,
                             new Node<K,V>(hash, key, value, null)))
                    break;                   // no lock when adding to empty bin
            }
            else if ((fh = f.hash) == MOVED)
                //。。。。。
            else {
                V oldVal = null;
                synchronized (f) {
                    // 。。。。。
                }
                //。。。。。
            }
        }
        addCount(1L, binCount);
        return null;
    }
复制代码

须要关注的是加锁对象synchronized (f)。对变量f表明就一个hash对应的一条链表,而加锁正好加的是这条链表,或者这颗红黑树上,另外索引为空时经过CAS的方式来建立一个新的节点。这也就是JDK 1.8引入的新机制CAS+锁。

那咱们再看看JDK 1.7的作法是什么样的,就直接用一张图来直观感觉吧

version 1.7的时候根据Segment来给每一链配锁,可是带来的问题就是hash搜索时间变长。不过相较于Hashtable而言,性能上仍是更加出色的。由于分段锁的机制也就不影响两两段之间并不会存在锁的问题,也就提升了性能。

而相较于version 1.8来讲,性能确是不足的,首先是引入了红黑树的缘由,第二Segment的维护其余相较于如今是一个比较麻烦的过程。然后者调整为单个Node进行一个调整,须要进行调整的范围减少了,带来了两个好处,一是好管理,二是可同时操做的数量增长。

总结

其实整体来讲就是性能上是HashMap > ConcurrentHashMap > Hashtable ,考虑上线程安全之后ConcurrentHashMap > Hashtable 。也就是基于这些缘由才会出现后来咱们在使用ConcurrentHashMap出现来替代Hashtable的状况。

红黑树也是咱们对于性能优化的一种策略,可是从构建角度来看的话,红黑树的构建方式确实仍是比较麻烦的,须要必定的逻辑基础。

以上就是个人学习成果,若是有什么我没有思考到的地方或是文章内存在错误,欢迎与我分享。


相关文章推荐:

手撕OkHttp

手撕Handler

手撕ButterKnife

手撕EventBus

相关文章
相关标签/搜索