HashMap

解决hash冲突的方法:

  1. 开放定址法(线性探测再散列,二次探测再散列,伪随机探测再散列)
  2. 再哈希法
  3. 链地址法

问题引入:

1.HashMap的源码,实现原理,JDK8对HashMap做了怎样的优化?

2.HaspMap扩容是怎样扩容的,为什么都是2的N次幂的大小。    

       Hashmap,两次哈希,第一次直接调用 key 的 hashcode 方法,第二次再调用一个函即hash= hash(key.hashcode()),此方法加入高位计算,防止低位不变高位变化时造成的冲突。

3.HashMap,HashTable,ConcurrentHashMap的区别。

4.极高并发下HashTable和ConcurrentHashMap哪个性能更好,为什么,如何实现的。

5.HashMap在高并发下如果没有处理线程安全会有怎样的安全隐患,具体表现是什么。

6.为什么Map桶中个数超过8才转为红黑树?答案:http://cmsblogs.com/?p=4374,简单就是泊松分布,达到8的这个概率非常低。

7.HashMap为什么要设置初始容量,初始容量设置为多少比较合适?

答:默认情况下,HashMap的容量16,但在实际情况中,建议我们在构造函数中设置HashMap的初始容量,主要是为了防止扩容而引起的性能下降,尤其是当hashmap中个数很多的时候,扩容是非常花时间的。

在已知需要存储的个数以后,initialCapacity=(需要存储的元素个数/负载因子)+1.然后在构造函数源码中会再转换成2^n次方。

https://www.hollischuang.com/archives/2431

8.32位的hashmap,如果存储的元素都集中在后面16位,源码中是如何解决的?未解决

9.hashmap和数组谁的查询快?首先hashmap在插入和删除一定更快,其次,仅仅按下标,数组快,如果按键来查,hashmap快,可以很快映射到哪一个快。

10.hashset和hashmap谁的查询效率更好?

底层数据结构:

JDK1.8之前:

JDK1.8 之前 HashMap 底层数据结构是 数组和链表 结合在一起使用也就是 链表散列。HashMap 通过 key 的 hashCode 经过扰动函数处理过后得到 hash 值,然后通过 (n - 1) & hash 判断当前元素存放的位置(这里的 n 指的是数组的长度),如果当前位置存在元素的话,就判断该元素与要存入的元素的 hash 值以及 key 是否相同,如果相同的话,直接覆盖,不相同就通过拉链法解决冲突。

JDK1.8 在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为8)时,将链表转化为红黑树,以减少搜索时间。

public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable {
    // 序列号
    private static final long serialVersionUID = 362498820763181265L;    
    // 默认的初始容量是16
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;   
    // 最大容量
    static final int MAXIMUM_CAPACITY = 1 << 30; 
    // 默认的填充因子
    static final float DEFAULT_LOAD_FACTOR = 0.75f;
    // 当桶(bucket)上的结点数大于这个值时会转成红黑树
    static final int TREEIFY_THRESHOLD = 8; 
    // 当桶(bucket)上的结点数小于这个值时树转链表
    static final int UNTREEIFY_THRESHOLD = 6;
    // 桶中结构转化为红黑树对应的table的最小大小
    static final int MIN_TREEIFY_CAPACITY = 64;
    // 存储元素的数组,总是2的幂次倍
    transient Node<k,v>[] table; 
    // 存放具体元素的集
    transient Set<map.entry<k,v>> entrySet;
    // 存放元素的个数,注意这个不等于数组的长度。
    transient int size;
    // 每次扩容和更改map结构的计数器
    transient int modCount;   
    // 临界值 当实际大小(容量*填充因子)超过临界值时,会进行扩容
    int threshold;
    // 加载因子
    final float loadFactor;
}

putMapEntries:可以插入一个集合

