内存泄漏常见场景及处理方法

1.Java垃圾回收(GC)

3971263068-5c03cfd1ce14b_articlex.png

Java 是如何管理内存
为了判断Java中是否有内存泄露,我们首先必须了解Java是如何管理内存的。Java的内存管理就是对象的分配和释放问题。在Java中,程序员需要通过关键字new为每个对象申请内存空间 (基本类型除外),所有的对象都在堆 (Heap)中分配空间。另外,对象的释放是由GC决定和执行的。在Java中,内存的分配是由程序完成的,而内存的释放是有GC完成的,这种收支两条线的方法确实简化了程序员的工作。但同时,它也加重了JVM的工作。这也是Java程序运行速度较慢的原因之一。因为,GC为了能够正确释放对象,GC必须监控每一个对象的运行状态,包括对象的申请、引用、被引用、赋值等,GC都需要进行监控。监视对象状态是为了更加准确地、及时地释放对象,而释放对象的根本原则就是该对象不再被引用。

为了更好理解GC的工作原理,我们可以将对象考虑为有向图的顶点,将引用关系考虑为图的有向边,有向边从引用者指向被引对象。另外,每个线程对象可以作为一个图的起始顶点,例如大多程序从main进程开始执行,那么该图就是以main进程顶点开始的一棵根树。在这个有向图中,根顶点可达的对象都是有效对象,GC将不回收这些对象。如果某个对象 (连通子图)与这个根顶点不可达(注意,该图为有向图),那么我们认为这个(这些)对象不再被引用,可以被GC回收。

在 Java 中垃圾判断方法是可达性分析算法,这个算法的基本思路是通过一系列的"GC Root"的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径成为引用链,当一个对象到GC Root没有任何引用链相连时,则证明此对象是不可用的。

  • GC Root的对象包括以下几种:

(1)虚拟机栈中引用的对象

(2)方法区中类静态属性引用的对象

(3)方法区中常量引用的对象

(4)本地方法栈中JNI引用的对象

就算一个对象,通过可达性分析算法分析后,发现其是『不可达』的,也并不是非回收不可的。

  • 一般情况下,要宣告一个对象死亡,至少要经过两次标记过程:

1、经过可达性分析后,一个对象并没有与GC Root关联的引用链,将会被第一次标记和筛选。筛选条件是此对象有没有必要执行finalize()方法。如果对象没有覆盖finalize()方法,或者已经执行过了。那就认为他可以回收了。如果有必要执行finalize()方法,那么将会把这个对象放置到F-Queue的队列中,等待执行。

2、虚拟机会建立一个低优先级的Finalizer线程执行F-Queue里面的对象的finalize()方法。如果对象在finalize()方法中可以『拯救』自己,那么将不会被回收,否则,他将被移入一个即将被回收的ReferenceQ

2.内存泄漏

在Java中,内存泄漏就是存在一些被分配的对象,这些对象有下面两个特点,首先,这些对象是可达的,即在有向图中,存在通路可以与其相连;其次,这些对象是无用的,即程序以后不会再使用这些对象。如果对象满足这两个条件,这些对象就可以判定为Java中的内存泄漏,这些对象不会被GC所回收,然而它却占用内存,如果长时间积累导致过多的对象不能被回收,最终导致 OOM。

在C++中,内存泄漏的范围更大一些。有些对象被分配了内存空间,然后却不可达,由于C++中没有GC,这些内存将永远收不回来。在Java中,这些不可达的对象都由GC负责回收,因此程序员不需要考虑这部分的内存泄露。

通过分析,我们得知,对于C++,程序员需要自己管理边和顶点,而对于Java程序员只需要管理边就可以了(不需要管理顶点的释放)。通过这种方式,Java提高了编程的效率。

因此,通过以上分析,我们知道在Java中也有内存泄漏,但范围比C++要小一些。因为Java从语言上保证,任何对象都是可达的,所有的不可达对象都由GC管理。

