服务器 频道

干货分享:浅谈内存泄露

作者:孤独烟 来源:微信公众号(打杂的ZRJ)

原文链接:https://mp.weixin.qq.com/s/TEeuqi3PXfpj0zvDKrX_vQ 孤独烟

前言

这个话题已经是老生常谈了,之所以又被我拎出来,是因为博主隔壁的一个童鞋最近写了一篇叫做《ThreadLocal内存泄露》的文章,我就不上链接了,因为写的实在是。。 (省略一万字) 
重点是写完后,还被我问懵了。出于人道主义关怀,博主很不要脸的再写一篇。

正文

定义 

首先,我们要先谈一下定义,因为一堆人搞不懂内存溢出和内存泄露的区别。 
内存溢出(OutOfMemory): 你只有十块钱,我却找你要了一百块。对不起啊,我没有这么多钱。 (给不起) 
内存泄露(MemoryLeak): 你有十块钱,我找你要一块。但是无耻的博主,不把钱还你了。 (没退还) 
关系: 多次的内存泄露,会导致内存溢出。(博主不要脸的找你多要几次钱,你就没钱了,就是这个道理。)

危害 

ok,大家在项目中有没遇到过java程序越来越卡的情况。 
因为内存泄露,会导致频繁的 Full GC ,而 Full GC   又会造成程序停顿,最后Crash了。因此,你会感觉到你的程序越来越卡,越来越卡,然后你就被产品经理鄙视了。顺便提一下,我们之所以JVM调优,就是为了减少 Full GC 的出现。 
我记得,我曾经有一次,就遇到项目刚上线的时候好好的。结果随着时间的堆积,报了 OutOfMemoryError: PermGen space 。 
说到这个 PermGen space ,突然间,一阵洪荒之力,从博主体内喷涌而出,一定要介绍一下这个方法区,不过点到为止,毕竟这不是在讲《jvm从入门到放弃》。 
方法区 :出自java虚拟机规范, 可供 各条线程共享 的 运行时内存区域 。它存储了 每一个类的结构信息 ,例如运行时常量池( Runtime Constant Pool )、字段和方法数据、构造函数和普通方法的字节码内容。 
上面讲的是规范,在不同虚拟机里头实现是不一样的,最典型的就是 永久代(PermGen space) 和 元空间(Metaspace)

jdk1.8以前: 实现方法区的叫永久代。因为在很久远以前,java觉得类几乎是 静态的 ,并且很少被卸载和回收,所以给了一个 永久代 的雅称。 因此 ,如果你在项目中,发现堆和永久代一直在不断增长,没有下降趋势,回收的速度根本赶不上增长的速度,不用说了,这种情况基本可以确定是内存泄露。

jdk1.8以后: 实现方法区的叫元空间。Java觉得对永久代进行调优是很困难的。永久代中的元数据可能会随着每一次 Full GC 发生而进行移动。并且为永久代设置空间大小也是很难确定的。 因此 ,java决定将类的元数据分配在本地内存中,元空间的最大可分配空间就是系统可用内存空间。这样,我们就避开了设置永久代大小的问题。 但是 ,这种情况下,一旦发生内存泄露,会占用你的大量本地内存。如果你发现,你的项目中本地内存占用率异常高。嗯,这就是内存泄露了。

如何排查 

(1)通过 jps 查找java进程id。 
(2)通过 top -p [pid] 发现内存占用达到了最大值 
(3) jstat -gccause pid 20000   每隔20秒输出 Full GC 结果 
(4)发现 Full GC 次数太多,基本就是内存泄露了。生成 dump 文件,借助工具分析是哪个对象太多了。基本能定位到问题在哪。

实例 

在stackoverflow上,有一个问题,如下所示

I just had an interview, and I was asked to create a memory leak with Java. Needless to say I felt pretty dumb having no clue on how to even start creating one.

大致就是,因为面试需要手写一段内存泄露的程序,然后提问的人突然懵逼了,于是很多大佬纷纷给出回答。 
案例一 
此例子出自《算法》(第四版)一书,我简化了一下