final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
    int s = m.size();//其中元素的个数
    if (s > 0) {
        // 判断table是否已经初始化
        if (table == null) { // pre-size
            // 未初始化,s为m的实际元素个数
            float ft = ((float)s / loadFactor) + 1.0F;//计算容量
            int t = ((ft < (float)MAXIMUM_CAPACITY) ?
                    (int)ft : MAXIMUM_CAPACITY);
            // 计算得到的t大于阈值,则初始化阈值
            if (t > threshold)
                threshold = tableSizeFor(t);//这个主要是为了保证hashmap的容量总是2^n次方
        }
        // 已初始化,并且m元素个数大于阈值,进行扩容处理
        else if (s > threshold)
            resize();
        // 将m中的所有元素添加至HashMap中
        for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) {
            K key = e.getKey();
            V value = e.getValue();
            putVal(hash(key), key, value, false, evict);//把每一个都插入进去
        }
    }
}

putVal:把每一个元素插入hash表中

具体的过程:

①如果定位到的数组位置没有元素 就直接插入。

②如果定位到的数组位置有元素就和要插入的key比较,如果key相同就直接覆盖,如果key不相同,就判断p是否是一个树节点,如果是就调用e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value)将元素添加进入。如果不是就遍历链表插入。

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,boolean evict) {
    
Node
<K,V>[] tab;
Node<K,V> p; int n, i; // table未初始化或者长度为0,进行扩容,并保存默认的长度 if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length;
// (n - 1) & hash 确定元素存放在哪个桶中,桶为空,新生成结点放入桶中(此时,这个结点是放在数组中) if ((p = tab[i = (n - 1) & hash]) == null)//当前这个节点没有值,直接插入 tab[i] = newNode(hash, key, value, null); // 桶中已经存在元素 else { Node<K,V> e; K k; // 比较桶中第一个元素(数组中的结点)的hash值相等,key相等,就可以保证一定是同一个对象那么就可以覆盖 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; } // 出现了是同一个节点的情况,这里的e其实已经保存了这个节点了,不用再次保存,如果出现直接break if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) // 相等,跳出循环 break; // 用于遍历桶中的链表,与前面的e = p.next组合,可以遍历链表 p = e; } } // 不为null说明这个hashmap里面出现了不是同一个对象的值,就直接覆盖值就好了,如果为null,在上面的操作中已经加进去了 if (e != null) { // 记录e的value V oldValue = e.value; // onlyIfAbsent为false或者旧值为null if (!onlyIfAbsent || oldValue == null) //用新值替换旧值 e.value = value; // 访问后回调 afterNodeAccess(e); // 返回旧值 return oldValue; } } // 结构性修改 ++modCount; // 实际大小大于阈值则扩容 if (++size > threshold) resize(); // 插入后回调 afterNodeInsertion(evict); return null; }

关于插入树节点:

putTreeVal:(不同的hash值是可以映射到同一个桶里),记住红黑树结构这里还维持了一个双向链表。所以在插入的时候还做了双向链表的操作。

final TreeNode<K,V> putTreeVal(HashMap<K,V> map, Node<K,V>[] tab,int h, K k, V v) {
    Class<?> kc = null; // 定义k的Class对象
    boolean searched = false;        // 标识是否已经遍历过一次树,未必是从根节点遍历的,但是遍历路径上一定已经包含了后续需要比对的所有节点。
    TreeNode<K,V> root = (parent != null) ? root() : this; // 父节点不为空那么查找根节点,为空那么自身就是根节点
    for (TreeNode<K,V> p = root;;) { // 从根节点开始遍历,没有终止条件,只能从内部退出
        int dir, ph; K pk;           // 声明方向、当前节点hash值、当前节点的键对象
        if ((ph = p.hash) > h)       // 如果当前节点hash 大于 指定key的hash值
            dir = -1;                // 要添加的元素应该放置在当前节点的左侧
        else if (ph < h)             // 如果当前节点hash 小于 指定key的hash值
            dir = 1;                 // 要添加的元素应该放置在当前节点的右侧
        else if ((pk = p.key) == k || (k != null && k.equals(pk))) //一定是同一个对象那么返回,指向的是同一个对象
            return p;//到这里说明hash值相等,那么既不是指向同一个对象,也没有重写equals方法使之相等。
        else if ((kc == null &&(kc = comparableClassFor(k)) == null) ||//没有实现comparable接口
                    (dir = compareComparables(kc, k, pk)) == 0) {      //实现了这个接口,并且比较的时候还相等,只是内容相等,其实并不是同一个对象,这种情况是允许插入到hashmap里面的,只是这些内容相同的节点都连在一起,在它下面可能存在是一个对象的节点
            if (!searched) {         // 如果还没有比对过当前节点的所有子节点
                TreeNode<K,V> q, ch; // 定义要返回的节点、和子节点
                searched = true;     // 标识已经遍历过一次了
                /*
                 * 红黑树也是二叉树,所以只要沿着左右两侧遍历寻找就可以了
                 * 这是个短路运算,如果先从左侧就已经找到了,右侧就不需要遍历了
                 * find 方法内部还会有递归调用。参见:find方法解析
                 */
                if (((ch = p.left) != null &&
                        (q = ch.find(h, k, kc)) != null) ||
                    ((ch = p.right) != null &&
                        (q = ch.find(h, k, kc)) != null))
                    return q; // 找到了指定key键对应的
            }
 
            //仍然没有找到相等的节点,那么就进行最后一道比较 
//先比较两个对象的类名,类名是字符串对象,就按字符串的比较规则
//如果两个对象是同一个类型,那么调用本地方法为两个对象生成hashCode值,再进行比较,hashCode相等的话返回-1,所以最后一个函数无论能不能比较出结果都要确定方向

dir = tieBreakOrder(k, pk);
        }
 
        TreeNode<K,V> xp = p; // 定义xp指向当前节点
        /*
        * 如果dir小于等于0,那么看当前节点的左节点是否为空,如果为空,就可以把要添加的元素作为当前节点的左节点,如果不为空,还需要下一轮继续比较
        * 如果dir大于等于0,那么看当前节点的右节点是否为空,如果为空,就可以把要添加的元素作为当前节点的右节点,如果不为空,还需要下一轮继续比较
        * 如果以上两条当中有一个子节点不为空,这个if中还做了一件事,那就是把p已经指向了对应的不为空的子节点,开始下一轮的比较
        */
        if ((p = (dir <= 0) ? p.left : p.right) == null) {  
            // 如果恰好要添加的方向上的子节点为空,此时节点p已经指向了这个空的子节点
            Node<K,V> xpn = xp.next; // 获取当前节点的next节点
            TreeNode<K,V> x = map.newTreeNode(h, k, v, xpn); // 创建一个新的树节点
            if (dir <= 0)
                xp.left = x;  // 左孩子指向到这个新的树节点
            else
                xp.right = x; // 右孩子指向到这个新的树节点
            xp.next = x; // 链表中的next节点指向到这个新的树节点
            x.parent = x.prev = xp; // 这个新的树节点的父节点、前节点均设置为 当前的树节点
            if (xpn != null) // 如果原来的next节点不为空
                ((TreeNode<K,V>)xpn).prev = x; // 那么原来的next节点的前节点指向到新的树节点
            moveRootToFront(tab, balanceInsertion(root, x));// 重新平衡,以及新的根节点置顶
            return null; // 返回空,意味着产生了一个新节点
        }
    }
}

hashmap中的红黑树最先应该是按hash值来排序的,然后实现了conparable接口就按就按接口里面的方式来比较,比较仍然相等或者根本就没有实现接口就按对象类名按字符串的大小来比较。

 

关于查一个节点:getNode:

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 && // 第一个节点就是直接返回
            ((k = first.key) == key || (key != null && key.equals(k))))
            return first;
        // 桶中不止一个节点
        if ((e = first.next) != null) {
            // 在树中get
            if (first instanceof TreeNode)
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);//二插法来查,时间复杂度log2(N)
            // 在链表中get
            do {
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;//从头往后查
            } while ((e = e.next) != null);
        }
    }
    return null;
}

