HashMap&ConcurrrentHashMap-总结

前言

JavaSE基础知识也学了大部分了,发现Java中有一个数据结构有着举足轻重的重用,什么面试必考啊,你必须掌握啊~~~,那就是HashMap,完后谈到这玩意,都拿1.7版本JDK和1.8版本JDK版本作比较。大多数学Java的听说过了,1.7嘛底层数据结构数组+链表,1.8多了个红黑树。完后1.7中它是线程不安全的,它查找效率可能会很低,冲突解决策略是简单用链表把冲突的节点串起来,那必然不会有很高效率,O(n)查找。因此1.8之后就加了红黑树,就冲突链表长度超过一个阈值,给他把链表转红黑树结构,但他依旧是线程不安全的。红黑树就是一个不是非常严格的平衡二叉树嘛,查找效率O(logn)级别。

都是线程不安全,有啥区别:

1.7中采用的是头插法,即插在链表的都节点处,而1.8是尾插法,这所谓头插尾插都是在扩容时的操作。1.7多线程头插法可能会导致出现环形链表。

线程安全的HashMapjava.util.concurrent包下

以上都是看了很多博客、视频总结得来的东西。完后我自己用的JDK15也去读了读源码,但是感觉还是不够,于是我下载了1.7版本JDK和1.8版本JDK来读一下源码。

在这里插入图片描述

接下来分四个部分读源码,1.7版本HashMap,1.8版本Hashmap,1.7版本ConcurrentHashMap

,1.8版本ConcurrentHashMap

JDK1.7版本HashMap

先看一下如何存键值对,列出static class Entry<K,V> implements Map.Entry<K,V>属性:

final K key;
V value;
Entry<K,V> next;
int hash;

next主要用来串出现冲突的键值对。单向链表处理冲突!

new-构造一个HashMap对象

借助强大的IDEA来直接导入1.7版本JDK版本

在这里插入图片描述

直接写new HashMap;

package hello;

import java.util.HashMap;

public class Test {
    public static void main(String[] args) {
        HashMap hashMap = new HashMap();
        hashMap.put(1,"code");
        hashMap.put(2,"friday");
    }
}

Ctrl+鼠标左键直接进到HashMap源码,使用快捷键Alt+7查看这个类中的一些方法以及属性

在这里插入图片描述

可以看到有4种构造方法,完后再看看其中的一些属性,直接上源码如下:

//其实源码中的注释已经解释得很清楚,中文备注一下
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
	//默认初始容量,必须是2的幂次,MUST be a power of two.

    static final int MAXIMUM_CAPACITY = 1 << 30;
	//最大容量

    static final float DEFAULT_LOAD_FACTOR = 0.75f;
	//默认加载因子,0.75

    static final Entry<?,?>[] EMPTY_TABLE = {};
	//用来比较判断table是否为空用的,后面代码会体现!

    transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
	//存储底层数据结构:数组

    transient int size;
	//已经存的key-value数量

    int threshold;
	//阈值,容量*加载因子得的,存的键值对超过这个阈值就要进行数组扩容操作

    final float loadFactor;
	//加载因子

根据我写的代码,我调用了无参构造,查看源码调用方法的过程如下:

 /**
     * Constructs an empty <tt>HashMap</tt> with the default initial capacity
     * (16) and the default load factor (0.75).
     */
    public HashMap() {
        this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
    }
//就给默认容量16,加载因子0.75,然后调用有参构造
public HashMap(int initialCapacity, float loadFactor) {
        if (initialCapacity < 0)//1.
            throw new IllegalArgumentException("Illegal initial capacity: " +
                                               initialCapacity);
        if (initialCapacity > MAXIMUM_CAPACITY)//2.
            initialCapacity = MAXIMUM_CAPACITY;
        if (loadFactor <= 0 || Float.isNaN(loadFactor))//3.
            throw new IllegalArgumentException("Illegal load factor: " +
                                               loadFactor);

        this.loadFactor = loadFactor;
        threshold = initialCapacity;//此时没有插入键值对,阈值==容量
        init();//空函数啥也没做
    }
  • 1.第一个if判断,传入容量值为负数,抛出一个异常
  • 2.第二个if判断,传入容量超过允许最大容量,就按最大容量来
  • 3.第三个if判断,加载因子也可以自己给,判断一下是否0-1范围且是一个有效的数,不是就抛出异常

