在java集合类中,不管是谁一定都用过如下两种集合。通常我们呢只是会用就行了,记住ArrayList 查询快,增删慢,LinkList刚好相反 查询慢增删快,即可,但是实际真的是这样吗? 下面我们做一个测试,使用的是JDK11
public static void main(String[] args) {
ArrayList list1 = new ArrayList<>(10000000);
LinkedList list2 = new LinkedList<>();
int num = 10000000;
long tim1 = System.currentTimeMillis();
for (int i = 0; i < num; i++) {
list1.add(i);
}
long tim2 = System.currentTimeMillis();
list1.add(2,2);
long tim3 = System.currentTimeMillis();
list1.add(num-1,num-1);
long tim4 = System.currentTimeMillis();
System.out.println("ArrayList 插入"+num+"条数据耗时:"+String.valueOf(tim2-tim1));
System.out.println("ArrayList 头部插入数据耗时:"+String.valueOf(tim3-tim2));
System.out.println("ArrayList 尾部插入数据耗时:"+String.valueOf(tim4-tim3));
long tim5 = System.currentTimeMillis();
for (int i = 0; i < num; i++) {
list2.add(i);
}
long tim6 = System.currentTimeMillis();
list2.add(2,2);
long tim7 = System.currentTimeMillis();
list2.add(num-1,num-1);
long tim8 = System.currentTimeMillis();
System.out.println("LinkedList 插入"+num+"条数据耗时:"+String.valueOf(tim6-tim5));
System.out.println("LinkedList 头部插入数据耗时:"+String.valueOf(tim7-tim6));
System.out.println("LinkedList 尾部插入数据耗时:"+String.valueOf(tim8-tim6));
// 查找。取出
long tim9 = System.currentTimeMillis();
list1.get(num/num);
long tim10 = System.currentTimeMillis();
System.out.println("ArrayList 头部取出数据耗时:"+String.valueOf(tim10-tim9));
list1.get(num / 2);
long tim11 = System.currentTimeMillis();
System.out.println("ArrayList 中部取出数据耗时:"+String.valueOf(tim11-tim10));
list1.get(num);
long tim12 = System.currentTimeMillis();
System.out.println("ArrayList 尾部取出数据耗时:"+String.valueOf(tim12-tim11));
list2.get(num/num);
long tim13 = System.currentTimeMillis();
System.out.println("LinkedList 头部取出数据耗时:"+String.valueOf(tim13-tim12));
list2.get(num / 2);
long tim14 = System.currentTimeMillis();
System.out.println("LinkedList 中部取出数据耗时:"+String.valueOf(tim14-tim13));
list2.get(num);
long tim15 = System.currentTimeMillis();
System.out.println("LinkedList 尾部取出数据耗时:"+String.valueOf(tim15-tim14));
}
我们将Arraylist 的初始容量定位10000000,这样做的目的是为了先不考虑Arraylist扩容带来的时间损耗。
经我测试n=100000以下基本看不出来什么差别,当num= 1000000时。
ArrayList 插入1000000条数据耗时:24
ArrayList 头部插入数据耗时:1
ArrayList 尾部插入数据耗时:0
LinkedList 插入1000000条数据耗时:141
LinkedList 头部插入数据耗时:0
LinkedList 尾部插入数据耗时:0
ArrayList 头部取出数据耗时:0
ArrayList 中部取出数据耗时:1
ArrayList 尾部取出数据耗时:0
LinkedList 头部取出数据耗时:0
LinkedList 中部取出数据耗时:6
LinkedList 尾部取出数据耗时:0
可以明显的看到,LinkList的,添加数据明显变慢。再将
num=10000000时候。
ArrayList 插入10000000条数据耗时:267
ArrayList 头部插入数据耗时:45
ArrayList 尾部插入数据耗时:0
LinkedList 插入10000000条数据耗时:1583
LinkedList 头部插入数据耗时:0
LinkedList 尾部插入数据耗时:0
ArrayList 头部取出数据耗时:0
ArrayList 中部取出数据耗时:1
ArrayList 尾部取出数据耗时:0
LinkedList 头部取出数据耗时:0
LinkedList 中部取出数据耗时:39
LinkedList 尾部取出数据耗时:1
可以看到差距十分明显了。再将num设置的更大,我的电脑会卡住。
所以我们可以得到下面的几条结论,在数据量较大的情况下
在这里我测试了插入数据,没测试删除数据,是因为两者效果是一样的我们后面再说。
所以和我么记住的规则并非完全相同。下面我们从源码来看为什么会这样。
数组:一段连续的内存空间。元素挨个存放,通过下标获取元素。
链表: 元素一节点的形式存在,每个节点除了存储本身信息,还存储前一个,后一个(单向链表只存储后一个,或者前一个,双向链表二者都存储)元素信息。存储空间由添加元素的时候操作系统给分配。可能连续可能不连续。
在清楚了,链表和数组的区别之后,继续往下看。
ArrayList 顺序添加
public boolean add(E e) {
++this.modCount;
this.add(e, this.elementData, this.size); // 调用下面方法
return true;
}
private void add(E e, Object[] elementData, int s) {
if (s == elementData.length) {
elementData = this.grow();
}
elementData[s] = e;
this.size = s + 1;
}
通过源码我们可以看到,当向数组最后添加一个元素的时候,只需要判断一下是否需要扩容,如果不需要则,直接赋值即可。如果我们将默认容量设置为10000000,所以是不需要扩容的。扩容的情况我们呢后面再说。
LinkedList顺序添加
public boolean add(E e) {
this.linkLast(e); // 调用下面函数
return true;
}
void linkLast(E e) {
LinkedList.Node l = this.last;
LinkedList.Node newNode = new LinkedList.Node(l, e, (LinkedList.Node)null);
this.last = newNode;
if (l == null) {
this.first = newNode;
} else {
l.next = newNode;
}
++this.size;
++this.modCount;
}
可以看到LinkedList 每次添加元素的时候,需要先获取到最后一个节点,然后创建一个新的节点,然后将最后一个节点的next指向当前创建的新的节点,再将当前节点的标记为最后一个节点。之前看到网上说,linkedList 插入慢是因为linkedList 每次插入都需要通过遍历链表,找到尾节点。但是看来,至少在JDK11中看来并不是这个原因。
我个人的理解是 LinkedList的数据结构是链表,它存储空间是不连续的,每次添加一个节点,都需要向操作系统申请分配一个节点大小的存储空间。而 ArrayList 的空间是已经分配好的。如果往一个集合中添加n个元素,那么不考虑扩容的情况下,Arraylist 只会申请一次内存空间分配,而且是在初始化后就已经分配好了,但是LinkedList 却需要申请n次。
上面的添加元素默认都是在尾部添加,我们现在看在中间插入的情况。
Arraylist
public void add(int index, E element) {
this.rangeCheckForAdd(index);
++this.modCount;
int s;
Object[] elementData;
if ((s = this.size) == (elementData = this.elementData).length) {
elementData = this.grow();
}
System.arraycopy(elementData, index, elementData, index + 1, s - index);
elementData[index] = element;
this.size = s + 1;
}
ArrayList 在指定位置添加元素,也是首先判断是否需要扩容,我们默认先不扩容。
通过System.arraycopy()函数将从当前位置的index后面的元素全部后移一个。然后将index 当前位置赋值为添加的元素。这个慢主要慢在移动这里,虽然这个函数具体实现是由jvm实现的,但是实现的原理也是通过遍历循环index后面的指针,挨个重新赋值。所以元素越多。插入的位置月靠前。需要移动的元素就越多,耗时自然也就越长。
LinkedList 为什么快
public void add(int index, E element) {
this.checkPositionIndex(index);
if (index == this.size) {
this.linkLast(element);
} else {
this.linkBefore(element, this.node(index)); // 调用下方代码。
}
}
void linkBefore(E e, LinkedList.Node succ) {
LinkedList.Node pred = succ.prev;
LinkedList.Node newNode = new LinkedList.Node(pred, e, succ);
succ.prev = newNode;
if (pred == null) {
this.first = newNode;
} else {
pred.next = newNode;
}
++this.size;
++this.modCount;
}
LinkedList 在向链表中间插入元素的时候,只需要创建一个新节点,将index的前一个节点的next只想当前节点,当前节点的next指向原先的index+1 节点即可。并没有循环遍历的操作。所以快,,并且对于链表来说,无论是在头尾还是中间,添加节点,都是一样的。 这就是LinkedList快的原因。
我们在测试中,没有测试删除,是因为删除也是相同的操作。
Arraylist 删除元素,需要将删除元素后面的元素挨个前移一位。所以也是需要循环便利。
linkedList的删除就简单了,直接将删除位置的前一个节点的next指向删除的节点的后面一个元素即可,并将删除节点赋值为null,以便gc回收。。所以删除和添加是同理。
Arraylist
public E get(int index) {
Objects.checkIndex(index, this.size);
return this.elementData(index);
}
Arraylist 根据下标直接从数组中取回下标返回元素。所以无论哪个位置都很快
LinkedList
public E get(int index) {
this.checkElementIndex(index);
return this.node(index).item; // 调用下面函数
}
LinkedList.Node node(int index) {
LinkedList.Node x;
int i;
if (index < this.size >> 1) {
x = this.first;
for(i = 0; i < index; ++i) {
x = x.next;
}
return x;
} else {
x = this.last;
for(i = this.size - 1; i > index; --i) {
x = x.prev;
}
return x;
}
}
LinkedList 获取元素的时候,先判断index < size/2 如果,成立,则从头开始找,否则从尾开始找。因为链表不能直接通过下标取出元素,每个节点只有前一个节点,当前节点,下一个节点。 所以就得循环便利,来便利到指定位置将元素返回。所以LinedList 获取元素就成了,首尾快,中间慢。因为首尾需要遍历的次数是最少的,最中间的位置需要遍历的次数最多。
我们上面的测试结果,是在Arraylist初始化的时候就设置了足够大的初始容量。所以在添加元素的过程中没有经过扩容,但是我们在实际用的过程中,我们不知道可能会有多少数据。设置的太大了浪费内存,设置小的数据量太大会多次扩容。如下是源码中扩容部分的代码
private Object[] grow(int minCapacity) {
return this.elementData = Arrays.copyOf(this.elementData, this.newCapacity(minCapacity));
}
private Object[] grow() {
return this.grow(this.size + 1);
}
private int newCapacity(int minCapacity) {
int oldCapacity = this.elementData.length;
int newCapacity = oldCapacity + (oldCapacity >> 1);
if (newCapacity - minCapacity <= 0) {
if (this.elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
return Math.max(10, minCapacity);
} else if (minCapacity < 0) {
throw new OutOfMemoryError();
} else {
return minCapacity;
}
} else {
return newCapacity - 2147483639 <= 0 ? newCapacity : hugeCapacity(minCapacity);
}
}
可以看到,扩容就是开辟一size为原来空间1.5倍的新空间,然后将当前数据全部迁移过去,又避免不了万恶的循环遍历。
显而易见,这就是ArrayList的添加元素的性能瓶颈。我们再测试一下。
num=10000000 时候,Arraylist 不指定初始容量的情况。结果如下。
ArrayList 插入10000000条数据耗时:607
ArrayList 头部插入数据耗时:27
ArrayList 尾部插入数据耗时:0
LinkedList 插入10000000条数据耗时:2464
LinkedList 头部插入数据耗时:0
LinkedList 尾部插入数据耗时:0
ArrayList 头部取出数据耗时:0
ArrayList 中部取出数据耗时:0
ArrayList 尾部取出数据耗时:1
LinkedList 头部取出数据耗时:0
LinkedList 中部取出数据耗时:48
LinkedList 尾部取出数据耗时:0
可见扩容对ArrayList的添加元素时间的影响还是比较大的。但是即使扩容之后的ArrayList插入数据也比LinkedList快很多。
当数据量较小的时候,用ArrayList 和用linekList对性能的差异不是很明显。都可以。但是如果你的数据量特别大,有几万的那中,这个时候需要看以下两中情况
总之,没有东西是完美无瑕的,根据使用场景选择最合适的数据结构才能达到最好的效果。
通过手写一个低配版的Arraylist,对理解源码真的很有帮助,有不当之处还望大佬斧正。
https://github.com/ligengithub/java2020.git