深入Java集合系列之三:HashMap

简介:

前言

无意中发现有很多对Map尤其是HashMap的线程安全性的话题讨论,在我的理解中,对HashMap的理解中也就知道它是线程不安全的,以及HashMap的底层算法采用了链地址法来解决哈希冲突的知识,但是对其线程安全性的认知有限,故写这篇博客的目的就是让和我对这块内容不熟悉的小伙伴有一个对HashMap更深的认知,为了让你更好的查看你感兴趣的内容,你也可以直接点击右边的文章目录进行阅读。

写这篇文章是在一年前,这里只是重新发表

哈希表

在数据结构中有一种称为哈希表的数据结构,它实际上是数组的推广。如果有一个数组,要最有效的查找某个元素的位置,如果存储空间足够大,那么可以对每个元素和内存中的某个地址对应起来,然后把每个元素的地址用一个数组(这个数组也称为哈希表)存储起来,然后通过数组下标就可以直接找到某个元素了。这种方法术语叫做直接寻址法。这种方法的关键是要把每个元素和某个地址对应起来,所以如果当一组数据的取值范围很大的时候,而地址的空间又有限,那么必然会有多个映射到同一个地址,术语上称为哈希冲突,这时映射到同一个地址的元素称为同义词。毕竟,存储空间有限,所以冲突是不可避免的,但是可以尽量做到减少冲突。目前有两种比较有效的方法来解决哈希冲突:

  • 链地址法
  • 开放地址法

这里简要说明一下开放地址法,顾名思义,就是哈希表中的每个位置要么存储了一个元素要么为NULL。当数据比较多的时候,查找一个元素挺费事的,但是可以使用探测的方法进行查找。这个话题与本主题关系不大,感兴趣的小伙伴可以自行研究。

链地址法

为什么要把链地址法单独拿出来呢?因为后面有用。
链地址法的大概思想是:对于每个关键字,使用哈希函数确定其在哈希表中位置(也就是下标),如果该位置没有元素则直接映射到该地址;如果该位置已经有元素了,就把该元素连接到已存在元素的尾部,也就是一个链表,并把该元素的next设置为null。这样的话,每个哈希表的位置都可能存在一个链表,这种方式要查找某个元素效率比较高,时间复杂度为O(1+a),a为哈希表中每个位置链表的平均长度。这里需要假设每个元素都被等可能映射到哈希表中的任意一个位置。

下面这张图展示了链地址法的过程:

链地址法

HashMap


HashMap底层实现

HashMap允许使用null作为key或者value,并且HashMap不是线程安全的,除了这两点外,HashMap与Hashtable大致相同,下面是官方API对HashMap的描述:

Hash table based implementation of the Map interface. This implementation provides all of the optional map operations, and permits null values and the null key. (The HashMap class is roughly equivalent to Hashtable, except that it is unsynchronized and permits nulls.) This class makes no guarantees as to the order of the map; in particular, it does not guarantee that the order will remain constant over time.

如果有多个线程对Hash映射进行访问,那么至少有一个线程会对哈希映射进行结构的修改:

结构上的修改是指添加或删除一个或多个映射关系的任何操作;仅改变与实例已经包含的键关联的值不是结构上的修改

那么很显然,当多个线程同时(严格来说不能称为同时,因为CPU每次只能允许一个线程获取资源,只不过时间上非常短,CPU运行速度很快,所以理解为同时)修改哈希映射,那么最终的哈希映射(就是哈希表)的最终结果是不能确定的,这只能看CPU心情了。如果要解决这个问题,官方的参考方案是保持外部同步,什么意思?看下面的代码就知道了:

Map m = Collections.synchronizedMap(new HashMap(...));

但是不建议这么使用,因为当多个并发的非同步操作修改哈希表的时候,最终结果不可预测,所以使用上面的方法创建HashMap的时候,当有多个线程并发访问哈希表的情况下,会抛出异常,所以并发修改会失败。比如下面这段代码:

