03-HashMap

HashMap

1、HashMap的主要参数都有哪些?

  • 初始化桶容量,默认16,
  • 负载系数,默认0.75
  • 最大容量,2的30次方
  • 扩容阈值,默认容量*负载系数,当元素个数达到阈值,再put进来元素,就会触发扩容
  • table数组,

2、HashMap 的数据结构?

  • 1.7是数组 + 链表,实体的数据结构是k-v,hash,next引用
  • 1.8是数组+链表/红黑树

3、hash的计算规则?

  • JDK8中使用扰动函数,32位hashCode中,高位不变,然后将高16位和低16位进行异或运算的结果作为低16位,最后的结果就是hash值,hash对长度减一做取余操作定位对应的桶;
  • JDK7中稍微有点不一样

4、hash碰撞和解决方法?

  • hash碰撞就是两个key通过hash值计算桶的位置的index,得到相同的index,这就是hash碰撞
  • 解决hash碰撞,采用链地址法,jdk8中链的元素大于等于8个会转换为红黑树,小于等于6个则转换为链表。另外hash值计算时的扰动函数也可以减少hash碰撞

5、关于扩容

5.1 为什么扩容是以2的幂次?

  • 为了便于hash计算后定位到桶,hash值对2的幂次取余在计算上是对该数-1进行按位与运算,效率高。(hash % len = hash | len-1 )
  • 另外2倍扩容,可以减少resize的数据重分配。部分元素不需要移动,比如原来16,位于hash为21的在5的位置,扩容之后就在21的位置了,如果hash37原本也是在5的位置,扩容之后还是在5的位置,这样有助于分散处于一个桶中的多个元素,

5.2 HashMap的扩容时机,什么时候会进行rehash?

  • 都是在插入元素的时候。不过稍微有点不一样,代码中1.8是在插入完成之后会检查扩容,1.7则是扩容之后,再头插法新增节点。扩容的思路都类似,就是扩大二倍,如果达到了最大值则不会扩容了,扩容的条件是元素个数达到阈值;

6、存取

6.1 HashMap put的过程

  • 定位到桶,如果桶为空则直接插入,如果不为空,1.7 以前是在链表进行头插法,在1.8是进行尾插法,1.8还会进行链表和红黑树转换的阈值判断,检查是否需要转换为红黑树。
    另外在1.8中增加了putOnlyIfAbsent 的功能,在1.7中没有
//1.8
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        //1.扩容
        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) {
                    //如果遍历的过程未找到,就会到链表最后一个位置,因此1.8是尾插法
                    if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);
                        //如果大于等于8就转为红黑树,binCount大于等于7的话,链表总元素个数就大于等于8了
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            treeifyBin(tab, hash);
                        break;
                    }
                    //如果遍历的过程中找到了,就会在这里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;
                //考虑onlyIfAbsent的情况
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                //返回旧元素
                return oldValue;
            }
        }
        ++modCount;
        //扩容
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }


//1.7
    public V put(K key, V value) {
        if (table == EMPTY_TABLE) {
            inflateTable(threshold);
        }
        //处理key为null
        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;
            //如果找到一样的key,那就替换,返回
            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;
    }
    
 void addEntry(int hash, K key, V value, int bucketIndex) {
        //检查扩容
        if ((size >= threshold) && (null != table[bucketIndex])) {
            resize(2 * table.length);
            //重新计算hash
            hash = (null != key) ? hash(key) : 0;
            bucketIndex = indexFor(hash, table.length);
        }

        createEntry(hash, key, value, bucketIndex);
    }
    
    //这里面能够看出来是头插法,原本的桶中元素作为next赋值给了新的节点
     void createEntry(int hash, K key, V value, int bucketIndex) {
        Entry<K,V> e = table[bucketIndex];
        table[bucketIndex] = new Entry<>(hash, key, value, e);
        size++;
    }

6.2 HashMap的get过程

  • 先得到key的hash值,再把这个hash值与length-1按位与(取余),得到table数组的下标。取出这个下标值的key,与传入的key比较,如果相同那就是这个了。如果不同呢,那就沿着这个单向链表向后找,直到找到或找到结束也找不到,找不到返回null。

