HashMap、HashTabe、CurrentHashMap
线程安全
HashMap线程不安全
HashTabe线程安全通过方法加锁来实现
CurrentHashMap线程安全,使用了分段锁来实现
值
HashTable不允许为null,key和value都不可以,HashTable直接使用对象的hashCode,hash数组默认大小为11,扩容方式为old * 2 + 1
HashMap允许null,key和value都可以,HashMap重新计算hash值,并且用于代替求模。hash数组默认大小为16,并且一定是2的指数,
HashMap的实现
JDK1.7 使用数组+链表实现(Entry)使用头插法
JDK1.8 使用数组+链表+红黑树实现(Node)使用尾插法
1.8使用尾插法的原因
头插法:新来的值会取代原有的值,原有的值就会顺推到链表中,(后来插入的值被获取到的可能性更大一些,提升查找效率)。
-
Infinite Loop
- 单链表的头插入方式,同一位置上新元素总会被放在链表的头部位置,在旧数组中同一条Entry链上的元素,通过重新计算索引位置后,可能被放到新数组的不同位置上。一旦几线程调用完成,就可能出现环形链表。这时候去取值,悲剧就出现了(Infinite Loop)。
- 造成原因就是扩容转移过程中修改了原来链表中节点的引用关系。
- 使用尾插法不会引起死循环,是因为保持之前节点的引用关系。
尾插法:Capacity:16(1<<4、16),LoadFactor:0.75f,
使用16是为了算法均匀分布的原则,一个Key的hashcode的二进制&
(length - 1)15的二进制结果为:Key的hashCode的最后几位。
因为在使用不是2的幂的数字的时候,length - 1
的值是所有二进制位全为1,这种情况下,index结果等同于HashCode后几位的值。
只要输入的HashCode本事分布均匀、Hash算法的结果就是均匀的。
-
在进行扩容的时候
- resize:创建一个新的Entry数组,长度是原数组的2倍。
- reHash:遍历原Entry数组,把所有的Entry重新Hash到新的数组,重新hash的公式为
index = hashCode(Key) & (length - 1)
- 使用头插会改变链表上的顺序,但是如果使用尾插,在扩容的时候会保持链表元素原本的顺序,就不会造成环的问题了。
虽然他不会造成死循环,但是通过put/get方法都没有同步锁,多线程情况最容易出现的就是:无法保证上一秒put的值,下一秒get的是原值,所以线程安全无法保障。
CurrentHashMap的实现
HashTable效率低下的原因是访问所有HashTable的线程必须竞争同一把锁。
所以CurrentHashMap使用了锁分段技术,容器里有多把锁,每一把锁用于锁容器里的一部分数据,当多线程访问容器里不同段的数据时,线程间就不会存在锁竞争,从而有效提高并发效率。当一个线程占用锁访问其中一个段的数据的时候,其他数据也能被其他线程访问。
对于get操作,一般不需要加锁,除非读到的值是空的时候才会加锁重读。
对于put操作,首先定位到对应的Segment,然后在Segment里进行插入操作,插入操作需要经历两个步骤:
- 判断是否需要对Segment里的HashEntry数组进行扩容
- 定位添加元素的位置然后放在HashEntry数组里
equals和hashCode
未重写equals方法我们是继承了Object的equals方法,那里的equals是比较两个对象的内存地址。
对于值对象,==比较两个对象的值
对于引用对象,比较的是两个对象的地址。
为什么要重写hashCode:需要对hashCode方法进行重写,以保证相同的对象返回相同的hash。
对于HashMap访问值的时候,根据key的hash找到index,假如index是链表的话,就需要重写equal方法来查找需要的值,这时候同样需要重写heshCode方法。避免对同一链表的对象无法分辨。
为什么Map桶中个数超过8才转为红黑树
这个问题是京东java面试官的问题?
通过查看源码
/**
* 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;
根据泊松分布,在负载因子默认为0.75的时候,单个hash槽内元素个数为8的概率小于百万分之一,所以将7作为一个分水岭,等于7的时候不转换,大于等于8的时候才进行转换,小于等于6的时候就化为链表。
This map usually acts as a binned (bucketed) hash table, but
when bins get too large, they are transformed into bins of TreeNodes,
each structured similarly to those in java.util.TreeMap
TreeNodes占用空间是普通Nodes的两倍,所以只有当bin包含足够多的节点时才会转成TreeNodes,而是否足够多就是由TREEIFY_THRESHOLD的值决定的。当bin中节点数变少时,又会转成普通的bin。并且我们查看源码的时候发现,链表长度达到8就转成红黑树,当长度降到6就转成普通bin。
这样就解析了为什么不是一开始就将其转换为TreeNodes,而是需要一定节点数才转为TreeNodes,说白了就是trade-off,空间和时间的权衡。
Because TreeNodes are about twice the size of regular nodes, we
use them only when bins contain enough nodes to warrant use
(see TREEIFY_THRESHOLD). And when they become too small (due to
removal or resizing) they are converted back to plain bins. In
usages with well-distributed user hashCodes, tree bins are
rarely used. Ideally, under random hashCodes, the frequency of
nodes in bins follows a Poisson distribution
(http://en.wikipedia.org/wiki/Poisson_distribution) with a
parameter of about 0.5 on average for the default resizing
threshold of 0.75, although with a large variance because of
resizing granularity. Ignoring variance, the expected
occurrences of list size k are (exp(-0.5)*pow(0.5, k)/factorial(k)).
The first values are:
0: 0.60653066
1: 0.30326533
2: 0.07581633
3: 0.01263606
4: 0.00157952
5: 0.00015795
6: 0.00001316
7: 0.00000094
8: 0.00000006
more: less than 1 in ten million
当hashCode离散性很好的时候,树型bin用到的概率非常小,因为数据均匀分布在每个bin中,几乎不会有bin中链表长度会达到阈值。
但是在随机hashCode下,离散性可能会变差,然而JDK又不能阻止用户实现这种不好的hash算法,因此就可能导致不均匀的数据分布。不过理想情况下随机hashCode算法下所有bin中节点的分布频率会遵循泊松分布,我们可以看到,一个bin中链表长度达到8个元素的概率为0.00000006,几乎是不可能事件。所以,之所以选择8,不是拍拍屁股决定的,而是根据概率统计决定的。由此可见,发展30年的Java每一项改动和优化都是非常严谨和科学的。