双列集合的学习
双列集合定义了映射java.util.Map,是以键值对的方式来存储元素的。接口有四种常用的实现类,分别为HashMap、Hashtable、LinkedHashMap和TreeMap
HashMap:它根据键的hashCode值存储数据,大多数情况下可以直接定位到它的值,因而具有很快的访问速度,但它的遍历顺序是不确定的。HashMap最多允许一条记录的键位null,允许多条记录的值位null。是线程不安全的
Hashtable:与HashMap类似,不同的是它继承自Dictionary类,并且是线程安全的。但其并发性不如ConcurrentHashMap
LinkedHashMap:是HashMap的一个子类,保存了记录的插入顺序,在用迭代器遍历LinkedHashMap时,先得到的记录是先插入的,也可以在构造时带参数,按照访问次序排序
TreeMap:底层采用了红黑树的数据结构,实现了SortedMap接口,能够把它保存的记录根据键排序,默认是按健值的升序排序,也可以指定排序的比较器。存储对象时,key对象必须实现Comparable接口或者在构造TreeMap时传入自定义的Comparator,否则会在允许时抛出java.lang.ClassCastException类型的异常
HashMap的详细解释
数据结构:采用数组+链表的结构,以下是jdk8时的数据结构
**HashMap的默认初始化容量为16,之后每次扩充,容量表为原来的2倍。**如果在创建时指定初始化容量,HashMap则会将其扩充为2的幂次方大小
1、HashMap存储数据的过程(JDK1.8):
HashMap通过key的hashcode经过扰动函数处理得到hash值,通过(n-1)&hash判断当前元素存放的位置,如果存在元素,则判断该元素与要存入的元素的hash值以及key是否相同,相同则覆盖,不相同则采用拉链法解决冲突
所谓的扰动函数即求hash值得过程,可以减少碰撞,JDK1.8源码如下:
static final int hash(Object key) {
int h;
// key.hashCode():返回散列值也就是hashcode
// ^ :按位异或
// >>>:无符号右移,忽略符号位,空位都以0补齐
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
即让key得hashcode值与右移16位的hashCode进行异或运算,求得hash值
JDK1.7求hash的源码如下:
static int hash(int h) {
// This function ensures that hashCodes that differ only by
// constant multiples at each bit position have a bounded
// number of collisions (approximately 8 at default load factor).
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
相较于JDK1.8的方法,1.7求hash的方法性能上会稍微差一点点,毕竟扰动了四次
拉链法就是:将链表和数组结合起来,即当遇到hash冲突的时候,就将冲突的值存储到链表中
JDK1.8之后,有了较大的改变,当链表的长度大于阈值(默认为8)时,将链表转化为红黑树,减少了搜索的时间。
2、HashMap的扩容机制
在·HashMap中有个非常重要的字段,即Node[] table哈希桶数组(数组),默认长度为16,其长度必须为2的n次方。采用这种设计,主要是为了在取模和扩容时做优化,减少hash冲突。
取模时优化:在求hash值时,**“取余(%)操作中如果除数是2的幂次则等价于与其除数减一的与(&)操作(也就是说 hash%length==hash&(length-1)的前提是 length 是2的 n 次方;)**并且 采用二进制位操作 &,相对于%能够提高运算效率,这就解释了 HashMap 的长度为什么是2的幂次方。
扩容时优化: 先讲下HashMap是如何扩容的。当数组容量不够时,进行resize操作重新计算容量。扩容时,使用新的数组代替小容量的数组。并重新计算hash值,具体如下:
void resize(int newCapacity) { //传入新的容量
Entry[] oldTable = table; //引用扩容前的Entry数组
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) { //扩容前的数组大小如果已经达到最大(2^30)了
threshold = Integer.MAX_VALUE; //修改阈值为int的最大值(2^31-1),这样以后就不会扩容了
return;
}
Entry[] newTable = new Entry[newCapacity]; //初始化一个新的Entry数组
transfer(newTable); //!!将数据转移到新的Entry数组里
table = newTable; //HashMap的table属性引用新的Entry数组
threshold = (int)(newCapacity * loadFactor);//修改阈值
}
//将原有的Entry数组元素存储到新的Entry数组里
void transfer(Entry[] newTable) {
Entry[] src = table; //src引用了旧的Entry数组
int newCapacity = newTable.length;
for (int j = 0; j < src.length; j++) { //遍历旧的Entry数组
Entry<K,V> e = src[j]; //取得旧Entry数组的每个元素
if (e != null) {
src[j] = null;//释放旧Entry数组的对象引用(for循环后,旧的Entry数组不再引用任何对象)
do {
Entry<K,V> next = e.next;
int i = indexFor(e.hash, newCapacity); //!!重新计算每个元素在数组中的位置
e.next = newTable[i]; //标记[1]
newTable[i] = e; //将元素放在数组上
e = next; //访问下一个Entry链上的元素
} while (e != null);
}
}
}
讲完扩容,那么来讲一讲容量采用2次幂上的优化,在进行rehash的时候,如果容量采用的是2次幂长度,那么重新计算的位置要么在元位置,要么在元位置+oldCap;
示例:图(a)表示扩容前的key1和key2两种key确定索引位置的示例,图(b)表示扩容后key1和key2两种key确定索引位置的示例,其中hash1是key1对应的哈希与高位运算结果。
从中可以看出,重新计算的元素位置要么在原来的位置,要么在原来位置+oldCap位置,不需要像JDK1.7那样重新计算元素位置。
JDK1.8源码如下:
1 final Node<K,V>[] resize() {
2 Node<K,V>[] oldTab = table;
3 int oldCap = (oldTab == null) ? 0 : oldTab.length;
4 int oldThr = threshold;
5 int newCap, newThr = 0;
6 if (oldCap > 0) {
7 // 超过最大值就不再扩充了,就只好随你碰撞去吧
8 if (oldCap >= MAXIMUM_CAPACITY) {
9 threshold = Integer.MAX_VALUE;
10 return oldTab;
11 }
12 // 没超过最大值,就扩充为原来的2倍
13 else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
14 oldCap >= DEFAULT_INITIAL_CAPACITY)
15 newThr = oldThr << 1; // double threshold
16 }
17 else if (oldThr > 0) // initial capacity was placed in threshold
18 newCap = oldThr;
19 else { // zero initial threshold signifies using defaults
20 newCap = DEFAULT_INITIAL_CAPACITY;
21 newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
22 }
23 // 计算新的resize上限
24 if (newThr == 0) {
25
26 float ft = (float)newCap * loadFactor;
27 newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
28 (int)ft : Integer.MAX_VALUE);
29 }
30 threshold = newThr;
31 @SuppressWarnings({"rawtypes","unchecked"})
32 Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
33 table = newTab;
34 if (oldTab != null) {
35 // 把每个bucket都移动到新的buckets中
36 for (int j = 0; j < oldCap; ++j) {
37 Node<K,V> e;
38 if ((e = oldTab[j]) != null) {
39 oldTab[j] = null;
40 if (e.next == null)
41 newTab[e.hash & (newCap - 1)] = e;
42 else if (e instanceof TreeNode)
43 ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
44 else { // 链表优化重hash的代码块
45 Node<K,V> loHead = null, loTail = null;
46 Node<K,V> hiHead = null, hiTail = null;
47 Node<K,V> next;
48 do {
49 next = e.next;
50 // 原索引
51 if ((e.hash & oldCap) == 0) {
52 if (loTail == null)
53 loHead = e;
54 else
55 loTail.next = e;
56 loTail = e;
57 }
58 // 原索引+oldCap
59 else {
60 if (hiTail == null)
61 hiHead = e;
62 else
63 hiTail.next = e;
64 hiTail = e;
65 }
66 } while ((e = next) != null);
67 // 原索引放到bucket里
68 if (loTail != null) {
69 loTail.next = null;
70 newTab[j] = loHead;
71 }
72 // 原索引+oldCap放到bucket里
73 if (hiTail != null) {
74 hiTail.next = null;
75 newTab[j + oldCap] = hiHead;
76 }
77 }
78 }
79 }
80 }
81 return newTab;
82 }
HashMap的常见问题
1、1.8和1.7的hashMap性能对比
1.8链表采用的是尾插法,1.7采用的头插法。采用头插法就是能够提高插入的效率,但是也会容易出现逆序且环形链表死循环问题。但是在JDK1.8之后是因为加入了红黑树使用尾插法,能够避免出现逆序且链表死循环的问题。
1.8和1.7的计算hash方式不同(详见上面描述),1.8计算hash方式扰动次数少,减少了hash冲突
1.8和1.7在扩容的时候不同,1.8扩容进行元素重新定位时,不像1.7重新进行与运算,而是判断bit为1还是0,从而得到元素新的位置(原位置或者原位置+oldcap)
1.8和1.7的链表不同,1.8在链表达到某个阈值(默认为8),会将链表转变为红黑树,提高了效率
2、HashMap和Hashtable的不同
HashMap是线程不安全的,HashTable是线程安全的。从效率上考虑,建议采用HashTable。从安全上建议采用ConcurrentHashMap
HashMap中允许一个键的值为null,而Hashtable中不允许有健值为null的,一有便会抛出异常
初始化不指定容量时,HashTable的默认容量为11,之后的每次扩容,容量变为原来的2n+1.而HashMap的初始化容量为16,之后的每次扩容,将容量变为原来的两倍;如果指定初始化容量,Hashtable会直接使用给定的容量,而HashMap会将容量扩充为2的幂次方。
数据结构上,1.8后,HashMap链表长度达到8阈值时,会将链表转变为红黑树。而HashTable没有这样的机制
ConcurrentHashMap
1、JDK1.7的ConcurrentHashMap底层采用分段数据+链表的形式
由Segment数组结构和HashEntry数组结构组成,Segment实现了ReentrantLock,所以 Segment 是一种可重入锁,扮演锁的角色。HashEntry 用于存储键值对数据。
static class Segment<K,V> extends ReentrantLock implements Serializable {
}
一个Segment就包含一个HashEntry数组,每个HashEntry是一个链表结构的元素,每个Segment守护着HashEntry数组里元素,要操作HashEntry数组的数据时,必须首先获得对应的Segment的锁。
**JDK1.8之后取消了Segment的分段锁,采用CAS和synchronized来保证并发安全。**数据结构跟HashMap1.8的结构类似,数组+链表/红黑二叉树。Java 8在链表长度超过一定阈值(8)时将链表(寻址时间复杂度为O(N))转换为红黑树(寻址时间复杂度为O(log(N)))
整个看起来就像是优化过且线程安全的 HashMap,虽然在JDK1.8中还能看到 Segment 的数据结构,但是已经简化了属性,只是为了兼容旧版本;
2、Hashtable和HashMap的区别
底层数据结构: JDK1.7的 ConcurrentHashMap 底层采用 分段的数组+链表 实现,JDK1.8 采用的数据结构跟HashMap1.8的结构一样,数组+链表/红黑二叉树。Hashtable 和 JDK1.8 之前的 HashMap 的底层数据结构类似都是采用 数组+链表 的形式,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的;
实现线程安全的方式(重要): ① 在JDK1.7的时候,ConcurrentHashMap(分段锁) 对整个桶数组进行了分割分段(Segment),每一把锁只锁容器其中一部分数据,多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。 到了 JDK1.8 的时候已经摒弃了Segment的概念,而是直接用 Node 数组+链表+红黑树的数据结构来实现,并发控制使用 synchronized 和 CAS 来操作。(JDK1.6以后 对 synchronized锁做了很多优化) 整个看起来就像是优化过且线程安全的 HashMap,虽然在JDK1.8中还能看到 Segment 的数据结构,但是已经简化了属性,只是为了兼容旧版本;② Hashtable(同一把锁) :使用 synchronized 来保证线程安全,效率非常低下。当一个线程访问同步方法时,其他线程也访问同步方法,可能会进入阻塞或轮询状态,如使用 put 添加元素,另一个线程不能使用 put 添加元素,也不能使用 get,竞争会越来越激烈效率越低。