面试必问的HashMap你知道多少?

前言:数月前的思必驰电话面试中就问到了HashMap,当时问的是HashMap和HashTable的区别,今天来研究一下HashMap的原理(全文以jdk1.8的HashMap为讨论对象,之前的版本不做研究,有时间博主再补充)

HashMap:

底层实现:
数组+链表+红黑树
HashMap的主干是一个Node数组。Node是HashMap的基本组成单元,每一个Node包含一个key-value键值对。也有叫做bucket(桶)的,但是个人感觉后者更形象一些。

1.为什么是链表+红黑树?

在jdk1.8及以后,当一个bucket中的链表长度大于8时,链表结构会自动转换为红黑树结构。而红黑树查找、插入、删除的时间复杂度最坏为O(log n),单链表的话就是O(n)。数学函数图
在这里插入图片描述

2. 为什么不一开始就使用红黑树?

在链表长度如果是小于等于6,虽然时间复杂度是O(n),但是此时查找速度也很快的,而且最重要的是转化为树结构和生成树会消耗一定时间
hashmap图示:
zZG4ubmV0L0FBQWh4eg==,size_16,color_FFFFFF,t_70)
当size超过8时转换为红黑树结构
在这里插入图片描述

我们知道,一般解决哈希冲突的三种办法:
(1):开放定址法
(2):拉链法
(3):再散列法
当我们对某个元素进行哈希运算,得到一个存储地址,然后要进行插入的时候,发现已经被其他元素占用了,其实这就是的哈希冲突,
那么HashMap采用的就是第二种方法,经计算得到的hash值相同的话放到一个“拉链”里
hash算法源码附上:

 static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }
2月15日补充,面试问到了为什么要无符号右移16位

这~~就涉及到我的知识盲区了,搜索一番:
得出结论:如果我们不做刚才移位异或运算,那么在计算槽位时将丢失高区特征,继而导致hash碰撞,也就是这个操作是为了减少hash碰撞的

2月23日补充:为什么用红黑树不用B+树?

首先看看这两者的使用场景:
2者都是有序数据结构,可用作数据容器。红黑树多用在内部排序,即全放在内存中的。B树多用在内存里放不下,大部分数据存储在外存上时才采用的。因为B树层数少,因此可以确保每次操作,读取磁盘的次数尽可能的少。
在数据较小,可以完全放到内存中时,红黑树的时间复杂度比B树低。反之,数据量较大,外存中占主要部分时,B树因其读磁盘次数少,而具有更快的速度。
我们知道,在负载因子为0.75时,链长度大于8的概率为百万分之六,意思就是绝大部分情况下是到不了红黑树这里的,因为hash函数理想状态下应该是散列的,即成均匀分布。
然后再结合上面的应用场景,因为B+树适合大数据量的情况,而本身链表长度大于8的概率就已经微乎其微,所以,我们犯不上去使用一个时间复杂度高的B+。

2月22日补充:说一下hashCode()?
        public final int hashCode() {
            return Objects.hashCode(key) ^ Objects.hashCode(value);
        }

很明显,源码附上
key的hashcode值和value的hashcode的值进行或与运算

3月17补充:红黑树为什么节点非黑即红?

红黑树的颜色是保证红黑树查找速度的一种方式,从任意的节点开始到叶节点的路径,黑节点的个数是相同的,这就能保证搜索路径的最大长度不超过搜索路径的最短长度的2倍

一些重要参数:
    //默认起始容量
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
    //最大扩容数量
    static final int MAXIMUM_CAPACITY = 1 << 30;
    //负载因子,代表了table的填充度有多少,默认是0.75,请注意看这一行
    static final float DEFAULT_LOAD_FACTOR = 0.75f;
    //这俩数就是上面提到的8和6,转换为树和链表的阈值(临界值)
    static final int TREEIFY_THRESHOLD = 8;
    static final int UNTREEIFY_THRESHOLD = 6;
    //最小树形化容量阈值
    static final int MIN_TREEIFY_CAPACITY = 64;

这里博主根据这几个值还发现了几个问题

3. 为什么负载因子(扩容因子)是0.75?

经过思考和一些参考,我们可以得出以下结论:
首先,我们上面提到了解决hash冲突的方法:拉链法,也就是在理想情况下,经过hash计算的每一个元素都会均匀地分布在每一个Node数组(bucket)里面,但是,假如我现在是0.75的扩容因子,先看以下源码里面的泊松分布的值