自此可以得到,调用构造函数new一个HashMap对象,实际用来存键值对的数组并没有创建。

自此,new操作结束,接下来肯定就是往里存键值对,调用的是put方法

put方法

put执行流程如下:

  • 判断数组是否已经创建
  • 判断key是否为空,针对key==null插入有一个方法
  • 计算哈希值并找一个数组下标去存
  • 先判断key是否已经存在,存在就更新value值,返回旧的value
  • 不存在就调用addEntry插入
public V put(K key, V value) {
    if (table == EMPTY_TABLE) {//判断数组是否为空
        inflateTable(threshold);
    }
    if (key == null)//key为空,调用一个插入key==null的方法,由此可知可以存key为null的键值对
        return putForNullKey(value);
    int hash = hash(key);//计算一下key的hash值
    int i = indexFor(hash, table.length);//根据哈希值取得应该存在数组中那个位置
    for (Entry<K,V> e = table[i]; e != null; e = e.next) {
        //遍历一个数组下标对应的链表,如果key已经存在,更新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++;//修改次数+1
    addEntry(hash, key, value, i);//实际插入键值对的方法
    return null;
}

到此,有两处需要拓展!

put方法扩展indexFor

注意:看源码时不能看到一个方法中调用了一个方法就马上点进去看,你会发现,可能一直点,点个好几层都没问题,完后回来你就不知道自己是要干嘛了。

人家写的代码的函数一般都见名知意。重要的方法,看完整体点进去验证一下就行~~~

比如这里得去看看indexFor做了些啥,这会解释了为什么数组容量必须2的幂次,扩容也必须2倍扩容~

/**
     * Returns index for hash code h.
     */
    static int indexFor(int h, int length) {
        // assert Integer.bitCount(length) == 1 : "length must be a non-zero power of 2";
        return h & (length-1);
    }

简单的做了一个按位与运算。正常把元素映射到数组,想到的映射方法肯定是用取余操作模上数组长度,这是一种相对平均的散列算法。实际这个地方本质就是模数组长度,但是必须保证length是2的幂次才能达到这个效果。举个例子:

在这里插入图片描述

数组长度保证2的幂次,就可用按位与代替取模操作,位运算的速度比取模运算快很多很多~,可以用个计数程序测试一下。

put方法扩展addEntry
/**传来的参数
*@hash:key的哈希值
*key,value即键值对
*@bucketIndex:键值对需要插入的桶的索引,就是数组索引,数组每一格当作一个桶
*/
void addEntry(int hash, K key, V value, int bucketIndex) {
    if ((size >= threshold) && (null != table[bucketIndex])) {//如果存的键值对已经超过阈值,就需要扩容
        resize(2 * table.length);//扩容,2倍扩容,后续分析源码
        hash = (null != key) ? hash(key) : 0;//重新计算一下key的hash值
        bucketIndex = indexFor(hash, table.length);//根据hash值重新找应该放在数组哪个位置
    }

    createEntry(hash, key, value, bucketIndex);//实际放入数组的方法
}

还得点一层createEntry,源码如下:

void createEntry(int hash, K key, V value, int bucketIndex) {
        Entry<K,V> e = table[bucketIndex];//把同中键值对取出来
        table[bucketIndex] = new Entry<>(hash, key, value, e);//把新键值对加进入,并把e接到后面,这就是头插法!!!
        size++;//键值对计数器+1
    }

画个图演示一下——头插法
在这里插入图片描述

补充:key==null时默认插入数组下标为0的地方

get方法

上源码:

public V get(Object key) {
        if (key == null)//key为null调用对应方法
            return getForNullKey();
        Entry<K,V> entry = getEntry(key);//获取整个Entry对象

        return null == entry ? null : entry.getValue();//如果Entry对象为空表示没有这个映射,否则返回value值
    }

getEntry源码如下:

final Entry<K,V> getEntry(Object key) {
        if (size == 0) {
            return null;
        }
        int hash = (key == null) ? 0 : hash(key);
        for (Entry<K,V> e = table[indexFor(hash, table.length)];//找到key对应数组中的位置
             e != null;
             e = e.next) {//遍历桶,也就是遍历链表
            Object k;
            if (e.hash == hash &&
                ((k = e.key) == key || (key != null && key.equals(k))))
                return e;//找到key返回Entry对象
        }
        return null;//没找到
    }

相对简单,主要还是因为数组+链表实现HashMap数据结构并不复杂。

接下来必须看看扩容操作了!

resize方法

void resize(int newCapacity) {
        Entry[] oldTable = table;//拿个指针指向原来的数组
        int oldCapacity = oldTable.length;//记录一下原来数组大小
        if (oldCapacity == MAXIMUM_CAPACITY) {//原来容量已经达到最大值
            threshold = Integer.MAX_VALUE;//把阈值给扩大一下,没办法,数组不允许再扩大了
            return;//返回
        }

        Entry[] newTable = new Entry[newCapacity];//新数组,原来的两倍
        transfer(newTable, initHashSeedAsNeeded(newCapacity));//把老数组数据转移到新数组
        table = newTable;//更新一下数组
        threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);//更新一下阈值
    }

