一文搞懂Hashmap(jdk1.8与1.7对比)

首先说明

jdk1.7的Hashmap是数组+链表。
jdk1.8的Hashmap是数组+链表+红黑树。
一个值要存储到Hashmap中,会根据key的值计算出它的hash值,通过hash值确定存放到数组中的位置。如果发生hash冲突就会在数组中维护一个链表的形式存储冲突的值,链表过长时,就会把链表转化为红黑树去存储。

从源码分析

一文搞懂Hashmap(jdk1.8与1.7对比)_第1张图片
Hashmap 继承自AbstractMap,实现了Map,Cloneable,Serializable接口。

Hashmap的一些基本属性

static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 定义初始容量值,这里也就是16

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

transient int size;// 元素个数,注意transient不会被序列化和反序列化,序列化操作属于三种拷贝中的深拷贝

int threshold;//容量阈值,元素超过这个值就会去扩容

static final float DEFAULT_LOAD_FACTOR = 0.75f; // 负载因子

static final int MIN_TREEIFY_CAPACITY = 64; // 树化的最小容量值条件

transient Node<K,V>[] table; // hash数组,也是一个散列表,再resize()中初始化的

主要要知道默认的初始容量负载因子hash数组(桶)容量阈值

table数组

我们使用hashmap最主要的还是这个table数组,它存放的是Node 对象,而Node是Hashmap的一个内部类,可以用来表示一个key-value. table数组也就是我们常说的桶数组或者散列表。
具体看table的构造:

static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;
        final K key;
        V value;
        Node<K,V> next;

        Node(int hash, K key, V value, Node<K,V> next) {
            this.hash = hash;
            this.key = key;
            this.value = value;
            this.next = next;
        }

table数组的总结:

  1. 数组的初始容量为16,默认负载因子为0.75
  2. 容量阈值=数组长度×负载因子,当元素个数超过容量阈值时,就会进行扩容操作。
  3. table数组中存放的是指向链表的引用
  4. table数组并不是在构造方法初始化的,而是在resize的扩容中初始化 的

这里 table 数组的长度永远为2 的幂次方

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;
    }

为什么长度永远为2 的幂次方?

  1. hashmap是通过这个 tableSizeFor 的方法实现的永远为2 的幂次方。
  2. 找到大于或等于cap的最小的2的幂,用来做容量阈值。
  3. 这个方法也就是返回大于等于输入参数且最近的2 的整数次幂的数。
  4. 最高位的1 后面的位全为1 ,最后让结果n+1,就能得到2 的整数次幂的值。

其实就一个思想,就是Hashmap在构造方法中使用这个tableSizeFor方法去设置容量阈值。值得注意的是:这个方法会为扩容第一次初始化table数组时将容量阈值(threshold)设置数组长度。

优点:当数组的长度为2 的幂次方时,可以使用位运算计算数组下标,增加了hash的随机性,减少哈希冲突,使用位运算也可以提高运算效率。

扩容

Hashmap每次扩容都是会建立一个新的数组,长度和容量阈值都会变为原来的两倍,然后把原数组的元素重新映射到新数组上,和ArrayList相似。
具体步骤:

  1. 首先判断table数组的长度,大于0 就说明已经被初始化了,那么就会按照当前的table数组的长度的2倍去扩容,容量阈值就变为原来的2倍。
  2. 如果table数组没有被初始化,并且容量阈值大于0 ,这就说明调用了Hashmap(initialCapacity,loadFactor)的构造方法,那么就把数组大小设为容量阈值了。
  3. 判断如果数组没有被初始化,并且容量阈值为0 ,这就说明调用了hashmap 的构造方法。那么就把数组大小设为16,容量阈值设为 16 × 0.75 的值。
  4. 以上判断完后,再判断如果不是第一次初始化,那么扩容后,会重新计算键值对的位置,并把它们移动到合适 的位置上,节点是红黑树类型 的就会对其进行拆分。

7,8对比:
1.7中会重新一个个的计算元素的hash值。
1.8中就是通过hash&oldcap的值去判断,如果为0 则索引位置不变,如果不为0则按照新索引=原索引+旧数据的长度。

这里涉及到两个红黑树的知识点:

  1. 链表树化:也就是把符合条件的链表转为红黑树,需要满足的是链表长度大于等于8,table数组的长度大于等于64,不过当table数组的容量比较小时,键值对节点hash碰撞率就比较高,所以一般这个时候,应该先扩容,不然会导致链表长度过长
  2. 红黑树拆分:扩容后会对元素重新映射时,可能会被拆分成两条链表。

查找

  1. 先通过自定义的hash方法计算出key的hash值,求出数组的位置。
  2. 判断该位置上是否有节点,若没有则返回null,也就是没有查找到这个元素。
  3. 如果有则判断该节点是不是要查找的,是就返回该节点。
  4. 如果不是就判断节点的类型,如果是红黑树就调用红黑树的方法去查找,如果是链表就遍历链表调用equals方法去查找
  5. 值得注意的是:查找不是直接通过key的hashCode方法直接获取hash值,而是通过内部自定义去计算的。如下:
 static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);//高位数据与低位数据进行异或,增加hash值的随机性
    }

插入

这是我们学习Hashmap的一个关键的操作!其中的步骤和源码分析尤为关键!
具体步骤:

  1. 当table数组为空时,通过扩容初始化table数组。
  2. 通过计算键的hash值求出下标后,如果这个位置上没有元素,也就是没有哈希冲突时就会新建Node节点去插入元素。
  3. 如果发生哈希冲突,则遍历链表查找要插入的key是否已存在,存在就会根据条件判断是否用新值替代旧值,如果不存在,就把元素插入到链表尾部,并根据链表长度决定是否把链表转为红黑树,当然条件是要满足table数组长度大于等于64并且链表的长度大于等于8,当达到条件转成红黑树时,链表就会转成红黑树,并且此红黑树会转到另一个数组的位置,原来的空间就会清空。
  4. 最后判断键值对数量是否大于容量阈值,大于则扩容,table数组是第一次调用put方法后才初始化的。