* 0:    0.60653066
* 1:    0.30326533
* 2:    0.07581633
* 3:    0.01263606
* 4:    0.00157952
* 5:    0.00015795
* 6:    0.00001316
* 7:    0.00000094
* 8:    0.00000006

那么就是当桶中元素到达8个的时候,概率已经变得非常小,每个碰撞位置的链表长度超过8个是几乎不可能的。因为越长的话操作起来越难。

那么假如我的扩容因子为0.95呢? 也就是平均20个桶里面只有1个是空的,那么就是这个代价是相当大的,就相当于是hash碰撞的特别特别厉害的时候才会出现这种情况,数组中的链表也就越容易长,而这种情况的出现会使get等操作效率大大降低!
那么假如负载因子是0.6或者更小呢? 你这个杠精,负载因子小不就扩容的次数越多吗?那扩容他不需要占用资源啊?过来挨打,所以选择0.75是一个这种的办法,而且是一种用空间换取时间的考虑。

4. 为什么会选择8作为阈值?

根据泊松分布,在负载因子默认为0.75的时候,通过泊松分布看出,当桶中结点个数为8时,出现的几率是亿分之6的(源码为我们算出来了),因此常见的情况是桶中个数小于8的情况,此时链表的查询性能和红黑树相差不多,而转化为树还需要时间和空间,所以此时没有转化成树的必要。

5. 为什么16是默认起始容量?

我的理解很鸡肋,就是这是一个经验值,即在这个值下既能保证碰撞的次数比较小,而又保证空间不被浪费。

6. 为什么hashmap的容量约定是2的倍数呢?

答:为了减少哈希碰撞的几率,选择了hash算法能让元素比较平衡的放到不同的桶中,而hash算法使用了位与&运算符。源码中使用了tab[i = (n - 1) & hash]。
当n=2时,n-1的二进制的后几位全是1,这时与操作更均匀。即更加均匀的让每一个bucket里面的size相同。

HashMap的默认构造器:

    public HashMap(int initialCapacity, float loadFactor) {
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal initial capacity: " +
                                               initialCapacity);
        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);
    }

好的,再往下,我们讨论一下HashMap的几个常见操作:
get

 public V get(Object key) {
        if (key == null)
            return getForNullKey();
        //计算hash值
        int hash = hash(key.hashCode());
        //先定位到数组元素,再遍历该元素处的链表
        for (Entry<K,V> e = table[indexFor(hash, table.length)];
             e != null;
             e = e.next) {
            Object k;
            if (e.hash == hash && ((k = e.key) == key || key.equals(k)))
                return e.value;
        }
        return null;
}

put源码放上之前,有必要说一个知识点就是:HashMap是非线程安全的
3. 问题3 为什么HashMap是非线程安全的? 这个非安全的原因无非是并发下的put扩容删除数据即对数据的操作造成的,下面先看源码:

非线程安全原因一:put
public V put(K key, V value) {
        //如果table数组为空数组{},进行数组填充(为table分配实际内存空间),入参为threshold,此时threshold为initialCapacity 默认是1<<4(24=16)
        if (table == EMPTY_TABLE) {
            inflateTable(threshold);
        }
       //如果key为null,存储位置为table[0]或table[0]的冲突链上
        if (key == null)
            return putForNullKey(value);
        int hash = hash(key);//对key的hashcode进一步计算,确保散列均匀
        int i = indexFor(hash, table.length);//获取在table中的实际位置
        for (Entry<K,V> e = table[i]; e != null; e = e.next) {
        //如果该对应数据已存在,执行覆盖操作。用新value替换旧value,并返回旧value
            Object k;
            if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
                V oldValue = e.value;
                e.value = value;
                e.recordAccess(this);
                return oldValue;
            }
        }
        modCount++;
        addEntry(hash, key, value, i);
        return null;
    }

我们知道:当发生 hash 冲突的时候,HashMap 是采用链表的方式来解决的,在对应的数组位置存放链表的头结点。对链表而言,新加入的节点会从头结点加入。
现在假如 A 线程和 B 线程两个线程同时进行插入操作,然后计算出了相同的哈希值对应了相同的数组位置,因为此时该位置还没数据,然后对一个数组同一个位置,两个线程会同时得到现在的头结点,然后 A 写入新的头结点之后,B 也写入新的头结点,那B的写入操作就会覆盖 A 的写入操作造成 A 的写入操作丢失,即put造成的非线程安全。

非线程安全原因二:扩容