截屏2021-01-06 下午6.51.07.png

3 内存泄漏常见场景

(1) 单例内存泄漏

推荐)单例中的函数参数不作为单例的成员变量保存

例:

public class AppManager {

    private static AppManager sInstance;
    private CallBack mCallBack;
    private Context mContext;

    private AppManager(Context context) {
        this.mContext = context;
    }

    public static AppManager getInstance(Context context) {
        if (sInstance == null) {
            sInstance = new AppManager(context);
        }
        return sInstance;
    }

    public void addCallBack(CallBack call){
        mCallBack = call;
    }
}

问题一:

分析:构造单例时传入的 Context 如果是 Activity,那么当 Activity 销毁时时得不到释放,就会出现内存泄漏,因为单例的生命周期是应用级别的,如果重复创建该 Activity,导致越来越多的实例存在,最终出现 OOM。

解决思路:

(1)推荐可以将 Context 作为调用方法的参数,不保存单例的成员变量,函数执行后不在持有该对象

(2)如果一定需要保存 Context,可以设置为 Application 的 Context

问题二:

分析:CallBack 也作为成员变量保存,CallBack 创建时会持有外部类实例,CallBack 不能释放,持有的外部类引用(如 Activity,或者 View)在垃圾回收时也不能释放,出现内存泄漏。

解决思路:

(1)同样推荐将 CallBack 作为调用方法的参数,不保存单例的成员变量

(2)如果一定需要保存 Context,使用虚引用(WeakReference)保存

匿名内部类/非静态内部类创建静态实例造成的内存泄漏

匿名内部类/非静态内部类持有外部类可以总结为以下两个作用:

  • (1)当内部类仅在外部类内使用,可以让外部不知道内部类的存在,从而减少代码的维护,体现封装性和扩展性,扩展 Java 单继承的限制

  • (2)内部类持有外部类实例,内部类就可以使用外部类的变量和方法

// 匿名了内部类,持有外部类实例,如 Activity、Fragment、View 等
Runnable runnable1 = new Runnable() {
    @Override
    public void run() {
    // do something
    }
};
// 静态内部类对象,不持有外部引用
Runnable runnable2 = new MyRunnable();

/** 静态类,不依赖外部对象 */
private static class MyRunnable implements Runnable {
    @Override
    public void run() {
    // do something
    }
}

【推荐】推荐使用静态内部类

Handler 内存泄漏 / View.post() 内存泄漏

  1. Handler 内存泄漏
    实际上 Handler 的内存泄漏可以归属到匿名内部类内存泄漏一类。Handler 使用一般使用两种方式,Handler.post(Runnable r) 和 Handler.sendMessage(Message msg),post 方法最终也是调用 sendMessage,封装成 Message 放到 Looper 中。
  • Handler.post(Runnable r) 内存泄漏

调用 Handler.post,由于 Handler 是主线程 Handler,所以可以通过 post 来更新 UI,获取 View 宽高等,一般情况下不会出现问题,但是特殊情况下,如果 Activity 已经销毁,Runnable 还没执行,就会出现内存泄漏。引用链为:Looper - MessageQueue - Message - Runnable - Activity

private Handler mHandler = new Handler();

private void test() {
 mHandler.post(new Runnable() {
    @Override
    public void run() {
    // 持有 Activity 实例,可以直接调用外部方法
    updateView();
    }
 });
}

/** 更新View */
private void updateView() {
findViewById(R.id.text_inject1).setBackgroundColor(Color.TRANSPARENT);
}

解决方案:保存 Runnable 引用,在页面销毁时进行移除

private Runnable mRunnable = new Runnable() {
    @Override
    public void run() {
      updateView();
    }
};

private void test() {
    mHandler.post(mRunnable);
}

@Override
protected void onDestroy() {
// 1.移除指定的 Runnable
mHandler.removeCallbacks(mRunnable);
// 2.移除指定的 Message
mHandler.removeMessages(message);
// 3.移除指定的 Runnable,移除所有的 CallBack 和 Message
mHandler.removeCallbacksAndMessages(null);
super.onDestroy();
}

