前不久为知笔记宣布收费,用不惯印象笔记和有道的我,便充了一年的会员,既然钱已经花了出去,就不能像以前一样,不懂得珍惜,刚好最近在重读《深入理解 java 虚拟机》,关于内存管理这块又多了一些收获,便记录下来,顺便整理成博客,还是毛主席教导的好:好记性不如烂笔头啊! 文中前部分简摘自周志华老师的 ,后部分为个人的一些验证和总结。
相对于 C++,Java 语言非常好的一点就是不需要我们手动管理内存,因为有一个像妈妈一样的虚垃圾回收器,每天默默地替我们清扫着房间的垃圾,无怨无悔。基本上所有的 java 程序员都知道有这么个默默无闻的妈妈,可是你真的了解她吗?
上面提到垃圾回收器替我们清扫垃圾,那么重要的一点来了,什么是垃圾?在现实生活中,没有用的东西就是垃圾,会被丢进垃圾桶,运到处理厂,掩埋或是焚烧。其实在 java 的世界里也是一样,无用即为垃圾,不过这里的垃圾全都是:对象。
什么叫无用,即没有人要用,在 java 里,无用即代表没有被引用,java 里面操控对象全都是通过引用来进行的,For Example:Object o=new Object(),这里我们就创建了一个 Object 对象,并用一个引用 o 来引用这个对象,这样我们就可以通过 o 来操作这个 Obejct 了。
java 里一共有四种引用:
这就是我们上面写的 Obejct o=new Object(),只要强引用还存在,对象就不会被回收。
SoftReference,只有将发生内存溢出时,才会进行回收。
WeakReference,GC 工作时,无论当前内存是否够用,一定会回收。
PhantomReference, 有这个引用和没有一样,因为通过引用拿到的一定是 null,和弱引用一样,GC 工作时一定会被回收,唯一的作用就是监听对象被 GC 回收,可以用来做 GC 监听器,监听虚拟机的每一次 GC。
即在对象头部增加一个计数器,每当对象被引用,内部的计数器就 + 1,当引用失效,计数器就 - 1。这样当垃圾回收时,就回收那些计数值为 0 的对象。
缺点:很难解决对象之间相互循环引用的问题。
从堆栈和静态存储区出发,遍历所有引用,找出所有存活的对象。每当找到一个对象,就给它设置一个标记,这样它就不会被回收。
缺点:清除后会产生大量的不连续空间,这样对于将来大对象的内存分配是不利的,因为可能因为找不到连续的大块内存,不得不触发下一次 gc。
将可用空间分为两块 A 和 B,每次只使用 AB 其中的一块,比如当 A 中内存已满的时候,就将 A 中所有存活的对象一次性复制到 B 中,然后清空整个 A 区。一般来说,98% 的对象都是朝生夕死,所有没必要 1:1 的分配 AB,所以一般会将内存划分为 3 块,一块较大的 Eden 区,两块较小的 Survivor 区。每次使用 Eden 和一块 Survivor,GC 时将存活对象移动到另一块 Survivor 中。但是一块 Survivor 可能容纳不下所有的存活对象,所以需要依赖另一块进行分配担保,只能将存活的一部分对象放入担保中,这就是内存的分配担保机制。
这种方法的标记过程和标记清除算法一样,但是它解决了标记清除的问题。它的清扫过程不是直接进行的,而是先将所有存活对象都移动到一边,然后整个清扫那一边,这样就不存在内存空间不连续的问题了。
新生代分为 Eden 区和 Survivor 区,大小比例通常为 8:1,新对象一般在 Eden 区进行分配,当 Eden 区已满时,虚拟机会发起一次 GC,将 Eden 区还存活的对象移动至 Survivor 区,并一次性清扫 Eden 区。
所谓的大对象是指需要大量连续内存空间的对象,比如说很长的字符串或者数组。经常出现大对象容易导致内存还有不少空间时就要进行 gc,以获取足够空间来存放它们。
如果对象在 Eden 区出生,并能够顺利熬过第一次 gc,且能被 Survivor 区容纳的话,那么将被移动到 Survivor 区,并初始化年龄为 1 岁,以后每熬过一次 gc,年龄就加一次,当年龄增加到一定程度(默认 15),就会晋升到老年代中。
对于所有的对象总不能一刀切吧,每种算法都有其缺点和优点。所以一般会将对象划分为新生代和老年代,对于新生代这种朝生夕死的对象,用复制清除算法,并采用老年代进行担保。而对于老年代中生命力较为顽强的对象,采用复制清除是不合适的,因为需要巨大的空间来进行分配担保,所以一般会采用标记清除或者标记整理算法。
新生代 gc 比较频繁、对象存活率低,用复制算法在回收时的效率会更高,也不会产生内存碎片。
第一次 GC:Eden 区的存活对象移动到 Survivor 区;
第二次 GC:Eden 区和 Survivor 区都发生了 GC,都会只有一部分对象存活,这时再将 Eden 区存活对象复制至 Survivor 区,因为 Survior 自身存活对象的不连续性,便会产生内存碎片。
第一次 GC:Eden 区的存活对象移动到 S1 区,S2 空闲;
第二次 GC:Eden 区和 S1 区都发生了 GC,都会只有一部分对象存活,这时再将 Eden 和 S1 的存活对象复制至 S2 区,这时 Eden 和 S1 又会保持空闲,且 S2 中的空闲内存也是连续的。
只学习理论是不够的,只会似懂非懂,绝知此事要躬行,下面我们就来通过实战分析一下垃圾回收器。
首先通过定时器每秒分配一个 100M 的大数组,并实时查看内存使用情况。
- public static void main(String[] args) {
- Timer timer = new Timer();
- TimerTask task = new TimerTask() {
- @Override
- public void run() {
- Runtime runtime = Runtime.getRuntime();
- System.out.print("total:"+(runtime.totalMemory()/1024)+ "k\n");
- long free=runtime.freeMemory()/1024;
- System.out.print("free:" + free+ "k\n");
- if(free<102400){
- System.out.print("need gc"+"\n");
- }
- byte[] a1 = new byte[100 * 1024 * 1024];
- a1[1] = 1;
- System.out.print(a1[1]+"\n");
- }
- };
- timer.schedule(task, 1000, 1000);
- }
下面来看看打印出的信息:
- total:251392k
- free:243527k
- 1
- total:251392k
- free:141127k
- 1
- total:354304k
- free:141639k
- 1
- total:457216k
- free:142151k
- 1
- total:560128k
- free:142663k
可见分配大对象时,虚拟机并不是频繁的 gc,而是在不断的申请内存,totalMemory 在不断变大。
下面在通过设置 - XX:+PrintGCDetails 来打印 GC 日志:
- max:3728384k
- total:2872832k
- free:100166k
- need gc
- [GC (Allocation Failure) [PSYoungGen: 7864K->480K(76288K)] 2772665K->2765280K(2872832K), 0.0423481 secs] [Times: user=0.15 sys=0.01, real=0.05 secs]
- [GC (Allocation Failure) [PSYoungGen: 480K->448K(76288K)] 2765280K->2765256K(2872832K), 0.0365171 secs] [Times: user=0.11 sys=0.00, real=0.03 secs]
- [Full GC (Allocation Failure) [PSYoungGen: 448K->0K(76288K)] [ParOldGen: 2764808K->423K(79360K)] 2765256K->423K(155648K), [Metaspace: 3019K->3019K(1056768K)], 0.4096268 secs] [Times: user=0.04 sys=0.37, real=0.41 secs]
- 1
- max:3728384k
- total:258560k
- free:154426k
通过 GC 日志可知,当连续空间不够时,分配对象失败,然后会针对新生代发起一次 minorGC,内存使用从 7864k 降低到了 480k,这样还剩余 75808k,但是还不够分配 102400k 的数组,所以接下来又因为分配失败发起一次 minorGC,但是这次却没有回收到太多垃圾,那么怎么办,只能往老年代分配了。现在我们来计算一下老年代还有多少空间:
- old = total - young = 2872832 - 76288 =2796544k
- oldFree = old - oldUsed = 2796544 - 2764808 =31736k
老年代也只剩余 31736k 的空间,根本不够分配 102400k 的数组,这时就出现了内存分配担保失败,也就导致了下一步的发生:Full GC。
Full GC 一般都是担保失败才出现,表明这次 GC 发生了 Stop The World,会对所有年代进行回收。可以看见,第三次 GC 后,新生代对象被全部回收,老年代的大对象也基本被回收,但是这次 GC 后 totalMemory 变小了,从 2872832k 降低为 155648k 了。这时无论是新生代还是老年代都不够分配数组了,所以只能申请更大的内存空间了,分配数组后 totalMemory 又升至 258560k 了。
当手动停止程序后,又打印出了新的堆内存日志:
- Heap PSYoungGen total 76288K,
- used 3058K[0x000000076ab00000, 0x0000000774000000, 0x00000007c0000000) eden space 65536K,
- 4 % used[0x000000076ab00000, 0x000000076adfca70, 0x000000076eb00000) from space 10752K,
- 0 % used[0x000000076f580000, 0x000000076f580000, 0x0000000770000000) to space 10752K,
- 0 % used[0x000000076eb00000, 0x000000076eb00000, 0x000000076f580000) ParOldGen total 799744K,
- used 717223K[0x00000006c0000000, 0x00000006f0d00000, 0x000000076ab00000) object space 799744K,
- 89 % used[0x00000006c0000000, 0x00000006ebc69d08, 0x00000006f0d00000) Metaspace used 3029K,
- capacity 4500K,
- committed 4864K,
- reserved 1056768K class space used 331K,
- capacity 388K,
- committed 512K,
- reserved 1048576K
从上面可以看出,新生代中 eden 区只使用了 4% 的空间,而老年代却使用了 89% 的空间,这也印证了大对象都在老年代中进行分配。
只看 GC 日志的话,很多信息无法得到,那么最好的办法就是实时监控内存了。
接下来对上面的程序进行一些小改变,通过一个类变量对分配的数组加以强引用,这样保证数组不会被 gc 回收掉,然后我们就可以来监控 JVM 了。
- WangXiandengdeMacBook-Pro:test wangxiandeng$ jps -l
- 38464
- 66083 com.intellij.rt.execution.application.AppMain
- 15875 com.xk72.charles.macosx.Main
- 66082 org.jetbrains.jps.cmdline.Launcher
- 66084 sun.tools.jps.Jps
- 22155
AppMain 即为当前运行的进程 pid,为 66083。
- WangXiandengdeMacBook-Pro:test wangxiandeng$ jstat -gc 66083 1000 100
- S0C S1C S0U S1U EC EU OC OU MC MU CCSC CCSU YGC YGCT FGC FGCT GCT
- 10752.0 10752.0 0.0 0.0 65536.0 6554.2 175104.0 0.0 4480.0 771.4 384.0 75.8 0 0.000 0 0.000 0.000
- 10752.0 10752.0 0.0 0.0 65536.0 7864.9 175104.0 102400.0 4480.0 771.4 384.0 75.8 0 0.000 0 0.000 0.000
- 10752.0 10752.0 0.0 0.0 65536.0 7864.9 278016.0 204800.0 4480.0 771.4 384.0 75.8 0 0.000 0 0.000 0.000
- 10752.0 10752.0 0.0 0.0 65536.0 7864.9 380928.0 307200.0 4480.0 771.4 384.0 75.8 0 0.000 0 0.000 0.000
- ................
- 10752.0 10752.0 0.0 0.0 65536.0 7864.9 2796544.0 2764800.4 4480.0 771.4 384.0 75.8 0 0.000 0 0.000 0.000
- 10752.0 10752.0 0.0 0.0 65536.0 0.0 2796544.0 2765263.3 4864.0 3069.5 512.0 335.1 3 0.124 2 0.072 0.196
可见,100m 的数组全都是直接在老年代中直接分配,最后一行可以看出,此时内存已经不够分配,于是发生了 3 次 minorGC,2 次 FullGC,但是因为老年代中的数组全被强引用了,导致无法回收。
- [GC (Allocation Failure) [PSYoungGen: 7864K->512K(76288K)] 2772665K->2765320K(2872832K), 0.0433492 secs] [Times: user=0.16 sys=0.01, real=0.04 secs]
- [GC (Allocation Failure) [PSYoungGen: 512K->496K(76288K)] 2765320K->2765304K(2872832K), 0.0422158 secs] [Times: user=0.11 sys=0.00, real=0.04 secs]
- [Full GC (Allocation Failure) [PSYoungGen: 496K->0K(76288K)] [ParOldGen: 2764808K->2765263K(2796544K)] 2765304K->2765263K(2872832K), [Metaspace: 3069K->3069K(1056768K)], 0.0720605 secs] [Times: user=0.04 sys=0.07, real=0.08 secs]
- [GC (Allocation Failure) [PSYoungGen: 0K->0K(76288K)] 2765263K->2765263K(2872832K), 0.0388848 secs] [Times: user=0.15 sys=0.00, real=0.03 secs]
- [Full GC (Allocation Failure) [PSYoungGen: 0K->0K(76288K)] [ParOldGen: 2765263K->2765246K(2796544K)] 2765263K->2765246K(2872832K), [Metaspace: 3069K->3069K(1056768K)], 0.0291437 secs] [Times: user=0.04 sys=0.00, real=0.03 secs]
- Exception in thread "Timer-0" java.lang.OutOfMemoryError: Java heap space
- at com.wangxiandeng.test.Test$1.run(Test.java:30)
- at java.util.TimerThread.mainLoop(Timer.java:555)
- at java.util.TimerThread.run(Timer.java:505)
这时虽然 totalMemory 远未达到 maxMemeory,但是因为老年代能够分配到的空间有限,即老年代已经不能申请到新的空间了,而这些大数组又无法放入新生代中,所以只能内存溢出了。
虽然了解 java 不需要我们手动管理内存,但是了解这方面还是很有必要的,一是可以避免写出导致频繁 GC、内存泄漏和溢出的代码,二是可以对虚拟机进行调优,虽然一般调优都是服务器端同学的事,但是今天却看到了维术同学的一篇新文章 , 很是佩服。
来源: