承认有些标题党味道,但却在实际异步框架中使用了。
比起“公认”concurrentHashMap方式,提高有3-4倍的性能以及更低cpu占有率
需求
异步框架需要一个buffer,存放请求数据,多线程共享。
显然这是一个多线程并发问题。
同步锁方案
开始小觑了问题,以为只是简单地锁住资源、插入请求对象,都是内存操作,时间短,即使“堵”也不严重。
private void multiThreadSyncLock(final int numofThread,final Map<String,String> map) throws Exception { final long[] errCount=new long[numofThread+1]; Thread t = new Thread(new Runnable() { public void run() { for (int i = 0; i < numofThread; i++) { new Thread(new Runnable() { public void run() { String val=UUID.randomUUID().toString(); String key=Thread.currentThread().getName(); int index=Integer.parseInt(key.substring(7, key.length()))+1; long t1=System.currentTimeMillis(); for(int j=0;j<10000;j++) { synchronized(map) {map.put(key,val);} //获得锁后插入 if(!(val).equals(map.get(key))) errCount[0]++; //errCount >1 表示读出数据和写入不同 } long t2=System.currentTimeMillis(); errCount[index]=+errCount[index]+t2-t1; } }, "Thread-" + i).start(); } } }, "Yhread-main"); t.start(); Thread.currentThread().sleep(1000); t.join(); long tt=0; for(int i=1;i<=numofThread;i++) tt=tt+errCount[i]; log.debug("numofThread={},10,000 per thread,total time spent={}",numofThread,tt); Assert.assertEquals(0,errCount[0]); }
同步锁测试代码
结果惨不忍睹!而且随着并发线程数量增加,“堵”得严重
100并发,每线程申请插 入数据10000次,总耗时 |
200并发,每线程申请插 入数据10000次,总耗时 |
4567.3ms | 20423.95ms |
自旋锁
@Test public void multiThreadPutConcurrentHashMap100() throws Exception{ final Map<String,String> map1=new ConcurrentHashMap<String,String>(512); for(int i=0;i<100;i++) multiThreadPutMap(100,map1); } private void multiThreadPutMap(final int numofThread,final Map<String,String> map) throws Exception { final long[] errCount=new long[numofThread+1]; Thread t = new Thread(new Runnable() { public void run() { for (int i = 0; i < numofThread; i++) { new Thread(new Runnable() { public void run() { String val=UUID.randomUUID().toString(); String key=Thread.currentThread().getName(); int index=Integer.parseInt(key.substring(7, key.length()))+1; long t1=System.currentTimeMillis(); for(int j=0;j<10000;j++) { map.put(key,val); //map的实现concurrentHashMap和HashMap if(!(val).equals(map.get(key))) errCount[0]++; //errCount >1 表示读出数据和写入不同 } long t2=System.currentTimeMillis(); errCount[index]=+errCount[index]+t2-t1; } }, "Thread-" + i).start(); } } }, "Yhread-main"); t.start(); Thread.currentThread().sleep(1000); t.join(); long tt=0; for(int i=1;i<=numofThread;i++) tt=tt+errCount[i]; log.debug("numofThread={},10,000 per thread,total time spent={}",numofThread,tt); Assert.assertEquals(0,errCount[0]); }
自旋锁测试代码
使用concurrentHashMap 100并发,每线程申请插入 数据10000次,耗时 |
使用concurrentHashMap 200并发,每线程申请插入 数据10000次,耗时 |
使用concurrentHashMap 300并发,每线程申请插入 数据10000次,耗时 |
200.69ms | 402.36ms | 542.08ms |
对比同步锁,效率提高很多,约22-50倍,一个数量级的差距!
自旋锁,线程一直在跑,避免了堵塞、唤醒来回切换的开销,而且临界状态一条指令完成,大大提高了效率。
还能进一步优化?
众所周知,hashmap数据结构是数组+链表(参考网上hashMap源码分析)。
简单来说,每次插入数据时:
- 会把给定的key转换为数组指针p
- 如果array[p]为空,将vlaue保存到array[p]=value中,完成插入
- 如果array[p]不为空,建立一链表,插入两个对象,并链表保存到array[p]中。
- 当填充率到默认的0.75,引起扩容。
javadoc中明确指出hashMap非线程安全。
但注意准确地说,不安全在于:
1、key重复,链表插入。
2、扩容,填充率小于0.75
在保证key不重复,应该是key的hash不重复,同时填充率小于0.75情况下,多线程插入/读取时安全的。
所以,可以进一步优化,使用线程名字作为key,把请求数据,插入hashMap中。
避免了锁!
当然有人会问
-
- 线程名字是唯一的?
- 怎么保证不同线程名hash后,对应不同指针?
- 线程会插入多个数据吗?
- hashmap发生扩容怎么办?
- 性能有改进吗?
在回答前,先看看tomcat如何处理请求:请求达到后,tomcat会从线程池中,取出空闲线程,执行filter,最后servlet。
tomcat处理请求有以下特点:
-
- 处理线程,在返回结果前,不能处理新的请求
- 处理线程池通常不大,200-300左右。(现在,更倾向于使用多个“小”规模tomcat实例)
- 线程名字唯一
所以,
问题1、3 显然是OK的
问题4,初始化hasmap时,预先分配一个较大的空间,可以避免扩容。比如对于300并发,new HashMap(512)。
请求虽然多,但是只有300个线程。
问题2,java对hash优化过,保证了hash的均匀度,避免重复。
public void checkHashcodeSpreadoutEnough() { int length=512; for(int j=0;j<10000;j++) { //重复1000次, Map<String,Object> map=new HashMap<String,Object>(length); for(int i=0;i<300;i++) { String key="Thread-"+(i+1); int hashcode=hash(key,length); Integer keyhashcode=new Integer(hashcode); log.debug("key={} hashcode={}",key,hashcode); if(map.containsKey(keyhashcode)) { //生成的hash值作为键保存在hash表,只要重复表示 有冲突 log.error("encounter collisions! key={} hashcode={}",key,hashcode); Assert.assertTrue("encounter collisions!", false); } } } } /* * 从hashMap源码提取的hash值计算方法 * 跟踪代码得知,一般情况下没有使用sun.misc.Hashing.stringHash32((String) k)计算 * 关于stringHash32,网上有评论,有兴趣可以查查。 */ private int hash(Object k,int length) { int h = 0; h ^= k.hashCode(); h ^= (h >>> 20) ^ (h >>> 12); h=h ^ (h >>> 7) ^ (h >>> 4); return h & (length-1); }
上面的代码,对300个线程名进行hash计算,检测是否冲突,并重复运行10000次。
结果表明没有发生冲突。换句话hashMap的hash算法均匀度没有问题,
特别是在本案例环境中,能保证hash唯一!
问题5,性能问题,看单元测试结果
publiwuwu void multiThreadPutHashMap100() throws Exception{ final Map<String,String> map=new HashMap<String,String>(512); //更换map实现为hashMap for(int i=0;i<100;i++) multiThreadPutMap(100,map); } // multiThreadPutMap(100,map); 参见concurrentHashMap单元测试代码
hashMap无锁并发
使用HashMap 100并发,每线程申请插入 数据10000次,耗时 |
使用HashMap 200并发,每线程申请插入 数据10000次,耗时 |
使用HashMap 300并发,每线程申请插入 数据10000次,耗时 |
46.79ms | 99.42ms | 137.03 |
提高4.289164351倍 | 提高4.047073024倍 | 提高3.955922061倍 |
有近3-4倍的提升
结论
- 在一定的情况下,hashMap可以用在多线程并发环境下
- 同步锁,属于sleep-waiting类型的锁。状态发生变化,会引起cpu来回切换。因而效率低下。
- 自旋锁,一直占有cpu,不停尝试,直到成功。有时单核情况下,造成“假死"
- 仔细揣摩,分析,可以找到无“锁”方式来解决多线程并发问题,能获得更高性能和更小开销
另,
在i5-2.5G,8G win10 jdk1.7.0_17,64bit
测试数据, 没有考虑垃圾回收,数据有些波动。
hashMap默认填充因子为0.75,在构造方法中修改。