Java-HashMap原理

1、HashMap的数据结构

数据结构中有数组和链表来实现对数据的存储,但这两者基本上是两个极端。

数组

数组存储区间是连续的,占用内存严重,故空间复杂的很大。但数组的二分查找时间复杂度小,为O(1);数组的特点是:寻址容易,插入和删除困难;

链表

链表存储区间离散,占用内存比较宽松,故空间复杂度很小,但时间复杂度很大,达O(N)。链表的特点是:寻址困难,插入和删除容易。

哈希表

那么我们能不能综合两者的特性,做出一种寻址容易,插入删除也容易的数据结构?答案是肯定的,这就是我们要提起的哈希表。哈希表((Hash
table)既满足了数据的查找方便,同时不占用太多的内容空间,使用也十分方便。

哈希表有多种不同的实现方法,我接下来解释的是最常用的一种方法—— 拉链法,我们可以理解为“链表的数组” ,如图:
这里写图片描述
这里写图片描述
从上图我们可以发现哈希表是由数组+链表组成的,一个长度为16的数组中,每个元素存储的是一个链表的头结点。那么这些元素是按照什么样的规则存储到数组中呢。一般情况是通过hash(key)%len获得,也就是元素的key的哈希值对数组长度取模得到。比如上述哈希表中,12%16=12,28%16=12,108%16=12,140%16=12。所以12、28、108以及140都存储在数组下标为12的位置。

  HashMap其实也是一个线性的数组实现的,所以可以理解为其存储数据的容器就是一个线性数组。这可能让我们很不解,一个线性的数组怎么实现按键值对来存取数据呢?这里HashMap有做一些处理。

  首先HashMap里面实现一个静态内部类Entry,其重要的属性有 key , value, next,从属性key,value我们就能很明显的看出来Entry就是HashMap键值对实现的一个基础bean,我们上面说到HashMap的基础就是一个线性数组,这个数组就是Entry[],Map里面的内容都保存在Entry[]里面。

   /**
     * The table, resized as necessary. Length MUST Always be a power of two.
    */
   transient Entry[] table;
 static class Entry<K,V> implements Map.Entry<K,V> {  
        final K key;  
        V value;  
        final int hash;  
        Entry<K,V> next;  
..........  
}  

上面的Entry就是数组中的元素,它持有一个指向下一个元素的引用,这就构成了链表。

Entry是HashMap的内部类 包含四个值(next,key,value,hash),其中next是一个指向
Entry的指针,key相当于上面节点的值
value对应要保存的值,hash值由key产生,hashmap中要找到某个元素,需要根据hash值来求得对应数组中的位置,然后在由key来在链表中找Entry的位置。HashMap中的一切操作都是以Entry为基础进行的。HashMap的重点在于如何处理Entry。因此HashMap中的操作大部分都是调用Entry中的方法。可以说HashMap类本身只是提供了一个数组,和对Entry类中方法的一些封装。

HashMap的原理图是:
这里写图片描述
Hashmap实际上是一个数组和链表的结合体:
这里写图片描述
当我们往hashmap中put元素的时候,先根据key的hash值得到这个元素在数组中的位置(即下标),然后就可以把这个元素放到对应的位置中了。如果这个元素所在的位子上已经存放有其他元素了,那么在同一个位子上的元素将以链表的形式存放,新加入的放在链头,最先加入的放在链尾。从hashmap中get元素时,首先计算key的hashcode,找到数组中对应位置的某一元素,然后通过key的equals方法在对应位置的链表中找到需要的元素。从这里我们可以想象得到,如果每个位置上的链表只有一个元素,那么hashmap的get效率将是最高的,但是理想总是美好的,现实总是有困难需要我们去克服,哈哈~

2、hash算法

Hash,一般翻译做“散列”,也有直接音译为”哈希”的,就是把任意长度的输入(又叫做预映射,pre-image),通过散列算法,变换成固定长度的输出,该输出就是散列值。这种转换是一种压缩映射,也就是,散列值的空间通常远小于输入的空间,不同的输入可能会散列成相同的输出,而不可能从散列值来唯一的确定输入值。

说的通俗一点,Hash 算法的意义在于提供了一种快速存取数据的方 法,它用一种算法建立键值与真实值之间的对应关系,(每一个真实值只 能有一个键值,但是一个键值可以对应多个真实值),这样可以快速在数组等里面存取数据。

我们可以看到在hashmap中要找到某个元素,需要根据key的hash值来求得对应数组中的位置。如何计算这个位置就是hash算法。前面说过hashmap的数据结构是数组和链表的结合,所以我们当然希望这个hashmap里面的元素位置尽量的分布均匀些,尽量使得每个位置上的元素数量只有一个,那么当我们用hash算法求得这个位置的时候,马上就可以知道对应位置的元素就是我们要的,而不用再去遍历链表。

所以我们首先想到的就是把hashcode对数组长度取模运算,这样一来,元素的分布相对来说是比较均匀的。但是,“模”运算的消耗还是比较大的,能不能找一种更快速,消耗更小的方式那?java中时这样做的,

 static int indexFor(int h, int length) {  
       return h & (length-1);  
   }  

首先算得key得hashcode值,然后跟数组的长度-1做一次“与”运算(&)。看上去很简单,其实比较有玄机。比如数组的长度是2的4次方,那么hashcode就会和2的4次方-1做“与”运算。很多人都有这个疑问,为什么hashmap的数组初始化大小都是2的次方大小时,hashmap的效率最高,我以2的4次方举例,来解释一下为什么数组大小为2的幂时hashmap访问的性能最高。

看下图,左边两组是数组长度为16(2的4次方),右边两组是数组长度为15。两组的hashcode均为8和9,但是很明显,当它们和1110“与”的时候,产生了相同的结果,也就是说它们会定位到数组中的同一个位置上去,这就产生了碰撞,8和9会被放到同一个链表上,那么查询的时候就需要遍历这个链表,得到8或者9,这样就降低了查询的效率。同时,我们也可以发现,当数组长度为15的时候,hashcode的值会与14(1110)进行“与”,那么最后一位永远是0,而0001,0011,0101,1001,1011,0111,1101这几个位置永远都不能存放元素了,空间浪费相当大,更糟的是这种情况中,数组可以使用的位置比数组长度小了很多,这意味着进一步增加了碰撞的机率,减慢了查询的效率!
这里写图片描述
所以说,当数组长度为2的n次幂的时候,不同的key算得得index相同的机率较小,那么数据在数组上分布就比较均匀,也就是说碰撞的机率小,相对的,查询的时候就不用遍历某个位置上的链表,这样查询效率也就较高了。

说到这里,我们再回头看一下hashmap中默认的数组大小是多少,查看源代码可以得知是16,为什么是16,而不是15,也不是20呢,看到上面annegu的解释之后我们就清楚了吧,显然是因为16是2的整数次幂的原因,在小数据量的情况下16比15和20更能减少key之间的碰撞,而加快查询的效率。

所以,在存储大容量数据的时候,最好预先指定hashmap的size为2的整数次幂次方。就算不指定的话,也会以大于且最接近指定值大小的2次幂来初始化的,代码如下(HashMap的构造方法中):

且最接近指定值大小的2次幂来初始化的,代码如下(HashMap的构造方法中):

   // Find a power of 2 >= initialCapacity  
          int capacity = 1;  
          while (capacity < initialCapacity)   
             capacity <<= 1;  

3、HashMap的重构resize

当hashmap中的元素越来越多的时候,碰撞的机率也就越来越高(因为数组的长度是固定的),所以为了提高查询的效率,就要对hashmap的数组进行扩容,数组扩容这个操作也会出现在ArrayList中,所以这是一个通用的操作,很多人对它的性能表示过怀疑,不过想想我们的“均摊”原理,就释然了,而在hashmap数组扩容之后,最消耗性能的点就出现了:原数组中的数据必须重新计算其在新数组中的位置,并放进去,这就是resize。

那么hashmap什么时候进行扩容呢?

当hashmap中的元素个数超过数组大小乘以loadFactor(负载因子)时,就会进行数组扩容,loadFactor的默认值为0.75,也就是说,默认情况下,数组大小为16,那么当hashmap中元素个数超过16*0.75=12的时候,就把数组的大小扩展为2*16=32即扩大一倍然后重新计算每个元素在数组中的位置,而这是一个非常消耗性能的操作,所以如果我们已经预知hashmap中元素的个数,那么预设元素的个数能够有效的提高hashmap的性能。比如说,我们有1000个元素new HashMap(1000), 但是理论上来讲new HashMap(1024)更合适,不过上面annegu已经说过,即使是1000,hashmap也自动会将其设置为1024。 但是new HashMap(1024)还不是更合适的,因为0.75*1000 < 1000, 也就是说为了让0.75 * size >1000, 我们必须这样new HashMap(2048)才最合适,既考虑了&的问题,也避免了resize的问题。

void resize(int newCapacity) {  
  Entry[] oldTable = table;  
  int oldCapacity = oldTable.length; 
  if (oldCapacity == MAXIMUM_CAPACITY) {  
    threshold = Integer.MAX_VALUE;  
    return;
  }
  Entry[] newTable = new Entry[newCapacity]; 
  transfer(newTable); table = newTable;
  threshold = (int)(newCapacity * loadFactor); 
}

4、key的hashcode()与equals()方法改写

在第一部分hashmap的数据结构中,annegu就写了get方法的过程:首先计算key的hashcode,找到数组中对应位置的某一元素,然后通过key的equals方法在对应位置的链表中找到需要的元素。所以,hashcode与equals方法对于找到对应元素是两个关键方法。

HashCode的计算方法是调用的各个对象自己的实现的 hashCode()方法。而这个方法是在Object对象中定义的,所以我们自己定义的类如果要在集合中使用的话,就需要正确的覆写 hashCode() 方法。

Hashmap的key可以是任何类型的对象,例如User这种对象,为了保证两个具有相同属性的user的hashcode相同,我们就需要改写hashcode方法,比方把hashcode值的计算与User对象的id关联起来,那么只要user对象拥有相同id,那么他们的hashcode也能保持一致了,这样就可以找到在hashmap数组中的位置了。如果这个位置上有多个元素,还需要用key的equals方法在对应位置的链表中找到需要的元素,所以只改写了hashcode方法是不够的,equals方法也是需要改写滴~当然啦,按正常思维逻辑,equals方法一般都会根据实际的业务内容来定义,例如根据user对象的id来判断两个user是否相等。

在改写equals方法的时候,需要满足以下三点:
(1) 自反性:就是说a.equals(a)必须为true。
(2) 对称性:就是说a.equals(b)=true的话,b.equals(a)也必须为true。
(3) 传递性:就是说a.equals(b)=true,并且b.equals(c)=true的话,a.equals(c)也必须为true。

通过改写key对象的equals()hashcode()方法,我们可以将任意的业务对象作为map的key(前提是你确实有这样的需要)。

假定我们写了一个类:Person(人),我们判断一个对象“人”是否指向同一个人,只要知道这个人的身份证号一直就可以了。

先看我们没有实现 hashCode的情况:

  //身份证类 
  class Code{
    public final int id;//身份证号码已经确认,不能改变 
    public Code(int i){
       this.id=i; 
    }
    //身份号号码相同,则身份证相同
    public boolean equals(Object anObject) {
      if (anObject instanceof Code){
         Code other=(Code) anObject; 
         return this.id==other.id;
      }
      return false;
    }
    public String toString() {
      return "身份证:"+id; 
    }
  }

  //人员信息类 
  class Person {
      public Code id;// 身份证 
      public String name;// 姓名 
      public Person(String name, Code id) {   
         this.id=id;
         this.name=name;
      }
      //如果身份证号相同,就表示两个人是同一个人 
      public boolean equals(Object anObject) {  
         if (anObject instanceof Person){  
           Person other=(Person) anObject;  
           return this.id.equals(other.id);  
         }
        return false; 
      }
     public String toString() {
       return "姓名:"+name+" 身份证:"+id.id+"\n";
     } 
   }
  package com.test;
  import java.util.HashMap;
  public class HashCodeDemo {
    public static void main(String[] args) {  
      HashMap map=new HashMap();
      Person p1=new Person("张三",new Code(123)); 
      Person p2=new Person("李四",new Code(456)); 
      //我们根据身份证来作为key值存放到Map中 
      map.put(p1.id,p1);
      map.put(p2.id,p2);
      System.out.println("HashMap 中存放的人员信息:\n"+map);
      // 张三,改名为:张山,但是还是同一个人(身份证id依然为123)
      Person p3=new Person("张山",new Code(123)); 
      map.put(p3.id,p3);
      System.out.println("张三改名后 HashMap 中存放的人员信 息:\n"+map);
      //查找身份证为:123 的人员信息
      System.out.println("查找身份证为:123 的人员信息:"+map.get(new Code(123)));
    } 
  }

运行结果为:

HashMap 中存放的人员信息: 
HashMap 中存放的人员信息:
 { 身份证:456=姓名:李四 身份证:456,  
   身份证:123=姓名:张三 身份证:123
 }
张三改名后 HashMap 中存放的人员信息:
 { 身份证:123=姓名:张山 身份证:123, 
   身份证:456=姓名:李四 身份证:456, 
   身份证:123=姓名:张三 身份证:123 
 }
查找身份证为:123 的人员信息:null

上面的例子的演示的是,我们在一个HashMap中存放了一些人员的信息。并以这些人员的身份证最为人员的“键”。

注意:此处之所以能够成功put两个Key(id)同为“123”的人(实际上HashMap只能存在唯一的Key,Key重复的话,Value将被新值覆盖),是因为Code类还没有重写Hashcode()函数!!!

而例子的输出结果表示,我们所做的更新和查找操作都失败了。

失败的原因就是我们的身份证类Code没有覆写hashCode()方法。
这个时候,当查找一样的身份证号码的键值对的时候,使用的是默认的对象的内存地址来进行定位(Object类定义的hashcode()方法默认返回的是对象的内存地址)。
这样,后面的所有的身份证号对象new Code(123) 产生的hashCode()值都是不一样的。所以导致操作失败。

下面,我们给 Code类加上 hashCode()方法,然后再运行一下程序看看:

//身份证类
class Code{
  public final int id;//身份证号码已经确认,不能改变 
  public Code(int i){
     this.id=i; 
  }
  //身份号号码相同,则身份证相同
  public boolean equals(Object anObject) {
     if (anObject instanceof Code){
       Code other=(Code) anObject;
       return this.id==other.id;
     }
     return false; 
  }
  public String toString() {
     return "身份证:"+id; 
  }
  //覆写hashCode方法,并使用身份证号作为hash值
  public int hashCode(){
      return id; 
  }
}

//人员信息类 
class Person {
  public Code id;// 身份证 
  public String name;// 姓名 
  public Person(String name, Code id) {  
     this.id=id;
     this.name=name; 
  }
  //如果身份证号相同,就表示两个人是同一个人 
  public boolean equals(Object anObject) {  
    if (anObject instanceof Person){  
        Person other=(Person) anObject; 
        return this.id.equals(other.id); 
     }
     return false; 
  } 
  public String toString() {
     return "姓名:"+name+" 身份证:"+id.id+"\n";
  }  
}
 package com.test;
  import java.util.HashMap;
  public class HashCodeDemo {
    public static void main(String[] args) {  
      HashMap map=new HashMap();
      Person p1=new Person("张三",new Code(123)); 
      Person p2=new Person("李四",new Code(456)); 
      //我们根据身份证来作为key值存放到Map中 
      map.put(p1.id,p1);
      map.put(p2.id,p2);
      System.out.println("HashMap 中存放的人员信息:\n"+map);
      // 张三,改名为:张山,但是还是同一个人(身份证id依然为123)
      Person p3=new Person("张山",new Code(123)); 
      map.put(p3.id,p3);
      System.out.println("张三改名后 HashMap 中存放的人员信 息:\n"+map);
      //查找身份证为:123 的人员信息
      System.out.println("查找身份证为:123 的人员信息:"+map.get(new Code(123)));
    } 
  }

运行结果为:

HashMap 中存放的人员信息: 
HashMap 中存放的人员信息:
 { 身份证:456=姓名:李四 身份证:456,  
   身份证:123=姓名:张三 身份证:123
 }
张三改名后 HashMap 中存放的人员信息:
 { 身份证:456=姓名:李四 身份证:456, 
   身份证:123=姓名:张三 身份证:123 
 }
查找身份证为:123 的人员信息:姓名:张山 身份证:123

这个时候,我们发现。我们想要做的更新和查找操作都成功了。

(1)对于 Map部分的使用和实现,主要就是需要注意存放“键值对”中的对象的 equals()方法和 hashCode()方法的覆写。
(2)如果需要使用到排序的话,那么还需要实现Comparable接口中的compareTo()方法。
(3)我们需要注意 Map 中的“键”是不能重复的,而是否重复的判断,是通过调用“键”对象的 equals()方法来决定的。
(4)而在HashMap中查找和存取“键值对”是同时使用 hashCode()方法和 equals()方法来决定的。

5、HashMap的存取实现

既然是线性数组,为什么能随机存取?这里HashMap用了一个小算法,大致是这样实现:

// 存储时:
int hash = key.hashCode(); // 这个hashCode方法这里不详述,只要理解每个key的hash是一个固定的int值
int index = hash % Entry[].length;
Entry[index] = value;

// 取值时:
int hash = key.hashCode();
int index = hash % Entry[].length;
return Entry[index];

1) put

