您的位置:首页 > 职场人生

Java菜鸟面试突破系列Java集合源码解读系列:HashMap实现原理

2017-05-16 11:41 1071 查看

Java菜鸟面试突破系列 Java集合源码解读系列:HashMap实现原理

1、HashMap概念:

HashMap是基于哈希表的Map接口的实现,是一种非同步实现,HashMap里面实现一个静态内部类Entry,其重要的属性有 key , value, next,其key-value对允许null值和null键,hashmap不保证映射的顺序,不保证顺序恒久不变。

2、HashMap数据结构

在java中,最基本的数据结构有两种:数组和模拟指针(引用),所有数据结构都可以由这两个基本结构来构造。HashMap实际上就是这么一个“链表散列”,即数组和链表的结合体。

HashMap的底层是一个数组结构,数组中的每一项又是一个链表,当新建一个HashMap的时候,首先会初始化一个数组,java源代码如下:

/**
* The table, resized as necessary. Length MUST Always be a power of two.
*/
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;

static class Entry<K,V> implements Map.Entry<K,V> {
final K key;
V value;
Entry<K,V> next;
int hash;
...


从源代码我们可以看出,Entry就是数组中的元素,每个Map.Entry其实就是一个key-value对,它持有一个指向下一个元素的引用,如此就构成了一个链表的结构。

3、HashMap工作原理

HashMap是基于hashing的原理,我们使用put(key, value)存储对象到HashMap中,使用get(key)从HashMap中获取对象(value)。当我们往HashMap中put元素的时候,也就是当我们给put()方法传递键和值时,我们先对键(key)调用hashCode()方法,返回的hashCode(hash值)就是这个元素在数组中的位置(即下标),找到了bucket位置,然后就可以把这个元素放到对应的位置中来储存Entry对象了。如果这个元素所在的位子上已经存放有其他元素了,那么在同一个位子上的元素将以链表的形式存放,新加入的放在链头,最先加入的放在链尾。这里关键点在于指出,HashMap是在bucket中储存键对象和值对象,作为Map.Entry。当我们从HashMap中get元素时,首先计算key的hashcode,找到数组中对应位置的某一元素,然后通过key的equals方法在对应位置的链表中找到需要的元素。从这里我们可以想象得到,如果每个位置上的链表只有一个元素,那么HashMap的get效率将是最高的。。。

4、HashMap的存取实现

上面在HashMap的工作原理之中,我们谈到了HashMap的存取,即put()和get()方法,这里我们详细来看看这两个方法底层的实现。

1)存储:

public V put(K key, V value) {
// 如果是空的 加载
if (table == EMPTY_TABLE) {
inflateTable(threshold);
}
// HashMap允许存放null键值对:null键和null值
// 如果key为null,调用putForNullKey方法,将value放在数组
// 的第一个位置
if (key == null)
return putForNullKey(value);
// 根据key的hashcode计算hash值
int hash = hash(key);
// 搜索指定hash值在对应table中的索引
int i = indexFor(hash, table.length);
// 如果i索引处的Entry不为null,则循环遍历e元素的下一个元素
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
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;
}
}
// 如果i索引处的Entry为null,则此处还没Entry
modCount++;
// 将key、value添加到i索引处
addEntry(hash, key, value, i);
return null;
}


从上面的源代码中可以看出:当我们往HashMap中put元素的时候,即将一个key-value对放入HashMap中时,我们先对键(key)调用key的hashCode()方法计算其hash值,返回的hashCode(hash值)就是这个元素在数组中的位置(即下标),找到了bucket位置,然后就可以把这个元素放到对应的位置中来储存Entry对象了。如果这个元素所在的位子上已经存放有其他元素了(即两个Entry的key的hashCode值相同),如果这两个Entry的key通过equals比较返回true,新添加的Entry的value将覆盖集合中原有Entry的value,但key不会覆盖;如果这两个Entry的key通过equals比较返回false,那么在同一个位子上的元素将以链表的形式存放形成Entry链,新加入的放在链头,最先加入的放在链尾。如果该位置上没有元素,就直接将该元素放到此数组中的该位置即可。

putForNullKey(value)方法,将null键的value放到数组第一个位置:

private V putForNullKey(V value) {
for (Entry<K,V> e = table[0]; e != null; e = e.next) {
if (e.key == null) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;
addEntry(0, null, value, 0);
return null;
}


这段代码就不写注释了,大体和 put()方法一致。。。

addEntry(hash, key, value, i)方法根据计算出的hash值,将key-value对存放在数组table的i索引处,其具体源码如下:

void addEntry(int hash, K key, V value, int bucketIndex) {
// 把table对象的长度扩充到原来的2倍
if ((size >= threshold) && (null != table[bucketIndex])) {
resize(2 * table.length);
hash = (null != key) ? hash(key) : 0;
bucketIndex = indexFor(hash, table.length);
}
// 调用createEtry方法
createEntry(hash, key, value, bucketIndex);
}

void createEntry(int hash, K key, V value, int bucketIndex) {
// 获取指定bucketIndex索引处的Entry
Entry<K,V> e = table[bucketIndex];
// 将新创建的Entry放入bucketIndex索引处,并让新的Entry指
// 向原来的Entry
table[bucketIndex] = new Entry<>(hash, key, value, e);
size++;
}


hash(int h)方法根据key的hashcode重新计算一次散列,这个算法加入了高位计算(好处:使得只有相同的hash值的两个值才会被放到数组中的同一个位置上形成链表),防止低位不变,高位变化时,造成的hash冲突,源码如下:

final int hash(Object k) {
int h = hashSeed;
if (0 != h && k instanceof String) {
return sun.misc.Hashing.stringHash32((String) k);
}
h ^= k.hashCode();

h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}


从上面我们可以看到,在HashMap中,要找到某个元素,我们需要根据hash算法计算key的hash值来求得对应数组中的bucket位置,这里就会存在一个冲突的问题,就是计算得到的hash值一致的时候,我们知道HashMap是一个数组和链表的结合体,所以这个时候,我们还需要遍历链表,所以这时候,我们应该尽量使得每个位置上的元素数量只有一个,那么当我们用hash算法求得这个bucket的时候,马上就可以知道对应位置的元素就是我们要的,而不是去遍历链表,这样能够大大优化查询的效率。

在HashMap中,调用indexFor(hash, table.length)方法来计算该对象应该保存在table数组的哪个索引处,它通过h & (length-1)来得到该对象的保存位,其源码如下:

static int indexFor(int h, int length) {
return h & (length-1);
}


hashmap的底层数组长度总是2的n次方,其默认初始容量为16,为何是16呢,主要是为了优化查询效率,其加载因子是0.75,当元素个数超过 容量长度的0.75倍时,进行扩容,按倍递增,即如果默认是16扩容后就是32,在HashMap构造器中有如下代码:

int capacity = 1;
while(capacity<initialCapacity)
capacity<<=1;


这段代码能够保证初始化时,hashmap的容量总是2的n次方,即底层数组的长度总是2的n次方,当length总是2的n次方时,h & (length-1)运算其实等价于对length取模,即h%length,不过&比%有更高的效率。

2)读取:

