前言
HashMap,键值对的一种数据结构,给定唯一的key,获取value。之前2篇文章说过ArrayList在随机访问的时候效率特别高。LinkedList做随机插入删除时候效率高。而jdk中的HashMap集成了这2种优点。
话说现在jdk11出来了。但我们工作中大多用的是jdk7。本人写的这一篇HashMap是在看了jdk7与jdk8后写的。2者的思想都有参考。(参考了jdk思想。并不是对jdk源码的讲解,希望不要对你造成误会。)
题外话:另外,希望还在用jdk8以下的,新开的项目尽量使用jdk8吧,对java的优化是一个质的飞跃。众所周知,jdk5开始是一个质的飞跃(引入了泛型,foreach,自动装箱/拆箱,变长参数等等),但jdk8对锁与集合性能有大幅提升。并且加入stream的处理集合。还有lamdba表达式的加入,使你写函数接口(如Runable)再也不用写那么冗长的代码了。
本文所写的HashMap也只是实现的一种简易版键值对集合帮助大家理解。在命名方面有部分参考过jdk的命名规则!
关注点
不只是HashMap,在任何集合中,都应关注以下几点:
是否可以为空:key允许有一个null,value你随意
是否有序(插入时候的顺序和读取时是否一致): hash码具有随机性,所以无序
是否允许重复: key肯定不行,value你随意
是否线程安全: 线程不安全(jdk8以下会出现链表成环)
特性: 时间复杂度为常数级
原理
内部一个Entry(内部定义的类)数组,将新添加的元素计算hash值后与数组长度取余。然后放到数组对应的下标位置。如果计算出来的hash一样,此时对应的数组下标已有元素,怎么办??如LinkedList(此处为单向链表),以链表的方式追加到链表头部。如果你的数据就是这么巧,所有数据的下标都在同一个位置,那你就一直链下去吧。此时get(key)的时间复杂度则与链表相当,但这种情况不多(后文会详细分析hash算法)。但jdk8已对此处优化,如果链表的长度>=8,则转化为红黑树(红黑树的特性自行查找资料吧)。二叉查找树的平均时间复杂度是O(LogN)效率远高于链表的O(N)。(个人认为hash随机性很强,分布均匀,且数组扩容,所以链表长度>8的情况应该不多,而转化红黑树的代价也是有的,空间上的占用与链表转红黑树的性能开销)
实现
构造器:首先,定义我们的HashMap,内部存储我们定义Entry类。如上原理:我们默认数组的长度是16,有个LOAD_FACTOR负载因子,是扩容时候用的,默认为0.75,即当元素>16*0.75=12时,发生扩容。我们将这个12定义一个变量threshold阈值,每次扩容后修改这个值。如下实现:
/**
* @author HK
* jdk7中,喜欢命名为Entry,而jdk8中,喜欢命名为Node
*/
public class HkHashMap {
/**
* table的默认初始长度
*/
static final int DEFAULT_INITIAL_CAPACITY = 16;
/**
* 负载因子,当达到16*0.75时触发扩容
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;
Entry[] table;
int size;
/**
* 阈值,当达到此值时扩容。threshold=DEFAULT_INITIAL_CAPACITY*DEFAULT_LOAD_FACTOR
*/
int threshold;
/**
* 无参构造器,当然了,指定初始长度和负载因子更好一些。
*/
public HkHashMap() {
threshold = (int)(DEFAULT_INITIAL_CAPACITY * DEFAULT_LOAD_FACTOR);
table = new Entry[DEFAULT_INITIAL_CAPACITY];
}
//map的存储单元
static class Entry{
int hash;
Object key;
Object value;
Entry next;
public Entry(int hash, Object key, Object value, Entry next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
}
}
重点看一下这个内部类Entry,通过key,计算出hash值,来定位存储位置,因为有hash碰撞,所以用单链表的方式实现,定义next节点。如果table数组上某下标只有一个元素,那么该元素的next则为null。
增put(key)
其实,修改一个key的值也是用的这个api。既HashMap中只有增删查,如果key相同,则覆盖旧的value。接下来,着重看下这个put(key)方法
首先拿到key,获取key的hashCode码(jdk的实现会重写hashCode与equals),hashCode是Object类的一个native方法(相同对象不同虚拟机的值可能会不同),返回一个int类型的值。这个int类型的值不确定性很强,长度也不固定。如下
public static void main(String[] args) {
String str = "66";
Integer i = 66;
System.out.println("String的HashCode:"+str.hashCode());//1728
System.out.println("Integer的HashCode:"+i.hashCode());//66
}
所以,我们需要一个hash算法,来算出一个具有很强的随机性和长度相对固定的数字。此处,我们参考jdk8中,将hashCode高16与本身做异或运算。如下:可以看到,如果key是null,固定放到table[0]的位置
/**
* HashCode做一次hash运算。以此更加保证散列性
*/
static final int hash(Object key) {
int h;
//将hashCode右移16位与hashCode做“异或”运算,即高16位^hashCode(参考jdk8)。如果key为null,固定放到table[0]的位置
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
ok,有了hash值后,如何确定在数组上的位置呢?而又得保证在数组上的位置比较均匀的分布。我们将这个hash值对数组的长度取余,余数 = hash%table.length。然后将这个key对于的Entry放到table[余数]的位置。
hashMap中扩容有一个规定,table会扩容为原来的2倍,且建议table.length为2的N次幂。为什么这样呢?我们来看以下,2的N次幂数,比如8,16,32,他们的2进制分别是1000,10000,100000,将它们减1则变为111,1111,11111。
用下图来做一个讲解:随便添加一个key,“我是key”的hash值算出来结果假如是 int hash = “555”
我们进行与运算后,结果就是hash值为555这个数的二进制后4位。而一个4位二进制数永远也不可能大于15。同理,当table的长度变为32位时,31的二进制就是11111。此时与运算后结果肯定是一个小于等于31的数。
因此,如果hashMap长度不是2的N次幂,那么做与运算将又糟糕的结果。假如上图红色位置有一个是0,那么&操作后,将有一位永远是0,此时,table数组上有位置永远不可能存放元素,**或者说,如果红色位置0特别多的话,hashMap将只有一俩个table位置存放了数据,性能大打折扣!!!**之前没注意源码中有这个的优化之处,假如人工将初始长度改为不是2的N次幂,则会自动调整为2的N次幂(这句红色是修改的文章,之前误人子弟不好意思)。
下面代码我们定义出通过hash找到再table数组位置的方法,即类似求余数的方法:
/**
* 任何一个key,都需要找到hash后对应的哈希桶位置
*/
static int findBucketIndex(int h, int length) {
//求余数的算法的结果是一样的。
return h & (length-1);
}
此时,我们再来写put方法
/**
* 添加元素,如果key已存在,则替换
*/
public Object put(Object key,Object value){
if(null==key){
return putNullKey(value);
}
int h=hash(key);
int i = findBucketIndex(h,table.length);
//如果已经有这个key,则替换
for (Entry e = table[i]; e != null; e = e.next) {
Object k;
if (e.hash == h && ((k = e.key) == key || key.equals(k))) {
Object oldValue = e.value;
e.value = value;
return oldValue;
}
}
addEntry(h, key, value, i);
return null;
}
void addEntry(int hash, Object key, Object value, int bucketIndex){
Entry e = table[bucketIndex];
table[bucketIndex] =new Entry(hash,key,value,e);
size++;
if(size>threshold){
resize(table.length*2);
}
}
如上代码,其中,第5~7行,是对key位null的Entry做处理,我们将这个Entry固定放到table[0]的位置上,
第8行是通过key计算hash值,然后通过hash值与table的长度,计算这个key在hash桶中的位置。
11~18行是HashMap的修改操作,即如果这个key已存在,则进行替换操作。(定位到hash桶后,遍历这个位置的单向链表)。
jdk中的hashMap即put成功返回null,如果key是替换操作,则返回旧的值。
看一下23~30行的addEntry方法。
24行:将原hash桶的位置的Entry赋予e元素。显然如果原先是空位置,则这个e是null。
25行:新添加的这个元素放到table[i]的位置上,原先的元素链接到现在位置的下一个元素。
27~29为扩容相关。可见,当元素数量大于阈值时,扩容为原先的2倍。扩容详细分析,写最后边👇(扩容在不同的jdk中实现也是大不相同)
/**
* hashMap允许键为空,对这个空键单独处理
*/
private Object putNullKey(Object value){
//如果已经有这个Null的Key,则替换
for (Entry e = table[0]; e != null; e = e.next) {
if (e.key == null) {
Object oldValue = e.value;
e.value = value;
return oldValue;
}
}
addEntry(0, null, value, 0);
return null;
}
这个是对key为null的情况单独处理,不解释!
查找get(key)
有了对put方法的描述,get就相对简单许多了。核心思想就是:根据key,先找到hash桶的位置,然后遍历hash桶位置上的链表,如果找不到,返回null。不做过多解释。看如下代码即可!
/**
* 获取元素
*/
public Object get(Object key){
int h = hash(key);
for(Entry e = table[findBucketIndex(h,table.length)];e!=null;e=e.next){
if(e.hash==h&&(key==e.key||key.equals(e.key))){
return e.value;
}
}
return null;
}
删除remove(key)
根据key删除一个元素,核心思想是定位这个key再table数组的位置,定位到后,遍历这个位置的链表,找到Entry e 后,切断e与前后链表的关系,将前后节点连接起来,与LinkedListd的核心思想一样
细节问题,代码注释已说明,不再阐述。如下:
public Object remove(Object key){
return removeEntryForKey(key);
}
private Entry removeEntryForKey (Object key){
int hash = hash(key.hashCode());
int i = findBucketIndex(hash,table.length);
//先定义一个prev表示待删除的元素的上一个
Entry prev = table[i];
Entry e=prev;
while(e!=null){
//定位出e的下一个元素,当找到我们要删除的元素时,将链表切断,将prev和next连接即可。参考linkedList的删除即可
Entry next = e.next;
//在链表上定位到这个元素,先判断hash值是否相等,再判断key的equals是否相等!注意,这里如果==返回true则说明是一个引用,这样可省略equals的判断。
if(e.hash==hash&& (e.key==key||(key!=null&&key.equals(e.key))) ){
size--;//此时已有元素被定位到,注意while外面是判断了hash值相等的情况。hash值相等时候equals不一定相等。
//这里,还得判断一个东西,即定位到的这个Entry是否是table[i],因为table[i]上的删除和链表上的删除有区别
if(prev==e){
table[i]=next;//尽管则个next可能为空
}else{
prev.next=next;//如果不是table位置的,则是链表后边的元素,此时,将prev的下一个置为e的next即可,此时,e已被切断,e的前后Entry被连接起来
}
return e;
}
//上边的if没进去,则执行下一次循环,指针向后移动一位!
prev=e;
e=next;
}
//直到while结束,如果没找到元素,return null
return null;
}
扩容
ok,接下来,我们说一下扩容,扩容再jdk8中与之前版本有较大区别,jdk8解决了链表成环的问题,且jdk8扩容中还有红黑树,所以肯定会有较大区别。我们主要讲以下jdk7中的扩容(头插入,即扩容后会将原链表倒置)。
/**
* 扩容,传入新容量
*/
void resize(int newCapacity){
Entry[] newTable = new Entry[newCapacity];
//将所有旧元素换到新table中start
Entry[] oldTable = table;
//遍历所有的位置
for (int j = 0; j < oldTable.length; j++){
Entry e = oldTable[j];
if(null!=e){
oldTable[j]=null;//循环结束后,会将oldTable引用全部置空,GC回收
do{
Entry next = e.next;//先记住e的链表下一个是谁
int i = findBucketIndex(e.hash,newTable.length);//找到新的哈希桶位置
e.next=newTable[i];//注意这里是循环,newTable[i]上是可能有元素的。将现在newTable[i]上的元素置为e的下一个,
newTable[i]=e;//赋值,所以,jdk7中的扩容是链表头插入。
e=next;//下一次循环使用
}while(null!=e);
}
}
table=newTable;
//将所有旧元素换到新table中end
threshold=(int)(newCapacity*DEFAULT_LOAD_FACTOR);
}
定义一个新的数组,newTable,扩容传入的参数一般是原数组的2倍长度
此时,我们需要从旧数组中挨个位置来遍历,从table[j]开始入手,对table[j]上有hash碰撞的Entry再进行单独计算hash桶,重新放到新数组的新位置。(因为原先长度在一个Hash桶的那些元素,现在不一定在一个hash桶中)
最后,将table引用到newTable
阈值重新计算,方便下一次扩容。
总结:
如果你对数据量有一个预估值,建议在创建Map的时候指定长度,以减少hashMap的扩容!扩容是一个很消耗性能的操作。最好自己手工指定为2的N次幂。
HashMap线程不安全,例如链表成环问题,只有在多线程情况下出现。但这并不是jdk设计的缺陷。所以如果你的集合要在多线程下访问,请禁止使用HashMap。建议使用Juc包下的HashMap。有关ConcurrentHashMap的详细讲解,会在后续多线程并发模块给大家详细讲解。个人认为hashTable应该淘汰了!
疑问
hashMap中modCount变量是干什么的?答:java中所有数据结果都有此变量,主要用在fail—fast快速失败。即遍历时并发修改集合的时候,通过此变量可判断在遍历时是否进行修改。
jdk源码中table数组变量为什么是transient修饰的?我们知道,此修饰符指序列化时忽略此字段。HashMap为什么要进行此设置呢?答:因为hashMap严重依赖hashCode,然而不同虚拟机的hashCode并不相同!意思就是A机器上序列化后的集合,到B机器上反序列化后,HashMap极有可能已不能正常使用。因为hash算法已无法定位到table上的位置!
对于上一个疑问,hashMap的解决方式就是重写了自己序列号的方式writeObject方法来进行自己的序列号方式。仅将key和value写到了一个文件中,并不参与hash的存储!还请大家自行详细研究吧!
完整代码
package my.hashmap.mymap.Utils;
import lombok.Data;
@Data
public class HkHashMap <K,V>{
/**
* table的默认初始长度
*/
static final int DEFAULT_INITIAL_CAPACITY = 16;
/**
* 负载因子,当达到16*0.75时触发扩容
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;
private Entry<K,V>[] table;
int size;
// K key;
V value;
/**
* 阈值,当达到此值时扩容。threshold=DEFAULT_INITIAL_CAPACITY*DEFAULT_LOAD_FACTOR
*/
int threshold;
/**
* 无参构造器,当然了,指定初始长度和负载因子更好一些。
*/
public HkHashMap() {
threshold = (int)(DEFAULT_INITIAL_CAPACITY * DEFAULT_LOAD_FACTOR);
table = new Entry[DEFAULT_INITIAL_CAPACITY];
}
/**
* 获取元素
*/
public V get(K key){
int h = hash(key);
for(Entry<K,V> e = table[findBucketIndex(h,table.length)];e!=null;e=e.next){
if(e.hash==h&&(key==e.key||key.equals(e.key))){
return e.value;
}
}
return null;
}
/**
* 添加元素,如果key已存在,则替换
*/
public V put(K key,V value){
if(null==key){
return putNullKey(value);
}
int h=hash(key);
int i = findBucketIndex(h,table.length);
//如果已经有这个key,则替换
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
K k;
if (e.hash == h && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
return oldValue;
}
}
addEntry(h, key, value, i);
return null;
}
public Object remove(K key){
return removeEntryForKey(key);
}
private Entry removeEntryForKey (K key){
int hash = hash(key.hashCode());
int i = findBucketIndex(hash,table.length);
//先定义一个prev表示待删除的元素的上一个
Entry prev = table[i];
Entry e=prev;
while(e!=null){
//定位出e的下一个元素,当找到我们要删除的元素时,将链表切断,将prev和next连接即可。参考linkedList的删除即可
Entry next = e.next;
//在链表上定位到这个元素,先判断hash值是否相等,再判断key的equals是否相等!注意,这里如果==返回true则说明是一个引用,这样可省略equals的判断。
if(e.hash==hash&& (e.key==key||(key!=null&&key.equals(e.key))) ){
size--;//此时已有元素被定位到,注意while外面是判断了hash值相等的情况。hash值相等时候equals不一定相等。
//这里,还得判断一个东西,即定位到的这个Entry是否是table[i],因为table[i]上的删除和链表上的删除有区别
if(prev==e){
table[i]=next;//尽管则个next可能为空
}else{
prev.next=next;//如果不是table位置的,则是链表后边的元素,此时,将prev的下一个置为e的next即可,此时,e已被切断,e的前后Entry被连接起来
}
return e;
}
//上边的if没进去,则执行下一次循环,指针向后移动一位!
prev=e;
e=next;
}
//直到while结束,如果没找到元素,return null
return null;
}
/**
* hashMap允许键为空,对这个空键单独处理
*/
private V putNullKey(V value){
//如果已经有这个Null的Key,则替换
for (Entry<K,V> e = table[0]; e != null; e = e.next) {
if (e.key == null) {
V oldValue = e.value;
e.value = value;
return oldValue;
}
}
addEntry(0, null, value, 0);
return null;
}
void addEntry(int hash, K key, V value, int bucketIndex){
Entry<K,V> e = table[bucketIndex];
table[bucketIndex] =new Entry(hash,key,value,e);
size++;
if(size>threshold){
resize(table.length*2);
}
}
/**
* 扩容,传入新容量
*/
void resize(int newCapacity){
Entry[] newTable = new Entry[newCapacity];
//将所有旧元素换到新table中start
Entry[] oldTable = table;
//遍历所有的位置
for (int j = 0; j < oldTable.length; j++){
Entry e = oldTable[j];
if(null!=e){
oldTable[j]=null;//循环结束后,会将oldTable引用全部置空,GC回收
do{
Entry next = e.next;//先记住e的链表下一个是谁
int i = findBucketIndex(e.hash,newTable.length);//找到新的哈希桶位置
e.next=newTable[i];//注意这里是循环,newTable[i]上是可能有元素的。将现在newTable[i]上的元素置为e的下一个,
newTable[i]=e;//赋值,所以,jdk7中的扩容是链表头插入。
e=next;//下一次循环使用
}while(null!=e);
}
}
table=newTable;
//将所有旧元素换到新table中end
threshold=(int)(newCapacity*DEFAULT_LOAD_FACTOR);
}
/**
* HashCode做一次hash运算。以此更加保证散列性
*/
static final int hash(Object key) {
int h;
//将hashCode右移16位与hashCode做“异或”运算,即高16位^16位(参考jdk8)。如果key为null,固定放到table[0]的位置
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
/**
* 任何一个key,都需要找到hash后对应的哈希桶位置
*/
static int findBucketIndex(int h, int length) {
//求余数的算法的结果是一样的。位运算效率高(装逼一点)
return h & (length-1);
}
//map的存储单元
private static class Entry<K,V>{
int hash;
K key;
V value;
Entry next;
public Entry(int hash, K key, V value, Entry next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
}
}
测试
public class MyMap {
public static void main(String[] args) {
HkHashMap<String,Person> myMap = new HkHashMap();
myMap.put("张三", new Person("张三",20) );
myMap.put("李四", new Person("李四",21) );
myMap.put("王五", new Person("王五",22) );
myMap.put("赵六", new Person("赵六",23) );
myMap.put("1", new Person("张三",20) );
myMap.put("2", new Person("李四",21) );
myMap.put("3", new Person("王五",22) );
myMap.put("4", new Person("赵六",23) );
myMap.put("5", new Person("张三",20) );
myMap.put("6", new Person("李四",21) );
myMap.put("7", new Person("王五",22) );
myMap.put("8", new Person("赵六",23) );
myMap.put("9", new Person("张三",20) );
myMap.put("10", new Person("李四",21) );
myMap.put("11", new Person("王五",22) );
myMap.put("12", new Person("赵六",23) );
myMap.remove("1");
System.out.println("张三的年龄是:"+myMap.get("张三").getAge());
System.out.println("赵六的年龄是:"+myMap.get("赵六").getAge());
}
}
输出结果:
Connected to the target VM, address: '127.0.0.1:14375', transport: 'socket'
张三的年龄是:20
赵六的年龄是:23
Disconnected from the target VM, address: '127.0.0.1:14375', transport: 'socket'
随着put元素的增加,resize后 threshold也会变大。
参考文章:
https://www.cnblogs.com/hkblogs/p/9151160.html