首先我们运行一段代码:
此时运行,程序正常,接下来我们将注释放开:
此时运行发现,OOM了:
为什么new出来HashMap的时候并没有报OOM,而是在第一次进行put操作的时候才报的OOM?我们来看下map的源码。
设置数组长度、扩容阈值
创建Map时可以指定元素个数,也可以不指定。先来看下不指定元素个数的构造方法:
public HashMap() { this.loadFactor = DEFAULT_LOAD_FACTOR; // 设置加载因子为0.75}static final float DEFAULT_LOAD_FACTOR = 0.75f;
当进行put操作时,会进行resize扩容操作:
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { Node[] tab; Node p; int n, i; if ((tab = table) == null || (n = tab.length) == 0) //初始table为空 n = (tab = resize()).length; //触发resize操作 //省略... } //省略...}transient Node[] table;
看下resize扩容的核心代码:
final Node[] resize() { Node[] oldTab = table; //初始table为空 int oldCap = (oldTab == null) ? 0 : oldTab.length; //所以oldCap为0 int oldThr = threshold; //初始threshold为0 所以oldThr为0 int newCap, newThr = 0; if (oldCap > 0) { //省略... } else if (oldThr > 0) //省略... else { newCap = DEFAULT_INITIAL_CAPACITY; //设置新数组长度为16 newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); //设置新map可容纳元素个数为16*0.75 } if (newThr == 0) { //省略... } threshold = newThr; //为threshold赋值为newThr 代表扩容后的map的可容纳元素个数上限 当map中元素个数超过threshold会触发扩容操作 @SuppressWarnings({"rawtypes","unchecked"}) Node[] newTab = (Node[])new Node[newCap]; table = newTab; //设置table为扩容后的新数组 //省略... return newTab;}static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
如果指定元素个数,例如:
Map<String,String> map = new HashMap<String,String>(17);
public HashMap(int initialCapacity) { this(initialCapacity, DEFAULT_LOAD_FACTOR); //加载因子0.75}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); this.loadFactor = loadFactor; //加载因子0.75 this.threshold = tableSizeFor(initialCapacity); //设置threshold为最接近initialCapacity的2次幂的值}static final float DEFAULT_LOAD_FACTOR = 0.75f;
调用tableSizeFor方法为threshold赋值,看下tableSizeFor的代码:
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;}
这个函数是用来对你申请的容量进行处理让他变成最接近你申请的容量的2次幂的大小,这里注意:假如你申请的容量为0,最后处理的结果会变成1,代表着你最小的容量为1。
我们自己测试一下,tableSizeFor(17)=32:
public static void main(String[] args) { int number = 17; System.out.println(tableSizeFor(number)); //32}public static int tableSizeFor(int number) { int n = number - 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;}
补充:
算术右移(>>)
右移是按最左边(高位)来补的(即如果是1就补1,如果是0就补0,不改变该位的值)。另一种说法,正数右移高位补0,负数右移高位补1。
逻辑右移(>>>)
不管最左边一位是0还是1,都补0。另一种说法,无论是正数还是负数,高位通通补0。
同样的,当put元素时会触发map的扩容操作。
接下来看下指定元素个数时,Map扩容的核心源码。
final Node[] resize() { Node[] oldTab = table; int oldCap = (oldTab == null) ? 0 : oldTab.length; //初始table为空 oldCap=0 int oldThr = threshold; //此时threshold已经有值(2的n次幂) int newCap, newThr = 0; if (oldCap > 0) { //省略... } else if (oldThr > 0) newCap = oldThr; //扩容后map的数组长度 else { 省略... } if (newThr == 0) { float ft = (float)newCap * loadFactor; //设置扩容后map的threshold newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ? (int)ft : Integer.MAX_VALUE); } threshold = newThr; @SuppressWarnings({"rawtypes","unchecked"}) Node[] newTab = (Node[])new Node[newCap]; table = newTab; //省略... return newTab;}
总结一下,如果没有指定元素个数,则扩容后的数组长度默认为16,如果指定元素个数,则扩容后的数组长度为与指定的元素个数最接近的2次幂的值,比如指定元素个数为17,则数组长度为32。threshold的取值都是固定数组长度*加载因子(0.75,0.75并不是一个绝对的,只是在时间和空间上可能map的效率最高)。
再回到一开始的问题,new的时候没有OOM但是在put的时候却OOM了,因为在new的时候只是设置threshold值,而且设置的值还是比最大整数大的2次幂,在扩容的时候,需要分配数组内存,所以OOM了。
数据迁移
上面其实只是分析了resize操作关于设置数组长度、扩容阈值的代码,真正扩容后数据迁移都省略了,接下来看下数据迁移部分:
final Node[] resize() { //省略... if (oldTab != null) { for (int j = 0; j < oldCap; ++j) { //遍历老数组 Node e; if ((e = oldTab[j]) != null) { oldTab[j] = null; if (e.next == null) newTab[e.hash & (newCap - 1)] = e; //如果老数组的某一索引位置没有链表,则将计算该索引位置的元素在新数组的索引位置 else if (e instanceof TreeNode) ((TreeNode)e).split(this, newTab, j, oldCap); else { //如果索引位置有链表 Node loHead = null, loTail = null; Node hiHead = null, hiTail = null; Node next; do { next = e.next; if ((e.hash & oldCap) == 0) { //将索引位置的链表拆分成loHead->loTail和hiHead->hiTail两个链表,顺序保持不变 if (loTail == null) loHead = e; else loTail.next = e; //尾插法 loTail = e; } else { if (hiTail == null) hiHead = e; else hiTail.next = e; hiTail = e; } } while ((e = next) != null); if (loTail != null) { loTail.next = null; newTab[j] = loHead; //新数组索引位置放入loHead链表 } if (hiTail != null) { hiTail.next = null; newTab[j + oldCap] = hiHead; //新数组索引位置+原始数组长度位置放入hiHead链表 } } } } } return newTab;}
举例说明下e.hash&oldCap==0来区分该放到loHead还是hiHead链表,下面是我的理解:
0 1 0 0 0 8 //假设原数组长度8 即oldCap=80 0 0 1 1 3 j=0 //假设原数组0索引位置存在3 6 12 三个数组成的链表0 0 1 1 0 6 j=0 3->6 //经e.hash&oldCap计算 3和6结果均为0 所以组成loHead:3-60 1 1 0 0 12 j=8 [0+8] //而12与8与操作结果不等于0 所以组成hiHead:12在扩容时 将3->6放入新数组的0索引位置,将12放入新数组的8索引位置
这样的好处是不用计算链表的每一个元素在新数组对应的索引位置了,同时也保持了元素在链表中的顺序。