赞
踩
HashMap基于哈希表的Map接口实现,是以key-value存储形式存在,即主要用来存放键值对。HashMap 的实现不是同步的,这意味着它不是线程安全的。它的key、value都可以为null。此外,HashMap中的映射不是有序的。
JDK1.8 之前 HashMap 由 数组+链表 组成的,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突(两个对象调用的hashCode方法计算的哈希码值一致导致计算的数组索引值相同)。JDK8之后,当链表长度大于阈值(或者红黑树的边界值,默认为 8)并且当前数组的长度大于64时,此时此索引位置上的所有数据改为使用红黑树存储。
补充:将链表转换成红黑树前会判断,即使阈值大于8,但是数组长度小于64,此时并不会将链表变为红黑树。而是选择进行数组扩容。
当然虽然增了红黑树作为底层数据结构,结构变得复杂了,但是阈值大于8并且数组长度大于64时,链表转换为红黑树时,效率也变的相对更高效。
小结:
特点:
1.存取无序的
2.键和值位置都可以是null,但是键位置只能是一个null
3.键位置是唯一的,底层的数据结构控制键的位置
4.jdk1.8前数据结构是:链表 + 数组 jdk1.8之后是:链表 + 数组 + 红黑树
5.阈值(边界值) > 8 并且数组长度大于64,才将链表转换为红黑树,变为红黑树的目的是为了高效的查询。
数据结构
数据结构是计算机存储、组织数据的方式。数据结构是指相互之间存在一种或多种特定关系的数据元素的集合。通常情况下,精心选择的数据结构可以带来更高的运行或者存储效率。数据结构往往同高效的检索算法和索引技术有关。
说白了就是存储数据的一种方式。
在JDK1.8 之前 HashMap 由 数组+链表 数据结构组成的。
在JDK1.8 之后 HashMap 由 数组+链表 +红黑树数据结构组成的。
HashMap底层的数据结构存储数据的过程
代码
public static void main(String[] args) {
HashMap<String, Integer> map = new HashMap<>();
map.put(“稽哥”, 18);
map.put(“雷哥”, 28);
map.put(“吴彦祖”, 18);
map.put(“张学友”, 40);
map.put(“郭德纲”, 50);
map.put(“赵本山”, 60);
map.put(“肖战”, 29);
System.out.println("Aa".hashCode());
System.out.println("BB".hashCode());
}
图解添加过程
面试题:HashMap中hash函数是怎么实现的?还有哪些hash函数的实现方式?
对于key的hashCode做hash操作,无符号右移16位然后做异或运算。除此以外,还可以用取余数法、伪随机数法等,但是这些效率都比较低,而无符号右移16我异或运算效率是最高的。
面试题:当两个对象的hashCode相等时会怎么样?
会发生哈希碰撞。若key值内容相同,则替换旧的value,否则连接到链表后面。
链表长度超过8,数组长度超过64的时候,会转成红黑树
面试题:何时发生哈希碰撞和什么是哈希碰撞,如何解决哈希碰撞?
只要两个元素的key计算的哈希值相同就会发生哈希碰撞,jdk8之前使用链表解决哈希碰撞,jdk8之后使用链表+红黑树解决哈希碰撞
面试题:如果两个键的hashcode相同,如何存储键值对?
hashCode相同,通过equals方法比较内容是否相同
相同:新的value覆盖旧的value,返回旧的value
不相同:将新的键值对添加到链表中
总结:
上述我们大概阐述了HashMap底层存储数据的方式。为了方便大家更好的理解,我们结合一个存储流程图来进一步说明一下:(jdk8存储过程)
说明:
1.size表示 HashMap中K-V的实时数量, 注意这个不等于数组的长度 。
2.threshold( 临界值) =capacity(容量) * loadFactor( 加载因子 0.75 )。这个值是当前已占用数组长度的最大值。size超过这个临界值就重新resize(扩容),扩容后的 HashMap 容量是之前容量的两倍。在我们实际开发中,如果对效率要求很高,应当尽可能避免hashMap的扩容
HashMap继承关系
HashMap继承关系如下图所示:
说明:
Cloneable 空接口,表示可以克隆。 创建并返回HashMap对象的一个副本。
Serializable 序列化接口。属于标记性接口。HashMap对象可以被序列化和反序列化。
AbstractMap 父类提供了Map实现接口。以最大限度地减少实现此接口所需的工作。
补充:通过上述继承关系我们发现一个很奇怪的现象, 就是HashMap已经继承了AbstractMap而AbstractMap类实现了Map接口,那为什么HashMap还要在实现Map接口呢?同样在ArrayList中LinkedList中都是这种结构。
据 java 集合框架的创始人Josh Bloch描述,这样的写法是一个失误。在java集合框架中,类似这样的写法很多,最开始写java集合框架的时候,他认为这样写,在某些地方可能是有价值的,直到他意识到错了。显然的,JDK的维护者,后来不认为这个小小的失误值得去修改,所以就这样存在下来了。
HashMap集合类的成员
成员变量
序列化版本号
private static final long serialVersionUID = 362498820763181265L;
集合的初始化容量
/**
问题: 为什么必须是2的n次幂?如果输入值不是2的幂比如10会怎么样?
HashMap构造方法还可以指定集合的初始化容量大小:
/**
根据上述讲解我们已经知道,当向HashMap中添加一个元素的时候,需要根据key的hash值,去确定其在数组中的具体位置。 HashMap 为了存取高效,要尽量较少碰撞,就是要尽量把数据分配均匀,每个链表长度大致相同,这个实现就在把数据存到哪个链表中的算法。
这个算法实际就是取模,hash%length,计算机中直接求余效率不如位移运算(这点上述已经讲解)。所以源码中做了优化,使用 hash&(length-1),而实际上hash%length等于hash&(length-1)的前提是length是2的n次幂。
为什么这样能均匀分布减少碰撞呢?2的n次方实际就是1后面n个0,2的n次方-1 实际就是n个1;
举例:
如果不考虑效率,直接求余数的话,就不需要要求长度是2的n次幂
小结:
如果在创建HashMap对象的时候,指定的容量不是2的n次幂,比如10,HashMap会通过一系列位移运算和或运算得到一个2的n次幂,这个数字是离我们指定容量最近的数字
源代码
/**
or the load factor is nonpositive
*/
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);
}
/**
说明:由此可以看到,当在实例化HashMap的时候,如果给定了initialCapacity(假设是10),由于HashMap的initialCapacity必须都是2的n次幂,因此这个方法用于找到大于等于initialCapacity的最小的是2的n次幂。如果initialCapacity已经是2的n次幂,则直接返回这个数。
下面我们分析一下这个算法。
默认的负载因子,默认值是0.75
/**
集合最大容量
/**
当链表的值小于6则会从红黑树转回链表
/**
当Map里面的数量超过这个值时,表中的桶才能进行树形化 ,否则桶内元素太多时会扩容,而不是树形化
/**
table用来初始化(必须是二的n次幂)(重点)
/**
HashMap中存放元素的个数(重点)
/**
用来调整大小下一个容量的值计算方式为(容量*负载因子)
/**
哈希表的加载因子(重点)
/**
2.构造一个指定初始容量和默认负载因子的HashMap
/**
3.构造一个具有指定容量和负载因子的HashMap
/**
or the load factor is nonpositive
/
public HashMap(int initialCapacity, float loadFactor) {
// 判断初始化容量 initialCapacity 是否小于0
if (initialCapacity < 0) {
// 如果小于 0,抛出非法的参数异常
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
}
// 判断初始化容量 initialCapacity 是否大于集合的最大容量 MAXIMUM_CAPACITY
if (initialCapacity > MAXIMUM_CAPACITY) {
// 如果超过最大容量,将最大容量赋值给 initialCapacity
initialCapacity = MAXIMUM_CAPACITY;
}
// 判断加载因子 是否小于等于0,或者是否是一个非法数值
if (loadFactor <= 0 || Float.isNaN(loadFactor)) {
// 如果满足上面条件,抛出非法参数异常
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
}
// 将指定的负载因子赋值给 loadFactor
this.loadFactor = loadFactor;
/
tableSizeFor 判断指定的初始化容量是否为 2 的n次幂,
如果不是,那就变为比指定容量大的最小的2的n次幂。
但是注意,这里计算出初始化容量之后,直接赋值给了threshold
有人认为这是个bug
事实上,在put方法中,会对threshold重新计算
*/
this.threshold = tableSizeFor(initialCapacity);
}
/**
表示符号位也会跟着移动,比如 -1 的最高位是1,表示是个负数,然后右移之后,最高位就是0表示当前是个正数。
所以 -1 >>>1 = 2147483647表示无符号右移,也就是符号位不变。那么-1 无论移动多少次都是-1
原理就是将最高位 1 右边的所有比特位全置为 1,然后再加 1,最高位进 1,右边的比特位全变成 0,从而得出一个 2 的次幂的值
*/
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
// 获取map的元素个数
int s = m.size();
if (s > 0) {
// 判断 table是否已经初始化
if (table == null) {
// 未初始化, s是m的元素个数
float ft = ((float) s / loadFactor) + 1.0F;
int t = ((ft < (float) MAXIMUM_CAPACITY) ?
(int) ft : MAXIMUM_CAPACITY);
// 判断得到的值是否大于阈值,如果大于阈值,则初始化阈值
if (t > threshold) {
threshold = tableSizeFor(t);
}
} else if (s > threshold) {
// 已初始化,并且元素个数大于阈值,进行扩容
resize();
}
// 将m中所有的元素添加到HashMap中
for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) {
K key = e.getKey();
V value = e.getValue();
putVal(hash(key), key, value, false, evict);
}
}
}
注意:loat ft = ((float) s / loadFactor) + 1.0F; 为什么要 +1
S / loadFactor结果是小数,+1.0F作用是相当于给小数向上取整,尽可能保证更大容量。更大的容量能够减少resize次数。
成员方法
Put方法
put方法是比较复杂的,实现步骤大致如下:
1)先通过hash值计算出key映射到哪个桶;
2)如果桶上没有碰撞冲突,则直接插入;
3)如果出现碰撞冲突了,则需要处理冲突:
a:如果该桶使用红黑树处理冲突,则调用红黑树的方法插入数据;
b:否则采用传统的链式方法插入。如果链的长度达到临界值,则把链转变为红黑树;
4)如果桶中存在重复的键,则为该键替换新值value;
5)如果size大于阈值threshold,则进行扩容;
具体的方法如下:
@Override
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
说明:
1)HashMap只提供了put用于添加元素,putVal方法只是给put方法调用的一个方法,并没有提供给用户使用。 所以我们重点看putVal方法。
2)我们可以看到在putVal()方法中key在这里执行了一下hash()方法,来看一下Hash方法是如何实现的。
Hash方法
static final int hash(Object key) {
int h;
/*
如果key为null
可以看到当key为null的时候也是有哈希值的,返回值是0
如果key不为null
首先计算出key的hashCode,然后赋值给h,接着,h进行无符号右移16位,再进行异或运算
*/
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
从上面我们可以得知,HashMap是支持null key的,而HashTable是直接使用key来获取hashCode,所以key为空会抛异常。
其实我们上面已经解释了为什么HashMap的长度要是2的n次幂,因为HashMap设计的非常巧妙,它通过 hash & (length - 1) 来得到该对象的保存位,等价于hash % length,但是 & 效率比 % 要高
探究为什么h 要右移16位
说明:高16位不变,低16位和高16位进行了一个异或运算。
问题:为什么要怎么操作
如果当length的长度很小,假设是16,那么length-1 —> 1111 ,这样的值和hashCode直接做按位与操作,实际上只使用了哈希值的后4位,高位将没有任何意义。如果当哈希值高位变化很大,低位变化很小,这样就非常容易造成哈希冲突,所以这里要把高位和低位都利用起来,从而解决这个问题。说白了,这个操作的作用,其实就是为了防止哈希冲突
putVal
HashMap中put方法的核心方法。向hashMap中添加数据的主要逻辑在这个方法中。
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K, V>[] tab;
Node<K, V> p;
// n存放数组长度。i存放key的hash计算后的值
int n, i;
/*
判断table是否为空
1. table 表示存储在Map中的元素的数组
2. (tab = table) == null 表示将table赋值给tab,并且判断tab是否为null。
3. (n = tab.length) == 0 表示,将tab的长度赋值给n,并判断n 是否等于-
/
if ((tab = table) == null || (n = tab.length) == 0) {
// 如果为空就通过resize实例化一个数组
/
这里的代码等价于
tab = resize();
n = tab.length
/
n = (tab = resize()).length;
}
/
i = hash & (n - 1) 计算当前key所在下标,确定在哪个桶中,并将下标赋值给i
p = tab(i) 将该位置的元素赋值给p,并且判断是否为null
/
if ((p = tab[i = (n - 1) & hash]) == null) {
// 直接创建一个Node元素,赋值给当前下标位置
tab[i] = newNode(hash, key, value, null);
} else {
// 当前下标位置不为null
// 注意,我们在上面的if中,已经把当前下标位置的元素,赋值给了p
Node<K, V> e;
K k;
/
比较桶中第一个元素的hash值和key是否相等。
1. p.hash == hash :判断第一个元素的hash与我们传进来的hash是否相等
2. ((k = p.key) == key || (key != null && key.equals(k)))
2.1 (k = p.key) == key 将第一个元素的key赋值给k,并且判断是否和我们传进来的key相等
2.2 判断我们传进来的key不等于null,并且key的值和k相等
上面如果都满足的情况下,说明第一个元素的key和我们传进来的key值是相等的
/
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k)))) {
// 将该位置的节点赋值给e
e = p;
} else if (p instanceof TreeNode) {
// 判断当前下标位置的数据类型是否为红黑树
e = ((TreeNode<K, V>) p).putTreeVal(this, tab, hash, key, value);
} else {
// 说明当前元素是个链表
// 不是红黑树,当前下标位置的key也与要插入的key不相等
// 遍历链表
for (int binCount = 0; ; ++binCount) {
/
(e = p.next) == null 将p的下一个元素赋值给e,并判断e是否为null
如果等于null,说明当前元素是表尾,已经没有下一个元素了
如果不为null,说明下一个元素还存在,可以继续遍历
/
if ((e = p.next) == null) {
// 进入,说明e是表尾
// 直接将数据写到下一个节点
p.next = newNode(hash, key, value, null);
/
1. 节点添加完成之后判断此时节点个数是否大于临界值 8,如果大于则将链表转为红黑树。
2. int binCount = 0,表示for循环的初始化值,从0开始计算,记录遍历节点的个数
|- 0表示第一个节点
|- 1表示第二个节点
|- 。。。。
|- 7表示第八个节点
因此这里TREEIFY_THRESHOLD需要-1
/
if (binCount >= TREEIFY_THRESHOLD - 1) {
// 将链表转为红黑树
treeifyBin(tab, hash);
}
break;
}
// 如果当前位置的key与要存放位置的key相同,直接跳出
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k)))) {
/
要添加的元素和链表中存在的元素相等了,则跳出for循环,不需要再比较后面的元素了
直接进入下面的if语句去替换e的值
*/
break;
}
// 说明新添加的元素和当前节点不相同,继续找下一个元素。
p = e;
}
}
// e不为空,说明上面找到了一个去存储Key-Value的Node
if (e != null) {
// 拿到旧Value
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null) {
// 新的值赋值给节点
e.value = value;
}
afterNodeAccess(e);
// 返回旧value
return oldValue;
}
}
// 统计数据改变次数
++modCount;
// 当最后一次调整之后的Size大于临界值,就需要调整数组容量
if (++size > threshold) {
resize();
}
afterNodeInsertion(evict);
return null;
}
数组扩容 resize
在元素重新计算hash之后,因为n变为2倍,那么n-1的标记范围在高位多1,因此我们的新index就会发生这样的变化。
因此,我们在扩容HashMap的时候,不需要重新计算hash,只需要看看原来的hash值新增的那个bit是1还是0就可以了。是0的话下标不变,是1的话下标变为 原位置+旧容量。可以看下图是16扩容到32的示意图。
正是因为这样巧妙地rehash方式,既省去了重新计算hash的时间,而且同时,因为新增的1bit是0还是1可以认为是随机的,在resize的过程中保证了rehash之后每一个桶上的节点数一定小于等于原来桶上的节点数,保证了rehash之后不会出现更严重的hash冲突,均匀的把之前的冲突的节点分散到新的桶中。
resize代码
/**
/**
/**
More formally, if this map contains a mapping from a key
A return value of {@code null} does not necessarily
/**
使用iterator迭代器迭代
@Test
public void testIterator() {
HashMap<String, Integer> map = getMap();
Iterator<Map.Entry<String, Integer>> iterator = map.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry<String, Integer> entry = iterator.next();
System.out.println(entry.getKey() + “:” + entry.getValue());
}
}
通过get方式
@Test
public void testGet() {
HashMap<String, Integer> map = getMap();
Set keySet = map.keySet();
for (String key : keySet) {
System.out.println(key + “:” + map.get(key));
}
}
说明:根据阿里开发手册,不建议这种方式,因为要迭代多次。keySet一次,get一次。
Jdk8以后使用Map接口中的一个默认方法
@Test
public void testForeach() {
HashMap<String, Integer> map = getMap();
map.forEach((key, value) -> {
System.out.println(key + “:” + value);
});
}
HashMap使用细节
hashmap初始化问题描述
如果我们确切的知道我们有多少键值对需要存储,那么我们在初始化hashmap的时候就应该指定它的容量,以防止hashmap自动扩容,影响使用效率。
默认情况下HashMap容量是16,如果用户通过构造方法指定了一个数字作为容量,那么hashmap会选择大于等于该数字的第一个2的n次幂作为容量。。
《阿里巴巴Java开发手册》中建议我们使用hashmap的初始化容量。
Hashmap中容量的初始化
当我们使用HashMap(int initialCapacity)来初始化容量的时候,jdk会默认给我吗计算一个相对合理的值来当做出事容量。那么,我们是不是直接把元素个数作为initialCapacity就可以了呢?
答案是否定的。因为在我们使用HashMap的过程中,随着元素的数量不断增大,hashmap会不断地进行扩容。并且扩容条件是元素个数=数组长度0.75。比如我们需要存放1000个元素,那么我们设置1000会有两个不合理之处。
(1)1000不是2的n次幂,设置为1000hashmap会给我们计算成1024
(2)1024虽然是2的n次幂,但是10240.75 < 1000,因此当我们使用的过程中,肯定会出现扩容,造成性能上的浪费
因此我们需要设置为2048.
红黑树
二叉查找树
上面这张图就是个二叉查找树。二叉查找树满足下面几条特征。
红黑树查找
因为红黑树是一个自平衡的二叉查找树,查询操作不会破坏红黑树的平衡,所以查找和二叉查找树的查询方式没有区别。
右旋:以某个节点作为支点,其左节点变为旋转节点的父节点,左节点的右节点变为旋转节点的左节点,其余不变。
变色:节点的颜色由红变黑或者由黑变红的过程
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。