注意:Handler 有几种移除的操作,第一种和第二种比较容易理解,第三种是移除所有的 MessageQueue 中所有的 Message,如果其他位置也有 Message 待执行,也会被移除,需要确认是否真正需要被移除。

  • Handler.sendMessage(Message msg)

Handler 构造方式,匿名内部类形式,持有外部类引用,可以直接使用外部方法。调用引用链 Looper - MessageQueue - Message - Handler - Activity

private Handler mHandler = new Handler() {
@Override
public void handleMessage(@NonNull Message msg) {
  super.handleMessage(msg);
  switch (msg.what) {
    case 0: {
      updateView();
      break;
    }
    default:
      break;
  }
}
};

解决方案:使用静态内部类,如果想引用 Activity 中的方法,则需要引用 Activity,那么可以使用弱引用来防止内存泄漏。

  private static class MyHandler extends Handler {

    private WeakReference<MainActivity> mWeakReference;

    public MyHandler(MainActivity activity) {
      mWeakReference = new WeakReference<>(activity);
    }

    @Override
    public void handleMessage(@NonNull Message msg) {
      super.handleMessage(msg);
      switch (msg.what) {
        case 0: {
          if (mWeakReference != null && mWeakReference.get() != null) {
            mWeakReference.get().updateView();
          }
          break;
        }
        default:
          break;
      }
    }
  }
  1. View.post() 与 Handler 区别

Handler 的作用上面已经分析了,一般在主线程更新 UI 时,将消息发送到主线程中,在主线程更新 UI。那么 View.post() 是否和 Handler.post() 一样呢?这里仅仅简单说明一下,如果 View 已经被 AttachedToWindow,意思是添加到窗口上时,View.post() 也是调用 Handler.post()。如果 View 没有附加在窗口上,View 的 AttachInfo 中是没有 Handler,这时候用 RunQueue 来实现延迟执行 runnable 任务,并且 runnable 最终不会被加入到 MessageQueue 里,也不会被 Looper 执行,而是等到 ViewRootImpl 的下一个 performTraversals 时候,把 HandlerActionQueue 里的所有 runnable 都拿出来并执行,接着清空 HandlerActionQueue。由此可见 HandlerActionQueue 的作用类似于 MessageQueue,这里面的所有 runnable 最后的执行时机,是在下一个 performTraversals 到来的时候,MessageQueue 里的消息处理的则是下一次 loop 到来的时候。

public boolean post(Runnable action) {
    final AttachInfo attachInfo = mAttachInfo;
    if (attachInfo != null) {
        return attachInfo.mHandler.post(action);
    }

    // Postpone the runnable until we know on which thread it needs to run.
    // Assume that the runnable will be successfully placed after attach.
    getRunQueue().post(action);
    return true;
}
  1. view.post() 内存泄漏

一般情况下,如果在主线程中调用 view.post(),没有问题,因为 屏幕每隔 16.6ms 刷新一次, 刷新回调会调用 getRunQueue().executeActions(mAttachInfo.mHandler);调用后清空 HandlerActionQueue,所以不会内存泄漏。可以通过在主线程调用 View#post 发送 runnable 来获取下一次 performTraversals 时视图树中 View 的布局信息,如宽高。

但是如果如果调用 View#post 方法的线程对象被 GC-Root 引用,则发送的 runnable 将会造成内存泄漏,如使用全局的线程池,开启一个任务,在该任务中调用 view.post(),就可能导致内存泄漏,对象引用链:

view_post.png

解决方案:同样在页面销毁时移除 Runnable

  @Override
  protected void onDestroy() {
    if (view != null) {
      view.removeCallbacks(mRunnable);
    }
    super.onDestroy();
  }

线程造成的内存泄漏

