深入理解JVM原理5—即时编译器JIT对代码的优化

1 公共子表达式消除

公共子表达式消除是一个普遍应用于各种编译器的经典优化技术,他的含义是:
如果一个表达式$E$已经计算过了,并且从先前的计算到现在$E$中所有变量的值都没有发生变化,那么$E$的这次出现就成为了公共子表达式

对于这种表达式,没有必要花时间再对他进行计算,只需要直接用前面计算过的表达式结果代替$E$就可以了。

  • 如果这种优化仅限于程序的基本块内,便称为局部公共子表达式消除(Local Common Subexpression Elimination)
  • 如果这种优化范围涵盖了多个基本块,那就称为全局公共子表达式消除(Global Common Subexpression Elimination)。

举个简单的例子来说明他的优化过程,假设存在如下代码:

int d = (c*b)*12+a+(a+b*c);

这段代码在经过javac编译时时不会做任何优化,是完全遵照Java源码的写法直译而成的,生成的代码如下所示:

iload_2 // b
imul // 计算b*c
bipush 12 // 推入12
imul // 计算(c*b)*12
iload_1 // a
iadd // 计算(c*b)*12+a
iload_1 // a
iload_2 // b
iload_3 // c
imul // 计算b*c
iadd // 计算a+b*c
iadd // 计算(c*b)*12+a+(a+b*c)
istore 4

当这段代码进入到虚拟机即时编译器后,他将进行如下优化:编译器检测到”cb“与”bc“是一样的表达式,而且在计算期间b与c的值是不变的。因此,这条表达式就可能被视为:

int d = E*12+a+(a+E);

这时,编译器还可能(取决于哪种虚拟机的编译器以及具体的上下文而定)进行另外一种优化:代数化简(Algebraic Simplification),把表达式变为:

int d = E*13+a*2

表达式进行变换之后,再计算起来就可以节省一些时间了。

2 方法内联

在使用JIT进行即时编译时,将方法调用直接使用方法体中的代码进行替换,这就是方法内联,减少了方法调用过程中压栈与入栈的开销,同时为之后的一些优化手段提供条件。如果JVM监测到一些小方法被频繁的执行,它会把方法的调用替换成方法体本身。比如说下面这个:

private int add4(int x1, int x2, int x3, int x4) { 
	return add2(x1,  x2) + add2 (x3,  x4); 
} 

private int add2(int x1, int x2) {
	return x1 + x2; 
}

可以肯定的是,在JVM运行一段时间以后,上述代码会被优化成一下:

private int add4(int x1, int x2, int x3, int x4) { 
	return x1 + x2 + x3 + x4; 
} 

3 逃逸分析

逃逸分析(Escape Analysis)是目前Java虚拟机中比较前沿的优化技术。这是一种可以有效减少Java程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法

通过逃逸分析,Java Hotspot编译器能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上

逃逸分析的基本行为就是分析对象动态作用域:当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他地方中,称为方法逃逸。

逃逸分析包括

  • 全局变量赋值逃逸
  • 方法返回值逃逸
  • 实例引用发生逃逸
  • 线程逃逸:赋值给类变量或可以在其他线程中访问的实例变量
public class EscapeAnalysis { 

	public static Object object;

	//全局变量赋值逃逸 
	public void globalVariableEscape(){ 
		object =new Object();  
	}  

	//方法返回值逃逸
	public Object methodEscape(){   
		return new Object(); 
	} 

	//实例引用发生逃逸
	public void instancePassEscape(){   
		this.speak(this); 
	} 

	public void speak(EscapeAnalysis escapeAnalysis){
		 System.out.println("Escape Hello"); 
	}
}

下面使用方法逃逸的案例进行简单分析:

public static StringBuffer craeteStringBuffer(String s1,  String s2) { 
	StringBuffer sb = new StringBuffer(); 
	sb.append(s1 ); 
	sb.append(s2 ); 
	return sb;
}

分析如下:
StringBuffer sb是一个方法内部变量,上述代码中直接将sb返回,这样这个StringBuffer有可能被其他方法所改变,这样它的作用域就不只是在方法内部,虽然它是一个局部变量,称其逃逸到了方法外部

甚至还有可能被外部线程访问到,譬如赋值给类变量或可以在其他线程中访问的实例变量,称为线程逃逸。

上述代码如果想要StringBuffer sb不逃出方法,可以这样写:

public static String createStringBuffer(String s1, String s2) { 
	StringBuffer sb = new StringBuffer(); 
	sb.append(s1 ); 
	sb.append(s2 ); 
	return sb.toString();
}

不直接返回 StringBuffer,那么StringBuffer将不会逃逸出方法。

使用逃逸分析,编译器可以对代码做如下优化:

  • 同步省略。如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步。
  • 将堆分配转化为栈分配。如果一个对象在子程序中被分配,如果指向该对象的指针永远不会逃逸,对象可能是栈分配的候选,而不是堆分配。
  • 分离对象或标量替换。有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在CPU寄存器中。

在Java代码运行时,通过JVM参数可指定是否开启逃逸分析,

-XX:+DoEscapeAnalysis :表示开启逃逸分析
-XX:-DoEscapeAnalysis :表示关闭逃逸分析

从jdk 1.7开始已经默认开始逃逸分析,如需关闭,需要指定-XX:-DoEscapeAnalysis

4 对象的栈上内存分配

