从根上理解什么是 this 逃逸,以及如何避免!

Posted 业余草

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了从根上理解什么是 this 逃逸,以及如何避免!相关的知识,希望对你有一定的参考价值。

从根上理解什么是 this 逃逸,以及如何避免!

公司的研发团队越来越强大,新来了不少新面孔。这两天有一位同事发我一个“奇怪”的问题。这个问题其实就是 this 逃逸,今天我们抽个时间来一起扯一扯这个问题。

什么是 Java This 逃逸?

在 Java 程序中,类的构造器构造还未彻底完成前(即实例初始化阶段还未完成),将自身 this 引用向外抛出并被其他线程复制(访问)了该 this 引用,就可能会访问到该 this 还未被初始化的变量,甚至可能会造成更大严重的问题。

上面的文字描述看起来比较枯燥,那我们就废话不多说,通过一段代码一段案例来说明它。

 /**
  * 模拟 this 逃逸
  * @author www.xttblog.com
  */
public class ThisEscape {
	// final 常量会保证在构造器内完成初始化(
	// 但是仅限于未发生 this 逃逸的情况下,具体可以看多线程对 final 保证可见性的实现,历史文章中有,后面我也会再写)
	final int i;
	//尽管实例变量有初始值,但是还实例化完成
	int j = 0;
	static ThisEscape obj;
	public ThisEscape() {
		i = 1;
		j = 1;
		// 将 this 逃逸抛出给线程 B
		obj = this;
	}

	public static void main(String[] args) {
		//线程A:模拟构造器中 this 逃逸,将未构造完全对象引用抛出
		/*Thread threadA = new Thread(new Runnable() {
		 @Override
		 public void run() {
		     //obj = new ThisEscape();
		 }
		});*/

		//线程B:读取对象引用,访问 i、j 变量
		Thread threadB = new Thread(new Runnable() {
			@Override
			public void run() {
				//可能会发生初始化失败的情况解释:实例变量 i 的初始化被重排序到构造器外,此时 i 还未被初始化
				ThisEscape objB = obj;
				try {
					System.out.println(objB.j);
				} catch (NullPointerException e) {
					System.out.println("发生空指针错误:普通变量 j 未被初始化");
				}
				try {
					System.out.println(objB.i);
				} catch (NullPointerException e) {
					System.out.println("发生空指针错误:final 变量 i 未被初始化");
				}
			}
		});
		//threadA.start();
		threadB.start();
	}
}

输出结果:这说明 ThisEscape 还未完成实例化,构造还未彻底结束。

发生空指针错误:普通变量 j 未被初始化
发生空指针错误:final 变量 i 未被初始化

另一种情况是利用线程 A 模拟 this 逃逸,但不一定会发生,线程 A 模拟构造器正在构造...而线程 B 尝试访问变量,这是因为

(1)由于 JVM 的指令重排序的存在,实例变量 i 的初始化被安排到构造器外(final 可见性保证是 final 变量规定在构造器中完成的);

(2)类似于 this 逃逸,线程 A 中构造器构造还未完全完成。

所以尝试多次输出(相信我一定会发生的,只是概率相对低),也会发生类似 this 引用逃逸的情况。

还是上面的代码,我们简单做一下改动:

public class ThisEscape {
	// final 常量会保证在构造器内完成初始化(但是仅限于未发送 this 逃逸的情况下)
	final int i;
	// 尽管实例变量有初始值,但是还实例化完成
	int j = 0;
	static ThisEscape obj;
	public ThisEscape() {
		i = 1;
		j = 1;
		//obj = this ;
	}

	public static void main(String[] args) {
		//线程A:模拟构造器中 this 逃逸,将未构造完全对象引用抛出
		Thread threadA = new Thread(new Runnable() {
			@Override
			public void run() {
				//构造初始化中...线程B可能获取到还未被初始化完成的变量
				//类似于 this 逃逸,但并不一定发生
				obj = new ThisEscape();
			}
		});

		// www.xttblog.com
		//线程B:读取对象引用,访问 i、j 变量
		Thread threadB = new Thread(new Runnable() {
			@Override
			public void run() {
				// 可能会发生初始化失败的情况解释:实例变量 i 的初始化被重排序到构造器外,此时1还未被初始化
				ThisEscape objB = obj;
				try {
					System.out.println(objB.j);
				} catch (NullPointerException e) {
					System.out.println("发生空指针错误:普通变量 j 未被初始化");
				}

				try {
					System.out.println(objB.i);
				} catch (NullPointerException e) {
					System.out.println("发生空指针错误:final 变量 i 未被初始化");
				}
			}
		});
		threadA.start();
		threadB.start();
	}
}

