HashMap&HashTable
HashCode
HashCode
在讲HashMap和HashTable之前,我们得先说一下HashCode。
HashCode本质上是一个均匀分布的随机数,在对象初始化时调用HashCode()方法计算得到并放入对象头中。这个方法底层是由native修饰的(在jvm中使用c++实现),所以我们并不能直接看到其源码。
但是我们知道HashCode的生成方法一共有六种:
0 - 使用Park-Miller伪随机数生成器(跟地址无关)
1 - 使用地址与一个随机数做异或(地址是输入因素的一部分)
2 - 总是返回常量1作为所有对象的identity hash code(跟地址无关)
3 - 使用全局的递增序列(跟地址无关)
4 - 使用对象地址的“当前”地址来作为它的identity hash code(就是当前地址)
5 - 使用线程局部状态来实现Marsaglia's 异或-位移随机数生成(跟地址无关) (ps:位运算的运算速度和占用空间小于常规运算符,倒不如说常规运算符都是通过位运算实现的)
有兴趣查看源码的可以自行研究:https://blog.csdn.net/u014520797/article/details/106322258/
JDK8之前默认采用方法0,JDK8之后默认采用方法5
也可以在JVM启动时配置xx:hashCode 更改
不合理的HashCode算法生成出来的HashCode值用于HashMap的存储中会让HashMap的性能极具下降,所以一般不建议个人重写hashCode()方法;
Java中重写了HashCode()方法的类
常见的有String,基本数据包装类,Date等。需要值得注意的是StringBuffer和StringBulider并没重写hashCode()方法。
HashMap
继承方面
HashMap继承了抽象类AbstractMap(AbstractMap实现了Map接口),实现了Map接口,Cloneable接口(可以调用Object类中的clone方法),Serializable接口(可序列化,创建一个序列化id用于在字节流传输时区分不同的类,以便于反序列化)
存储方式
HashMap内部存储以Node<K,V>形式存储在一个数组中(Node<K,V>[]) 其中Node是Map接口中Entry<K,V>的实现
Node<K,V>是HashMap的内部类,里面有四个属性
final int hash;
final K key;
V value;
Node<K,V> next;
同时,HashMap中也存在相当多的集合视图,例如entrySet;
transient Set<K> keySet;
transient Collection<V> values;
//从AbstractMap中继承 transient
Set<Map.Entry<K,V>> entrySet;
//HashMap的视图
以entrySet为例:
final class EntrySet extends AbstractSet<Map.Entry<K,V>> {
public final int size() { return size; }
public final void clear() { HashMap.this.clear(); }
public final Iterator<Map.Entry<K,V>> iterator() {
return new EntryIterator();
}
public final boolean contains(Object o) {
if (!(o instanceof Map.Entry))
return false;
Map.Entry<?,?> e = (Map.Entry<?,?>) o;
Object key = e.getKey();
Node<K,V> candidate = getNode(hash(key), key);
return candidate != null && candidate.equals(e);
}
public final boolean remove(Object o) {
if (o instanceof Map.Entry) {
Map.Entry<?,?> e = (Map.Entry<?,?>) o;
Object key = e.getKey();
Object value = e.getValue();
return removeNode(hash(key), key, value, true, true) != null;
}
return false;
}
public final Spliterator<Map.Entry<K,V>> spliterator() {
return new EntrySpliterator<>(HashMap.this, 0, -1, 0, 0);
}
public final void forEach(Consumer<? super Map.Entry<K,V>> action) {
Node<K,V>[] tab;
if (action == null)
throw new NullPointerException();
if (size > 0 && (tab = table) != null) {
int mc = modCount;
for (int i = 0; i < tab.length; ++i) {
for (Node<K,V> e = tab[i]; e != null; e = e.next)
action.accept(e);
}
if (modCount != mc)
throw new ConcurrentModificationException();
}
}
}
entrySet是定义在HashMap中的一个被final修饰的内部类,在外部无法直接获取并且不能修改。可以看到的是EntrySet重写了一系列方法,全部都是通过调用HashMap本身的方法实现,可以说确实是HashMap的一个视图——提供HashMap内部数据存储的另外一种查看方式。keySet和values也大同小异,这里不再过多阐述。
成员变量介绍
private static final long serialVersionUID = 362498820763181265L;
//序列化ID 唯一
transient Node<K,V>[] table;
//哈希桶
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
//初始容量,默认为16
static final int MAXIMUM_CAPACITY = 1 << 30;
//最大容量 默认为2^30 (其实你也改不了
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//加载因子,不给定的情况下默认为0.75
static final int TREEIFY_THRESHOLD = 8;
//链表转红黑树的阈值(注意:这不是唯一决定链表转红黑树的因素)
static final int UNTREEIFY_THRESHOLD = 6;
//红黑树中节点个数阈值,低于该值时,扩容时会转回链表结构(注意:HashMap没有缩容只有扩容
static final int MIN_TREEIFY_CAPACITY = 64;
//另一个决定链表转红黑树的因素,当数组长度不满64时只会扩容,不会转成红黑树
//为了避免和另一个阈值冲突,该 值至少得是TREEIFY_THRESHOLD的4倍(虽然你也改不了
transient int modCount;
//HashMap读写操作次数
transient int size;
//不解释了
int threshold; //阈(yù)值 也就是不需要扩容的最大上限
final float loadFactor;//加载因子 用于计算阈值
//其实还有数组容量,但是容量只能用方法反射获得
DEFAULT_INITIAL_CAPACITY
DEFAULT_INITIAL_CAPACITY取16的原因主要有以下几点
1.容量取2的幂可以获得最佳的HashMap性能 性能最优化接下来会讲,先搁置一下
2.取16是因为8很容易就进行扩容,而32则占用太大内存空间
MAXIMUM_CAPACITY
1左移30次 得到2^30 一共占用31位 而int类型一共只有32位,第一位是符号位,占满了 所以只能是2^30
DEFAULT_LOAD_FACTOR
HashMap的底层代码中有写,翻译成中文大概就是:
理想状态下,在随机哈希值的情况,对于loadfactor = 0.75 ,虽然由于粒度调整会产生较大的方差,桶中的Node
的分布频率服从参数为0.5的泊松分布。
泊松分布:
其中n为数组长度,t=1,λ=0.5。不过好像有个老哥证明了最佳的加载因子应该在ln2的位置,有兴趣的同学可以去
看看(https://stackoverflow.com/questions/10901752/what-is-the-significance-of-load-factor-in-hashmap)
HashMap初始化过程
HashMap的构造方法在执行的时候会初始化一个数组table,大小为0。
HashMap的put方法在执行的时候会先判断table的大小是否为0,如果为0则会进行真初始化,也叫延迟初始化。
当真初始化时,数组的默认大小为16,当然你也可以调用HashMap的有参构造,来指定一个初始化容量,但是你指定的容量未必就是table数组真正的大小。也就是你想初始化一个大小为n的数组,但是HASHMAP会初始化一个大小大于等于n的二次方数的一个数组。
对于put方法,当无需对table进行初始化,或者初始化已经完成了过后,它接下来的主要任务就是把key和value出入数组或者链表中。
我们通过对key进行hash运算得到数组下标。
但是这里我们有一个问题就是,HashCode可以直接作为数组下标吗?HashCode一般是一个比较大的值,而我们的数组大小一般才16。我们通过对数组长度-1和HashCode(事实上是先让HashCode的高位和低位进行或非运算,这样提升了HashCode中高位字节的参与感,不致于只有低位字节直接决定了数组下标。不过我个人觉得没啥用,可有可无)进行逻辑与运算得到数组下标。
这也是为什么为什么数组的长度只能是二次方数,因为二次方数减一可以获得一个全为低位全为1的二进制数,这样使得HashMap的性能提升的最高;
所以到此我们可以理一下:在调用PUT方法时,会对传入的key进行哈希运算得到一个hashcode,然后再通过逻辑与操作得到一个数组下标,最后将keyvalue存在这个数组下标处。
Hash冲突
确定了keyvalue该存的位置之后,上文说过,对于不同的参数可能会得到相同的HashCode,也就是会发生哈希冲突,反应到HashMap中就是,当PUT两个不同的key时可能会得到相同的HashCode从而得到相同的数组下标,其实在HashMap中就算key所对应的HashCode不一样,那么也有可能在经过逻辑与操作之后得到相同的数组下标,那么这时HashMap是如何处理的呢?
链表和红黑树
我们现在知道,HashMap中将数据存储在一个Node 节点当中。当执行put操作后,我们得到了数组下标,当这个位置没有节点时,当前节点就占据这个下标,当这个下标有节点时,我们就把该节点放入这个链表的尾部(1.7中是放入头部,但是放入头部会陷入扩容死循环的问题。)
小节
好,写到这里其实对于HashMap的PUT的主要逻辑也差不多了,总结一下:
- PUT(key,value);
- 首先判断当前HashMap的数组长度是否为0,若为0则调用resize()方法;
- 根据当前key获取数组下标,判断当前下标上是否有节点,若无则把当前节点放入该数组下标中;
- 若有节点,先判断当前节点中的key是否和输入的key相同,如若相同则覆盖value值;
- 若不相同,先判断一下当前下标上的节点是否为TreeNode类型,若是则使用树类型的添加方式;
- 不是Treenode就遍历当前链表,有key相同则替换值,没有相同的就添加到尾部,超过8个转换为红黑树;
- 若put操作后hashMap内的元素有增加,操作数加一,size加一后再判断是否需要扩容。
扩容
简单来说阈值和node数组长度都扩容为之前的两倍。数组长度变为两倍,h将原来数组中的元素遍历,重新放入新的数组中。这个过程极具消耗资源。
HashMap的迭代
从上文中可以看到HashMap中有3个视图,内部里都有迭代器的实现(当然也有foreach的实现),所以可以很自然的想到HashMap也存在着遍历的方式(用的最多的应该都是对keySet进行遍历)。
HashMap中一共有6个迭代器,分为2类,一类是普通的迭代器,一类是在jdk8以后新加入的可拆分迭代器Spliterator,可拆分迭代器的主要目的是为了高效并行而设计,但是其本身不是线程安全的,具体内容较多,有兴趣的同学可以自行研究。
普通的迭代器Iterator在HashMap中一共有4个相关类,分别是抽象类HashIterator(模板模式),以及3个实现
类:EntryIterator,KeyIterator,ValueIterator,它们在相对应的遍历中会被调用。其中EntryIterator效率最高。
Map map = new HashMap();
Iterator iter = map.entrySet().iterator();
while (iter.hasNext()) {
Map.Entry entry = (Map.Entry) iter.next();
Object key = entry.getKey();
Object val = entry.getValue();
}
HashMap线程不安全
HashMap的线程不安全主要出现在扩容中,但是线程不安全在jdk7和jdk8中分别有着不同的体现:
1.7
主要体现在数据丢失,数据覆盖和死循环上,根本原因是因为扩容时采用了头插法的缘故
//扩容方法:transfer() void
transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> next = e.next;
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获得时间片执行完整个扩容。此时该HashMap中的数组和数组中的链表都是新的且顺序发生了变化(这主要是因为采用了头插法的缘故)。此时线程1再执行,会先把3放入坐标位置链表,然后此时的next是7,7的next是3,经过两次循环后就形成链表循环。并且节点5不见了(和头插法无关,故1.8也会出现这个问题)。这时再去使用get方法查询一个不存在的key例如11就会出现死循环的问题。
1.8
1.8采用是尾插法,尾插法不会出现链表循环的问题,但是一样会出现再扩容时节点丢失、当两个线程,一个读,一个写时,也有可能出现value值丢失的问题。++size 也会有线程安全的问题。
解决方案
1.使用HashTable。
2.使用Collections.synchronizedMap()将HashMap包装起来。
3.ConcurrentHashMap替换HashMap
4.在涉及到对HashMap写操作时加锁
HashTable
继承方面
Hashtable继承了抽象类Dictionary类,实现了Map,Cloneable,Serializable接口
储存方式
同HashTable
成员变量介绍
private transient Entry<?,?>[] table;
//哈希桶
private transient int count;
//哈希桶内元素个数(也就是size
private static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;
//Hashtable最大容量
private int threshold;
//阈值
private float loadFactor;
//加载因子
private transient int modCount = 0;
//操作数 写入操作才会更改
private static final long serialVersionUID = 1421746759512286392L;
//序列化ID
private static final int KEYS = 0;
private static final int VALUES = 1;
private static final int ENTRIES = 2;
//迭代器选择常量
值得注意的是,HashTable的初始化默认容量是11,加载因子是0.75
MAX_ARRAY_SIZE
MAX_ARRAY_SIZE之所以比Integer.MAX_VALUE小了8,是因为有一些vm会在数组头带有一些信息,请求过大的数
组可能会造成OOM异常(内存溢出)
存储原理以及扩容原理
和HashMap相比较,HashTable的计算下标的算法有不同,直接将key的hashCode拿过来(相较于HashMap没有做扰动),和Integer.MAX_VALUE做了一次与运算(HashMap是和数组长度-1做一次于运算,这里这样做主要是为了去除负号),再对数组长度取余(在这和HashMap相比较,效率就以及低了很多,但是容量就可以不用是2的幂次方数了)。
扩容原理
因为hash算法的不同,HashTable的扩容是将原来的容量扩大两倍再加1.主要保证容量尽可能是质数(若不是质数,hash碰撞的概率会高很多,对HashTable的性能影响很大)
HashTable的迭代
Hashtable最早采用的是Enumeration枚举接口进行迭代,但是在jdk1.2之后出现了Iterator接口后,Hashtable的
迭代器也实现了Iterator接口,现在两种模式都能使用。目前Enumeration接口基本已经被废弃,只有极少数数据
结构在使用。迭代方式和HashMap基本相同。
HashTable的线程安全性
方法上有synchronized的关键字
基本上大多数人都会说Hashtable是线程安全的,实则不然,Hashtable在1.2版本以后因为迭代器实现了Iterator接口,导致了在使用Iterator迭代器时会有线程不安全的情况发生。但是使用Enumeration进行迭代时是线程安全的。线程不安全时会抛出ConcurrentModificationException。
ConcurrentModificationException主要是因为边读边写的操作(写的操作比较宽泛,对表的数据或者结构产生变化的都算)(边读边写主要出现在HashMap中)以及在使用迭代器时不使用迭代器提供的方法对表的结构进行改变导致(主要是在Hashtable中)
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
异常的抛出基本都是由这句判断带来,HashMap和Hashtable在读写时会记录先前的操作数,在执行时会进行比
较,如果不相等则代表有别的线程进行了操作,抛出并发修改异常。
为了回避这类异常,可以考虑如下策略:
1.使用Hashtable的Enumeration迭代器 (不推荐 效率太低
2.使用Collections.synchronizedMap (不推荐
3.在业务的实现上加逻辑锁
4.使用java.util.concurrent下的ConcurrentHashMap类
HashMap和Hashtable的其他区别
HashTable中不允许有null键和null值,HashMap中允许出现一个null键(因为null的hash值在HashMap里指定为0),可以存在一个或者多个键的值都为null。程序中,对于HashMap,如果使用get(参数为 键)方法时,返回结果为null,可能是该键不存在,也可能是该键对应的值为null,这就出现了结果的二义性。因此,在HashMap中,我们不能使用get()方法来查询键 对应的值,应该使用containskey()方法。