疑问:如果两个key通过hash%Entry[].length得到的index相同,会不会有覆盖的危险?

  这里HashMap里面用到链式数据结构的一个概念。上面我们提到过Entry类里面有一个next属性,作用是指向下一个Entry。打个比方, 第一个键值对A进来,通过计算其key的hash得到的index=0,记做:Entry[0] = A。一会后又进来一个键值对B,通过计算其index也等于0,现在怎么办?HashMap会这样做:B.next = A,Entry[0] = B,如果又进来C,index也等于0,那么C.next = B,Entry[0] = C;这样我们发现index=0的地方其实存取了A,B,C三个键值对,他们通过next这个属性链接在一起。所以疑问不用担心。也就是说数组中存储的是最后插入的元素。到这里为止,HashMap的大致实现,我们应该已经清楚了。

public V put(K key, V value) {
        if (key == null)
            return putForNullKey(value); //null总是放在数组的第一个链表中
        int hash = hash(key.hashCode());
        int i = indexFor(hash, table.length);
        //遍历链表
        for (Entry<K,V> e = table[i]; e != null; e = e.next) {
            Object k;
            //如果key在链表中已存在,则替换为新value
            if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
                V oldValue = e.value;
                e.value = value;
                e.recordAccess(this);
                return oldValue;
            }
        }
        modCount++;
        addEntry(hash, key, value, i);
        return null;

    }


void addEntry(int hash, K key, V value, int bucketIndex) {
    Entry<K,V> e = table[bucketIndex];
    table[bucketIndex] = new Entry<K,V>(hash, key, value, e); //参数e, 是Entry.next
    //如果size超过threshold,则扩充table大小。再散列
    if (size++ >= threshold)
            resize(2 * table.length);
}

当然HashMap里面也包含一些优化方面的实现,这里也说一下。比如:Entry[]的长度一定后,随着map里面数据的越来越长,这样同一个index的链就会很长,会不会影响性能?HashMap里面设置一个因子,随着map的size越来越大,Entry[]会以一定的规则加长长度。

2) get

 public V get(Object key) {
        if (key == null)
            return getForNullKey();
        int hash = hash(key.hashCode());
        //先定位到数组元素,再遍历该元素处的链表
        for (Entry<K,V> e = table[indexFor(hash, table.length)];
             e != null;
             e = e.next) {
            Object k;
            if (e.hash == hash && ((k = e.key) == key || key.equals(k)))
                return e.value;
        }
        return null;
}

6、解决hash冲突的办法

开放定址法(线性探测再散列,二次探测再散列,伪随机探测再散列)
再哈希法
链地址法
建立一个公共溢出区 

Java中hashmap的解决办法就是采用的链地址法。

7、ConcurrentHashMap

ConcurrentHashMap是Java5中新增加的一个线程安全的Map集合,可以用来替代HashTable。ConcurrentHashMap相比于HashTable,使用了多个锁代替HashTable中的单个锁,也就是锁分离技术(Lock Stripping),使得其是线程安全并且高效的HashMap,在并发编程中经常可见它的使用。

在开始分析它的高并发实现机制前,先讲讲废话,看看它是如何被引入jdk的。

(1) 线程不安全的HashMap

HashMap线程不安全,它的线程不安全主要发生在put等对HashEntry有直接写操作的地方。
这里写图片描述
在多线程环境下,使用Hashmap进行put操作会引起死循环,导致CPU利用率接近100%,所以在并发情况下不能使用HashMap。

(2) 效率低下的HashTable

Hashtable线程安全,但是效率低下。
这里写图片描述
从Hashtable示例的源码可以看出,Hashtable是用synchronized关键字来保证线程安全的,由于synchronized的机制是在同一时刻只能有一个线程操作,其他的线程阻塞或者轮询等待,在线程竞争激烈的情况下,这种方式的效率会非常的低下。如线程1使用put进行添加元素,线程2不但不能使用put方法添加元素,并且也不能使用get方法来获取元素,所以竞争越激烈效率越低。

(3) 高效且安全的ConcurrentHashMap

Hashtable低效主要是因为所有访问Hashtable的线程都争夺一把锁。如果容器有很多把锁,每一把锁控制容器中的一部分数据,那么当多个线程访问容器里的不同部分的数据时,线程之前就不会存在锁的竞争,这样就可以有效的提高并发的访问效率。这也正是ConcurrentHashMap使用的分段锁技术。将ConcurrentHashMap容器的数据分段存储,每一段数据分配一个Segment(锁),当线程占用其中一个Segment时,其他线程可正常访问其他段数据。

(4) ConcurrentHashMap结构分析

这里写图片描述
从类图可以看出:ConcurrentHashMap由Segment和HashEntry组成。

(1) Segment是可重入锁,它在ConcurrentHashMap中扮演分离锁的角色;

(2) HashEntry主要存储键值对;

CurrentHashMap包含一个Segment数组,每个Segment包含一个HashEntry数组并且守护它,当修改HashEntry数组数据时,需要先获取它对应的Segment锁;而HashEntry数组采用开链法处理冲突,所以它的每个HashEntry元素又是链表结构的元素。

由此可以得出ConcurrentHashMap的结构图如下:
这里写图片描述

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章