书的第一章,写了个开头

原创
2022/04/07 20:14
阅读数 1.8K

[^历史,深度,对比,可观察,优缺点]:

http://cr.openjdk.java.net/~vlivanov/talks/2015_JIT_Overview.pdf

1 编译和运行Java代码

通过 javac 将程序源代码编译,转换成 java 字节码,JVM 把字节码将其翻译成对应的机器指令,逐条读入,逐条解释翻译。执行速度必然会比可执行的二进制字节码程序慢很多。为了提高Java执行速度,引入了 JIT 技术。事实上,通过JIT,Java的执行速度已经能跟C程序相当

JIT是JVM的重要组成部分,JIT通过分析程序代码,找到热点的执行代码,会部分的把字节码编译成机器码保存起来用于下次调用,对于较小得方法,会尝试进行内联展开。本章讲介绍JIT概念以及如何通过配置影响JIT,介绍通过JITWatch来观察我们的代码是否被JIT优化。

本章的的核心知识如下

  • 编译Java源码
  • 解释和编译执行
  • 代码缓存
  • 编译器优化技术

应用程序大部分情况下很少考虑到JIT的优化,这是一个自动过程。不过对于性能要求极高的工具或者关键服务类,还是可以考虑JIT对代码得优化影响,有时候性能能提高数百倍。

1.1 编译Java源码

Java的编译通常有

  • 前端编译Javac,Java源码编译成字节码。
  • 即时编译JIT(Just-In-Time),字节码在执行过程中,动态编译成机器码的过程,JIT通常会分析系统的热点,对热点代码会再次尝试更加激进的优化措施从而提高Java系统性能。这是本章的重点
  • 提前编译AOT,将Java源码编译成机器码,优点是执行速度快,缺点是牺牲了平台无关性,有些优化需要在运行过程中分析确认,AOT做不到。系统中不常用的代码也编译了。Java9后提供jaotc

这里的前端编译是指将Java源码编译成Java字节码的过程,JDK提供javac命令将Java源程序编译成java class,格式是

javac [options] [sourcefiles-or-classnames]

TODO

${java_home}/bin/javac Hello.java

前端编译Java源程序不一定是文件,比如,可以使用javax.tools.JavaCompiler(JDK6开始支持)类,将字符串源码编译成字节码存到Test.class里

public class CompileString {

	public static void main(String[] args) throws IOException {
		JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
		
		DiagnosticCollector<JavaFileObject> diagnostics =
				new DiagnosticCollector<>();
		StandardJavaFileManager fileManager = compiler.getStandardFileManager(diagnostics, null, null);
		JavaStringObject stringObject =
				new JavaStringObject("Test.java", getSource());

		String classes = System.getProperty("user.dir")+"/compile/javac/target/classes";
		File classesFile = new File(classes);
		fileManager.setLocation(CLASS_OUTPUT,Arrays.asList(classesFile));

		JavaCompiler.CompilationTask task = compiler.getTask(null,
				fileManager, diagnostics, null, null, Arrays.asList(stringObject));

		boolean success = task.call();
		System.out.println(success?"编译成功":"编译失败");
		diagnostics.getDiagnostics().forEach(System.out::println);

	}

	public static String getSource() {
		return "public class Test {"
			
				+ " }";
	}
}

JavaCompiler 用于编译java代码,javac命令也会调用JavaCompiler。diagnostics用于在编译过程中保留调试,警告,或者错误信息

StandardJavaFileManager对象用于管理源码和编译后输出文件。本例子设定了CLASS_OUTPUT 目录。

编译类的classpath,与运行此类的classpath一致(TODO,说明)

JavaStringObject是自定义的一个对象继承了SimpleJavaFileObject,用于代表Java源代码,定义如下

public class JavaStringObject extends SimpleJavaFileObject {
    private final String source;

    protected JavaStringObject(String name, String source) {
        super(URI.create(name), Kind.SOURCE);
        this.source = source;
    }

    @Override
    public CharSequence getCharContent(boolean ignoreEncodingErrors)
            throws IOException {
        return source;
    }
}

JavaStringObject最重要的方法是实现了getCharContent,提供Java代码。这个例子里,Java代码以字符的形式提供,而不是文件。

为了编译JavaStringObject,需要创建 CompilationTask,并执行call方法 ,如下代码

JavaCompiler.CompilationTask task = compiler.getTask(null,
                                                     fileManager, diagnostics, options, null, 
                                                     Arrays.asList(stringObject));
boolean success = task.call();
System.out.println(success?"编译成功":"编译失败");
diagnostics.getDiagnostics().forEach(System.out::println);

task.call 如果返回true,表示编译成功,可以在/compile/javac/target/classes 下找到编译好的Test.class.

代码最后,打印出编译过程中的调试,告警,或者错误信息

JavaCompiler完成Java源码转化成字节码。具体工程用图表示如下(TODO http://openjdk.java.net/groups/compiler/doc/compilation-overview/index.html,换一个自己等我图,并详细解释每部分输入输出

compilation-overview

  • 解析与填充符号表,这里会生成AST。

    1. 将源代码的字符流转换为标记(Token)集合
    2. 将Token流构建为抽象语法树AST,均是JCTree的子类,比如方法的定义是JCMethodDecl
    3. 将类的定义放到符号表中(Symbol Table)
    4. 如果需要,为此类添加默认构造函数,默认的父类也在这个阶段完成,代码在 com.sun.tools.javac.comp.MemberEnter.complete(Symbol sym)
  • 调用合适的注解处理器 注解处理器完成后会重新跳转到步骤1,然后继续执行,直到没有合适的注解处理器.注解处理器,JDK6开始支持,它的主要功能是在Java编译时对源码进行处理。我们熟悉的lombok,jmh性能测试框架,大厂必备的Selma&StrutsMap对象映射&拷贝工具,都是通过它实现的,业务系统也可以定义自己的注解来增强源码。

  • 分析然后生成class文件

    1. 标注检查 为AST添加属性。这一步包含名字解析(name resolution),类型检测(type checking)和常数折叠(constant fold)等
    2. 数据及控制流分析 为前面得到的AST执行流分析(Flow analysis)操作。这个步骤包含赋值(assignment)的检查和可执行性(reachability)的检查
    3. 处理语法糖 重写AST, 并且把一些复杂的语法转化成一般的语法,比如字符串"+"使用StringBuilder,Foreach、泛型擦除,自动装箱拆箱,常量折叠,条件编译等等
    4. 生成class文件,class文件包含字节码。

1.2 处理语法糖

Java在编译的时候通过com.sun.tools.javac.comp.Lower 处理语法糖,

1) 条件编译。如下代码FLAG是final类型,因此编译时候可以省去这个代码

static final boolean  FLAG = false;
public void run(){
  if(FLAG){
    System.out.println("hello");
  }
}

通过com.sun.tools.javac.comp.Lower.visitIf 来处理

编译结果,run方法是个空方法。

public void run() {
}

如果你想查看com.sun.tools.javac.comp.Lower.visitIf源码,你可在visitIf代码打上断点,并修改CompileString的getSouce方法,如下

public static String getSource() {
		return " public class Test {"
				+"static final boolean  FLAG = false;" 
				+ " public void run(){" 
				+ "  if(FLAG){"
				+ "    System.out.println(\"hello\");" 
				+ "  }" 
				+ "}"
				+ "}";
	}

以Debug方式运行CompileString,可以看到JavaCompile进入visitIf代码的cond.type.isFalse() 分支

public void visitIf(JCIf tree) {
  JCTree cond = tree.cond = translate(tree.cond, syms.booleanType);
  if (cond.type.isTrue()) {
    result = translate(tree.thenpart);
    addPrunedInfo(cond);
  } else if (cond.type.isFalse()) {
    if (tree.elsepart != null) {
      result = translate(tree.elsepart);
    } else {
      result = make.Skip();
    }
    addPrunedInfo(cond);
  } else {
    // Condition is not a compile-time constant.
    tree.thenpart = translate(tree.thenpart);
    tree.elsepart = translate(tree.elsepart);
    result = tree;
  }
}

