前言:本以为(OutOfMemoryError)OOM问题会离我们很远,但在一次生产上线灰度的过程中就出现了Java.Lang.OutOfMemoryError:Java heap space异常,通过对线上日志的查看,最终定位到ArrayList#addAll方法中,出现这个问题的原因是:由于历史原因有个接口的响应时间经常超时,所以笔者对其进行了优化,之前使用的是ArrayList#add方法,笔者通过一系列修改后将add方法修改为了addAll方法,导致内存溢出。但具体是怎样产生的呢,下面对其详细分析。
ArrayList的内部原理
谈起ArrayList想必大家在日常中经常使用,用于存储一系列的元素。由于笔者在使用过程中出现了OOM异常,这里有必要对其内部原理进行简单的分析:
#1.ArrayList底层采用数组来存储数据,查找速度快,毕竟直接使用数组下标进行数据的查找。这里有一点特别重要其内部的数据存储结构为数组。
#2.数组:数组是一种线性表数据结构,它是一组连续的内存空间。注意:一组连续的内存空间,这就意味着在申请数组时如果不能满足连续的内存空间,哪怕是内存足够也会导致OOM问题。
#3.ArrayList的默认容量为10,超过10时,会进行扩容:int newCapacity = oldCapacity + (oldCapacity >> 1);相当于扩大为原来的1.5倍。其扩容函数如下:
1 private void grow(int minCapacity) { 2 // overflow-conscious code 3 // 获得当前ArrayList的大小 4 int oldCapacity = elementData.length; 5 // 进行扩容,扩大为原来的1.5倍,那为什么不直接*1.5呢,因为位操作速度更快 6 int newCapacity = oldCapacity + (oldCapacity >> 1); 7 // minCapacity参数为扩容前确认的数组大小参数,将在下面进行分析 8 // 如果新容量比minCapacity小,说明容量不够,则使用minCapacity 9 if (newCapacity - minCapacity < 0) 10 newCapacity = minCapacity; 11 // 如果newCapacity大于最大ArrayList承受的最大值,则计算最大值 12 if (newCapacity - MAX_ARRAY_SIZE > 0) 13 newCapacity = hugeCapacity(minCapacity); 14 // minCapacity is usually close to size, so this is a win: 15 // 进行扩容 16 elementData = Arrays.copyOf(elementData, newCapacity); 17 }
分析:上述扩容函数涉及到几个变量minCapacity、MAX_ARRAY_SIZE,下面将对其进行解释。
关于minCapacity变量通过ArrayList#addAll函数进行分析(add函数其实一样):
1 public boolean addAll(Collection<? extends E> c) { 2 Object[] a = c.toArray(); 3 // 获取要插入集合的长度 4 int numNew = a.length; 5 // 确认容量大小,扩容也就是在该函数中进行操作 6 ensureCapacityInternal(size + numNew); // Increments modCount 7 // 将要插入的数据拷贝至数组尾部 8 System.arraycopy(a, 0, elementData, size, numNew); 9 size += numNew; 10 return numNew != 0; 11 }
1 private void ensureCapacityInternal(int minCapacity) { 2 ensureExplicitCapacity(calculateCapacity(elementData, minCapacity)); 3 } 4 5 private void ensureExplicitCapacity(int minCapacity) { 6 modCount++; 7 8 // overflow-conscious code 9 // 所需容量大于当前数组容量,则进行扩容 10 if (minCapacity - elementData.length > 0) 11 grow(minCapacity); 12 }
分析:
#1.ArrayList的扩容入口就是ensureCapacityInternal函数,其入参为当前ArrayList存储容量与要处理集合容量的和。
#2.然后通过calculateCapacity函数进行容量确认:
1 private static int calculateCapacity(Object[] elementData, int minCapacity) { 2 // 如果当前数组为空,则从默认值(10)与minCapacity(当前ArrayList容量+要插入集合容量之和)中取最大值 3 if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) { 4 return Math.max(DEFAULT_CAPACITY, minCapacity); 5 } 6 // 否则直接返回minCapacity 7 return minCapacity; 8 }
#3.在ensureExplicitCapacity函数中进行具体扩容,也就是调用grow函数。
在grow函数中有一个变量需要注意一下MAX_ARRAY_SIZE:
注释已讲的非常清楚:尝试去分配最大容量的数组内存也许会造成OOM异常。
还有这里为什么要用Integer.MAX_VALUE-8呢,因为数组在虚拟机中存储时需要8字节来存储其自身的大小。
#4.ArrayList的扩容是通过Array.copyOf函数进行的:
1 public static <T> T[] copyOf(T[] original, int newLength) { 2 // original需要被拷贝的原数据集合 3 // newLength新的数组长度 4 return (T[]) copyOf(original, newLength, original.getClass()); 5 } 6 public static <T,U> T[] copyOf(U[] original, int newLength, Class<? extends T[]> newType) { 7 @SuppressWarnings("unchecked") 8 // 申请内存空间,如果这里没有连续的内存空间,则会抛出OOM异常 9 T[] copy = ((Object)newType == (Object)Object[].class) 10 ? (T[]) new Object[newLength] 11 : (T[]) Array.newInstance(newType.getComponentType(), newLength); 12 // 将原数组拷贝到新空间中 13 System.arraycopy(original, 0, copy, 0, 14 Math.min(original.length, newLength)); 15 return copy; 16 }
分析:
关键在上述代码第8行中,申请新的内存空间,由于是数组,需要连续的内存空间,如果当前无连续的内存空间,哪怕内存足够也会抛出OOM异常。
通过对ArrayList的源码分析,就可以得出出现OOM原因的关键点了。这里贴上当时灰度环境JVM的堆内存走势图:
从以上JVM监控图可以清楚的看到堆内存从0直接飙到了2G,在2G后出现了OOM异常,并且此时JVM进行了垃圾回收,幸好没有把当前节点拖崩,万幸!!!
在同样的数据量下为什么用add未抛OOM异常,而用addAll确抛了OOM异常呢
在同样数据量的情况下,之前的代码使用了ArrayList#add方法未出现问题,而使用ArrayList#addAll方法却抛出了OOM异常呢,通过源码进行比较:
ArrayList#add:
ArrayList#addAll
通过对源码进行比较可知,ArrayList#add方法每次确认容量是size+1,而ArrayList#addAll每次是size+numNew(要插入的容量)。在ArrayList#add方法插入数据进行扩容时,每次都是扩容器为其1.5倍,而ArrayList#addAll不确定,需要依据numNew大小。
在使用ArrayList#addAll方法时,如果插入集合的过大,而且该方法处于循环中,就会导致扩容非常的频繁,在JVM未来得及进行垃圾回收的情况下,就会导致OOM异常。
最终的解决方法:在初始化ArrayList的时候,尽量知道所需存储元素的容量或者避免其频繁扩容,就有很大的机会避免OOM异常,笔者的解决方法就是如此,以为通过其他途径得知了每次的ArrayList大小,最终解决了这个问题,由于是公司代码,这里就不贴具体代码了,其实在灰度时也把我吓了一跳。
总结
本文来源于笔者在生产环境中遇到的问题(线上数据量太大,在QA环境中并为出现该问题),通过对ArrayList源码的分析,最终找到问题出现的核心点,通过及时的修改,再次上线后该问题得到解决,因此特别记录下该问题,并以此为戒。
#1.在使用ArrayList的时候,尽量对其进行容量大小的初始化,避免其频繁扩容,造成OOM异常,线上出现该问题真的很恐怖。
#2.出现问题也不要过于惊慌,及时发现问题,并解决,也许你会有不小的收获。
#3.本次问题幸好出现在灰度环境,并未全量,这是不幸中的万幸,下次一定注意、注意、注意!!!
by Shawn Chen,2019.07.14日,下午。