删除一个节点(保存前一个节点值)

 

public V remove(Object key) {
            if (key == null) {
                return removeNullKey();
            }
            int hash = secondaryHash(key.hashCode());
            HashMapEntryGac<K, V>[] tab = table;
            int index = hash & (tab.length - 1);
            for (HashMapEntryGac<K, V> e = tab[index], prev = null;
                    e != null; prev = e, e = e.next) {
                if (e.hash == hash && key.equals(e.key)) {
                    if (prev == null) {
                        tab[index] = e.next;
                    } else {
                        prev.next = e.next;
                    }
                    modCount++;
                    size--;
                    //postRemove(e);
                    return e.value;
                }
            }
            return null;
        }

关于扩容resize:每次扩容都会重新进行一次hash分配:

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 { 
        // signifies using defaults
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    // 计算新的resize上限
    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) { // 把每个bucket都移动到新的buckets中 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 {
//记住resize这个地方jdk1.7采用的是头插法,在多线程的情况下会出现循环指针,所以在jdk1.8里面我们采用了尾插法 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; } // 原索引+oldCap else { if (hiTail == null) hiHead = e; else hiTail.next = e; hiTail = e; } } while ((e = next) != null); // 原索引放到bucket里 if (loTail != null) { loTail.next = null; newTab[j] = loHead; } // 原索引+oldCap放到bucket里 if (hiTail != null) { hiTail.next = null; newTab[j + oldCap] = hiHead; } } } } } return newTab; }

关于扩容,就是每次容量和阀值都扩充为两倍,然后总是为2^n主要原因是

      1.我们在对hash值取模的时候是(length-1)&hash这样的方式,如果不是2^n次方的话会导致分配不随机的问题,假如length=15,length-1=1110,机会导致末尾为1的数永远不能被entry占领。造成浪费。

      2.观察源码我们发现为2^n时在我们扩容的时候是非常方便的,我们只需要比较n-1多一位,也就是1111后面一位就是10000,第五个这个是不是1,是1我们新的链表就可以插入到entry[16+index]这个位置,不是1的话就可以插入到原位置。

HashMap,HashTable,ConcurrentHashMap的区别。

HashMap和HashTable的区别:

  1. 线程是否安全: HashMap 是非线程安全的,HashTable 是线程安全的;HashTable 内部的方法基本都经过synchronized 修饰。(如果你要保证线程安全的话就使用 ConcurrentHashMap 吧!);
  2. 效率: 因为线程安全的问题,HashMap 要比 HashTable 效率高一点。另外,HashTable 基本被淘汰,不要在代码中使用它;
  3. 对Null key 和Null value的支持: HashMap 中,null 可以作为键,这样的键只有一个,可以有一个或多个键所对应的值为 null。。但是在 HashTable 中 put 进的键值只要有一个 null,直接抛出 NullPointerException。
  4. 初始容量大小和每次扩充容量大小的不同 : ①创建时如果不指定容量初始值,Hashtable 默认的初始大小为11,之后每次扩充,容量变为原来的2n+1。HashMap 默认的初始化大小为16。之后每次扩充,容量变为原来的2倍。②创建时如果给定了容量初始值,那么 Hashtable 会直接使用你给定的大小,而 HashMap 会将其扩充为2的幂次方大小(HashMap 中的tableSizeFor()方法保证,下面给出了源代码)。也就是说 HashMap 总是使用2的幂作为哈希表的大小,后面会介绍到为什么是2的幂次方。
  5. 底层数据结构: JDK1.8 以后的 HashMap 在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为8)时,将链表转化为红黑树,以减少搜索时间。Hashtable 没有这样的机制。

