linux那些事之我是usb_集合类之List那些事

    在日常工作中,线性表相关的数据结构算是比较常用的了,而其中的链表与数组这两个数据结构的出镜率则更高。它们往往能高效地管理和操作一批类型相同的数据,比如从数据库读取一批数据列表,准备一批数据来批量发送消息等众多场景都有它们的身影。在Java里面,常用的有ArrayList和LinkedList这两个列表的实现,今天我们就来聊聊这两个常用的数据结构。
    从名字可以看出来, ArrayList其实就是数组,它是动态数组实现,能自动扩容;而LinkedList是双向链表的实现,所以它是不需要扩容的。

知行合一,探索ArrayList和LinkedList

网上搜索一下数组和链表的时间复杂度,大多会有以下的结论:

  • 数组,随机元素访问的时间复杂度是 O(1),元素插入、删除操作是 O(n)

  • 链表,随机元素访问的时间复杂度是 O(n),元素插入、删除操作是 O(1)

于是基于这个理论,(不考虑线程安全的前提下)我们一般会在读多写少的时候选择ArraList,而在大量插入,随机读少的场景下使用LinkedList。那么事实真的是这样子的么?我们不妨写个简单的测试代码对比一下。

public class ListCompare {    private final static int TOTAL = 50000;    private final static int LOOP = 50000;    private static LinkedList prepareLinkedList() {        LinkedListlist = new LinkedList<>();        for (int i = 0; i < TOTAL; i++) {            list.add(i);        }        return list;    }    private static ArrayList prepareArrayList() {        ArrayListlist = new ArrayList<>();        for (int i = 0; i < TOTAL; i++) {            list.add(i);        }        return list;    }    private static void linkedListRandomAdd() {        LinkedListlist = prepareLinkedList();        long start = System.currentTimeMillis();        for (int i = 0; i < LOOP; i++) {            list.add(ThreadLocalRandom.current().nextInt(TOTAL), -1);        }        System.out.printf("%s:%d(ms)\n", "LinkedList随机插入耗时", System.currentTimeMillis() - start);    }    private static void arrayListRandomAdd() {        ArrayListlist = prepareArrayList();        long start = System.currentTimeMillis();        for (int i = 0; i < LOOP; i++) {            list.add(ThreadLocalRandom.current().nextInt(TOTAL), -1);        }        System.out.printf("%s:%d(ms)\n", "ArrayList随机插入耗时", System.currentTimeMillis() - start);    }    private static void linkedListRandomGet() {        LinkedListlist = prepareLinkedList();        long start = System.currentTimeMillis();        for (int i = 0; i < LOOP; i++) {            list.get(ThreadLocalRandom.current().nextInt(TOTAL));        }        System.out.printf("%s:%d(ms)\n", "LinkedList随机读耗时", System.currentTimeMillis() - start);    }    private static void arrayListRandomGet() {        ArrayListlist = prepareArrayList();        long start = System.currentTimeMillis();        for (int i = 0; i < LOOP; i++) {            list.get(ThreadLocalRandom.current().nextInt(TOTAL));        }        System.out.printf("%s:%d(ms)\n", "ArrayList随机读耗时", System.currentTimeMillis() - start);    }    private static void arrayListForEach() {        ArrayListlist = prepareArrayList();        long start = System.currentTimeMillis();        for (int i = 0; i < LOOP; i++) {            list.get(i);        }        System.out.printf("%s:%d(ms)\n", "ArrayList顺序读耗时", System.currentTimeMillis() - start);    }    private static void linkedListForEach() {        LinkedListlist = prepareLinkedList();        long start = System.currentTimeMillis();        for (int i = 0; i < LOOP; i++) {            list.get(i);        }        System.out.printf("%s:%d(ms)\n", "LinkedList顺序读耗时", System.currentTimeMillis() - start);    }    public static void main(String[] args) {        arrayListRandomAdd();        linkedListRandomAdd();        arrayListRandomGet();        linkedListRandomGet();        arrayListForEach();        linkedListForEach();    }}

运行结果如下:

ArrayList随机插入耗时:476(ms)LinkedList随机插入耗时:8547(ms)ArrayList随机读耗时:6(ms)LinkedList随机读耗时:1409(ms)ArrayList顺序读耗时:2(ms)LinkedList顺序读耗时:1248(ms)

    在随机读和顺序读方面,ArrayList占压倒性优势,这个是在意料之中的事情,至于为什么顺序读会比随机读更快一些,我们后面再做解释;但是想不到的是,随机插入情况下,LinkedList居然也完败!是不是人生第一次开始对大O表示法产生怀疑了?其实从算法维度给的O(1)指的是插入这个原子操作的时间复杂度是O(1),也就是说,你已经知道了要插入节点位置的指针,那么从这个维度来计算,它确实是O(1)的。可是,实际使用场景中,是不可能知道你的随机位置的指针的,这个时候不得不从链表头指针开始遍历,直到找到这个要插入的位置节点,所以在最坏的情况下,需要O(n)的前置操作。所以在同样的场景下,LinkedList并不见得能优秀多少。其实,做更多的实验的话,你会发现LinkedList在众多场景下并不见得比ArrayList有优势。这也就是为什么Redis要使用跳表来实现有序集合而不是直接使用链表,因为跳表可以将查询的复杂度降低到O(logn),这是个非常优秀的时间复杂度了,对于链表,只要搜索速度提高了,整体性能就会高起来。    对于这个问题,我个人也是工作几年后才开始抱着怀疑的态度去做实验观察,发现结果大跌眼镜,结合源码分析了一下,才发现问题所在。所以还是那句话,尽信书则不如无书。学习一定要知行合一,从书上学到的知识,要付诸实践后,才会成为你的知识。其实后来我特地去翻阅了一下大学的《数据结构》课本(王晓东版,我们院长 >_< ),才发现人家压根就没跟你讲,链表的插入是O(1)……

6b66d9935344bf0c3de2cdec0f87f082.png

    如书上所说,的这种O(K)复杂度还是比较靠谱的(k表示插入的位置)。
所以,对于ArrayList和LinkedList在使用过程中,一定要注意使用场景,其实大部分场景下,ArrayList都是表现特别优秀了,我能想到的几种场景使用LinkedList是:

  • 只会进行头部插入数据,这时候相当于插入位置已知,而数组则需要O(n)移动所有元素,1.8以前的HashMap链表就是头插

  • 内存紧张则碎片化严重的场合,链表的好处就是不需要连续的地址空间,所以只要内存还有空闲,它就可以添加元素,数组则不然,每次扩容都需要一段连续的地址空间,如果碎片化严重,那么可能明明还有空间,却初始化失败了

    回到之前的测试结果,我们发现顺序遍历数组速度要比随机遍历来得好一丢丢,原因是数组是连续的内存空间,而CPU读内存时做了优化,并不是需要哪个读哪个,而是读“一行”并写入CPU调整缓存,所以顺序读的时候,因为后续的内存数据已经进入缓存了,不需要再做访存的操作,故速度会比随机读来得快一些。

警惕subList有可能带来OOM

先来看一段简单的代码:

public class SubListOOM {    private static List> data = new ArrayList<>();    private static ArrayListprepareArrayList(int total) {        ArrayList list = new ArrayList<>();        for (int i = 0; i < total; i++) {            list.add(i);        }        return list;    }    public static void main(String[] args) {        for (int i = 0; i < 1000; i++) {            List rawList = prepareArrayList(10000);            data.add(rawList.subList(0, 1));        }    }}

很简单,对一个10000个元素的数组subList出一个,然后放到data里面去,重复这个操作1000次。
我们对上面这个代码添加jvm参数 

-Xmx32m -XX:-UseGCOverheadLimit -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/Users/funcas/oom

限制它的最大内存为32M(这个内存来说,对于1000个元素是够用的)并且设置在发生OOM时,输出dump文件。运行后,不出料地OOM了。

Exception in thread "main" java.lang.OutOfMemoryError: Java heap space    at java.lang.Integer.valueOf(Integer.java:832)    at com.funcas.parctice.collection.SubListOOM.prepareArrayList(SubListOOM.java:20)    at com.funcas.parctice.collection.SubListOOM.main(SubListOOM.java:27)

于是我们拿HA来分析下dump出来的堆快照

e1115945f8f8d578b82313a67356d1ef.png

可以发现,人家直接给你推测出ArrayList发生了内存泄露,再仔细查看下,发现rawList的数量有30个之多。按正常情况下,rawList应该是会被GC回收的,现在没有被回收,说明一直被强引用着,所以最终怀疑是subList搞的鬼。于是我们查看下subList的实现:

public ListsubList(int fromIndex, int toIndex) {    subListRangeCheck(fromIndex, toIndex, size);    return new SubList(this, 0, fromIndex, toIndex);}//...省略无数代码private class SubList extends AbstractList<E> implements RandomAccess {        private final AbstractList parent;        private final int parentOffset;        private final int offset;        int size;        SubList(AbstractList parent,                int offset, int fromIndex, int toIndex) {            this.parent = parent;            this.parentOffset = fromIndex;            this.offset = offset + fromIndex;            this.size = toIndex - fromIndex;            this.modCount = ArrayList.this.modCount;        }    // 省略无数代码...}

    豁然开朗了,subList出来的并不是普通的List,而是ArrayList自己维护的一个SubList内部类,并且SubList内部会引用ArrayList实例。所以元凶就找到了,大量地强引用subList实例会让原始的ArrayList得不到释放,从而导致内存泄露而引发OOM。

    解决方案也很简单,不要直接使用返回的SubList,而是做一个深拷贝出来一个新的ArrayList即可。

for (int i = 0; i < 1000; i++) {    List rawList = prepareArrayList(10000);    // 修复    data.add(new ArrayList(rawList.subList(0, 1)));}

慎用Arrays.asList()

    这个api是当年写android在官方demo上学到的,用来模拟ListView的数据,把一串数组内容直接转成List传给adapter,用起来特别爽。但是这玩意真的是从入门到放弃,后面慢慢地转用guava包了。下面就讲讲这个api的几个注意事项,这里不作大量展开,因为这个api估计现在也少有人用了。

  • 参数不能用基本数据类型,如:int[],这会导致出来的是一个只包含一个int数组的List,需要使用包装类型才可以按预期输出

  • 转出来的List是个只读List,对其进行添加元素会抛异常,原因是这个List是Arrays的一个内部类,并没有实现add()方法

  • 转换出来的List与原始数组共享,所以修改原始数组将影响List的内容,要排除这个影响,可以自己new ArrayList(Arrays.asList(array)),这个解决方案同样适用于第二个问题。

    所以其实并不省多少事情,反而多了不少问题,日常工作中,建议还是使用guava的Lists工具类来实现,优雅直观,省出来的时候多陪陪老婆孩子。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值