不惑JAVA之JAVA基础 - HashMap

HashMap应该是平时应用开发中或是框架设计中最为常用高效的容器。在介绍HashMap之前,先介绍两个常见的区别。后期会专门介绍CurrentHashMap。

hashmap 和 hashtable 区别

HashMap和HashTable有什么区别,一个比较简单的回答是:

  1. HashMap是非线程安全的,HashTable是线程安全的。
  2. HashMap的键和值都允许有null值存在,而HashTable则不行。
  3. 因为线程安全的问题,HashMap效率比HashTable的要高。

hashmap 和 hashset 区别

简单的说:

HashMap :实现map接口;使用hash算法,里面的数据是无序的;并且存储的是键值对;非线程安全;

HashSet :实现了Set接口;内部封装了hashmap,故也是无序的;因为实现set接口,存储的是key,value永远为PRESENT;非线程安全;

设计理念

HashMap的存储结构其实就是哈希表的存储结构(由数组与链表结合组成,称为链表的数组)。如下图所示:

如上图所示,HashMap中元素存储的形式是键-值对(key-value对,即Entry对),所有具有相同hashcode值的键(key)所对应的entry对会被链接起来组成一条链表,而数组的作用则是存储链表中第一个结点的地址值。

所以也会面临两个问题:

  • 设计个好的hash函数,使冲突尽可能的减少;
  • 其次是需要解决发生冲突后如何处理。

这两个问题会在下面的源码介绍中解答。

构造函数

在查看构造函数前,我们先来了解一下HashMap定义的成员变量:

/**
 * The default initial capacity - MUST be a power of two.
 */
static final int DEFAULT_INITIAL_CAPACITY = 16;// 默认初始容量为16,必须为2的幂

/**
 * The maximum capacity, used if a higher value is implicitly specified
 * by either of the constructors with arguments.
 * MUST be a power of two <= 1<<30.
 */
static final int MAXIMUM_CAPACITY = 1 << 30;// 最大容量为2的30次方

/**
 * The load factor used when none specified in constructor.
 */
static final float DEFAULT_LOAD_FACTOR = 0.75f;// 默认加载因子0.75

/**
 * The table, resized as necessary. Length MUST Always be a power of two.
 */
transient Entry<K,V>[] table;// Entry数组,哈希表,长度必须为2的幂

/**
 * The number of key-value mappings contained in this map.
 */
transient int size;// 已存元素的个数

/**
&nbsp;* The next size value at which to resize (capacity * load factor).
&nbsp;* @serial
&nbsp;*/
int threshold;// 下次扩容的临界值,size>=threshold就会扩容

/**
&nbsp;* The load factor for the hash table.
&nbsp;*
&nbsp;* @serial
&nbsp;*/
final float loadFactor;// 加载因子

这几个属性还是比较重要,后面会用到。

HashMap提供了三个构造函数:

  • HashMap():构造一个具有默认初始容量 (16) 和默认加载因子 (0.75) 的空 HashMap。
  • HashMap(int initialCapacity):构造一个带指定初始容量和默认加载因子 (0.75) 的空 HashMap。
  • HashMap(int initialCapacity, float loadFactor):构造一个带指定初始容量和加载因子的空HashMap。

initialCapacity:初始容量 默认是16;

loadFactor: 加载因子 默认是0.75.

这两个参数是影响HashMap性能的重要参数,其中容量表示哈希表中桶的数量,初始容量是创建哈希表时的容量,加载因子是哈希表在其容量自动增加之前可以达到多满的一种尺度,它衡量的是一个散列表的空间的使用程度,负载因子越大表示散列表的装填程度越高,反之愈小。对于使用链表法的散列表来说,查找一个元素的平均时间是O(1+a),因此如果负载因子越大,对空间的利用更充分,然而后果是查找效率的降低;如果负载因子太小,那么散列表的数据将过于稀疏,对空间造成严重浪费。系统默认负载因子为0.75,一般情况下我们是无需修改的。

说了这么多,其实就是当HashMap数组容量达到initialCapacity*loadFactor(默认是16*0.75=12)以上时,会进行rehash也就是扩容操作,rehash是非常消耗性能的这个下面会详细解析。

数据结构

Java中最常用的两种结构是数组和模拟指针(引用),几乎所有的数据结构都可以利用这两种来组合实现,HashMap也是如此。实际上HashMap是一个“链表散列”。

HashMap底层实现还是数组,只是数组的每一项都是一条链。其中参数initialCapacity就代表了该数组的长度。下面为HashMap构造函数的源码:

public HashMap(int initialCapacity, float loadFactor) {
        // 初始容量不能<0
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal initial capacity: "
                    + initialCapacity);
        // 初始容量不能 > 最大容量值,HashMap的最大容量值为2^30
        if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;
        // 负载因子不能 < 0
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            throw new IllegalArgumentException("Illegal load factor: "
                    + loadFactor);

        // 计算出大于 initialCapacity 的最小的 2 的 n 次方值。
        int capacity = 1;
        while (capacity < initialCapacity)
            capacity <<= 1;

        this.loadFactor = loadFactor;
        // 设置HashMap的容量极限,当HashMap的容量达到该极限时就会进行扩容操作
        threshold = (int) (capacity * loadFactor);
        // 初始化table数组
        table = new Entry[capacity];
        init();
    }

从源码中可以看出,每次新建一个HashMap时,都会初始化一个table数组。table数组的元素为Entry节点。

static class Entry<K,V> implements Map.Entry<K,V> {
        final K key;
        V value;
        Entry<K,V> next;
        final int hash;

        /**
         * Creates new entry.
         */
        Entry(int h, K k, V v, Entry<K,V> n) {
            value = v;
            next = n;
            key = k;
            hash = h;
        }
        .......
    }

其中Entry为HashMap的内部类,它包含了键key、值value、下一个节点next,以及hash值,这是非常重要的,正是由于Entry才构成了table数组的项为链表。

如果想深度了解为什么HashMap容量一定要为2的幂,可以参考:HashMap深度解析(二)

put方法

public V put(K key, V value) {
        //当key为null,调用putForNullKey方法,保存null与table第一个位置中,这是HashMap允许为null的原因
        if (key == null)
            return putForNullKey(value);
        //计算key的hash值
        int hash = hash(key.hashCode());                  ------(1)
        //计算key hash 值在 table 数组中的位置
        int i = indexFor(hash, table.length);             ------(2)
        //从i出开始迭代 e,找到 key 保存的位置
        for (Entry<K, V> e = table[i]; e != null; e = e.next) {
            Object k;
            //判断该条链上是否有hash值相同的(key相同)
            //若存在相同,则直接覆盖value,返回旧value
            if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
                V oldValue = e.value;    //旧值 = 新值
                e.value = value;
                e.recordAccess(this);
                return oldValue;     //返回旧值
            }
        }
        //修改次数增加1
        modCount++;
        //将key、value添加至i位置处
        addEntry(hash, key, value, i);
        return null;
    }

通过源码我们可以清晰看到HashMap保存数据的过程为:首先判断key是否为null,若为null,则直接调用putForNullKey方法。若不为空则先计算key的hash值,然后根据hash值搜索在table数组中的索引位置,如果table数组在该位置处有元素,则通过比较是否存在相同的key,若存在则覆盖原来key的value,否则将该元素保存在链头(最先保存的元素放在链尾)。若table在该处没有元素,则直接保存。

具体的实现过程见addEntry方法,如下

void addEntry(int hash, K key, V value, int bucketIndex) {
        //获取bucketIndex处的Entry
        Entry<K, V> e = table[bucketIndex];
        //将新创建的 Entry 放入 bucketIndex 索引处,并让新的 Entry 指向原来的 Entry
        table[bucketIndex] = new Entry<K, V>(hash, key, value, e);
        //若HashMap中元素的个数超过极限了,则容量扩大两倍
        if (size++ >= threshold)
            resize(2 * table.length);
    }

扩充HashMap实例容量源代码:

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];  //重新定义新容量的Entry对
        transfer(newTable);                         //rehash操作,将旧表中的元素重新映射到新表中
        table = newTable;
        threshold = (int)(newCapacity * loadFactor);//新的临界值为新的容量*加载因子
    }

正如上述代码所示,HashMap通过key值的hashcode获得了对应的bucket存储空间的下标,然后进入bucket空间,通过链表遍历的方式逐个查询,看看链表中是否已经存在了这个key的键-值对,如果已经存在则用新值替换旧值,否则插入新的键-值对。看到这里,相信大家会发现,hashCode值相同的两个值可能是不同的两个对象,而当put进去的是另一个hashCode值相等的对象时,会发生冲突,而在HashMap中解决这种冲突的方法就是将hashCode值相同的key值所对应的key-value对串联成一条链表。如果等于最大容量时,会进行rehash,也就是对数组进行扩容非常消耗性能。因为它需要重新计算这些数据在新table数组中的位置并进行复制处理,所以如果我们已经预知HashMap中元素的个数,那么预设元素的个数能够有效的提高HashMap的性能。

get方法

相对于HashMap的存而言,取就显得比较简单了。通过key的hash值找到在table数组中的索引处的Entry,然后返回该key对应的value即可。

public V get(Object key) {
        // 若为null,调用getForNullKey方法返回相对应的value
        if (key == null)
            return getForNullKey();
        // 根据该 key 的 hashCode 值计算它的 hash 码
        int hash = hash(key.hashCode());
        // 取出 table 数组中指定索引处的值
        for (Entry<K, V> e = table[indexFor(hash, table.length)]; e != null; e = e.next) {
            Object k;
            //若搜索的key与查找的key相同,则返回相对应的value
            if (e.hash == hash && ((k = e.key) == key || key.equals(k)))
                return e.value;
        }
        return null;
    }

在这里能够根据key快速的取到value除了和HashMap的数据结构密不可分外,还和Entry有莫大的关系,在前面就提到过,HashMap在存储过程中并没有将key,value分开来存储,而是当做一个整体key-value来处理的,这个整体就是Entry对象。同时value也只相当于key的附属而已。在存储的过程中,系统根据key的hashcode来决定Entry在table数组中的存储位置,在取的过程中同样根据key的hashcode取出相对应的Entry对象。

remove方法

public V remove(Object key) {
    Entry<K,V> e = removeEntryForKey(key);
    //可以看到删除的key如果存在,就返回其所对应的value
    return (e == null ? null : e.value);
}
final Entry<K,V> removeEntryForKey(Object key) {
    if (size == 0) {
        return null;
    }
    int hash = (key == null) ? 0 : hash(key);
    int i = indexFor(hash, table.length);
    //这里用了两个Entry对象,相当于两个指针,为的是防治冲突链发生断裂的情况
    //这里的思路就是一般的单向链表的删除思路
    Entry<K,V> prev = table[i];
    Entry<K,V> e = prev;
    //当table[i]中存在冲突链时,开始遍历里面的元素
    while (e != null) {
        Entry<K,V> next = e.next;
        Object k;
        if (e.hash == hash &&
            ((k = e.key) == key || (key != null && key.equals(k)))) {
            modCount++;
            size--;
            if (prev == e) //当冲突链只有一个Entry时
                table[i] = next;
            else
                prev.next = next;
            e.recordRemoval(this);
            return e;
        }
        prev = e;
        e = next;
    }
    return e;
}

影响hashmap性能因素

再强调一下hashmap性能因素, 在HashMap中,还存在着两个概念,桶(buckets)和加载因子(load factor)

  • 桶(buckets):上图中的标有0、1、2、3、….、11所对应的数组空间就是一个个桶。
  • 加载因子(load factor):是哈希表在其容量自动增加之前可以达到多满的一种尺度,默认值是0.75。

根据源代码中所述,影响HashMap性能有两个因素:哈希表中的初始化容量(桶的数量)和加载因子。当哈希表中条目数超过了当前容量与加载因子的乘积时,哈希表将会作出自我调整,将容量扩充为原来的两倍,并且重新将原有的元素重新映射到表中,这一过程成为rehash。看到这里,相必大家会发现rehash操作是会造成时间与空间的开销的,所以尽量减少rehash的次数。

有一篇博文不错:如何优化一个哈希策略

Fail-Fast机制

先来举个例子:

public class HashMapRemoveException {
    public static void main(String args[]){
        Map<String,String> hashMap = new HashMap<String,String>();
        hashMap.put("1","1");
        hashMap.put("2","2");
        hashMap.put("3","3");

        for (Map.Entry<String, String> entry : hashMap.entrySet()){
            System.out.println(entry.getKey());
            hashMap.remove(entry.getKey());
        }

        System.out.println(hashMap.size());

    }
}

这段代码会报”Exception in thread “main” java.util.ConcurrentModificationException” 异常。这是为什么呢?

我们知道java.util.HashMap不是线程安全的,因此如果在使用迭代器的过程中有其他线程修改了map,那么将抛出ConcurrentModificationException,这就是所谓fail-fast策略。

这一策略在源码中的实现是通过modCount域,modCount顾名思义就是修改次数,对HashMap内容的修改都将增加这个值,那么在迭代器初始化过程中会将这个值赋给迭代器的expectedModCount。

HashIterator() {
    expectedModCount = modCount;
    if (size > 0) { // advance to first entry
    Entry[] t = table;
    while (index < t.length && (next = t[index++]) == null)
        ;
    }
}

在迭代过程中,判断modCount跟expectedModCount是否相等,如果不相等就表示已经有其他线程修改了Map:

注意到modCount声明为volatile(volatile详解可有参见之前我的博文不惑JAVA之JAVA基础 - volatile),保证线程之间修改的可见性。