极高并发下HashTable和ConcurrentHashMap哪个性能更好,为什么,如何实现的。

底层数据结构: JDK1.7的 ConcurrentHashMap 底层采用 分段的数组+链表 实现,JDK1.8 采用的数据结构跟HashMap1.8的结构一样,数组+链表/红黑二叉树。Hashtable 和 JDK1.8 之前的 HashMap 的底层数据结构类似都是采用 数组+链表的形式,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的;

实现线程安全的方式(重要): ① 在JDK1.7的时候,ConcurrentHashMap(分段锁) 对整个桶数组进行了分割分段(Segment),每一把锁只锁容器其中一部分数据,多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。 到了 JDK1.8 的时候已经摒弃了Segment的概念,而是直接用 Node 数组+链表+红黑树的数据结构来实现,并发控制使用 synchronized 和 CAS 来操作。(JDK1.6以后 对 synchronized锁做了很多优化) 整个看起来就像是优化过且线程安全的 HashMap,虽然在JDK1.8中还能看到 Segment 的数据结构,但是已经简化了属性,只是为了兼容旧版本;② Hashtable(同一把锁) :使用 synchronized 来保证线程安全,效率非常低下。当一个线程访问同步方法时,其他线程也访问同步方法,可能会进入阻塞或轮询状态,如使用 put 添加元素,另一个线程不能使用 put 添加元素,也不能使用 get,竞争会越来越激烈效率越低。

