《partner4java 讲述 java基础》之第一步:list 、set 、map 粗浅性能对比分析

不知道有多少同学和我一样,工作五年了还没有仔细看过list、set的源码可怜,一直停留在老师教导:“LinkedList插入性能比ArrayList好,LinkedList顺序遍历性能比ArrayList好”的世界里。可是真是如此么?本文很“肤浅”的对比和分析了几种常用的集合,“高手”可以就此打住不必往下阅读了。。。


本文分开介绍了List、Map、Set:

(测试环境:win7、jdk、4G、i3;文章示例为了节省篇幅,只会列出测试大体形式和遍历次数)

第一部分:List

1.add(E e)

==============================性能测试=================================

(1)表格统计的差异在于new的方式不同:

		for (int i = 0; i < 10000; i++) {
			List<Integer> list = new LinkedList<Integer>();
			for (int j = 0; j < 10000; j++) {
				list.add(j);
			}
			list = null;
		}

new方式消耗时间(毫秒)
new LinkedList<Integer>()3420
new ArrayList<Integer>()3048
new ArrayList<Integer>(10000)2280
new Vector<Integer>() 4045
new Vector<Integer>(10000)3859

(2)我们变化一下集合的长度和遍历次数:

		for (int i = 0; i < 1000000; i++) {
			List<Integer> list = new LinkedList<Integer>();
			for (int j = 0; j < 100; j++) {
				list.add(j);
			}
			list = null;
		}
new方式消耗时间(毫秒)
new LinkedList<Integer>()2286
new ArrayList<Integer>()2832
new ArrayList<Integer>(10000)1714
new Vector<Integer>() 3937
new Vector<Integer>(10000)3184


*************************************************源码分析*************************************************

似乎并没有看到期待的差异,若非“冒充下大牛”纠结一下区别:

1、若集合长度比较小(小于百位),LinkedList比ArrayList的插入性能稍胜一筹。

2、但是当长度达到五位数时,ArrayList的插入性能反而会比LinkedList好一些。

3、若你能预知ArrayList的长度,可以完胜LinkedList。(原因后面会说)

4、Vector的作用不在一个纬度上,不过也没有想象中的那么差。


LinkedList.add:

    先调用了自身的add:
    public boolean add(E e) {
	addBefore(e, header);
        return true;
    }
    然后又调用了addBefore,表示插入在谁之前(有点绕,因为是双项链):
    private Entry<E> addBefore(E e, Entry<E> entry) {
	Entry<E> newEntry = new Entry<E>(e, entry, entry.previous);
	newEntry.previous.next = newEntry;
	newEntry.next.previous = newEntry;
	size++;
	modCount++;
	return newEntry;
    }
    用于维护指定前后关系:
    private static class Entry<E> {
	E element;
	Entry<E> next;
	Entry<E> previous;

	Entry(E element, Entry<E> next, Entry<E> previous) {
	    this.element = element;
	    this.next = next;
	    this.previous = previous;
	}
    }
也就是说LinkedList的上下级维护关系是借助了指针,若任意位置插入,则是先查找到before前的entry,然后重指原有位置前后元素的指针。


ArrayList.add:

首先判断下是否超过了最大长度数组长度,若超过会重新扩展拷贝(这也就说明了,为什么当我们在newArrayList时指定合适的长度会提升性能)

    相比LinkedList来说就简单了狠多:
    public boolean add(E e) {
	ensureCapacity(size + 1);  // Increments modCount!!
	elementData[size++] = e;
	return true;
    }
    //首先判断下是否超过了最大长度数组长度,若超过会重新扩展拷贝(这也就说明了,为什么当我们在newArrayList时指定合适的长度会提升性能)
    public void ensureCapacity(int minCapacity) {
	modCount++;
	int oldCapacity = elementData.length;
	if (minCapacity > oldCapacity) {
	    Object oldData[] = elementData;
	    int newCapacity = (oldCapacity * 3)/2 + 1;
    	    if (newCapacity < minCapacity)
		newCapacity = minCapacity;
            // minCapacity is usually close to size, so this is a win:
            elementData = Arrays.copyOf(elementData, newCapacity);
	}
    }
    //默认长度10
    public ArrayList() {
	this(10);
    }

通过上面的源码可以理解出为什么当集合的长度会左右LinkedList和ArrayList的插入性能PK。影响ArrayList性能损耗的一大元凶就是数组的不断增长拷贝,LinkedList由于自身的特性当数据插入时需要借助特殊的内部类“Entry”来维护插入数据的上下级链。


2.add(int index, E element)

通过上面的分析出现了一个想法,如果是

随机插入位置呢?

==============================性能测试=================================

(1)

for (int i = 0; i < 100; i++) {
	List<Integer> list = new LinkedList<Integer>();
	for (int j = 0; j < 10000; j++) {
		list.add(list.size() / 2, j);
	}
	list = null;
}
new方式消耗时间
new LinkedList<Integer>()15782
new ArrayList<Integer>()3513
new ArrayList<Integer>(10000)3490

(2)我们变化一下集合的长度和遍历次数:

		for (int i = 0; i < 100000; i++) {
			List<Integer> list = new LinkedList<Integer>();
			for (int j = 0; j < 100; j++) {
				list.add(list.size() / 2, j);
			}
			list = null;
		}
new方式消耗时间
new LinkedList<Integer>()880
new ArrayList<Integer>()1262
new ArrayList<Integer>(10000)2308

*************************************************源码分析*************************************************

从纯插入性能上来说,随机插入LikedList要比ArrayList性能好:(至于为什么当LinkedList集合长度为10000时性能变化会如此之大,我们后面的get会有介绍)

LikedList.add(int index, E element) :

大体分为两个步骤

    public void add(int index, E element) {
        addBefore(element, (index==size ? header : entry(index)));
    } 
    //第一步获取到addBefore需要的插入位置(entry),第二步如上
    private Entry<E> entry(int index) {
        if (index < 0 || index >= size)
            throw new IndexOutOfBoundsException("Index: "+index+
                                                ", Size: "+size);
        Entry<E> e = header;
        if (index < (size >> 1)) {
            for (int i = 0; i <= index; i++)
                e = e.next;
        } else {
            for (int i = size; i > index; i--)
                e = e.previous;
        }
        return e;
    }
所以从代码上看, LikedList. add(int index, E element) 没有理由在集合长度为10000时会有这么大的差异,除非entry(int index)存在问题(我们后面可能要说的hash也正是为了避免遍历)。


ArrayList.add(int index, E element) :

大体分为三步

第二步暴漏了为什么现在初始指定了集合长度反而不一定是好事

    public void add(int index, E element) {
	if (index > size || index < 0)
	    throw new IndexOutOfBoundsException(
		"Index: "+index+", Size: "+size);
	//第一步:扩充长度
	ensureCapacity(size+1);  // Increments modCount!!
	//第二步:挪动数据(这一步决定了为什么现在初始指定了集合长度反而不一定是好事)
	System.arraycopy(elementData, index, elementData, index + 1,
			 size - index);
	//第三步:插入
	elementData[index] = element;
	size++;
    }

3.get(int index)

==============================性能测试=================================

		List<Integer> list = new LinkedList<Integer>();
		for (int j = 0; j < 10000; j++) {
			list.add(j);
		}

		for (int i = 0; i < 100; i++) {
			for (int j = 0; j < 10000; j++) {
				list.get(j);
			}
		}
new方式消耗时间
new LinkedList<Integer>()8349
new ArrayList<Integer>(10000)15
*************************************************源码分析*************************************************

通过这个表首先能够知道如果直接或间接(如模板语言)调用for循环遍历,LikedList的性能要差很多。

LinkedList.get(int index):

    public E get(int index) {
        return entry(index).element;
    }
    //性能损耗在这里,每次都需要线性搜索(遍历链定位位置)
    private Entry<E> entry(int index) {
        if (index < 0 || index >= size)
            throw new IndexOutOfBoundsException("Index: "+index+
                                                ", Size: "+size);
        Entry<E> e = header;
        if (index < (size >> 1)) {
            for (int i = 0; i <= index; i++)
                e = e.next;
        } else {
            for (int i = size; i > index; i--)
                e = e.previous;
        }
        return e;
    }

