HashMap、HashTable、ConcurrentHashMap复习笔记

196 阅读5分钟

面试官问:说下你对HashMap的理解

答:HashMap由数组加链表加红黑树组成。

/**
     * The default initial capacity - MUST be a power of two.
     */
    
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

    /**
     * The maximum capacity, used if a higher value is implicitly specified
     * by either of the constructors with arguments.
     * MUST be a power of two <= 1<<30.
     */
    static final int MAXIMUM_CAPACITY = 1 << 30;

    /**
     * The load factor used when none specified in constructor.
     */
    static final float DEFAULT_LOAD_FACTOR = 0.75f;

    /**
     * The bin count threshold for using a tree rather than list for a
     * bin.  Bins are converted to trees when adding an element to a
     * bin with at least this many nodes. The value must be greater
     * than 2 and should be at least 8 to mesh with assumptions in
     * tree removal about conversion back to plain bins upon
     * shrinkage.
     */
    static final int TREEIFY_THRESHOLD = 8;

    /**
     * The bin count threshold for untreeifying a (split) bin during a
     * resize operation. Should be less than TREEIFY_THRESHOLD, and at
     * most 6 to mesh with shrinkage detection under removal.
     */
    static final int UNTREEIFY_THRESHOLD = 6;

    /**
     * The smallest table capacity for which bins may be treeified.
     * (Otherwise the table is resized if too many nodes in a bin.)
     * Should be at least 4 * TREEIFY_THRESHOLD to avoid conflicts
     * between resizing and treeification thresholds.
     */
    static final int MIN_TREEIFY_CAPACITY = 64;

默认情况下,数组长度为16,加载因子是0.75。也就是说当key数组的长度超过12的时候,数组长度就由于原来的16*2=32。当HashMap插入数据时,会根据key的头16位和尾16尾进行hash运算,得到key值,这样可以让value在数据量增加的同时尽可能散步在各个value桶子里,减少hash碰撞。当hash碰撞时,会将value插入到链表或者红黑树中。在JDK1.7以前是会在Value链表头部插法进行插入,JDK 1.8进行尾部插法进行插入。当前链表长度超过8的时候,就会自动转化为红黑树。当红黑树上长度少于6的时候,会自动转化为链表。红黑树的查找时间复杂度是O(Logn),链表是O(n)。当hashmap根据key来获取value时候,先查到的key对应的hash值找到对应的桶子,然后根据equals方法找到对应的value取出。

HashMap弊端?

1、当数据量大时,扩容会消耗性能。 所以在能够确定数据量的时候,根据情况一次初始化好容量,避免数组扩容过程中内存数据的拷贝导致的性能问题。或者开辟多个HashMap存储。

2、线程不安全。当put数据时,有可能对原有数组进行扩容和hash运算。在多线程的环境下,数组扩容导致内存的额外开销,和有可能数据存储出现问题。

3、死循环问题,CPU 100%问题。当多线程put数据时,可能触发扩容,也会进行rehash操作,如果hash值相同,则有可能链表出现2个元素,next指针指向形成闭环,造成在HashMap使用get获取值的时候,值刚好又不在这个缓内部,就会产生死循环等致命问题。

HashMap与HashTable之间一些事

HashTable在初始化上和HashMap有一些不同。初始化的值默认是12。扩容的算法是2n+1.其他的最大区别是在方法中采用了synchronized关键字进行线程互斥,达到线程安全的效果。

HashMap与ConcurrentHashMap之间一些事

ConcurrentHashMap允许null键值,HashTable也允许null值。其他的存储原理和HashMap没有太多不通过的地方。ConcurrentHashMap和HashMap值采用红黑树的方式优化都是在JDK 1.8以后才有。在链表数量超过8,key数组超过64的时候转化为红黑树。

ConcurrentHashMap和HashTable一些事

在JDK1.7以前。ConcurrentHashMap采用ReentrantLock和分段锁的方式,在计算size大小时,会采用不加锁的方式进行3次计算大小,如果结果准确,则直接返回,说明结果正确。如果结果不一致则采用进一步方案,对每一个Segment加锁,然后进行size计算得出结果。而HashTable是锁住整个链表。所以ConcurrentHashMap在多线程环境下效率明显要高。一个是可能不采用锁,第二个是锁的粒度降低。

在JDK1.8以后,ConcurrentHashMap采用CAS+synchronized+Node+红黑树的方式。锁的粒度降低了。另外JVM团队可以放弃对synchronized关键字的持续优化。基于JVM层面,可塑性更强,使用更简洁,性能会越来越好。put数据的时候,如果没有hash冲突则使用CAS的方式插入数据。如果有hash冲突则通过synchronized加锁的方式进行。另外在JDK 1.8的扩容过程在多线程环境下一起协作扩容,从而提高了效率。

总结与思考

其实可以看出JDK1.8版本的ConcurrentHashMap的数据结构已经接近HashMap,相对而言,ConcurrentHashMap只是增加了同步的操作来控制并发,从JDK1.7版本的ReentrantLock+Segment+HashEntry,到JDK1.8版本中synchronized+CAS+HashEntry+红黑树,相对而言,总结如下思考

  1. JDK1.8的实现降低锁的粒度,JDK1.7版本锁的粒度是基于Segment的,包含多个HashEntry,而JDK1.8锁的粒度就是HashEntry(首节点)
  2. JDK1.8版本的数据结构变得更加简单,使得操作也更加清晰流畅,因为已经使用synchronized来进行同步,所以不需要分段锁的概念,也就不需要Segment这种数据结构了,由于粒度的降低,实现的复杂度也增加了
  3. JDK1.8使用红黑树来优化链表,基于长度很长的链表的遍历是一个很漫长的过程,而红黑树的遍历效率是很快的,代替一定阈值的链表,这样形成一个最佳拍档
  4. JDK1.8为什么使用内置锁synchronized来代替重入锁ReentrantLock,我觉得有以下几点
    1. 因为粒度降低了,在相对而言的低粒度加锁方式,synchronized并不比ReentrantLock差,在粗粒度加锁中ReentrantLock可能通过Condition来控制各个低粒度的边界,更加的灵活,而在低粒度中,Condition的优势就没有了
    2. JVM的开发团队从来都没有放弃synchronized,而且基于JVM的synchronized优化空间更大,使用内嵌的关键字比使用API更加自然
    3. 在大量的数据操作下,对于JVM的内存压力,基于API的ReentrantLock会开销更多的内存,采用了空间换时间的方式,虽然不是瓶颈,但是也是一个选择依据。