8g内存 eclipse怎么设置_Hashmap&Java内存机制项目应用总结

Hashmap是我们在Java开发中经常使用到的数据结构,软件研究院济南分院结算分中心的胡海波通过对Hashmap源代码的研究,结合项目实践,为大家分享对Hashmap&Java内存机制项目应用的总结。 大体上说,HashMap的存储结构是用数组+链表实现的。数组大小也就是hash算法中的桶大小。HashMap有三个构造函数,分别为:

2eac5634678910fb218989c34b52aa1e.png

图1 Hashmap的三个构造函数

其中的initialCapacity就是数组的初始容量大小,如果不指定,则会使用默认值16。 一、 put(key, value) 函数原理: 首先计算key的hash值,根据hash值找到对应的桶(即数组元素的下标,然后遍历桶中的节点,看是否有节点的key与要加入的key相同,如果相同则更新value值,如果没有,则新建节点,并将新建节点加入到链表的头部。伪算法总结如下:

3d5c5f31d566219919c5f3f7ac862214.png

图2 put函数伪算法

       第5步新建节点的过程中,如果发现map的大小超过了一定阈值(与initialCapacity和loadFactor相关),则需要扩容。为什么需要扩容呢?因为若不进行扩容,后面再加入元素时发生hash冲突的概率将会大大增加,而hash冲突的发生会大大降低map的性能。那么扩容是怎么实现的呢?首先,新建一个大小是原来数组两倍的数组。然后重新计算各个节点的hash值,并分配到新数组中。打个比方说,把一堆小球放入一些盒子里,要求每个盒子里尽量最多只有一个小球。原来有n个盒子,某个时刻大部分盒子里都装入了小球,这个时候还希望继续放球,就需要找来2*n个新的盒子,把原来n个盒子里的小球拿出来重新放到2*n个新盒子中,然后原来的n个盒子就不用了。二、结论分析及验证        由上述分析可以得出结论:扩容是一个耗时又耗空间的操作,应该尽量避免扩容。避免扩容的方法可以是在新建HashMap对象的时候,设定一个合理的initialCapacity初始容量值。以下给出一个小程序验证这个方法的合理性:

7269039ae5cd44c87374e43f47ef1385.png

图3 验证小程序


    程序实现的功能非常简单:循环往HashMap对象里push值,但多次实验后发现结果并没有提升。于是提出 可能是因为影响效率的不止初始容量一个因素,还有其它因素,当数据量大的时候,初始容量占主导作用,数据量小的时候,其它因素占主导作用。为证明猜想,通过多次试验排除编译优化的问题;对源代码进行仔细研究单步调试发现程序少走了一个resize方法;排除大数按位与造成的影响;排除取数组的影响;排除hash冲突的影响,最终定位到HashMap的createEntry方法:

b7d7d054077401c2d3caaf2fcd2c106d.png

图4 HashMap createEntry方法


    将图3小程序里的put方法替换成上述createEntry方法,再通过逐次调试修改代码测试结果:

7a3eb5c2e01622921e5734dd3266b814.png

图5 对createEntry的调试经过

    于是可以得出结论,设置初始值对结果没有优化是因为内存分配和垃圾回收机制导致的。以下深究具体原因:

0a69e440563391cba2aa6d493db32fc3.png

图6 结论验证过程

     为HashMap设置合适的初始容量确实能提升效率 ,出现偏差是因为碰到了JVM的Full GC。但新的疑问随之产生:

    1、Full GC为何会被触发,2、try3中执行语句没有减少,为何执行时间却优化了一个数量级

    进行下一步验证前,先简单介绍Java内存模型:Java内存大致可分为程序计数器、方法区、栈区、堆区等几大部分。垃圾回收作用于方法区和堆区,其中主要是堆区。一般我们通过new创建的对象都会放在堆中。当内存不足,或到达一个指定的垃圾回收时间点时,JVM就会执行垃圾回收动作,清除无用对象,整理可用内存。      堆部分又可以细分为新生代和老生代(两者内存占比默认1:2),可以理解为对象出生在新生代,活得时间久了,就会挪到老生代。新生代进一步细分,还可分为一个Eden区和两个Survivor区(from和to)(默认内存占比8:1:1)。java程序只能使用Eden区和其中的一个Survivor区(from),即对象只能在这两个地方创建,而不能再另外一个Survivor区中创建。(此处指的是对象在新生代创建的情况,某些情况下,对象还有可能在老生代中直接创建。)新生代垃圾回收时,JVM使用标记-复制算法,尝试把Eden区和from区中还存活的对象转入to区,然后清空Eden区和from区,from区和to区身份互换。如果发现to区内存不足以安放所有对象,就需要把对象转入老生代。如果老生代也放不下,那就需要执行Full GC。

ada3d83a475b8221dd28688517ea2257.png

图7 后续验证

    对比verify5和verify6,verify5分别在30万次、61万次循环后执行了一次GC;verify6分别在30万次、66万次循环后执行了一次GC,并且第二次GC引发了一次Full GC。为什么verify5的第二次GC没有引发Full GC,而verify6的第二次GC引发了Full GC呢?查阅引起JVM Full GC的条件,有这么几条: 1. 老生代空间不足 2. 方法区空间满 3. CMS GC 时出现promotion failed和concurrent mode failure 4. 统计得到的Minor GC晋升到老生代的平均大小大于老生代的剩余空间     重点在于第四条。一般垃圾收集器会在GC前计算这个平均值,如果平均值大于剩余空间会引发Full GC;而Parallel Scavenge收集器比较特殊,在GC执行完后还会进行一次比较,条件成立时照样会引发Full GC。观察GC日志,JVM用的垃圾收集器正是ParallelScavenge。仔细分析GC日志,对比第二次GC时verify5和verify6内存情况:

Verify5

Verify6

晋升到老年代的总量

52872K

56672K

晋升发生次数

2

2

平均值

26436K

28336K

老生代总内存

83968K

83968K

老生代剩余内存

31096K

27296K

平均值是否大于老生代总内存

   
    可以看出,对于verify6,引发Full GC的第四个条件成立,所以执行了Full GC:因为设置初始容量100万,在第二次垃圾回收的时候,满足了Full GC的条件。那么为什么verify5没满足条件,verify6满足了?根据JVM日志,verify5在61万次循环后执行了GC,verify6在66万次循环后才执行GC,也就是verify5执行第二次GC的时间更早。来看一下之前我们提到过的HashMap的resize方法:

c5a8d3831d631e94b17e9722d180e7e9.png

图8 HashMap resize方法

    可以看到,HashMap扩容后,会分配新的数组,而原来的数组就失去作用了,但是在垃圾回收前还会占用内存空间。verify5进行到61万次循环后map容量需要扩大到61万以上,而这时已经为数组分配的内存为(16+32+64+128+...+1048576)*8,总计2097151*8(*8是因为数组存放的是Entry的引用)。而verify6因为设置了初始容量,不会扩容,为数组分配的内存为1000000*8,因而执行到61万次循环时,verify5占用的内存空间更大,会提前进行一次新生代GC,避免了verify6情况中接下来的一次Full GC。     接下来是try3反映的问题。这个就比较好理解了。Entry entry = new Entry(hash, key, value, e);table[bucketIndex]= null。虽然创建了对象,但是table[bucketIndex]=null,创建的对象并没有被利用,成为了垃圾对象,所以垃圾回收时这部分内存很容易被回收利用,不会引起Full GC。三、内存占用分析      verify6中,为什么30多万次循环后占用的内存为31744K?揪出java对象的内存模型来,咱们来计算一下程序中对象占用的内存空间。     首 先看一下程序中有哪些对象。执行到30万次循环后,存活的对象是一个HashMap,HashMap中包含一个长度为100万的数组tables,数组中存放着30万个Entry,每个Entry包含两个对String对象的引用,每个String对象包含一个value数组。接下来内存的占用就可以通过计算得出。 HashMap 对象:只有一个,本身占用空间忽略不计。     1、tables 数组:对象头24个字节,100万个数组元素,每个元素是对Entry对象的引用,100万*8个字节。数组占用:24+100万*8字节。     2、30 万个Entry:每个Entry包含一个key的引用、value的引用、next的引用,以及一个hash,总共28,加16字节对象头=44字节,对齐到8字节48,最终总内存:48*30万字节。     3、30 万个不同的String对象:每个String对象包含一个数组对象value的引用,两个int变量,单个String占用空间8+2*4=16,加16字节对象头=32,最终总内存:32*30万字节。     4、30 万个value数组:数组元素类型char,大部分数组长度为6(因为10万到30万之间的数字符串长度为6,只有一小半长度不足6,差别不是很大),单个char数组占用空间6*2=12,加24字节数组对象头=36,对齐到8字节40,最终总内存:40*30万字节。    示意图如下:

d082efa7406e61f28e9f95c55d8a5967.png

图9 内存占用示意图

    计算总空间:(24 + 8*100万 + 48*30万+ 32*30万 + 40*30万)/1024 =42968K,大约42M。而JVM日志中实际的占用约为31M;经过测试程序查证,此处如此大的差距原因是JVM自动调用了指针压缩,修改内存占用示意图如下:

f19bf9cbccfe1a4cd2e23c5790640659.png

图10 修改后的内存占用示意图

     重新计算总空间:(20+4*100万+32*30万+24*30万+32*30万)/1024 =29687K,大约29M。再加上其它一些没计算的内存(即时main函数是空的,运行时也会占用一些堆内存的),基本上与控制台打印的31M对应起来了。

    什么是指针压缩?为什么要指针压缩?对内存和运行效率有何影响?64位下,不压缩时普通对象头为什么占用16个字节?32位下对象头占用多少字节?为什么对象内存要对齐到8个字节?为什么JVM可以自动回收内存,它是如何知道一些对象无效了的?......简直是十万个为什么。没有谁可以把所有疑问全都研究明白,而且搞清楚的东西越多,产生的疑问也就越多。

四、结语

    那么,对这些问题的深究有用吗?对于一般码农来说没用,对于高手来说有用。如果你只是完成主管交代的简单任务,做几个set和get,使用几个简单的if和循环,那这样的研究确实没有必要,徒增烦恼不说,还会影响自己工作效率。而如果需要站在更高的层次上,需要审视整个系统的效率,那这些东西多少就要知道些了。举几个简单例子:

1、tomcat设置多大内存合适,是不是越大越好?

2、为什么有些人选择在64位机器上安装32位jvm和几个32位tomcat,然后让这几个32位tomcat做集群,这样有什么好处?

3、我把tomcat换成64位,内存从1G提到16G,大部分人访问变快了,但老有人反映偶尔访问时会被卡十几秒。哪里出问题了?

4、我要给我的系统开启全局缓存,缓存我的这些数据大约需要多少内存?

    IT系统运行过程中,可能会发生各种各样的问题。很多时候,我们必须深入研究一些底层原理,才能搞明白问题发生的根源是什么,如何解决问题。这就好比盖房子,基础原理就是地基,如果地基打不稳,上面的房子建的再漂亮也是没用的。


附:

机器配置:酷睿i5,2.5GHz;8G内存;64位windows7操作系统

eclipse配置:-Xmx512m-XX:MaxPermSize=256m

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值