HashMap与ConcurrentHashMap的内存占用与get操作性能比较

一、测试背景

        项目中需要提供一个单机计算视频相似度的服务,计算的方式是对视频标题进行分词,提取关键词,然后通过word2vec的方式对关键词进行embedding,最后通过向量累加得到视频的词向量,然后通过某种相似度算法(比如欧式距离)得到视频相似度。这个服务要求5ms返回,可行性预研阶段需要估算响应时间能否达到要求,需要多少台机器支撑每天50亿的请求量。这里面有两个关键内容需要估算,一个是响应时间是多少,能否达到5ms的要求;另一个是每个服务需要多少存储空间(从而估算一台64G的机器能跑几个实例,这里假定一个实例能支撑的QPS是已知的)。这个服务中占用内存最大的是存储80万的词向量,时间消耗比较大的地方也是词向量的读取。

二、实现思路

         每次计算视频相似度需要大概查询150次词向量(50个视频乘以3个关键词),大量的时间都花在词向量查询,如果使用分布式缓存进行存储词向量,都不能够达到预期,所以初步设计是存在内存中,使用HashMap进行存储,Map的key是关键词,value是一个float数组。我们从生成好的80万词向量文件中随机抽取了10%的数据约81000行,样例文件大小225M,数据样例如下:


现在需要对程序使用内存进行预估,来决定申请机器的台数。这块使用了两种方法来预估内存:第一种通过使用java.lang.Runtime.freeMemory()方法来进行粗略的估计,在读取文件到Map前进行调用一次freeMemory方法,然后在数据填充进Map后再调用一次freeMemory,使用第一次的值减去第二次值,就可以得到HashMap近似的内存大小。注意使用这样方法进行预估时,一定要将运行jvm虚拟机-Xms 与-Xmx设置相等。具体代码和运行结果如下:

计算结果HashMap对象占用内存为83M,生成HasMap对象总共占用了约324M内存。

        第二种方法是将生成好的HashMap对象序列化到本地生成文件,查看文件大小近似估计HashMap所占的空间,生成文件大小为95M。

通过上述两种方法测试分析得到,加载10%的数据到HashMap对象时大约需要400M的内存空间,实际保存HashMap需要约100M的内存,依次类推加载所有数据HashMap不超过1.5G,加载HashMap使用内存到的总内存不会超过2G,加上相关性计算及多线程访问需要的内存大约每个实例预估使用4G内存。由于HashMap是线程不安全的,所以每次重新加载数据时需要先临时生成一个HashMap对象将新的数据加载进临时对象中,加载完毕之后将引用对象指向临时生成的HashMap,所以在重新加载对象时使用的内存是实际存储HashMap空间的2倍,也就是一个实例最终需要6G的内存,其中有2G的内存在平时属于空闲状态只有在数据重新加载时才会使用。这样实现很大程度会浪费很多内存空间,增加机器台数,增加了投入成本。实现代码如下:


       因为是HashMap是线程不安全,所以采用了以上实现。如果使用线程安全的ConcurrentHashMap在数据重新加载不需要以上操作节省一个Map存储的内存。使用同样代码对ConcurrentHashMap进行测试,得到ConcurrentHashMap存储所占用的内存约为84M,生成的序列化文件大小为95.3M与HashMap占用内存空间基本一致。现在唯一的问题是确定ConcurrentHashMap读取效率,是否能满足要求,网上查找资料时发现没有比较HashMap和ConcurrentHashMap在多线程下get操作的耗时对比,所以做了以下实验。

三、HashMap与ConcurrentHashMap多线程get操作对比

测试环境

CPU:Intel(R) Xeon(R)CPU E5-2620 0 @ 2.00GHz, 2个物理cpu,每个cpu包含6核心,每个核心4个线程

Jdk1.7版本:jdk1.7.0_80

Jdk1.6版本:jdk1.6.0_45

测试方法

       使用相同的词向量文件分别构造HashMap与ConcurrentHashMap,分别使用1个,12个,24个,48个线程,每个线程循环进行1000000次的get操作达到模拟高并发下的查询,记录测试时间;同样的程序使用jdk1.6与jdk1.7分别进行测试比较耗时。测试代码如下:

测试结果

测试结果如下表所示(每个线程请求1000000次):

 

jdk1.7

jdk1.6

线程数

HashMap

ConcurrentHashMap

HashMap

ConcurrentHashMap

1

42ms

48ms

46ms

62ms

12

52ms

62ms

62ms

76ms

24

70ms

84ms

83ms

96ms

48

137ms

159ms

139ms

174ms


      使用jdk1.7测试结果来看ConcurrentHashMap比HashMap慢20%, HashMap的get操作需要42ns,ConcurrentHashMap的get操作需要48ns。从jdk两个版本对比来看,jdk1.7比jdk1.6大约快20%左右。从测试结果来看,使用ConcurrentHashMap代替HashMap完全没有问题。

结果分析

      ConcurrenHashMap使用了锁分段技术,将Hash表默认分为16个段(桶),每一个段上加一把锁,如果一个段被锁不会影响其他段的线程访问。ConcurrenHashMap 具体是由Segment数组和HashEntry数组构成的。每个Segment都可以理解为是一个HashTable,Segment包含一个HashEntry数组,HashEntry是一个链表结构,每一个Segment守护一个HashEntry数组,要对HashEntry数组操作时必须首先取得Segment的锁,才能更改HashEntry数组中的数据。读取数据是先需要找到数据所在的Segment,然后再在HashEntry数组中找到具体的HashEntry对象,然后从头开始访问链表,找到相同key的返回该对象的值,找不到查next对象的key值是否相同,一直查询到链表结束。从Jdk1.7 ConcurrentHashMap get方法源码可以看出CocurrentHashMap比HashMap进行get操作时,多进行了一次Hash来得到Segment,得到Segment后的操作与HashMap的get方法基本一致,通过一次hash找到HashEntry在数组中的位置,然后从头遍历该链表。CocurrentHashMap在取得Segment和HashEntry时使用了sun.misc.Unsafe类中提供的方法,Unsafe类提供的硬件级别的原子操作,调用操作系统底层提高性能。jdk1.7 CocurrentHashMap与jdk1.6的访问速度差异主要在于,jdk1.6如果在HashEntry中找多对用的key的值,如果值为null会加锁再读一次,而jdk1.7大量使用了Unsafe类提供的方法来提高性能。jdk1.7 CocurrentHashMap与jdk1.6的访问速度差异主要在于,jdk1.6如果在HashEntry中找多对用的key的值,如果值为null会加锁再读一次,而jdk1.7大量使用了Unsafe类提供的方法来提高性能。

参考资料:

http://ifeve.com/sun-misc-unsafe/

http://www.blogjava.net/stevenjohn/archive/2015/03/15/423475.html

http://www.infoq.com/cn/articles/ConcurrentHashMap

http://www.cnblogs.com/ITtangtang/p/3948786.html


在 Java 8 及之后版本中,`HashMap` 和 `ConcurrentHashMap` 是两种常用的哈希表实现,但它们的并发控制机制和设计目标有显著差异。以下是它们的详细对比,重点分析 `ConcurrentHashMap` 如何控制并发。 --- ## **1. HashMap 的核心特性(Java 8+)** ### **1.1 数据结构** - **数组 + 链表 + 红黑树**: - 哈希表的主结构是一个**节点数组**(`Node<K,V>[] table`)。 - 每个数组槽位(Bucket)是一个**单向链表**,当链表长度超过阈值(`TREEIFY_THRESHOLD = 8`)时,会转换为**红黑树**(提升查询效率)。 - 红黑树节点为 `TreeNode`,继承自 `Node`。 ### **1.2 非线程安全** - `HashMap` 是**非线程安全**的,多线程并发操作可能导致: - **数据不一致**:如 `put` 和 `get` 同时操作时,可能读取到部分更新的数据。 - **死循环**:在扩容时(`resize()`),多线程可能形成链表环,导致 `get` 操作陷入死循环(Java 8 之前更常见)。 - **丢失更新**:两个线程同时 `put` 相同键,可能导致一个线程的写入被覆盖。 ### **1.3 Java 8 的优化** - **红黑树优化**:链表长度超过 8 时转为红黑树,查询时间从 O(n) 降到 O(log n)。 - **扩容优化**:使用**异步扩容**(转移数据时允许并发读写),减少锁竞争。 - **无锁读取**:`get()` 操作无需加锁,通过 `volatile` 变量保证可见性。 --- ## **2. ConcurrentHashMap 的核心特性(Java 8+)** ### **2.1 设计目标** - **线程安全**:支持高并发读写,避免全局锁。 - **高性能**:通过**分段锁(Java 7)**或**CAS + synchronized(Java 8)**减少锁粒度。 - **弱一致性**:迭代器反映的是创建时的状态,不保证实时性(避免强一致性带来的性能开销)。 ### **2.2 数据结构(Java 8+)** - **数组 + 链表 + 红黑树**: - `HashMap` 类似,但每个槽位(Bucket)的锁粒度更细。 - 节点类型包括: - `Node`:普通链表节点。 - `TreeNode`:红黑树节点。 - `ForwardingNode`:扩容时使用的占位节点。 - `ReservedNode`:保留节点(用于未来扩展)。 ### **2.3 并发控制机制** #### **2.3.1 CAS + synchronized 锁** - **写操作(`put`、`remove`)**: 1. **定位槽位**:通过哈希计算确定数组索引。 2. **CAS 尝试插入**:如果槽位为空,尝试用 CAS 插入新节点。 3. **synchronized 锁槽位**:如果 CAS 失败(槽位非空),则对槽位的头节点加 `synchronized` 锁,再操作链表或树。 - **示例代码**: ```java final V putVal(K key, V value, boolean onlyIfAbsent) { if (key == null || value == null) throw new NullPointerException(); int hash = spread(key.hashCode()); // 扰动函数,减少哈希冲突 for (Node<K,V>[] tab = table;;) { Node<K,V> f; int n, i, fh; if (tab == null || (n = tab.length) == 0) tab = initTable(); // 初始化表 else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) { if (casTabAt(tab, i, null, new Node<>(hash, key, value))) break; // CAS 插入新节点 } else if ((fh = f.hash) == MOVED) // 扩容中 tab = helpTransfer(tab, f); else { V oldVal = null; synchronized (f) { // 对槽位头节点加锁 if (tabAt(tab, i) == f) { // 链表或树的操作 if (fh >= 0) { // 链表 // 遍历链表,更新或插入 } else if (f instanceof TreeNode) { // 红黑树 // 树的操作 } } } if (oldVal != null) return oldVal; break; } } addCount(1L, binCount); // 更新元素计数 return null; } ``` #### **2.3.2 扩容优化** - **多线程协助扩容**: - 当某个线程发现表正在扩容(`ForwardingNode`),会通过 `helpTransfer()` 协助扩容。 - 扩容时,原表分成多个段(默认 16),每个线程负责一部分数据的转移。 - **扩容过程**: 1. 创建新数组(大小为原数组的 2 倍)。 2. 将原数组的槽位标记为 `ForwardingNode`(包含新数组的引用)。 3. 线程在 `put` 或 `get` 时,如果遇到 `ForwardingNode`,会帮助转移数据到新数组。 #### **2.3.3 读操作(`get`)** - **无锁读取**: - `get()` 操作无需加锁,直接通过哈希定位槽位。 - 如果槽位是 `ForwardingNode`,则从新数组中读取。 - 通过 `volatile` 变量保证节点的可见性。 #### **2.3.4 计数优化** - **LongAdder 机制**: - `size()` 操作通过维护多个 `CounterCell` 减少竞争。 - 更新时随机选择一个 `CounterCell` 进行 CAS 操作,统计时累加所有 `CounterCell` 的值。 --- ## **3. ConcurrentHashMap 的并发控制细节** ### **3.1 锁粒度** - **槽位锁(Bucket Lock)**: - Java 8 之前使用**分段锁(Segment)**,每个 Segment 是一个独立的哈希表,锁粒度为 Segment。 - Java 8 之后改为**槽位锁**,每个槽位的头节点加 `synchronized` 锁,锁粒度更细(通常 16-64 个槽位共享一个锁的时代已过去)。 ### **3.2 CAS 操作** - **无竞争优化**: - 如果槽位为空,直接通过 CAS 插入新节点,避免锁开销。 - 例如 `tabAt()` 和 `casTabAt()` 是基于 `Unsafe` 的原子操作。 ### **3.3 内存可见性** - **volatile 变量**: - 节点的 `val` 和 `next` 字段用 `volatile` 修饰,保证多线程下的可见性。 - 例如: ```java static class Node<K,V> implements Map.Entry<K,V> { final int hash; final K key; volatile V val; // volatile 保证可见性 volatile Node<K,V> next; // volatile 保证链表遍历的正确性 // ... } ``` ### **3.4 失败重试** - **自旋优化**: - 在 `put` 或 `remove` 时,如果 CAS 失败或锁竞争激烈,会通过自旋重试(`for (;;)` 循环)。 --- ## **4. ConcurrentHashMap性能对比** | **场景** | **HashMap** | **ConcurrentHashMap** | |------------------------|---------------------------|----------------------------------| | **单线程读** | O(1)(无锁) | O(1)(无锁) | | **单线程写** | O(1) | O(1)(CAS + 槽位锁) | | **多线程读** | 可能数据不一致 | 无锁,线程安全 | | **多线程写** | 可能死循环/丢失更新 | 槽位锁,高并发 | | **扩容** | 阻塞所有操作 | 多线程协助扩容,减少停顿时间 | | **内存占用** | 更低(无额外同步结构) | 更高(`volatile`、`CounterCell`)| --- ## **5. 实际应用场景** ### **5.1 ConcurrentHashMap 的适用场景** - **高并发读写**:如缓存、计数器、会话管理。 - **弱一致性要求**:如统计指标、日志聚合。 - **避免全局锁**:需要比 `Hashtable` 或 `Collections.synchronizedMap` 更高的并发性能。 ### **5.2 HashMap 的适用场景** - **单线程环境**:如局部变量、单线程应用。 - **读多写少**:且对线程安全无要求。 - **内存敏感**:需要最小化哈希表开销。 --- ## **6. 总结** | **关键点** | **HashMap** | **ConcurrentHashMap** | |----------------------|---------------------------------------|--------------------------------------| | **线程安全** | 非线程安全 | 线程安全 | | **并发控制** | 无 | CAS + 槽位锁 + 协助扩容 | | **数据结构** | 数组 + 链表 + 红黑树 | 数组 + 链表 + 红黑树 | | **读性能** | O(1)(无锁) | O(1)(无锁) | | **写性能** | O(1)(非线程安全) | O(1)(CAS + 槽位锁) | | **扩容** | 阻塞所有操作 | 多线程协助扩容 | | **内存开销** | 更低 | 更高(`volatile`、同步结构) | **最佳实践**: 1. **优先使用 `ConcurrentHashMap`**:除非明确知道是单线程环境,否则避免使用 `HashMap` 的多线程场景。 2. **避免频繁扩容**:预估数据量,初始化时指定合适容量(`new ConcurrentHashMap<>(1024)`)。 3. **合理选择并发级别**:Java 8 的 `ConcurrentHashMap` 已自动优化锁粒度,无需手动配置。 --- ### **
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值