ArrayList.get(int index):

    public E get(int index) {
	//检查是否越界
	RangeCheck(index);
	//直接从数组中取出对应index的数据
	return (E) elementData[index];
    }


4.迭代器

==============================性能测试=================================

		List<Integer> list = new LinkedList<Integer>();
		for (int j = 0; j < 100; j++) {
			list.add(j);
		}

		for (int i = 0; i < 1000000; i++) {
			Iterator<Integer> it = list.iterator();
			while (it.hasNext()) {
				it.next();
			}
			it = null;
		}
new方式消耗时间
new LinkedList<Integer>()1335
new ArrayList<Integer>();3940


*************************************************源码分析*************************************************

LinkedList.iterator():

    private class ListItr implements ListIterator<E> {
	//header为LikedList的成员变量Entry
	private Entry<E> lastReturned = header;
	private Entry<E> next;
	private int nextIndex;
	private int expectedModCount = modCount;

	//这里我们会默认传入0,
	ListItr(int index) {
	    if (index < 0 || index > size)
		throw new IndexOutOfBoundsException("Index: "+index+
							    ", Size: "+size);

 	    //下面的if和else很有意思,为了实现“所有操作都是按照双重链接列表的需要执行的。在列表中编索引的操作将从开头或结尾遍历列表(从靠近指定索引的一端)。”
	    //size为外部LikedList的成员变量
	    if (index < (size >> 1)) {
		next = header.next;
		for (nextIndex=0; nextIndex<index; nextIndex++)
		    next = next.next;
	    } else {
		next = header;
		for (nextIndex=size; nextIndex>index; nextIndex--)
		    next = next.previous;
	    }
	}

	//判断是否已经超过长度
	public boolean hasNext() {
	    return nextIndex != size;
	}

	//便宜至下一个元素并返回element
	public E next() {
	    checkForComodification();
	    if (nextIndex == size)
		throw new NoSuchElementException();

	    lastReturned = next;
	    next = next.next;
	    nextIndex++;
	    return lastReturned.element;
	}

	...

	final void checkForComodification() {
	    if (modCount != expectedModCount)
		throw new ConcurrentModificationException();
	}
    }
也就是说我们的LinkedList的Iterator性能损耗主要在变换指针,所以对比for遍历方式来说性能提升了很多。


ArrayList.iterator():

    public Iterator<E> iterator() {
	return new Itr();
    }

    private class Itr implements Iterator<E> {
	/**
	 * Index of element to be returned by subsequent call to next.
	 */
	int cursor = 0;

	/**
	 * Index of element returned by most recent call to next or
	 * previous.  Reset to -1 if this element is deleted by a call
	 * to remove.
	 */
	int lastRet = -1;

	/**
	 * The modCount value that the iterator believes that the backing
	 * List should have.  If this expectation is violated, the iterator
	 * has detected concurrent modification.
	 */
	int expectedModCount = modCount;

	public boolean hasNext() {
            return cursor != size();
	}

	public E next() {
            checkForComodification();
	    try {
		//主要内容在这里,又绕回了ArrayList的获取方式
		E next = get(cursor);
		lastRet = cursor++;
		return next;
	    } catch (IndexOutOfBoundsException e) {
		checkForComodification();
		throw new NoSuchElementException();
	    }
	}

    }
ArrayList“很懒”的没有做什么,主要借助了父类AbstractList,获取方式采用了模板方法设计模式(Template Method Pattern)。

总结:无论你遍历ArrayList还是LinkedList都可以尽量采用迭代器。


通过add和get的分析,我们最常用的场景就是单页数据获取,然后利用jstl或velocity等遍历展示:

1、如果能确定遍历采用的for循环建议使用ArrayList并采用带参的构造器指定集合长度(也就是每页的个数);

2、若遍历采用迭代器建议采用LinkedList,因为我们还会有时对dao获取的数据采用缓存策略。

(jstl用的迭代器,可看源码org.apache.taglibs.standard.tag.common.core.ForEachSupport.toForEachIterator方法)


第二部分:Map


1.put(K key, V value)

==============================性能测试=================================

表格统计的差异在于new的方式不同:

		for (int i = 0; i < 100000; i++) {
			Map<Integer, String> map = new TreeMap<Integer, String>();
			for (int j = 0; j < 100; j++) {
				map.put(j, "s" + j);
			}
			map = null;
		}
