5

高并发服务优化篇:详解一次由读写锁引起的内存泄漏

 3 years ago
source link: https://www.heapdump.cn/article/2599619
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

JVM相关的异常,一直是一线研发比较头疼的问题。因为对于业务代码,JVM的运行基本算是黑盒,当异常发生时,较难直观的看到和找到问题所在,这也是我们一直要研究其内部逻辑的原因。

本篇就由一个近期线上JVM内存泄漏的例子,带大家强行分析一波~

线上服务器报警了

某天,同事来找我帮忙,原来是某系统毫无征兆的来了一连串报警,一波机器的老年代内存占用率超过阈值~

Xnip20210801_124342.jpg

可以看到,在7月中旬之前,内存占用还是比较正常的,每次GC都可以回收掉很大一部分的老年代对象。

而中旬之后,老年代内存一直缓慢增长而无法释放。很明显,应该是对象没法被正常回收导致。

内存泄漏了~

如果是刚上线的项目爆出了此类问题,因为影响面比较小,可以直接先回滚代码,止血为第一要务。

不过,这个项目明显已经上线N多天,中间还不知道上过多少需求,而且,既然流量近期有上涨导致问题出现,说明,已经对客开流量了。

回滚是不可能了,抓紧时间定位问题,上线修复吧。

一般的步骤:

  • 拿到dump文件
  • 用MAT等工具,找出内存占用过多的异常对象,以及引用关系
  • 分析异常对象关联代码的可能问题

不过,因为这次dump下来的文件十多G,太大的,MAT基本无能为力,只能打印出来人工分析了

定位问题代码

jmap结果查看

很幸运,异常对象非常明显。Point对象和GeoDispLocal对象,居然多达好几百万实例数,那就先看下代码中这两个对象是怎么用的。

private static final CacheMap<String, List<GeoDispLocal>> NEAR_DISTRICT_CACHE = new CacheMap<String, List<GeoDispLocal>>(3600 * 1000, 1000);

private static final CacheMap<Integer, Point> LOCAL_POINT_CACHE = new CacheMap<Integer, Point>(3600 * 1000, 6000);

都是被存放在本次缓存CacheMap中(内存泄漏的一个常见原因,就是因为被静态集合持有,无法回收导致),而dump文件中的CacheMap.Entry也是非常高的。

CacheMap就是我们的第一优先怀疑对象了。先看下这个缓存类是怎么回事:

public class CacheMap<K, V> {
    private final long expireMs;
    private LRUMap<K, CacheMap.Entry<V>> valueMap;
    //其他略
}

内部依赖一个带LRU功能的map,怎么实现的呢:

public class LRUMap<K, V> extends LinkedHashMap<K, V> {
    private static final long serialVersionUID = 1L;
    private final int maxCapacity;
    // 这个map不会扩容
    private static final float LOAD_FACTOR = 0.99f;
    private final ReadWriteLock lock = new ReentrantReadWriteLock();

    public LRUMap(int maxCapacity) {
        super(maxCapacity, LOAD_FACTOR, true);
        this.maxCapacity = maxCapacity;
    }

    @Override
    protected boolean removeEldestEntry(java.util.Map.Entry<K, V> eldest) {
        return size() > maxCapacity;
    }

    @Override
    public V get(Object key) {
        try {
            lock.readLock().lock();
            return super.get(key);
        } finally {
            lock.readLock().unlock();
        }
    }

    @Override
    public V put(K key, V value) {
        try {
            lock.writeLock().lock();
            return super.put(key, value);
        } finally {
            lock.writeLock().unlock();
        }
    }
    //remove clear 略
}

内部是一个依赖LinkedHashMap实现的LRU缓存。看注释,目的是要构建一个限定容量、且不会进行扩容的MAP(百度了一波,和网上的实现一模一样~)。那么,实际情况真的和想象中的一样么?。

LinkedHashMap实现的LRUMap好使么