线程造成的内存泄漏其实和 Handler 类似,Handler 也是基于线程执行的,主要用来做线程任务执行和线程之间通讯。线程使用出现内存泄漏问题主要有以下几种情况:

private void threadTest() {

    // 1.线程使用方式一,Runnable 持有 Activity 实例
    // 一般不会自己创建 Thread
    new Thread(new Runnable() {
      @Override
      public void run() {
        // do something
      }
    }).start();

    // 2.线程使用方式二,Thread 持有 Activity 实例
    // 一般不会自己创建 Thread
    new Thread() {
      @Override
      public void run() {
        super.run();
        // do something
      }
    }.start();
    
    // new Thread 泄漏解决方法,使用静态内部类,使用弱引用持有 Activity 实例
    new MyThread(this).start();
    
    // 3.1 线程使用方式二,使用线程池,Runnable 持有 Activity 实例
    // 结束任务执行使用 shutdown() 方法,如果是线程池是一个单例使用 shutdown() 可能不太合适
    mExecutorService = Executors.newCachedThreadPool();
    mExecutorService.submit(new Runnable() {
      @Override
      public void run() {
        // do something
      }
    });
    // 3.2 线程使用方式二,使用线程池,Callable 持有 Activity 实例
    // 可以使用 shutdown() 方法,也可以利用 mFuture 取消任务,推荐使用 mFuture
    mFuture = mExecutorService.submit(new Callable<Object>() {
      @Override
      public Object call() throws Exception {
        // do something
        return null;
      }
    });
  }
  
    @Override
  protected void onDestroy() {

    // 取消方式一:取消任务,如果是全局单例模式,这样做可能不太合适,其他待执行的任务不会被执行
    mExecutorService.shutdown();

    // 取消方式二:利用 Future 取消任务,参数 true,正在执行也会被取消,false 正在执行会等执行完成取消
    if (mFuture != null && !mFuture.isCancelled()) {
      mFuture.cancel(false);
    }

    super.onDestroy();
  }

其中直接使用 Thread 方式并不推荐,因为对线程不能管理,只能交给系统,而且 Activity 销毁后,线程并不能停止,执行完成或者异常结束。其造成内存泄漏也是因为匿名内部类持有外部类引用导致的。解决方式和 Handler 类似。

而对于线程池方式,一般情况下也是使用全局单例的线程池,这样也是为了方面管理线程和 CPU 的使用,这种情况下使用 shutdown() 方式不够合理,使用 mFuture.cancel() 方式相对合理。

除此之外,如果项目中引入了 Rxjava,可以使用 RxJava 处理耗时和异步任务,在页面销毁时可以取消订阅,使用起来更方便。关于 RxJava 内存泄漏问题可以看下这篇文章 RxJava 内存泄漏分析及解决方法

集合中对象造成的内存泄漏

通常会把一些对象的引用加入到集合容器如 List 中,当不再需要该对象时,并没有把它的引用从集合中清理掉,这样这个集合就会越来越大。如果这个集合是 static 的话,那情况就更严重了。所以在退出程序之前,将集合里面的东西 clear。

  private List<Fragment> mFragments;

  @Override
  protected void onDestroy() {
    if (mFragments != null && !mFragments.isEmpty()) {
      mFragments.clear();
      mFragments = null;
    }
    super.onDestroy();
  }

资源未关闭造成的内存泄漏

一般认为资源未关闭会造成内存泄漏,如 IO 流,那么 IO 不关闭时是否真的会造成内存泄漏?
实际上 IO 流是基于内核资源的,通过文件句柄来操作。当打开一个文件进行读写时,会获取一个文件描述符(file descriptor)出于稳定系统性能和避免因为过多打开文件导致 CPU 和 RAM 占用居高的考虑,每个进程都会有可用的 file descriptor 限制。所以如果不释放file descriptor,会导致应用后续依赖 file descriptor 的行为(socket连接,读写文件等)无法进行,甚至是导致进程崩溃。当我们调用 FileInputStream.close 后,会释放掉这个 file descriptor。所以,不关闭流不是内存泄露问题,是资源泄露问题(file descriptor 属于资源)。

