Java中的Map集合:从HashMap到ConcurrentHashMap

目录

1. Map接口简介

2. HashMap

2.1 基本特性

2.2 内部实现

2.3 性能考虑

2.4 代码示例

2.5 HashMap的扩容机制

扩容过程

扩容时机

2.6 HashMap的put操作流程

2.7 为什么选择0.75作为默认负载因子

3. Hashtable

3.1 特性

3.2 与HashMap的比较

3.3 使用场景

4. ConcurrentHashMap

4.1 特性

4.2 实现原理

4.3 性能优势

4.4 代码示例

5. LinkedHashMap

5.1 特性

5.2 实现原理

5.3 用途

5.4 代码示例

6. 其他Map实现

6.1 TreeMap

6.2 WeakHashMap

6.3 IdentityHashMap

7. 使用场景比较

8. 总结


1. Map接口简介

在Java集合框架中,Map接口占据着重要的位置。Map用于存储键值对(key-value pairs),其中每个键都是唯一的。Map接口定义了一系列操作这些键值对的方法,如添加、删除、获取等。

主要的Map方法包括:

  • put(K key, V value): 添加键值对

  • get(Object key): 根据键获取值

  • remove(Object key): 移除键值对

  • containsKey(Object key): 检查是否包含某个键

  • containsValue(Object value): 检查是否包含某个值

  • size(): 返回Map的大小

  • isEmpty(): 检查Map是否为空

2. HashMap

HashMap是Map接口最常用的实现之一,它提供了高效的插入和查找操作。

2.1 基本特性

  • 允许null键和null值

    • 为什么ConcurrentHashMap不允许:他是专门为并发场景设计的Map实现,当我们get(key)操作返回null时,存在歧义:是key压根就不存在,还是该key存在,只是它的值为null。在单线程环境下,可以通过containsKey进行进一步判断,而在并发场景下,调用containsKey和get操作之间可能有其他线程对该key进行了修改。

  • 不保证顺序

  • 非同步(线程不安全)

  • 初始容量和负载因子影响其性能

2.2 内部实现

HashMap在Java 8之前使用数组+链表实现,Java 8及以后使用数组+链表+红黑树实现。

  • 哈希函数: HashMap使用键的hashCode()方法计算哈希值,然后通过哈希函数将其映射到数组索引。

  • 解决冲突: 当多个键映射到同一个数组索引时,HashMap使用链表(或红黑树)来存储这些元素。

  • 树化: 在Java 8中,当链表长度超过阈值(链表节点数为8,且哈希数组长度大于64)时,链表会转换为红黑树,以提高查找效率。

    • 链表节点数为8,如果哈希数组长度小于64则不会进行树化而是进行扩容操作,主要是为了避免频繁树化和减少内存占用

    • 当链表节点数小于6时,红黑树又会退化为链表,保持简单高效。设置为6而不是8时为了留个缓存空间,避免反复横跳

2.3 性能考虑

  • 初始容量和负载因子(0.75)是影响HashMap性能的两个重要参数。

  • 合理设置这两个参数可以减少rehash操作,提高性能。

2.4 代码示例

Map<String, Integer> map = new HashMap<>();
map.put("apple", 1);
map.put("banana", 2);
System.out.println(map.get("apple")); // 输出: 1

2.5 HashMap的扩容机制

HashMap的扩容是一个重要的操作,它直接影响HashMap的性能。当HashMap中的元素数量超过负载因子与当前容量的乘积时,就会触发扩容操作。

扩容过程
  1. 创建新数组: 创建一个新的数组,其容量是原数组的两倍。

  2. 重新哈希: 将原数组中的所有元素重新计算哈希值,并放入新数组中。

  3. 链表重排: 对于链表结构,需要重新排列链表中的元素(重新计算元素在哈希数组中的位置)。

  4. 树化或反树化: 在Java 8及以后版本中,还需要考虑红黑树的处理。

扩容时机

扩容发生在添加新元素后,如果满足以下条件:

if (++size > threshold)
    resize();

其中,threshold是触发扩容的阈值,通常等于容量 * 负载因子

2.6 HashMap的put操作流程

put操作是HashMap中最常用的操作之一,其流程如下:

  1. 计算哈希值: 对key调用hashCode()方法,并进行处理以减少碰撞;

  2. 确定桶位置: 根据哈希值确定在哈希数组中的位置 (哈希值与数组长度-1进行&运算);

  3. 初始化:如果数组是空的,则调用 resize 进行初始化;

  4. 查找或插入:

    1. 如果桶为空,直接插入新节点。

    2. 如果桶不为空,遍历链表或红黑树:

      1. 如果找到相同的key,更新value。

      2. 如果没找到,在链表末尾添加新节点或将新节点插入红黑树。

  5. 树化检查: 判断该链表是否大于 8 ,如果大于 8 并且数组容量小于 64,就进行扩容;如果链表节点大于 8 并且数组的容量大于 64,则将这个结构转换为红黑树;
  6. 扩容检查: 插入新节点后,检查是否需要扩容。

另外要注意:hashCode方法和equals方法应同时进行重写,如果只重写了hashCode,那么在插入操作时,相同对象能够得到一样的哈希数组的位置,但是在进行对象比较时,相同对象会被认为是不同对象,从而导致hashMap中存储了两个一摸一样的对象。equals默认是比较对象的引用是否相等,即==运算比较。

2.7 为什么选择0.75作为默认负载因子

首先看看注释的解释:

大概的意思是:作为一般规则,默认负载因子(0.75)在时间和空间成本上提供了很好的折衷。较高的值会降低空间开销,但提高查找成本(体现在大多数的HashMap类的操作,包括get和put)。设置初始大小时,应该考虑预计的entry数在map及其负载系数,并且尽量减少rehash操作的次数。如果初始容量大于最大条目数除以负载因子,rehash操作将不会发生。 

再详细地解释:HashMap的默认负载因子是0.75,这个值是经过权衡得出的最佳值,原因如下:

  1. 空间和时间的平衡:

    1. 较高的负载因子会减少空间开销,但会增加查找成本。

    2. 较低的负载因子会增加空间开销,但会减少查找成本。

    3. 0.75提供了一个很好的平衡点。

  2. 避免碰撞:
    1. 根据统计学原理,当负载因子为0.75时,哈希冲突的概率较低。

    2. 这意味着大多数桶中的元素数量会比较少,从而提高访问效率。

  3. 减少扩容频率:
    1. 0.75允许HashMap在扩容前存储更多元素,减少了扩容操作的频率。

    2. 这有助于提高整体性能,因为扩容是一个耗时的操作。

  4. 泊松分布:
    1. 在负载因子为0.75时,根据泊松分布,桶中元素个数为8的概率很小。

    2. 这就是为什么选择8作为将链表转换为红黑树的阈值。

3. Hashtable

Hashtable是Java早期提供的一个同步的Map实现。

3.1 特性

  • 同步(线程安全)

  • 不允许null键或null值(原因参考前面提到过的)

  • 性能较HashMap差(因为同步)

3.2 与HashMap的比较

  1. 同步性: Hashtable是同步的,HashMap不是。

  2. Null值: Hashtable不允许null键和值,HashMap允许。

  3. 性能: 在单线程环境下,HashMap通常优于Hashtable。

  4. 迭代器: HashMap的迭代器是fail-fast的,而Hashtable的枚举器不是。

3.3 使用场景

虽然Hashtable是线程安全的,但在现代Java应用中,通常推荐使用ConcurrentHashMap来代替Hashtable,因为ConcurrentHashMap提供了更好的并发性能(ConcurrentHashMap锁的粒度更小)。

4. ConcurrentHashMap

ConcurrentHashMap是专门为并发场景设计的Map实现。

4.1 特性

  • 线程安全

  • 高并发性能

  • 不允许null键也不允许null值

  • 分段锁设计(Java 7及之前)或CAS+Synchronized(Java 8及以后)

4.2 实现原理

  • Java 7: 使用分段锁(Segment)实现,将Map分为多个部分,每个部分独立加锁。

  • Java 8+: 使用CAS和synchronized来保证并发安全,synchronized只锁定当前链表或红黑树的首节点

4.3 性能优势

  • 读操作完全并发(volatile关键字)

  • 写操作局部锁定

  • 迭代不需要加锁

4.4 代码示例

Map<String, Integer> concurrentMap = new ConcurrentHashMap<>();
concurrentMap.put("one", 1);
concurrentMap.put("two", 2);
System.out.println(concurrentMap.get("one")); // 输出: 1

5. LinkedHashMap

LinkedHashMap是HashMap的一个子类,它在HashMap的基础上维护了一个双向链表,用于保持插入顺序或访问顺序。

5.1 特性

  • 维护插入顺序或访问顺序(accessOrder,默认是false按照插入顺序,true按照访问顺序排序,即LRU)

  • 允许null键和null值

  • 性能略低于HashMap(因为要维护链表)

5.2 实现原理

LinkedHashMap在HashMap的基础上,为每个条目(Entry)增加了before和after指针,这些指针将所有条目连接成一个双向链表。

5.3 用途

  1. 需要按插入顺序遍历Map时

  2. 实现LRU(Least Recently Used)缓存

5.4 代码示例

Map<String, Integer> linkedMap = new LinkedHashMap<>();
linkedMap.put("one", 1);
linkedMap.put("two", 2);
linkedMap.put("three", 3);
for (Map.Entry<String, Integer> entry : linkedMap.entrySet()) {
    System.out.println(entry.getKey() + ": " + entry.getValue());
}
// 输出:
// one: 1
// two: 2
// three: 3

6. 其他Map实现

6.1 TreeMap

  • 基于红黑树实现

  • 保持键的自然顺序或指定的比较器顺序

  • 适用于需要按键排序的场景

6.2 WeakHashMap

  • 键是弱引用

  • 当键不再被强引用时,相应的条目会被自动移除

  • 常用于缓存场景

6.3 IdentityHashMap

  • 使用引用相等(==)而不是equals()来比较键

  • 主要用于特殊场景,如序列化或深度复制

7. 使用场景比较

  1. HashMap: 适用于大多数单线程场景,需要高效的插入和查找操作。

  2. ConcurrentHashMap: 适用于高并发场景,需要线程安全的Map实现。

  3. LinkedHashMap: 需要保持插入顺序或实现LRU缓存时使用。

  4. TreeMap: 需要按键排序的场景。

  5. Hashtable: 已被ConcurrentHashMap取代,不推荐使用。

  6. WeakHashMap: 实现缓存,其中键对象可能会被垃圾回收。

8. 总结

Java提供了丰富的Map实现,每种实现都有其特定的用途和性能特征。在选择使用哪种Map实现时,需要考虑以下因素:

  1. 是否需要线程安全

  2. 键值对的数量和访问模式

  3. 是否需要保持特定的顺序

  4. 内存使用和垃圾回收的考虑

另外:

Java中的List集合:从ArrayList到CopyOnWriteArrayList

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值