##1、介绍
HashMap是一个散列表,存储的内容是键值对(HashMap),存储结构由数组加单向链表组成的,如图:
##2、使用
这里说明一些hashmap的用法和对应的源码解析,这里使用的的jdk1.7版本。
1、定义
HashMap hashMap = new HashMap<>();
HashMap hashMap2 = new HashMap<>(20);
HashMap hashMap3 = new HashMap<>(20,0.75f);
HashMap hashMap4 = new HashMap<>(hashMap);
有四种定义方法,也对应了四种构造函数。
①、
public HashMap() {
this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
}
这里调用了另一个构造函数。
②、
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
这里也调用了第三个构造函数。
③、
public HashMap(int initialCapacity, float loadFactor) {
//初始容量小于0抛出错误
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity);
//如果大于最大值,那么就等于最大值,这里最大值为1<<30;
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
//加载因子无效,抛出异常
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
//加载因子赋值
this.loadFactor = loadFactor;
//threshold赋值,后面会被重新赋值=容量*加载因子
threshold = initialCapacity;
init();
}
这里面传入有两个,一个数组容量大小a,一个是加载因子b,数组容量大小这里可以随意输入,在后面put操作的时候会将数组真实容量设置为不小于a的最小的2的幂的数,这里容量的值都会设置为2的幂,具体原因后面会介绍。加载因子b,表示填充度,也就是数组填满的程度,填充度越高,空间利用率就越高,但是索引值冲突的机会就大了;填充度小,索引值冲突的机会小了,但是空间利用率也小了,所以这个值代表了时间和空间的一个折中,默认是0.75。构造函数中的threshold代表阈值,当hashmap里面的容量达到阈值时,将会把哈希表重建,扩容为之前的两倍,这几个构造方法只是添加了初始值,并没有真正的开辟存储空间,在后面put操作中才会开辟空间。
④、
public HashMap(Map extends K, ? extends V> m) {
//利用上面那个构造方法构造出一个新的hashmap
this(Math.max((int) (m.size() / DEFAULT_LOAD_FACTOR) + 1,
DEFAULT_INITIAL_CAPACITY), DEFAULT_LOAD_FACTOR);
//初始化map
inflateTable(threshold);
// 将m中的所有键值对存储到本map中
putAllForCreate(m);
}
private void inflateTable(int toSize) {
// Find a power of 2 >= toSize
//获得一个不小于传入容量的最大的2的幂的数
int capacity = roundUpToPowerOf2(toSize);
//设置阈值
threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
//创建数组
table = new Entry[capacity];
//初始化一个hashseed值,这里为0,后面的hash方法中会用到
initHashSeedAsNeeded(capacity);
}
2、添加操作(里面最复杂的操作,这个操作理解了,其余就简单了)
hashMap.put("k1", "v1");添加键位k1,值为v1的键值对到map中。
源码:
public V put(K key, V value) {
//如果数组是空的,说明还没有申请存储空间
if (table == EMPTY_TABLE) {
//初始化表
inflateTable(threshold);
}
//如果key的值为null
if (key == null)
//放到table[0]中
return putForNullKey(value);
//通过hash方法得到hash值
int hash = hash(key);
//通过hash值与数组的长度过得在数组中的索引值
int i = indexFor(hash, table.length);
//
for (Entry e = table[i]; e != null; e = e.next) {
Object k;
//如果这个键的hash值已经存在,再判断键值是否相等,如果hash值不一样,那么也不用再比较,因为key相同hash肯定相同,直接比较链表中下一个元素即可
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
//返回旧的值,存储新的值
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
//如果没有发现相同的key,那么创建一个新的entry连接上去
modCount++;
addEntry(hash, key, value, i);
return null;
}
//用了几个函数
//第一个:
//将key为null的值放到table[0]中去
private V putForNullKey(V value) {
for (Entry e = table[0]; e != null; e = e.next) {
if (e.key == null) {
//写入新值,返回旧值
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
//如果table[0]中没有点,那么久新建一个
modCount++;
addEntry(0, null, value, 0);
return null;
}
//第二个:
//对hashcode的值进行各种异或加移为操作,使得1的位置更加均匀
//这个函数的意义后面再做具体解释
final int hash(Object k) {
int h = hashSeed;
if (0 != h && k instanceof String) {
return sun.misc.Hashing.stringHash32((String) k);
}
h ^= k.hashCode();
// This function ensures that hashCodes that differ only by
// constant multiples at each bit position have a bounded
// number of collisions (approximately 8 at default load factor).
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
//第三个函数:
//将hash值与数组的长度值相与获得索引值
static int indexFor(int h, int length) {
// assert Integer.bitCount(length) == 1 : "length must be a non-zero power of 2";
return h & (length-1);
}
//最后一个函数
//创建一个新的节点
void addEntry(int hash, K key, V value, int bucketIndex) {
//如果容量已经超过临界值,并且出现hash冲突则扩容
if ((size >= threshold) && (null != table[bucketIndex])) {
//扩容为之前的两倍
resize(2 * table.length);
//获取当前key的扩容后的hash值
hash = (null != key) ? hash(key) : 0;
//获取扩容后的索引值
bucketIndex = indexFor(hash, table.length);
}
//创建新的节点
createEntry(hash, key, value, bucketIndex);
}
//这里又调用了两个函数
//创建新的节点
void createEntry(int hash, K key, V value, int bucketIndex) {
//获取之前链表的头部节点
Entry e = table[bucketIndex];
//插入新节点到头部
table[bucketIndex] = new Entry<>(hash, key, value, e);
size++;
}
//这里有个Entry类的构造方法
//将新节点的next指向原来的头部节点即可
Entry(int h, K k, V v, Entry n) {
value = v;
next = n;
key = k;
hash = h;
}
//添加节点中还有另一个函数扩容
void resize(int newCapacity) {
//获取旧的hash表
Entry[] oldTable = table;
//旧的hash表的长度
int oldCapacity = oldTable.length;
//如果长度等于最大了,那就不能扩容了,直接返回
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
//建立新的节点表
Entry[] newTable = new Entry[newCapacity];
//将就表中的元素放到新表中
transfer(newTable, initHashSeedAsNeeded(newCapacity));
//原来的表的引用指向新的表
table = newTable;
//得到新的阈值
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
//这里面有个旧表函数放入新表的函数
void transfer(Entry[] newTable, boolean rehash) {
//获取新表的长度
int newCapacity = newTable.length;
//表中数组进行遍历
for (Entry e : table) {
while(null != e) {
Entry next = e.next;
//获取hash值
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
//获取索引值
int i = indexFor(e.hash, newCapacity);
//将这个点插入到新的索引点的头部
e.next = newTable[i];
//索引点头部指向再指向这个点
newTable[i] = e;
//继续遍历这个链表
e = next;
}
}
}
到这个put操作基本就完成了,这里总结一下步骤:
1、插入的时候判断这个表是不是空的,是的话先初始化一个新表。
2、插入的时候判断key是否为null,是的话直接往table[0]的链表里面插。
3、通过key计算hash值,再找到对应的索引点。
4、插入元素的时候如果索引点处的链表包含了这个key就直接替代原来的 value。
5、如果没有这个key就直接加入一个新的点
6、加入新点的时候首先要判断空间是否够用,够用的话就直接将点插到链表头。这里的table[i]即指向了链表头。
7、不够用的话就扩容为之前的两倍。然后再插入
8、扩容的时候需要重建hash表(因为长度变了,对应的索引值同样会变),然后将原来的点插入新的位置。
对于put操作中几个关键点的解释:
1、为什么数组的长度都是2的次幂?
①、先看我们的求索引函数
static int indexFor(int h, int length) {
// assert Integer.bitCount(length) == 1 : "length must be a non-zero power of 2";
return h & (length-1);
}
这里是将hash值与长度值-1相与,其实这里要实现的是取模运算,但是位运算的效率更高,所有用与运算代替了取模运算,例如:
因为length-1后二进制全为1,可以完全的保留hash值低位的特征。这里换成其他的值,例如21,21-1=010100,这样低位的特征只有两位能够被保存。总结起来就是,因为索引值是通过hash值与长度-1相与得到的,而长度为2的次幂那么低位都是0可以很好的保留低位特征。如果换成其他值的话低位不完全为0,不能很好的保留低位特征。
②、扩容为之前的两倍后我们需要重建hash表:
如果是按照2的次幂长度来存储的话,扩容后比扩容前也只会多出一位的不同,这样总的索引相对于之前变化不会太大,但是如果是长度是21,那么扩容两倍后42,length-1有5位不同,索引位置相对之前有很大的改变。总结一句话就是因为长度为2的幂次方,所以两倍的时候的索引值与之前只有1位的变化,这样节点在老数组中的位置和新数组中的位置不会差特别多。
2、为什么要使用hash函数
通过上面我们也可以看到了,索引值是通过hash值与上长度-1得到的,这样的缺点是丢掉了高位元素的特征,冲突性可能比较大,所以需要一个扰动函数来讲hash值的1变得更加均匀, 这里我们直接看看1.8中的hash函数:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
这个函数也叫扰动函数,做了两个事情,首先将获取到hash值,然后再讲hash值和hash值右移16位,然后和hash值相异或,这样高位特征得到保留,低位既包含了高位特征也包含了低位特征,这样大大减小冲突性。总结一句话就是通过扰动函数,hash值的低位有更加丰富的特征,大大减小冲突性。
3、通过索引值找key时为什么要通过这句话:if (e.hash == hash && ((k = e.key) == key || key.equals(k)))
首先我们知道两个对象相同hashcode一定相同,hashcode相同对象不一定相同,hashcode不同对象一定不相同。如果key相同,那么hashcode一定相同,hashcode相同那么通过hash函数的hash值也一定相同,然后通过索引函数的索引值也一定相同, 但是当hashcode不同的时候,通过我们的hash函数再通过索引函数的时候也有可能取得相同的索引(hash冲突),所以存在同一个地方的hash值可以是不相等的,所以这里先判断hash值是否相同,如果hash值都不同那么key肯定不同,就不用进行后面的equal对比,这样效率也更高,如果hash值相等然后再去比较key值是否相等。
4、hashcode和equals比较
通过上面的操作我们也可以看到,首先容器里面的key是不能重复的,如果索引的值都通过equals去和每一个节点对比,这样效率会非常低,但是利用我们的hashcode,对象相同的hashcode一定相同,所以我们直接减小的对比的元素,然后对比的时候还是先比较hash值,这样效率又增加一步。
至此put操作介绍完成,继续介绍后面的操作。
3、删除操作
hashMap.remove("k1"); //删除键值k1对应的键值对
hashMap.clear();//清空所以键值对
源码介绍:
①、
public V remove(Object key) {
//调用函数删除节点
Entry e = removeEntryForKey(key);
return (e == null ? null : e.value);
}
//删除节点并且返回被删除的节点
final Entry removeEntryForKey(Object key) {
//判断节点是否为空
if (size == 0) {
return null;
}
//获取hash值
int hash = (key == null) ? 0 : hash(key);
//获取索引值
int i = indexFor(hash, table.length);
//获取链表头部
Entry prev = table[i];
Entry e = prev;
while (e != null) {
Entry next = e.next;
Object k;
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k)))) {
modCount++;
size--;
//如果要删除的点就是头部,那么头部指向下一个点
if (prev == e)
table[i] = next;
//如果不是那么就将当前节点的前一个节点指向当前节点的后面一个节点
else
prev.next = next;
e.recordRemoval(this);
return e;
}
//保存当前节点的前一个点
prev = e;
//继续遍历
e = next;
}
return e;
}
②、
//调用函数清除表
public void clear() {
modCount++;
Arrays.fill(table, null);
size = 0;
}
//通过循环直接将数组清空
public static void fill(Object[] a, Object val) {
for (int i = 0, len = a.length; i < len; i++)
a[i] = val;
}
4、获取操作
hashMap.get("k1");//获取键值k1对应的值
public V get(Object key) {
if (key == null)
return getForNullKey();
Entry entry = getEntry(key);
return null == entry ? null : entry.getValue();
}
//这里调用两个函数
//第一个获取key为空值的
private V getForNullKey() {
//节点为0直接返回空
if (size == 0) {
return null;
}
//通过循环在table【0】链表中找到key为空的值并且返回
for (Entry e = table[0]; e != null; e = e.next) {
if (e.key == null)
return e.value;
}
return null;
}
//第二个函数
//获取到key对应的节点
final Entry getEntry(Object key) {
if (size == 0) {
return null;
}
//获取到hash值
int hash = (key == null) ? 0 : hash(key);
//通过索引位置循环找出key相同的节点
for (Entry 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;
}
5、修改操作
hashMap.put("k1", "vv");//还是用put,如果put的key在map中已经有了,就替换原来的值
这里同样用到了put函数,如果找到了相同的key直接修改元素即可。
6、查找操作
hashMap.containsKey("k1");//是否包含key为k1的键值对hashMap.containsValue("vv");//是否包含值为vv的键值对
①、
public boolean containsKey(Object key) {
return getEntry(key) != null;
}
直接调用getEntry操作,判断返回值是否为null即可。
②、
public boolean containsValue(Object value) {
//如果值为null,调用函数判断
if (value == null)
//和下面那个循环其实差不多只是对比是直接对比变量==
return containsNullValue();
//不为空,通过双重循环(遍历出所有节点)来判断值是否相等
Entry[] tab = table;
for (int i = 0; i < tab.length ; i++)
for (Entry e = tab[i] ; e != null ; e = e.next)
if (value.equals(e.value))
return true;
return false;
}
7、遍历(三种方法)
①、对key进行遍历
Iterator iterator = hashMap.keySet().iterator();
while(iterator.hasNext()){
String key = iterator.next();
String val = hashMap.get(key);
System.out.println("k="+key+",v="+val);
}
这里调用了hashmap的keySet函数,返回一个包含所有key的一个集合。
②、对value进行遍历
Collection collection = hashMap.values();
Iterator iterator = collection.iterator();
while(iterator.hasNext()){
System.out.println("v="+iterator.next());
}
这里调用了hashmap的values函数,返回一个集合。
③、对节点进行遍历
Iterator iterator = hashMap.entrySet().iterator();
while(iterator.hasNext()){
Entry entry = (Entry) iterator.next();
System.out.println("k="+entry.getKey()+",v="+entry.getValue());
}
这里调用了hashmap的entrySet函数,返回一个entry的集合。其实这个三个方法都是通过对数组和链表的双重循环遍历实现的,这里只是给了我们接口方便调用。
##3、总结
1、之前我们学习了数组和链表知道了,数组的优点是查询和修改快,缺点是插入和删除慢,而链表的优点是插入和删除快,缺点是查询和修改慢,而hashmap正是这两种情况的折中查询和修改比链表快,插入和修改比数组快。适合存放海量数据。
2、hashmap不是线程安全的,后面要学的hashtable是线程安全的。
3、存储的是key-value形式的键值对