最近由于工作的原因,我就把HashMap说深入的学习了一下,把知识点做以下总结:
在面试中常见的HashMap的问题一般有以下几个:
1,JDK1.8中的HashMap有那些改动,请说出三点以上。
2,JDK1.8中为什么要使用红黑树。
3,HashMap的扩容机制是怎么样的。
4,为什么重写对象的equales()方法时,要重写hashCode()方法,跟HashMap有关系吗?为什么?
5,HashMap是线程安全的吗?遇到过ConcurrentModificationException异常吗?为什么会出现?如何解决?
6,在使用HashMap的过程中,我们应该注意什么问题?
说到HashMap首先要了解哈希表的用法,哈希表的的存储原理是:根据代存储的的数据的关键字,通过哈希函数的变换得到一个哈希码,这个哈希码就是待存储数据的地址。具体的细节大家可以自行去复习数据结构与算法——哈希表。
下面通过这些代码来看看HashMap的哈希码:
public class HashMapTest {
public static void main(String args[]){
HashMap hashMap=new HashMap<>();
hashMap.put("张三","张三");
hashMap.put("李四","李四");
hashMap.put("王五","王五");
hashMap.put("老大","老大");
hashMap.put("老二","老二");
hashMap.put("老三","老三");
for (String key:hashMap.keySet()){
int hashCode=key.hashCode();
int index=hashCode%7;
System.out.println(String.format("%s的hashCode是%s,index是%s",key,hashCode,index));
}
}
}
输出:
李四的hashCode是842061,index是3
张三的hashCode是774889,index是3
老二的hashCode是1035947,index是3
王五的hashCode是937065,index是3
老三的hashCode是1035816,index是5
老大的hashCode是1038662,index是2
在这里是不是有个疑惑:hashMap是数组加上链表实现的,也就是形如下面的格式:
那么哈希码是这么大的数字,又是在怎么把哈希码转化成数组下标的?转换后可以看到数组下标明显的重复了,又是怎么解决这种冲突的呢?至于第一个问题,这么大的哈希码转换成很小的数组下标,那肯定就是使用哈希表的“除留余数法”来进行定位的,只需要用哈希码值除以数组的长度取余就行了。至于第二个问题,可以看到李四,张三,老二,王五的数组下标都是3,那如果把他们存储在一起,岂不是出错了,那么这里就要使用到链表了,把数组下标一样的元素依次连接起来,这其实也就是解决哈希冲突的一个过程,这个方法叫做——链地址法。
那么问题来了,如果数组下标一样的元素连接起来,是要依次从头部开始连接还是从尾部开始连接?答案是:JDK1.7是从头部开始连接的,JDK1.8是从尾部开始连接的。至于原因,等后边会讲解。
JDK1.7中从头部依次连接,比如L:李四的上面是张三,再上面是老二,,,,这样在查找的时候依次从最上面开始遍历,知道找到要找到元素为止。如图:
下面就试着写一下这种存储的方式:
public class MyHashMap {
private static Integer CAPACITRY=8;
private Entry[] table;
private int size;
public MyHashMap(){
this.table=new Entry[CAPACITRY];
}
public int size(){
return this.size;
}
public V put(K key,V value){
//Entry entry=new Entry(key,value,null);
//计算哈希码,通过哈希码来确定下表位置
int hashCode=key.hashCode();
int i=hashCode%CAPACITRY;
//如果key的值相同的话,就要遍历链表,用新值替换老值,并把老值返回
for (Entry entry=table[i];entry!=null;entry=entry.next){
if (entry.k.equals(key)){
V oldValue=entry.v;
entry.v=value;
return oldValue;
}
}
// 以上确定了数组的位置,下面就要造一个节点,依次插入该下标位置
addEntry(key,value,i);
return null;
}
public V get(K k){
//对于get方法也是一样的,先通过key定位哈市Code,在定位数组下标,最后把值返回
int hashCode=k.hashCode();
int i=hashCode%CAPACITRY;
for (Entry entry=table[i];entry!=null;entry=entry.next){
if (entry.k.equals(k)){
return entry.v;
}
}
return null;
}
private void addEntry(K key,V value,int i){
Entry entry=new Entry(key,value,table[i]);
table[i]=entry;
size++;
}
class Entry{
public K k;
public V v;
public Entry next;
public Entry(K k,V v,Entry next){
this.k=k;
this.v=v;
this.next=next;
}
}
public static void main(String args[]){
MyHashMap myHashMap=new MyHashMap<>();
myHashMap.put("张三","学生");
System.out.println(myHashMap.get("张三"));
}
}
输出:
学生
以上就是简单的实现了HashMap,下面往该HashMap中存储是个数据,修改main()函数,如下:
public static void main(String args[]){
MyHashMap myHashMap=new MyHashMap<>();
for (int i=0;i<10;i++){
myHashMap.put("张三"+i,"学生"+i);
}
System.out.println(myHashMap.get("张三"));
}
}
打断点Debug调试如下:
可以看到hashMap的数组长度是8,但是存储了十个元素,因此肯定有链表存在。
以上便是HashMap的基本原理,下面开始讲解HashMap的源码:
/**
*这是默认的数组的大小,默认是16
*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
/**
*最大能扩容的大小
*/
static final int MAXIMUM_CAPACITY = 1 << 30;
/**
*默认的加载因子,这个和扩容有关系
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;
/**
*当链表长度大于8的时候就会把这个链表转换成红黑树
*/
static final int TREEIFY_THRESHOLD = 8;
/**
*
*/
static final int UNTREEIFY_THRESHOLD = 6;
/**
*
*/
static final int MIN_TREEIFY_CAPACITY = 64;
接着是构造方法:
public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity);
}
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
为什么JDK1.7只用了链表,而JDK1.8使用了链表加红黑树?
首先要知道链表的特点是插入快,而查找慢,树的特点是查找很快,但是插入肯定是没有链表快,树可以是红黑树,也可以是完全平衡二叉树,至于JDK1.8为什么选择红黑树而不选择完全平衡二叉树,可以通过比较完全平衡二叉树和红黑树的特点来得出结论:
二叉搜索树作为一种数据结构,其查找、插入和删除操作的时间复杂度都为O(logn),底数为2。但是我们说这个时间复杂度是在平衡的二叉搜索树上体现的,也就是如果插入的数据是随机的,则效率很高,但是如果插入的数据是有序的,比如从小到大的顺序,从大到小就是全部在左边,这和链表没有任何区别了,这种情况下查找的时间复杂度为O(N),而不是O(logN)。当然这是在最不平衡的条件下,实际情况下,二叉搜索树的效率应该在O(N)和O(logN)之间,这取决于树的不平衡程度。
那么为了能够以较快的时间O(logN)来搜索一棵树,我们需要保证树总是平衡的(或者大部分是平衡的),也就是说每个节点的左子树节点个数和右子树节点个数尽量相等。红-黑树的就是这样的一棵平衡树,对一个要插入的数据项(删除也是),插入例程要检查会不会破坏树的特征,如果破坏了,程序就会进行纠正,根据需要改变树的结构,从而保持树的平衡。
通过以上分析,红黑树相比较完全平衡二叉树来说,没有完全平衡二叉树那么严谨(必须左右两边相等子节点个数),红黑树的插入效率比完全平衡二叉树要高,,但是没有完全平衡二叉树的查找效率高,因此红黑树的插入和查询效率是处于链表和完全平衡二叉树之间,与因此两者折中使用红黑树是比较合适的。
JDK1.7的HashMap 插入算法之所以比较麻烦(多次左移或者右移),就是因为JDK1.7没有使用红黑树,为了不让一棵树上有太多节点(减少碰撞,提高查询效率),就使用的移位策略。
下面讲解与扩容相关知识:
前面看到有一个0.75,这个0.75乘以16=12,但是根据代码可以看出:如果已经达到12个元素,再来一个元素,如果这个元素和前边的12个元素中有冲突,意思是需要加入链表,那么就扩容,否则不扩容(当然这一点在JDK1.8中已经去掉了,这只是JDK1.7中的原理)。扩容的长度是原来长度的两倍,new出来一个新的是原来长度两倍的数组,把原来数组的值复制到新的数组中。但是在复制的时候会出现一个问题,就是链表底部的会跑到上面,顶部在下面,如果数据一样的话会出现环形链表,导致死锁。(张三——>李四——>李四,按照这个顺序你推理一下是不是会出现循环链表)
再来看JDK1.8的扩容原理:
上面有一行代码:
/**
*当链表长度大于8的时候就会把这个链表转换成红黑树
*/
static final int TREEIFY_THRESHOLD = 8;
/**
* 当红黑树上面的节点数小于等于6的时候就会把红黑树转换成链表
*/
static final int UNTREEIFY_THRESHOLD = 6;
/**
*
*/
static final int MIN_TREEIFY_CAPACITY = 64;
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node[] tab; Node p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//取出指定数组下标里面的值,如果没有的值为空的话就把一个新节点赋值过去
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
//如果以上两个判断都不满足的话就表示该位置上有值
Node e; K k;
if (p.hash == hash &&
//判断key相等的情况
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
else if (p instanceof TreeNode)
//如果是树节点
e = ((TreeNode)p).putTreeVal(this, tab, hash, key, value);
else {
//如果是链表的情况,先遍历链表,肯定药去数链表
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
//如果大于8,就会变成红黑树
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
在上面,如果是链表的时候,新节点插入是从尾部插入的,因为链表要想转成红黑树就需要知道当前链表有多长,所以每次插入一个元素的时候都要遍历链表,遍历一次就会停留在链表尾部,为了节约时间,直接就把插入的节点放在链表尾部。
至于JDK1.8数组扩容问题这里就不再多讲了。