首页 » 编写高质量代码:改善Java程序的151个建议 » 编写高质量代码:改善Java程序的151个建议全文在线阅读

《编写高质量代码:改善Java程序的151个建议》建议78:减少HashMap中元素的数量

关灯直达底部

在系统开发中,我们经常会使用HashMap作为数据集容器,或者是用缓冲池来处理,一般很稳定,但偶尔也会出现内存溢出的问题(如OutOfMemory错误),而且这经常是与HashMap有关的,比如我们使用缓冲池操作数据时,大批量的增删查改操作就可能会让内存溢出,下面建立一段模拟程序,重现该问题,代码如下:


public static void main(Stringargs){

Map<String, String>map=new HashMap<String, String>();

final Runtime rt=Runtime.getRuntime();

//JVM终止前记录内存信息

rt.addShutdownHook(new Thread(){

@Override

public void run(){

StringBuffer sb=new StringBuffer();

long heapMaxSize=rt.maxMemory()>>20;

sb.append(/"最大可用内存:/"+heapMaxSize+/"Mn/");

long total=rt.totalMemory()>>20;

sb.append(/"对内存大小:/"+total+/"Mn/");

long free=rt.freeMemory()>>20;

sb.append(/"空闲内存:/"+free+/"M/");

System.out.println(sb);

}

});

//放入近40万键值对

for(int i=0;i<393217;i++){

map.put(/"key/"+i,/"vlaue/"+i);

}

}


该程序只是向Map中放入了近40万个键值对元素(不是整40万个,而是393217个,为什么呢?请继续往后看),只是增加,没有任何其他操作。想想看,会出现什么问题?内存溢出?运行结果如下所示:


Exception in thread/"main/"最大可用内存:63M

java.lang.OutOfMemoryError:Java heap space

at java.util.HashMap.resize(HashMap.java:462)

at java.util.HashMap.addEntry(HashMap.java:755)

at java.util.HashMap.put(HashMap.java:385)

at Client.main(Client.java:24)

对内存大小:63M

空闲内存:7M


内存溢出了!可能会有读者说,这很好解决,在运行时增加/"-Xmx/"参数设置内存大小即可。这确实可以,不过浮于表面了,没有真正从溢出的最根本原因上来解决问题。

难道是String字符串太多了?不对呀,字符串对象加起来撑死也就10MB,而且这里还空闲了7MB内存,不应该报内存溢出呀?

或者是put方法有缺陷,产生了内存泄露?不可能,这里还有7MB内存可用,应该要用尽了才会出现内存泄露啊。

为了更加清晰地理解该问题,我们与ArrayList做一个对比,把相同数据插入到ArrayList中看看会怎么样,代码如下:


public static void main(Stringargs){

List<String>list=new ArrayList<String>();

/*Runtime增加的钩子函数相同,不再赘述*/

//放入40万同样字符串

for(int i=0;i<400000;i++){

list.add(/"key/"+i);

list.add(/"vlaue/"+i);

}

}


同样的程序,只是把HashMap修改成了List,增加的字符串元素也相同(只是HashMap将其拆分成了两个字符串,一个是key,一个是value,此处则是把两个字符串放到list中),我们来看运行结果:


最大可用内存:63M

对内存大小:63M

空闲内存:11M


ArrayList运行很正常,没有出现内存溢出情况。两个容器,容纳的元素相同,数量相同,ArrayList没有溢出,但HashMap却溢出了。很明显,这与HashMap内部的处理机制有极大的关系。

HashMap在底层也是以数组方式保存元素的,其中每一个键值对就是一个元素,也就是说HashMap把键值对封装成了一个Entry对象,然后再把Entry放到了数组中,我们简单看一下Entry类:


static class Entry<K, V>implements Map.Entry<K, V>{

//键

final K key;

//值

V value;

//相同哈希码的下一个元素

Entry<K, V>next;

final int hash;

/*key、value的getter/setter方法,以及重写的equals、hashCode、toString方法*/

}

}


HashMap底层的数组变量名叫table,它是Entry类型的数组,保存的是一个一个的键值对(在我们的例子中Entry是由两个String类型组成的)。再回过头来想想,对我们的例子来说,HashMap比ArrayList多了一次封装,把String类型的键值对转换成Entry对象后再放入数组,这就多了40万个对象,这应该是问题产生的第一个原因。

我们知道HashMap的长度也是可以动态增加的,它的扩容机制与ArrayList稍有不同,其代码如下:


if(size++>=threshold)

resize(2*table.length);


在插入键值对时,会做长度校验,如果大于或等于阀值(threshold变量),则数组长度增大一倍。不过,默认的阀值是多大的呢?默认是当前长度与加载因子的乘积。


threshold=(int)(newCapacity*loadFactor);


默认的加载因子(loadFactor变量)是0.75,也就是说只要HashMap的size大于数组长度的0.75倍时,就开始扩容,经过计算得知(怎么计算的?查找2的N次幂大于40万的最小值即为数组的最大长度,再乘以0.75就是最后一次扩容点,计算的结果是N=19),在Map的size为393216时,符合了扩容条件,于是393216个元素准备开始大搬家,要扩容嘛,那首先要申请一个长度为1048576(当前长度的两倍嘛,2的19次方再乘以2,即2的20次方)的数组,但问题是此时剩余的内存只有7MB了,不足以支撑此运算,于是就报内存溢出了!这是第二个原因,也是最根本的原因。

这也就解释了为什么还剩余着7MB内存就报内存溢出了。我们再来思考一下ArrayList的扩容策略,它是在小于数组长度的时候才会扩容1.5倍,经过计算得知,ArrayList的size在超过80万后(一次加两个元素,40万的两倍),最近的一次扩容会是在size为1005308时,也就是说,如果程序设置了增加元素的上限为502655,同样会报内存溢出,因为它也要申请一个1507963长度的数组,如果没这么大的地方,就会报错了。

综合来说,HashMap比ArrayList多了一个层Entry的底层对象封装,多占用了内存,并且它的扩容策略是2倍长度的递增,同时还会依据阀值判断规则进行判断,因此相对于ArrayList来说,它就会先出现内存溢出。

可能会有读者在想,是不是可以在声明时指定HashMap的默认长度和加载因子来减少此问题的发生。是可以缓解此问题,可以不再频繁地进行数组扩容,但仍然避免不了内存溢出问题,因为键值对的封装对象Entry还是少不了的,内存依然增长较快。

注意 尽量让HashMap中的元素少量并简单。