TWT Studio - Android 组培训 & 技术分享

Java内存泄漏

2019-04-05  本文已影响97人  毒死预言家的女巫

本文将会介绍:

一、C++中的内存泄露

在大一上C++面向对象课的时候老师告诉我们,new 和 delete 一定要配对使用!不然就会造成内存泄漏。在当时听的迷迷糊糊的,只是记住了。但到底什么是内存泄漏?为什么 new 出来的内存不 delete 就会造成内存泄漏呢?这就要从C++的运行时内存区域划分来讲起了

  1. C++运行时内存区域划分
    咱们先来看这样一段代码——
#include <iostream>
 using namespace std;

int var1 = 1;
int* arr = new int[2];

void memLeak();

int main()
{
   while(1)
       memLeak();
   return 0;
}

void memLeak(){
   int a = 0;
   int* b = new int[2];
   // do something with b
}

我们声明的这些变量分别存放在不同的内存区域,如下图——

注:stack和静态/全局区的格子中展示的是存放的值,而Heap中展示的是地址

我们先看看这几个分区的介绍:

栈区(stack):存放局部变量和参数,申请和释放都由编译器自动完成
堆区(heap):动态内存分配,申请和释放都是由程序员控制。
静态区/全局区(static):存放全局变量和静态变量。

再结合我们的代码来看,我们在memLeak函数中声明的局部变量 a 和 c 都存放在栈区,而全局变量var1则存放在静态全局区。存放在栈中的变量我们无需进行管理,随着函数的返回,分配的内存自动进行了回收;而存放在静态/全局区中的变量的生命周期是跟随整个程序的,我们也无需在代码中进行内存的管理。

那new出来的内存呢?从图中我们可以看出,我们在函数中执行 int* b = new int[2]; 并非是在栈中开辟了两个int大小的空间,而是在堆中分配了空间,并将这片空间的头地址赋值给b,与此同时,插一面小旗子,占山为王,说这一块地已经是我的了,只有我能在上面撒欢,别人不许过来!但是在函数结束之后,这一块内存并不会自动释放(拔掉小旗子,这一块地方可以被别人使用), 如果不手动调用 delete 去释放内存,其生命周期与会与程序一样长。

就我个人总结来说的话,内存泄漏就是在堆上已经无用的内存并未被回收而无法挪作他用的情况。

  1. C++中的内存泄漏

之前的代码就是一个内存泄漏的例子,在memLeak函数外,我们没有再用到 b 申请的内存,在memLeak函数中,我们并没有释放动态申请的内存。我们在外面死循环调用函数,会导致在堆中越来越多的人“占地为王”,最终导致堆被占满,其他地方无法再正常的申请到堆中内存。

#include <iostream>
using namespace std;

int main(){
    int b = new int[1024]; // #1 this memory was not recycled,4 KB memory was wasted
    //..
    b = new int[10000];
    //do some things
    delete b;
    //do other things
    return 0;
}

同时还在上述情况,一开始b 指向了一片分配的内存(注释1),而 b 又指向了其他内存,此时再也没有办法可以访问到注释1处分配的内存,导致内存泄漏。

二、Java内存管理和垃圾回收

  1. JVM 运行时内存模型
  • Java 虚拟机栈
  • Java 堆
  • 方法区

相信大家此时对栈和堆已经有了一定的认识,其实方法区也和 C++ 中的静态/全局区有一些类似,是用于存储虚拟机加载的类信息、常量、静态变量、及时编译后的代码等数据的地方。

同 C++ 一样,Java 中 new 关键字也是在堆上分配内存,将首地址赋给(*句柄,句柄的地址再赋给)局部变量。而与 C++ 不同的是,JVM 提供了 GC(Garbage Collection) 机制来管理 Java 堆中的内存。

(* 句柄...): 分别对应句柄使用访问和使用直接指针访问的两种对象访问模式,在 Sun 公司 HotSpot 虚拟机中是采用直接指针的访问模式(即直接定位对象)。

  1. GC

在讨论 GC 之前,我们先来想一个问题,为什么堆区需要专门的垃圾回收机制?

一个接口中的多个实现类需要的内存可能不一样,一个方法的多个分支需要的内存也可能不一样,我们只有在程序处于运行期间才内知道创建那些对象,这部分的内存分配是动态的。
——《深入理解Java虚拟机》

我个人理解,首先这一片内存是动态分配的,我们无法从编译期得知需要分配多少内存,其次因为程序不知道这一块内存什么时候会没用,所以需要一个算法,来告诉虚拟机,这一片内存已经不会再被使用了,即对象已死亡。