7,8对比:
1.7 是插入链表头部的,因为考虑新插入的元素可能会用的比较多,所以会放到头部减少用到的时候的遍历。
1.8 是插入链表尾部的,因为头插法在多线程环境下可能导致两个节点互相引用,形成死循环,为了安全,还是使用尾插法。

删除

相对来说删除操作还是比较简单的。

  1. 首先定位table数组的元素位置。
  2. 然后有链表就遍历链表找到相应的节点,然后删除。 没有链表的就直接删除节点。
    需要注意的是:删除操作后,一般都会破坏红黑树的结构,那么针对红黑树就会有对应的 removeTreeNode 方法对红黑树进行变色和旋转等去保持红黑树的平衡结构

遍历

遍历也是一个经常被问到的高频面试题,因为可能因为你的一个不小心,就给自己挖了一个大坑。
首先说一下遍历的顺序问题,再说安全问题,hashmap是从桶(table数组)中找到包含链表节点引用的桶,然后对这个桶指向的链表进行遍历。遍历完成后,再继续找下一个包含链表节点引用的桶,找到就继续遍历。找不到,就结束遍历。

重点来了!!

对于Hashmap来说,使用for-each遍历时,很可能因为一个modCount的变量抛出异常,也就是常见的fail-fast机制。
所以我们可以使用迭代器,迭代器自带remove方法,会将modCount复制给expectedModCount,这样就不会出现异常。但是我们的hashmap是线程不安全的,那么有个Hashtable可以解决线程安全问题,Hashtable底层是数组+链表,继承自Dictionary,是线程安全的,默认初始化是11,扩容方式是原始容量的2倍+1,底层数组也不会要求容量为2 的整数次幂。虽然它是线程安全的,但我们现在也不推荐使用,因为它跟Vector一样,所有方法都是加上锁的,虽然保证了线程的安全,但是效率低下。所以我们推荐使用java 1.5就推出的线程安全集合类 java.util.concurrent,比如CopyOnWriteArrayList,CopyOnWriteArraySet和ConCurrentHashMap,那么我们针对Hashmap的线程安全就可以选择使用ConCurrentHashMap。

ConCurrentHashMap

ConCurrentHashMap其实底层和HashMap一样,但是不同的是它使用的是部分加锁和CAS算法实现同步的。其中查询(get)元素不用加锁,通过重写Node类,volatile修饰next实现每次获取的都是最新的值。
因为要支持高并发操作,所以ConCurrentHashMap也是由一个个Segment组成的,默认初始也就是16个Segment,Segment代表的就是一段或一部分的意思,我们也把这个称之为分段锁。所以我们可以这么理解,ConCurrentHashMap就是一个Segment数组,Segment通过继承ReenTranLock进行加锁,那么每次加锁的操作锁住的就是一个Segment,这样就只要保证每个Segment是线程安全的,就ok了。
那么对于线程集合类,使用迭代器反而会抛出ConcurrentModificationException的异常,因为迭代器的fail-fast机制。

CAS算法

CAS也被成为乐观锁的一种,CAS就是Compare and Swap:比较与交换。是一种无锁算法。CAS有三个操作数:内存值V旧的预期值A要修改的新值B。那么当预期值A和内存值V相同时,就会把V改成新值B,否则什么都不会做,多线程使用CAS更新变量时,只有一个线程能去更新变量的值,其他线程都会失败,但并不会被挂起,而是告诉它们竞争失败,可以再去尝试的。这也就是比较与交换算法。
面对更专业的面试官,你可能会被问到CAS的底层核心是什么?
一文搞懂Hashmap(jdk1.8与1.7对比)_第2张图片
其实我们天天挂在嘴边的CAS算法就是concurrent线程集合类下的Atomic包的类中的类,它们基本使用的是Unsafe的包装类。
原理就是:Atomic包的类的实现基本都是调用Unsafe的方法,Unsafe 的底层实际上就是调用C代码,C又去调汇编,最后生成一条CPU指令cmpxchg,完成操作。这也能解释为什么我们这个Atomic是原子性的,因为最后就是一条CPU指令,是不会被打断的,所以就是原子性的了。
使用CAS也需要注意的ABA问题:
案例说明ABA:假设有一个变量count=10 ,现在有三个线程A,B,C。当A,C同时读到count时,A,C的内存值和预期值都是10,这时A用CAS把count改成了20,改完后的同时,B进来读到count值为100,B把count改成10。C拿到执行权后,它发现内存值是10 ,预期也是10 ,C把count改成30。这样看似好像没什么问题,但是C是始终不知道A,B改过的count值的,这样是有一定风险存在的。

解决ABA:可以用AtomicStampedReference和AtomicMarkableReference类。这两个类是干啥的呢?说出来大家其实都知道,其实就是给对象提供一个版本,当前版本如果被改了就会自动更新,像不像MVCC机制?
其原理就是:维护了一个Pair对象,这个Pair对象存储了我们的对象引用和一个stamp值,每次CAS比较的也就是两个Pair对象。

对于Atomic的一些实际应用和迭代器及它的fail-fast机制等等我也会在以后的文章中总结出,这里就不发散太多知识了,觉得这篇文章对你有帮助的话可以给博主点个关注或者赞!后期大量干货免费分享!二叉树和红黑树等等系列过几天有空就写,涉及知识点太多总结起来需要很多时间。望体谅!

你可能感兴趣的:(面试专题,Java基础部分)