重点那肯定是在transfer,这也是并发操作导致双向链表的地方!!!源码如下:

void transfer(Entry[] newTable, boolean rehash) {
        int newCapacity = newTable.length;
        for (Entry<K,V> e : table) {
            while(null != e) {
                Entry<K,V> next = e.next;
                if (rehash) {//是否需要重新计算hash值
                    e.hash = null == e.key ? 0 : hash(e.key);
                }
                int i = indexFor(e.hash, newCapacity);//重新计算数组下标
                e.next = newTable[i];
                newTable[i] = e;
                e = next;//一样的的头插法重新放进去
            }
        }
    }

并发出现环形链表

在这里插入图片描述

下一次get查找这个桶时,死循环在里面不出来了!

JDK1.8版本HashMap

进入Project Structure切换JDK版本:

在这里插入图片描述

数据结构和辅助函数改变

点进源码后,存储一个键值对的数据结构如下:

 static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;
        final K key;
        V value;
        Node<K,V> next;
 }
//只列出属性

名字都改了,Entry改成Node,内容倒是没变。既然引入了红黑树,那肯定由红黑树节点对应的数据结构:(只列出属性)

 static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
        TreeNode<K,V> parent;  // red-black tree links
        TreeNode<K,V> left;
        TreeNode<K,V> right;
        TreeNode<K,V> prev;    // needed to unlink next upon deletion
        boolean red;
  }

hash函数也改了:

static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
//1.7版本直接获取key的hashCode,现在把hashcode高位和低位做了一下异或操作,这玩意叫扰动函数

**扰动函数作用:**你求于的时候包含了高16位和第16位的特性 也就是说你所计算出来的hash值包含从而使得你的hash值更加不确定 来降低碰撞的概率。

构造函数其实本质和1.7版本还是差不多。有很大不同的地方还是分析put,get,resize方法

put方法

public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}
//直接调用了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)//如果数组还未创建
            n = (tab = resize()).length;
        if ((p = tab[i = (n - 1) & hash]) == null)//根据hash值找到对应存放的数组下标
            tab[i] = newNode(hash, key, value, null);//如果该位置空,直接新建一个链表节点
        else {//否则就遍历一下链表,看key是否有重复
            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) { // key已经存在,覆盖value并返回原来的
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }
        ++modCount;
        if (++size > threshold)
            resize();//判断一下是否需要扩容+
    
        afterNodeInsertion(evict);
        return null;
    }

putVal插入流程:

  • 判断数组是否已经创建
  • 根据hash值找到对应存放的数组下标
  • 分三种情况
    • 该位置为空
    • 红黑树的插入
    • 链表的插入
  • 插入如果是覆盖就返回旧值
  • 判断一下是否达到阈值,然后扩容一下

get方法

public V get(Object key) {
        Node<K,V> e;
        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;
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (first = tab[(n - 1) & hash]) != null) {//先判断表不为空,并根据hash索引到数组下标不为空
            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;//找不到返回null
    }

执行流程也相对简单,分红黑树和链表的查找方法,重点在扩容(注:我只分析了链表,为了对比1.7版本)

resize方法

源码挺长:

final Node<K,V>[] resize() {
        Node<K,V>[] oldTab = table;//保存一下原来的表,不再需要传容量大小的参数,区别于1.7
        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;
            }
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                //扩大两倍容量并判断是否小于允许的最大容量,原来的容量是否大于等于16
                newThr = oldThr << 1; // double threshold,都满足就扩大阈值,阈值在new的时候没传参数其实就给了默认
        }
        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;
    }

