为什么建议在集合初始化时指定集合容量大小

4 篇文章 0 订阅

文末有彩蛋!!!!

集合是Java开发日常开发中经常会使用到的,关于集合类,《阿里巴巴开发手册》中有一个规定:
在这里插入图片描述
为什么会有如此建议?如果一定要设置初始容量的话,设置多少比较合适?

为什么要设置初始值
我们先来写一段代码测试一下,在不指定初始化容量和指定初始化容量的情况下的性能如何。

	public static void main(String[] args) {
		int aHundredMillion = 1000000;
		Map<Integer, Integer> map = new HashMap<Integer, Integer>();
		long s1 = System.currentTimeMillis();
		for (int i=0; i < aHundredMillion; i++) {
			map.put(i, i);
		}
		long s2 = System.currentTimeMillis();
		System.out.println("未初始化容量,耗时:" + (s2 - s1));
				
		Map<Integer, Integer> map1 = new HashMap<Integer, Integer>(aHundredMillion/2);
		long s3 = System.currentTimeMillis();
		for (int i=0; i < aHundredMillion; i++) {
			map1.put(i, i);
		}
		long s4 = System.currentTimeMillis();
		System.out.println("初始化容量500000,耗时:" + (s4 - s3));
		
		Map<Integer, Integer> map2 = new HashMap<Integer, Integer>(aHundredMillion);
		long s5 = System.currentTimeMillis();
		for (int i=0; i < aHundredMillion; i++) {
			map2.put(i, i);
		}
		long s6 = System.currentTimeMillis();
		System.out.println("初始化容量1000000,耗时:" + (s6 - s5));
	}

以上代码不难理解,我们创建了3个HashMap,分别使用默认的容量(16),使用元素个数的一半(5十万)和使用元素个数(一百万)作为初始容量进行初始化,然后分别向其中put一百万个KV。
输出结果:

未初始化容量,耗时:83
初始化容量500000,耗时:52
初始化容量1000000,耗时:29

从结果我们可以知道,在已知HashMap中将要存放的KV个数的时候,设置一个合理的初始化容量可以有效的提高性能。

这是因为HashMap有扩容机制,当达到扩容条件时会进行扩容。HashMap的扩容条件就是当HashMap中的元素个数(size)超过临界值(threshold)时就会自动扩容。在HashMap中threshold=loadFactory * capacity。
所以,如果我们没有设置初始容量的大小,随着元素的不断增加,HashMap会发生多次扩容,而HashMap中的扩容机制决定了每次扩容都需要重建hash表,这是非常影响性能的。

从上面的代码我们还可以发现,同样时设置初始容量,设置的数值不同也会影响性能,那么当我们已知HashMap中即将存放的KV个数的时候,容量设置成多少为好呢?

HashMap中容量的初始化
默认情况下,当我们设置HashMap的初始化容量时,实际上HashMap会采用第一个大于该数值的2的幂作为初始化容量。如一下示例代码:

public static void main(String[] args) throws Exception {
		Map<String, String> map = new HashMap<String, String>(3);
		Class<?> mapType = map.getClass();
		try {
			Method capacity = mapType.getDeclaredMethod("capacity");
			capacity.setAccessible(true);
			System.out.println("capacity : " + capacity.invoke(map));
		} catch (Exception e) {
			e.printStackTrace();
		}
	}

运行结果:

capacity : 4

由上面测试结果可以看出,当我们通过HashMap(int initialCapacity)设置初始容量的时候,HashMap并不一定会直接采用我们传入的数值,而是经过计算,得到一个新值,目的是提高hash的效率。
(1->1、3->4、7->8)

HashMap计算初始容量的算法是下面示例:

	/**
     * Returns a power of two size for the given target capacity.
     */
    static final int tableSizeFor(int cap) {
        int n = cap - 1;
        n |= n >>> 1;
        n |= n >>> 2;
        n |= n >>> 4;
        n |= n >>> 8;
        n |= n >>> 16;
        return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
    }

一个看似简单的容量初始化,Java的工程师也是有很多考虑在里面。上面的算法就是根据用户传入的容量值,通过计算得到第一个比它大的2的幂并返回。

如果让我们自己设计这个算法,要怎么设计?我们用二进制举个例子:
5 0000 0000 0000 0101
7 0000 0000 0000 0111
8 0000 0000 0000 1000

9 0000 0000 0000 1001
15 0000 0000 0000 1111
16 0000 0000 0001 0000

19 0000 0000 0001 0011
31 0000 0000 0001 1111
32 0000 0000 0010 0000

