HashMap与ConcurrentHashMap

HashMap是我们开发中经常使用。面试官也总喜欢问关于HashMap的实现原理,那么咱们就用一篇文章来说明一下。

关于HashMap的底层结构,那么必须要说的就是

JDK1.7及之前,采用的是数组+链表
JDK1.8及之后采用数组+链表 或者数组+红黑树的方式进行元素存储

首先贴一段我们常用的代码

String key = "keyTest";
Map<String, String> hashMap = new HashMap<>();
hashMap.put(key,"valueTest");
hashMap.get(key);

还是先说一下整体逻辑。创建一个HashMap容器后,指定数组和扩容因子大小,或使用默认值。hashmap中具体数组的创建是在put中进行的。执行put方法插入一条数据,如果数组为空,就根据指定的数组大小或默认大小来创建Node数组,根据传入的key,计算出数组下标位置,如果下标位置为空就直接插入,如果为空,就直接插入;如果不为空判断是不是红黑树,如果是树就插入数据。如果不是树就遍历链表,存在相同key就覆盖value,否则插入链表尾节点,然后判断当前链表是否满足转红黑树条件,如果满足就转红黑树。最后判断数据条数是否大于需要扩容的数量,如果满足,进行扩容
先创建一个没指定长度的HashMap对象,那么底层是如何做的呢?

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

使用无参构造创建对象,所有属性均使用默认值
我们来看一下

//默认数组长度16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

//数组最大的长度
static final int MAXIMUM_CAPACITY = 1 << 30;

//加载因子,当数组中数据达到 DEFAULT_INITIAL_CAPACITY * DEFAULT_LOAD_FACTOR的数量时,容器就要进行扩容
static final float DEFAULT_LOAD_FACTOR = 0.75f;

//链表转红黑树临界值,就是当数组某个节点的链表长度大于等于当前值,就需要将链表转为红黑树
static final int TREEIFY_THRESHOLD = 8;

//红黑树转链表临界值,就是当红黑树中节点小于等于当前值时,需要退化成链表
static final int UNTREEIFY_THRESHOLD = 6;

//链表转红黑树的数组长度临界值
static final int MIN_TREEIFY_CAPACITY = 64;
//HashMap中数据实际存储位置
transient Node<K,V>[] table;

然后从put方法开始看起

public V put(K key, V value) {
  return putVal(hash(key), key, value, false, true);
}

第一个步需要根据当前key计算hash,后面根据这个hash来计算下标位

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

key是null的话,直接返回0,否则计算hashCode ^ hashCode无符号右移16位
以上面的key计算结果

String key = "keyTest";
        //-815460463
        int h = key.hashCode();
        System.out.println(h);
        //不带符号右移
        int s = h >>> 16;
        //53093
        System.out.println(s);

继续跟踪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;
   //容器未初始化的话,在这里初始化容器
   if ((tab = table) == null || (n = tab.length) == 0)
   	   //初始化table后,tab为创建的Node数组,n = 16
       n = (tab = resize()).length;
   //当前计算是i=4,取出数组下标为4的数据,如果为null就创建一个Node节点
   if ((p = tab[i = (n - 1) & hash]) == null)
       tab[i] = newNode(hash, key, value, null);
   else {
       Node<K,V> e; K k;
       //如果hash相同,key不为null且相同,就进行覆盖
       if (p.hash == hash &&
           ((k = p.key) == key || (key != null && key.equals(k))))
           e = p;
       //如果是TreeNode,就是红黑树,就进行红黑树操作
       else if (p instanceof TreeNode)
           e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
       else {
       	   //遍历链表,如果循环到的Node的下一个节点为null,就创建一个新Node,将当前节点放到尾部节点的下一个节点
           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;
               }
               //判断当前循环到的节点的下一个节点是不是key和hash相等,如果相等就直接终止循环,后面覆盖value
               if (e.hash == hash &&
                   ((k = e.key) == key || (key != null && key.equals(k))))
                   break;
               p = e;
           }
       }
       //覆盖value
       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();
   afterNodeInsertion(evict);
   return null;
}

而对于数组的初始化,实际上调用的是hashmap的扩容方法resize(),初始化数组后返回,然后继续看putVal()方法