new方式运行时间
new TreeMap<Integer, String>()2945
new HashMap<Integer, String>()2029
new HashMap<Integer, String>(100)1845
*************************************************源码分析*************************************************

TreeMap.put(K key, V value):

首先确认一点TreeMap采用的“红黑树”二叉查找方式。(具体算法请自行google)

该映射根据其键的自然顺序进行排序,或者根据创建映射时提供的 Comparator 进行排序,具体取决于使用的构造方法。此实现为 containsKey、get、put 和 remove 操作提供受保证的 log(n) 时间开销。

插入大体分为四步:

1、判断是否为初次;

2、获取指定Comparator,若没有指定获取key的父类的实现方式。

3、利用Comparator确定数据存放位置;

4、树的旋转。

    public V put(K key, V value) {
        Entry<K,V> t = root;
	//判断是否存在根节点,或者集合是否存在了数据
        if (t == null) {
            root = new Entry<K,V>(key, value, null);
            size = 1;
            modCount++;
            return null;
        }
        int cmp;
        Entry<K,V> parent;
        // split comparator and comparable paths
	//这点是获取构造器传入的Comparator
        Comparator<? super K> cpr = comparator;
        if (cpr != null) {
            do {
                parent = t;
                cmp = cpr.compare(key, t.key);
                if (cmp < 0)
                    t = t.left;
                else if (cmp > 0)
                    t = t.right;
                else
                    return t.setValue(value);
            } while (t != null);
        }
	//若没有指定Comparator,查找父类实现
        else {
            if (key == null)
                throw new NullPointerException();
            Comparable<? super K> k = (Comparable<? super K>) key;
	    //遍历放入确切位置
            do {
                parent = t;
                cmp = k.compareTo(t.key);
                if (cmp < 0)
                    t = t.left;
                else if (cmp > 0)
                    t = t.right;
                else
                    return t.setValue(value);
            } while (t != null);
        }
        Entry<K,V> e = new Entry<K,V>(key, value, parent);
        if (cmp < 0)
            parent.left = e;
        else
            parent.right = e;
	//树的旋转
        fixAfterInsertion(e);
        size++;
        modCount++;
        return null;
    }

HashMap.put(K key, V value):

1、HashMap 的实例有两个参数影响其性能:初始容量 和加载因子。容量 是哈希表中桶的数量,初始容量只是哈希表在创建时的容量。加载因子 是哈希表在其容量自动增加之前可以达到多满的一种尺度。当哈希表中的条目数超出了加载因子与当前容量的乘积时,则要对该哈希表进行 rehash 操作(即重建内部数据结构),从而哈希表将具有大约两倍的桶数;

2、key的hashcode至关重要。

    //构造一个带指定初始容量和加载因子的空 HashMap
    public HashMap(int initialCapacity, float loadFactor) {
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal initial capacity: " +
                                               initialCapacity);
        if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            throw new IllegalArgumentException("Illegal load factor: " +
                                               loadFactor);

        // Find a power of 2 >= initialCapacity
        int capacity = 1;
	//取大于等于初始容量最近的2的倍数
        while (capacity < initialCapacity)
            capacity <<= 1;

        this.loadFactor = loadFactor;
        threshold = (int)(capacity * loadFactor);
        table = new Entry[capacity];
        init();
    }
    public V put(K key, V value) {
        if (key == null)
            return putForNullKey(value);
	//计算key的hash值
        int hash = hash(key.hashCode());
	//计算出在第几个“水桶”,计算方式是&与了table.length-1,这应该也是水桶数据是2的倍数的原因
        int i = indexFor(hash, table.length);
	//通过这步可以理解为什么看到好多文档要求key的hashCode“均匀分布”便于均匀落入各个水桶
        for (Entry<K,V> e = table[i]; e != null; e = e.next) {
            Object k;
            if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
                V oldValue = e.value;
                e.value = value;
                e.recordAccess(this);
                return oldValue;
            }
        }

        modCount++;
        addEntry(hash, key, value, i);
        return null;
    }

2.get

==============================性能测试=================================