HashMap在高并发下如果没有处理线程安全会有怎样的安全隐患,具体表现是什么。

在jdk1.8之前两个线程同时插入扩容的时候会出现循环链的问题,导致get方法永远执行,这个问题主要是之前扩容的时候采用的头插法来插入,jdk之后采用尾插法改变了这个问题。

参考:https://blog.csdn.net/zhuqiuhui/article/details/51849692

但是还是会有多个线程put的时候导致元素丢失的问题,就是两个元素添加的元素添加到一个同一个bucket里面并且key相等就可能会导致前一个数据被覆盖。

 

额外附加:

1.对于hashmap里面常用的函数要了解:

在entry这个单链表上的每一个节点可以有这些函数操作getKey(), getValue(), setValue(V value), equals(Object o)【这个函数要保证key和value都相等】, hashCode()【这个需要去看源码实现方式】。

https://www.cnblogs.com/skywang12345/p/3310835.html

 

2.Hashmap默认是无序的。

对于LinkedHashmap,主要是维持了一个双向队列,同时它的顺序是按照你的插入顺序,如果要按插入的顺序排列:LinkedHashmap在实际中最大的应用就是LRU算法。

Map<Integer, String> paramMap = new LinkedHashMap <Integer, String>();//在put函数时,若出现和前面相同的键,键的值改变,顺序还是按之前的,并不是插到最后

当你想定制hashmap按照键或者值的增大减小来排序:

参考:

https://blog.csdn.net/xifeijian/article/details/46522531

3.WeakHashMap:WeakHashMap 特殊之处在于 WeakHashMap 里的entry可能会被垃圾回收器自动删除,也就是说即使你没有调用remove()或者clear()方法,它的entry也可能会慢慢变少。所以多次调用比如isEmpty,containsKey,size等方法时可能会返回不同的结果。

1、WeakHashMap中的Entry为什么会自动被回收。

     WeakHashMap中的key是间接保存在弱引用中的,所以当key没有被继续使用时,就可能会在GC的时候被回收掉。

2、WeakHashMap与HashMap的区别是什么。

      在JDK8中,当发生较多key冲突的时候,HashMap中会由链表转为红黑树,而WeakHashMap则一直使用链表进行存储。WeakHashMap多了一个ReferenceQueue的队列,用来存放那些已经被回收了的弱引用对象。

3、WeakHashMap的引用场景有哪些。

      由于WeakHashMap可以自动清除Entry,所以比较适合用于存储非必需对象,用作缓存非常合适。

 

4.用hashmap替代redis会有哪些问题?

1,redis 数据可持久化保存,有些缓存你想重启程序后还能继续使用,map实现不了

2,redis 可以实现分布式部署,只要涉及到多台多进程啥的,map实现不了

3,hashmap不是线程安全的(并且:多线程同时调用hashMap的resize方法后,后续调用get方法时,可能进入死循环),可以考虑concurrentHashmap

为什么要用redis而不用map做缓存?
Redis 可以用几十 G 内存来做缓存,Map 不行,一般 JVM 也就分几个 G 数据就够大了

Redis 的缓存可以持久化,Map 是内存对象,程序一重启数据就没了

Redis 可以实现分布式的缓存,Map 只能存在创建它的程序里

Redis 缓存有过期机制,Map 本身无此功能

参考:

https://www.imooc.com/article/253087

posted @ 2019-02-22 17:23  LeeJuly  阅读(184)  评论(0)    收藏  举报