7、HashMap初始化传入的容量参数的值就是HashMap实际分配的空间么?

  • 不是,空间是2的幂次,不管传入的数字是什么,最后会转成一个大于该数字的2的幂次。且构造之后不会分配空间,会在第一次put元素的时候存入
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;
    }
//方法执行的效果就是将这个数字的所有位都置为1,最后加上1,就得到了2的幂次,并且数字不会过大,因为都是把这个数字的地位置为1了

8、为什么String, Interger这样的wrapper类适合作为键?

  • 一个是因为不可变,另一个是因为已经重写了hashcode和equals方法
  • String, Interger这样的wrapper类作为HashMap的键是再适合不过了,而且String最为常用。因为String是不可变的,也是final的,而且已经重写了equals()和hashCode()方法了。其他的wrapper类也有这个特点。不可变性是必要的,因为为了要计算hashCode(),就要防止键值改变,如果键值在放入时和获取时返回不同的hashcode的话,那么就不能从HashMap中找到你想要的对象。不可变性还有其他的优点如线程安全。如果你可以仅仅通过将某个field声明成final就能保证hashCode是不变的。因为获取对象的时候要用到equals()和hashCode()方法,那么键对象正确的重写这两个方法是非常重要的。如果两个不相等的对象返回不同的hashcode的话,那么碰撞的几率就会小些,这样就能提高HashMap的性能。

9、自定义对象做key什么要重写hashcode方法和equals方法?如果都不重写会怎么样?如果值重写一个会怎么样?

  • 假设我们使用自定义对象作为key,new了两个一模一样的对象A和B,先用map.put(A,“123”),再使用map.get(B),肯定不能获取到“123”,因为这两个对象的hash值不等且equals也是返回false。但是我们肯定期望能够获取到123,因此我们需要重写这两个方法,让他们在属性一致的时候,hash值一致且equals为true,这样才能满足需求。
  • 如果重写了hash,equals不一样,那么在get的时候,因为二者的equals为false,因此也是get不到的,
  • 如果重写了equals,没有重写hash,那么AB的hash值不一样,那么也是get不到的,从源码我们可以看到不管是put还是get,判断key一致的条件是hash值首先要一样,然后要么equals为true,要么是一个对象(==wei true)
//key一致的条件首先要hash一致,其次要equals为true或者==为true,二者是必须同时满足的
if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
  • 结合HashMap的原理这里我们也可以看出我们我们不但要重写hashcode方法,还要尽量降低hashcode方法的冲突

10、HashMap的key是否可以为null

  • key和value都可以为null

11、HashMap的复杂度

  • HashMap整体上性能都非常不错,但是不稳定,为O(N/Buckets),N就是以数组中没有发生碰撞的元素。
  • 新增查找获取的复杂度都是O(N/Buckets),设计良好的话接近一O(1)
  • 如果某个桶中的链表记录过大的话(大于等于8),就会把这个链动态变成红黑二叉树,使查询最差复杂度由O(N)变成了O(logN)

12、HashMap在JDK7和8的区别

  • https://my.oschina.net/hosee/blog/618953
  • https://blog.csdn.net/qq_36520235/article/details/82417949
对比维度1.71.8
数据结构数组 + 链表数组 + 链表/红黑树
插入链表头插法尾插法
扩容计算hash不计算hash要么不动要么往后移动扩容的大小(通过和旧容量与运算得到)。
hash计算9次扰动处理(4次位运算 + 5次异或)2次扰动处理( 1次位运算 + 1次异或)
//1.7
final int hash(Object k) {
        int h = 0;
        if (useAltHashing) {
            if (k instanceof String) {
                return sun.misc.Hashing.stringHash32((String) k);
            }
            h = hashSeed;
        }
        h ^= k.hashCode();
        h ^= (h >>> 20) ^ (h >>> 12);
        return h ^ (h >>> 7) ^ (h >>> 4);
    }

//1.8
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);

13、我们能否让HashMap同步?

  • Map m = Collections.synchronizedMap(map);思想很简单,返回的是一个SynchronizedMap,该类实现了Map接口,因此可以直接调用Map接口的方法,它内部持有真正的非线程安全的Map和一把锁(其实就是一个Object对象),然后调用的时候都加锁,再调用内部Map的方法,让所有方法变成了同步方法,方式其实并不是很可取,推荐使用CocurrentHashMap。