说扩容之前先看几个重要参数:

    //默认起始容量
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
    //最大扩容数量
    static final int MAXIMUM_CAPACITY = 1 << 30;
    //负载因子,代表了table的填充度有多少,默认是0.75,请注意看这一行
    static final float DEFAULT_LOAD_FACTOR = 0.75f;
    //这俩数就是上面提到的8和6,转换为树的阈值(临界值)
    static final int TREEIFY_THRESHOLD = 8;
    static final int UNTREEIFY_THRESHOLD = 6;
    //最小树形化容量阈值
    static final int MIN_TREEIFY_CAPACITY = 64;

扩容代码:

  final Node<K,V>[] resize() {
        Node<K,V>[] oldTab = table;
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        int oldThr = threshold;
        int newCap, newThr = 0;
        if (oldCap > 0) {
        // 超过最大值就不再扩充了
            if (oldCap >= MAXIMUM_CAPACITY) {
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            // 没超过最大值,就扩充为原来的2倍
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                newThr = oldThr << 1; // double threshold
        }
        else if (oldThr > 0) // initial capacity was placed in threshold
            newCap = oldThr;
        else {               // zero initial threshold signifies using defaults
            newCap = DEFAULT_INITIAL_CAPACITY;
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
        }
        if (newThr == 0) {
            float ft = (float)newCap * loadFactor;
            newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                      (int)ft : Integer.MAX_VALUE);
        }
        threshold = newThr;
        @SuppressWarnings({"rawtypes","unchecked"})
            Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
        table = newTab;
        if (oldTab != null) {
            for (int j = 0; j < oldCap; ++j) {
                Node<K,V> e;
                if ((e = oldTab[j]) != null) {
                    oldTab[j] = null;
                    if (e.next == null)
                        newTab[e.hash & (newCap - 1)] = e;
                    else if (e instanceof TreeNode)
                        ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                    else { // preserve order
                        Node<K,V> loHead = null, loTail = null;
                        Node<K,V> hiHead = null, hiTail = null;
                        Node<K,V> next;
                        do {
                            next = e.next;
                            if ((e.hash & oldCap) == 0) {
                                if (loTail == null)
                                    loHead = e;
                                else
                                    loTail.next = e;
                                loTail = e;
                            }
                            else {
                                if (hiTail == null)
                                    hiHead = e;
                                else
                                    hiTail.next = e;
                                hiTail = e;
                            }
                        } while ((e = next) != null);
                        if (loTail != null) {
                            loTail.next = null;
                            newTab[j] = loHead;
                        }
                        if (hiTail != null) {
                            hiTail.next = null;
                            newTab[j + oldCap] = hiHead;
                        }
                    }
                }
            }
        }
        return newTab;
    }

其中!多个线程同时操作,检测到总数量超过门限值的时候就会同时调用 resize 操作,各自生成新的数组并 rehash 后赋给该 map 底层的数组,结果最终只有最后一个线程生成的新数组被赋给该 map 底层,其他线程的均会丢失。A和B两个人同时对一个map进行扩容,A需要1000容量大小map在先,而B需要100大小的map,那么就会造成A的扩容结果失败。
这里有必要说一下就是:在jdk1.7的时候,HashMap解决hash冲突的时候采取的是头插法,这样在并发下,会造成

  1. 丢失数据
  2. 数组成环(假如有AB两个线程进行扩容,那么此时很容易造成:1->2,2->3,3->1的情况!)
非线程安全原因三:删除数据

源码:

    public V remove(Object key) {
        Node<K,V> e;
        return (e = removeNode(hash(key), key, null, false, true)) == null ?
            null : e.value;
    }
    final Node<K,V> removeNode(int hash, Object key, Object value,
                               boolean matchValue, boolean movable) {
        Node<K,V>[] tab; Node<K,V> p; int n, index;
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (p = tab[index = (n - 1) & hash]) != null) {
            Node<K,V> node = null, e; K k; V v;
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                node = p;
            else if ((e = p.next) != null) {
                if (p instanceof TreeNode)
                    node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
                else {
                    do {
                        if (e.hash == hash &&
                            ((k = e.key) == key ||
                             (key != null && key.equals(k)))) {
                            node = e;
                            break;
                        }
                        p = e;
                    } while ((e = e.next) != null);
                }
            }
            if (node != null && (!matchValue || (v = node.value) == value ||
                                 (value != null && value.equals(v)))) {
                if (node instanceof TreeNode)
                    ((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
                else if (node == p)
                    tab[index] = node.next;
                else
                    p.next = node.next;
                ++modCount;
                --size;
                afterNodeRemoval(node);
                return node;
            }
        }
        return null;
    }

同上面两个操作,当多个线程同时操作同一个数组位置的时候,也都会先取得现在状态下该位置存储的头结点,然后各自去进行计算操作,之后再把结果写会到该数组位置去,其实写回的时候可能其他的线程已经就把这个位置给修改过了,就会覆盖其他线程的修改。
并发情况下要实现线程安全,可以采用:

  1. Hashtable
  2. 通过Collections.synchronizedMap()返回一个新的Map,这种方法底层源码上实现的是synchronize关键字+一个mutex即信号量,底层维护了一个用synchronize关键字加锁的Map
  3. ConcurrentHashMap
并发下应该用ConcurrentHashMap 摒弃Hashtable

因为HashTable操作十分繁重,每个线程,每个操作都用synchronize(悲观锁),以后博主会出一篇博客和大家一块研究一下ConcurrentHashMap ,其实主要的是jdk1.7ConcurrentHashMap用的是分段锁+volatile关键字来保持其内存可见性,而jdk1.8用的是CAS操作(乐观锁)+synchronize关键字。

HashMap&与Hashtable的区别

1. 作者不同

是不是很狗血但是就是作者不同啊

Hashtable:
在这里插入图片描述
HashMap
在这里插入图片描述

2. 是否符合驼峰命名法

很明显HashMap符合驼峰命名法,Hashtable不符合,我没有打错字!

3. 继承的父类不同

Hashtable

public class Hashtable<K,V>
extends Dictionary<K,V>
implements Map<K,V>, Cloneable, java.io.Serializable

HashMap

public class HashMap<K,V> extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable

4. 初始化时机不同

Hashtable是在构造函数初始化,而HashMap是在第一次put()初始化hash数组。
Hashtable

//HashTable构造器 
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];//初始化Hash数组
        threshold = (int)Math.min(initialCapacity * loadFactor, MAX_ARRAY_SIZE + 1);
        initHashSeedAsNeeded(initialCapacity);
    }

HashMap

//hashMap的put函数 
public V put(K key, V value) {
        if (table == EMPTY_TABLE) {
            inflateTable(threshold);//初始化Hash数组
        }
        if (key == null)
            return putForNullKey(value);
        int hash = hash(key);
        int i = indexFor(hash, table.length);
        for (Entry<K,V> e = table[i]; e != null; e = e.next) {
            Object k;
            if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
                V oldValue = e.value;
                e.value = value;
                e.recordAccess(this);
                return oldValue;
            }
        }
 
        modCount++;
        addEntry(hash, key, value, i);
        return null;
    }

5. 默认大小和扩容方式不同

在HashTable中,hash数组默认大小是11,增加的方式是原来的2 + 1。在HashMap中,hash数组默认大小是16,增加的方式是2原来的而且一定是2的整数(这个在前面有说过)。

6. 是否允许非空键值

HashMap允许空键值,而HashTable不允许。所以我们在使用HashMap get到的键或者值为null的时候,不能判断该键值不存在!

7. hash值的使用不同

即计算数组下角标方式不同
Hashtable:

int hash = key.hashCode();
int index = (e.hash & 0x7FFFFFFF) % newCapacity;
//注意这里是直接调用的Object超类里面的hashCode

HashMap:

    static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }
8. 内部方法不同

HashMap把Hashtable的contains()方法去掉了,改成了containsvalue()和containsKey()。
我就不列出代码了有点多了

9. 线程安全性不同

Hashtable的方法是线程安全的,而HashMap不支持线程的同步,不是线程安全的。

10. 迭代器不同

Hashtable使用Enumeration,HashMap使用Iterator。这个是快速失败的(fail-fast)还有一种失败方式是安全失败(fail-safe)值得一提的是java.util包下面的所有的集合类都是快速失败的,而java.util.concurrent包下面的所有的类都是安全失败的

  1. fail-fast:快速失败:当多个线程进行操作时,若其中某一个线程通过iterator去遍历集合时,该集合的内容被其他线程所改变;则会抛出ConcurrentModificationException异常。其底层维护了一个modCount数,若非预期值,则报错。
  2. fail-safe:安全失败:采用安全失败机制的集合容器,在遍历时不是直接在集合内容上访问的,而是先复制原有集合内容,在拷贝的集合上进行遍历。这样就不会直接扔出一个异常了

快速失败和安全失败是对迭代器而言的。并发环境下建议使用java.util.concurrent 包下的容器类,除非没有修改操作。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值