1
2
3
4
5
6
7
8
9
10
    class stack{    
        Object data[1000];    
        int top = 0;    
        public void push(Object o){        
            data[top++] = o;   
        }    
        public Object pop(Object o){ 
            return data[--top];
        }
    }

当数据从栈里面弹出来之后,data数组还一直保留着指向元素的指针。那么就算你把栈pop空了,这些元素占的内存也不会被回收的。 
解决方案就是

1
2
3
4
5
    public Object pop(Object o){ 
        Object result = data[--top];
        data[top] = null;
        return result;
    }

案例二 
这个其实是一堆例子,这些例子造成内存泄露的原因都是类似的,就是 不关闭流 ,具体的,可以是文件流,socket流,数据库连接流,等等 
具体如下,没关文件流

1
2
3
4
5
6
7
try {
    BufferedReader br = new BufferedReader(new FileReader(inputFile));
    ...
    ...
catch (Exception e) {
    e.printStacktrace();
}

再比如,没关闭连接

1
2
3
4
5
6
7
try {
    Connection conn = ConnectionFactory.getConnection();
    ...
    ...
catch (Exception e) {
    e.printStacktrace();
}

解决方案就是。。。嗯,大家应该都会。。你敢说你不会调 close() 方法。 
案例三 
讲这个例子前,大家对 ThreadLocal 在 Tomcat 中引起内存泄露有了解么。不过,我要说一下,这个泄露问题,和ThreadLocal本身关系不大,我看了一下官网给的例子,基本都是属于使用不当引起的。 
在Tomcat的官网上,记录了这个问题。地址是: https://wiki.apache.org/tomcat/MemoryLeakProtection 
不过,官网的这个例子,可能不好理解,我们略作改动。

1
2
3
4
5
6
7
8
9
10
11
public class HelloServlet extends HttpServlet{
    private static final long serialVersionUID = 1L;
    static class LocalVariable {
        private Long[] a = new Long[1024 * 1024 * 100];
    }
    final static ThreadLocal<LocalVariable> localVariable = new ThreadLocal<LocalVariable>();
    @Override
    public void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException {
        localVariable.set(new LocalVariable());
    }
}

再来看下conf下sever.xml配置

1
2
3
  <!--The connectors can use a shared executor, you can define one or more named thread pools-->
    <Executor name="tomcatThreadPool" namePrefix="catalina-exec-" 
        maxThreads="150" minSpareThreads="4"/>

线程池最大线程为150个,最小线程为4个

Tomcat中Connector组件负责接受并处理请求,每来一个请求,就会去线程池中取一个线程。 
在访问该servlet时, ThreadLocal 变量里面被添加了 new LocalVariable() 实例,但是没有被 remove ,这样该变量就随着线程回到了线程池中。另外多次访问该 servlet 可能用的不是工作线程池里面的同一个线程,这会导致工作线程池里面多个线程都会存在内存泄露。

另外, servlet 的 doGet 方法里面创建 new LocalVariable() 的时候使用的是 webappclassloader 。 
那么 
LocalVariable 对象没有释放 ->   LocalVariable.class 没有释放 -> webappclassloader 没有释放 ->  webappclassloader 加载的所有类也没有被释放,也造成了内存泄露。

除此之外,你在 eclipse 中,做一个 reload 操作,工作线程池里面的线程还是一直存在的,并且线程里面的threadLocal 变量并没有被清理。而 reload 的时候,又会新构建一个 webappclassloader ,重复上述步骤。多reload几次,就内存溢出。 
不过Tomcat7.0以后,你每做一次 reload ,会清理工作线程池中线程的 threadLocals 变量。因此,这个问题在tomcat7.0后,不会存在。

ps: ThreadLocal 的使用在 Tomcat 的服务环境下要注意,并非每次web请求时候程序运行的 ThreadLocal 都是唯一的。 ThreadLocal 的什么生命周期不等于一次 Request 的生命周期。 ThreadLocal 与线程对象紧密绑定的,由于Tomcat 使用了线程池,线程是可能存在复用情况。

0
相关文章