14、HashMap 和 HashTable有何不同?

  • HashTable的相关方法都是synchronized同步的get方法也是,比如get、put,putAll,remove、size、isEmpty、keys、contains、containsKey、clear、forEach等,甚至toString和equals都是同步方法,我们看到很多只读方法也是同步的,其实读取方法有时候没有必要同步,这样让它性能很低,比如单线程写多线程读取的时候我可以用HashMap,但是用HashTable多线程get都要排队,性能低。
  • HashTable无论key或value都不能为null,HashMap只能允许一个key为null,可以运行多个value为null。而且HashTable是线程安全的,HashMap是线程不安全的。

15、JDK1.8中对 HashMap做了哪些性能优化?

  • 主要是:链表和红黑树的转换,阈值分表为6和8
  • 旧版本的HashMap存在一个问题,即使负载因子和Hash算法设计的再合理,也免不了会出现拉链过长的情况,一旦出现拉链过长,则会严重影响HashMap的性能。于是,在JDK1.8版本中,对数据结构做了进一步的优化,引入了红黑树。而当链表长度太长(TREEIFY_THRESHOLD默认超过8)时,链表就转换为红黑树,利用红黑树快速增删改查的特点提高HashMap的性能(O(logn))。当长度小于(UNTREEIFY_THRESHOLD默认为6),就会退化成链表。
  • 扩容时,1.7需要重新计算hash,1.8不需要,并且要么元素不动,要么元素移动一个2的N次方
  • 参考

16、高并发下HashMap为什么会线程不安全?

  • JDK 1.7 下死循环(头插法导致)
  • JDK 1.8 下值覆盖(尾插法规避了死循环,但是会出现值覆盖),两个线程对同一个位置进行put操作,对应桶为null且hash冲突时,线程A put完之后,线程B解挂后再操作时,因B之前已经判断过hash,就不判断直接写入

参考:HashMap 线程不安全的体现

  • 下面是我给出的一个简单的1.8下值覆盖的例子,hashmap保存的键值对中键和值都是一样的,理论上线程安全就会有10万个键值对,由于线程不安全,少于10万个,并且打印出键值不等的键值对
public class HashMapTest {

    public static final CountDownLatch latch = new CountDownLatch(5);

    public static void main(String[] args) throws InterruptedException {
        HashMapThread thread1 = new HashMapThread("HashMap-TestThread-1");
        HashMapThread thread2 = new HashMapThread("HashMap-TestThread-2");
        HashMapThread thread3 = new HashMapThread("HashMap-TestThread-3");
        HashMapThread thread4 = new HashMapThread("HashMap-TestThread-4");
        HashMapThread thread5 = new HashMapThread("HashMap-TestThread-5");
        thread1.start();
        thread2.start();
        thread3.start();
        thread4.start();
        thread5.start();
        latch.await();
        System.out.println("size: " + HashMapThread.map.size());
        for (Integer i : HashMapThread.map.keySet()) {
            if (!HashMapThread.map.get(i).equals(i)) {
                System.out.println(i + "  》》 " + HashMapThread.map.get(i));
            }
        }
    }
}


public class HashMapThread extends Thread {

    private static AtomicInteger count = new AtomicInteger();
    public static Map<Integer, Integer> map = new HashMap<>();

    public HashMapThread(String name) {
        super(name);
    }

    @Override
    public void run() {
        while (count.get() < 1000000) {
            map.put(count.get(), count.get());
            count.incrementAndGet();
        }
        HashMapTest.latch.countDown();
    }
}
  • 部分打印如下,可以看到只有不到6万个键值对,每次都不一样,而且被覆盖的值和原本正确的值相差只有一两个数,因为原子变量是递增的,这体现出了HashMap的值覆盖问题
size: 597274
786435  》》 786437
786495  》》 786496
786504  》》 786506
786505  》》 786506
786516  》》 786517
786514  》》 786515
786532  》》 786533
786555  》》 786556
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值