JVM——内存泄漏排查

GC 回收

对于 java GC 回收来说,怎么确认一个对象是否可被回收是最首要的事情。

1.引用计数
很多教科书判断对象是否存活的算法是这样的:给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加 1;当引用失效时,计数器值就减 1;任何时刻计数器为 0 的对象就是不可能再被使用的。

引用计数算法(Reference Counting)的实现简单,判定效率也很高,在大部分情况下它都是一个不错的算法。但是它很难解决对象之间相互循环引用的问题。

举个简单的例子:对象 objA 和 objB 都有字段 instance,赋值令 objA.instance=objB 及 objB.instance=objA,除此之外,这两个对象再无任何引用,实际上这两个对象已经不可能再被访问,但是它们因为互相引用着对方,导致它们的引
用计数都不为 0,于是引用计数算法无法通知 GC 收集器回收它们。

所以, java 虚拟机并不是通过引用计数算法来判断对象是否存活的。

2.可达性分析算法
这个算法的基本思路就是通过一系列的称为 “GC Roots” 的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到 GC Roots 没有任何引用链相连(用图论的话来说,就是从 GC Roots 到这个对象不可达)时,则证明此对象是不可用的。 如图所示,对象 Obj D、 Obj E 虽然互相有关联,但是它们到 GC Roots 是不可达的,所以它们将会被判定为是可回收的对象。

 

java 堆栈

SUN 公司的 java 规范中,运行时数据区域分为两大块,一个是共享数据区,一个是线程私有。其中共享数据区包括方法区和堆,线程私有包括程序计数器 PC,虚拟机栈和本地方法栈。

 

1.程序计数器 PC
很小,存放下一条指令(即下一条该执行的代码),属于线程私有的。

CPU 运行是 抢占式 的,在同一时间点只会执行一个代码语句。多线程时候,各个线程进行抢占 CPU,当 A 线程运行到第 10 行的时候,B 线程抢占了 CPU,一段时间后,A 线程又抢占回来,这时候 A 线程需要继续第 11 行代码,这个就要靠程序计数器 PC 进行保存。

当程序在执行一行 java 代码的时候,程序计数器 PC 记录的是虚拟机字节码的地址;当执行的是一个 native 方法, 程序计数器 PC 为 null。

注: *程序计数器 PC 是 java 虚拟机规范中唯一没有 oom 的区域。
 

2.虚拟机栈和本地方法栈
本地方法栈(Native Method Stacks)与虚拟机栈所发挥的作用是非常相似的,其区别不过是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的Native方法服务。

关于虚拟机栈和本地方法栈,在Java虚拟机规范中描述了两种异常:
1.如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出StackOverflowError异常。

2.如果虚拟机在扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError异常。

这里把异常分成两种情况,看似更加严谨,但却存在着一些互相重叠的地方:当栈空间无法继续分配时,到底是内存太小,还是已使用的栈空间太大,其本质上只是对同一件事情的两种描述而已。

在笔者的实验中,将实验范围限制于单线程中的操作,尝试了下面两种方法均无法让虚拟机产生 OutOfMemoryError 异常,尝试的结果都是获得 StackOverflowError 异常,测试代码如代码清单 2-4 所示。
1.使用-Xss参数减少栈内存容量。 结果:抛出 StackOverflowError 异常,异常出现时输出的堆栈深度相应缩小。
2.定义了大量的本地变量,增大此方法帧中本地变量表的长度。 结果:抛出  StackOverflowError 异常时输出的堆栈深度相应缩小。
 

代码清单 2-4 虚拟机栈和本地方法栈 OOM 测试(仅作为第 1 点测试程序)

/**
 * VM Args:-Xss128k
 * 
 * @author zzm
 */
public class JavaVMStackSOF {
    private int stackLength = 1;

    public void stackLeak() {
        stackLength++;
        stackLeak();
    }

    public static void main(String[] args) throws Throwable {
        JavaVMStackSOF oom = new JavaVMStackSOF();
        try {
            oom.stackLeak();
        } catch (Throwable e) {
            System.out.println("stack length:" + oom.stackLength);
            throw e;
        }
    }
}
 

运行结果:

stack length:2402
Exception in thread"main"java.lang.StackOverflowError
at org.fenixsoft.oom.VMStackSOF.leak(VMStackSOF.java:20)
at org.fenixsoft.oom.VMStackSOF.leak(VMStackSOF.java:21)
at org.fenixsoft.oom.VMStackSOF.leak(VMStackSOF.java:21)

……后续异常堆栈信息省略
 

实验结果表明: 在单个线程下,无论是由于栈帧太大还是虚拟机栈容量太小,当内存无法分配的时候,虚拟机抛出的都是 StackOverflowError 异常。

