1. HashMap
① HashMap 的原理
- 在
JDK1.6
,JDK1.7
中,HashMap采用位桶+链表实现,即使用链表处理冲突,同一hash值的键值对都存储在一个链表里。 - 但是当位于一个桶中的元素较多,即hash值相等的元素较多时,通过key值依次查找的效率较低。而
JDK1.8
中,HashMap采用位桶+链表+红黑树实现,当链表长度超过阈值8时,将链表转换为红黑树,这样大大减少了查找时间
。
- HashMap是基于hashing的原理,使用
put(key, value)
存储对象到HashMap中,使用get(key)
从HashMap中获取对象。 - 当我们给put()方法传递键和值时,我们先对键调用
hashCode()
方法,计算并返回的hashCode是用于找到Map数组的bucket位置来储存Node 对象。这里关键点在于指出,HashMap
是在bucket中储存键对象和值对象,作为Map.Node 。 - 其中
Node对象有next元素
,可以形成链表,即每个bucket下面的存储的是链表。当链表长度超过8时,会转化为红黑树,这样大大提高了查找的效率。当红黑树数节点不超过6时,会转回链表。 - HashMap的类图结构
public class HashMap<K,V> extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable
- HashMap 继承于
AbstractMap
,实现了Map
、Cloneable
、java.io.Serializable
接口。其中Map接口定义了键映射到值的规则,而AbstractMap类
提供 Map 接口的骨干实现,以最大限度地减少实现此接口所需的工作。 - 注意事项:
- HashMap根据键的hashCode值存储数据,大多数情况下可以直接定位到它的值,因而具有很快的访问速度,但遍历顺序却是不确定的。
- HashMap最多只允许一条记录的
key为null
,允许多条记录的value为null
。 - HashMap的实现是非synchronized,意味着它是非线程安全的。即任一时刻可以有多个线程同时写HashMap,可能会导致数据的不一致。
- 如果需要满足线程安全,可以用
Collections的synchronizedMap方法
使HashMap具有线程安全的能力,或者使用ConcurrentHashMap
。 - 链表的插入: 在jdk1.8之前是插入头部的,在jdk1.8中是插入尾部的。
类型 | 空间复杂度 | 时间复杂度 | 优缺点 |
---|---|---|---|
数组 | 存储区间是连续,空间复杂度大 | 二分查找时间复杂度小,为O(1) | 寻址容易,插入和删除困难 |
链表 | 存储区间离散,空间复杂度很小 | 时间复杂度很大,达O(N) | 寻址困难,插入和删除容易 |
② 数据结构和重要属性
桶数组(node结构)
- Node是HashMap的一个
内部类
,实现了Map.Entry接口
,本质是就是一个映射(键值对)。
transient Node<k,v>[] table;//桶数组<k,v>
static class Node<K,V> implements Map.Entry<K,V> {
final int hash; //用来定位数组索引位置
final K key;
V value;
Node<K,V> next; //链表的下一个node
// 省略方法内容
Node(int hash, K key, V value, Node<K,V> next) { ... }
public final K getKey(){ ... }
public final V getValue() { ... }
public final String toString() { ... }
public final int hashCode() { ... }
public final V setValue(V newValue) { ... }
//判断两个node是否相等,若key和value都相等,返回true。
public final boolean equals(Object o) { ... }
}
红黑树
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {// 红黑树节点
TreeNode<K,V> parent; // red-black tree links
TreeNode<K,V> left; //左子树
TreeNode<K,V> right; //右子树
TreeNode<K,V> prev; // needed to unlink next upon deletion
boolean red;//颜色
//Returns root of tree containing this node.
TreeNode(int hash, K key, V val, Node<K,V> next) {
super(hash, key, val, next);
}
}
重要属性
- Java中
transient
关键字的作用,简单地说,就是让某些被修饰的成员属性变量不被序列化
。 modCount
用来记录结构发生变化的次数。结构发生变化是指添加或者删除至少一个元素的所有操作,或者是调整内部数组的大小,仅仅只是设置元素的值不算结构发生变化
。- fail-fast迭代: 在进行序列化或者迭代等操作时,需要比较操作前后 modCount 是否改变,如果改变了需要抛出
ConcurrentModificationException
。
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 默认容量16,即桶的默认大小
static final int MAXIMUM_CAPACITY = 1 << 30; // 最大容量
static final float DEFAULT_LOAD_FACTOR = 0.75f; // 默认负载因子0.75
static final int TREEIFY_THRESHOLD = 8; // 链表节点转换红黑树节点的阈值, 8个节点转
static final int UNTREEIFY_THRESHOLD = 6; // 红黑树节点转换链表节点的阈值, 6个节点转
static final int MIN_TREEIFY_CAPACITY = 64; // 转红黑树时, table的最小长度
transient Node<k,v>[] table;//存储元素的数组
transient Set<map.entry<k,v>> entrySet;
transient int size;//存放元素的个数
transient int modCount;//被修改的次数fast-fail机制
int threshold;//临界值 当实际大小(容量*填充比)超过临界值时,会进行扩容
final float loadFactor;//负载因子
③ HashMap的扩容
- 关于扩容的重要参数
参数 | 含义 |
---|---|
capacity | table 的容量大小,默认为 16。需要注意的是 capacity 必须保证为2^N |
size | HashMap的大小,它是HashMap保存的键值对的数量 |
threshold | size 的临界值,当 size 大于等于 threshold 就必须进行扩容操作。threshold = capacity * loadFactor |
loadFactor | 装载因子,table 能够使用的比例,默认值DEFAULT_LOAD_FACTOR = 0.75f |
- 当
size >= threshold
时,就进行扩容操作。这个过程也叫作rehashing
,因为它重建内部数据结构,并调用hash方法找到新的bucket位置。 - resize()函数实现扩容,过程大致分两步:
- 扩容: 容量扩充为原来的两倍(
2 * table.length
); - 移动: 对每个节点重新计算哈希值,重新计算每个元素在数组中的位置,将原来的元素移动到新的哈希表中。
- 移动过程中需要重新计算桶下标。HashMap 使用了一个特殊的机制,可以降低重新计算桶下标的操作。
- 假设原数组长度 capacity 为 16,扩容之后 new capacity 为 32:
- 对于一个 Key,它的哈希值如果
在第 5 位上为 0
,那么取模得到的结果和之前一样; 如果为 1
,那么得到的结果为原来的结果 +16。
capacity : 00010000
new capacity : 00100000
- 总结: 扩容后桶下标的位置:一个是原下标的位置(第N位为0,新的capacity= 2 N 2^N 2N),另一种是在下标为 <原下标+原容量> 的位置(第N位为1)
④ put()操作(1.7、1.8均有)
- jdk1.7中的put操作:
- key为null,调用
putForNullKey()
单独处理。因为无法调用 null 的hashCode() 方法
,也就无法确定该键值对的桶下标,只能通过强制指定一个桶下标来存放。HashMap 使用第 0 个桶存放键为 null 的键值对。putForNullKey()方法
中调用addEntry()
实现键值对的插入。 - 计算key的hash值,根据hash值、调用
indexFor()
计算桶下标。 - 遍历桶中的链表,如果已经存在键为key的键值对,更新键值对的value值。(是否存在key的判断,需要判断hash值和key的equals)
- 没有已经存在的key,调用
addEntry()
使用头插法插入新键值对。注意:addEntry()
会对size和threshold大小进比较
,达到threshold时,会使用resize()对桶进行扩容。
-
jdk1.8中put操作的具体步骤:
① 判断table是否为null或者空(长度为0),若是null或者空,执行resize()进行扩容;
② 根据键值key计算hash值得到插入的数组索引(桶下标),如果没有碰撞,直接放入桶中(碰撞的意思是:该bucket中,没有键值对)。然后判断实际存在的键值对数量size是否超多了最大容量threshold
,如果超过,执行resize()进行扩容;
③ 如果发生碰撞,需要判断桶为红黑树还是链表。桶为红黑树,直接在树中插入键值对。否则遍历链表,如果链表长度大于8,将其转为红黑树,在树中执行插入操作;在遍历过程中,发现某个key重复,直接覆盖value即可。
④ 完成插入操作后,判断实际存在的键值对数量size是否超多了最大容量threshold,如果超过,执行resize()进行扩容;
⑤ get()操作(jdk1.7为例)
- jdk1.7中的get操作:
- 若
key为null
,调用getForNullKey()
获取对应的value; - 否者计算key的hash值,根据hash值调用
indexFor()
获取桶下标,然后遍历桶中的链表。 - 遍历过程中key相同,返回其对应的value(key相同要求hashCode和equals都相同);遍历结束没有找到对应的key,返回null。
public V get(Object key) {
if (key == null)
return getForNullKey();
int hash = hash(key.hashCode());
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.equals(k)))
return e.value;
}
return null;
}
⑥ equals
==
用于比较引用和比较基本数据类型时具有不同的功能:
- 比较基本数据类型,看二者的值是否相等。如果两个值相同,则结果为true。
- 比较引用类型时,看二者是否指向内存中的同一对象。如果引用指向内存中的同一对象,结果为true;
-
equals()
作为方法,用于实现对象的比较。由于 == 运算符不允许我们进行覆盖,需要重写equals()方法,达到比较对象内容是否相同
的目的。而这些通过 == 运算符是做不到的。 -
object类的equals()方法的比较规则为:如果两个对象的类型一致,并且内容一致, 则返回true,这些类包括:
java.io.file
,java.util.Date
,java.lang.string
,包装类
(Integer,Double等)
参考链接:
Java中HashMap的实现原理
java提高篇(二三)-----HashMap
Java集合:HashMap详解(JDK 1.8)
HashMap到底是插入链表头部还是尾部
⑦ HashMap常见面试问题
1. HashMap 怎样解决冲突?抛开HashMap,有哪些方法可以解决hash冲突?
- HashMap使用拉链法解决冲突:采用桶数组,每个桶中以链表的形式存放hashCode相同的键值对。
- 解决hash冲突的其他方法:开放定址法、再hash法、建立一个公共溢出区。
- 开放定址法: 对某个值进行hash时,发现地址被占用,于是使用(hash(key)+di) % m去获取新的地址,直到新地址没被占用。增量di的三种取法:
线性探测再散列
: di = 1 , 2 , 3 , … , m-1
平方探测再散列
: di = 1 , -1 , 2, -2 , 3 , -3 , … , k , -k(取相应数的平方,1表示加 1 2 1^2 12,-1表示减 1 2 1^2 12)
随机探测再散列
: di 是一组伪随机数列 - 再hash法: 产生hash冲突时,再用其他的hash函数计算地址,直到不发生冲突为止。
- 建立一个公共溢出区: 假设哈希函数的值域是[1,m-1],则设向量HashTable[0…m-1]为基本表,每个分量存放一个记录,另外设向量OverTable[0…v]为溢出表。一旦发生冲突,都填入溢出表。
参考链接:解决Hash冲突四种方法
2. 为什么String, Interger这样的类适合作为键?
- String最为常,因为String对象是不可变的,而且已经重写了equals()和hashCode()方法。
不可变性是必要的
,因为在HashMap中如果key的hashCode在存入和获取时不一致
,那么就不能从HashMap中找到你想要的键值对
。不可变性还有其他的优点,如线程安全。获取对象
的时候要用到equals()和hashCode()方法
,key对象正确的重写这两个方法是非常重要的。因为,如果两个不相等的对象返回不同的hashCode的话
,那么碰撞的几率就会小些,这样就能提高HashMap的性能。
3. HashMap与HashTable的区别
- extends的类不同: HashMap继承于AbstractMap,而Hashtable继承于陈旧的Dictionary。
- 线程安全不同: HashTable 使用 synchronized 来进行同步,即HashTable是线程安全的,支持多线程。而HashMap不是同步的,即HashMap不是线程安全的。若要在多线程中使用HashMap,需要我们额外的进行同步处理。
- null值: HashMap的key、value都可以为null。Hashtable的key、value都不可以为null。
- 迭代器(Iterator)不一样::HashMap的迭代器(Iterator)是
fail-fast迭代器
,当有其它线程改变了HashMap的结构(增加或者移除元素),将会抛出ConcurrentModificationException
。而Hashtable的enumerator迭代器不是fail-fast的。 - 容量的初始值和增加方式都不一样: HashMap默认的容量大小是16;增加容量时,每次将容量变为 原 始 容 量 ∗ 2 原始容量 * 2 原始容量∗2。Hashtable默认的容量大小是11;增加容量时,每次将容量变为 原 始 容 量 ∗ 2 + 1 原始容量 * 2 + 1 原始容量∗2+1。
- 添加key-value时的hash值算法不同: HashMap添加元素时,是
使用自定义的哈希算法
。Hashtable没有自定义哈希算法,而直接采用的key的hashCode()
。 - 速度不同: 由于Hashtable是线程安全的,即是
synchronized
,所以在单线程环境下它比HashMap要慢。如果你不需要同步,只需要单一线程,那么使用HashMap性能要好过Hashtable。
4. 如何解决HashMap不是线程安全的问题?
- HashMap可以通过下面的语句进行同步,实现线程安全:
Map m = Collections.synchronizedMap(hashMap);
- 使用
ConcurrentHashMap
代替
参考链接:
【java集合】HashMap常见面试题