重点分析链表数据迁移的过程,定义了四个链表节点

Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;

其实数据迁移过程,无非是把原来链表拆分成两个链表(不考虑红黑树),而且两个链表中的数据根据哈希值和oldlength-1求与之后得到的数组索引一定满足以下关系:

OldIndex == OldIndex 或 OldIndex+oldlength

分出去到更高索引的其实就是多看一个二进制位,比如原来容量是8,现在看看第4位(从低到高从1开始计),如果是1那就分到更高索引的数组去。因此定义了一个loHeadhiHead两个链表。接下来模拟操作以一下

do-while循环完之后:

在这里插入图片描述

接下来两个if判断:就是把链表放到新数组中去:

if (loTail != null) {
	loTail.next = null;
	newTab[j] = loHead;
}
if (hiTail != null) {
	hiTail.next = null;
	newTab[j + oldCap] = hiHead;
}

并发操作出现的问题

没有任何同步机制,多线程肯定会出现关键节点线程抢占,比如其中size表示键值对的数目,其他线程可能对size的副本做出修改还未更新本来的值,那必然会出现多个线程数据覆盖的问题。实际就会出现,并发插入键值对,实际插入数量!=size,这只是一个不严谨的举例,实际自己写个测试程序运行就会抛出并发操作异常。

JDK1.7版本ConcurrentHashMap

由于目前只有只学习了操作系统导论中的并发,讲的也是C/C++下的,还未学习Java中并发的一些实现进制。只能浅显分析一波~~~

出错以后来改,或者欢迎评论区纠正

首先这是它的结构图:

在这里插入图片描述

为了实现互斥的话,最简单的思路就是只允许一个线程操作哈希表,也就是价格锁,但这样自己用HashMap写好同步代码块就完事了,这东西也就没存在的意义,而且并不是所有线程并发操作哈希表都是会导致出错的,因此可以考虑把哈希表分成很多段,每个段保证只能一个线程进去操作,那就可以实现真正意义上的并发操作哈希表,JDK1.7中就是利用了分段锁的机制实现互斥。

核心属性以及数据结构

新增的属性:

static final int DEFAULT_CONCURRENCY_LEVEL = 16;
//默认并发级别,也就是允许多少个线程同时操作

static final int MIN_SEGMENT_TABLE_CAPACITY = 2;
//每个段下默认hash表的长度

static final int MAX_SEGMENTS = 1 << 16; // slightly conservative
//允许最大的段数量

数据结构:

final Segment<K,V>[] segments;

transient Set<K> keySet;
transient Set<Map.Entry<K,V>> entrySet;
transient Collection<V> values;

查看Segment类定义:

static final class Segment<K,V> extends ReentrantLock implements Serializable 

实现了ReentrantLock,其实就是一种锁的类型。jdk中独占锁的实现除了使用关键字synchronized外,还可以使用ReentrantLock。

上面的东西暂时没学到。

本质和HashMap没多大区别,只不过用到了volatile关键字等实现同步互斥。

原理上来说:ConcurrentHashMap 采用了分段锁技术,其中Segment继承于ReentrantLock。不会像HashTable那样不管是put还是 get操作都需要做同步处理,理论上ConcurrentHashMap支持CurrencyLevel (Segment 数组数量)的线程并发。每当一个线程占用锁访问一个Segment 时,不会影响到其他的 Segment

1.7版本解决并发问题之后,但是数组+链表的实现还是会导致查询效率低。

JDK1.8版本ConcurrentHashMap

在JDK1.8版本丢弃了分段锁。

采用了 CAS + synchronized 来保证并发安全性。

CAS全称CompareAndSwap,在操作系统导论中这是操作系统中硬件提供的功能强大的原子操作,来实现锁机制用的。

此处我觉得是差不多的思想,实际上这个操作似乎也是C++写的,调用了C++写的包,因为Java不能去搞底处的内存管理。

总结

1.8 在 1.7 的数据结构上做了大的改动,采用红黑树之后可以保证查询效率(O(logn)),甚至取消了ReentrantLock改为了 synchronized,这样可以看出在新版的JDK 中对 synchronized优化是很到位的。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值