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