数据结构优化篇
List
ArrayList
初始化数组容量对比
ArrayList指定长度的情况下
List array1 = new ArrayList(10000000);
long start1 = System.currentTimeMillis();
for (int i = 0; i < 10000000; i++) {
array1.add(i);
}
long end1 = System.currentTimeMillis();
System.out.println(end1-start1);
List array2 = new ArrayList();
long start2 = System.currentTimeMillis();
for (int i = 0; i < 10000000; i++) {
array2.add(i);
}
long end2 = System.currentTimeMillis();
System.out.println(end2-start2);
#输出结果
239
492
结论:指定长度比不指定长度时速度要快
原因分析:指定长度时,省去了容器扩容花费的时间,但是同时,如果随意设置,则浪费计算机内存资源,所以尽量的按需设置!
存储数据类型对比
这里主要以字符型和长整型作为示例
List list1 = new ArrayList(10000000);
long start1 = System.currentTimeMillis();
for (int i = 0; i < 10000000; i++) {
list1.add("1111111111");
}
long end1 = System.currentTimeMillis();
System.out.println(end1-start1);
List list2 = new ArrayList(10000000);
long start2 = System.currentTimeMillis();
for (int i = 0; i < 10000000; i++) {
list2.add(1111111111);
}
long end2 = System.currentTimeMillis();
System.out.println(end2-start2);
#输出结果
100
464
结论:字符型比长整型要快
原因分析:真不知道!有哪位大佬知道请留言,不甚感谢
插入数据时指定角标对比
List list1 = new ArrayList(10000000);
long start1 = System.currentTimeMillis();
//不能再多了,多了得等到天昏地暗
for (int i = 0; i < 100000; i++) {
list1.add(0,i);
}
long end1 = System.currentTimeMillis();
System.out.println("头部插入耗时:" + (end1-start1));
List list2 = new ArrayList(10000000);
long start2 = System.currentTimeMillis();
for (int i = 0; i < 100000; i++) {
list2.add(list2.size(),i); //等于list2.add(i);
}
long end2 = System.currentTimeMillis();
System.out.println("尾部插入耗时:" + (end2-start2));
List list3 = new ArrayList(10000000);
long start3 = System.currentTimeMillis();
//不能再多了,多了得等到天昏地暗
for (int i = 0; i < 100000; i++) {
list3.add(list3.size()/2,i);
}
long end3 = System.currentTimeMillis();
System.out.println("中间插入耗时:" + (end3-start3));
#输出结果
头部插入耗时:1133
尾部插入耗时:4
中间插入耗时:520
结论:尾部插入>头部插 ~中间插入
原因分析:
中间插入需要对角标之后所有元素重新排序并且需要执行复制行为,性能非常差,所以如果不是特殊要求,基本不考虑
头部插入需要对所有元素的重新排序并且需要执行复制行为,性能奇差,基本不考虑,其实你可以把头部插入看成是中间插入的极端
尾部插入在没有发生扩容下,不会有元素排序的过程,性能最高,也是默认使用的方式!并且只有在空间不足的情况下,才会有复制行为
删除位置对比
List list1 = new ArrayList(100000);
List list2 = new ArrayList(100000);
for (int i = 0; i < 100000; i++) {
list1.add(i);
}
for (int i = 0; i < 100000; i++) {
list2.add(i);
}
long start1 = System.currentTimeMillis();
for (int i = 0; i < 25000; i++) {
list1.remove(i);
}
long end1 = System.currentTimeMillis();
System.out.println("头部删除耗时:" + (end1-start1));
long start2 = System.currentTimeMillis();
for (int i = 50000; i < 75000 ; i++) {
list2.remove(i);
}
long end2 = System.currentTimeMillis();
System.out.println("尾部删除耗时:" + (end2-start2));
#输出结果
头部删除耗时:395
尾部删除耗时:128
结论:Arraylist每一次删除,都会进行数组的重组,且删除越靠前的元素,数组的重组开销就越大.
原因分析:越靠近头部的元素被删除后,需要重组的元素排序越多,时间复杂度为O(size-n),size为数组的长度,n为删除元素的角标
LinkedList
插入位置对比
List list1 = new LinkedList();
List list2 = new LinkedList();
List list3 = new LinkedList();
long start1 = System.currentTimeMillis();
for (int i = 0; i < 1000000; i++) {
list1.add(0,i);//等于list1.add(i)
}
long end1 = System.currentTimeMillis();
System.out.println("头部插入耗时:" + (end1-start1));
long start2 = System.currentTimeMillis();
for (int i = 0; i < 1000000; i++) {
list2.add(list2.size(),i);
}
long end2 = System.currentTimeMillis();
System.out.println("尾部插入耗时:" + (end2-start2));
long start3 = System.currentTimeMillis();
//少一点,少等一下,但是你会发现比头尾插入不会快
for (int i = 0; i < 100000; i++) {
list3.add(list3.size()/2,i);
}
long end3 = System.currentTimeMillis();
System.out.println("中部插入耗时:" + (end3-start3));
#输出结果
头部插入耗时:156
尾部插入耗时:161
中部插入耗时:8182
结论:因为LinkedList是双链表实现,所以头尾插入性能差不多,性能都比较快(当然,你知道默认用哪个吧?),而中部插入是最慢的!
原因分析:原因也是因为LinkedList的数据结构是双链表,它遍历获取插入位置是从两头往中间搜,所以插入位置越往中间,LinkedList效率越低
遍历数据方式对比
List<Integer> list = new LinkedList();
for (int i = 0; i < 100000; i++) {
list.add(i);
}
long start1 = System.currentTimeMillis();
for (Integer i : list) {
Integer ii = i;
}
long end1 = System.currentTimeMillis();
System.out.println("for-each耗时:" + (end1-start1));
Iterator<Integer> iterator = list.iterator();
long start2 = System.currentTimeMillis();
while (iterator.hasNext()){
Integer ii = iterator.next();
}
long end2 = System.currentTimeMillis();
System.out.println("iterator耗时:" + (end2-start2));
long start3 = System.currentTimeMillis();
for (int i = 0; i < list.size(); i++) {
Integer ii = list.get(i);
}
long end3 = System.currentTimeMillis();
System.out.println("for-i耗时:" + (end3-start3));
#输出结果
for-each耗时:4
iterator耗时:2
for-i耗时:4395
结论:for-each和iterator耗时性能处于同一个水平,for-i循环相对奇慢无比
原因分析:这个原因很玄妙,光说理论会说不清楚,我们先来看看以上代码的编译后的class类
List<Integer> list = new LinkedList();
for(int i = 0; i < 100000; ++i) {
list.add(i);
}
long start1 = System.currentTimeMillis();
Integer i;
for(Iterator var4 = list.iterator(); var4.hasNext(); i = (Integer)var4.next()) {
}
long end1 = System.currentTimeMillis();
System.out.println("for-each耗时:" + (end1 - start1));
Iterator<Integer> iterator = list.iterator();
long start2;
Integer var9;
for(start2 = System.currentTimeMillis(); iterator.hasNext(); var9 = (Integer)iterator.next()) {
}
long end2 = System.currentTimeMillis();
System.out.println("iterator耗时:" + (end2 - start2));
long start3 = System.currentTimeMillis();
for(int i = 0; i < list.size(); ++i) {
Integer var14 = (Integer)list.get(i);
}
long end3 = System.currentTimeMillis();
System.out.println("for-i耗时:" + (end3 - start3));
看出来了没?首先可以解释for-each和iterator是不是同一个妈妈生的?同时看iterator耗时源码:
//没有循环,只有判断下一个角标是否越界
public boolean hasNext() {
return nextIndex < size;
}
//没有循环,只有判断,且记录下一个角标nextIndex++
public E next() {
checkForComodification();
if (!hasNext())
throw new NoSuchElementException();
lastReturned = next;
next = next.next;
nextIndex++;
return lastReturned.item;
}
但是为什么for-i会那么慢呢?看关键源码:
if (index < (size >> 1)) {//判断角标在前半部分还是后半部分
Node<E> x = first; //如果是前半部分,拿到头
for (int i = 0; i < index; i++) //通过遍历头到角标,通过赋值指向操作拿到角标所在的node元素
x = x.next;
return x;
} else {
Node<E> x = last;//如果是后半部分,拿到尾
for (int i = size - 1; i > index; i--) //遍历尾到角标,通过赋值指向操作拿到角标所在的node元素
x = x.prev;
return x;
}
//问题是每个node元素都要这么循环来一遍!
所以,当需要遍历LinkedList时,优先使用哪一个就不用多说了吧?
Map
HashMap
结构解释
非常有必要解释一下HashMap的结构(JDK8以后):
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-C5GbfJxW-1647831872017)(C:\Users\paratera\AppData\Roaming\Typora\typora-user-images\image-20220318134428555.png)]
归纳一下就是:数组+链表+红黑树
- 元素key的hash值不同,放在数组里。(横向)
- 元素key的hash值相同,会产生hash冲突,需要把他放在数组同一位置的链表里,有些地方也理解为桶bucket。(纵向)
- 横向数组长度大于等于64,纵向链表长度大于8,链表就转换为红黑树
HashMap在JDK1.8后已经非常完善了,各API方法基本上都是最优化了,所以使用的灵活性不会很大,这里主要考虑一些使用需要注意的地方!
根据加载因子设置初始容量
Map<Integer,Object> map1 = new HashMap();
long start1 = System.currentTimeMillis();
for (int i = 0; i < 1000000; i++) {
map1.put(i,i);
}
long end1 = System.currentTimeMillis();
System.out.println("未初始化容量:" + (end1-start1));
Map<Integer,Object> map2 = new HashMap(1333334);
long start2 = System.currentTimeMillis();
for (int i = 0; i < 1000000; i++) {
map2.put(i,i);
}
long end2 = System.currentTimeMillis();
System.out.println("设置合理初始容量:" + (end2-start2));
Map<Integer,Object> map3 = new HashMap(99999999);
long start3 = System.currentTimeMillis();
for (int i = 0; i < 1000000; i++) {
map3.put(i,i);
}
long end3 = System.currentTimeMillis();
System.out.println("过量设置初始容量:" + (end3-start3));
#输出结果
未初始化容量:142
设置合理初始容量:74
过量设置初始容量:412
结论:设置合理初始化容量性能最高
原因分析:
HashMap扩容机制触发公式:(int) ((float) expectedSize / 0.75F + 1.0F),扩容后HashMap size: expectedSize * 2
遍历删除安全问题
Map<Integer, Object> map1 = new HashMap(1333334);
Map<Integer, Object> map2 = new ConcurrentHashMap<>(1333334);
Map<Integer, Object> map3 = new HashMap<>(1333334);
for (int i = 0; i < 1000000; i++) {
map1.put(i, i);
map2.put(i, i);
map3.put(i, i);
}
Set<Map.Entry<Integer, Object>> entries1 = map1.entrySet();
Iterator<Map.Entry<Integer, Object>> iterator = entries1.iterator();
long start1 = System.currentTimeMillis();
while (iterator.hasNext()) {
iterator.next();//必须要进行指针下移
iterator.remove();
}
long end1 = System.currentTimeMillis();
System.out.println("iterator删除耗时:" + (end1 - start1) + ",删除后size:" + map1.size());
Set<Map.Entry<Integer, Object>> entries2 = map2.entrySet();
long start2 = System.currentTimeMillis();
for (Map.Entry<Integer, Object> entry : entries2) {
entries2.remove(entry);
}
long end2 = System.currentTimeMillis();
System.out.println("ConcurrentHashMap遍历删除耗时:" + (end2 - start2) + ",删除后size:" + map2.size());
Set<Map.Entry<Integer, Object>> entries3 = map3.entrySet();
long start3 = System.currentTimeMillis();
for (Map.Entry<Integer, Object> entry : entries3) {
entries3.remove(entry);
}
long end3 = System.currentTimeMillis();
System.out.println("HashMap遍历删除耗时:" + (end3 - start3) + ",删除后size:" + map3.size());
#输出结果
iterator删除耗时:65,删除后size:0
ConcurrentHashMap遍历删除耗时:86,删除后size:0
Exception in thread "main" java.util.ConcurrentModificationException
at java.base/java.util.HashMap$HashIterator.nextNode(HashMap.java:1597)
at java.base/java.util.HashMap$EntryIterator.next(HashMap.java:1630)
at java.base/java.util.HashMap$EntryIterator.next(HashMap.java:1628)
at com.paratera.console.dict.utils.Test2.main(Test2.java:40)
结论:iterator和ConcurrentHashMap方式遍历删除都能成功,ConcurrentHashMap因为同步锁问题稍慢,但是区别不大;HashMap遍历删除报异常ConcurrentModificationException
原因分析:
1、HashMap里维护了一个modCount变量,HashIterator迭代器里维护了一个expectedModCount变量,一开始两者是一样的
2、进行HashMap.removeNode操作的时候就会++modCount1,此时迭代器里的expectedModCount还是之前的值
3、在下一次对迭代器进行next()调用时,判断是否HashMap.this.modCount != this.expectedModCount,如果是则抛出异常
我们可以使用异常捕获打印一下,异常发生后map3 size大小,实际上第一次remove是成功的,只是指向下一个entry时被HashMap认为是不安全的删除而报错,所以使用iterator迭代遍历删除时作者增加了一个操作:remove了一个元素之后会对expectedModCount重新赋值
Set<Map.Entry<Integer, Object>> entries3 = map3.entrySet();
long start3 = System.currentTimeMillis();
try {
for (Map.Entry<Integer, Object> entry : entries3) {
entries3.remove(entry);
}
}catch (Exception e){
}
long end3 = System.currentTimeMillis();
System.out.println("HashMap遍历删除耗时:" + (end3 - start3) + ",删除后size:" + map3.size());
#输出结果
HashMap遍历删除耗时:0,删除后size:999999
加载因子设置
前提:没法预估Map容量的情况下
当Map比较稳定,并且长期存在(比如作为服务器缓存)时,可以适当减少初始化大小,减小加载因子;—稳定时减少资源占用
当Map比较活跃,并且是动态生成时,可以适当增加初始化大小,增大加载因子;—活跃时减少扩容频率
TreeMap
顺序遍历
适用于按自然顺序或自定义顺序遍历键(key),在需要排序的Map时候才建议使用TreeMap,否则使用HashMap,提高性能
for (Map.Entry<String, String> entry : map1.entrySet()) {
System.out.println(entry.getKey());
}
System.out.println("---------");
Map<String, String> map2 = new TreeMap<>();
map2.put("aaa", "aaa");
map2.put("bbb", "bbb");
map2.put("ccc", "ccc");
for (Map.Entry<String, String> entry : map2.entrySet()) {
System.out.println(entry.getKey());
}
#输出结果
aaa
ccc
bbb
---------
aaa
bbb
ccc
Map<Integer, Integer> map = new HashMap<Integer, Integer>();
for (int i = 0; i < 1000000; i++) {
map.put(i, i);
}
Iterator<Integer> iterator1 = map.keySet().iterator();
long start1 = System.currentTimeMillis();
while (iterator1.hasNext()) {
map.get(iterator1.next());
}
long end1 = System.currentTimeMillis();
System.out.println("HashMap遍历耗时:" + (end1 - start1));
Map<Integer, Integer> tmp = new TreeMap<Integer, Integer>();
for (int i = 0; i < 1000000; i++) {
tmp.put(i, i);
}
Iterator<Integer> iterator_2 = tmp.keySet().iterator();
long start2 = System.currentTimeMillis();
while (iterator_2.hasNext()) {
tmp.get(iterator_2.next());
}
long end2 = System.currentTimeMillis();
System.out.println("TreeMap遍历耗时:" + (end2 - start2));
#输出结果
HashMap遍历耗时:41
TreeMap遍历耗时:92
结论:HashMap性能高,无序;TreeMap性能低,有序
原因分析:
HashMap是数组+链表+有条件红黑树,无法保证内部存储的键值对的有序性;但是数组的查询效果比其他任何方式都要高,而JDK8以后,限制了HashMap的长度,衍生红黑树,从而有效控制了链表的查询效果,所以综合起来,性能不错!
TreeMap是纯红黑树,能够保证有序性;但是查询性能比数组差
Set
HashSet和TreeSet
二者类似于HashMap和TreeMap,一个偏向性能,一个偏向有序,只是HashMap和TreeMap存储的是key/value;HashSet和TreeSet存储的是集合元素,所以这里不再赘述!