这里是一篇关于HashMap的数据结构、底层原理和代码演变的技术博客:
HashMap的数据结构采用“链表散列”结构,即一个链表和一个数组,数组称为hash table,链表成为链表数组。HashMap通过key的hashCode来计算index,然后将key-value对存放在hash table的对应位置。如果出现hash冲突,就将数据存放在链表中。HashMap主要由Node[] table、size和loadFactor三个字段组成。
获取数据的步骤:
JDK1.6中的HashMap是非线程安全的,扩容使用synchronized来锁定整个table。
public V put(K key, V value) {
// 扩容
if (table == EMPTY_TABLE) {
inflateTable(threshold);
} else if (size >= threshold) {
resize(2 * table.length);
}
// 如果key为null,存储在table[0]中
if (key == null) {
return putForNullKey(value);
}
// 计算key的hash值
int hash = hash(key);
int i = indexFor(hash, table.length);
// 进行拉链操作
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
// 如果key存在,则覆盖旧值
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
return oldValue;
}
}
// 添加新Entry
modCount++;
addEntry(hash, key, value, i);
return null;
}
JDK1.7中的HashMap是非线程安全的,采用“链表+红黑树”的结构。当链表长度超过8时会将链表转换为红黑树。扩容时使用synchronized锁定table[0]和table[table.length-1]。
public V put(K key, V value) {
// 扩容
if (table == EMPTY_TABLE)
inflateTable(threshold);
// 如果key为null,存储在table[0]中
if (key == null)
return putForNullKey(value);
int hash = hash(key);
// 找到对应的桶
int i = indexFor(hash, table.length); // 先找出是否已经存在该key,如果存在则更新值并返回
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
if (e.hash == hash && eq(key, e.key)) {
V oldValue = e.value;
e.value = value;
return oldValue;
}
} // 如果不存在,添加到链表尾部或树中
modCount++;
addEntry(hash, key, value, i); // 确定是否需要扩容
if (size >= threshold)
resize(2 * table.length); return null;
}
JDK1.8中HashMap是非线程安全的,默认采用"链表+红黑树"的结构,当链表长度超过8时会将链表转换为红黑树。
JDK1.8对扩容做了优化,不再锁定整个table,而是采用"synchronized+CAS"的方式来控制并发度。同时引入了红黑树,减少了链表过长的情况。
final V putVal(K key, V value, boolean onlyIfAbsent) {
// 或获取 key 的 hash 值
int hash = spread(key.hashCode());
// 找到对应桶的索引
int i = indexFor(hash, table.length);
// 链表或红黑树的节点
Node<K,V> p;
boolean newEntry = true;
// 如果桶中第一个节点为 TreeNode,则为红黑树结构
if ((p = tab[i]) == null)
// CAS 控制并发,初始化首节点
tab[i] = newNode(hash, key, value, null);
else {
K k;
// 如果键 hash 值和节点 hash 值相等,并且键 equals 节点键,则更新节点键值对
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
// 更新节点值
p.val = value;
else {
// 判断是否是红黑树
if (p instanceof TreeNode)
// 调用红黑树插入方法插入新节点
((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
// 若为链表,则插入链表尾部
for (int binCount = 0; ; ++binCount) {
// 插入新节点到链表尾部
if (newEntry && binCount >= TREEIFY_THRESHOLD - 1)
// 链表长度达 TREEIFY_THRESHOLD 则转化为红黑树
treeifyBin(tab, hash);
// 遍历至链表尾部,插入新节点
if (p.next == null) {
p.next = newNode(hash, key, value, null);
break;
}
p = p.next;
}
}
}
}
// 判断是否需要扩容
if (++size > threshold)
// 将容量扩大两倍
resize();
return onlyIfAbsent ? oldValue : null;
}
通过HashMap的代码演变,我们可以看出:
static class Node {
int key;
int value;
Node next;
public Node(int key, int value) {
this.key = key;
this.value = value;
}
}
private static int CAPACITY = 16;
private static float LOAD_FACTOR = 0.75f;
private Node[] table;
private int size = 0;
private int hash(int key) {
return key & (table.length - 1);
}
public void put(int key, int value) {
if (size >= LOAD_FACTOR * table.length) {
// 进行扩容
}
int hash = hash(key);
Node node = table[hash];
if (node == null) {
// 插入新节点,无hash冲突
table[hash] = new Node(key, value);
size++;
} else {
// 有hash冲突,采用拉链法解决
Node pre = null;
while (node != null) {
if (node.key == key) {
node.value = value;
return;
}
pre = node;
node = node.next;
}
// 插入新节点到链表尾部
pre.next = new Node(key, value);
size++;
}
}
private void resize() {
Node[] newTable = new Node[table.length * 2];
for (Node node : table) {
if (node != null) {
Node next = null;
while (node != null) {
next = node.next;
int hash = hash(node.key);
if (newTable[hash] == null) {
newTable[hash] = node;
} else {
Node n = newTable[hash];
while (n.next != null)
n = n.next;
n.next = node;
}
node = next;
}
}
}
table = newTable;
}
这就是一个简单的HashMap的实现,通过数组+链表实现“拉链法”解决哈希冲突。当链表长度过长时进行扩容,采用重新计算位置的方式迁移节点。
我们来分析一下HashMap的时间复杂度:
所以,总体来说,HashMap的时间复杂度可以看作O(1)。除非数据分布极不均匀,导致链表过长或频繁扩容,但这在实际使用中很少出现。HashMap之所以能达到O(1)的时间复杂度,主要得益于它采用的“拉链法”来解决哈希冲突。相比直接在数组中查找,拉链法大大减少了查找的时间复杂度。
空间复杂度主要取决于 HashMap 的容量(table的大小)和键值对的数量。
所以:
但由于扩容操作消耗性能,也会浪费一定的空间(相当于目前使用了容量的1/2),所以我们应该在初始化HashMap的时候就指定一个合适的容量,避免过多的扩容操作。
如果我们指定HashMap的初始容量,那么空间复杂度可以简单的分析如下:
所以我们可以得出结论:
因此,我们在初始化HashMap的时候,应该设置一个合适的初始容量,既不会造成过多扩容,也不会有太大的空间浪费,这需要根据实际场景来判断。
HashMap是Java中最常用的Map之一,它有以下主要应用场景:
class LRUCache {
class Node {
int key;
int value;
Node prev;
Node next;
}
private HashMap<Integer, Node> map;
private Node head;
private Node tail;
private int capacity;
public LRUCache(int capacity) {
this.capacity = capacity;
map = new HashMap<>();
}
public int get(int key) {
if (map.containsKey(key)) {
// 如果key存在,先从链表中删除该节点
Node node = map.get(key);
removeFromList(node);
// 然后重新插到头部
addToHead(node);
return node.value;
} else {
return -1;
}
}
public void put(int key, int value) {
if (map.containsKey(key)) {
// 如果key已存在,先从链表中删除该节点
Node node = map.get(key);
removeFromList(node);
}
// 将新节点添加到头部
Node node = new Node(key, value);
addToHead(node);
map.put(key, node);
// 如果容量已满,删除链表尾部节点
if (map.size() > capacity) {
Node tail = removeTail();
map.remove(tail.key);
}
}
}
这就是使用HashMap和双向链表实现的LRU缓存机制。我们利用HashMap的O(1)时间复杂度快速存取键值对,利用双向链表保证最近使用的数据靠近头部,这样在容量满的时候可以快速删除最近最少使用的节点。
HashMap也有一些弊端,主要包括:
综上,ConcurrentHashMap可以很好的解决HashMap的上述弊端,所以在高并发环境下,ConcurrentHashMap通常被用来替代HashMap。但当键值对数量较小,并发度不高的场景下,HashMap也是一种非常高效的数据结构。
HashSet底层实际上是基于HashMap实现的。它使用HashMap的key来存储元素,value使用默认对象PRESENT。HashSet的主要方法底层实际上是调用的HashMap的方法。例如:
所以我们可以总结:
下面通过一个示例体现他们的关系:
HashSet<String> set = new HashSet<>();
set.add("a");
set.add("b");
set.add("c");
HashMap<String, Object> map = (HashMap<String, Object>) set;
// map是 {a=PRESENT, b=PRESENT, c=PRESENT}
// set和map的方法调用是相同的
set.remove("a");
map.remove("a");
set.contains("b");
map.containsKey("b");
set.size();
map.size();
从此示例可以很清楚的看出,HashSet仅仅是在HashMap上做了一层包装,它使用HashMap的键来存储元素,value使用默认的PRESENT对象。 所以每当我们调用HashSet的方法时,实际上都是在调用HashMap对应的方法,二者之间的关系十分密切。理解了HashSet和HashMap的关系,也就很容易理解HashSet的实现原理和工作机制了。所以这是一个比较重要的知识点,值得我们深入学习和理解。
HashMap和Hashtable都是基于哈希表实现的Map,但是在实现上有一定的区别。主要区别如下:
除了以上区别外,HashMap和Hashtable的方法使用方式基本相同,功能也基本相同。所以除非对线程安全有较高要求,否则更推荐使用HashMap来代替Hashtable。
下面通过一个示例来体现他们的一些区别:
// HashMap
HashMap<String, String> map = new HashMap<>();
map.put(null, null);
map.put("a", "a");
map.put(null, "b");
// Hashtable
Hashtable<String, String> table = new Hashtable<>();
table.put("a", "a"); // OK
table.put(null, null); // java.lang.NullPointerException
table.put(null, "b"); // java.lang.NullPointerException
从示例可以看出HashMap允许null键null值,而Hashtable则抛出异常。这就是二者在这一点的重要区别。
所以总体来说,除非对线程安全有较高要求,HashMap在大多数情况下都优于Hashtable。Hashtable相比来说耗时耗空间,体现不出现代计算机异构计算能力。
HashMap的长度为什么要取2的幂(一般初始化为2的4次方16),这是因为:
举个例子,假设当前HashMap的容量为16(10000),扩容后容量变为32(100000)。
如果是2的幂,只需要将原本的hash值(如11011)与新的表长-1(11111)做与运算(11011)。得到新的位置(11011)。如果不是2的幂,需要重新hash。
比如原来位置是hash%16=11,新位置应该是hash%32=19。这需要重新计算hash值,效率较低。
所以总结来说,HashMap的长度取2的幂主要出于3个方面的考虑:
这也就是HashMap容量为什么要选用2的幂的原因了。这在一定程度上也提高了HashMap的性能,值得我们学习和理解。
在JDK1.8之前,HashMap采用数组+链表实现,数组是HashMap的主体,链表则是解决哈希冲突的手段。
但是当链表长度过长时,HashMap的性能会受到比较大的影响。JDK1.8对这一点进行了优化,引入了红黑树。
当链表长度太长(默认超过8)时,链表就转化为红黑树,利用红黑树快速增删改查的特点提高HashMap的性能。
所以JDK1.8的HashMap底层采用数组+链表+红黑树实现,当链表长度不足8时仍然采用链表解决冲突,当链表长度超过8时转化为红黑树,这样既发挥了链表在冲突少的情况下性能高的优点,又利用了红黑树在冲突多的情况下性能高的优点。
tips: 其中红黑树的插入、删除、查找时间复杂度均为O(logN),效率很高。
除此之外,JDK1.8的HashMap还有其他优化:
通过以上几点优化,JDK1.8的HashMap在时间和空间两方面都取得较大提高。它既解决了之前版本在大容量和高冲突率下性能下降的问题,也不失在一般场景下的高性能,这也是它成为如今最主流的Map实现的原因。
所以如果你的程序需要支持JDK1.8以上的环境,强烈建议使用JDK1.8及之后的HashMap,这会让你的程序既高效又具有很好的拓展性。理解JDK1.8对HashMap的优化,也有助于我们更深入地理解HashMap这个数据结构。
HashMap提供了几种方式遍历所有键值对:
HashMap<String, Integer> map = new HashMap<>();
for (Map.Entry<String, Integer> entry : map.entrySet()) {
String key = entry.getKey();
Integer value = entry.getValue();
// do something
}
HashMap<String, Integer> map = new HashMap<>();
for (String key : map.keySet()) {
Integer value = map.get(key);
// do something
}
HashMap<String, Integer> map = new HashMap<>();
for (Integer value : map.values()) {
// do something
}
HashMap<String, Integer> map = new HashMap<>();
Iterator<Map.Entry<String, Integer>> iter = map.entrySet().iterator();
while (iter.hasNext()) {
Map.Entry<String, Integer> entry = iter.next();
String key = entry.getKey();
Integer value = entry.getValue();
// do something
}
HashMap<String, Integer> map = new HashMap<>();
map.forEach((k, v) -> {
// do something
});
以上就是HashMap提供的几种遍历方式,entrySet()方法是最常用的方式,我们在学习和使用HashMap时可以灵活运用这几种遍历方式。
需要注意的是,无论使用哪种遍历方式,遍历过程中如果对HashMap进行修改(增加、删除键值对),都有可能会产生 ConcurrentModificationException,这是因为遍历时使用的迭代器或是HashMap的快照,并不反映在遍历过程中对原HashMap的修改。
所以在遍历HashMap的过程中,如果对其进行修改,应该使用Iterator.remove()等保证迭代器稳定的方法,或者捕获ConcurrentModificationException并在catch块中进行相应处理。
以上是HashMap常见的一些面试题,我们在学习和使用HashMap的时候需要理解这些概念和机制,这也有助于我们在面试时回答问题。如果有不理解的地方可以再回过头来复习。