本文将重温这个经典的线程安全问题,并使用一个简单的Java程序演示与并发线程上下文中涉及的普通旧java.util.HashMap数据结构的错误使用有关的风险。
此概念验证练习将尝试实现以下三个目标:
- 重新访问和比较非线程安全和线程安全Map数据结构实现(HashMap,Hashtable,同步的HashMap,ConcurrentHashMap)之间的Java程序性能级别
- 使用每个人都可以编译,运行和理解的简单Java程序,复制并演示HashMap无限循环问题
- 回顾上述Map数据结构在现实和现代Java EE容器实现(例如JBoss AS7)中的用法
有关ConcurrentHashMap实现策略的更多详细信息,我强烈推荐Brian Goetz撰写的出色文章。
工具和服务器规格
首先,请找到以下用于练习的不同工具和软件:
- Sun / Oracle JDK和JRE 1.7 64位
- Eclipse Java EE IDE
- Windows Process Explorer(每个Java线程关联的CPU)
- JVM线程转储(阻塞的线程分析和每个线程的CPU相关性)
以下本地计算机用于问题复制过程和性能测量:
- 英特尔(R)酷睿TM i5-2520M CPU @ 2.50Ghz(2个CPU内核,4个逻辑内核)
- 8 GB内存
- Windows 7 64位
* Java程序的结果和性能可能会因您的工作站或服务器规格而异。
Java程序
为了帮助我们实现上述目标,按如下方式创建了一个简单的Java程序:
- Java主程序是HashMapInfiniteLoopSimulator.java
- 还创建了一个工作线程类WorkerThread.java
该程序正在执行以下操作:
- 初始化大小为2的不同静态Map数据结构
- 将选定的Map分配给工作线程(您可以在4个Map实现中进行选择)
- 创建一定数量的工作线程(根据标头配置)。 为此概念证明创建了3个工作线程NB_THREADS = 3;
- 这些工作线程中的每一个都有相同的任务:使用介于1到1000000之间的随机 Integer元素查找并在分配的Map数据结构中插入新元素。
- 每个辅助线程执行此任务共计500K次迭代
- 整个程序执行50次迭代,以便为HotSpot JVM提供足够的启动时间
- 并发线程上下文是使用JDK ExecutorService实现的
如您所见,Java程序任务相当简单,但是足够复杂以生成以下关键条件:
- 针对共享/静态Map数据结构生成并发
- 混合使用get()和put()操作,以尝试触发内部锁和/或内部损坏(对于非线程安全的实现)
- 使用较小的Map初始大小2,强制内部HashMap触发内部重新哈希/调整大小
最后,可以方便地修改以下参数:
##工作线程数
private static final int NB_THREADS = 3;
## Java程序迭代次数
private static final int NB_TEST_ITERATIONS = 50;
##地图数据结构分配。 您可以选择4种结构
// Plain old HashMap (since JDK 1.2)
threadSafeMap1 = new Hashtable(2);
// Plain old Hashtable (since JDK 1.0)
threadSafeMap1 = new Hashtable(2);
// Fully synchronized HashMap
threadSafeMap2 = new HashMap(2);
threadSafeMap2 = Collections.synchronizedMap(threadSafeMap2);
// ConcurrentHashMap (since JDK 1.5)
threadSafeMap3 = new ConcurrentHashMap(2);
/*** Assign map at your convenience ****/
assignedMapForTest = threadSafeMap3;
现在,在下面找到我们示例程序的源代码。
#### HashMapInfiniteLoopSimulator.java
package org.ph.javaee.training4;
import java.util.Collections;
import java.util.Map;
import java.util.HashMap;
import java.util.Hashtable;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* HashMapInfiniteLoopSimulator
* @author Pierre-Hugues Charbonneau
*
*/
public class HashMapInfiniteLoopSimulator {
private static final int NB_THREADS = 3;
private static final int NB_TEST_ITERATIONS = 50;
private static Map assignedMapForTest = null;
private static Map nonThreadSafeMap = null;
private static Map threadSafeMap1 = null;
private static Map threadSafeMap2 = null;
private static Map threadSafeMap3 = null;
/**
* Main program
* @param args
*/
public static void main(String[] args) {
System.out.println("Infinite Looping HashMap Simulator");
System.out.println("Author: Pierre-Hugues Charbonneau");
System.out.println("http://javaeesupportpatterns.blogspot.com");
for (int i=0; i(2);
// Plain old Hashtable (since JDK 1.0)
threadSafeMap1 = new Hashtable(2);
// Fully synchronized HashMap
threadSafeMap2 = new HashMap(2);
threadSafeMap2 = Collections.synchronizedMap(threadSafeMap2);
// ConcurrentHashMap (since JDK 1.5)
threadSafeMap3 = new ConcurrentHashMap(2); // ConcurrentHashMap
/*** Assign map at your convenience ****/
assignedMapForTest = threadSafeMap3;
long timeBefore = System.currentTimeMillis();
long timeAfter = 0;
Float totalProcessingTime = null;
ExecutorService executor = Executors.newFixedThreadPool(NB_THREADS);
for (int j = 0; j < NB_THREADS; j++) {
/** Assign the Map at your convenience **/
Runnable worker = new WorkerThread(assignedMapForTest);
executor.execute(worker);
}
// This will make the executor accept no new threads
// and finish all existing threads in the queue
executor.shutdown();
// Wait until all threads are finish
while (!executor.isTerminated()) {
}
timeAfter = System.currentTimeMillis();
totalProcessingTime = new Float( (float) (timeAfter - timeBefore) / (float) 1000);
System.out.println("All threads completed in "+totalProcessingTime+" seconds");
}
}
}
#### WorkerThread.java
package org.ph.javaee.training4;
import java.util.Map;
/**
* WorkerThread
*
* @author Pierre-Hugues Charbonneau
*
*/
public class WorkerThread implements Runnable {
private Map map = null;
public WorkerThread(Map assignedMap) {
this.map = assignedMap;
}
@Override
public void run() {
for (int i=0; i<500000; i++) {
// Return 2 integers between 1-1000000 inclusive
Integer newInteger1 = (int) Math.ceil(Math.random() * 1000000);
Integer newInteger2 = (int) Math.ceil(Math.random() * 1000000);
// 1. Attempt to retrieve a random Integer element
Integer retrievedInteger = map.get(String.valueOf(newInteger1));
// 2. Attempt to insert a random Integer element
map.put(String.valueOf(newInteger2), newInteger2);
}
}
}
线程安全的Map实现之间的性能比较
第一个目标是比较使用不同线程安全的Map实现时我们程序的性能水平:
- 普通的旧哈希表(自JDK 1.0起)
- 完全同步的HashMap(通过Collections.synchronizedMap())
- ConcurrentHashMap(自JDK 1.5起)
在下面找到每个迭代的Java程序执行的图形结果以及程序控制台输出示例。
#使用ConcurrentHashMap时的输出
Infinite Looping HashMap Simulator
Author: Pierre-Hugues Charbonneau
http://javaeesupportpatterns.blogspot.com
All threads completed in 0.984 seconds
All threads completed in 0.908 seconds
All threads completed in 0.706 seconds
All threads completed in 1.068 seconds
All threads completed in 0.621 seconds
All threads completed in 0.594 seconds
All threads completed in 0.569 seconds
All threads completed in 0.599 seconds
………………
如您所见,ConcurrentHashMap在这里显然是赢家,所有3个工作线程平均仅花费半秒(在初始启动后)就可以针对指定的共享Map并在500K循环语句中同时读取和插入数据。 请注意,程序执行没有问题,例如没有挂起情况。
性能的提高肯定是由于ConcurrentHashMap性能的提高,例如无阻塞的get()操作。
其他2个Map实现的性能水平非常相似,但对于同步的HashMap而言却具有很小的优势。
HashMap无限循环问题复制
下一个目标是复制从Java EE生产环境中经常观察到的HashMap无限循环问题。 为此,您只需要按照下面的代码片段分配非线程安全的HashMap实现即可:
/*** Assign map at your convenience ****/
assignedMapForTest = nonThreadSafeMap;
使用非线程安全的HashMap按原样运行程序应导致:
- 除程序头外无输出
- 从系统观察到的CPU大量增加
- Java程序有时会挂起,您将被迫杀死Java进程
发生了什么? 为了了解这种情况并确认问题,我们将使用Process Explorer和JVM Thread Dump从Windows操作系统执行每个线程的CPU分析。
1 –再次运行程序,然后按照以下方法从Process Explorer快速捕获每个CPU数据的线程。 在explorer.exe下,您需要右键单击javaw.exe并选择属性。 将显示“线程”选项卡。 我们可以看到几乎所有系统CPU都使用了4个线程。
2 –现在,您必须使用JDK 1.7 jstack实用程序快速捕获JVM线程转储。 对于我们的示例,我们可以看到我们的3个工作线程,它们似乎忙/忙于执行get()和put()操作。
..\jdk1.7.0\bin>jstack 272
2012-08-29 14:07:26
Full thread dump Java HotSpot(TM) 64-Bit Server VM (21.0-b17 mixed mode):
"pool-1-thread-3" prio=6 tid=0x0000000006a3c000 nid=0x18a0 runnable [0x0000000007ebe000]
java.lang.Thread.State: RUNNABLE
at java.util.HashMap.put(Unknown Source)
at org.ph.javaee.training4.WorkerThread.run(WorkerThread.java:32)
at java.util.concurrent.ThreadPoolExecutor.runWorker(Unknown Source)
at java.util.concurrent.ThreadPoolExecutor$Worker.run(Unknown Source)
at java.lang.Thread.run(Unknown Source)
"pool-1-thread-2" prio=6 tid=0x0000000006a3b800 nid=0x6d4 runnable [0x000000000805f000]
java.lang.Thread.State: RUNNABLE
at java.util.HashMap.get(Unknown Source)
at org.ph.javaee.training4.WorkerThread.run(WorkerThread.java:29)
at java.util.concurrent.ThreadPoolExecutor.runWorker(Unknown Source)
at java.util.concurrent.ThreadPoolExecutor$Worker.run(Unknown Source)
at java.lang.Thread.run(Unknown Source)
"pool-1-thread-1" prio=6 tid=0x0000000006a3a800 nid=0x2bc runnable [0x0000000007d9e000]
java.lang.Thread.State: RUNNABLE
at java.util.HashMap.put(Unknown Source)
at org.ph.javaee.training4.WorkerThread.run(WorkerThread.java:32)
at java.util.concurrent.ThreadPoolExecutor.runWorker(Unknown Source)
at java.util.concurrent.ThreadPoolExecutor$Worker.run(Unknown Source)
at java.lang.Thread.run(Unknown Source)
..............
现在该按照以下方法将Process Explorer线程ID DECIMAL格式转换为HEXA格式。 HEXA值使我们可以按照以下方式映射和标识每个线程:
## TID:1748(nid = 0X6D4)
- 线程名称:pool-1-thread-2
- CPU @ 25.71%
- 任务:工作线程执行HashMap.get()操作
at java.util.HashMap.get(Unknown Source)
at org.ph.javaee.training4.WorkerThread.run(WorkerThread.java:29)
at java.util.concurrent.ThreadPoolExecutor.runWorker(Unknown Source)
at java.util.concurrent.ThreadPoolExecutor$Worker.run(Unknown Source)
at java.lang.Thread.run(Unknown Source)
## TID:700(nid = 0X2BC)
- 线程名称:pool-1-thread-1
- CPU @ 23.55%
- 任务:工作线程执行HashMap.put()操作
at java.util.HashMap.put(Unknown Source)
at org.ph.javaee.training4.WorkerThread.run(WorkerThread.java:32)
at java.util.concurrent.ThreadPoolExecutor.runWorker(Unknown Source)
at java.util.concurrent.ThreadPoolExecutor$Worker.run(Unknown Source)
at java.lang.Thread.run(Unknown Source)
## TID:6304(nid = 0X18A0)
- 线程名称:pool-1-thread-3
- CPU @ 12.02%
- 任务:工作线程执行HashMap.put()操作
at java.util.HashMap.put(Unknown Source)
at org.ph.javaee.training4.WorkerThread.run(WorkerThread.java:32)
at java.util.concurrent.ThreadPoolExecutor.runWorker(Unknown Source)
at java.util.concurrent.ThreadPoolExecutor$Worker.run(Unknown Source)
at java.lang.Thread.run(Unknown Source)
## TID:5944(nid = 0X1738)
- 线程名称:pool-1-thread-1
- CPU @ 20.88%
- 任务:主Java程序执行
"main" prio=6 tid=0x0000000001e2b000 nid=0x1738 runnable [0x00000000029df000]
java.lang.Thread.State: RUNNABLE
at org.ph.javaee.training4.HashMapInfiniteLoopSimulator.main(HashMapInfiniteLoopSimulator.java:75)
如您所见,上面的相关性和分析非常有启发性。 我们的主要Java程序处于挂起状态,因为我们的3个工作线程正在占用大量CPU,并且无法正常运行。 它们在执行HashMap get()和put()时可能看起来“卡住”,但实际上它们都涉及无限循环条件。 这正是我们想要复制的内容。
HashMap无限循环深入探究
现在,让我们进一步分析,以更好地了解这种循环条件。 为此,我们在JDK 1.7 HashMap Java类本身中添加了跟踪代码,以了解正在发生的情况。 为put()操作添加了类似的日志记录,还添加了一条跟踪,指示内部和自动重新哈希/调整大小已触发。
在get()和put()操作中添加的跟踪使我们能够确定for()循环是否正在处理循环依赖关系,这将解释无限循环条件。
#### HashMap.java get() operation
public V get(Object key) {
if (key == null)
return getForNullKey();
int hash = hash(key.hashCode());
/*** P-H add-on- iteration counter ***/
int iterations = 1;
for (Entry e = table[indexFor(hash, table.length)];
e != null;
e = e.next) {
/*** Circular dependency check ***/
Entry currentEntry = e;
Entry nextEntry = e.next;
Entry nextNextEntry = e.next != null?e.next.next:null;
K currentKey = currentEntry.key;
K nextNextKey = nextNextEntry != null?(nextNextEntry.key != null?nextNextEntry.key:null):null;
System.out.println("HashMap.get() #Iterations : "+iterations++);
if (currentKey != null && nextNextKey != null ) {
if (currentKey == nextNextKey || currentKey.equals(nextNextKey))
System.out.println(" ** Circular Dependency detected! ["+currentEntry+"]["+nextEntry+"]"+"]["+nextNextEntry+"]");
}
/***** END ***/
Object k;
if (e.hash == hash && ((k = e.key) == key || key.equals(k)))
return e.value;
}
return null;
}
HashMap.get() #Iterations : 1
HashMap.put() #Iterations : 1
HashMap.put() #Iterations : 1
HashMap.put() #Iterations : 1
HashMap.put() #Iterations : 1
HashMap.resize() in progress...
HashMap.put() #Iterations : 1
HashMap.put() #Iterations : 2
HashMap.resize() in progress...
HashMap.resize() in progress...
HashMap.put() #Iterations : 1
HashMap.put() #Iterations : 2
HashMap.put() #Iterations : 1
HashMap.get() #Iterations : 1
HashMap.get() #Iterations : 1
HashMap.put() #Iterations : 1
HashMap.get() #Iterations : 1
HashMap.get() #Iterations : 1
HashMap.put() #Iterations : 1
HashMap.get() #Iterations : 1
HashMap.put() #Iterations : 1
** Circular Dependency detected! [362565=362565][333326=333326]][362565=362565]
HashMap.put() #Iterations : 2
** Circular Dependency detected! [333326=333326][362565=362565]][333326=333326]
HashMap.put() #Iterations : 1
HashMap.put() #Iterations : 1
HashMap.get() #Iterations : 1
HashMap.put() #Iterations : 1
.............................
HashMap.put() #Iterations : 56823
再次,添加的日志记录非常有启发性。 我们可以看到,在几个内部HashMap.resize()之后,内部结构受到了影响,创建了循环依赖条件,并触发了这个无限循环条件(#iterations不断增加和增加……)而没有退出条件。
这也表明resize()/ rehash操作最容易遭受内部损坏,尤其是当使用默认的HashMap大小16时。这意味着HashMap的初始大小似乎是造成风险的重要因素。问题复制。
最后,有趣的是,我们能够通过将初始大小设置为1000000来成功运行非线程安全HashMap的测试用例,从而完全避免了任何调整大小。 在合并图结果下方找到:
HashMap是我们表现最好的,但是仅在防止内部调整大小时才使用。 同样,这绝对不是解决线程安全风险的方法,而只是一种方法,表明考虑到当时执行的HashMap的整个操作,调整大小操作的风险最大。
到目前为止,ConcurrentHashMap是我们的整体赢家,因为它针对该测试用例提供了快速的性能和线程安全性。
JBoss AS7 Map数据结构用法
现在,我们将通过研究现代Java EE容器实现(例如JBoss AS 7.1.2)中的不同Map实现来结束本文。 您可以从github master分支获取最新的源代码。
在报告下方找到:
- JBoss AS7.1.2 Java文件总数(2012年8月28日快照):7302
- 使用java.util.Hashtable的Java类总数:72
- 使用java.util.HashMap的Java类总数:512
- 使用同步的HashMap的Java类总数:18
- 使用ConcurrentHashMap的Java类总数:46
哈希表引用主要在测试套件组件中以及命名和与JNDI相关的实现中找到。 这种低使用率在这里不足为奇。
从512个Java类中找到了对java.util.HashMap的引用。 考虑到自从最近几年以来这种实现方式的普及程度,这再次不足为奇。 但是,重要的是要提到,从局部变量(未在线程间共享),同步的HashMap或手动同步防护措施中找到了很好的比率,因此“技术上”使线程安全,并且不会暴露于上述无限循环条件(待处理/隐藏的错误)考虑到Java并发编程的复杂性,这仍然是一个现实……涉及Oracle Service Bus 11g的案例研究就是一个很好的例子)。
发现JMS,EJB3,RMI和集群等软件包中只有18个Java类,使用的同步HashMap使用率较低。
最后,在下面找到ConcurrentHashMap用法的细分,这是我们主要的兴趣所在。 正如您将在下面看到的那样,关键的JBoss组件层(例如Web容器,EJB3实现等)使用此Map实现。
## JBoss单点登录
用于管理涉及并发线程访问的内部SSO ID
合计:1
## JBoss Java EE和Web容器
这并不奇怪,因为许多内部Map数据结构用于管理http会话对象,
部署注册表,群集和复制,统计信息等,并发线程访问量大。 总数:11
## JBoss JNDI和安全层
由高度并发的结构(例如内部JNDI安全管理)使用。
合计:4
## JBoss域和受管服务器管理,推出计划…
合计:7
## JBoss EJB3
由数据结构使用,例如文件计时器持久性存储,应用程序异常,实体Bean缓存,序列化,钝化…
合计:8
## JBoss内核,线程池和协议管理
由高并发线程数映射数据结构使用,这些数据结构涉及处理和分派/处理传入请求(例如HTTP)。
合计:3
## JBoss连接器,例如JDBC / XA DataSources…
合计:2
## Weld(JSR-299的参考实现:JavaTM EE平台的上下文和依赖注入)用于ClassLoader和涉及并发线程访问的并发静态Map数据结构的上下文。
合计:3
## JBoss测试套件用于某些集成测试用例,例如内部数据存储,ClassLoader测试等。
合计:3
最后的话
我希望本文能帮助您重新研究这个经典问题,并理解与错误使用非线程安全HashMap实现有关的常见问题和风险之一。 我的主要建议是在并发线程上下文中使用HashMap时要小心。 除非您是Java并发专家,否则我建议您改用ConcurrentHashMap,它在性能和线程安全性之间提供了很好的平衡。
像往常一样,总是建议进行额外的尽职调查,例如执行负载和性能测试周期。 这将使您能够在将解决方案推广到客户生产环境之前检测线程安全和/或性能问题。
参考: Java 7:我们的JCG合作伙伴 Pierre-Hugues Charbonneau的HashMap与ConcurrentHashMap ,位于Java EE支持模式和Java教程博客。
翻译自: https://www.javacodegeeks.com/2012/08/java-7-hashmap-vs-concurrenthashmap.html