public V get(Object key) {
if (key == null)
return getForNullKey();
Entry<K,V> entry = getEntry(key);

return null == entry ? null : entry.getValue();
}

private V getForNullKey() {
if (size == 0) {
return null;
}
for (Entry<K,V> e = table[0]; e != null; e = e.next) {
if (e.key == null)
return e.value;
}
return null;

final Entry<K,V> getEntry(Object key) {
if (size == 0) {
return null;
}

int hash = (key == null) ? 0 : hash(key);
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 != null && key.equals(k))))
return e;
}
return null;
}


从上面源代码可以看出:当我们从HashMap中get元素时,首先计算key的hashcode,找到数组中对应位置的某一元素,然后通过key的equals方法在对应位置的链表中找到需要的元素。从这里我们可以想象得到,如果每个位置上的链表只有一个元素,那么HashMap的get效率将是最高的。。。

5、loadFactor和rehash

首先看看java定义负载因子:

static final float DEFAULT_LOAD_FACTOR = 0.75F;


可以看出,默认的负载因子大小为0.75,也就是说,当一个map填满了75%的bucket时候,和其它集合类(如ArrayList等)一样,将会创建原来HashMap大小的两倍的bucket数组,来重新调整map的大小,并将原来的对象放入新的bucket数组中。这个过程叫作rehashing,因为它调用hash方法找到新的bucket位置。

rehash存在的问题:在多线程的情况下,当重新调整HashMap大小的时候,存在条件竞争,因为如果两个线程都发现HashMap需要重新调整大小了,它们会同时试着调整大小。在调整大小的过程中,存储在链表中的元素的次序会反过来,因为移动到新的bucket位置的时候,HashMap并不会将元素放在链表的尾部,而是放在头部,这是为了避免尾部遍历(tail traversing)。如果条件竞争发生了,那么就死循环了。这个时候,你可以质问面试官,为什么这么奇怪,要在多线程的环境下使用HashMap呢?

6、HashMap Hashtable LinkedHashMap 和TreeMap?

首先我们知道,java为数据结构中的映射定义了一个接口java.util.Map;它有四个实现类,分别是HashMap Hashtable LinkedHashMap 和TreeMap.

Map主要用于存储健值对,根据键得到值,因此不允许键重复(重复了覆盖了),但允许值重复!

Hashmap 是一个最常用的Map,它根据键的HashCode值存储数据,根据键可以直接获取它的值,具有很快的访问速度,遍历时,取得数据的顺序是完全随机的。 HashMap最多只允许一条记录的键为Null,允许多条记录的值为 Null;HashMap不支持线程的同步,即任一时刻可以有多个线程同时写HashMap,可能会导致数据的不一致。如果需要同步,可以用 Collections的synchronizedMap方法使HashMap具有同步的能力,或者使用ConcurrentHashMap。

Hashtable与 HashMap类似,它继承自Dictionary类,不同的是:它不允许记录的键或者值为空;它支持线程的同步,即任一时刻只有一个线程能写Hashtable,因此也导致了 Hashtable在写入时会比较慢。(主要区别就是以上两点是相反的,HashMap进一步改进了)

LinkedHashMap 是HashMap的一个子类,保存了记录的插入顺序,在用Iterator遍历LinkedHashMap时,先得到的记录肯定是先插入的。也可以在构造时用带参数,按照应用次数排序。在遍历的时候会比HashMap慢,不过有种情况例外,当HashMap容量很大,实际数据较少时,遍历起来可能会比 LinkedHashMap慢,因为LinkedHashMap的遍历速度只和实际数据有关,和容量无关,而HashMap的遍历速度和他的容量有关。

TreeMap实现了SortMap接口,能够把它保存的记录根据键排序,默认是按键值的升序排序,也可以指定排序的比较器,当用Iterator 遍历TreeMap时,得到的记录是排过序的。

一般情况下,我们用的最多的是HashMap,在Map 中插入、删除和定位元素,HashMap 是最好的选择。但如果您要按自然顺序或自定义顺序遍历键,那么TreeMap会更好。如果需要输出的顺序和输入的相同,那么用LinkedHashMap 可以实现,它还可以按读取顺序来排列。

结束语:推荐结合我之前的博文Java面试系列之HashMap大扫盲汇总一起学习!

-2017.5.16总结 by 小仇哥
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: