Java HashMap详解及实现原理
作者:蜀山剑客李沐白
一、什么是Java HashMap
Java HashMap是Java集合框架中最常用的实现Map接口的数据结构,它使用哈希表实现,允许null作为键和值,可以存储不同类型的键值对。HashMap提供了高效的存取方法,并且是非线程安全的。在Java中,HashMap被广泛应用于各种场景,如缓存、数据库连接池、路由器等。
二、Java HashMap的实现原理
HashMap使用哈希表(Hash Table)实现,哈希表是一种以键值对(key-value)的形式进行存储和快速查找的数据结构。HashMap内部维护了一个数组,每个数组元素都是一个链表节点,每个节点包含一个键值对,以及指向下一个节点的指针。当需要查找或插入一个元素时,HashMap首先计算该元素的哈希值,根据哈希值确定它在数组中的位置,然后在对应的链表上进行查找或插入操作。
1. 哈希值的计算方法
首先,HashMap会调用键对象的hashCode()方法,获取到该对象的哈希码。哈希码是一个int类型的整数,用于表示该对象的标识号。但是,由于哈希码的范围很大,因此通常需要对它进行下一步处理,转换成一个比较小的数值,以便存储到数组中。这样,就用到了哈希函数(Hash Function),哈希函数用于将大范围的哈希码映射到较小的数组索引范围内。
HashMap的哈希函数有多种实现方式,其中一种常用的方法是将哈希码与数组长度取模后的余数作为数组下标,即:
index = hashCode % array.length
其中,array为HashMap内部维护的数组,hashCode为键的哈希码,index为键在数组中的下标。这个方法的优点是简单、快速,但缺点也很明显:当哈希码分布不均衡时,容易出现哈希冲突(Haah Collision),即不同的键对象具有相同的哈希码,导致它们被映射到同一个数组位置上,形成一个链表。
2. 解决哈希冲突的方法
为了解决哈希冲突,HashMap使用链表法(Chaining)来处理。链表法是将哈希冲突的元素以链表的形式组织起来,所有哈希值相同的元素作为同一个链表的节点,并按照插入顺序排列。
链表法的实现非常简单,每个数组元素都是一个链表节点,如果该元素已经存在链表中,则将新元素插入到链表的末尾,否则创建一个新的节点,并将其插入到链表头部。这种方式可以在O(1)的时间内进行查找、插入和删除等操作。
当链表长度变长时,查找、插入和删除操作的效率会降低。为了解决这个效率问题,JDK1.8引入了红黑树(Red-Black Tree)的使用场景,当链表长度超过阈值(默认为8)时,将链表转换为红黑树,以提高效率。
3. 数组扩容
数组扩容是HashMap内部的一个重要操作,当调用put()方法时,若当前的元素数量已经达到了扩容阈值,则需要进行数组扩容操作。其扩容机制如下:
首先,创建一个新的空数组,大小为原数组的两倍;然后遍历原数组中的每个元素,重新计算它们在新数组中的位置,然后将这些元素放到新数组中相应的位置上;最后,再将新数组设置为HashMap内部的数组。因此,在扩容过程中,需要重新计算哈希值,重新映射数组下标,并将元素复制到新数组,这个过程是很费时间和空间的。因此,为了减少扩容的次数,一般情况下,将HashMap的初始化容量设置为能够存放预计元素数量的1.5倍。
4. 加载因子
HashMap内部还维护着一个加载因子(load factor)属性,默认为0.75。它表示当元素数量与数组长度的比值超过了这个阈值时,就会进行扩容操作,以便保持哈希表的性能。一般来说,较小的负载因子会增加哈希表的存储空间,但会减少哈希冲突的发生机率,提高查询效率;而较大的负载因子则会减少存储空间,但会增加哈希冲突的概率,降低查询效率。因此,在决定负载因子的大小时,需要根据应用场景、数据量和时间复杂度等因素进行合理的取舍。
三、Java HashMap的常用方法
HashMap提供了一些常见的操作方法,如put()、get()、remove()、size()等。下面对这些方法进行简要介绍:
- put(Object key, Object value)
将指定的键值对插入到HashMap中,如果该键已经存在,则会用新的值替换已有的值。插入成功返回null,否则返回被替换的旧值。
HashMap<String, Integer> map = new HashMap<>(); map.put("tom", 90); // 插入键值对 map.put("jerry", 85); int oldScore = map.put("tom", 95); // 替换键值对 System.out.println(map.get("tom")); // 输出95
- get(Object key)
返回与指定键相关联的值,如果该键不存在,则返回null。
HashMap<String, Integer> map = new HashMap<>(); map.put("tom", 90); map.put("jerry", 85); int score = map.get("tom"); System.out.println(score); // 输出90
- remove(Object key)
删除HashMap中与指定键相关联的值,并返回被删除的值。
HashMap<String, Integer> map = new HashMap<>(); map.put("tom", 90); map.put("jerry", 85); int score = map.remove("tom"); System.out.println(score); // 输出90
- size()
返回HashMap中键值对的数量。
HashMap<String, Integer> map = new HashMap<>(); map.put("tom", 90); map.put("jerry", 85); int size = map.size(); System.out.println(size); // 输出2
- clear()
删除HashMap中所有键值对。
HashMap<String, Integer> map = new HashMap<>(); map.put("tom", 90); map.put("jerry", 85); map.clear(); System.out.println(map.size()); // 输出0
四、Java HashMap的线程安全性
在多线程环境下,由于HashMap是非线程安全的数据结构,会产生多线程访问带来的并发问题,因此在多线程环境下需要特别注意HashMap的线程安全性。
- HashMap的线程安全问题
HashMap是一种非线程安全(Not Thread-Safe)的数据结构,因此,在多线程环境下使用它可能会导致多种并发问题,主要包括以下几个方面:
(1)覆盖和丢失:如果多个线程同时对同一个位置进行写入操作,最终只有一个线程的结果能够生效,而其他的操作将被覆盖或丢失。
(2)读取过期数据:在HashMap中,读取操作可以在读取和修改操作之间进行,也就是说,多个线程可以同时读取同一个数据。然而,如果一个线程在读取一个键的值时,另一个线程正在修改它,那么读操作可能会读取到过期的数据,从而导致程序出现问题。
(3)死锁和饥饿:由于HashMap使用数组和链表(或红黑树)的结合实现,因此在多线程环境下,可能会出现死锁和饥饿的情况,降低程序性能。
- HashMap的线程安全解决方案
为了解决HashMap的线程安全问题,Java提供了多种解决方案,以下是几种常用的方式:
(1)使用ConcurrentHashMap
ConcurrentHashMap是Java 5中提供的一种线程安全的Map实现,它采用了锁分段技术,在每个段(Segment)中都使用了一个独立的锁,以避免多个线程访问同一段的问题,从而保证了并发性能和线程安全性。ConcurrentHashMap实现了Java中的ConcurrentMap接口,并提供了多个线程安全的方法,如putIfAbsent、remove、replace等。如果需要在多线程环境下使用Map,推荐使用ConcurrentHashMap。
(2)使用Collections.synchronizedMap()
Collections.synchronizedMap()是Java提供的一种将非线程安全的Map对象转换为线程安全的Map对象的方法。它实际上是对Map对象的每个方法都添加了synchronized关键字,从而保证了并发性能和线程安全性。使用Collections.synchronizedMap()创建线程安全的Map对象的代码如下:
Map<K, V> map = Collections.synchronizedMap(new HashMap<>());
这种方式适合于访问频率较低或者对线程安全性要求不高的场景。
(3)使用线程安全的并发集合
除了ConcurrentHashMap和synchronizedMap()外,Java还提供了其他多种线程安全的集合实现和映射实现,如CopyOnWriteArrayList、ConcurrentSkipListMap等。这些集合和映射实现都具有优秀的性能和线程安全性,可以根据实际需求选择使用。
- HashMap的并发测试
为了验证HashMap的线程安全问题,可以编写并发测试程序来模拟多线程访问HashMap时可能出现的问题。以下是一段示例代码:
Map<String, Integer> map = new HashMap<>(); for (int i = 0; i < 1000; i++) { final int index = i; new Thread(() -> map.put("key-" + index, index)).start(); } for (int i = 0; i < 1000; i++) { final int index = i; new Thread(() -> System.out.println(map.get("key-" + index))).start(); }
该程序首先创建了1000个线程并发往HashMap中添加键值对,然后又创建了1000个线程并发读取HashMap中的键值对。由于HashMap是非线程安全的数据结构,可能会产生数据丢失、读取过期数据等问题,因此,执行上述代码,有可能会输出null或错误的结果。
五、Java HashMap使用注意事项
- 键必须实现hashCode()方法和equals()方法
在使用HashMap时,键必须实现hashCode()方法和equals()方法,以便用于哈希表中的查找操作。hashCode()方法用于获取对象的哈希码,equals()方法用于判断两个对象是否相等。如果键没有实现这两个方法,则会出现查询异常和哈希冲突等问题。
值可以为null在HashMap中,值可以为null,这意味着一个键可以映射到空值。但需要注意的是,如果多个键映射到null,则它们在HashMap中实际上是相等的,因为它们都会被映射到同一个位置上。
迭代器操作时需要注意
在使用HashMap的迭代器遍历键值对时,需要注意当在遍历过程中插入或删除元素时,可能会导致ConcurrentModificationException异常的发生。这是因为在遍历过程中,遍历器会对HashMap的修改操作进行快照,并在遍历结束后进行检查,如果与快照不一致,则抛出异常。为了避免这种情况的发生,可以使用Iterator接口提供的remove()方法来删除元素,而不是直接调用HashMap的remove()方法。
- 初始化容量和加载因子的设置
在创建HashMap对象时,需要根据实际业务场景合理地设置初始化容量和加载因子,以便提高HashMap的性能。如果预计插入的元素数量很大,那么初始化容量应该足够大,以减少数组扩容的次数;同时,可以将加载因子设置为较小的值,以提高查询效率。但是,需要注意不要将初始化容量设置过大或加载因子设置过小,否则会浪费内存资源或增加哈希冲突的概率,影响性能。
- 避免哈希冲突
哈希冲突是指不同的键对象具有相同的哈希码,导致它们被映射到同一个数组位置上,形成一个链表。当链表长度变长时,查询效率会降低。为了避免哈希冲突,可以在设计键对象时,尽可能地使其哈希值范围分布均匀,并且尽可能减少哈希冲突的发生。例如,在字符串类型的键中,可以采用汉明距离等算法来计算键的哈希值,并增加随机数来打乱散列结果,从而减少哈希冲突的发生。
- hashCode()方法和equals()方法的重写
在使用HashMap时,为了保证其正确性和性能,通常需要重写键对象的hashCode()方法和equals()方法。hashCode()方法用于计算键对象的哈希码,而equals()方法用于比较两个对象是否相等。如果两个键对象的哈希码相同,但equals()方法返回false,则会导致哈希冲突的发生。因此,在重写hashCode()方法时,需要保证对于相等的对象其哈希码相等;而在重写equals()方法时,需要保证对于相等的对象其equals()方法返回true。
例如,在自定义类型的键中,可以将键的各个字段的哈希码按照不同的权重组合起来,生成一个唯一的哈希值。同时,重写equals()方法时需要判断两个对象的各个字段是否相等,以确保它们是相等的。
class Person { private String name; private int age; public Person(String name, int age) { this.name = name; this.age = age; } @Override public int hashCode() { return Objects.hash(name, age); } @Override public boolean equals(Object obj) { if (this == obj) return true; if (obj == null || getClass() != obj.getClass()) return false; Person person = (Person) obj; return age == person.age && Objects.equals(name, person.name); } }
- LinkedHashMap的使用
LinkedHashMap是Java集合框架中实现了Map接口的有序哈希表,它具有HashMap的所有特性,并且支持按照插入顺序或者访问顺序遍历键值对。在使用LinkedHashMap时,可以通过构造函数来指定其遍历顺序,并且可以通过覆盖removeEldestEntry()方法来实现缓存淘汰策略。
LinkedHashMap<String, Integer> map = new LinkedHashMap<>(16, 0.75f, true); map.put("tom", 90); map.put("jerry", 85); map.put("bob", 88); System.out.println(map.get("tom")); // 输出90 map.get("jerry"); for (Map.Entry<String, Integer> entry: map.entrySet()) { System.out.println(entry.getKey() + " : " + entry.getValue()); }
在上面这个例子中,创建了一个容量为16、负载因子为0.75、按照访问顺序排序的LinkedHashMap对象。然后依次插入三个键值对,其中“tom”对应的值为90。接着,访问“tom”键,并通过遍历LinkedHashMap来输出所有的键值对,可以看到“tom”的位置已经发生
以上就是Java HashMap详解及实现原理的详细内容,更多关于Java HashMap的资料请关注脚本之家其它相关文章!