JCTree 是AST的抽象类,JCIf是其子类,在1.3注解处理器中会有解释,TODO,解释AST

2) 最常见的语法糖之一是foreach,如下代码

List<Integer> list = Arrays.asList(1,2);
for(Integer key:list){
  System.out.println(key);
}

编译成class后,用eclipise或者idea等IDE直接打开class,,可以看到反编译后的代码

List<Integer> list = Arrays.asList(1, 2);
Iterator var2 = list.iterator();

while(var2.hasNext()) {
  Integer key = (Integer)var2.next();
  System.out.println(key);
}

这段通过com.sun.tools.javac.comp.Lower.visitIterableForeachLoop方法处理。

3)常量折叠

static final int  b = 3;
public void run(){
  int a = 1+b;
  System.out.println(a);
}

编译代码后是, ConstFold.fold

public void run() {
  int a = 4;
  System.out.println(a);
}

4) 装箱和拆箱,这个功能是在代码在Lower.unBox处理

Integer i = 1;

编译代码

Integer i = Integer.valueOf(1);

注意,当使用IDE反编译class的时候,仍然看到的是Integer i=1; 这是反编译工具对反编译结果做了优化,用IDE编译如下源码

Integer i = Integer.valueOf(1);

用IDE直接打开此类,可以看到IDE显示的如下,证明IDE做了显示优化

Integer i=1;

如果想了解编译后的代码,可以直接打开字节码,可以安装ASMPLugin插件,用插件打开class文件,就能看到Integer i=1的字节码是

ICONST_1 //把常量1压栈
INVOKESTATIC java/lang/Integer.valueOf (I)Ljava/lang/Integer;   //调用Integer.valueOf方法

关于字节码,将在1.7一章介绍

其他的语法糖还包括字符串+ 改成StringBuilder(实际上,这部分是在最后阶段,字节码生成时候完成Gen.visitBinary,并非在Lower阶段),Lambda语法,断言Assert语法糖,枚举处理,对String支持的switch进行转化等。

需要注意的是通过Eclipse,IDEA这样强大的工具的反编译功能,会反编译代码并还原成语法糖,比如

String str = a+c;

实际编译后的class,应该是如下

String str = new SringBuilder().append(a).append(c).toString();

IDEA反编译后并不会显示如下上代码,而是还原了语法糖String str = a+c;

1.3 注解处理器

写好代码代码,在Javac编译,就能生成class了。 实际上,我们还能参与class的生成,通过注解处理器我们的代码有了参与编译过程的能力,实现一些魔法功能,比如一些注明的开源工具Lombok,Selma,JMH等均使用了。

  • Lombok,使用注解器,自动为对象生成构造函数,toString方法,getter和setter方法等
  • Selma,自动生成一个对象复制自生的方法,或者生成一个对象转化成另外一个对象的方法 (举一个例子,说一下selam)
  • JMH,Java微性能测试框架,自动生成对目标方法的测试代码(TODO,)

1.3.1 搭建Maven

到目前为止,都是采用IDE的自带命令来编译和运行我们的代码,现在要使用maven的环境和运行命令来运行本书后面的所有章节内容。

在IDEA 下,需要进入设置|Build|Maven|Runner,勾选Delegate IDE Build。

这样是因为默认情况下IDEA或者其他IDE工具都有自己的Build和运行配置,我们这里使用Maven提供的Build和运行配置。

然后设置VM Options -Dexec.classpathScope=compile . 这样运行配置包含POM的所有依赖lib。如果没有此选项,Maven不会把Provided和System的依赖加入到运行classpath里

当保存上诉选项后,再用IDE的build或者运行功能时候,可以在控制台看到是maven的build或者运行过程(TODO),=

1.3.1 构建Processor

为了快速理解注解处理器,假设提供一个@Tool注解,当此注解应用到类上,期望javac编译类的时候,自动加上final,以及增加一个private构造函数。如下

[@Tool](https://my.oschina.net/u/999979)
public class Test{
}

期望Javac编译后


public final class Test{
  private Test(){}
}

首先新建一个 processor moule(上下文补充),processor,

<groupId>com.interview</groupId>
<artifactId>processor</artifactId>
<version>1.0-SNAPSHOT</version>
<dependencies>
  <dependency>
    <groupId>sun.jdk</groupId>
    <artifactId>tools</artifactId>
    <version>1.8</version>
    <scope>system</scope>
    <optional>true</optional>
    <systemPath>${JAVA_HOME}/lib/tools.jar</systemPath>
  </dependency>
</dependencies>

这里使用jdk的tools.jar (TODO,说明原因)

定义com.interview.processor.Tool 注解,以及此注解的处理类

/**
 * 在编译期间为类提供一个私有构造函数,并且设置此类为final
 */
@Retention(RetentionPolicy.SOURCE)
@Target(ElementType.TYPE)
@Documented
public @interface Tool {
}

com.interview.processor.ToolProcessor 需要集成javax.annotation.processing.AbstractProcessor,如下是一个Prcessor类的框架

@SupportedAnnotationTypes("com.interview.processor.Tool")
@SupportedSourceVersion(SourceVersion.RELEASE_8)
public class ToolProcessor  extends AbstractProcessor {

    @Override
    public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {

        if (!roundEnv.processingOver()) {
            //简单向控制台输出消息
            processingEnv.getMessager().printMessage(Diagnostic.Kind.MANDATORY_WARNING, "开始改写类!");
			//主要逻辑TODO
        }
        return true;

    }
}

SupportedAnnotationType指明了需要处理哪些注解,在Javac编译过程中,当遇到这些注解,则process方法会被调用

SupportedSourceVersion注解申明了支持的Java版本(TODO,有什么特性).

process方法目前只是简单像控制台打印一个信息。

有了这俩个类,还需要告知Javac,你有一个注解处理器。最常用的办法是META-INF/services目录下新建一个javax.annotation.processing.Processor 文件,添加如下内容

com.interview.processor.ToolProcessor

在processor的pom中,还需要申明javac编译processor本生模块的时候,忽略此模块的processor处理过程

<build>
  <plugins>
    <plugin>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-compiler-plugin</artifactId>
      <configuration>
        <compilerArgs>
          <arg>-proc:none</arg>
        </compilerArgs>
      </configuration>
    </plugin>
  </plugins>
</build>

这是因为如果不使用-proc:none 编译参数,javac会按照的编译流程对processor工程开启ToolProcessor处理,然而此时ToolProcessor并还没有编译,javac会在使用ToolProcessor报错找不到ToolProcessor类

Compilation failure
服务配置文件不正确, 或构造处理程序对象javax.annotation.processing.Processor: Provider com.interview.processor.ToolProcessor not found

ToolProcessor目前没有实际功能,在进一步完善ToolProcessor前,先确保ToolProcessor能被正确调用。编写一个processor-sample moudle,引入processor

<groupId>com.interview</groupId>
<artifactId>processor-sample</artifactId>
<version>1.0-SNAPSHOT</version>
<dependencies>
  <dependency>
    <groupId>com.interview</groupId>
    <artifactId>processor</artifactId>
    <version>1.0-SNAPSHOT</version>
  </dependency>
</dependencies>

接下来编写一个简单的类,引入@Tool注解

@Tool
public class Test {
	
}

最后编译这个类,会发现控制台出现如下提示。

>mvn compile
[INFO] Scanning for projects...
[INFO] 
[INFO] -------------------< com.interview:processor-sample >-------------------
[INFO] Building processor-sample 1.0-SNAPSHOT
........
[WARNING] 开始改写类!
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------

这说明ToolProcessor已经参与到编译过程

1.3.2 调试Processor

在实现Processor之前,需要考虑如何开启调试功能,这样方便我们通过Debug了解Processor是被谁调用,process的入参具体是什么值

在1.1 中,举例了如何编译字符串源代码,在1.2中,还举例如何在此基础上如何调试脱糖代码(TODO,怎么表达),因此我们在CompileString基础上可以编写一个如下代码

JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();

		DiagnosticCollector<JavaFileObject> diagnostics =
				new DiagnosticCollector<>();
		StandardJavaFileManager fileManager = compiler.getStandardFileManager(diagnostics, null, null);
		JavaStringObject stringObject =
				new JavaStringObject("Test.java", getSource());

		String classes = System.getProperty("user.dir")+"/compile/javac/target/classes";
		File classesFile = new File(classes);
		fileManager.setLocation(CLASS_OUTPUT,Arrays.asList(classesFile));


		JavaCompiler.CompilationTask task = compiler.getTask(null,
				fileManager, diagnostics, null, null, Arrays.asList(stringObject));

		boolean success = task.call();
		System.out.println(success?"编译成功":"编译失败");
		diagnostics.getDiagnostics().forEach(System.out::println);

	}

	/**
	 * 编译后,Test变成final
	 * @return
	 */
	public static String getSource() {
		return "package spring;import com.interview.processor.Tool; "
				+ "@Tool "
				+ "public class Test  {"
				+ "}";
	}

在ToolProcessor的process方法内部打上断点后,以debug方式运行CompileAnnotationString,就可以看到process断点了

1.3.3 改写类

ToolPrcessor能被Debug后,可以开始添加私有构造函数,,为process方法添加如下方法

if (!roundEnv.processingOver()) {
  //简单向控制台输出消息
  processingEnv.getMessager().printMessage(Diagnostic.Kind.MANDATORY_WARNING, "开始改写类!");
  Set<? extends Element> serialSet = roundEnv.getElementsAnnotatedWith(Tool.class);
  JavacTrees tree  = JavacTrees.instance(processingEnv);
  for(Element element:serialSet){
    if(!(element instanceof Symbol.ClassSymbol)){
      continue;
    }
    //转成classDecl
    JCTree.JCClassDecl classDecl = (JCTree.JCClassDecl)tree.getTree(element);
    //打印类名
    processingEnv.getMessager().printMessage(Diagnostic.Kind.MANDATORY_WARNING,
                                             "find "+classDecl.getSimpleName());
		//修改为final
    long originalModifier = classDecl.mods.flags;
    classDecl.mods.flags=(originalModifier | Flags.FINAL);
    //输出修改后的代码
    processingEnv.getMessager().printMessage(Diagnostic.Kind.MANDATORY_WARNING, "新代码:"+classDecl);

  }
}
return true;

TODO,增加对JCClassDecl说明。对Flags统一使用Flag.flags (TODO)

运行CompileAnnotationString代码,可以看到输出,Test代码增加了final修饰。也可以反编译生成的Test代码,也能看到

编译成功
警告: 开始改写类!
警告: find Test
警告: 新代码:
  @Tool()
  public final class Test {
      
      public Test() {
          super();
      }
  }

如下是Idea反编译代码

public final class Test {
    public Test() {
    }
}

思考 idea反编译代码为什么没有supper()?TODO

1.3.4 添加私有构造函数

工具类应该声明构造函数为私有,以避免实例化。在ToolProcessor基础上,可以进一步检查类的所有方法,找到默认构造函数,添加private标记


for (JCTree jcTree : classDecl.defs) {
  //遍历所有Test方法
  if (jcTree instanceof JCTree.JCMethodDecl) {
    JCTree.JCMethodDecl methodDecl = (JCTree.JCMethodDecl) jcTree;
    // 如果是构造方法,Java构造方法名字是<init>
    if ("<init>".equals(methodDecl.name.toString())) {
      // 把修饰符改为指定访问级别
      methodDecl.mods.flags = Flags.PRIVATE;
      continue;
    }
  }
}

TODO, 增加对JCMethodDecl 说明

工具类应该都是static方法,因此,添加代码,检测所有方法是否为static,如果不是,则编译失败

if ("<init>".equals(methodDecl.name.toString())) {
  // 把修饰符改为指定访问级别
  methodDecl.mods.flags =Flags.PRIVATE;
  continue;
}
//检查statgic方法
EnumSet<Flags.Flag> set = Flags.asFlagSet(methodDecl.mods.flags);
if(!set.contains(Flags.Flag.STATIC)){
  processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR, "编译失败,需要static方法 "+methodDecl.name);
  return false ;
}

TODO,对Flag说明

现在修改CompileAnnotationString.getSource方法,添加一个方法

public static String getSource() {
		return "import com.interview.processor.Tool; "
				+ "@Tool "
				+ "public class Test   {"
				+ "  public void doSomething(String v){"
				+ "     int a = 1;"
				+ "  }"
				+ "}";
	}

当运行CompileAnnotationString时候,会看到编译出错

编译失败
警告: 开始改写类!
警告: find Test
错误: 编译失败,需要static方法 doSomething

1.3.5 自动生成代码

使用注解处理器不仅可以改写类,还可以用生成新的代码,比如Selam用来生成对象Copy代码,JMH用来生成性能测试代码,这一节,我们将根据注解@RestCode 注解,自动生成调用Spring Service的的RestController代码。比如如下Service

@RestCode
public class MyService{
	public Integer add(int a){
  		return a++;
  }
}

在编译此代码时候,会自动生和编译成如下代码

@RestConroller
public class MyServiceController{
  @Autowired MyService service;
  @RequestMapping("/add")
	public Integer addUser(int  a){
  		return service.add(a);
  }
}

当代码打包部署后,就可以通过浏览器访问 http:/127.0.0.1:8080/add?a=1,可以看到MyServiceController被调用,开发者不需要写任何代码。

编写RestCode注解

@Retention(RetentionPolicy.SOURCE)
@Target(ElementType.TYPE)
@Documented
public @interface RestCode {
}

编写RestCodeProcessor处理器,代码基本上与ToolProcessor类似

@SupportedAnnotationTypes("com.interview.processor.RestCode")
@SupportedSourceVersion(SourceVersion.RELEASE_8)
public class RestCodeProcessor extends AbstractProcessor {

	@Override
	public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {

		if (!roundEnv.processingOver()) {
			//简单向控制台输出消息
			processingEnv.getMessager().printMessage(Diagnostic.Kind.MANDATORY_WARNING, "开始生成类!");
			Set<? extends Element> serialSet = roundEnv.getElementsAnnotatedWith(RestCode.class);
			JavacTrees tree  = JavacTrees.instance(processingEnv);
			for(Element element:serialSet){
				if(!(element instanceof Symbol.ClassSymbol)){
					continue;
				}

				JCTree.JCClassDecl classDecl = (JCTree.JCClassDecl)tree.getTree(element);
				processingEnv.getMessager().printMessage(Diagnostic.Kind.MANDATORY_WARNING,
						"find "+classDecl.getSimpleName());
			  //TODO 代码

			}
		}

		return true;
	}
}

@SupportedAnnotationTypes 注解申明了com.interview.processor.RestCode 处理,代码Set<? extends Element> serialSet = roundEnv.getElementsAnnotatedWith(RestCode.class); 表示获取所有被RestCode.class处理类

代码部分如下,用来创建一个新的Java文件。这个Java文件目前暂时没有内容,这是因为我们先确保此processor能如我们期望那样创建一个Java源码

//得到类名
String classFullName = ((Symbol.ClassSymbol) element).getQualifiedName().toString();
//目标类名
String targetFullClassName = classFullName+"Controller";

try {
  JavaFileObject builderFile = processingEnv.getFiler()
    .createSourceFile(targetFullClassName);

    StringBuilder content = new StringBuilder(" //代码 \n");
	processingEnv.getMessager().printMessage(Diagnostic.Kind.MANDATORY_WARNING, "新代码:"+content);

	//写入代码
  Writer writer = builderFile.openWriter();
  writer.append(content);
  writer.close();

} catch (Exception exception) {
  processingEnv.getMessager().printMessage(Diagnostic.Kind.MANDATORY_WARNING, exception.getMessage());
  return false;
}

有了RestCodeProcessor后,需要添加此Processor到javax.annotation.processing.Processor 文件里

com.interview.processor.ToolProcessor
com.interview.processor.RestCodeProcessor

在1.3.2中,说明了如何调试Processor,因此,在真正编写RestProcessor前,先搭建一个调试代码,在CompileAnnotationString基础上,创建类CompileAnnotationGeneratedString, 调用fileManager,设置注解类生成代码的路径,约定习俗是generated-sources/annotations,JMH,Selma,MapStruts等框架生成代码都在这里

String src = System.getProperty("user.dir")+"/compile/javac/target/generated-sources/annotations";
fileManager.setLocation(SOURCE_OUTPUT,Arrays.asList(new File(src)));

并修改getSource代码.

public static String getSource() {
		return "package spring;import com.interview.processor.RestCode; "
				+ "@RestCode\n"
				+ "public class MyService  {"
				+ "  public int doSomething(int v){"
				+ "     return v++;"
				+ "  }"
				+ "}";
	}

当运行或者Debug CompileAnnotationGeneratedString时候,可以看到控制台输出

编译成功
警告: 开始生成类!
警告: find MyService
警告: 新代码: //代码 
警告: 开始生成类!

进入target/generated-sources/annotations,可以看到我们已经生成了一个spring.MyServicecController.java. ,也可看到

target/classes 下编译好的spring.MyServicecController.class.

在processor-sample 里,添加一个SpringBoot 应用,先修改pom

<dependencies>
        <dependency>
            <groupId>com.interview</groupId>
            <artifactId>processor</artifactId>
            <version>1.0-SNAPSHOT</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
    </dependencies>

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-dependencies</artifactId>
                <version>2.3.0.RELEASE</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

编写一个MySpringBootApplication,

@SpringBootApplication
public class MySpringBootApplication {
	public static  void main(String[] args){
		SpringApplication.run(MySpringBootApplication.class, args);
	}

}

编写一个MyService

@RestCode
@Service
public class MyService {
	public Integer add(int a){
		return a+1;
	}
}

现在RestCode还只是生成了一个空的MyServicecController实现,接下来实现process方法,生成完整的MyServiceController方法

代码生成,可以像本例子那样拼接字符串,更建议使用模板语言如Freemaker,作者的Beetl,或者也可以javapoet这样专有生成代码的工具。

//得到类名
String classFullName = ((Symbol.ClassSymbol) element).getQualifiedName().toString();
//目标类名
String targetFullClassName = classFullName + "Controller";
String packageName = targetFullClassName.substring(0, targetFullClassName.lastIndexOf('.'));

String serviceName = classDecl.getSimpleName().toString();
String restName = serviceName + "Controller";

JavaFileObject builderFile = processingEnv.getFiler().createSourceFile(targetFullClassName);

StringBuilder content = new StringBuilder(" //代码 \n");
content.append("package ").append(packageName).append(";\n");
content.append("import org.springframework.web.bind.annotation.RequestMapping;\n");
content.append("import ").append(classFullName).append(";\n");
content.append("import org.springframework.beans.factory.annotation.Autowired;\n");
content.append("import org.springframework.web.bind.annotation.RestController;\n");
content.append("@RestController").append("\n");
content.append("public class ").append(restName).append("{\n");
content.append("@Autowired ").append(serviceName).append(" service;\n");
for (JCTree jcTree : classDecl.defs) {
  if (jcTree instanceof JCTree.JCMethodDecl) {
    JCTree.JCMethodDecl methodDecl = (JCTree.JCMethodDecl) jcTree;
    String methodName = methodDecl.name.toString();
    // 如果是构造方法
    if ("<init>".equals(methodName)) {
      continue;
    }
		//为每个方法增加一个@RequestMapping
    content.append("@RequestMapping(\"/").append(methodName).append("\")\n");
    content.append(methodDecl.mods.toString()).append(" ");
    content.append(methodDecl.getReturnType().toString()).append(" ");
    content.append(methodName).append("(");
    String str = methodDecl.getParameters().toString();
    content.append(str).append("){").append("\n");
    content.append("return service.").append(methodName).append("(");
    methodDecl.getParameters().stream().forEach(jcVariableDecl -> {
      content.append(jcVariableDecl.name).append(",");
    });
    content.setCharAt(content.length()-1,')');
    content.append(";\n}").append("\n");

  }
}
content.append("}\n");

processingEnv.getMessager().printMessage(Diagnostic.Kind.MANDATORY_WARNING, "新代码:" + content);

Writer writer = builderFile.openWriter();
writer.append(content);
writer.close();

TODO,上面代码拆一下讲解,比如package和import部分,遍历每个方法等.

编译prcessor-sample工程,可以看到控制台代码生成

[WARNING] find MyService
[WARNING] 新代码: //代码 
  package com.interview.processor.test;
  import org.springframework.web.bind.annotation.RequestMapping;
  import com.interview.processor.test.MyService;
  import org.springframework.beans.factory.annotation.Autowired;
  import org.springframework.web.bind.annotation.RestController;
  @RestController
  public class MyServiceController{
  @Autowired MyService service;
  @RequestMapping("/add")
  public  Integer add(int a){
  return service.add(a);
  }
 }

编写好RestProcessor后,运行maven instal 安装到本地,然后再运行MySpringBootApplication.

1.3 字节码生成

Java是一种跨平台的语言,也就是说其能够实现“一次编译,到处运行”,也就是说在Windows操作系统上编译的Java程序,能够不经过修改直接在linux操作系统上运行;与之对比的是C语言,在linux平台编译的C程序,一般情况下如果不进行特殊的转换,是不能在Windows操作系统上运行的。 要了解Java是如何实现这一目标的呢,我们需要对Java实际运行做一个简单的介绍。 首先Java的源程序的扩展名是.java,经过编译程序进行编译之后生成扩展名为.class的字节码文件

1.3.1 基础知识

在class文件中,类名都是使用的全限定名,并且其表示方式与在源文件中的方式不一致,比如java.lang.String在String.class中的表示就是java/lang/String

Java虚拟机的操作基于两种数据类型,基本类型和引用类型。

  • 基本类型 包括:数字类型、boolean类型和returnAddress,其中returnAddress在Java语言中没有对应
  • 引用类型 包括:class、array、interface 引用类型是与实例关联的

对于这些类型在class文件中有不同的描述

