二、Map
1)HashMap:
HashMap实现了Map接口,即允许放入key为null的元素,也允许插入value为null的元素;除该类未实现同步外,其余跟Hashtable大致相同;跟TreeMap不同,该容器不保证元素顺序,根据需要该容器可能会对元素重新哈希,元素的顺序也会被重新打散,因此不同时间迭代同一个HashMap的顺序可能会不同。 根据对冲突的处理方式不同,哈希表有两种实现方式,一种开放地址方式(Open addressing),另一种是冲突链表方式(Separate chaining with linked lists)。 HashMap采用的是冲突链表方式。
HashMap可以看做是数组和链表结合组成的复合结构,数组被分为一个个桶,通过哈希值决定了键值对在这个数组的寻址;哈希值相同的键值对,则以链表形式存储。
HashMap的设计与实现主要围绕下面几个方面:
* HashMap内部实现基本点分析。
* 容量(capacity)和负载系数(load factor)
* 树化
拉链法的工作原理:
HashMap map = new HashMap<>();
map.put("K1", "V1");
map.put("K2", "V2");
map.put("K3", "V3");
新建一个 HashMap,默认大小为 16;
插入
插入
插入
应该注意到链表的插入是以头插法方式进行的,例如上面的
查找需要分成两步进行:
计算键值对所在的桶;
在链表上顺序查找,时间复杂度显然和链表的长度成正比。
(1)get()方法的原理
使用Get方法根据Key来查找Value的时候,发生了什么呢?首先通过hash()函数得到对应bucket的下标index,然后依次遍历冲突链表,通过key.equals(k)方法来判断是否是要找的那个entry。
上图中hash(k)&(table.length-1)等价于hash(k)%table.length,原因是HashMap要求table.length必须是2的指数,因此table.length-1就是二进制低位全是1,跟hash(k)相与会将哈希值的高位全抹掉,剩下的就是余数了。
(2)put()方法的原理
put(K key, V value)方法是将指定的key, value对添加到map里。该方法首先会对map做一次查找,看是否包含该元组,如果已经包含则直接返回,查找过程类似于getEntry()方法;如果没有找到,则会通过addEntry(int hash, K key, V value, int bucketIndex)方法插入新的entry,插入方式为头插法。因为HashMap的长度是有限的,当插入的Entry越来越多时,再完美的Hash函数也难免会出现index冲突的情况。使用头插法是因为HashMap的发明者认为,后插入的Entry被查找的可能性更大。
publicVput(Kkey,Vvalue) {
if (table == EMPTY_TABLE) {
inflateTable(threshold);
}
// 键为 null 单独处理
if (key == null)
returnputForNullKey(value);
int hash =hash(key);
// 确定桶下标
int i = indexFor(hash, table.length);
// 先找出是否已经存在键为 key 的键值对,如果存在的话就更新这个键值对的值为 value
for (Entry
Objectk;
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
VoldValue= e.value;
e.value =value;
e.recordAccess(this);
returnoldValue;
}
}
modCount++;
// 插入新键值对
addEntry(hash, key, value, i);
return null;
}
HashMap 允许插入键为 null 的键值对。但是因为无法调用 null 的 hashCode() 方法,也就无法确定该键值对的桶下标,只能通过强制指定一个桶下标来存放。HashMap使用第 0 个桶存放键为null 的键值对。
privateVputForNullKey(Vvalue) {
for (Entry
if (e.key == null) {
VoldValue= e.value;
e.value =value;
e.recordAccess(this);
returnoldValue;
}
}
modCount++;
addEntry(0, null, value, 0);
return null;
}
使用链表的头插法,也就是新的键值对插在链表的头部,而不是链表的尾部。
void addEntry(inthash,Kkey,Vvalue,intbucketIndex) {
if ((size >= threshold) && (null !=table[bucketIndex])) {
resize(2 * table.length);
hash= (null != key) ? hash(key) : 0;
bucketIndex= indexFor(hash, table.length);
}
createEntry(hash, key, value, bucketIndex);
}
void createEntry(inthash,Kkey,Vvalue,intbucketIndex) {
Entry
// 头插法,链表头部指向新的键值对
table[bucketIndex]= new Entry<>(hash, key, value, e);
size++;
}
Entry(inth,Kk,Vv,Entry
value=v;
next=n;
key=k;
hash=h;
}
(3)计算位置的原理
很多操作都需要先确定一个键值对所在的桶下标。
int hash =hash(key);
int i = indexFor(hash, table.length);
计算 hash 值:
final inthash(Objectk) {
int h =hashSeed;
if (0 != h && k instanceofString) {
returnsun.misc.Hashing.stringHash32((String) k);
}
h^= k.hashCode();
// 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);
}
public final inthashCode() {
returnObjects.hashCode(key) ^Objects.hashCode(value);
}
取模:
令 x = 1<<4,即 x 为 2 的 4 次方,它具有以下性质:
x : 00010000
x-1 : 00001111
令一个数 y 与 x-1 做与运算,可以去除 y 位级表示的第 4 位以上数:
y : 10110010
x-1 : 00001111
y&(x-1) : 00000010
这个性质和 y 对 x 取模效果是一样的:
y : 10110010
x : 00010000
y%x : 00000010
我们知道,位运算的代价比求模运算小的多,因此在进行这种计算时用位运算的话能带来更高的性能。
确定桶下标的最后一步是将 key的 hash 值对桶个数取模:hash%capacity,如果能保证capacity 为 2 的 n 次方,那么就可以将这个操作转换为位运算。
static int indexFor(int h, intlength) {
return h & (length-1);
}
HashMap的初始长度是16,并且每次自动扩展或手动初始化时,长度必须是2的幂。
index = HashCode(Key) & (Length - 1)
下面我们以值为“book”的Key来演示整个过程:
1.计算book的hashcode,结果为十进制的3029737,二进制的101110001110101110
1001。
2.假定HashMap长度是默认的16,计算Length-1的结果为十进制的15,二进制的1111。
3.把以上两个结果做与运算,101110001110101110 1001 & 1111 = 1001,十进制是9,所以 index=9。
可以说,Hash算法最终得到的index结果,完全取决于Key的Hashcode值的最后几位。
为什么长度必须是16或者2的幂?比如HashMap长度是10会怎么样?
假设HashMap的长度是10,重复刚才的运算步骤:
HashCode:1011100011101011101001
Length-1: 1001
Index: 1001
单独看这个结果,表面上并没有问题。我们再来尝试一个新的HashCode 101110001110101110 1011:
HashCode:1011100011101011101011
Length-1: 1001
Index: 1001
让我们再换一个HashCode 101110001110101110 1111试试:
HashCode:1011100011101011101111
Length-1: 1001
Index: 1001
是的,虽然HashCode的倒数第二第三位从0变成了1,但是运算的结果都是1001。也就是说,当HashMap长度为10的时候,有些index结果的出现几率会更大,而有些index结果永远不会出现(比如0111)!
这样,显然不符合Hash算法均匀分布的原则。
反观长度16或者其他2的幂,Length-1的值是所有二进制位全为1,这种情况下,index的结果等同于HashCode后几位的值。只要输入的HashCode本身分布均匀,Hash算法的结果就是均匀的。
(4)扩容原理
设 HashMap 的 table 长度为 M,需要存储的键值对数量为 N,如果哈希函数满足均匀性的要求,那么每条链表的长度大约为 N/M,因此平均查找次数的复杂度为 O(N/M)。
为了让查找的成本降低,应该尽可能使得 N/M 尽可能小,因此需要保证 M 尽可能大,也就是说 table 要尽可能大。HashMap 采用动态扩容来根据当前的 N 值来调整 M 值,使得空间效率和时间效率都能得到保证。
HashMap的容量是有限的。当经过多次元素插入,使得HashMap达到一定饱和度时,Key映射位置发生冲突的几率会逐渐提高。这时候,HashMap需要扩展它的长度,也就是进行Resize。
和扩容相关的参数主要有:capacity、size、threshold 和 load_factor。
参数 含义
capacity table 的容量大小,默认为 16。需要注意的是 capacity 必须保证为 2 的 n 次方。
size 键值对数量。
threshold size 的临界值,当 size 大于等于 threshold 就必须进行扩容操作。
loadFactor 装载因子,table 能够使用的比例,threshold = capacity * loadFactor。默认值为0.75f。
衡量HashMap是否进行Resize的条件如下:
HashMap.Size >=threshold= Capacity * LoadFactor
HaashMap的Resize不是简单的把长度扩大,而是经过下面两个步骤:
1. 扩容
创建一个新的Entry空数组,长度是原数组的2倍。
2. ReHash
遍历原Entry数组,把所有的Entry重新Hash到新数组。为什么要重新Hash呢?因为长度扩大后,Hash的规则也随之改变。
让我们回顾一下Hash公式:
index = HashCode(Key)&(Length - 1)
当原数组长度为8时,Hash运算是和111B做与运算;新数组长度为16,Hash运算是和1111B做与运算。Hash结果显然不同。
从下面的添加元素代码中可以看出,当需要扩容时,令capacity 为原来的两倍。
void addEntry(inthash,Kkey,Vvalue,intbucketIndex) {
Entry
table[bucketIndex]= new Entry<>(hash, key, value, e);
if (size++ >=threshold)
resize(2 * table.length);
}
扩容使用resize() 实现,需要注意的是,扩容操作同样需要把oldTable 的所有键值对重新插入newTable 中,因此这一步是很费时的。
void resize(intnewCapacity) {
Entry[] oldTable =table;
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold=Integer.MAX_VALUE;
return;
}
Entry[] newTable = newEntry[newCapacity];
transfer(newTable);
table=newTable;
threshold= (int)(newCapacity *loadFactor);
}
void transfer(Entry[] newTable) {
Entry[] src =table;
int newCapacity = newTable.length;
for (int j = 0; j < src.length; j++) {
Entry
if (e != null) {
src[j]= null;
do{
Entry
int i = indexFor(e.hash, newCapacity);
e.next =newTable[i];
newTable[i]=e;
e=next;
}while (e != null);
}
}
}
(5)高并发问题
Hashmap的Resize包含扩容和ReHash两个步骤,ReHash在并发的情况下可能会形成链表环。
public classHashMapInfiniteLoop
{
private static HashMap
map = new HashMap
public static void main(String[] args)
{
map.put(5, "C");
new Thread("Thread1")
{
public void run() {
map.put(7, "B");
System.out.println(map);
};
}.start();
new Thread("Thread2")
{
public void run() {
map.put(3, "A);
System.out.println(map);
};
}.start();
}
}
其中,map初始化为一个长度为2的数组,loadFactor=0.75,threshold=2*0.75=1,也就是说当put第二个key的时候,map就需要进行resize。
通过设置断点让线程1和线程2同时debug到transfer方法的首行。注意此时两个线程已经成功添加数据。放开thread1的断点至transfer方法的“Entry next = e.next;” 这一行;然后放开线程2的的断点,让线程2进行resize。结果如下图:
注意,Thread1的e指向了key(3),而next指向了key(7),其在线程二rehash后,指向了线程二重组后的链表,此时e仍然指向key(3),next指向key(7)。
线程一被调度回来执行,先是执行newTalbe[i] = e,此时newTalbe[i]为key(3),然后是e = next,导致了e指向了key(7),而下一次循环的next = e.next导致了next指向了key(3)。
e.next = newTable[i]导致key(3).next指向了key(7)。注意:此时的key(7).next 已经指向了key(3),环形链表就这样出现了。
此时,问题还没有直接产生。当调用Get查找一个不存在的Key,而这个Key的Hash结果恰好等于3的时候,由于位置3带有环形链表,所以程序将会进入死循环!
如何杜绝这种情况产生呢?
在高并发场景下,我们通常使用另一个集合类ConcurrentHashMap,这个集合类兼顾了线程安全和性能。
(6)HashMap在Java8中的优化
HashMap是数组+链表+红黑树(JDK1.8增加了红黑树部分)实现的。从 JDK 1.8 开始,一个桶存储的链表长度大于 8 时会将链表转换为红黑树。
为什么HashMap要树化呢?
本质上这是一个安全问题,因为在元素放置过程中,如果一个对象哈希冲突,都被放置到同一个桶里,则会形成一个链表,我们知道链表的查询是线性的,会严重影响存取的性能。
JDK 1.7 使用分段锁机制来实现并发更新操作,核心类为 Segment,它继承自重入锁 ReentrantLock,并发度与 Segment 数量相等。
JDK 1.8 使用了 CAS 操作来支持更高的并发度,在 CAS 操作失败时使用内置锁 synchronized。
并且 JDK 1.8 的实现也在链表过长时(大于8)会转换为红黑树。
Java8中put方法的原理:
①.判断键值对数组table[i]是否为空或为null,否则执行resize()进行扩容;
②.根据键值key计算hash值得到插入的数组索引i,如果table[i]==null,直接新建节点 添加,转向⑥,如果table[i]不为空,转向③;
③.判断table[i]的首个元素是否和key一样,如果相同直接覆盖value,否则转向④,这里的相同指的是hashCode以及equals;
④.判断table[i] 是否为treeNode,即table[i] 是否是红黑树,如果是红黑树,则直接在树中插入键值对,否则转向⑤;
⑤.遍历table[i],判断链表长度是否大于8,大于8的话把链表转换为红黑树,在红黑树中执行插入操作,否则进行链表的插入操作;遍历过程中若发现key已经存在直接覆盖value即可;
⑥.插入成功后,判断实际存在的键值对数量size是否超多了最大容量threshold,如果超过,进行扩容。
与 HashTable 的比较
HashTable 使用 synchronized 来进行同步。
HashMap 可以插入键为 null 的 Entry。
HashMap 的迭代器是 fail-fast 迭代器。
HashMap 不能保证随着时间的推移 Map 中的元素次序是不变的。
2)LinkedHashMap
LinkedHashMap实现了Map接口,即允许放入key为null的元素,也允许插入value为null的元素。从名字上可以看出该容器是linked list和HashMap的混合体,也就是说它同时满足HashMap和linked list的某些特性。可将LinkedHashMap看作采用linked list增强的HashMap。
事实上LinkedHashMap是HashMap的直接子类,二者唯一的区别是LinkedHashMap在HashMap的基础上,采用双向链表(doubly-linked list)的形式将所有entry连接起来,这样是为保证元素的迭代顺序跟插入顺序相同。上图给出了LinkedHashMap的结构图,主体部分跟HashMap完全一样,多了header指向双向链表的头部(是一个哑元),该双向链表的迭代顺序就是entry的插入顺序。
除了可以保迭代历顺序,这种结构还有一个好处:迭代LinkedHashMap时不需要像HashMap那样遍历整个table,而只需要直接遍历header指向的双向链表即可,也就是说LinkedHashMap的迭代时间就只跟entry的个数相关,而跟table的大小无关。
(1)put()方法
put(K key, V value)方法是将指定的key, value对添加到map里。该方法首先会对map做一次查找,看是否包含该元组,如果已经包含则直接返回,查找过程类似于get()方法;如果没有找到,则会通过addEntry(int hash, K key, V value, int bucketIndex)方法插入新的entry。
注意,这里的插入有两重含义:
1.从table的角度看,新的entry需要插入到对应的bucket里,当有哈希冲突时,采用头插
法将新的entry插入到冲突链表的头部。
2.从header的角度看,新的entry需要插入到双向链表的尾部。
(2)remove()方法
remove(Object key)的作用是删除key值对应的entry,该方法的具体逻辑是在removeEntryForKey(Object key)里实现的。removeEntryForKey()方法会首先找到key值对应的entry,然后删除该entry(修改链表的相应引用)。查找过程跟get()方法类似。
注意,这里的删除也有两重含义:
1.从table的角度看,需要将该entry从对应的bucket里删除,如果对应的冲突链表不空,
需要修改冲突链表的相应引用。
2.从header的角度来看,需要将该entry从双向链表中删除,同时修改链表中前面以及后
面元素的相应引用。
JDK1.8中的LinkedHashMap:
LinkedHashMap 最重要的是以下用于维护顺序的函数,它们会在 put、get 等方法中调用。
void afterNodeAccess(Node p) { }
void afterNodeInsertion(boolean evict) { }
afterNodeAccess()
当一个节点被访问时,如果accessOrder 为true,则会将该节点移到链表尾部。也就是说指定为 LRU顺序之后,在每次访问一个节点时,会将这个节点移到链表尾部,保证链表尾部是最近访问的节点,那么链表首部就是最近最久未使用的节点。
void afterNodeAccess(Node
LinkedHashMap.Entry
if (accessOrder && (last = tail) !=e) {
LinkedHashMap.Entry
(LinkedHashMap.Entry
p.after = null;
if (b == null)
head=a;
else
b.after =a;
if (a != null)
a.before =b;
else
last=b;
if (last == null)
head=p;
else{
p.before =last;
last.after =p;
}
tail=p;
++modCount;
}
}
afterNodeInsertion()
在 put 等操作之后执行,当removeEldestEntry() 方法返回 true 时会移除最晚的节点,也就是链表首部节点 first。evict 只有在构建 Map 的时候才为false,在这里为true。
void afterNodeInsertion(boolean evict) { // possibly remove eldest
LinkedHashMap.Entry
if (evict && (first = head) != null &&removeEldestEntry(first)) {
Kkey= first.key;
removeNode(hash(key), key,null, false, true);
}
}
removeEldestEntry() 默认为 false,如果需要让它为true,需要继承LinkedHashMap 并且覆盖这个方法的实现,这在实现 LRU的缓存中特别有用,通过移除最近最久未使用的节点,从而保证缓存空间足够,并且缓存的数据都是热点数据。
protected booleanremoveEldestEntry(Map.Entry
return false;
}
LRU缓存
以下是使用LinkedHashMap 实现的一个 LRU缓存:
* 设定最大缓存空间 MAX_ENTRIES 为 3;
* 使用 LinkedHashMap 的构造函数将 accessOrder 设置为true,开启LRU 顺序;
* 覆盖 removeEldestEntry() 方法实现,在节点多于MAX_ENTRIES 就会将最近最久未使用的数据移除。
class LRUCache
private static final int MAX_ENTRIES = 3;
protected boolean removeEldestEntry(Map.Entryeldest) {
return size() > MAX_ENTRIES;
}
LRUCache() {
super(MAX_ENTRIES, 0.75f, true);
}
}
public static void main(String[] args) {
LRUCache
cache.put(1, "a");
cache.put(2, "b");
cache.put(3, "c");
cache.get(1);
cache.put(4, "d");
System.out.println(cache.keySet());
}
[3, 1, 4]
3)TreeMap:
TreeMap实现了SortedMap接口,也就是说会按照key的大小顺序对Map中的元素进行排序,key大小的评判可以通过其本身的自然顺序(natural ordering),也可以通过构造时传入的比较器(Comparator)。
TreeMap底层通过红黑树(Red-Black tree)实现,也就意味着containsKey(), get(), put(), remove()都有着log(n)的时间复杂度。
4)HashTable:
HashMap是支持null键和null值的,而HashTable在遇到null时,会抛出NullPointer
Exception异常。这并不是因为HashTable有什么特殊的实现层面的原因导致不能支持null键和null值,这仅仅是因为HashMap在实现时对null做了特殊处理,将null的hashCode值定为了0,从而将其存放在哈希表的第0个bucket中。
HashMap/HashTable内部用Entry数组实现哈希表,而对于映射到同一个哈希桶(数组的同一个位置)的键值对,使用Entry链表来存储(解决hash冲突)。
HashTable默认的初始大小为11,之后每次扩充为原来的2n+1。HashMap默认的初始化大小为16,之后每次扩充为原来的2倍。
HashTable是同步的,HashMap不是,也就是说HashTable在多线程使用的情况下,不需要做额外的同步,而HashMap则不行。
5)WeakHashMap
存储结构
WeakHashMap 的 Entry 继承自 WeakReference,被 WeakReference 关联的对象在下一次垃圾回收时会被回收。
WeakHashMap 主要用来实现缓存,通过使用 WeakHashMap 来引用缓存对象,由 JVM对这部分缓存进行回收。
private static class Entry
ConcurrentCache
Tomcat 中的 ConcurrentCache 使用了 WeakHashMap 来实现缓存功能。
ConcurrentCache 采取的是分代缓存:
经常使用的对象放入 eden 中,eden 使用 ConcurrentHashMap 实现,不用担心会被回收(伊甸园);
不常用的对象放入 longterm,longterm 使用 WeakHashMap 实现,这些老对象会被垃圾收集器回收。
当调用 get() 方法时,会先从 eden 区获取,如果没有找到的话再到 longterm 获取,当从 longterm 获取到就把对象放入 eden 中,从而保证经常被访问的节点不容易被回收。
当调用 put() 方法时,如果 eden 的大小超过了 size,那么就将 eden 中的所有对象都放入 longterm 中,利用虚拟机回收掉一部分不经常使用的对象。
public final class ConcurrentCache {
private final intsize;
private final Map
private final Map
public ConcurrentCache(int size) {
this.size =size;
this.eden = new ConcurrentHashMap<>(size);
this.longterm = new WeakHashMap<>(size);
}
publicVget(Kk) {
Vv= this.eden.get(k);
if (v == null) {
v= this.longterm.get(k);
if (v != null)
this.eden.put(k, v);
}
returnv;
}
public void put(Kk,Vv) {
if (this.eden.size() >=size) {
this.longterm.putAll(this.eden);
this.eden.clear();
}
this.eden.put(k, v);
}
}
三、Set
1)HashSet:
HashSet是对HashMap的简单包装,对HashSet的函数调用都会转换成合适的HashMap方法
2)TreeSet:
TreeSet是对TreeMap的简单包装,对TreeSet的函数调用都会转换成合适的TreeMap方法
3)LinkedHashSet:
LinkedHashSet是对LinkedHashMap的简单包装,对LinkedHashSet的函数调用都会转换成合适的LinkedHashMap方法
四、Queue
PriorityQueue实现了Queue接口,不允许放入null元素;其通过堆实现,具体说是通过完全二叉树(complete binary tree)实现的小顶堆(任意一个非叶子节点的权值,都不大于其左右子节点的权值),也就意味着可以通过数组来作为PriorityQueue的底层实现。
Deque接口又有ArrayDeque和LinkedList、ConcurrentLinkedDeque实现类及BlockingDeque接口,其中BlockingDeque接口有LinkedBlockingDeque并发实现类。
Queue的实现
1)没有实现的阻塞接口的LinkedList: 实现了java.util.Queue接口和java.util.AbstractQueue接口
内置的不阻塞队列: PriorityQueue 和ConcurrentLinkedQueue
PriorityQueue 和ConcurrentLinkedQueue 类在 Collection
Framework 中加入两个具体集合实现。
PriorityQueue 类实质上维护了一个有序列表。加入到 Queue 中的元素根据它们的天然排序(通过其 java.util.Comparable 实现)或者根据传递给构造函数的 java.util.Comparator 实现来定位。
ConcurrentLinkedQueue是基于链接节点的、线程安全的队列。并发访问不需要同步。因为它在队列的尾部添加元素并从头部删除它们,所以只要不需要知道队列的大小,对公共集合的共享访问就可以工作得很好。收集关于队列大小的信息会很慢,需要遍历队列。
2)实现阻塞接口的:
java.util.concurrent中加入了BlockingQueue 接口和五个阻塞队列类。它实质上就是一种带有一点扭曲的FIFO 数据结构。不是立即从队列中添加或者删除元素,线程执行操作阻塞,直到有空间
或者元素可用。
五个队列所提供的各有不同:
* ArrayBlockingQueue :一个由数组支持的有界队列。
* LinkedBlockingQueue :一个由链接节点支持的可选有界队列。
* PriorityBlockingQueue :一个由优先级堆支持的无界优先级队列。
* DelayQueue :一个由优先级堆支持的、基于时间的调度队列。
* SynchronousQueue :一个利用 BlockingQueue 接口的简单聚集(rendezvous)机制。
Deque的使用场景
在一般情况,不涉及到并发的情况下,有两个实现类,可根据其自身的特性进行选择,分别是:
LinkedList 大小可变的链表双端队列,允许元素为插入null。
ArrayDeque 大下可变的数组双端队列,不允许插入null。
ConcurrentLinkedDeque 大小可变且线程安全的链表双端队列,非阻塞,不允许插入null。
LinkedBlockingDeque 为线程安全的双端队列,在队列为空的情况下,获取操作将会阻塞,直到有元素添加。
注意:LinkedList 和 ArrayDeque 是线程不安全的容器。