我们知道,在一般情况下,对象和数组元素的内存分配是在堆内存上进行的。但是随着JIT编译器的日渐成熟,很多优化使这种分配策略并不绝对。JIT编译器就可以在编译期间根据逃逸分析的结果,来决定是否可以将对象的内存分配从堆转化为栈。
我们来看以下代码:

public class EscapeAnalysisTest { 
	public static void  main  (String[] args  ) { 
		long  a1 = System.currentTimeMillis(); 
		for  (int   i = 0; i < 1000000; i++ ) { 
			alloc();
	 	} 
		// 查看执行时间 
		long  a2 = System.currentTimeMillis(); 					
		System.out.println("cost " + (a2 - a1) + " ms"); 
		// 为了方便查看堆内存中对象个数,线程sleep 
		try  {  
			Thread.sleep(100000); 
		}   catch (InterruptedException e1) { 
			e1.printStackTrace(); 
		} 
	} 
	private static void  alloc() { 
		User  user   = new User (); 
	} 
	static class User { 
	}
}

其实代码内容很简单,就是使用for循环,在代码中创建100万个User对象。我们在alloc方法中定义了User对象,但是并没有在方法外部引用他。

也就是说,这个对象并不会逃逸到alloc外部。经过JIT的逃逸分析之后,就可以对其内存分配进行优化。我们指定以下JVM参数并运行:

-Xmx4G -Xms4G 
-XX:-DoEscapeAnalysis 
-XX:+PrintGCDetails 
-XX:+HeapDumpOnOutOfMemoryError

在程序打印出cost XX ms后,代码运行结束之前,我们使用[jmap][1]命令,来查看下当前堆内存中有多少个User对象:

~ jps
2809 StackAllocTest
2810 Jps

~ jmap -histo 2809
num     #instances  #bytes  class name
---------------------------------------------- 
1:  524  87282184  [I 
2:  1000000  16000000  StackAllocTest$User 
3:  6806  2093136  [B 
4:  8006  1320872  [C 
5:  4188  100512  java.lang.String 
6:  581  66304  java.lang.Class

从上面的jmap执行结果中我们可以看到,堆中共创建了100万个StackAllocTest$User实例。在关闭逃避分析的情况下(-XX:-DoEscapeAnalysis),虽然在alloc方法中创建的User对象并没有逃逸到方法外部,但是还是被分配在堆内存中。

也就说,如果没有JIT编译器优化,没有逃逸分析技术,正常情况下就应该是这样的,即所有对象都分配到堆内存中。接下来,我们开启逃逸分析,再来执行下以上代码。

-Xmx4G -Xms4G 
-XX:+DoEscapeAnalysis 
-XX:+PrintGCDetails 
-XX:+HeapDumpOnOutOfMemoryError

在程序打印出cost XX ms后,代码运行结束之前,我们使用jmap命令,来查看下当前堆内存中有多少个User对象

~ jps
709
2858 Launcher
2859 StackAllocTest
2860 Jps

~ jmap -histo 2859
num     #instances  #bytes  class name
--------------------------------------------- 
1:  524      101944280  [I 
2:  6806  2093136  [B 
3:  83619  1337904  StackAllocTest$User 
4:  8006  1320872  [C 
5:  4188  100512  java.lang.String 
6:  581  66304  java.lang.Class

从以上打印结果中可以发现,开启了逃逸分析之后(-XX:+DoEscapeAnalysis),在堆内存中只有8万多个StackAllocTest$User对象。也就是说在经过JIT优化之后,堆内存中分配的对象数量,从100万降到了8万

除了以上通过jmap验证对象个数的方法以外,读者还可以尝试将堆内存调小,然后执行以上代码,根据GC的次数来分析,也能发现,开启了逃逸分析之后,在运行期间,GC次数会明显减少。正是因为很多堆上分配被优化成了栈上分配,所以GC次数有了明显的减少

总结
所以,如果以后再有人问你:是不是所有的对象和数组都会在堆内存分配空间?

那么你可以告诉他:不一定,随着JIT编译器的发展,在编译期间,如果JIT经过逃逸分析,发现有些对象没有逃逸出方法,那么有可能堆内存分配会被优化成栈内存分配。但是这也并不是绝对的。就像我们前面看到的一样,在开启逃逸分析之后,也并不是所有User对象都没有在堆上分配。

5 标量替换

标量(Scalar)的定义是指一个无法再进一步分解的最小的数据。

在JIT阶段,如果经过逃逸分析,发现一个对象不会被外界访问的话,那么经过JIT优化,就会把这个对象拆解成若干个成员变量来代替,下面举个小例子:

//有一个类
Apublic class A{ 
	public int   a=1; 
	public int   b=2
}
//方法getAB使用类A里面的a,b
private void getAB(){ 
	A x = new A();
	x.a;
	x.b;
}
//JVM在编译的时候会直接编译成
private void getAB(){
	a = 1;
	b = 2;
}//这就是标量替换

6 同步锁消除

jvm 
更新时间:2020-11-10 10:19:59

本文由 清水河恶霸 创作,如果您觉得本文不错,请随意赞赏
采用 知识共享署名4.0 国际许可协议进行许可
本站文章除注明转载/出处外,均为本站原创或翻译,转载前请务必署名
原文链接:http://ql.magic-seven.top/2020/10/29/深入理解jvm原理5即时编译器jit的优化.html
最后更新:2020-11-10 10:19:59

评论

Your browser is out of date!

Update your browser to view this website correctly. Update my browser now

×