1.
Map接口有两个实现类:HashMap TreeMap HashTable(线程安全)
LinkedHashMap是HashMap的子类
Properties类是HashTable的子类
2. 源码解析
1. HashMap是有序的还是无序的 ? LinkedHashMap? TreeMap?
2. HashMap 容量 还有扩容机制
3. 为什么是线程不安全的?
public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable
(1)类的属性
加载因子:是哈希表在其容量自动增加之前可以达到多满的一种尺度。它衡量的是一个散列表的空间的使用程度
负载因子越大表示散列表的装填程度越高,反之愈小。对于使用链表法的散列表来说,查找一个元素的平均时间是O(1+a),因此如果负载因子越大,对空间的利用更充分,然而后果是查找效率的降低;如果负载因子太小,那么散列表的数据将过于稀疏,对空间造成严重浪费。系统默认负载因子为0.75,一般情况下我们是无需修改的。
关于这个,再说得细一点,之所以采用Hash散列进行存储,主要就是为了提高检索速度。
众所周知,有序数组存储数据,对数据的检索效率会很高,但是,插入和删除会有瓶颈产生。而链表存储数据,通常只能采用逐个比较的方法来检索数据(查找数据),但是,插入和删除的效率很高
//默认初始化化容量,即16 static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; //最大容量,即2的30次方 static final int MAXIMUM_CAPACITY = 1 << 30; //默认装载因子 static final float DEFAULT_LOAD_FACTOR = 0.75f; //HashMap内部的存储结构是一个数组,此处数组为空,即没有初始化之前的状态 static final Entry<?,?>[] EMPTY_TABLE = {}; //空的存储实体 transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE; //实际存储的key-value键值对的个数 transient int size; //阈值,当table == {}时,该值为初始容量(初始容量默认为16);当table被填充了,也就是为table分配内存空间后,threshold一般为 capacity*loadFactory。HashMap在进行扩容时需要参考threshold int threshold; //负载因子,代表了table的填充度有多少,默认是0.75 final float loadFactor; //用于快速失败,由于HashMap非线程安全,在对HashMap进行迭代时,如果期间其他线程的参与导致HashMap的结构发生变化了(比如put,remove等操作),需要抛出异常ConcurrentModificationException transient int modCount;
(2)构造方法
HashMap() 构造一个空的HashMap,默认初始容量为16,默认加载因子为0.75。 |
HashMap(int initialCapacity) 构造一个空的HashMap,指定初始容量,默认加载因子为0.75。 |
HashMap(int initialCapacity, float loadFactor) 构造一个空的HashMap,指定初始容量和加载因子。 |
HashMap(Map<? extends K,? extends V> m) 构造一个映射关系与指定 Map 相同的 HashMap。 |
在这四个构造方法中,其他三个构造方法都共同调用了第三个构造方法:
//其他三种构造方法最后都指向了该构造方法
public HashMap(int initialCapacity, float loadFactor) {
//检查初始容量是否小于0,是则抛出异常
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
//检查初始容量是否大于默认最大容量值,是则重置为MAXIMUM_CAPACITY
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
//检查加载因子是否合法
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
//指定加载因子
this.loadFactor = loadFactor;
//初始化阈值
threshold = initialCapacity;
//初始化函数,里面是空的,供子类调用
init();
}
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
public HashMap() {
this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
}
public HashMap(Map<? extends K, ? extends V> m) {
this(Math.max((int) (m.size() / DEFAULT_LOAD_FACTOR) + 1,
DEFAULT_INITIAL_CAPACITY), DEFAULT_LOAD_FACTOR);
inflateTable(threshold);
putAllForCreate(m);
}
3) put 操作
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
static class Entry<K,V> implements Map.Entry<K,V> { final K key; V value; Entry<K,V> next; int hash;
这里的Entry是什么?它也是维护着一个key-value映射关系,除了key和value,还有next引用(该引用指向当前table位置的链表),hash值(用来确定每一个Entry链表在table中位置)
public V put(K key, V value) {
//检查是否为空表,是则膨胀容量
if (table == EMPTY_TABLE) {
inflateTable(threshold);
}
//检查key是否为null,这个很熟悉吧
if (key == null)
return putForNullKey(value);
//计算key的hash值
int hash = hash(key);
//获取bucketIndex,即在table中存放的位置
int i = indexFor(hash, table.length);
//取出该索引下的Entry,遍历单链
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
//检查hash码是否相同,key是否相等
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
//该key已存在,取出对应的value并转移
V oldValue = e.value;
//存入新的value
e.value = value;
//该方法内容为空,供子类重写所用
e.recordAccess(this);
//返回对应的旧value
return oldValue;
}
}
//记录表结构修改次数;到了这里证明,该table中并不存在该key,向表中增加Entry
modCount++;
//增加Entry
addEntry(hash, key, value, i);
//返回空值
return null;
}
从源码中我们可以看到,put方法进行了如下操作:
1. HashMap是在put操作的时候才开始膨胀的;
2. 然后判断输入的key是否为空值,如果为空则调用putForNullKey(V)设入空key(原理差不多,但需要注意,空Key都是放在table[0]里面的);
3. hash(key)获取哈希码;
4. indexFor(hash, table.length)获取存放位置的索引;
5. 遍历table[i],检查是否存在,存在则覆盖并返回旧值;
6. 不存在,准备修改表结构,先记录次数;
7. 调用addEntry(hash, key, value, i)增加元素。
遍历Entry单链了,这个应该很好理解,Entry是以单链的形式存在的,用于解决hash碰撞时的存放问
看源码可以知道HashMap是可以把null当做key的,看下putForNullKey方法:
HashMap默认把null键的Entry放在数组的0位置,因为null无法获得hash值
private V putForNullKey(V value) { //查找链表中是否有null键 for (Entry<K,V> e = table[0]; e != null; e = e.next) { if (e.key == null) { V oldValue = e.value; e.value = value; e.recordAccess(this); return oldValue; } } modCount++; //如果链中查找不到,则把该null键插入 addEntry(0, null, value, 0); return null; }
2. 扩容resize方法
void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
//创建一个新的 Hash 表
Entry[] newTable = new Entry[newCapacity];
transfer(newTable, initHashSeedAsNeeded(newCapacity));
table = newTable;
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> next = e.next;
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}
还记得HashMap中的一个变量吗,threshold是2^30,这是容器的容量极限,还有一个变量size,这是指HashMap中键值对的数量,也就是node的数量
threshold = (int)Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
什么时候发生扩容?
还记得HashMap中的一个变量吗,threshold,这是容器的容量极限,还有一个变量size,这是指HashMap中键值对的数量,也就是node的数量
threshold = (int)Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
什么时候发生扩容?
当不断添加key-value,size大于了容量极限threshold时,会发生扩resize。
resize这里是个大坑,因为会导致死锁,而根本原因来自transfer方法,这个方法干的事情是把原来数组里的1-2-3链表transfer成了3-2-1,不太看得懂的同学可以看我下面的演示代码,本质是一样的:
package com.amuro.studyhashmap;
public class HashMapStudy
{
public static void main(String[] args)
{
Node n1 = new Node();
n1.data = 1;
Node n2 = new Node();
n2.data = 2;
Node n3 = new Node();
n3.data = 3;
n1.next = n2;
n2.next = n3;
printLinkedNode(n1);
System.out.println("------");
Node newHead = mockHashMapTransfer(n1);
printLinkedNode(newHead);
}
static class Node
{
int data;
Node next;
}
static void printLinkedNode(Node head)
{
while(head != null)
{
System.out.println(head.data);
head = head.next;
}
}
static Node mockHashMapTransfer(Node e)
{
Node newHead = null;
while(e != null)
{
Node next = e.next;
e.next = newHead;
newHead = e;
e = next;
System.out.print("");
}
return newHead;
}
}
结果输出:
1
2
3
_ _
3
2
1
transfer方法的本质就是这个,那为什么会导致死锁呢?简单分析一下: 但是容量不可能为2
我们假设有两个线程T1、T2,HashMap容量为2,T1线程放入key A、B、C、D、E。在T1线程中A、B、C Hash值相同,于是形成一个链接,假设为A->C->B,而D、E Hash值不同,于是容量不足,需要新建一个更大尺寸的hash表,然后把数据从老的Hash表中迁移到新的Hash表中(refresh)。这时T2进程闯进来了,T1暂时挂起,T2进程也准备放入新的key,这时也发现容量不足,也refresh一把。refresh之后原来的链表结构假设为C->A,之后T1进程继续执行,链接结构为A->C,这时就形成A.next=B,B.next=A的环形链表。一旦取值进入这个环形链表就会陷入死循环。
所以多线程场景下,建议使用ConcurrentHashMap,用到了分段锁的技术,后面有机会再讲。
最后整理一下put的步骤:
1. 传入key和value,判断key是否为null,如果为null,则调用putForNullKey,以null作为key存储到哈希表中;
2. 然后计算key的hash值,根据hash值搜索在哈希表table中的索引位置,若当前索引位置不为null,则对该位置的Entry链表进行遍历,如果链中存在该key,则用传入的value覆盖掉旧的value,同时把旧的value返回,结束;
3. 否则调用addEntry,用key-value创建一个新的节点,并把该节点插入到该索引对应的链表的头部。
四、get
public V get(Object key) {
//如果key为null,求null键
if (key == null)
return getForNullKey();
// 用该key求得entry
Entry<K,V> entry = getEntry(key);
return null == entry ? null : entry.getValue();
}
final Entry<K,V> getEntry(Object key) {
int hash = (key == null) ? 0 : hash(key);
for (Entry<K,V> e = table[indexFor(hash, table.length)];
e != null;
e = e.next) {
Object k;
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
}
return null;
}
put能看懂的同学看get应该毫无压力,调用hash(key)求得key的hash值,然后调用indexFor(hash)求得hash值对应的table的索引位置,然后遍历索引位置的链表,如果存在key,则把key对应的Entry返回,否则返回null。
从HashMap的结构和put原理我们也能理解为什么HashMap在遍历数据时,不能保证插入时的顺序。这时需要使用LinkedHashMap。
最后把java里map的四个实现类做个总结。
1.Hashmap 是一个最常用的Map,它根据键的HashCode值存储数据,根据键可以直接获取它的值,具有很快的访问速度,遍历时,取得数据的顺序是完全随机的。 HashMap最多只允许一条记录的键为Null;允许多条记录的值为 Null;HashMap不支持线程的同步,即任一时刻可以有多个线程同时写HashMap;可能会导致数据的不一致。如果需要同步,可以用 Collections的synchronizedMap方法使HashMap具有同步的能力,或者使用ConcurrentHashMap。
2.Hashtable与 HashMap类似,它继承自Dictionary类,不同的是:它不允许记录的键或者值为空;它支持线程的同步,即任一时刻只有一个线程能写Hashtable,因此也导致了 Hashtable在写入时会比较慢。
3.LinkedHashMap 是HashMap的一个子类,保存了记录的插入顺序,在用Iterator遍历LinkedHashMap时,先得到的记录肯定是先插入的.也可以在构造时用带参数,按照应用次数排序。在遍历的时候会比HashMap慢,不过有种情况例外,当HashMap容量很大,实际数据较少时,遍历起来可能会比 LinkedHashMap慢,因为LinkedHashMap的遍历速度只和实际数据有关,和容量无关,而HashMap的遍历速度和他的容量有关。
4.TreeMap实现SortMap接口,能够把它保存的记录根据键排序,默认是按键值的升序排序,也可以指定排序的比较器,当用Iterator 遍历TreeMap时,得到的记录是排过序的。
最后的最后再加个tip:
ConcurrentHashMap提供的线程安全是指他的put和get等操作是原子操作,是线程安全的。但没有提供多个操作(判断-更新)的事务保护,也就是说:
//T1:
concurrent_map.insert(key1,val1);
//T2:
concurrent_map.contains(key1);
是线程安全的。
//T1,T2同时
if (! concurrent_map.contains(key1) {
concurrent_map.insert(key1,val1)
}
线程不安全。
转自 https://blog.youkuaiyun.com/amurocrash/article/details/78882498
https://blog.youkuaiyun.com/jevonscsdn/article/details/54619114