final Entry<K,V> nextEntry() {
    if (modCount != expectedModCount)
        throw new ConcurrentModificationException();

在HashMap的API中指出:

由所有HashMap类的“collection 视图方法”所返回的迭代器都是快速失败的:在迭代器创建之后,如果从结构上对映射进行修改,除非通过迭代器本身的 remove 方法,其他任何时间任何方式的修改,迭代器都将抛出 ConcurrentModificationException。因此,面对并发的修改,迭代器很快就会完全失败,而不冒在将来不确定的时间发生任意不确定行为的风险。

注意,迭代器的快速失败行为不能得到保证,一般来说,存在非同步的并发修改时,不可能作出任何坚决的保证。快速失败迭代器尽最大努力抛出 ConcurrentModificationException。因此,编写依赖于此异常的程序的做法是错误的,正确做法是:迭代器的快速失败行为应该仅用于检测程序错误。

解决上面ConcurrentModificationException异常有两种方法:

  1. 使用CurrentHashMap;
  2. 单线程情况下,使用Iterator.remove()。
public class HashMapRemoveException {
    public static void main(String args[]){
        Map<String,String> hashMap = new HashMap<String,String>();
        hashMap.put("1","1");
        hashMap.put("2","2");
        hashMap.put("3","3");

        Iterator<String> iterator = hashMap.keySet().iterator();
        while (iterator.hasNext()){
            String key = iterator.next();
            System.out.println(key);
            iterator.remove();
        }

        System.out.println(hashMap.size());
    }
}

实际应用中遍历用法

在项目中不可避免会进行遍历,HashMap提供了四种遍历形式,其中第三种最为优秀。

public static void main(String[] args) {

  Map<String, String> map = new HashMap<String, String>();
  map.put("1", "value1");
  map.put("2", "value2");
  map.put("3", "value3");

  // 第一种:普遍使用,二次取值
  System.out.println("通过Map.keySet遍历key和value:");
  for (String key : map.keySet()) {
   System.out.println("key= "+ key + " and value= " + map.get(key));
  }

  // 第二种
  System.out.println("通过Map.entrySet使用iterator遍历key和value:");
  Iterator<Map.Entry<String, String>> it = map.entrySet().iterator();
  while (it.hasNext()) {
   Map.Entry<String, String> entry = it.next();
   System.out.println("key= " + entry.getKey() + " and value= " + entry.getValue());
  }

  // 第三种:推荐,尤其是容量大时
  System.out.println("通过Map.entrySet遍历key和value");
  for (Map.Entry<String, String> entry : map.entrySet()) {
       System.out.println("key= " + entry.getKey() + " and value= " + entry.getValue());
  }
  //第四种
  System.out.println("通过Map.values()遍历所有的value,但不能遍历key");
  for (String v : map.values()) {
   System.out.println("value= " + v);
  }
 }

计算h的hash值(可以简单了解)

上面put方法中有这么两段:

 //计算key的hash值
        int hash = hash(key.hashCode());
        //计算key hash 值在 table 数组中的位置
        int i = indexFor(hash, table.length);   

来看一下他们的源码:

static int hash(int h) {
        h ^= (h >>> 20) ^ (h >>> 12);
        return h ^ (h >>> 7) ^ (h >>> 4);
    }

我们知道对于HashMap的table而言,数据分布需要均匀(最好每项都只有一个元素,这样就可以直接找到),不能太紧也不能太松,太紧会导致查询速度慢,太松则浪费空间。计算hash值后,怎么才能保证table元素分布均与呢?我们会想到取模,但是由于取模的消耗较大,HashMap是这样处理的:调用indexFor方法。

当n=15时,6和7的结果一样,这样表示他们在table存储的位置是相同的,也就是产生了碰撞,6、7就会在一个位置形成链表,这样就会导致查询速度降低。诚然这里只分析三个数字不是很多,那么我们就看0-15。

从上面的图表中我们看到总共发生了8此碰撞,同时发现浪费的空间非常大,有1、3、5、7、9、11、13、15处没有记录,也就是没有存放数据。这是因为他们在与14进行&运算时,得到的结果最后一位永远都是0,即0001、0011、0101、0111、1001、1011、1101、1111位置处是不可能存储数据的,空间减少,进一步增加碰撞几率,这样就会导致查询速度慢。而当length = 16时,length – 1 = 15 即1111,那么进行低位&运算时,值总是与原来hash值相同,而进行高位运算时,其值等于其低位值。所以说当length = 2^n时,不同的hash值发生碰撞的概率比较小,这样就会使得数据在table数组中分布较均匀,查询速度也较快。

参考:

java提高篇(二三)—–HashMap

HashMap的hash算法(解决冲突的方式)

HashMap的工作原理

深入Java集合学习系列:HashMap的实现原理

时间: 2024-10-10 08:31:42

不惑JAVA之JAVA基础 - HashMap的相关文章

不惑JAVA之JAVA基础 - NIO (一)

JAVA中最可以大书特书的我觉得至少有两个:一个是NIO,另外一个就是JVM了.这也就是为什么一直我没有去写这两个知识点的原因,因为我一直找不出来一个可以在一篇博文中全部覆盖这个知识点的总结. 这两天翻了一下了JAVA中的圣经<think in java>和<Java核心技术>,虽然写的很好,但感觉写的也不是太符合我想一篇博文覆盖NIO知识点的要求.由于NIO本来就是技术难点,并且java对IO的设计和使用也较为复杂难懂.我也是能力有限如有说明不到位或错误的地方请大家指出. 本文参

java面试笔试基础题目

JAVA相关基础知识 1.面向对象的特征有哪些方面 1.抽象: 抽象就是忽略一个主题中与当前目标无关的那些方面,以便更充分地注意与当前目标有关的方面.抽象并不打算了解全部问题,而只是选择其中的一部分,暂时不用部分细节.抽象包括两个方面,一是过程抽象,二是数据抽象. 2.继承: 继承是一种联结类的层次模型,并且允许和鼓励类的重用,它提供了一种明确表述共性的方法.对象的一个新类可以从现有的类中派生,这个过程称为类继承.新类继承了原始类的特性,新类称为原始类的派生类(子类),而原始类称为新类的基类(父

Java语言的基础知识12

第十四章(使用集合类保存对象) 1.java中得集合对象就像是一个容器,它用来存放Java类的对象.Java中的集合类有些方便存入和取出,有些则方便查找.集合类和数组的区别是,数组的长度是固定的,集合的长度是可变的,数组用来存放基本类型,集合用来存放对象的引用.常用的集合类有List集合,Set集合,和Map集合. 2.List集合包括List接口以及List接口的所有实现类.List集合中的元素许重复,个元素的顺序就是对象插入的顺序.类似java中的数组.List类继承了Collection接

JAVA并行程序基础

JAVA并行程序基础 一.有关线程你必须知道的事 进程与线程 在等待面向线程设计的计算机结构中,进程是线程的容器.我们都知道,程序是对于指令.数据及其组织形式的描述,而进程是程序的实体. 线程是轻量级的进程,是程序执行的最小单位.(PS:使用多线程去进行并发程序的设计,是因为线程间的调度和切换成本远小于进程) 线程的状态(Thread的State类): NEW–刚刚创建的线程,需要调用start()方法来执行线程: RUNNABLE–线程处于执行状态: BLOCKED–线程遇到synchroni

Java 笔试面试 基础篇 一

1. Java 基础部分 基础部分的顺序:基本语法,类相关的语法,内部类的语法,继承相关的语法,异常的语法, 线程的语法,集合的语法,io 的语法,虚拟机方面的语法. 1.一个".java"源文件中是否可以包括多个类(不是内部类)?有什么限制? 可以有多个类,但只能有一个public 的类,并且public 的类名必须与文件名相一致. 2.Java 有没有goto? java 中的保留字,现在没有在java 中使用. 3.说说&和&&的区别. &和&am

Java加密解密(一)Java加密体系基础

Java加密解密(一)Java加密体系基础 1. JCA(Java Cryptography Architecture) 提供基本的加密框架,如证书,数字签名,消息摘要和密钥对生成器.其主要实现在java.security包中. 2. JCE(Java Cryptography Extension) 在JCA的基础了作了扩展,提供了各种加密算法.消息摘要算法和密钥管理等功能.JDK提供的JCE实现主要在javax.crypto包中.第三方提供的JCE也称为安全提供者.由于出口限制,可能需要一个或

浅析关于java的一些基础问题(上篇)

要想让一个问题变难,最基本有两种方式,即极度细化和高度抽象.对于任何语言的研究,良好的基础至关重要,本篇文章,将从极度细化的角度 来解析一些java中的基础问题,这些问题也是大部分编程人员的软肋或易混淆点. 一  关于String问题 1.String是基本类型(值类型)还是引用类型? (1)String是引用类型.通过查看jdk,String是一个类,既然是一个类,那么就是引用类型: (2)基本类型包括:int,float,boolean,byte,凡是通过new关键字的,都属于引用类型,如

java String 类 基础笔记

字符串是一个特殊的对象. 字符串一旦初始化就不可以被改变. String s = "abc";//存放于字符串常量池,产生1个对象 String s1=new String("abc");//堆内存中new创建了一个String对象,产生2个对象 String类中的equals比较字符串中的内容. 常用方法: 一:获取 1.获取字符串中字符的个数(长度):length();方法. 2.根据位置获取字符:charAt(int index); 3.根据字符获取在字符串中

2.2JAVA基础复习——JAVA语言的基础组成运算符和语句

JAVA语言的基础组成有: 1.关键字:被赋予特殊含义的单词. 2.标识符:用来标识的符号. 3.注释:用来注释说明程序的文字. 4.常量和变量:内存存储区域的表示. 5.运算符:程序中用来运算的符号. 6.语句:程序中常用的一些语句. 7.函数:也叫做方法,用来做一些特定的动作. 8.数组:用来存储多个数据的集合. JAVA中的运算符 1.算术运算符:用来进行一些数据算法的符号 算术运算符分为单目运算符.双目运算符.三目运算符. 单目运算符有:+(取正)-(取负)++(自增)--(自减)代码如