上面的内容,大家可以多次运行。甚至可以改动我的 demo,你会发现上面的 this 逃逸并不是每次都会发生。

那么问题来了,在 Java 中,在什么情况下才会发生 this 逃逸?

什么情况下会 This 逃逸?

下面我先给出两种 this 逃逸的常见场景!

(1)「在构造器中很明显地抛出 this 引用提供其他线程使用(如上述的 demo1 明显将 this 抛出)。」

(2)「在构造器中内部类使用外部类情况:内部类访问外部类是没有任何条件的,也不要任何代价,也就造成了当外部类还未初始化完成的时候,内部类就尝试获取为初始化完成的变量」

在通俗的解释一下:

  • 在构造器中启动线程:启动的线程任务是内部类,在内部类中 xxx.this 访问了外部类实例,就会发生访问到还未初始化完成的变量

  • 在构造器中注册事件,这是因为在构造器中监听事件是有回调函数(可能访问了操作了实例变量),而事件监听一般都是异步的。在还未初始化完成之前就可能发生回调访问了未初始化的变量。

在构造器中启动线程代码实现:

public class ThisEscape2 {
	final int i;
	int j;
	public ThisEscape2() {
		i = 1;
		j = 1;
		new Thread(new RunablTest()).start();
	}

	//内部类实现 Runnable:引用外部类
	private class RunablTest implements Runnable{
		@Override
		public void run() {
			try {
				System.out.println(ThisEscape2.this.j);
			} catch (NullPointerException e) {
				System.out.println("发生空指针错误:普通变量 j 未被初始化");
			}

			try {
				System.out.println(ThisEscape2.this.i);
			} catch (NullPointerException e) {
				System.out.println("发生空指针错误:final 变量 i 未被初始化");
			}
		}
	}

	public static void main(String[] args) {
		new ThisEscape2();
	}
}

如上代码,也是一种非常常见的 this 逃逸。另外在构造器中注册事件,比如下面的代码:

public class ThisEscape3 {
    private final int var;
 
    public ThisEscape3(EventSource source) {
     //注册事件,会一直监听,当发生事件 e 时,会执行回调函数doSomething
        source.registerListener(
       //匿名内部类实现
            new EventListener() {
                public void onEvent(Event e) {
            //此时 ThisEscape3 可能还未初始化完成,var 可能还未被赋值,自然就发生严重错误
                    doSomething(e);
                }
            }
        );
        var = 10;
    }
    // 在回调函数中访问变量
    int doSomething(Event e) {
        return var;
    }
}

举了这么多例子,那我们到底有没有办法避免 this 逃逸?

怎样避免 This 逃逸?

(1)单独编写一个启动线程的方法,不要在构造器中启动线程,尝试在外部启动。

...
private Thread t;
public ThisEscape2() {
    t = new Thread(new EscapeRunnable());
}

public void initStart() {
    t.start();
}
...

(2)将事件监听放置于构造器外,比如 new Object() 的时候就启动事件监听,但是在构造器内不能使用事件监听,那可以在 static{} 中加事件监听,这样就跟构造器解耦了。

static{
    source.registerListener( 
    	new EventListener() {
    		public void onEvent(Event e) {
                    doSomething(e);
            }
        });
        var = 10;
    }
}

(3)最重要的还是要彻底理解 this 逃逸,从根上理解它。这样我相信你就不会再去 this 逃逸的代码了,当然也能很快的解决这类问题了。

总结

this 引用逃逸问题实则是 Java 多线程编程中需要注意的问题,引起逃逸的原因无非就是在多线程的编程中“滥用”引用(往往涉及构造器中显式或隐式地滥用 this 引用),在使用到 this 引用的时候需要特别注意!

同时这会涉及到:final 的内存语义,即 final 域禁止重排序问题,包括写final 域与读 final 域重排序两个规则 关于 final,后面我还会重点抽时间来写 demo。

以上是关于从根上理解什么是 this 逃逸,以及如何避免!的主要内容,如果未能解决你的问题,请参考以下文章

图解|从根上彻底理解MySQL的索引

从根上理解Mybatis的一级二级缓存

从根上理解,一个 Redis 字符串为什么要设计的这么复杂!

从根上理解ziplist为什么要牺牲速度而进行压缩!

动画:深入浅出从根上理解 HTTP 缓存机制及原理!

动画:深入浅出从根上理解 HTTP 缓存机制及原理!