不手动关闭的真的会发生上面的问题么? 其实也不完全是。因为对于这些流的处理,源代码中通常会做一个兜底处理。以 FileInputStream 为例:

protected void finalize() throws IOException {
    // Android-added: CloseGuard support.
    if (guard != null) {
        guard.warnIfOpen();
    }

    if ((fd != null) &&  (fd != FileDescriptor.in)) {
        // Android-removed: Obsoleted comment about shared FileDescriptor handling.
        close();
    }
}

虽然在 finalize 方法中有调用 close 来释放 file descriptor,但是 finalize 方法依赖 GC,执行速度不确定,不可靠。所以,我们不能依赖于这种形式,还是要手动调用 close 来释放 file descriptor。

FileInputStream fis = null;
File file = new File("xx.txt");
try {
  fis = new FileInputStream(file);
  byte[] buf = new byte[1024];
  int length = 0;
  StringBuilder builder = new StringBuilder();
  while ((length = fis.read(buf)) != -1) {
    builder.append(new String(buf, 0, length));
  }
  Log.d("xx.txt", builder.toString());
  
} catch (IOException e) {
  e.printStackTrace();
  
} finally {
  if (fis != null) {
    try {
      fis.close();
    } catch (IOException e) {
      e.printStackTrace();
    }
  }
}

不需要用的监听导致的内存泄露

  • Activity 实现了接口,并注册到某个集合中,如单例类中的集合。页面销毁前需要移除实例,防止内存泄漏。相似的,还有 EventBus,也需要在页面销毁前取消注册。
public class MainActivity extends AppCompatActivity implements IActivity {

  @Override
  protected void onStart() {
    super.onStart();
    ActivityManager.getInstance().register(this);
  }
  
  @Override
  protected void onDestroy() {
    ActivityManager.getInstance().unRegister(this);
    super.onDestroy();
  }
}
  • 注册了系统服务,在 onDestory 前未注销,如 BraodcastReceiver

  • View 相关监听需要移除,如 ViewTreeObserver 中的相关回调,一般在首次回调后取消注册,如果某个 View 被移除后没有取消注册,那么可能会导致内存泄漏。

final View view = findViewById(R.id.sub_component);
view.getViewTreeObserver().addOnGlobalLayoutListener(
    new ViewTreeObserver.OnGlobalLayoutListener() {
      @Override
      public void onGlobalLayout() {
        // 移除监听
        view.getViewTreeObserver().removeOnGlobalLayoutListener(this);
      }
    });

属性动画导致的内存泄漏

在使用 ValueAnimator、ObjectAnimator、AnimatorSet、ViewPropertyAnimator,如果没有及时取消动画,就可能造成内存泄露。在属性动画中,有个 AnimationHandler 单例,会持有属性动画对象的引用,属性对象持有 view 的引用,view 持有 activity 引用,所以导致的内存泄露。所以在页面销毁前判断动画是否执行完,没执行完的话,需要取消动画。

  public void stopAnimation() {
    mLottieView.setVisibility(INVISIBLE);
    if (mLottieView.isAnimating()) {
      mLottieView.cancelAnimation();
    }
}

其他

ContentObserver,File,Cursor,Stream,Bitmap 等资源的使用,应该在 Activity 销毁时及时关闭或者注销,否则这些资源将不会被回收。资源相关虽然不一定导致内存泄漏,但是可能会导致 OOM 等问题,所以需要及时关闭和清除。

参考

Java 的内存泄漏

未关闭的文件流会引起内存泄露么?

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 205,386评论 6 479
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,939评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,851评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,953评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,971评论 5 369
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,784评论 1 283
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,126评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,765评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,148评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,744评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,858评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,479评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,080评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,053评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,278评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,245评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,590评论 2 343

推荐阅读更多精彩内容