/ 今日科技快讯 /
近日,国新办就工业和信息化发展情况举行发布会,工信部部长肖亚庆表示,在APP中看到不喜欢的广告很难找到关闭按钮,对于这种广告骚扰,群众反映很多。如果不想看到广告,关闭按钮应该很明显。遇到网络不良和垃圾信息,可以打12321举报。
/ 作者简介 /
本篇文章来自进击的鱼儿同学的投稿,分享了自己对HashMap源码的相关理解,相信会对大家有所帮助!同时也感谢作者贡献的精彩文章!
进击的鱼儿的博客地址:
https://juejin.cn/user/4406498333823310
/ 正文 /
HashMap(哈希表),是根据key而直接访问内存存储位置的数据结构。也就是说,它通过计算一个关于key的函数,将所需查询的数据映射到表中的一个位置来访问记录,这加快了查找速度。这个映射函数称作散列函数,存放记录的数组称作散列表,也叫哈希表。
HashMap设计
HashMap应该包含以下功能:
put(key, value):向哈希映射中插入(键,值)对。如果键对应的值已存在,更新这个值。
get(key):返回给定键所对应的值,如果映射中不包含这个键,返回null。
remove(key):如果映射中存在这个键,删除这个键值对。
为了实现HashMap有两个关键的问题,即散列函数和冲突处理。
散列函数:若关键字为k,则其值存放在f(k)的位置上。因此,不用比较就可以直接通过key找到value。
冲突处理:如果k1≠k2,而f(k1)=f(k2),即对于不同的key得到了同一个散列地址,这种现象称为冲突。
若对于关键字集合中的任一个关键字,经散列函数映象到地址集合中任何一个地址的概率是相等的,则称此类散列函数为均匀散列函数,这就使关键字经过散列函数得到一个随机的地址,从而减少冲突。
构造散列函数
散列函数能使对一个数据序列的访问过程更加迅速有效,通过散列函数,数据元素将被更快定位。
几种常见的方法:
直接定址法:hash(k)=a∗k+b,其中 ab 为常数
随机数法
平方取中法:取关键字平方后的中间几位为哈希地址
除留余数法:取key被某个不大于散列表表长m的数p取模为散列地址。即hash(k)=k%p,(p≤m)。不仅可以对key直接取模,也可以在随机数法、平方取中法等运算之后取模。对p的选择很重要,一般取素数或m,若p选择不好,容易产生冲突
处理冲突
为了知道冲突产生的相同散列函数地址所对应的关键字,必须选用另外的散列函数,或者对冲突结果进行处理。而不发生冲突的可能性是非常之小的,所以通常对冲突进行处理。常用方法有以下几种:
开放定址法
其中hash(key) 为散列函数,m为散列表长,d为增量序列,i为已发生冲突次数。增量序列可有下列取法:
称为线性探测;即
,其中ab为常数,且a≠0。假设a = 1,相当于在发生冲突时逐个探测存放地址的表,直到找到一个空单元,把散列地址存在该空单元。
称为平方探测。相对线性探测,相当于发生冲突时探测间隔i个单元的位置是否为空,如果为空,将地址存放进去。
当i是伪随机数序列,称为伪随机探测。
下图显示线性探测填装一个散列表的过程。关键字为{80,11,40},此时线性探测方程是
假设取关键字对10取模为散列函数。
当填装40的时候,地址0已经填装了80,取i = 1 ,往地址1查找,发现地址0已经装填了11,取i = 2 ,往地址2查找,发现为空,将40填装在地址为2的空单元。
表的大小选取至关重要,此处选取10作为大小,发生冲突的几率就比选择质数11作为大小的可能性大。越是质数,取余就越可能均匀分布在表的各处。
从图中容易发现在函数地址的表中,散列函数的结果如果不均匀的分布表的单元,形成区块,会造成线性探测和平方探测产生聚集,散列到区块中的关键字需要多次的探测才能找到空单元插入,造成了时间浪费。
单独链接法
对于相同的散列值,将它们用一个链表链接起来,每个链表相互独立。
双散列法
使用两个哈希函数计算散列值,选择碰撞更少的地址。
再散列
hash是一些散列函数。即在上次散列计算发生冲突时,利用该此冲突的散列函数地址产生新的散列函数地址,直到冲突不再发生。这种方法不易产生聚集,但增加了计算时间。
单独链接法实现HashMap
如图,这里使用java中LinkedList实现单链表。
public static class MyHashMap<K, V> {
private static int DEFAULT_BUCKET_SIZE = 10;//默认长度10
private Bucket<K, V>[] buckets;
public MyHashMap() {
buckets = new Bucket[DEFAULT_BUCKET_SIZE];
}
public void put(K key, V value) {
int hashKey = key.hashCode() % buckets.length;
this.buckets[hashKey].put(key, value);
}
public V get(K key) {
int hashKey = key.hashCode() % buckets.length;
return buckets[hashKey].get(key);
}
public void remove(K key) {
int hashKey = key.hashCode() % buckets.length;
buckets[hashKey].remove(key);
}
}
private static class Pair<K, V> {
public K key;
public V value;
public Pair(K key, V value) {
this.key = key;
this.value = value;
}
}
private static class Bucket<K, V> {
private List<Pair<K, V>> bucket;
public Bucket() {
bucket = new LinkedList<>();
}
public V get(K key) {
for (Pair<K, V> pair : bucket) {
if (pair.key.equals(key)) {
return pair.value;
}
}
return null;
}
public void put(K key, V value) {
boolean found = false;
for (Pair<K, V> pair : bucket) {
if (pair.key.equals(key)) {
pair.value = value;
found = true;
}
}
if (!found)
this.bucket.add(new Pair<>(key, value));
}
public void remove(K key) {
for (Pair<K, V> pair : this.bucket) {
if (pair.key.equals(key)) {
this.bucket.remove(pair);
break;
}
}
}
}
/**
* 测试
*/
public void test(){
MyHashMap<Integer,String> hashMap = new MyHashMap<>();
hashMap.put(80,"A");
hashMap.put(11,"B");
hashMap.put(40,"D");
hashMap.put(25,"C");
System.out.println(hashMap.get(40));
}
复杂度分析
时间复杂度:O(N/k)。其中N指的是所有可能值数量,K指的是预定义的散列地址长度。
假设值是平均分布的,因此可以考虑每个链表的平均长度是N/k。
对于每个操作,最坏情况下需要遍历整个链表,因此时间复杂度是O(N/k)。
空间复杂度:O(K+M),其中K指的是预定义的散列地址长度,M指已经插入到HashMap中值的个数。
一个最简单的HashMap完成了,来分析一下有哪些问题:
单链表的查询、插入、删除时间复杂度都是O(N),效率并不高。
散列地址长度固定为10,随着插入的元素增加,链表的长度会原来越长,而我们的时间复杂度是O(N/k),k = 10固定,N一直增大,可想而知算法的效率越来越低。
对于第一个问题,可以换成一个效率更高的数据结构来解决,比如红黑树,可以把时间复杂度从O(N)降低到O(logN)。
第二个问题,既然时间复杂度是O(N/K),那是不是只要把K取值大一些就好了?确实K增大时间复杂度降低,但是空间复杂度为O(K+M),K增大的同时也会增大空间复杂度。
综合考虑下,应该让N和K保持一定的比例关系,不至于N很大,K很小导致时间复杂度太高,同时也不会N很小,K很大浪费空间,让时间复杂度相对稳定。
载荷因子
载荷因子定义定义为:a = 填入表中的元素个数 / 散列表的长度。
可以看出载荷因子实际上就等于上文说的N/K。a是散列表装满程度的标志因子。由于表长是定值,所以a越大,表明填入的元素越多,产生冲突的可能行就越大,反之,a越小,表明填入的元素越少,产生冲突的可能行越小。因此对于空间比较大,注重时间的情景可以选取小一些的a,反之相反。
对于开放定址法,荷载因子是特别重要因素,应严格限制在0.7-0.8以下。超过0.8,查表时的CPU缓存不命中(cache missing)按照指数曲线上升。因此,一些采用开放定址法的hash库,如Java的系统库限制了荷载因子为0.75,超过此值将resize散列表。
Java8 HashMap
Java8 HashMap是JDK中实现的散列表,它做了很多的优化,对于上文提到的问题,来看看它是如何巧妙的解决的。
桶结构
先来看看它的散列表地址中存的是什么数据结构:
/**
* The table, initialized on first use, and resized as
* necessary. When allocated, length is always a power of two.
* (We also tolerate length zero in some operations to allow
* bootstrapping mechanics that are currently not needed.)
*/
transient Node<K,V>[] table;
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
Node(int hash, K key, V value, Node<K,V> next) { ... }
public final K getKey(){ ... }
public final V getValue() { ... }
public final String toString() { ... }
public final int hashCode() { ... }
public final V setValue(V newValue) { ... }
public final boolean equals(Object o) { ... }
}
成员变量中有个Node健值对数组,很明显其散列表中存储的正是Node,从注释可以看到如果需要,这个数组会resized,并且数组的长度总是2的幂!,从上文除留余数法中说的散列表的长度应该取素数得知这里的是一个非常规的设计,而之所以这么设计要是为了在取模和扩容时做优化,同时为了减少冲突,HashMap定位散列表索引位置时,也加入了高位参与运算的过程。
put(key, value)
既然散列标存放的是Node,而Node即可以是链表的节点,也可以是树的节点,那java中使用的什么呢?
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;//初始化
if ((p = tab[i = (n - 1) & hash]) == null) // 1
//对应散列地址为空,直接插入
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;//散列表对应地址第一个key就相同,直接更新
else if (p instanceof TreeNode)
//如果红黑树的节点,那么就插入到红黑树中
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
//找到空位置,插入
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
//如果链表长度>=(TREEIFY_THRESHOLD = 8),就将该链表转换成红黑树
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;//p指向next节点
}
}
if (e != null) { //要插入的节点已存在
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null) //onlyIfAbsent用来控制已存在时是否更新值
e.value = value;//更新值
afterNodeAccess(e);//模板方法,交由子类实现
return oldValue;
}
}
++modCount;
if (++size > threshold)
//存储元素个数大雨threshld,重新调整大小
resize();
afterNodeInsertion(evict);//模板方法
return null;
}
可以看到Node并不固定为一种数据结构,在节点个数大于等于8时,将会从链表转换成红黑树。在节点个数小于8时,链表的平局查找次数小于8/2 = 4,相比而言红黑树的查找次数小于log(8) = 3,因此只有在链表长度大于等于8时才有必要转换成红黑树,虽然链表个数大于等于8时转换成红黑树的平均查找次数依然小于链表,但是差距很小,且转换成树结构的也需要时间,来看看JDK中给出的实验数据:
* Because TreeNodes are about twice the size of regular nodes, we
* use them only when bins contain enough nodes to warrant use
* (see TREEIFY_THRESHOLD). And when they become too small (due to
* removal or resizing) they are converted back to plain bins. In
* usages with well-distributed user hashCodes, tree bins are
* rarely used. Ideally, under random hashCodes, the frequency of
* nodes in bins follows a Poisson distribution
* (http://en.wikipedia.org/wiki/Poisson_distribution) with a
* parameter of about 0.5 on average for the default resizing
* threshold of 0.75, although with a large variance because of
* resizing granularity. Ignoring variance, the expected
* occurrences of list size k are (exp(-0.5) * pow(0.5, k) /
* factorial(k)). The first values are:
*
* 0: 0.60653066
* 1: 0.30326533
* 2: 0.07581633
* 3: 0.01263606
* 4: 0.00157952
* 5: 0.00015795
* 6: 0.00001316
* 7: 0.00000094
* 8: 0.00000006
* more: less than 1 in ten million
发现jdk中还考虑到了树的节点占用空间大约是普通链表节点的2倍,因此只有在链表长度达到一定长度时才使用红黑树来加快查询速度,并且在在长度小于一定值时还会将红黑树转回链表。在hash函数设计合理的情况下,其链表的长度将符合泊松分布(Poisson_distribution),如数据显示,在HashMap中,链表长度为8的概率仅为0.00000006。泊松分布函数图像如下:
横轴是索引k,发生次数。纵轴为x = k是的概率。
由此我们也可以知道HashMap链表长度超过8的概率非常小,也就是说时间复杂度大于O(8)的概率很低,因此可以近似的说HashMap的操作时间复杂度小于O(8),我们知道在算时间复杂度时系数的大小往往忽略,可以说HashMap的各操作时间复杂度为O(1)。
在注释1处,p = tab[i = (n - 1) & hash] 中的(n-1) & hash等同于hash % n,实际上是使用了除留余数法,之所以能等价,正是因为 n = tab.length,并且上文提到,tab.length是2的幂,从二进制的角度来看,2的幂的数减1会等于低于自身最高有效位的掩码,与该掩码相与的结果必定小于自身。
例如8 = 0x1000,8-1 = 0x0111,正数的高位全为0,因此与上2的幂减1的数最大就为其减1。而对计算机而言,取模运算的复杂度要远大于与运算。
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
put的时候会先对key进行一次hash函数运算,这里的hash函数通过hashCode()的高16位异或低16位实现,让高低位都参与hash的运算,增加随机性,使散列更加均匀,有效减少了碰撞,同时运算的效率也很高。
之所以说这种方式能增加随机性,是因为上文提到的tab的index获取方式是通过 (n-1) & hash ,而在n比较小的时候,n的二进制高位全部是0,如下图所示,与运算后的有效为只有低4位,因此hash的高位无法参与计算,会使得散列结果相对集中。
为什么是“^”,而不是“|”、“&”?
先来看看与、或、异或的真值表:
可以看到“&”输出Y为0的概率为75%,“|”输出Y为0的概率为25%,“^”输出Y的概率为50%,上文提到,散列的结果越均匀,越随机,那么冲突就会越少,所以这里选择了“^”。
resize()
在put方法中看到,在存储的数据个数大于threshold的时候将会进行resize;在理解Hash和扩容流程之前,我们得先了解下HashMap的几个字段。
//默认的初始化容量
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
//默认载荷因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//触发扩容的阈值容量 * 载荷因子
int threshold;
//载荷因子
final float loadFactor;
//散列表结构发生变化的次数,主要用于迭代的快速失败
int modCount;
载荷因子上文已经说过,这里就不再赘述。这里注意modCount在散列表结构没有变化的时候不会+1,如put方法中,如果是找到了要修改的值,修改完成后直接return,并没有增加modCount。
@Override
public void forEach(BiConsumer<? super K, ? super V> action) {
Node<K,V>[] tab;
if (action == null)
throw new NullPointerException();
if (size > 0 && (tab = table) != null) {
int mc = modCount;//先缓存当前线程的modCount
for (Node<K,V> e : tab) {
for (; e != null; e = e.next)
action.accept(e.key, e.value);
}
if (modCount != mc) //如果其它线程对HashMap的结构做了改变,那么modCount就会加1,触发ConcurrentModificationException
throw new ConcurrentModificationException();
}
}
可以看到当有在遍历过程中其它线程对HashMap进行了增删将会触发ConcurrentModificationException。
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length; //扩容器的散列表长度
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) {
if (oldCap >= MAXIMUM_CAPACITY) { // 如果容量已经达到MAXIMUM_CAPACITY = 1 << 30,直接将threshold(扩容阈值)设置成Integer.MAX_VALUE = (1 << 31) - 1
threshold = Integer.MAX_VALUE;
return oldTab;
}
//扩容为原来的2倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
else if (oldThr > 0) // 如果使用带参的构造函数创建HashMap并且第一次put会调用到这里
newCap = oldThr; // 0 设置新桶容量
else { // 调用无参数构造函数创建HashMap并且第一次put调用到这里
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
if (oldTab != null) {
//遍历所有桶,分配到新的桶上
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
if (e.next == null) //该桶只有一个节点,直接分配到新的桶
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
//红黑树相关操作
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // 链表长度大于1的
Node<K,V> loHead = null, loTail = null; //原索引的头尾节点
Node<K,V> hiHead = null, hiTail = null; //新索引的头尾节点
Node<K,V> next;
do {
next = e.next;
if ((e.hash & oldCap) == 0) { // 1 原索引
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else { // 2 新索引
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead; //原索引放在原来的所以处
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead; //新索引放在原索引+ oldCap处
}
}
}
}
}
return newTab;
}
代码中注释已经很详细了,主要来看有数字注释的地方。
注释0:newCap等于使用有参构造创建HashMap时初始化的threshold,而上文说过tab的长度必须是2的幂,那要是调用有参构造创建时传入的容量不是2的幂怎么办?看一下源码是怎么做的:
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);
}
static final int tableSizeFor(int cap) {
int n = cap - 1;
n |= n >>> 1; //1
n |= n >>> 2; //2
n |= n >>> 4; //3
n |= n >>> 8; //4
n |= n >>> 16;//5
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
可以看到threshold最后等于tableSizeFor(cap)返回的值,下面是数字8205经过tableSizeFor(cap)计算的过程:
8250是随机取的一个数字,可以看到经过5步之后,从最高有效位开始往后全变成1,tabSizeFor(cap)在最后返回的时候在这个数字上+1,使其最高有效位进1,变成了一个2的幂的数,而n = cap - 1 是为了解决传入的cap本身就是2的幂,如果没有-1,那么最后返回的数会是cap的2倍。
resize中的优化
注释1,2:这个地方设计的非常巧妙,优化了resize的性能。
由于oldCap是2的幂,即用二进制表示时就只有1位是1,因此任何数与oldCap就只有2种结果,要么0,要么是oldCap。
因此新桶index = 旧桶index或者旧桶index + oldCap,并且两种可能性相等。两种可能性相等这一点非常重要,因为它可以使扩容后的散列依然保持均匀分散,就不需要再次hash来求新的位置,极大的提高了效率。
还有一点,如果不采用这种方式来找resize之后的位置,那么就会有这种情况,找到的新的桶中已经有链表了,这时有两种选择,要么插入尾部,这需要遍历,效率低;要么采用头插法,插入链表头部,但是这种方式会把节点原来的顺序反过来,是一种不稳定的算法。
红黑树转回链表
上文提到过在链表长度小于一定值时还会将红黑树转回链表,来看看这个值是多少,源码就是resize()中的红黑树节点处理部分调用的TreeNode#split方法:
static final int UNTREEIFY_THRESHOLD = 6;
final void split(HashMap<K,V> map, Node<K,V>[] tab, int index, int bit) {
TreeNode<K,V> b = this;
// Relink into lo and hi lists, preserving order
TreeNode<K,V> loHead = null, loTail = null;
TreeNode<K,V> hiHead = null, hiTail = null;
int lc = 0, hc = 0;
//...省略红黑树部分数据处理
if (loHead != null) {
if (lc <= UNTREEIFY_THRESHOLD)
//当节点数小于UNTREEIFY_THRESHOLD时将红黑树转换成链表
tab[index] = loHead.untreeify(map);
else {
tab[index] = loHead;
if (hiHead != null) // (else is already treeified)
loHead.treeify(tab);
}
}
if (hiHead != null) {
if (hc <= UNTREEIFY_THRESHOLD)
tab[index + bit] = hiHead.untreeify(map);
else {
tab[index + bit] = hiHead;
if (loHead != null)
hiHead.treeify(tab);
}
}
}
//TreeNode中当untreeify方法,将红黑树转换成链表
final Node<K,V> untreeify(HashMap<K,V> map) {
Node<K,V> hd = null, tl = null;
//这里当this为TreeNode,可以看到这里到转换也十分巧妙,直接向上转型成Node,丢掉TreeNode特有到属性
for (Node<K,V> q = this; q != null; q = q.next) {
Node<K,V> p = map.replacementNode(q, null);
if (tl == null)
hd = p;
else
tl.next = p;
tl = p;
}
return hd;
}
从split方法中看到当节点个数小于6时将红黑树转换成链表。看到这里有点奇怪,链表转成红黑树的阈值是8,而红黑树转成链表的阈值怎么是6呢?
乍一看感觉有些奇怪,其实也很好理解。首先从效率方面来看,6和8实际上不会有什么差别。想象一下如果两个转换都是8会是什么情况?如果有一个HashMap反复的插入、删除元素,链表长度在8左右徘徊,就会频繁的发生树和链表的互相转换,效率很低。
字段table为什么是transient
看一下table字段的定义,发现是transient的,也就是说在序列化是并不会直接序列化table字段。
/**
* The table, initialized on first use, and resized as
* necessary. When allocated, length is always a power of two.
* (We also tolerate length zero in some operations to allow
* bootstrapping mechanics that are currently not needed.)
*/
transient Node<K,V>[] table;
至于原因我们来看看序列化和反序列化时做了什么。
writeObject
writeObject,序列化时执行的方法:
private void writeObject(java.io.ObjectOutputStream s)
throws IOException {
int buckets = capacity();
// Write out the threshold, loadfactor, and any hidden stuff
s.defaultWriteObject(); // ObjectOutputStream默认的序列化方法
s.writeInt(buckets); // 1
s.writeInt(size); // 2
internalWriteEntries(s);
}
这里主要注意注释1和注释2,将容量大小和实际存储的数据个数序列化。
readObject
readObject反序列化时执行的方法:
private void readObject(java.io.ObjectInputStream s)
throws IOException, ClassNotFoundException {
// Read in the threshold (ignored), loadfactor, and any hidden stuff
s.defaultReadObject(); //ObjectInputStream默认的反序列化
reinitialize(); //初始化
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new InvalidObjectException("Illegal load factor: " +
loadFactor);
s.readInt(); // 在writeObject方法中写入的容量大小被忽略来
int mappings = s.readInt(); // witeObject方法中写入的实际数据个数size
if (mappings < 0)
throw new InvalidObjectException("Illegal mappings count: " +
mappings);
else if (mappings > 0) { // (if zero, use defaults)
// 根据实际数据的个数来生成合理的容量,扩容阈值
float lf = Math.min(Math.max(0.25f, loadFactor), 4.0f);
float fc = (float)mappings / lf + 1.0f;
int cap = ((fc < DEFAULT_INITIAL_CAPACITY) ?
DEFAULT_INITIAL_CAPACITY :
(fc >= MAXIMUM_CAPACITY) ?
MAXIMUM_CAPACITY :
tableSizeFor((int)fc));
float ft = (float)cap * lf;
threshold = ((cap < MAXIMUM_CAPACITY && ft < MAXIMUM_CAPACITY) ?
(int)ft : Integer.MAX_VALUE);
SharedSecrets.getJavaObjectInputStreamAccess().checkArray(s, Map.Entry[].class, cap);
Node<K,V>[] tab = (Node<K,V>[])new Node[cap];
table = tab;
// 循环反序列化出key和value,并加入到HashMap中
for (int i = 0; i < mappings; i++) {
K key = (K) s.readObject();
V value = (V) s.readObject();
putVal(hash(key), key, value, false, false);
}
}
}
看到这里已经知道为什么没有把table整个序列化了,由于扩容机制,而且HashMap中并没有自动缩容,导致table中有些空间是浪费的,并没有存储数据,直接序列化会造成浪费。因此序列化是只序列了实际的Node节点和数据个数,反序列化时在进行重新的初始化并装填。
小结
HashMap的所有操作并没有进行加锁和缓存线程数据,因此它不是线程安全的。
扩容是一个O(n)的操作,和其它操作相比特别耗性能,尤其在数据量很大的时候,因此我们在使用HashMap的时候应该先预估好要缓存的数据量,初始化时就直接给一个大致的数值,减少HashMap的扩容操作。
载荷因子尽量不要修改,如果十分确定使用场景可以根据是时间优先还是空间优先选择大一点或小一点的载荷因子。
HashMap没有自动缩容,因此有时选择多个HashMap来替代一个HashMap反而是能够节省空间的。
2的幂是一个非常有用的数字,不仅HashMap中使用到,在开发中也有很多地方可以用它的特性来进行一些意向不到的优化。
推荐阅读:
欢迎关注我的公众号
学习技术或投稿
长按上图,识别图中二维码即可关注