for (int i = 0; i < 20; i++) {
        collectionSynMap.put(i, String.valueOf(i));
    }
    Set<Entry<Integer,String>> keySets = collectionSynMap.entrySet();
    Iterator<Entry<Integer, String>> keySetsIterator = keySets.iterator();
    try {
        while(keySetsIterator.hasNext()){
            Entry<Integer,String> entrys = (Entry<Integer, String>) keySetsIterator.next();
            System.out.println(entrys.getValue());
            if(entrys.getValue().equals("1")){
                System.out.println(entrys.getValue());
                collectionSynMap.remove(1);
                //keySetsIterator.remove();
            }
        }
    } catch (Exception e) {
        e.printStackTrace();
    }

就会抛出ConcurrentModificationException异常,因为在使用迭代器遍历的时候修改映射结构,但是使用代码中注释的删除是不会抛出异常的。

通过上面的分析,我们初步了解HashMap的非线程安全的原理,下面从源码的角度分析一下,为什么HashMap不是线程安全的:

public V put(K key, V value) {
    //这里省略了对重复键值的处理代码
    modCount++;
    addEntry(hash, key, value, i);
    return null;
}

那么问题应该处在addEntry()上,下面来看看其源码:

void addEntry(int hash, K key, V value, int bucketIndex) {
    //如果达到Map的阈值,那么就扩大哈希表的容量
    if ((size >= threshold) && (null != table[bucketIndex])) {
        //扩容
        resize(2 * table.length);
        hash = (null != key) ? hash(key) : 0;
        bucketIndex = indexFor(hash, table.length);
    }
    //创建Entry键值对,此处省略这部分代码
}

假设有线程A和线程B都调用addEntry()方法,线程A和B会得到当前哈希表位置的头结点(就是上面链地址法的第一个元素),并修改该位置的头结点,如果是线程A先获取头结点,那么B的操作就会覆盖线程A的操作,所以会有问题。

下面再看看resize方法的源码:

void resize(int newCapacity) {
    //此处省略如果达到阈值扩容为原来两倍的过程代码
    Entry[] newTable = new Entry[newCapacity];
    //把当前的哈希表转移到新的扩容后的哈希表中
    transfer(newTable, initHashSeedAsNeeded(newCapacity));
    table = newTable;
    threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}

所以如果有多个线程执行put方法,并调用resize方法,那么就会出现多种情况,在转移的过程中丢失数据,或者扩容失败,都有可能,所以从源码的角度分析这也是线程不安全的。

