慎用List中的subList方法

时间:2022-07-17 19:04:09
本期的案例依然是来自实际项目,很寻常的代码,却意外遭遇传说中的Java"内存溢出"。 

    先来看看发生了什么,代码逻辑很简单,在请求的处理过程中: 

1. 创建了一个ArrayList,然后往这个list里面放了一些数据,得到了一个size很大的list 

List cdrInfoList = new ArrayList(); 
for(...) { 
        cdrInfoList.add(cdrInfo); 


2. 从这个list里面,取出一个size很小的sublist(我们忽略这里的业务逻辑) 
        cdrSublist = cdrInfoList.subList(fromIndex, toIndex) 

3. 这个cdrSublist被作为value保存到一个常驻内存的Map中(同样我们忽略这里的业务逻辑) 
        cache.put(key, cdrSublist); 

4. 请求处理结果,原有的list和其他数据被抛弃 

    正常情况下保存到cdrSublist不是太多,其内存消耗应该很小,但是实际上sig的同事们在用JMAP工具检查SIG的内存时,却发现这 里的subList()方法生成的RandomAccessSubList占用的内存高达1.6G! 完全不合符常理。 

    我们来细看subList()和RandomAccessSubList在这里都干了些什么:详细的代码实现追踪过程请见附录1,我们来看关键代码,类SubList的实现代码,忽略不相关的内容 

class SubList<E> extends AbstractList<E> { 
    private AbstractList<E> l; 
    private int offset; 
    private int size; 

    SubList(AbstractList<E> list, int fromIndex, int toIndex) { 
        ...... 
        l = list; 
        offset = fromIndex; 
        size = toIndex - fromIndex; 
    } 

    这里我们可以清楚的看到SubList的实现原理: 

1. 保存一个原始list对象的引用 
2. 用offset和size来表明当前sublist的在原始list中的范围 

      为了让大家有一个感性的认识,我们用debug模式跑了一下测试代码,截图如下: 

慎用List中的subList方法 

       可以看到生成的sublist对象内有一个名为"l"的属性,这是一个ArrayList对象,注意它的id和原有的list对象相同(图中都是id=33)。 

    这种实现方式主要是考虑运行时性能,可以比较一下普通的sublist实现: 

    public List<E> subList(int fromIndex, int toIndex) { 
        List<E> result = ...; // new a empty list 
        for(int i = fromIndex; i <= toIndex; i++) { 
                result.add(this.get(i)); 
        } 
        return result; 
    } 

    这种实现需要创建新的list对象,然后添加所需内容,相比之下无论是内存消耗还是运行效率都不如前面SubList直接引用原始 list+记录偏差量的方式。 

    但是SubList的这种方式,会有一个极大的隐患:这个SubList的实例中,保存有原有list对象的引用——而且是强引用,这意味着, 只要sublist没有被jvm回收,那么这个原有list对象就不能gc,这个list中保存的所有对象也不能gc,即使这个list和其包含的对象已经没有其他任何引用。 

    这个就是Java世界中“内存泄露"的一个经典实例:某些被期望能被JVM回收的对象,却因为某个没有被觉察到的角落中"偷偷的"保留 了一个引用而躲过GC......在SIG的这个例子中,我们本来只想在内存中保留很少很少的一点点数据,被意外的将整个list和它包含的所 有对象都留下来。注意在截图中,list的size为100000,而sublist只是1而已,这就是我们标题中所说的"冰山一角"。 
        
    这里有一段实例代码,大家可以运行一下,很快就可以看到Java世界中名声显赫的OOM: 

public class SublistTest { 
        public static void main(String[] args) { 
                List<List<Integer>> cache = new ArrayList<List<Integer>>(); 

                try { 
                        while (true) { 
                                List<Integer> list = new ArrayList<Integer>(); 
                                for (int j = 0; j < 100000; j++) { 
                                        list.add(j); 
                                } 

                                List<Integer> sublist = list.subList(0, 1); 
                                cache.add(sublist); 
                        } 
                } finally { 
                        System.out.println("cache size = " + cache.size()); 
                } 
        } 


   在我的测试中,打印结果为"cache size = 121",也就是说我的测试中121个list,每个list里面只放了一个Integer对象,就可以吃 掉所有内存,造成out of memory. 

   仔细的同学会发现,其实在sublist()方法的javadoc里面,已经对此有明确的说明,“The returned list is backed by this list” ,因此提醒大家在使用某个不熟悉的方法之前最好读一读Javadoc: 

   Returns a view of the portion of this list between fromIndex, inclusive, and toIndex, exclusive. (If fromIndex and toIndex are equal, the returned list is empty.) The returned list is backed by this list, so changes in the returned list are reflected in this list, and vice-versa. The returned list supports all of the optional list operations supported by this list. 

   同样的,在java中还有一个非常类似的案例,来自最常见的String类,它的substring()方法和split()方法,大家可以翻开jdk 的源码看到具体代码。原理和sublist()方法非常类似,就不重复解释了。 

   简单给出一段代码,演示一下substring()方法在类似情景下是如何OOM的: 

public class SubstringTest { 
        public static void main(String[] args) { 
                List<String> cache = new ArrayList<String>(); 

                try { 
                        int i = 1; 
                        while (true) { 
                                String original = buildABigString(i++); 
                                String substring = original.substring(0, 1); 
                                cache.add(substring); 
                        } 
                } finally { 
                        System.out.println("cache size = " + cache.size()); 
                } 
        } 
        
        private static String buildABigString(int count) { 
                long thistime = System.currentTimeMillis() + count; 
                StringBuilder buf = new StringBuilder(1024 * 100); 
                for(int i = 0; i < 10000; i++) { 
                        buf.append(thistime); 
                } 
                return buf.toString(); 
        } 


    这一次,我的测试用只用了994个长度为1的字符串,就"成功"达到了OOM。 

    最后谈一下怎么解决上面的问题,当然前提是我们有需要将得到的小的list或者string长时间存放在内存中: 

1. 对于sublist()方法得到的list,貌似没有太好的办法,只能用最直接的方式:自己创建新的list,然后将需要的内容添加进去 

2. 对于substring()/split()方法得到的string,可以用String类的构造函数new String(String original)来创建一个新的String,这 样会重新创建底层的char[]并复制需要的内容,不会造成"浪费"。 

    String类的构造函数new String(String original)是一个非常特别的构造函数,通常没有必要使用,正如这个函数的javadoc所言 :Unless an explicit copy of original is needed, use of this constructor is unnecessary since Strings are immutable. 除非明确需要原始字符串的拷贝,否则没有必要使用这个构造函数,因为String是不可变的。 

    但是对于前面的这种特殊场景(从超大字符串中substring()得到后再放置到常驻内存的结构中),new String(String original)就 可以将我们从这种潜在的内存溢出(或者浪费)中拯救出来。因此,当遇到同时处理大字符串+长时间放置内容在内存中时,请小心。 

       最后鸣谢Ray Tao同学为本次分享提供素材! 

附录:List.sublist() 代码实现追踪 

    1. ArrayList的代码,继承自AbstractList,实现了RandomAccess接口 

    public class ArrayList<E> extends AbstractList<E> 
        implements List<E>, RandomAccess, Cloneable, java.io.Serializable 

    2. AbstractList类的subList()函数的代码,对于ArrayList,返回RandomAccessSubList的实例 

    public List<E> subList(int fromIndex, int toIndex) { 
        return (this instanceof RandomAccess ? 
                new RandomAccessSubList<E>(this, fromIndex, toIndex) : 
                new SubList<E>(this, fromIndex, toIndex)); 
    } 

    3. RandomAccessSubList的代码,继承自SubList 

class RandomAccessSubList<E> extends SubList<E> implements RandomAccess { 
    RandomAccessSubList(AbstractList<E> list, int fromIndex, int toIndex) { 
        super(list, fromIndex, toIndex); 
    } 

    public List<E> subList(int fromIndex, int toIndex) { 
        return new RandomAccessSubList<E>(this, fromIndex, toIndex); 
    } 
}