final Node<K,V>[] resize() {
    Node<K,V>[] oldTab = table;
    //对初始化而言,oldTab为null,所以oldCap为0
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    int oldThr = threshold;
    int newCap, newThr = 0;
    //如果旧数组大小大于数组允许的最大值,就将扩容临界值改为int的最大数
    if (oldCap > 0) {
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        //扩容时,默认是原数组大小*2变成32,如果乘2后的值满足不大于数组允许最大值,且原数组长度大于等于默认容量值16时,将扩容临界值也*2=24
        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 {               // oldCap为0时,初始化默认值。newCap=16;newThr=0.75*16为12
        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);
    }
    //初始化时赋值扩容临界值为12,扩容后为24
    threshold = newThr;
    @SuppressWarnings({"rawtypes","unchecked"})
    //创建数组,赋值给table属性并返回
    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;
            //旧数组节点中有数据的时候执行,比如咱们4节点中有数据,e为数组的head节点
            if ((e = oldTab[j]) != null) {
            	//先将旧数组4节点的数据清空
                oldTab[j] = null;
                //如果head节点的next为空,也就是说只有这一个节点,不是链表。就计算这个节点的下标放入新数组
                if (e.next == null)
                    newTab[e.hash & (newCap - 1)] = e;
                //如果head节点是红黑树,就进行移动
                else if (e instanceof TreeNode)
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                else { // 普通链表移动
                    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;
}

对于数组扩容及链表存值上面代码中已经添加注释。下面我们来看链表转红黑树逻辑

final void treeifyBin(Node<K,V>[] tab, int hash) {
  int n, index; Node<K,V> e;
  	//如果数组为空需要初始化;当前数组长度小于64,需要扩容
    if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
        resize();
    //数组的下标4不为空时,开始转换红黑树.当前的e,是拿到下标4的头节点
    else if ((e = tab[index = (n - 1) & hash]) != null) {
        TreeNode<K,V> hd = null, tl = null;
        do {
        	//将普通的Node节点信息存储到一个新建的TreeNode节点中
            TreeNode<K,V> p = replacementTreeNode(e, null);
            if (tl == null)
                hd = p;
            else {
                p.prev = tl;
                tl.next = p;
            }
            tl = p;
        } while ((e = e.next) != null);
        if ((tab[index] = hd) != null)
            hd.treeify(tab);
    }
}

以上就是put的实现逻辑,而get()就比较简单了。咱们也看一下

public V get(Object key) {
  Node<K,V> e;
  //1. 传入key的hash,和key本身。调用getNode方法取出Node,判断是否为空,不为空返回Node的值
  return (e = getNode(hash(key), key)) == null ? null : e.value;
}

然后进入getNode()方法

final Node<K,V> getNode(int hash, Object key) {
   Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
    //数组不为空才查询,否则直接返回null
    //(n - 1) & hash看过好多次了。就是计算这个key的下标位,取出数组中的Node,为空就不走这里的逻辑,直接返回null
        if ((tab = table) != null && (n = tab.length) > 0 &&
        (first = tab[(n - 1) & hash]) != null) {
        if (first.hash == hash && // 如果hash和key相同,就直接返回这个节点
            ((k = first.key) == key || (key != null && key.equals(k))))
            return first;
        //如果数组4的节点上有子节点,就执行这里的逻辑
        if ((e = first.next) != null) {
        	//如果数组4节点是红黑树,就从红黑树中查找数据并返回找到的节点
            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的源码了。可是你觉得到这里就完了吗。下面我们简单记住就可以了。对于上面的源码,put数据到一个存在节点的时候,咱们是将数据存到链表尾部对吧。这也不一定,JDK8以前是头插法,JDK8后是尾插法,为什么要该成尾插呢
主要原因就在于其的扩容机制。
看1.8之前的扩容写法

void transfer(Entry[] newTable)
{
    Entry[] src = table;
    int newCapacity = newTable.length;
    //下面这段代码的意思是:
    //  从OldTable里摘一个元素出来,然后放到NewTable中
    for (int j = 0; j < src.length; j++) {
    	//取出oldTablue中的Node,可以说是head节点
        Entry<K,V> e = src[j];
        //如果Node不为空
        if (e != null) {
        	//将oldTable当前节点置为空
            src[j] = null;
            do {
            	//取出head的下一个节点
                Entry<K,V> next = e.next;
                //计算head的新下标
                int i = indexFor(e.hash, newCapacity);
                //现在newTable还是空的,将newTable的节点赋值给head节点的next,
                //head的下一个节点为空		head - > null
                e.next = newTable[i];
                //head节点存入newTable中
                newTable[i] = e;
                //head的下一个节点变成head节点
                e = next;
            } while (e != null);
        }
    }
}

当只有一个线程时执行逻辑
头插法逻辑图

HashMap多线程环境下的使用呢。HashMap是非线程安全的,所以在多线程环境下会出现问题,那么我们分析一下

do {
    Entry<K,V> next = e.next; // <--假设线程二执行到这里就被调度挂起了
    int i = indexFor(e.hash, newCapacity);
    e.next = newTable[i];
    newTable[i] = e;
    e = next;
} while (e != null);

而线程一正常执行,完成了上面图的最终结果。此时线程一继续执行。说一下现在的情况。

线程二状态

在这里插入图片描述
线程一状态

在这里插入图片描述
实际上线程一唤醒的时候,实际上7的next已经指向3了,3指向null
然后线程一执行扩容逻辑

  1. 当前e指向3,执行e.next = newTable[i]; 将7 - > 3放在3的后面
  2. 将3放在数组下标3中,目前链表指向是3 -> 7 -> 3
  3. 然后next变成e,e现在是7,将数组中指向e的next变成7 -> 3 -> 7 -> 3 就成环了

官方本来也不推荐在多线程环境下使用HashMap,多线程下推荐使用ConcurrentHashMap

下面我们来了解下ConcurrentHashMap
和HashMap一样的例子

Map<String, String> hashMap = new ConcurrentHashMap<>();
hashMap.put(key,"valueTest");
hashMap.get(key);

首先看创建对象的构造方法

//Creates a new, empty map with the default initial table size (16). 
public ConcurrentHashMap() {
 }

和HashMap一样,可以初始化大小,也可以使用默认的初始化长度16
接着看put方法

public V put(K key, V value) {
  return putVal(key, value, false);
}

调用putVal()方法,和HashMap不同的是,这里并没有将key 的hash传到方法中,直接传递了key,继续看putVal()方法

final V putVal(K key, V value, boolean onlyIfAbsent) {
	//如果key为空,直接抛空指针
    if (key == null || value == null) throw new NullPointerException();
    //计算hash,细节就不看了。面试问我也答不上来
    int hash = spread(key.hashCode());
    //记录数据条数
    int binCount = 0;
    //自旋
    for (Node<K,V>[] tab = table;;) {
        Node<K,V> f; int n, i, fh;
        //初始化时tab为空,第一次调用put的时候初始化
        if (tab == null || (n = tab.length) == 0)
            tab = initTable();
        //通过hash技术数组下标,并通过偏移量直接拿到tab内存中该下标的值,赋值给f。如果f为空,代表下标中还没有数据,创建一个Node节点,通过cas操作把null改成添加值,修改成功的话就终止循环,否则进入下次循环,再尝试添加元素
        else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
            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)
            tab = helpTransfer(tab, f);
        else { //当第二次操作put方法时,假如计算的下标位还是这里,那么f就不为空,就需要给当前数组下标位的Node加锁操作,保证线程安全
            V oldVal = null;
            synchronized (f) {
            	//怕其它线程将这里修改,这里再取一次数组中的head,看看是否和之前取的一样,如果是一样的,就走下面逻辑
                if (tabAt(tab, i) == f) {
                    if (fh >= 0) {
                        binCount = 1;
                        for (Node<K,V> e = f;; ++binCount) {
                            K ek;
                            //如果hash一样,key也一样,就覆盖value
                            if (e.hash == hash &&
                                ((ek = e.key) == key ||
                                 (ek != null && key.equals(ek)))) {
                                oldVal = e.val;
                                if (!onlyIfAbsent)
                                    e.val = value;
                                break;
                            }
                            //如果上面的不满足,有没有下一个节点了。就新建个Node节点,插入尾部
                            Node<K,V> pred = e;
                            if ((e = e.next) == null) {
                                pred.next = new Node<K,V>(hash, key,
                                                          value, null);
                                break;
                            }
                        }
                    }
                    else if (f instanceof TreeBin) {
                        Node<K,V> p;
                        binCount = 2;
                        if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
                                                       value)) != null) {
                            oldVal = p.val;
                            if (!onlyIfAbsent)
                                p.val = value;
                        }
                    }
                }
            }
            if (binCount != 0) {
                if (binCount >= TREEIFY_THRESHOLD)
                    treeifyBin(tab, i);
                if (oldVal != null)
                    return oldVal;
                break;
            }
        }
    }
    //更新记录数条数。chm这里采用分段锁的机制实现,首先有一个baseCount,通过cas去增加。如果cas失败,生成随机数,随机存入CounterCell中,查询size()的时候,使用baseCount + CounterCell。CounterCell有一个数组,默认长度为2。分担拿不到锁,重新自旋的开销。如果竞争很激烈,CounterCell数组也会扩容, * 2倍扩容。代码实现太复杂,就不看了。看了也记不住。。。
    addCount(1L, binCount);
    return null;
}

具体初始化数组逻辑

private final Node<K,V>[] initTable() {
    Node<K,V>[] tab; int sc;
    //因为可能有多个线程竞争。如果到这里拿到的tab为空,就一直循环等其它线程将tab初始化好
    while ((tab = table) == null || tab.length == 0) {
    	//如果第一次进来,这里是不满足条件的,只有在多个线程环境下,一个线程已经通过cas操作修改共享变量sc的值为-1,就是通知其它线程,已经有其它线程在初始化容器了,当前线程可以不用继续循环了。让出时间片给其它线程
        if ((sc = sizeCtl) < 0)
            Thread.yield(); // lost initialization race; just spin
        //通过cas拿锁,将sc的值改为-1,如果发生多个线程竞争,保证只有一个线程去初始化容器
        else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
            try {
            	//有可能当前线程到这里时,其它线程已经将容器初始化好了。这里是双重检查,防止重复创建容器
                if ((tab = table) == null || tab.length == 0) {
                	//拿到初始长度,默认16
                    int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
                    @SuppressWarnings("unchecked")
                    //创建16长度的数组
                    Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
                    table = tab = nt;
                    //计算出扩容因子。当前n是16, 16 - (16/2^2) = 12
                    sc = n - (n >>> 2);
                }
            } finally {
                sizeCtl = sc;
            }
            break;
        }
    }
    return tab;
}

记录条数据及扩容逻辑实现

private final void addCount(long x, int check) {
    CounterCell[] as; long b, s;
    if ((as = counterCells) != null ||
        !U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) {
        CounterCell a; long v; int m;
        boolean uncontended = true;
        if (as == null || (m = as.length - 1) < 0 ||
            (a = as[ThreadLocalRandom.getProbe() & m]) == null ||
            !(uncontended =
              U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))) {
            fullAddCount(x, uncontended);
            return;
        }
        if (check <= 1)
            return;
        s = sumCount();
    }
    if (check >= 0) {
        Node<K,V>[] tab, nt; int n, sc;
        while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&
               (n = tab.length) < MAXIMUM_CAPACITY) {
            int rs = resizeStamp(n);
            //有线程正在扩容
            if (sc < 0) {
                if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
                    sc == rs + MAX_RESIZERS || (nt = nextTable) == null ||
                    transferIndex <= 0)
                    break;
                //协助扩容,
                if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
                    transfer(tab, nt);
            }
            //当前没有线程在扩容,扩容时,标记数量为+2
            else if (U.compareAndSwapInt(this, SIZECTL, sc,
                                         (rs << RESIZE_STAMP_SHIFT) + 2))
                transfer(tab, null);
            s = sumCount();
        }
    }
}