HashMap测试代码

    for (int i = 0; i < 40; i++) {
        hashMap.put(i, String.valueOf(i));
    }
    Set<Entry<Integer,String>> keySets = hashMap.entrySet();
    final Iterator<Entry<Integer, String>> keySetsIterator = keySets.iterator();
    Thread t3 = new Thread(){
        public void run(){
            try {
                while(keySetsIterator.hasNext()){
                    Entry<Integer,String> entrys = (Entry<Integer, String>) keySetsIterator.next();
                    System.out.println(entrys.getValue());
                    if(entrys.getValue().equals("1")){
                        System.out.println(entrys.getValue());
                        hashMap.remove(1);
                    }
                }
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    };
    Thread t4 = new Thread(){
        public void run(){
            try {
                while(keySetsIterator.hasNext()){
                    Entry<Integer,String> entrys = (Entry<Integer, String>) keySetsIterator.next();
                    System.out.println(entrys.getValue());
                    if(entrys.getValue().equals("1")){
                        System.out.println(entrys.getValue());
                        hashMap.remove(1);
                    }
                }
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    };
    t3.start();
    t4.start();

这段代码启动了两个线程并发修改HashMap的映射关系,所以会抛出两个ConcurrentModificationException异常,通过这段测试代码在此证明了HashMap的非线程安全。

目录
相关文章
|
13天前
|
Java
Java之HashMap详解
本文介绍了Java中HashMap的源码实现(基于JDK 1.8)。HashMap是基于哈希表的Map接口实现,允许空值和空键,不同步且线程不安全。文章详细解析了HashMap的数据结构、主要方法(如初始化、put、get、resize等)的实现,以及树化和反树化的机制。此外,还对比了JDK 7和JDK 8中HashMap的主要差异,并提供了使用HashMap时的一些注意事项。
Java之HashMap详解
|
12天前
|
Java
Java 8 引入的 Streams 功能强大,提供了一种简洁高效的处理数据集合的方式
Java 8 引入的 Streams 功能强大,提供了一种简洁高效的处理数据集合的方式。本文介绍了 Streams 的基本概念和使用方法,包括创建 Streams、中间操作和终端操作,并通过多个案例详细解析了过滤、映射、归并、排序、分组和并行处理等操作,帮助读者更好地理解和掌握这一重要特性。
21 2
|
11天前
|
安全 Java
Java多线程集合类
本文介绍了Java中线程安全的问题及解决方案。通过示例代码展示了使用`CopyOnWriteArrayList`、`CopyOnWriteArraySet`和`ConcurrentHashMap`来解决多线程环境下集合操作的线程安全问题。这些类通过不同的机制确保了线程安全,提高了并发性能。
|
16天前
|
存储 Java
判断一个元素是否在 Java 中的 Set 集合中
【10月更文挑战第30天】使用`contains()`方法可以方便快捷地判断一个元素是否在Java中的`Set`集合中,但对于自定义对象,需要注意重写`equals()`方法以确保正确的判断结果,同时根据具体的性能需求选择合适的`Set`实现类。
|
16天前
|
存储 Java 开发者
在 Java 中,如何遍历一个 Set 集合?
【10月更文挑战第30天】开发者可以根据具体的需求和代码风格选择合适的遍历方式。增强for循环简洁直观,适用于大多数简单的遍历场景;迭代器则更加灵活,可在遍历过程中进行更多复杂的操作;而Lambda表达式和`forEach`方法则提供了一种更简洁的函数式编程风格的遍历方式。
|
16天前
|
Java 开发者
|
27天前
|
存储 Java 程序员
Java面试加分点!一文读懂HashMap底层实现与扩容机制
本文详细解析了Java中经典的HashMap数据结构,包括其底层实现、扩容机制、put和查找过程、哈希函数以及JDK 1.7与1.8的差异。通过数组、链表和红黑树的组合,HashMap实现了高效的键值对存储与检索。文章还介绍了HashMap在不同版本中的优化,帮助读者更好地理解和应用这一重要工具。
54 5
|
28天前
|
安全 Java 程序员
深入Java集合框架:解密List的Fail-Fast与Fail-Safe机制
本文介绍了 Java 中 List 的遍历和删除操作,重点讨论了快速失败(fail-fast)和安全失败(fail-safe)机制。通过普通 for 循环、迭代器和 foreach 循环的对比,详细解释了各种方法的优缺点及适用场景,特别是在多线程环境下的表现。最后推荐了适合高并发场景的 fail-safe 容器,如 CopyOnWriteArrayList 和 ConcurrentHashMap。
55 5
|
28天前
|
存储 Java API
详细解析HashMap、TreeMap、LinkedHashMap等实现类,帮助您更好地理解和应用Java Map。
【10月更文挑战第19天】深入剖析Java Map:不仅是高效存储键值对的数据结构,更是展现设计艺术的典范。本文从基本概念、设计艺术和使用技巧三个方面,详细解析HashMap、TreeMap、LinkedHashMap等实现类,帮助您更好地理解和应用Java Map。
48 3
|
28天前
|
存储 缓存 安全
在Java的Map家族中,HashMap和TreeMap各具特色
【10月更文挑战第19天】在Java的Map家族中,HashMap和TreeMap各具特色。HashMap基于哈希表实现,提供O(1)时间复杂度的高效操作,适合性能要求高的场景;TreeMap基于红黑树,提供O(log n)时间复杂度的有序操作,适合需要排序和范围查询的场景。两者在不同需求下各有优势,选择时需根据具体应用场景权衡。
30 2