37 0000 0000 0010 0101
63 0000 0000 0011 1111
64 0000 0000 0100 0000

请关注上面例子中后面的数字的变化情况,或许你会发现写规律。5->8、9->16、19->32、37->64都主要经过了两个阶段:

Step 1: 5-->7
Step 2: 7-->8

Step 1: 9-->15
Step 2: 15-->16

Step 1: 19-->31
Step 2: 31-->32

Step 1: 37-->63
Step 2: 63-->64

对应上面代码中,step 1:

n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;

对应到上面代码中,step 2:

return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;

Step 2比较简单,就是做一下极限值判断,然后把step 1得到的值+1。
Step 1怎么理解呢?其实就是对一个二进制数一次向右位移,然后与原值取或。其目的是对于一个数字的二进制,从第一个不为0的位开始,把后面所有位都设置成1。

随便拿一个二进制数,套一遍上面的公式就发现其目的了:

1100 1100 1100 >>>1 = 0110 0110 0110
1100 1100 1100 | 0110 0110 0110 = 1110 1110 1110
1110 1110 1110 >>>2 = 0011 1011 1011
1110 1110 1110 | 0011 1011 1011 = 1111 1111 1111
1111 1111 1111 >>>4 = 1111 1111 1111
1111 1111 1111 | 1111 1111 1111 = 1111 1111 1111

通过几次无符号右移按位或运算,我们把1100 1100 1100 转换成了1111 1111 1111,再把1111 1111 1111 加1,就得到了1 0000 0000 0000,这就是大于1100 1100 1100的第一个2的幂了。

现在我们清楚了Step 1和Step 2的代码,就是可以把一个数转化成第一个比它大的2的幂。 但是还是有一种特殊情况套用以上公式不行,这些数字就是2的幂自身,如果数字4套用公式的话,得到的会是8:

Step 1:
0100 >>>1 = 0010
0100 | 0010 = 0110
0110 >>>2 = 0001
0110 | 0001 = 0111
0111 >>> 4 = 0000
0111 | 0000 = 0111
0111 >>> 8 = 0000
0111 | 0000 = 0111
0111 >>> 16 = 0000
0111 | 0000 = 0111

Step 2:
0111 + 0001 = 1000

为了解决这一问题,JDK的工程师把所有用户传进来的数在进行计算之前先减1, 就是源码中的第一行:

int n = cap - 1;

至此,再回过头看看这个设置初始容量的代码,就会很清晰了。

HashMap初始容量的合理值
当我们使用HashMap(int initialCapacity)来初始化容量的时候,jdk会默认帮我们计算一个相对合理的值当作初始容量。那么,是不是我们只要把已知的HashMap中即将存放的元素个数直接传给initialCapacity就可以了呢?

关于这个值的设置,在阿里巴巴开发手册中有如下建议:
在这里插入图片描述
这个值并不是阿里巴巴的工程师创建的,在HashMap的putAll方法中就是这么实现的:

final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
        int s = m.size();
        if (s > 0) {
            if (table == null) { // pre-size
                float ft = ((float)s / loadFactor) + 1.0F;
                int t = ((ft < (float)MAXIMUM_CAPACITY) ?
                         (int)ft : MAXIMUM_CAPACITY);
                if (t > threshold)
                    threshold = tableSizeFor(t);
            }
            else if (s > threshold)
                resize();
            for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) {
                K key = e.getKey();
                V value = e.getValue();
                putVal(hash(key), key, value, false, evict);
            }
        }
    }

虽然当我们使用HashMap(int initialCapacity)来初始化容量的时候,jdk会默认帮我们计算一个相对合理的值作为初始容量,但是这个值并没有参考loadFactory的值。也就是说,如果我们设置的默认值是7,经过JDK处理,初始容量会被设置成8, 但是这个HashMap在元素个数达到8*0.75 = 6的时候就会进行一次扩容,这明显是我们不希望看到的。

如果我们通过expectedSize/0.75F + 1.0F计算,7/0.75 + 1 = 10,10经过JDK计算处理后,初始容量会被设置成16,这就大大的减少了扩容的几率。

当HashMap内部维护的哈希表的容量达到75%的时候(默认情况),会触发rehash,而rehash的过程是比较耗费时间的。所以初始化容量要设置成expectedSize/0.75 + 1的话,可以有效的减少扩容的几率,但同时也会牺牲一些内存。

彩蛋
点击下方链接,可以免费获取大量电子书资源
资料免费获取

  • 9
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

无法无天过路客

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值