Map简介
Map用于保存具有映射关系的数据,因此Map集合里保存着两组值,一组值用于保存Map里的key,另外一组值用于保存Map里的value。key和value都可以是任何引用类型的数据。Map的key不允许重复,即同一个Map对象的任何两个key通过equals方法比较结果总是返回false。 关于Map,从代码复用的角度去理解,java是先实现了Map,然后通过包装了一个所有value都为null的Map就实现了Set集合。Map的这些实现类和子接口中key集的存储形式和Set集合完全相同(即key不能重复) 。Map的这些实现类和子接口中value集的存储形式和List非常类似(即value可以重复、根据索引来查找)。
java.util.Map接口有4个常用的实现类,分别为HashMap,HashTable,LinkedHashMap和TreeMap,类的继承关系如下图所示:
各个类的特点分别如下:
- HashMap:根据key的hash值存储value,有较快的访问速度,遍历的顺序是不确定的。允许使用null值和null键。非线程安全,任一时刻可以有多个线程同时写HashMap,可能导致数据不一致。Collections.synchronizedMap()和ConcurrentHashMap可以满足线程安全的需求。
- HashTable:常用功能与HashMap类似,不过它继承自Dictionary类,是线程安全的。HashTable是遗留类,不建议再使用,线程安全的场景下可以使用ConcurrentHashMap,任一时刻只能有一个线程写HashTable,并发性不如ConcurrentHashMap。
- LinkedHashMap:是HashMap的子类,与HashMap有着同样的存储结构,但它加入了双向链表的头结点,将所有put到LinkedHashMap的节点串成双向循环链表,因此它保存了记录的插入顺序,可以使用Iterator顺序遍历,先得到的记录是先插入的
- TreeMap:实现SortedMap接口,能将它保存的记录按key排序(默认升序),也可指定排序的比较器,当使用Iterator遍历时,得到的记录是经过排序的。使用TreeMap时,key必须实现Comparable接口或在构造TreeMap时传入自定义的Comparator。
HashMap讲解
介绍
HashMap基于哈希表的 Map 接口的实现。此实现提供所有可选的映射操作,并允许使用 null 值和 null 键。(除了非同步和允许使用 null 之外,HashMap 类与 Hashtable 大致相同。)此类不保证映射的顺序,特别是它不保证该顺序恒久不变。 此实现假定哈希函数将元素适当地分布在各桶之间,可为基本操作(get 和 put)提供稳定的性能。迭代 collection 视图所需的时间与 HashMap 实例的“容量”(桶的数量)及其大小(键-值映射关系数)成比例。所以,如果迭代性能很重要,则不要将初始容量设置得太高(或将加载因子设置得太低)。
定义
public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable
HashMap 实现了Serializable接口,因此它支持序列化,实现了Cloneable接口,能被克隆。
重要属性
/** * 默认的初始容量(容量为HashMap中槽的数目)是16,且实际容量必须是2的整数次幂。 */
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
/** * 最大容量为2^30(必须是2的幂且小于2的30次方,传入容量过大将被这个值替换) */
static final int MAXIMUM_CAPACITY = 1 << 30;
/** * 默认因子为0.75 */
static final float DEFAULT_LOAD_FACTOR = 0.75f;
/** * 初始化存储数据的Entry数组。 */
static final Entry<?,?>[] EMPTY_TABLE = {};
/** * 装载键值对的内部容器Entry数组,长度一定得是2的幂 */
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
/** * HashMap中包含的键值对的个数 */
transient int size;
/** * HashMap的阈值,用于判断是否需要调整HashMap的容量(threshold = 容量*负载因子) */
int threshold;
/** * 负载因子实际大小 */
final float loadFactor;
/** * HashMap被修改的次数 */
transient int modCount;
构造函数
构造函数接受的两个参数:初始容量和负载因子。它们是hashmap最重要的指标。
初始化容量从代码中,可看出指的是链表数组的长度,负载因子是hashmap中当前元素数量/初始容量的一个上限(代码中用threshold(容量*负载因子)来衡量)。当超过整个限度时,会把链表数组的长度增加,重新计算各个元素的位置(最耗性能)。
/** * 指定默认容量和负载因子 */
public HashMap(int initialCapacity, float loadFactor) {
//对initialCapacity进行参数校验,若小于0,则抛出IllegalArgumentException异常
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity);
//若initialCapacity大于MAXIMUM_CAPACITY(2^30),则将MAXIMUM_CAPACITY赋值给initialCapacity
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
//对参数loadFactor进行有效性校验,不能<=0,不能非数字,否则抛出IllegalArgumentException异常
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " + loadFactor);
//负载因子,默认构造时为0.75
this.loadFactor = loadFactor;
threshold = initialCapacity;
init();
}
//init方法是空的,如果你定制额外的初始化操作,可以继承HashMap,覆盖init()方法
void init() {
}
/** * 通过指定的容量initialCapacity来构造HashMap,这里使用了默认的加载因子DEFAULT_LOAD_FACTOR 0.75 */
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
/** * 无参的构造函数 加载因子为DEFAULT_LOAD_FACTOR 0.75,容量为默认的DEFAULT_INITIAL_CAPACITY 16,极限为 16*0.75=12 */
public HashMap() {
this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
}
/** * 包含“子Map”的构造函数 */
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);
// 将m中的全部元素逐个添加到HashMap中
putAllForCreate(m);
}
Entry实现
Entry是一个链表节点,每个节点包含键值对、hash值和下个节点的引用。
JDK7 里 HashMap的bucket数组也不会在new 的时候分配,也是在第一次 put 的时候通过 inflateTable() 函数进行分配。
/** *Entry是HashMap里面承载键值对数据的数据结构,实现了Map接口里面的Entry接口,除了方法recordAccess(HashMap<K,V> m),recordRemoval(HashMap<K,V> m)外,其他方法均为final方法,表示即使是子类也不能覆写这些方法。 */
static class Entry<K,V> implements Map.Entry<K,V> {
final K key;
V value;
Entry<K,V> next;
int hash;
/** * Creates new entry. */
Entry(int h, K k, V v, Entry<K,V> n) {
value = v;
next = n;
key = k;
hash = h;
}
public final K getKey() {
return key;
}
public final V getValue() {
return value;
}
//设置value值,返回原来的value值
public final V setValue(V newValue) {
V oldValue = value;
value = newValue;
return oldValue;
}
//比较键值对是否equals相等,只有键、值都相等的情况下,才equals相等
public final boolean equals(Object o) {
if (!(o instanceof Map.Entry))
return false;
Map.Entry e = (Map.Entry)o;
Object k1 = getKey();
Object k2 = e.getKey();
//若k1 == k2(k1,k2引用同一个对象),或者k1.equals(k2)为true时,进而判断value值是否相等
if (k1 == k2 || (k1 != null && k1.equals(k2))) {
Object v1 = getValue();
Object v2 = e.getValue();
//若v1 == v2(v1,v2引用同一个对象),或者v1.equals(v2)为true时,此时才能说当前的键值对和指定的的对象equals相等
if (v1 == v2 || (v1 != null && v1.equals(v2)))
return true;
}
return false;
}
public final int hashCode() {
return Objects.hashCode(getKey()) ^ Objects.hashCode(getValue());
}
public final String toString() {
return getKey() + "=" + getValue();
}
/** * 此方法只有在key已存在的时候调用m.put(key,value)方法时,才会被调用,即覆盖原来key所对应的value值时被调用 */
void recordAccess(HashMap<K,V> m) {
}
/** * 此方法在当前键值对被remove时调用 */
void recordRemoval(HashMap<K,V> m) {
}
}
put方法源码
JDK7中 HashMap 的bucket数组大小也一定是2的幂,同样有计算下标简便的优点。如果你通过 HashMap(int initialCapacity) 构造器传入initialCapacity,会先存入 threshold,在第一次 put 时调用 inflateTable() 初始化,会计算出比initialCapacity大的2的幂作为初始化数组的大小,此后 resize 扩容也都是每次乘2。
/** * put操作逻辑是如果当前键已经在hashmap中存在,那么覆盖之,并返回原来的值,否则返回null */
public V put(K key, V value) {
if (table == EMPTY_TABLE) {
inflateTable(threshold);
}
// 当key为null时,调用putForNullKey方法,将value放置在数组Entry第一个位置。
if (key == null)
return putForNullKey(value);
// 根据key重新计算hash值。
int hash = hash(key);
// 搜索指定hash值在对应table中的索引。
int i = indexFor(hash, table.length);
//遍历bucket桶上面的链表元素,找出HashMap中是否有相同的key存在,若存在,则替换其value值,返回原来的value值
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
//直接将键值对插入,由于插入元素,修改了HashMap的结构,因此将modeCount+1
modCount++;
//进行键值对的插入
addEntry(hash, key, value, i);
//由于原来HashMap中不存在key,则不存在替换value值问题,因此返回null
return null;
}
/** * 第一次put时,初始化table */
private void inflateTable(int toSize) {
// Find a power of 2 >= toSize
int capacity = roundUpToPowerOf2(toSize);
// threshold 在不超过限制最大值的前提下等于 capacity * loadFactor
threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
table = new Entry[capacity];
// 判断是否需要初始化hash
initHashSeedAsNeeded(capacity);
}
/** * 开关打开(hashSeed不为0)的时候,对String类型的key采用sun.misc.Hashing.stringHash32的hash算法;对非String类型的 key,多一次和hashSeed的异或,也可以一定程度上减少碰撞的概率。 */
final boolean initHashSeedAsNeeded(int capacity) {
boolean currentAltHashing = hashSeed != 0;
boolean useAltHashing = sun.misc.VM.isBooted() &&
(capacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);
boolean switching = currentAltHashing ^ useAltHashing;
if (switching) {
hashSeed = useAltHashing
? sun.misc.Hashing.randomHashSeed(this)
: 0;
}
return switching;
}
//先看看HashMap中原先是否有key为null的键值对存在,若存在,则替换原来的value值;若不存在,则将key为null的键值对插入到Entry数组的第一个位置table[0]
private V putForNullKey(V value) {
for (Entry<K,V> e = table[0]; e != null; e = e.next) {
//说明原来之前HashMap中就已经存在key问null的键值对了,现在又插入了一个key为null的新元素,则替换掉原来的key为null的value值
if (e.key == null) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;
addEntry(0, null, value, 0);
return null;
}
/** * 将指定的key,value,hash,bucketIndex 插入键值对,若此时size 大于极限threshold,则将Entry数组table扩容到原来容量的两倍 */
void addEntry(int hash, K key, V value, int bucketIndex) {
//若此时size大于极限threshold,则将Entry数组table扩容到原来容量的两倍
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);
}
/** * 在HashMap中要找到某个元素,需要根据key的hash值来求得对应数组中的位置,如何计算这个位置就是hash算法. * HashMap的数据结构是数组和链表的结合,所以我们当然希望这个HashMap里面的元素位置尽量的分布均匀些,尽量使得每个位置上的元素数量只有一个,那么当我们用hash算法求得这个位置的时候,马上就可以知道对应位置的元素就是我们要的,而不用再去遍历链表. * 所以我们首先想到的就是把hashcode对数组长度取模运算,这样一来,元素的分布相对来说是比较均匀的,但是,“模”运算的消耗还是比较大的,能不能找一种更快速,消耗更小的方式那?java中时这样做的 :将hash值和数组长度按照位与&来运算 * 可以这么理解 length肯定是 2的幂,如 16 转换 2禁制是 10000 ,减一为01111 ,进行&运算就可以得到h对应的低位,刚好是相当于h%length */
static int indexFor(int h, int length) {
// assert Integer.bitCount(length) == 1 : "length must be a non-zero power of 2";
return h & (length-1);
}
/** * 将key-value插入数组指定位置 */
void createEntry(int hash, K key, V value, int bucketIndex) {
Entry<K,V> e = table[bucketIndex];
table[bucketIndex] = new Entry<>(hash, key, value, e);
size++;
}
扩容机制
当HashMap中的元素越来越多的时候,hash冲突的几率也就越来越高,因为数组的长度是固定的。所以为了提高查询的效率,就要对HashMap的数组进行扩容,数组扩容这个操作也会出现在ArrayList中,这是一个常用的操作,而在HashMap数组扩容之后,最消耗性能的点就出现了:原数组中的数据必须重新计算其在新数组中的位置,并放进去,这就是resize。
- 那么HashMap什么时候进行扩容呢?当HashMap中的元素个数超过数组大小*loadFactor时,就会进行数组扩容,loadFactor的默认值为0.75,这是一个折中的取值。也就是说,默认情况下,数组大小为16,那么当HashMap中元素个数超过16*0.75=12的时候,就把数组的大小扩展为 2*16=32,即扩大一倍,然后重新计算每个元素在数组中的位置,而这是一个非常消耗性能的操作,所以如果我们已经预知HashMap中元素的个数,那么预设元素的个数能够有效的提高HashMap的性能。
/** * 将HashMap中Entry数组table的容量扩容至新容量newCapacity,数组的扩容不但涉及到数组元素的复制,还要将原数组中的元素rehash到新的数组中,很耗时;如果能预估到放入HashMap中元素的大小,最好使用new HashMap(size)方式创建足够容量的HashMap,避免不必要的数组扩容(rehash操作),提高效率 */
void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
Entry[] newTable = new Entry[newCapacity];
//进行数组元素的移动和rehashing
transfer(newTable, initHashSeedAsNeeded(newCapacity));
table = newTable;
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
/** * 将原Entry数组table中的所有键值对迁移到新Entry数组newTable上 */
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);
}
//重新计算键值对e在新数组newTable中的bucketIndex位置(即rehash操作)
int i = indexFor(e.hash, newCapacity);
//将newTable[i]的引用赋给了e.next,也就是使用了单链表的头插入方式,同一位置上新元素总会被放在链表的头部位置;这样如果发生了hash冲突的话,先放在一个索引上的元素终会被放到Entry链的尾部
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}
获取元素
HashMap 在底层将 key-value 当成一个整体进行处理,这个整体就是一个 Entry 对象。HashMap 底层采用一个 Entry[] 数组来保存所有的 key-value 对,当需要存储一个 Entry 对象时,会根据hash算法来决定其在数组中的存储位置,在根据equals方法决定其在该数组位置上的链表中的存储位置;当需要取出一个Entry时,也会根据hash算法找到其在数组中的存储位置,再根据equals方法从该位置上的链表中取出该Entry
/** * 获取指定key所对应的value值 */
public V get(Object key) {
if (key == null)
return getForNullKey();
Entry<K,V> entry = getEntry(key);
return null == entry ? null : entry.getValue();
}
/** * 返回键为key的键值对 */
final Entry<K,V> getEntry(Object key) {
if (size == 0) {
return null;
}
// 获取哈希值,HashMap将key为null的元素存储在table[0]位置,key不为null的则调用hash()计算哈希值
int hash = (key == null) ? 0 : hash(key);
// 在该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;
}
问题点
《阿里巴巴Java开发规约》中有提到: 【推荐】集合初始化时,指定集合初始值大小。 说明:HashMap使用如下构造方法进行初始化,如果暂时无法确定集合大小,那么指定默认值(16)即可:
public HashMap (int initialCapacity) { this(initialCapacity, DEFAULT_LOAD_FACTOR); }
读完了源码再回头看这条规约涉及到的几个问题:
- HashMap 默认bucket数组多大?
16。 - 如果new HashMap<>(19),bucket数组多大?
HashMap 的 bucket 数组大小一定是2的幂,如果 new 的时候指定了容量且不是2的幂,实际容量会是最接近(大于)指定容量的2的幂,比如 new HashMap<>(19),比19大且最接近的2的幂是32,实际容量就是32。 - HashMap 什么时候开辟bucket数组占用内存?
HashMap 在 new 后并不会立即分配bucket数组,而是第一次 put 时初始化,类似 ArrayList 在第一次 add 时分配空间。 - HashMap 何时扩容?
HashMap 在 put 的元素数量大于 Capacity * LoadFactor(默认16 * 0.75) 之后会进行扩容。
总结
1.jdk1.7的HashMap采用数组+链表的形式存储数据,当预先知道要存储在HashMap中数据量的大小时,可以使用new HashMap(int size)来指定其容量大小,避免HashMap数组扩容导致的元素复制和rehash操作带来的性能损耗。
2.HashMap是基于Hash表、实现了Map接口的,查找元素的时候,先根据key计算器hash值,进而求得key在数组中的位置,但是要尽量避免hash冲突造成的要遍历链表操作,因此在我们手动指定HashMap容量的时候,容量capacity一定得是2的整数次幂,这样可以让数据平均的分配在数组中,减小hash冲突,提高性能。
3.HashMap是非线程安全的,在多线程条件下不要使用HashMap,可以使用ConcurrentHashMap代替。
参考:http://www.importnew.com/7099.html
https://yq.aliyun.com/articles/225660?spm=5176.100239.bloglist.5.75T0bO
https://tech.meituan.com/java-hashmap.html
http://zhangshixi.iteye.com/blog/672697
http://www.cnblogs.com/lewis0077/p/5347061.html