(1)表格统计的差异在于new的方式不同:

		Map<Integer, String> map = new TreeMap<Integer, String>();
		for (int j = 0; j < 100; j++) {
			map.put(j, "s" + j);
		}

		for (int i = 0; i < 100000; i++) {
			for (int j = 0; j < 100; j++) {
				map.get(j);
			}
		}
new方式运行时间
new TreeMap<Integer, String>()542
new HashMap<Integer, String>(100)280

(2)变化下集合的大小(有时我们使用Map存放上万条乃至几十万条数据用于小数据本地快速缓存):

		Map<Integer, String> map = new HashMap<Integer, String>(300000);
		for (int j = 0; j < 300000; j++) {
			map.put(j, "sasd");
		}
		beginTime = System.currentTimeMillis();
		for (int i = 0; i < 100; i++) {
			for (int j = 0; j < 300000; j++) {
				map.get(j);
			}
		}
new方式运行时间
new TreeMap<Integer, String>()5100
new HashMap<Integer, String>(300000)1400

(3)当hashcode为极端情况下:

Map<HashKeyTest, String> map = new TreeMap<HashKeyTest, String>();
for (int j = 0; j < 10000; j++) {
	map.put(new HashKeyTest(j), "sasd");
}
beginTime = System.currentTimeMillis();
for (int i = 0; i < 10; i++) {
	for (int j = 0; j < 10000; j++) {
		map.get(new HashKeyTest(j));
	}
}

public class HashKeyTest implements Comparable<HashKeyTest> {

	private int value;
	...
	
	@Override
	public boolean equals(Object obj) {
		if (this == obj)
			return true;
		if (obj == null)
			return false;
		if (getClass() != obj.getClass())
			return false;
		HashKeyTest other = (HashKeyTest) obj;
		if (value != other.value)
			return false;
		return true;
	}
	//模拟hashCode的极端情况,返回相同的值
	@Override
	public int hashCode() {
		return 345345435;
	}
	//用于TreeMap的二叉树对比排序
	@Override
	public int compareTo(HashKeyTest o) {
		int thisVal = this.value;
		int anotherVal = o.getValue();
		return (thisVal < anotherVal ? -1 : (thisVal == anotherVal ? 0 : 1));
	}

}
new方式消耗时间
new TreeMap<HashKeyTest, String>()18
new HashMap<HashKeyTest, String>()3480

*************************************************源码分析*************************************************

TreeMap.get(Object key):

基于红黑树查找

    public V get(Object key) {
        Entry<K,V> p = getEntry(key);
        return (p==null ? null : p.value);
    }
    final Entry<K,V> getEntry(Object key) {
        // Offload comparator-based version for sake of performance
        if (comparator != null)
            return getEntryUsingComparator(key);
        if (key == null)
            throw new NullPointerException();
	Comparable<? super K> k = (Comparable<? super K>) key;
        Entry<K,V> p = root;
        while (p != null) {
            int cmp = k.compareTo(p.key);
            if (cmp < 0)
                p = p.left;
            else if (cmp > 0)
                p = p.right;
            else
                return p;
        }
        return null;
    }

HashMap.get(Object key):

代码中很简明的可以看出,如果hashCode不均为出现的问题

    public V get(Object key) {
        if (key == null)
            return getForNullKey();
        int hash = hash(key.hashCode());
	//若hashCode相同,会导致查找方式就是这里的不停的遍历
        for (Entry<K,V> e = table[indexFor(hash, table.length)];
             e != null;
             e = e.next) {
            Object k;
            if (e.hash == hash && ((k = e.key) == key || key.equals(k)))
                return e.value;
        }
        return null;
    }

总结:

1、key如果是自定义对象,一定要有效地重载hashCode(),可参考《覆盖equals时总要覆盖hashCode》;

2、尽量保证hashCode“均为分布”,以便于均匀填充“水桶”;

3、当我们确定Map存放的数据比较多且hashCode分配严重不均匀,切记不要使用HashMap,建议使用TreeMap;

4、正常情况下,也就是我们常把表id作为key时,建议使用HashMap。


Set留给同学自己学习吧。(HashSet其实就是使用的HashMap作为容器)

评论 14
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值