类型描述 Java 类型 描述
B byte Java中最小的数据类型,在内存中占8位(bit),即1个字节
C char 字符型,用于存储单个字符,占16位,即2个字节
S short 短整型,在内存中占16位,即2个字节
I int 整型,用于存储整数,在内在中占32位,即4个字节
J long 长整型,在内存中占64位,即8个字节
D double 双精度浮点型,用于存储带有小数点的数字,在内存中占64位,即8个字节
F float 浮点型,在内存中占32位,即4个字节
Z boolean 布尔类型,占1个字节,只有两个值:true和false
LClassName; reference 关联一个ClassName的实例
[ reference 一维数组

引用类型举例

Java 类型 类型描述
int[] [I
int[][] [[I
Object Ljava/lang/Object;
Object[] [Ljava/lang/Object;

方法的描述是参数类型描述与返回类型描述的组合,参数类型描述在一对()之间,后边紧跟着返回类型的描述。我们以java.lang.Object与java.lang.String中的几个方法举例如下:

方法声明 类型描述
public boolean equals(Object obj) (Ljava/lang/Object;)Z
public final native void notify() ()V
public String toString() ()Ljava/lang/String;
public String[] split(String regex, int limit) (Ljava/lang/String;I)[Ljava/lang/String;
public int lastIndexOf(String str) (Ljava/lang/String;)I
public int lastIndexOf(String str) (Ljava/lang/String;)I

1.3.2 class文件格式

class 文件的格式如下:

ClassFile {
u4 magic;
u2 minor_version;
u2 major_version;
u2 constant_pool_count;
cp_info constant_pool[constant_pool_count-1];
u2 access_flags;
u2 this_class;
u2 super_class;
u2 interfaces_count;
u2 interfaces[interfaces_count];
u2 fields_count;
field_info fields[fields_count];
u2 methods_count;
method_info methods[methods_count];
u2 attributes_count;
attribute_info attributes[attributes_count];
}

在classFile中结构的顺序是直接线性排列的,没有其他的分隔符, 在上边的结构中,u1、u2、u4分别代表无符号1字节、2字节、4字节,且其多字节的排列是“大端法”,即高位字节在低位

  1. magic魔数,4字节,固定为0xCAFEBABE,在文件中就是直接CAFE BABE
  2. minor_version、major_version,分别占2个字节,表示子版本号和主版本号,用于Java虚拟机识别是否支持该Class文件,以及是否支持新特性等
  3. constant_pool_count,2字节,其表示的值为常量池实际大小+1
  4. constant_pool[],常量池,其中包含各种格式的常量,包括类的全限定名、字段名称和描述符、方法名称和描述符等,其通用格式为cp_info{u1 tag;u1 info[]},由于篇幅有限,不再详细展开介绍常量池结构。我们知道Java类的所有所有常量,类名,方法名等字符串都存放在常量池里
  5. access_flags,2字节,表示该类或接口的访问标志,比如ACC_PUBLIC(值为0x0001)表示public,ACC_FINAL,值为0x0010,表示final,因此0x0011表示了final public
  6. this_class,2字节,表示当前类,其值为常量池中的索引,该位置所表示的常量类型必须为0x07,即class类型
  7. super_class,2字节,表示当前类的直接父类,其值为常量池中的索引,该位置所表示的常量类型必须为0x07,即class类型,当然Object类的class文件中,该值为0x0000
  8. interfaces_count,2字节,表示当前类或接口直接实现的接口的数量
  9. interfaces[],直接实现的接口表示,其值为常量池中索引的位置,且类型必须为0x07
  10. interfaces_count,2字节,表示当前类或接口直接实现的接口的数量
  11. interfaces[],直接实现的接口表示,其值为常量池中索引的位置,且类型必须为0x07
  12. methods_countt,2字节,该类中方法表中方法结构的数量
  13. methods[],方法表,表示该类中所有方法:实例方法、类方法、初始化方法等,不包括从父类或父接口中继承但没实现的方法
  14. attributes_count,2字节,该class文件中属性表中实体的数量
  15. attributes[],属性表,此位置的属性表示的是Class文件中的属性,可以存储在此位置的属性是有限值的

字段的结构:

field_info {
    u2             access_flags;
    u2             name_index;
    u2             descriptor_index;
    u2             attributes_count;
    attribute_info attributes[attributes_count];
}

方法的结构:

method_info {
    u2             access_flags;
    u2             name_index;
    u2             descriptor_index;
    u2             attributes_count;
    attribute_info attributes[attributes_count];
}

属性的结构:

attribute_info {
    u2 attribute_name_index;
    u4 attribute_length;
    u1 info[attribute_length];
}

我们看到Class、Filed、Method,甚至于Attribute中都有属性结构,但是不同的位置拥有的属性也是不同的,比如SourceFile 属性存储值ClassFile中,Code属性在Method结构中,而LineNumberTable属性则存储在Code属性中,限于篇幅,不再具体说明,具体可以参考以下地址[Java虚拟机规格-第4章4.7节属性](

1.3.3 查看字节码

使用javap或者asmplugin

1.3.5 字节码执行

public int add(int i){
    int c = i+1;
    return c;
}

用一个简单的add方法描述

1.4 JIT

Java VM 使用了JIT(Just-in-time简称,即时编译) 编译器,在运行时刻进将字节码(TODO,字节码啥样子没交代)通过模板方式转为高效的机器码,也可以在运行时候通过分析,进一步将热点代码编译成更加高效的机器码,因此现在Java程序,已经能跟C和C++具有一样的性能。在《Energy Efficiency across Programming Languages,How Does Energy, Time, and Memory Relate?》 报告中, Java 的执行效率非常高,约为最快的C语言的一半,仅次于C、Rust 和 C++。 其他编程语言,如PHP和Python,也再研发和优化各自的JIT以提高性能

早在JDK1.2版本,JIT已经作为JVM插件已经可以使用了,到了JDK1.3,正式作为HotSpot虚拟机的一部分

JVM的解释执行使用基于模板的解释器,比如,对于字节码(TODO,字节码如何变成机器码需要详细说明)

iload_1
invokestatic ...

第一个字节码iload_1映射成机器码

MOV -0x8(%r14),	%eax  # ILOAD_1
movzbl		0x1(%r13),	%ebx # 下一个指令
inc				%r13	
mov				$0xff40,%r10	
jmpq			*(%r10,%rbx,8)

虚拟机对于最常用的代码,会尝试编译成机器码放到"Code Cache 代码缓存“里,如下图所示

img001

JIT也会做其他优化,比如,对于多态调用,采用的是虚方法表中查找,在JIT在执行多次后,如果收集到足够的信息,会取消多态掉调用的代码,改为直接调用。如果随后发现存在多态调用,则可能取消这部分优化,称之为逆优化(Deoptimization). 关于虚方法调用,可以参考1.6.2

JIT也会根据收集到性能数据,决定不优化,相关代码又回到优化前的状态

对于小的方法,JIT也可以优化为内联调用,比如属性字段的getter和 setter方法,内联是JIT提高Java性能的一个重要方法

public int getAge(){
  return age;
}

当调用user.getAge()的时候,JIT会直接优化成user.age对应的机器码。关于内联优化,可以参考1.6.1。

1.4.1 C1和C2

​ 虚拟机(TODO,还是没介绍,看样子得把JVM放到第一章?)运行有俩种模式,一种是client模式,一种是server模式,前者启动虚拟机使用-client,后者使用-server,这俩种模式的命名也是因为这俩个参数名字得来。client模式使用C1编译器,有较快的启动速度,简单的将字节码编译为机器码,一些Java UI,比如NetBean使用这种模式。Web应用通常使用server模式,server模式采用了重量C2编译器,C2 比 C1 编译器编译的更加激进,性能更高。C2提供了内联优化,循环展开,Dead-Code删除,分支预测等优化

启动client模式

java -client 

启动server模式

java -server

通常来说,在服务器或者64位机器上,默认都是以server模式启动,32位机器默认采用client模式。有些服务器上,甚至没有client模式,究竟虚拟机运行在何种模式,可以通过java -version 进一步确定。

$ java  -version
java version "1.8.0_45"
Java(TM) SE Runtime Environment (build 1.8.0_45-b14)
Java HotSpot(TM) 64-Bit Server VM (build 25.45-b02, mixed mode)

在JDK7版本,-server模式还存在一种选项,开启TieredCompilation。这这种模式默认开启client以获得较快性能,一旦程序运行起来,则采用C2编译器。jdk8以上默认开启了这种模式

以Spring框架为例子,通常启动Spring框架的的时候,会启用容器加载被@Controller,@Service,@Configuration等注解的类,这种类的构造函数或者初始化方法在加载执行一次后,在Spring应用里就很少再被调用,JVM不再优化这部分代码。因此使用TieredCompilation模式对于Java Web应用来说,是非常合适的选择。

基本上来讲,虚拟机会监控执行的方法,设定方法调用计数器,执行很频繁的方法(成为Hot Method,也是Hotpot VM名称的由来),在server模式下,默认执行10,000次那么这个方法的优化会被JIT作为一个任务放到一个优化的队列进行异步优化。C1队列或者C2队列,这个队列并非先进先出队列,是按照调用次数高,优先级高编译,编译线程个数取决于CPU的数量,如下是一个摘自默认的线程个数

P C1 编译线程个数 C2 编译线程个数
1 1 1
2 1 1
4 1 2
8 1 2
16 2 6
32 3 7
64 4 8
128 4 10

被优化的代码会被放到代码缓存CodeCache,这样再次执行代码得时候,JVM将会使用新的优化代码。

对于for循环执行频繁的代码块,JIT也会为维护一个回边计数器,当超过设定阀值,会进行优化编译,这种编译叫做栈上替换(OSR),因为即使循环被编译了,这也是不够的:JVM 必须有能力当循环正在运行时,开始执行此循环已被编译的版本。换句话说,当循环的代码被编译完成,那么循环的下个迭代执行最新的被编译版本则会更加快

可以通过下图,理解解释执行,解释执行以及C1和C2对性能的影响

WX20190716-124349@2x

如果想理解JIT过程,最好的办法是通过JIT日志观察,可以使用 -XX:+PrintCompilation 开启JIT,虚拟机运行的时候,会输出到标准控制台,如下程序,启用 -XX:+PrintCompilation

public class HelloWorld {

  public static void main(String[] args){
    HelloWorld say = new HelloWorld();
    for(int i=0;i<20000;i++){
      say.sayHello();
    }

  }
  public void sayHello(){
    String msg = getMessage();
    String output = "hello"+msg;
  }
  public synchronized  String getMessage() {
    return "world";
  }
}

会在控制台有如下输出

    209    1     n 0       java.lang.System::arraycopy (native)   (static)
    239    2       4       java.lang.Object::<init> (1 bytes)
    240    3       3       java.lang.String::equals (81 bytes)
    241    5       4       java.lang.String::charAt (29 bytes)
    241    6       3       sun.nio.cs.UTF_8$Encoder::encode (359 bytes)
    242    4       3       java.lang.System::getSecurityManager (4 bytes)
    249    7       4       java.lang.String::indexOf (70 bytes)
    250    8       4       java.lang.String::hashCode (55 bytes)
    253    9       3       java.lang.Math::min (11 bytes)
    253   10       3       java.lang.String::startsWith (7 bytes)
    253   11       3       java.lang.String::toCharArray (25 bytes)
    256   12       1       java.net.URL::getQuery (5 bytes)
    256   13       3       java.lang.AbstractStringBuilder::append (50 bytes)
    256   14       3       java.lang.String::getChars (62 bytes)
    257   15       3       java.lang.String::indexOf (166 bytes)
    258   16       3       java.lang.String::startsWith (72 bytes)
    260   17       1       java.io.File::getPath (5 bytes)
    261   19       3       java.lang.String::<init> (62 bytes)
    261   20       3       java.lang.StringBuilder::append (8 bytes)
    262   23       3       java.util.Arrays::copyOfRange (63 bytes)
    262   18       3       java.lang.AbstractStringBuilder::<init> (12 bytes)
    263   21       3       java.lang.StringBuilder::toString (17 bytes)
    263   22       3       java.lang.StringBuilder::<init> (7 bytes)
    263   24  s    1       com.ibeetl.code.jit.HelloWorld::getMessage (3 bytes)
    263   25       3       com.ibeetl.code.jit.HelloWorld::sayHello (26 bytes)
    264   26       4       java.lang.AbstractStringBuilder::append (50 bytes)
    268   27       4       java.lang.String::getChars (62 bytes)
    269   14       3       java.lang.String::getChars (62 bytes)   made not entrant
    270   13       3       java.lang.AbstractStringBuilder::append (50 bytes)   made not entrant
    270   28       4       java.util.Arrays::copyOfRange (63 bytes)
    271   29       4       java.lang.String::<init> (62 bytes)
    273   23       3       java.util.Arrays::copyOfRange (63 bytes)   made not entrant
    274   19       3       java.lang.String::<init> (62 bytes)   made not entrant
    274   30       4       com.ibeetl.code.jit.HelloWorld::sayHello (26 bytes)
    276   25       3       com.ibeetl.code.jit.HelloWorld::sayHello (26 bytes)   made not entrant

第一列数字表示时间,从虚拟机启动开始计算,这一列说明了JIT的优化时间点,第二列是JIT的优化代码块分配的一个ID,第三列是JIT的编译器层次,有如下

  • 0,解释执行
  • 1,使用客户端编译器讲字节码转成机器码运行
  • 2,在阶段1,但会开启方法和回边次数统计,(TODO,更详细点)
  • 3 在阶段2的基础上,还会收集分支调用和虚方法统计
  • 4 C2 阶段,根据统计信息对代码做较为激进的优化,如内联,虚方法调用优化

C1编译器有三个执行阶段,而C2只有一个。通常来说编译优化,会从0到3然后到4阶段。1和2阶段指的是有限优化,限于篇幅,不再详细介绍C1的具体编译过程。

在第二列和第三列中间,有符号对方法进行补充说明,如n表示这是一个native调用,s表示这是一个同步方法 最后一列是编译的方法,数字部分是表示字节码大小,比如ID为24的方法是getMessage,是在263毫秒使用C1编译,getMessage的字节码大小是3个字节

  263   24  s    1       com.ibeetl.code.jit.HelloWorld::getMessage (3 bytes)

getMessage方法的字节码如下

 LDC "world"
 ARETURN

LDC 指令占用一个字节,表示把常量池的数据放到操作栈里,LDC的参数是占一个字节,指向当前的类的常量池,这里就是常量"world",ARETURN 取出栈里的数据并返回,因此总共3个字节

尽管有些方法没有被调用,但虚拟机初始化过程中早已经被调用,比如String.indexOf,String.hashCode等等,这些方法会最早被编译成机器码

如果查看sayHello方法,JIT的日志如下

 263   25       3       com.ibeetl.code.jit.HelloWorld::sayHello (26 bytes)
 274   30       4       com.ibeetl.code.jit.HelloWorld::sayHello (26 bytes)
 276   25       3       com.ibeetl.code.jit.HelloWorld::sayHello (26 bytes)   made not entrant
   

可以看到263毫秒,使用了C3进行编译,然后274毫秒使用了C4编译,276毫秒,sayHello方法后面指示有made not entrant,表示C3编译的结果无效,这里表示被C4编译的机器码替换了。

如果想进一步观察内联信息,可以为虚拟机增加如下参数

-XX:+UnlockDiagnosticVMOptions -XX:+PrintCompilation  -XX:+PrintInlining

再次运行HelloWorld程序,会有更丰富的信息输出,如下是一个关于getMeesage的片段

4       com.ibeetl.code.jit.HelloWorld::sayHello (26 bytes)                      
              @ 1   com.ibeetl.code.jit.HelloWorld::getMessage (3 bytes)   inline
              @ 9   java.lang.StringBuilder::<init> (7 bytes)   inline (hot)     
              @ 14   java.lang.StringBuilder::append (8 bytes)   inline (hot)    
              @ 18   java.lang.StringBuilder::append (8 bytes)   inline (hot)    
              @ 21   java.lang.StringBuilder::toString (17 bytes)   inline (hot) 
3       com.ibeetl.code.jit.HelloWorld::sayHello (26 bytes)   made not entrant   

可以清楚看到在C4阶段,getMessage方法内联了,同时sayHello方法再调用StringBuilder完成字符串操作的所有方法都已经内联

1.4.2 代码缓存

当JIT编译代码后,会放入一个叫代码缓存(CODE CACHE)的地方,这在JDK8 32位机器上,client模式下固定大小为32M, 64为机器上,大小为240M,代码缓存对性能影响非常重要,如果缓存不够,一些优化后的代码不得不被清空以让其他优化进入代码缓存

可以通过XX:+PrintFlagsFinal来打印平台所有参数默认值,比如,我的Mac机器行,有如下输出

 InitialCodeCacheSize                      = 2555904
 ReservedCodeCacheSize                     = 251658240  
 CodeCacheExpansionSize                    = 65536

所示所示,代码缓存默认初始化大小为2555904字节,每次增长6536字节,代码缓存大小为251658240字节

-XX:+PrintCodeCache 用于打印代码缓存使用情况,这是在程序退出时候打印到控制台

CodeCache: size=245760Kb used=1128Kb max_used=1147Kb free=244632Kb
bounds [0x0000000106e00000, 0x0000000107070000, 0x0000000115e00000]
total_blobs=291 nmethods=35 adapters=170
compilation: enabled

size 表示代码缓存大小,这并不是实际使用,这是一个最大值,used表示实际占用的大小,max_used比used大,表示实际占用的内存大小,需要参考这个指标作为设定Code Cache大小一句,free是size-used的值

当代码缓存满的时候,JIT通常会清理掉一部分Code Cache,这使用UseCodeCacheFlushing来控制

UseCodeCacheFlushing = true

可以使用XX:-UseCodeCacheFlushing 关闭自动清理,这样JIT将停止编译新的代码

另外一种清理Code Cache原因JIT认为优化认为是无效的,将会退出这部分优化代码,比如虚方法调用出现的逆优化,在1.6.2s详细说明

可以通过-XX:ReservedCodeCacheSize=*N* 来设置代码缓存,通常不需要那么做,除非你通过打印代码缓存,认为CodeCache使用过大或者过小

java -XX:ReservedCodeCacheSize=24960K

显然,内联策略也会影响代码缓存大小,比如设置内联嵌套层次,最大的内联代码大小等,我们将在8.5一节详细说明

1.4.3 JITWatch

JITWatch是JIT日志分析工具,一个分析展现JIT日志等的图形界面工具,本章花费一定篇幅介绍JITWatch,是因为使用它,能更清楚的了解JIT的优化过程。JITWatch是开源工具,如下安装JITWatch

git clone git@github.com:AdoptOpenJDK/jitwatch.git
cd jitwatch
mvn clean install -DskipTests=true
./launchUI.sh  # 或者launchUI.bat

启动界面如下

jitwatch-init

为了获得JIT日志,需要在启动应用程序的虚拟机增加一个参数

-XX:+LogCompilation

虚拟机会在程序运行的目录生成一个hotspot_pidXXX.log的XML文件,XXX是进程ID,日志文件是一个xml格式,记录了JIT优化过程,其内容很难看懂。但通过JITWatch,可以方便可视化的分析日志文件

Open Log 按钮用于加载日志,点击按钮,选择日志文件即可以加载成功。JITLog窗口的下方面板是控制台,会有输出,表示加载成功

Selected log file: /Users/xiandafu/git/code/ch10/hotspot_pid51119.log(TODO,修改)

Using Config: Default

Click Start button to process the JIT log

在点击Start按钮前,还需要配置以下JITWatch,告诉程序的源代码位置和编译后的class位置,点击Config按钮,我们可以本章的附带例子HelloWorld为例子(TODO,修改成最新例子)

jitwatch-config

JITWatch默认包含了JDK的代码和class,我们需要增加自己项目的源代码和class

配置好项目源代码和Class,点击Start按钮,JITWatch会消耗数秒分析日志。显示结果如下

jitwatch-inline

左边是一个按照Java包组织的类视图,所有参与编译的的类都在这里,我们可以找到HelloWorld,则右边面板显示了编译的方法,绿色打勾的类或者包意味着JIT进行了编译优化。如上,getMessage方法被编译了,main方法和sayHello都被编译了,然后HelloWold()因为是构造函数,只执行了一次,没有被JIT编译。

单击右边面板的sayHello方法。会弹出新的窗口,从左到右包含三个面板,分别是方法的源码,字节码,和编译后的机器码,要获取机器码,需要一个Debug JVM并开启-XX:PrintAssembly,查看机器码超出了本书的范畴,就不再本书介绍。

jitwatch-sayHello

点击工具栏按钮chain,JITWatch会打开一个新的面板,列出sayHello方法的优化明细(TODO,修改如下图小点)

jitwatch-chain

如上图右侧面板Key解释

  • Inlined,绿色表示inline,比如getMessage方法已经内联到sayHello方法里了,我们再前面也分析过,getMessage得字节码只占用三个字节,很容易被内联

  • Compiled,红色表示编译,sayHello被JIT编译,但是否被内联,我们需要查看其调用者mian方法,可以查看main方法的chain,也可以看到sayHello同时也被内联了。

  • Virtaul Call,表示虚方法调用,我们会在8.6解释虚方法调用,虚方法,简单来说,就是在Java支持多态,虚拟机需要查询一个虚表才能知道方法调用的是具体哪个类的哪个方法,这样性能就比较慢了,我们在第五章里也提到了,静态方法有最快的调用效率。

回到主面板,JITWatch也提供了汇总信息,点击Suggesion按钮,会给出一些方法未采用内联的原因,点击Threads,可以给出一个优化队列和编译线程的汇总信息,如下

jitwatch-thread

面板上方有3个线程,一个是C1得编译线程,两个是C2得编译线程,上方点击任意一个方块,会在下方面板显示编译任务的详细信息

CompileID,编译任务ID

Class 编译的类

Compiled Member,编译的方法

Compilations 表示的是编译的阶段

Compiler Used,采用的编译方式,比如HelloWorld.Main是一个循环调用,因此采用OSR优化方式

Queue at 表示这次编译任务入队列时间

Compile Start JIT编译的开始时间

Compile Duration 编译所有的时长

Bytecode Size,字节码大小

Native Size,编译的机器码大小。

主面板的TopList也是个值得关注的汇总信息,点击TopList后,弹出一个新的面板

jitwatch-toplist

比如默认降序排列了方法的字节码大小。其他可选的还有

  • Compilation Order: 编译顺序

  • OSR:OSR顺序

  • Inlining Failure REASON:内联失败原因,比如,"calee is too large",表示调用方法太大

  • Most Decompiled Method: 逆优化方法列表

1.4.4 JIT常见优化

参考 https://www.chrisnewland.com/images/slides/JavaZone2016_JITWatch.pdf

https://www.javacodegeeks.com/2016/08/java-steroids-5-super-useful-jit-optimization-techniques.html

https://docs.oracle.com/en/database/oracle/oracle-database/12.2/jjdev/Oracle-JVM-JIT.html#GUID-9466BE4E-E7EE-486F-9DF8-D331B316359D

JIT提供了很有优化方式,这里列出三个较为重要的优化方法

  • 内联,避免调用方法成本,在JVM一章,讲介绍虚拟机调用方法的过程
  • 虚方法调用,Java支持多态,在运行时候Java调用的具体是哪个对象方法,需要查询一个虚方法表。
  • 逃逸分析 (TODO)

内联

为了避免调用的方法成本,我们期望方法能尽可能的小,从而能内联到调用者。这样能获得数倍性能提高,如下例子测试了同一个方法dataAdd(int,int)被内联和没有内联的性能。

@BenchmarkMode(Mode.AverageTime)
@Warmup(iterations = 10)
@Measurement(iterations = 10, time = 1, timeUnit = TimeUnit.SECONDS)
@Threads(1)
@Fork(1)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@State(Scope.Benchmark)
public class InlineTest {

  int x=0,y=0;
  //通过CompilerControl指示不要内联
  @Benchmark
  @CompilerControl(CompilerControl.Mode.DONT_INLINE) 
  public  int   add(){
    return add(x,y);
  }

  //内联方法
  @Benchmark
  public  int  addInline(){
    return add(x,y);
  }

  private int  dataAdd(int x,int y){
    return x+y;
  }

  @Setup
  public void init(){
    x =1;
    y= 2;
  }

  public static void main(String[] args) throws RunnerException {
    Options opt = new OptionsBuilder()
      .include(InlineTest.class.getSimpleName())
      .build();
    new Runner(opt).run();
  }
}

add方法和 addInline方法是要测试的方法,都调用dataAdd方法,前者使用CompilerControl,告诉JIT不内联。按照每次操作消耗的纳秒统计,结果如下

Benchmark                       Mode   Score    Units        
c.i.c.j.InlineTest.add          avgt   4.381    ns/op        
c.i.c.j.InlineTest.addInline    avgt   2.951    ns/op        

可以看到,内联对性能得提高还是非常大的。

通过-XX:+PrintFlagsFinal可以看到JIT对内联的默认设定,在我的64位Mac的 JDK8上,如下值

  • MaxInlineSize 一个方法能被内联得最大字节,默认为35字节,可以设定较小值,比如6,保证只有一些简单方法被内联.设定较大字节,能使得一些较大方法被内联,带来性能的提升。
  • MinInliningThreshold,一个计数,默认250次,超过这个次数,JIT决定内联,较小得方法不受这个参数影响
  • MaxInlineLevel,默认配置为最多允许9层嵌套得方法被内联,比如a(),调用b(),b()调用c(),因此,a()在内联b的时候可以内联c
  • InlineSmallCode ,默认为2000字节,一个阀值,当要内联一个已经被编译的方法时候,其机器码的最大字节数不超过此值大小 。否则,直接调用此方法
  • FreqInlineSize,325字节,调用频繁的方法能被内联的最大字节码大小。

查看JIT的内联日志需要启用如下虚拟机参数

-XX:+PrintCompilation -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining

比如本章的Hellowrld的输出

 java.lang.StringBuilder::append (8 bytes)                                  
       @ 2   java.lang.AbstractStringBuilder::append (50 bytes)   callee is 
     @ 18   java.lang.StringBuilder::append (8 bytes)                       
       @ 2   java.lang.AbstractStringBuilder::append (50 bytes)   callee is 
     @ 21   java.lang.StringBuilder::toString (17 bytes)                    
       @ 13   java.lang.String::<init> (62 bytes)   callee is too large     
com.ibeetl.code.jit.HelloWorld::main @ 10 (27 bytes)                        
   @ 17   com.ibeetl.code.jit.HelloWorld::sayHello (26 bytes)   inline (hot)
     @ 1   com.ibeetl.code.jit.HelloWorld::getMessage (3 bytes)   inline (ho
     @ 9   java.lang.StringBuilder::<init> (7 bytes)   inline (hot)         
     @ 14   java.lang.StringBuilder::append (8 bytes)   inline (hot)        
     @ 18   java.lang.StringBuilder::append (8 bytes)   inline (hot)        

内联通常有如下信息显示,指示内联是否成功

  • inline (hot) ,表示方法被标记为内联

  • callee is too large,C1打印出来信息,指示方法超过MaxInlineSize 不能内联

  • hot method too big,C2打印出来的信息,指示方法大小超过FreqInlineSize

  • already compiled into a big method:内联一个已经编译的方法,大小超过了InlineSmallCode值

我们也可以通过上一节介绍的JITWatch来观察方法是否被内联,以及没有被内联的原因,有助于提高Java系统性能化

虚方法调用

调用方法的另外一个成本来自于虚方法调用,Java支持多态,在运行时候具体调用哪个方法,需要查询一张vtable,这是一个耗时的操作,vtable是虚拟函数表,每个类都维护一个vtable,包含了自有函数(非final,非static,非private)和父类的函数虚拟表:如下俩个类

class Foo {
	int  say(int x) {
    return x+2;
  }
  int  say() {
    return0;
  }
}
class Bar extends Foo{
	int  say(int x) {
    return x+1;
  }

}

那么对于Foo类,则vtable如下

  • say(int)->Foo.say(int)
  • say() ->Foo.say()
  • hasCode()->Object.hasCode
  • ...忽略其它Object方法

对于Bar类,vtable如下

  • say(int)-> Bar.say(int)
  • say() ->Foo.say()
  • hasCode()->Object.hasCode
  • ..忽略其它Object方法

当在java中调用方法虚方法的时候,并不知道具体调用的是哪一个方法,比如对应如下代码片段的foo.say()调用,字节码是使用INVOKEVIRTUAL

public void test() {
  Foo foo =  getFoo();
  foo.say();
}

public  Foo getFoo(){
  return new Bar();
}

虚拟机指令为

INVOKEVIRTUAL com/ibeetl/code/jit/Foo.say ()V

这意味这运行时,JVM不能直接使用INVOKEVIRTUAL后面的参数当着方法的入口地址直接调用,需要查询foo变量对应的类的虚方法,找到也就是Bar类虚方法表中say()的调用地址。

JIT 通过优化,如果发现虚方法总是调用统一方法,会尝试优化成直接调用。如果后期发现实例改变,则会退出优化,可以JMH测试验证,如下只测试call方法性能,但提供俩个参数virtual,先测试false情况,即getFoo总是返回Foo对象,然后再测试virutal为true情况,在一次迭代调用超过Max次后,返回Bar对象。

@BenchmarkMode(Mode.AverageTime)
@Warmup(iterations = 10)
@Measurement(iterations = 10, time = 1, timeUnit = TimeUnit.SECONDS)
@Threads(1)
@Fork(1)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@State(Scope.Benchmark)
public class VirtualCallTest {

  @Param({"false","true"})
  boolean virtual;
  int x = 1;
  static final int   MAX = 2000;
  int count = 0;

  @Benchmark
  public  int  call(){
    return justCall();
  }

  private int justCall(){
    Foo foo = getFoo();
    return foo.say(x);
  }

  //
  private Foo getFoo() {
    count++;
    if (virtual) {
      
      //在调用超过max次后,JIT会退出优化,因为类型不在为Foo方法
      if(count>MAX){
        return new Bar();
      }else{
        return new Foo();
      }

    }else{
      //为false,总是返回同一个对象
      if(count>MAX){
        return new Foo();
      }else{
        return new Foo();
      }
    }
  }

测试结果跟我们预期一样,输出如下

Benchmark                               (virtual)  Mode    Score   Units        
c.i.c.j.v.VirtualCallTest.call              false  avgt    2.292   ns/op        
c.i.c.j.v.VirtualCallTest.call               true  avgt    3.592   ns/op        

事实上,JIT的优化速度接近静态调用,如果为Foo类增加一个静态调用方法

static int say2(int x){
  return x+2;
}

在JMH中,同时测试这个静态调用方法

@Benchmark
public  int  staticCall(){
  //代码同call方法,最后采用了静态调用
  Foo foo = getFoo();
  return Foo.say2(x);
}

你会发现JIT优化后的虚方法调用跟静态调用的性能机会是一样的

Benchmark                                 Mode    Score   Units 
c.i.c.j.v.VirtualCallTest.staticCall      avgt    2.116   ns/op 

可以打开编译日志查看到底发生了什么

@Benchmark
@Fork(value=1,jvmArgsAppend = "-XX:+PrintCompilation")
public  int  call(){
  return justCall();

}

输出的信息较多,节选一段出现虚拟调用的时候的日志,出现了“made zombie“,这个与made not entrant一样,都是指示JIT退出优化。不同的是made zombie通常是虚方法调用的退出优化指示

 24681  340        3       com.ibeetl.code.jit.virtuals.VirtualCallTest::call (5 bytes)   made zombie
  24681  342       3       com.ibeetl.code.jit.virtuals.VirtualCallTest::getFoo (69 bytes)   made zombie
  24681  427       3       java.util.Vector::ensureCapacityHelper (16 bytes)
  24681  345       3       org.openjdk.jmh.infra.Blackhole::consume (39 bytes)   made zombie
  24681  346       3       com.ibeetl.code.jit.virtuals.Bar::<init> (5 bytes)   made zombie
  24681  347       3       com.ibeetl.code.jit.virtuals.Bar::say (4 bytes)   made zombie
展开阅读全文
加载中
点击加入讨论🔥(1) 发布并加入讨论🔥
打赏
1 评论
0 收藏
0
分享
返回顶部
顶部