1. 程式人生 > >Java中隱藏的this變數和區域性變數可能引發的記憶體洩露問題

Java中隱藏的this變數和區域性變數可能引發的記憶體洩露問題

背景

眾所周知,在Java中,成員方法內可以使用this來引用當前物件,使用起來特別方便。但是在JVM中方法是在方法區中,所有的類的物件都共用了一個方法區,那麼JVM是怎麼知道this是指向哪個物件的呢?
其實為了實現這一功能,Java的處理方式很簡單,在編譯時,為每個成員方法都默默的添加了一個引數this,當呼叫這個方法時,把當前物件以引數的形式傳進去即可。
本文重點不在this是怎麼來的,因此簡單驗證下:

一個簡單的java類:

import java.util.Arrays;

public class Test {

    public Test() {
    }

    public
void hi() { } public static void staticHi() { } }

編譯後,使用javap來檢視class檔案

~ javap -verbose Test.class
Classfile /Users/Johnny/Downloads/Test.class
  Last modified 2015-7-13; size 280 bytes
  MD5 checksum 5f8b96a983d277edb1974e472cf0b62a
  Compiled from "Test.java"
public class Test
  SourceFile: "Test.java"
minor version: 0 major version: 51 flags: ACC_PUBLIC, ACC_SUPER Constant pool: #1 = Methodref #3.#12 // java/lang/Object."<init>":()V #2 = Class #13 // Test #3 = Class #14 // java/lang/Object #4 = Utf8 <
init> #5 = Utf8 ()V #6 = Utf8 Code #7 = Utf8 LineNumberTable #8 = Utf8 hi #9 = Utf8 staticHi #10 = Utf8 SourceFile #11 = Utf8 Test.java #12 = NameAndType #4:#5 // "<init>":()V #13 = Utf8 Test #14 = Utf8 java/lang/Object { public Test(); flags: ACC_PUBLIC Code: stack=1, locals=1, args_size=1 0: aload_0 1: invokespecial #1 // Method java/lang/Object."<init>":()V 4: return LineNumberTable: line 5: 0 line 7: 4 public void hi(); flags: ACC_PUBLIC Code: stack=0, locals=1, args_size=1 0: return LineNumberTable: line 11: 0 public static void staticHi(); flags: ACC_PUBLIC, ACC_STATIC Code: stack=0, locals=0, args_size=0 0: return LineNumberTable: line 15: 0 }

看Test方法和hi方法的args_size=1,原始碼中是0引數的,因此可以驗證確實是偷偷的添加了一個引數,當然這裡肯定是this引數了。

Memory Leak

迴歸正題,上面驗證了成員方法中會默默的新增一個this引數,而引數其實是放在方法區域性變量表中的。因此如果引數沒有被明確賦值為null的話,那麼這個引數就一直是以強引用的形態指向了該物件。在一般情況下,方法和物件的生命週期是一樣的,也就是物件不存在了,方法也不會被呼叫,不會存在洩露。但是有些情況下,比如一些非同步的操作,導致物件本來應該被回收掉時,方法還在被呼叫,因此這期間就會存在短暫的洩露,當然如果是耗時的方法,洩露會表現的比較明顯。
下面是在有非同步任務的情況下的洩露測試。

import java.lang.ref.WeakReference;

/**
 * 測試隱藏的this引用帶來的Memory Leak
 */
public class Test {
    /**
     * 主要用來標記test方法是否被呼叫,避免Test例項提前被gc
     */
    public static boolean sHasRun = false;

    private static class MyThread extends Thread {
        protected WeakReference<Test> mTestRef;
        protected boolean mTestLeak;

        public MyThread(Test t, boolean testLeak) {
            mTestRef = new WeakReference<Test>(t);
            mTestLeak = testLeak;
        }

        @Override
        public void run() {
            if (mTestLeak) {
                // 洩露點1:區域性變數,如果執行的方法是耗時的,並且在同一個執行緒執行的話,那麼在這個方法執行完成前,區域性變數是不會被回收的。
                Test t = mTestRef.get();
                if (t != null) {
                    // 洩露點2:成員方法,因為在編譯時,預設會給每個成員方法加上this引數,this指向了當前類的例項。
                    // 方法引數本身就是一個區域性變數,因此類此洩露點1,必須等該方法執行完成後,這個區域性變數的強引用才會清除。
                    t.testLeak();
                }
            } else {
                // 解決洩露1:不儲存區域性變數
                if (mTestRef.get() != null) {
                    // 解決洩露2:呼叫靜態方法而不是成員方法
                    // 當然這裡可能會發生NPE,因為可能在判空後test呼叫前Test例項被gc了,因此try catch下
                    try {
                        mTestRef.get().test();
                    } catch (Exception e) {
                        e.printStackTrace();
                    }
                }
            }
        }
    }

    /**
     * 模擬耗時方法
     */
    private static void test() {
        sHasRun = true;
        System.out.println("time-consuming method start");
        try {
            Thread.sleep(5000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("time-consuming method end");
        sHasRun = false;
    }

    void testLeak() {
        test();
    }

    public void start(boolean testLeak) {
        new MyThread(this, testLeak).start();
    }

    public static void main(String[] args) {
        WeakReference<Test> testRef;
        boolean testLeak = true;
        {
            Test t = new Test();
            testRef = new WeakReference<Test>(t);
            t.start(testLeak);
            t = null;
        }
        while (testRef.get() != null) {
            if (sHasRun) {
                System.gc();
            } else {
                System.out.println("thread not run.");
            }
        }
        System.out.println("Test instance has cleaned.");
    }
}

當testLeak為true時,列印結果為

thread not run.
time-consuming method start
time-consuming method end
Test instance has cleaned.

當testLeak為false時,列印結果為

thread not run.
time-consuming method start
Test instance has cleaned.
time-consuming method end

從列印結果可以看出,在測試洩露時,必須等到耗時方法執行結束後,該物件才能被gc,改進後的測試,在耗時方法未執行結束前,物件就已經可以被gc了。

解決方案

  1. 為了解決隱藏的this引數這個問題,可以從根源上儘可能去除方法中this區域性變數的存在,比如將方法改成static的。
  2. 對於區域性變數,比如弱引用的呼叫時,不要用區域性變數儲存從弱引用中get出來的值等。