Map是java开发中最常见的一种数据结构,最常用的Map类型有HashMap、TreeMap、SortedMap等,今天就和大家分享下HashMap的底层原理,以及注意事项。HashMap 底层是基于 数组 + 链表 组成的,但是在具体实现方面jdk1.7和jdk1.8稍微有些不同,今天我们先来学习下jdk1.7的HashMap,请看下图
根据上图我们知道HashMap其实是由一个数组组成,每个数组里面包含一个链表,每个链表是由多个K,V的数据结构组成,OK我们先对其结构有个大概的了解,接下来我们先看下jdk1.7中的HashMap的主要源码实现;
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; 既然HashMap底层是数组,那么数组总有个初始化大小吧,这个大小默认就是16,那为什么要写成1 << 4,其实就是为了强调数组的大小要是2的幂。
static final int MAXIMUM_CAPACITY = 1 << 30;该项是指数组最大为2的30次方,为什么是2^30呢,是因为1左移31位的为-2147483648,所以最多只能移30。
static final float DEFAULT_LOAD_FACTOR = 0.75f;该项指的是默认的负载因子为0.75,后面的f是代表float类型。因为Map在使用过程中不断的往里面存放数据,当数组的大小超过一定的容量时,就需要扩容,那到底多大就会触发扩容呢?默认容量16*0.75=12时,就需要将当前 16 的容量进行扩容,看到这里DEFAULT_LOAD_FACTOR 明白是干嘛用了吧!
static final Entry<?,?>[] EMPTY_TABLE = {}; transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE; 这边需要结合起来看,首先声明个空的数组,然后将空的数组赋给table。那么这个数组是如何定义的呢,请看源码
static class Entry<K,V> implements Map.Entry<K,V> {
final K key;
V value;
//存储指向下一个Entry的引用,单链表结构
Entry<K,V> next;
//对key的hashcode值进行hash运算后得到的值,存储在Entry,避免重复计算
int hash;
/**
* Entry构造函数.
*/
Entry(int h, K k, V v, Entry<K,V> n) {
value = v;
next = n;
key = k;
hash = h;
}
transient int size;Map存放数量的大小。
int threshold;该项表示下次扩容阀值(容量*加载因子)
final float loadFactor;该项表示负载因子,那和DEFAULT_LOAD_FACTOR有什么不一样呢,其实DEFAULT_LOAD_FACTOR只是初始化了个常量,loadFactor才是真正负载因子的变量,在HashMap无参构造函数中就执行了一行代码
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
transient int modCount;modCount用于记录HashMap的修改次数,由于HashMap是非线程安全的,不可避免的造成了线程1在遍历,线程2在修改的时候发生冲突,此时就会抛出ConcurrentModificationException异常。
想要了解一个类,就必须先要了解其创建过程,HashMap一共有四个构造函数,请看源码;
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;
threshold = initialCapacity;
init();//这个先不用管
}
//通过扩容因子构造HashMap,容量去默认值,即16
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
//装载因子取0.75,容量取16,构造HashMap
public HashMap() {
this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
}
//通过其他Map来初始化HashMap,容量通过其他Map的size来计算,装载因子取0.75,并加入其他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);//初始化HashMap底层的数组结构
putAllForCreate(m);//添加m中的元素
}
从上面源码可以看出HashMap的构造函数就干了一件事,指定默认容量和装载因子。了解了构造函数接下来我们看下最重要的put和get操作。
public V put(K key, V value) {
//如果table数组为空数组{},即创建后的第一次put
if (table == EMPTY_TABLE) {
inflateTable(threshold);//分配数组空间
}
if (key == null)
return putForNullKey(value);
int hash = hash(key);//对key的hashcode进一步计算,确保散列均匀
int i = indexFor(hash, table.length);//获取在table中的实际位置
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
//如果该对应数据已存在,执行覆盖操作。返回旧value
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;
}
}
modCount++;//保证并发访问时,若HashMap内部结构发生变化,快速响应失败
addEntry(hash, key, value, i);//新增一个entry
return null;
}
这里面关键几步操作:1.如果数组为空,那么给数据分配空间。2.对key进行hash计算。3.根据hash值找到数组中位置。4.如果数据已存在则覆盖。5.如果在数组中没有找到对应的hashcode,则新建Entry。整个流程看下图:
接下来我们看下是如何给数组分配空间的,请看源码:
private void inflateTable(int toSize) {
int capacity = roundUpToPowerOf2(toSize);
//记录下次扩容的阀值
threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
table = new Entry[capacity];//分配空间
initHashSeedAsNeeded(capacity);//更新一下rehash的判断条件,便于以后判断是否rehash,先不用管这个方法
}
/**
* 2的次幂
*/
private static int roundUpToPowerOf2(int number) {
return number >= MAXIMUM_CAPACITY
? MAXIMUM_CAPACITY
: (number > 1) ? Integer.highestOneBit((number - 1) << 1) : 1;
}
这是初始化数组空间的主要逻辑,请看下图:
添加一个K,V时候如果在数组中找到相应的Entry时候,则需要覆盖老的value,如果没有就需要添加新的Entry,源码如下:
void addEntry(int hash, K key, V value, int bucketIndex) {
if ((size >= threshold) && (null != table[bucketIndex])) {
resize(2 * table.length);//进行扩容,新容量为旧容量的2倍
hash = (null != key) ? hash(key) : 0;
bucketIndex = indexFor(hash, table.length);//扩容后重新计算插入的位置下标
}
//把元素放入HashMap的桶的对应位置
createEntry(hash, key, value, bucketIndex);
}
//创建元素
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++;//元素个数+1
}
添加新的Entry之前先要判断下是否需要扩容,如果需要则扩容到之前的两倍,扩容之后需要重新计算位置的下标,重新迁移数据,最后插入数据。请看下图:
ok,这就是put操作的主要源码,接下来我们来看下get源码:
//获取key值为key的元素值
public V get(Object key) {
if (key == null)
return getForNullKey();
Entry<K,V> entry = getEntry(key);//获取实体
return null == entry ? null : entry.getValue();//判断是否为空,不为空,则获取对应的值
}
final Entry<K,V> getEntry(Object key) {
if (size == 0) {//元素个数为0
return null;//直接返回null
}
int hash = (key == null) ? 0 : hash(key);//获取key的Hash值
for (Entry<K,V> e = table[indexFor(hash, table.length)];//根据key和表的长度,定位到Hash桶
e != null;
e = e.next) {//进行遍历
Object k;
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))//判断Hash值和对应的key,合适则返回值
return e;
}
return null;
}
看完源码详细大家对get的大体流程有了初步了解,首先计算出key的hashcode,根据indexFor找到数组的下标,然后遍历链表,通过key的equals方法比对查找对应的记录。看到这里大家有没有发现个问题呢?就是当 Hash 冲突严重时,在数组里形成的链表会变的越来越长,这样在查询时的效率就会越来越低;时间复杂度为 O ( N ) 。其实啊这个问题在jdk1.8中得到了优化了,欲知详情敬请关注下一遍《Java集合Map之HashMap原理(jdk1.8版)》
如果你想更加系统的学习java的各种知识,请关注公众号:"辉哥讲技术"