在Java GC的主流实现中,都是通过可达性分析 (Reachability analysis) 来实现判定对象是否存活的。这个算法的基本思想就是通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,所走过的路径称为引用链 (Reference Chain),当一个对象到 GC Roots 没有任何引用链的时,则证明此对象是不可用的。
在Java语言中,可作为 GC Roots 的对象包括以下几种
》 虚拟机栈中引用的对象
》 方法区中类静态属性引用的对象
》 方法区中常量引用的对象
》 本地方法栈中 JNI 引用的对象
——《深入理解Java虚拟机》 周志明

举个例子~

class Test{

    int id;
    Test(int id){
        this.id = id;
    }

    public static void main(String []args){
        Test test = new Test(0);
        System.gc();
        System.out.println("--------------");
        test = null;
        System.gc();
    }

    @Override
    public void finalize(){
        System.out.println("Test" + id);
    }

}
运行结果:

一开始,在栈中存在一个GC Root test, 它指向了在堆中的Test对象,这个对象在存在一条到GC Roots的引用链,此时调用 System.gc() 不会回收该对象,而后我们执行了 test = null,断开了对象与 GC Root 的引用链,表明此对象已经死亡,再次调用 System.gc() 对象被回收(执行了finalize方法):

三、Java中的内存泄露

在第二部分我们介绍了Java的 GC 机制,由虚拟机通过一定的算法在帮我们在 gc 的时候去释放一些堆中无用的内存。但这并不能完全避免 Java 的内存泄漏,当引用链的生命周期长于对象的生命周期时,还是会出现内存泄漏的情况。
在网上搜集了一些内存泄漏的例子——

  1. 静态集合类引起内存泄漏
static Vector v = new Vector(10);
for (int i = 1; i<100; i++)
{
    Object o = new Object();
    v.add(o);
    o = null;
}

在这个例子中,循环申请Object 对象,并将所申请的对象放入一个Vector 中,如果仅仅释放引用本身(o=null),那么Vector 仍然引用该对象,所以这个对象对GC 来说是不可回收的。因此,如果对象加入到Vector 后,还必须从Vector 中删除,最简单的方法就是将Vector对象设置为null。

  1. 当集合里面的对象属性被修改后,再调用remove()方法时不起作用
public static void main(String[] args)
{
    Set<Person> set = new HashSet<Person>();
    Person p1 = new Person("唐僧","pwd1",25);
    Person p2 = new Person("孙悟空","pwd2",26);
    Person p3 = new Person("猪八戒","pwd3",27);
    set.add(p1);
    set.add(p2);
    set.add(p3);
    System.out.println("总共有:"+set.size()+" 个元素!"); //结果:总共有:3 个元素!
    p3.setAge(2); //修改p3的年龄,此时p3元素对应的hashcode值发生改变

    set.remove(p3); //此时remove不掉,造成内存泄漏

    set.add(p3); //重新添加,居然添加成功
    System.out.println("总共有:"+set.size()+" 个元素!"); //结果:总共有:4 个元素!
    for (Person person : set)
    {
         System.out.println(person);
    }
}
  1. 单例模式
class A{
    public A(){
            B.getInstance().setA(this);
    }
//...
}
//B类采用单例模式
class B{
    private A a;
    private static B instance=new B();
    public B(){}
    public static B getInstance(){
        return instance;
    }
    public void setA(A a){
        this.a=a;
    }
    //getter...  
} 

显然B采用singleton模式,它持有一个A对象的引用,而这个A类的对象将不能被回收。想象下如果A是个比较复杂的对象或者集合类型会发生什么情况

  1. 非静态内部类
public class MainActivity extends Activity {
    ...
    Handler handler = new MyHandler();
    Runnable ref1 = new MyRunable();
    Runnable ref2 = new Runnable() {
        @Override
        public void run() {
              //
        }
    };
       ...
    public void dosomething(){
        handler.post(ref1); 
    }
}
ref1和ref2的区别是,ref2使用了匿名内部类。我们来看看运行时这两个引用的内存: image

可以看到ref2这个匿名类的实现对象里面多了一个引用:
this$0这个引用指向MainActivity.this,也就是说当前的MainActivity实例会被ref2持有,如果将这个引用再传入一个异步线程,此线程和此Acitivity生命周期不一致的时候,就造成了Activity的泄露。同理,Handler也是这样。


引用:
  1. C/C++程序编译时和运行时内存区域分配
  2. 《深入理解Java虚拟机》 —— 周志明
  3. Android内存泄漏总结
上一篇下一篇

猜你喜欢

热点阅读