我们来看容量和扩容相关的设置:为什么设计者认为该LRUMap不会进行扩容?

//**把容量和扩容相关的参数摘出来**
//用户期望的最大容量
private final int maxCapacity;
//加载系数
private static final float LOAD_FACTOR = 0.99f;
//构造函数中调用LinkedHashMap进行初始化
super(maxCapacity, LOAD_FACTOR, true);

@Override  //复写删除最久元素条件方法
protected boolean removeEldestEntry(java.util.Map.Entry<K, V> eldest) {
   //当LinkedHashMap.size 比 我们限定容量大时,执行删除
   return size() > maxCapacity;
}

按我们的实际使用实例化一下:

  • maxCapacity=6000,是我们希望的最大元素容量。
  • load_factor=0.99 加载因子。
  • Map内部threshold=8192*0.99=8110,是那么下次扩容时的容量大小。(map中table容量的真实大小是离6000最近的2的N次幂,即8192)。

因为复写了LRU条件函数,当size>6000时会进行LRU替换。因此,理论上,size永远不会达到8110。

怎么解决并发下的读写冲突呢?

//读写锁
private final ReadWriteLock lock = new ReentrantReadWriteLock();
 
public V get(Object key) {
   try {
       lock.readLock().lock();
       return super.get(key);
   } finally {
       lock.readLock().unlock();
   }
}

public V put(K key, V value) {
   try {
      lock.writeLock().lock();
      return super.put(key, value);
   } finally {
      lock.writeLock().unlock();
   }
}

设计者为了解决并发下的读写冲突,给查询和修改方法加了锁,为了兼顾性能,使用了读写锁:在get的时候加读锁,在put/remove的时候加写锁。

看起来,整个设计很好的解决了LRUMap的固定容量和并发操作问题,那么事实是什么样的呢?

其实,这个问题很早就有人分析过了 ,是因为LinkedHashMap在get读操作的时候,会为了维护LRU从而进行元素修改,即将get到的元素转移到链表最后。这样,就导致了读写并发问题,但这个解释感觉朦朦胧胧,因此,我决定在其基础上对读写并发问题再讲细致一些。

LinkedHashMap内存泄漏拆解

都加了读写锁为什么不好使呢?

这里我们还是需要先明确,读写锁的概念和适用场景:读写锁,允许多个线程共享读锁,适用于读多写少的情况。(前提是,读操作不会改变存储结构)

所以,问题就发生在get操作上,LinkedHashMap的get操作被重写,目的是为了实现LRU功能,在get之后,将当前节点移动到链表最后。

移动啊,同志们,这明显是一个写操作,所以,加读锁还有用么?

即允许多线程进入,又进行了修改,那还能起什么作用,能没有并发问题么?

下面,对照节点移动的代码,详细拆解一下多线程下的并发问题:

get之后的节点移动,将节点移动到最后
Xnip20210802_115802.jpg

实际拆解分析如下,为什么在多线程的情况下,会出现内存泄漏:

Xnip20210802_123122.jpg

我们看到,在线程1执行完前两句,让出了时间片,当线程2执行到p.after=null之后又出让了时间片,这样,本来a应该是后面的<2,B>节点,结果多线程下变成了null,最终,后面两个节点被踢出了链表,删除操作无法触达,造成内存泄漏。

验证的代码就不贴了,大家有兴趣可以自己试一下~

话说回来,既然定位到了问题,这个内存泄漏怎么修复呢?

可以把读写锁改成互斥锁。或者直接用分布式存储,能慢多少呢,是不是,既方便,简单,又免得为了节约机器内存自己构造LRUMap。

每一个八股文都不只是为了面试,而是每次线上问题排查的基石。千万别把八股文的作用定位错了。。。

欢迎关注同名微信公众号「Coder的技术之路」一起交流探讨~

推荐阅读:

从RPC预热转发看服务端性能调优
详解RPC的一次调用过程
浅谈数据库连接池


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK