写在最前:首先要搞清楚HashMap的数据结构是怎样的,它是用来解决什么问题的,以及该数据结构中体现javabean结构的成员变量,有参/无参构造,成员方法等是如何定义的。
本文所写的数据结构模拟的是jdk7,数组+链表。jdk8的红黑树只是优化链表,后续更新。
可以参考下我之前的文章:《HashMap源码分析(jdk8)》
我们先思考几个问题:
1. 有哪些成员变量?各自的默认值是什么?
2. 有哪些构造方法?
3. 有哪些成员方法?
4. 数组是如何定义的?
5. 链表是如何定义的?
6. 什么是hash冲突,它什么时候发生?该怎么解决?
7. map的put方法,如何判定key是否重复?
8. 何时扩容?
9. hashmap如何优化?
10. 欢迎评论区补充,一起探讨
我们先通过自定义一个HashMap来了解这种数据结构,再来回答这些问题。
1. 定义MyMap接口
/**
* @author yog
*
* 自定义map接口
* @param <K> key
* @param <V> value
*/
public interface MyMap<K,V> {
/**
* put
* @param k key
* @param v value
* @return value
*/
V put(K k,V v);
/**
* get
* @param k key
* @return alue
*/
V get(K k);
/**
* 内部接口,存放key-value的entry桶
* @param <K> key
* @param <V> value
*/
interface Entry<K,V>{
/**
* getKey 获取key
* @return key
*/
K getKey();
/**
* getValue 获取value
* @return value
*/
V getValue();
}
}
暂定一个get和一个put方法。
2. 定义MyHashMap,实现MyMap接口
public class MyHashMap<K,V> implements MyMap<K,V> {
@Override
public Object put(Object o, Object o2) {
return null;
}
@Override
public Object get(Object o) {
return null;
}
}
2.1 成员变量
/**
* 定义数组初始化容量 32
*/
private static final int DEFAULT_INITIAL_CAPATITY = 1 << 4;
/**
* 定义加载因子 0.75f
*/
private static final float DEFAULT_LOAD_FACTOR = 0.75f;
/**
* 默认初始化容量
*/
private transient int defaultInitSize;
/**
* 默认加载因子
*/
private final float defaultLoadFactor;
/**
* entry数组
*/
private Entry<K,V>[] table;
/**
* map中entry数量
*/
private int entryUseSize;
2.2 构造方法
//这里两个构造方法其实指向同一个构造方法,但对外暴露两个,"门面模式"
MyHashMap(){
this(DEFAULT_INITIAL_CAPATITY,DEFAULT_LOAD_FACTOR);
}
private MyHashMap(int defaultInitialCapatity, float defaultLoadFactor) {
if(defaultInitialCapatity < 0){
throw new IllegalArgumentException("非法初始化容量异常:" + defaultInitialCapatity);
}
if (defaultLoadFactor <= 0 || Float.isNaN(defaultLoadFactor)){
throw new IllegalArgumentException("非法初始化加载因子异常" + defaultLoadFactor);
}
this.defaultInitSize = defaultInitialCapatity;
this.defaultLoadFactor = defaultLoadFactor;
table = new Entry[this.defaultInitSize];
}
2.3 定义Entry数组
/**
* @author yog
* @param <K> key
* @param <V> value
*
* 内部类
*/
static class Entry<K,V> implements MyMap.Entry<K,V>{
private K key;
private V value;
private Entry<K,V> next;
public Entry(){}
private Entry(K key, V value, Entry<K,V> next){
this.key = key;
this.value = value;
this.next = next;
}
@Override
public K getKey() {
return key;
}
@Override
public V getValue() {
return value;
}
}
2.4 put方法
@Override
public V put(K k, V v) {
V oldValue = null;
//是否需要扩容
if(entryUseSize >= defaultInitSize * defaultLoadFactor ){
//扩容完毕,重新散列
resize(2 * defaultInitSize);
}
//求hash值,计算在数组中的位置
int index = hash(k) & (defaultInitSize - 1);
if(null == table[index]){
table[index] = new Entry<K,V>(k,v,null);
++entryUseSize;
}else {
//需要遍历单链表
Entry<K,V> entry = table[index];
Entry<K,V> e = entry;
while (null != e){
if(k == e.getKey() || k.equals(e.getKey())){
oldValue = e.value;
e.value = v;
return oldValue;
}
e = e.next;
}
table[index] = new Entry<K,V>(k,v,entry);
++entryUseSize;
}
return oldValue;
}
2.5 resize扩容
//参考jdk的hashap的hash运算
private int hash(K k){
int hashCode = k.hashCode();
hashCode ^= (hashCode >>> 20) ^ (hashCode >>> 12);
return hashCode ^ (hashCode >>> 7) * (hashCode >>> 4);
}
//从这里可以看到resize/rehash的操作是影响性能的,需要数组的重新put操作。但要注意状态变量的变化
private void resize(int i){
Entry[] newTable = new Entry[i];
//改变数组的大小
defaultInitSize = i;
entryUseSize = 0;
rehash(newTable);
}
private void rehash(Entry<K,V>[] newTable) {
//将旧table遍历,放入新集合中
List<Entry<K,V>> entries = new ArrayList<>();
for (Entry<K, V> entry : table) {
if(null != entry){
do{
entries.add(entry);
entry = entry.next;
}while (null != entry);
}
}
//覆盖旧的引用
if(newTable.length > 0){
table = newTable;
}
//重新hash:重新put entry到hashMap
for (Entry<K, V> entry : entries) {
put(entry.getKey(),entry.getValue());
}
}
2.6 get方法
@Override
public V get(K k) {
int index = hash(k) & (defaultInitSize - 1);
if(null == table[index]){
return null;
}else {
Entry<K, V> entry = table[index];
do {
if(k == entry.getKey() || k.equals(entry.getKey())){
return entry.getValue();
}
entry = entry.next;
}while (null != entry);
}
return null;
}
3. 测试
public class MyHashMapTest {
public static void main(String[] args) {
MyMap<Integer,String> myMap = new MyHashMap<>();
for (int i = 0; i < 100; i++) {
myMap.put(i,"value" + i);
}
for (int i = 0; i < 100; i++) {
System.out.println("key : " + i + " , value : " + myMap.get(i));
}
}
}
测试结果:
4. 分析HashMap数据结构
4.1 成员变量
- 数组默认的初始化容量DEFAULT_INITIAL_CAPATITY为32(必须是2的次幂),默认的加载因子DEFAULT_LOAD_FACTOR为0.75f。初始化容量决定hashmap中初始化时存放的元素数量,加载因子决定hashmap何时进行扩容。当HashMap中元素数量超过 初始化容量*加载因子 时,就进行扩容resize()操作,如达到 16*0.75=12时,就进行扩容,默认扩容为原来的两倍,16*2=32。
- Entry数组:它是一个静态的内部类。从Entry的构造函数可以看出,每次创建新的Entry对象,就会把链表的头结点作为next拼接到新的entry对象上。也就是说每次插入新的map数据时,就会生成一个bucket在链表头部。由此可以得出单链表的实现方式。
- 在jdk8之后,采用了红黑树来优化单链表。当哈希冲突比较多的时候,因为链表的长度很大 , 链表是不利于查询的,有利于删除和插入,所以引进了红黑树,每一个链表分成奇偶两个子链表分别挂在新链表数组的散列位置。若桶(hashmap的table数组)中链表元素超过8,会自动转化成红黑树;若桶中元素小于等于6时,树结构还原成链表。红黑树的平均查找长度是log(n),长度为8,查找长度为log(8)=3,链表的平均查找长度为n/2,当长度为8时,平均查找长度为8/2=4,这才有转换成树的必要;链表长度如果小于等于6,6/2=3,虽然速度也很快,但是转化为树和生成树的时间并不会太短。中间的差值7为了防止链表和树频繁的转换,试想如果一个HashMap不停的插入、删除元素,链表个数在8左右徘徊,就会频繁的发生树转链表、链表转树,效率会很低。
- 扩容resize:扩容后需要再哈希rehash。put操作时也要判断是否需要扩容。rehash操作时,先用一个新的数组接受原数组,重新put entry到新数组中。
- hash:参考jdk的hash运算。
- rehash操作/resize操作可以看出是需要开销的,所以主要优化的地方也在这里。如果业务中能判断初始化容量,可以提前设置好初始化参数。而默认加载因子0.75f是平衡了时间和空间等因素; 负载因子越小桶的数量越多,读写的时间复杂度越低(极限情况O(1), 哈希碰撞的可能性越小); 负载因子越大桶的数量越少,读写的时间复杂度越高(极限情况O(n), 哈希碰撞可能性越高)。 0.1,0.9,2,3等都是合法值。
4.2 构造方法
- 无参构造:它并没有实际的操作,而是调用有参构造。源码中它的作用是初始化一些参数。
- 有参构造:初始化参数。初始化加载因子和初始化容量。
4.3 成员方法
4.3.1 put方法
- 首先根据当前数组容量,来判断是否需要扩容;如果需要扩容,需要进行rehash操作。
- 之后根据put时的key来计算hash值,计算在数组中的位置;如果hash值在数组中不存在,就直接将元素设置到数组中,并且容量加一;否则遍历该索引位置上的单链表,如果单链表已存在value,就替换,并将旧值返回;如果不存在就set该元素到链表头位置,同时容量加一。
4.3.2 get方法
首先计算key的hash值,如果hash值为null,说明数组中不存在该key,就返回null;如果hash值存在,通过该hash值得到数组中的Entry位置,并且判断该位置上的元素的key和请求参数key是否相同(需要==和equals同时比较),如果相同则返回该value,否则返回null。
代码复制粘贴完即可运行。欢迎评论指正!