HashMap与ConcurrentHashMap简述

版本:JDK1.8

针对put方法展开

0、两者底层数据结构

数组+链表+红黑树(JDK1.8新增)

0.1、数组

特点:查改快,增删慢。

查改快:通过数组下标定位
增删慢:增删会引起元素的移动

0.2、链表

特点:增删快,查改慢。

增删快:节点之间的链接断开,进行元素的增删,之后在链接上即可
查改慢:需要从链表头开始查。最大时间复杂度:T(m) = O(n);常数时间

0.3、红黑树

特点:接近于于平衡的二叉树。

相对于链表降低时间复杂度,T(n) = O(logn);对数时间,由于计算机使用二进制的记数系统,对数常常以2为底

引入红黑树的原因

1、解决发生哈希碰撞后,链表过长而导致索引效率的问题
2、利用红黑树增删改查快速的特点
3、时间复杂度有O(n)降为O(logn)

0.4、扩展

算法复杂度

算法复杂度分为时间复杂度和空间复杂度
    时间复杂度是指执行这个算法所需要的计算工作量;
    空间复杂度是指执行这个算法所需要的内存空间

1、两者整体区别

HashMap是线程不安全的,ConcurrentHashMap是线程安全的
HashMap的key和value可以为null,ConcurrentHashMap的key和value不可以为null

2、数组初始化

2.1、HashMap(线程不安全)

if ((tab = table) == null || (n = tab.length) == 0)
    n = (tab = resize()).length;
 
//DEFAULT_INITIAL_CAPACITY = 1 << 4;
//DEFAULT_LOAD_FACTOR = 0.75f;
newCap = DEFAULT_INITIAL_CAPACITY;		
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);

默认数组初始化长度:16

加载因子:0.75f

默认扩容阀值:12

2.2、ConcurrentHashMap(线程安全)

 if ((sc = sizeCtl) < 0)
     Thread.yield();
 else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
    
    
     int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
     Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
 }

默认数组初始化长度:16

加载因子:0.75f

默认扩容阀值:12

线程安全体现

对数组初始化使用了CAS无锁化的方式保证了线程安全
CAS:Compare And Swap:比较并交换

数组初始化比较了内存中Node数组长度的值和当前线程中Node数组长度的值,默认都是0。
CAS操作将内存中数组的长度改为了-1,之后其他线程进来就会让出CPU执行权
然后当前线程就进行数组初始化操作

3、put操作和get操作

3.1、put操作之前

3.1.1、对key进行哈希散列计算

HashMap

hash(key)=================
static final int hash(Object key) {
    
    
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
=================
if ((p = tab[i = (n - 1) & hash]) == null)

ConcurrentHashMap

int hash = spread(key.hashCode());
===================
static final int spread(int h) {
    
    
    return (h ^ (h >>> 16)) & HASH_BITS;
}
===================
f = tabAt(tab, i = (n - 1) & hash)

哈希散列计算描述

进行哈希散列计算主要是为了确定元素的索引
为了保证数组的空间能得到充分的利用,需要考虑分散性、均匀性
两者底层hash算法会将key的hashCode值与数组的长度换算成32位二进制形式做位与运算

有一个前提和两个关键点
一个提前:
	数组长度必须是2的次幂数
两个关键
	1、得到一个整型数:key.hashCode()
    	该整型数很关键,关系到数组索引的分散性、均匀性,所以该整型数的最终运算结果要尽可能的不一样
    	
	2、控制这个整型数在0~length-1之间:key.hashCode()%length
    	将%运算换成&运算,key.hashCode()%length = key.hashCode()&(length - 1)
    	用位与运算替代取模的运算,位与效率更高
    	整型数最终会转化为32位二进制数形式的&(length-1)最终会得到一个索引index,确定Node节点存放的位置,10进制表示的范围 0~(length - 1)
    	索引index是由整型数和(length - 1)两个操作数决定的,所以最终还是取决于hash函数的结果

索引index还是要尽可能保证一样
   在同一个数组中,length-1是固定的,所以索引index结果最终还是取决于整型数处理的最终结果,准确来说取决整型数的32位二进制数最后几位,因为高位不参与运算
   即使整型数不同,但是二进制数形式表示的时候低位可能相同,参与运算结果还是有可能一样,因为只是低位参与运算
   所以要保证最后几位不一样即可,因此用高16位和低16位进行异或(^)运算,重复的可能性减少,这样就更加保证了index不一样

3.2、HashMap(线程不安全)

put元素操作

索引处是否有值
没有值:直接put
有值:
	key相同:直接替换,返回旧值
	key不同:形成链表或红黑树
		链表:遍历链表,判断插入后链表长度是否达到8,JDK1.8之前是头插法
			没有达到,尾插法。
			达到,转为红黑树处理,判断哈希表中的容量(数组长度)是否达到最小树形化容量阈值64
				当哈希表中的容量 > 该值时,才允许树形化链表 (即 将链表 转换成红黑树)
				没有达到,进行数组扩容
				达到,转红黑树
		红黑树:直接put

get元素操作

public V get(Object key) {
    
    
    Node<K,V> e;
    //先通过hash(key)找到hash值,然后调用getNode(hash,key)找到节点
    return (e = getNode(hash(key), key)) == null ? null : e.value;
}
final Node<K,V> getNode(int hash, Object key) {
    
    
    Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
    //通过(n - 1) & hash找到数组对应位置上的第一个node
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (first = tab[(n - 1) & hash]) != null) {
    
    
        //如果这个node刚好key值相同,直接返回
        if (first.hash == hash && // always check first node
            ((k = first.key) == key || (key != null && key.equals(k))))
            return first;
        //如果不相同就再往下找
        if ((e = first.next) != null) {
    
    
            //如果是treeNode,就遍历红黑树找到对应node,最终会通过find方法
            if (first instanceof TreeNode)
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            //如果是链表,遍历链表找到对应node
            do {
    
    
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    //没有找到返回null
    return null;
}

3.2、ConcurrentHashMap(线程安全)

put元素操作

索引处是否有值
没有值:直接put(CAS无锁化机制保证线程安全)
有值:使用同步代码块保证线程安全,将链表头或者红黑树根节点元素作为锁
	key相同:直接替换,返回旧值
	key不同:形成链表或红黑树
		链表:遍历链表,判断插入后链表长度是否达到8(不在此同步锁),JDK1.8之前是头插法
			没有达到,尾插法。
			达到,转为红黑树处理(另一同步锁),判断哈希表中的容量(数组长度)是否达到最小树形化容量阈值64
				当哈希表中的容量 > 该值时,才允许树形化链表 (即 将链表 转换成红黑树)
				没有达到,进行数组扩容
				达到,转红黑树
		红黑树:直接put

get元素操作

public V get(Object key) {
    
    
    Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
    //先通过hash(key)找到hash值
    int h = spread(key.hashCode());
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (e = tabAt(tab, (n - 1) & h)) != null) {
    
    
        //如果这个node刚好key值相同,直接返回
        if ((eh = e.hash) == h) {
    
    
            if ((ek = e.key) == key || (ek != null && key.equals(ek)))
                return e.val;
        }
        //如果不相同就再往下找
        else if (eh < 0)
            //如果是treeNode,通过find方法中遍历红黑树找到对应node
            return (p = e.find(h, key)) != null ? p.val : null;
        //如果是链表,遍历链表找到对应node
        while ((e = e.next) != null) {
    
    
            if (e.hash == h &&
                ((ek = e.key) == key || (ek != null && key.equals(ek))))
                return e.val;
        }
    }
    //没有找到返回null
    return null;
}

4、扩容

4.1、前置条件

每次put元素都会有判断,在每次put如果数组索引位置被使用都会有++操作,初始值是0,在数组初始化时会有一个扩容阀值:数组长度X加载因子

4.2、扩容条件

1、数组table被使用的容量超过扩容阀值
2、红黑树扩容:哈希表中的容量小于最小树形化容量阈值64(引起链表过长的根本原因是数组过短)

4.3、怎么扩容

长度和扩容阀值左移1位,变为原来容量的双倍

4.4、扩容与元素迁移

4.4.1、HashMap(线程不安全)

扩容说明:resize()方法

前置:判断扩容前哈希表是否为空,不为空则开始遍历数组
条件:数组索引处有元素
元素迁移
	会重新计算hash值,得到新数组的index
	1、没有链表或红黑树
		直接搬运
	2、有链表节点
		遍历搬运
	3、有红黑树节点
		树形拆分搬运

4.4.2、ConcurrentHashMap(线程安全)

扩容说明:
在put元素的时候会进行头节点的hash值判断,如果等于MOVEN(也就是-1),就让当前线程帮助扩容(没有加锁,因为还需要其他线程帮助迁移元素,但是其他线程要能监测到当前线程正在扩容),在扩容的时候,会将这个hash值置为-1,相当于让其他线程监听到当前线程正在扩容,就去帮助当前线程进行扩容,会暂停线程put元素操作,等扩容完以后才能再put元素到新数组

addCount()判断是否需要扩容
transfer()方法
	在此方法中,每个线程会领取任务
	stride = MIN_TRANSFER_STRIDE;//最小16,
	//如果数组长度只有16,一个线程即可,多个的话下一个线程进来会领取自己对应的任务

搬运元素和HashMap一样,只是是用来同步代码块

前置:判断扩容前哈希表是否为空,不为空则开始遍历数组
条件:数组索引处有元素
元素迁移(同步代码块)
	会重新计算hash值,得到新数组的index
	1、没有链表或红黑树
		直接搬运
	2、有链表节点
		遍历搬运
	3、有红黑树节点
		树形拆分搬运

5、问题

1、为什么要对key进行hash计算?

为了保证数组的空间能得到充分的利用,需要考虑分散性、均匀性

2、为什么数组初始化的长度要是2的指数次幂?

因为底层hash算法仍然要换算成二进制运算:hash%length=hash&(length-1)
如果不是2的次幂,就不等价hash%length!=hash&(length-1)
提升效率

即使传入非2的指数次幂的容量,在数组初始化时,也会将这个容量长度转换成最接近这个容量的2的n次方的数

3、加载因子为什么是0.75f?

如果设置为1,最大化利用空间,但是没办法达到理想状态,会导致链表过长或者红黑树时间复杂度增加
如果设置为0.5,查询效率高,节省时间,但是太浪费空间
空间利用率与时间复杂度上去择中,所以选择0.75

4、ConcurrentHashMap在put元素的时候有值情况下为什么不使用CAS无锁化机制保证线程安全?

因为有可能put很频繁,用CAS每次都要和内存中的作比较,非常不好。使用同步代码块,也就是数组上一个元素有一把锁,锁粒度变小,性能提高。

5、链表长度为什么阀值是8?

泊松分布算法

猜你喜欢

转载自blog.csdn.net/zhuzbYR/article/details/109260242