扩容实现看不下去了。找一篇网上的话

并发扩容的时候,由于操作的table都是同一个,不像JDK7中分段控制,所以这里需要等扩容完之后,所有的读写操作才能进行,所以扩容的效率就成为了整个并发的一个瓶颈点,好在Doug lea大神对扩容做了优化,本来在一个线程扩容的时候,如果影响了其他线程的数据,那么其他的线程的读写操作都应该阻塞,但Doug lea说你们闲着也是闲着,不如来一起参与扩容任务,这样人多力量大,办完事你们该干啥干啥,别浪费时间,于是在JDK8的源码里面就引入了一个ForwardingNode类,在一个线程发起扩容的时候,就会改变sizeCtl这个值,其含义如下:

sizeCtl :默认为0,用来控制table的初始化和扩容操作,具体应用在后续会体现出来。  
-1 代表table正在初始化  
-N 表示有N-1个线程正在进行扩容操作  
其余情况:  
1、如果table未初始化,表示table需要初始化的大小。  
2、如果table初始化完成,表示table的容量,默认是table大小的0.75

扩容时候会判断这个值,如果超过阈值就要扩容,首先根据运算得到需要遍历的次数i,然后利用tabAt方法获得i位置的元素f,初始化一个forwardNode实例fwd,如果f == null,则在table中的i位置放入fwd,否则采用头插法的方式把当前旧table数组的指定任务范围的数据给迁移到新的数组中,然后
给旧table原位置赋值fwd。直到遍历过所有的节点以后就完成了复制工作,把table指向nextTable,并更新sizeCtl为新数组大小的0.75倍 ,扩容完成。在此期间如果其他线程的有读写操作都会判断head节点是否为forwardNode节点,如果是就帮助扩容。

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值