如果测试时不限于单线程,通过不断地建立线程的方式倒是可以产生内存溢出异常,如代码清单 2-5 所示。 但是这样产生的内存溢出异常与栈空间是否足够大并不存在任何联系,或者准确地说,在这种情况下,为每个线程的栈分配的内存越大,反而越容易产生内存溢出异常。
 

其实原因不难理解,操作系统分配给每个进程的内存是有限制的,譬如 32 位的 Windows 限制为 2GB。 虚拟机提供了参数来控制 Java 堆和方法区的这两部分内存的最大值。 剩余的内存为 2GB(操作系统限制)减去 Xmx(最大堆容量),再减去 MaxPermSize(最大方法区容量),程序计数器消耗内存很小,可以忽略掉。 如果虚拟机进程本身耗费的内存不计算在内,剩下的内存就由虚拟机栈和本地方法栈“瓜分”了。 每个线程分配到的栈容量越大,可以建立的线程数量自然就越少,建立线程时就越容易把剩下的内存耗尽。

这一点读者需要在开发多线程的应用时特别注意,出现 StackOverflowError 异常时有错误堆栈可以阅读,相对来说,比较容易找到问题的所在。 而且,如果使用虚拟机默认参数,栈深度在大多数情况下(因为每个方法压入栈的帧大小并不是一样的,所以只能说在大多数情况下)达到1000~2000完全没有问题,对于正常的方法调用(包括递归),这个深度应该完全够用了。 但是,如果是建立过多线程导致的内存溢出,在不能减少线程数或者更换 64 位虚拟机的情况下,就只能通过减少最大堆和减少栈容量来换取更多的线程。 如果没有这方面的处理经验,这种通过“减少内存”的手段来解决内存溢出的方式会比较难以想到。

代码清单2-5 创建线程导致内存溢出异常。

/**
 * VM Args:-Xss2M(这时候不妨设置大些)
 * 
 * @author zzm
 */
public class JavaVMStackOOM {
	private void dontStop() {
		while (true) {
		}
	}

	public void stackLeakByThread() {
		while (true) {
			Thread thread = new Thread(new Runnable() {
				@Override
				public void run() {
					dontStop();
				}
			});
			thread.start();
		}
	}

	public static void main(String[] args) throws Throwable {
		JavaVMStackOOM oom = new JavaVMStackOOM();
		oom.stackLeakByThread();
	}
}

注意: 特别提示一下,如果读者要尝试运行上面这段代码,记得要先保存当前的工作。由于在 Windows 平台的虚拟机中,Java 的线程是映射到操作系统的内核线程上的 [1],因此上述代码执行时有较大的风险,可能会导致操作系统假死。

3.方法区

用于存储已被虚拟机加载的类信息、 常量、 静态变量、 即时编译器编译后的代码等数据。

根据 Java 虚拟机规范的规定,当方法区无法满足内存分配需求时,将抛出 OutOfMemoryError 异常。

在安卓中,方法区内存溢出的情况基本不会出现,如果是做 web 的,或者想了解的,可以自己查看 深入理解Java虚拟机 JVM高级特性与最佳实践。

注: 常量池属于方法区的一部分。

 

4.java 堆


对于大多数应用来说,.java 堆是 java 虚拟机管理最大的一块内存。此内存区域存在的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。

Java堆是垃圾收集器管理的主要区域,因此很多时候也被称做 “GC堆”。 从内存回收的角度来看,由于现在收集器基本都采用分代收集算法,所以 Java 堆中还可以细分为:新生代和老年代;再细致一点的有 Eden 空间、 From Survivor 空间、 To Survivor 空间等。 从内存分配的角度来看,线程共享的 Java 堆中可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB)。 不过无论如何划分,都与存放内容无关,无论哪个区域,存储的都仍然是对象实例,进一步划分的目的是为了更好地回收内存,或者更快地分配内存。

Java 堆用于存储对象实例,只要不断地创建对象,并且保证 GC Roots 到对象之间有可达路径来避免垃圾回收机制清除这些对象,那么在对象数量到达最大堆的容量限制后就会产生内存溢出异常。
 

java 堆内存不足时:

如果是内存泄露,可进一步通过工具查看泄露对象到 GC Roots 的引用链。 于是就能找到泄露对象是通过怎样的路径与GC Roots 相关联并导致垃圾收集器无法自动回收它们的。 掌握了泄露对象的类型信息及 GC Roots 引用链的信息,就可以比较准确地定位出泄露代码的位置。

如果不存在泄露,换句话说,就是内存中的对象确实都还必须存活着,那就应当检查虚拟机的堆参数(-Xmx 与 -Xms),与机器物理内存对比看是否还可以调大,从代码上检查是否存在某些对象生命周期过长、 持有状态时间过长>的情况,尝试减少程序运行期的内存消耗。
注:-Xmx(最大值) -Xmn(最小值)–Xms(初始值)

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值