在日常工作中,线性表相关的数据结构算是比较常用的了,而其中的链表与数组这两个数据结构的出镜率则更高。它们往往能高效地管理和操作一批类型相同的数据,比如从数据库读取一批数据列表,准备一批数据来批量发送消息等众多场景都有它们的身影。在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)……
如书上所说,的这种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出来的堆快照
可以发现,人家直接给你推测出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工具类来实现,优雅直观,省出来的时候多陪陪老婆孩子。