最近调优一个多线程使用共享Map对象本地缓存性能问题。原有实现背景为Map对象存储从Redis加载的数据,如果对应Redis数据为空,需要调用Redis加载逻辑,这段逻辑封装在一个更新数据方法,并且加了同步锁,实现线程安全。
示例代码:private Map<String,Object> cachMap = Maps.newHashMap();public synchronized void updateCache(Map<String,Object> getNewMap){ cachMap.clear(); cachMap.putAll(getNewMap); } public synchronized Map<String,Object> getCache(){ return cachMap; }
我们都知道synchronized是实现共享对象同步处理的首选解决方法,在处理线程较少资源竞争下情况下确实性能不会有太大影响,但是现在我们面临的场景是读取cache的请求增加了10倍左右,写入数据操作基本维持不变,应用性能明显下降,排查发现synchronized是问题之一,因为它导致我们的cache访问变成串行,影响整体链路并发执行效率。
private ReadWriteLock cacheRWLock = new ReentrantReadWriteLock(); public void updateCache(Map<String,Object> getNewMap){ try { cacheRWLock.writeLock().lock();//获取写锁,唯一一个线程持有 cachMap.clear(); cachMap.putAll(getNewMap); } catch (Exception e) { // TODO: handle exception }finally{ cacheRWLock.writeLock().unlock();//释放写锁 } } public Map<String,Object> getCache(){ try { cacheRWLock.readLock().lock();//获取读锁,多个线程可共享 return cachMap; } catch (Exception e) { // TODO: handle exception }finally{ cacheRWLock.readLock().unlock();//释放读锁,以免影响写锁 } return null; }
分析代码示例,变更共享对象Map前获取写锁,如果获取不到线程等待。获取到写锁后 开始数据操作,数据操作之后要释放写锁。而读取对象Map前获取读锁,同一时刻可以有多个线程获取读锁从而达到并行的目的。对于同一个线程,如果获取了写锁,同样可以获取读锁,也就是API描述的可重入性。读锁和写锁在ReadWriteLock 采用了不同的锁机制,具体原理不表,API有详细描述。
2. 将HashMap替换为ConcurrentHashMap,实现细粒度线程安全同时提升并发性能。
从场景中分析可知,我们需要处理的共享对象是HashMap实例,HashMap类在并发场景下非线程安全。但util.concurrent提供了线程安全的ConcurrentHashMap实现,这个类的实现采用了是细粒度的hash segment维度的同步机制,也就是不同的segment对应不同的锁,通过这种方式可以保持同步互斥下的并发处理能力。具体我们分析下ConcurrentHashMap的clear和putAll实现源码。
public void clear() { final Segment<K,V>[] segments = this.segments; for (int j = 0; j < segments.length; ++j) { Segment<K,V> s = segmentAt(segments, j); if (s != null) s.clear(); }}static final <K,V> Segment<K,V> segmentAt(Segment<K,V>[] ss, int j) { long u = (j << SSHIFT) + SBASE; return ss == null ? null : (Segment<K,V>) UNSAFE.getObjectVolatile(ss, u); }
Clear的实现过程是遍历map对象所有的segments,针对每个segment持有的volatile 定义的锁对象判断是否有同步锁,如果都没命中可以执行清理。
public V put(K key, V value) { Segment<K,V> s; if (value == null) throw new NullPointerException(); int hash = hash(key); int j = (hash >>> segmentShift) & segmentMask; if ((s = (Segment<K,V>)UNSAFE.getObject (segments, (j << SSHIFT) + SBASE)) == null) s = ensureSegment(j); return s.put(key, hash, value, false); }
PutAll的实现基础是put方法,基本原理是针对传入数据key做hash,然后针对命中的segment判断是否能获取同步锁,如果能获取分配segments写入,否则等待。
对比两种方案性能总体相差不大,测试case不表。
对于使用读写锁优化并发性能场景基于读取量大于变更量的前提,如果读取量跟变更量基本持平情况下就需要根据业务具体情况采用相应的策略,那有没有通用的解决方案?
这种除了沿用Syncronized关键字实现重量级锁,也可以采用volatile 实现内存共享实例,但是频繁操作内存的成本较高不太适合。那是否可以结合volatile 和 ReentrantLock 实践CopyOnWrite机制,实现CopyOnWriteArrayList相似功能(java.util.concurrent 没有实现CopyOnWriteMap类,一直比较费解)。CopyOnWriteMap一种实现方式(源码来源mongo-java-driver)可以拿出来分析一下,假设需要实现场景需要的clear和putAll方法代码如下:
private volatile M delegate;// volatile定义的同步Map实例private final transient Lock lock = new ReentrantLock(); //定义读写锁
基于上述两个关键同步变量定义,clear方法优先获取锁,然后采用置换方法,把空Map对象置换变更delegate存储的Map,基于volatile导致当前线程把数据从缓存中写入内存中导致其他线程缓存失效的特性,保持数据变更的同步。
public final void clear() { lock.lock(); try { set(copy(Collections.<K, V> emptyMap())); } finally { lock.unlock(); } }
PutAll方法通用是获取读写锁,然后将写入delegate存储的旧Map对象copy出来,同时把新传入的对象copy到中间Map对象,最后将中间Map对象写入delegate。对应的delegate同步实现也交给JVM完成。操作完成后释放读写锁。
public final void putAll(final Map<? extends K, ? extends V> t) { lock.lock(); try { final M map = copy(); map.putAll(t); set(map); } finally { lock.unlock(); } }
从这个实现分析,CopyOnWriteMap非常依赖volatile存储的对象,而我们的缓存肯定是有限制的,所以这种方案存在一定局限性:需要限制处理Map大小,在使用前需要评估预估存储量,否则过大map容易导致内存溢出。
联系客服