写点什么

JVM 核心知识体系

作者:钟奕礼
  • 2022-11-28
    湖南
  • 本文字数:34279 字

    阅读完需:约 112 分钟

1.问题


1、如何理解类文件结构布局?2、如何应用类加载器的工作原理进行将应用辗转腾挪?3、热部署与热替换有何区别,如何隔离类冲突?4、JVM 如何管理内存,有何内存淘汰机制?5、JVM 执行引擎的工作机制是什么?6、JVM 调优应该遵循什么原则,使用什么工具?7、JPDA 架构是什么,如何应用代码热替换?8、JVM 字节码增强技术有哪些?2.关键词


类结构,类加载器,加载,链接,初始化,双亲委派,热部署,隔离,堆,栈,方法区,计数器,内存回收,执行引擎,调优工具,JVMTI,JDWP,JDI,热替换,字节码,ASM,CGLIB,DCEVM


3.全文概要(文末有惊喜,PC 端阅读代码更佳)


作为三大工业级别语言之一的 JAVA 如此受企业青睐有加,离不开她背后 JVM 的默默复出。只是由于 JAVA 过于成功以至于我们常常忘了 JVM 平台上还运行着像 Clojure/Groovy/Kotlin/Scala/JRuby/Jython 这样的语言。我们享受着 JVM 带来跨平台“一次编译到处执行”台的便利和自动内存回收的安逸。本文从 JVM 的最小元素类的结构出发,介绍类加载器的工作原理和应用场景,思考类加载器存在的意义。进而描述 JVM 逻辑内存的分布和管理方式,同时列举常用的 JVM 调优工具和使用方法,最后介绍高级特性 JDPA 框架和字节码增强技术,实现热替换。从微观到宏观,从静态到动态,从基础到高阶介绍 JVM 的知识体系。


4.类的装载


4.1 类的结构


我们知道不只 JAVA 文本文件,像 Clojure/Groovy/Kotlin/Scala 这些文本文件也同样会经过 JDK 的编译器编程成 class 文件。进入到 JVM 领域后,其实就跟 JAVA 没什么关系了,JVM 只认得 class 文件,那么我们需要先了解 class 这个黑箱里面包含的是什么东西。


JVM 规范严格定义了 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];}详细的描述我们可以从 JVM 规范说明书里面查阅类文件格式(https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-4.html ),类的整体布局如下图展示的。


在我的理解,我想把每个 CLASS 文件类别成一个一个的数据库,里面包含的常量池/类索引/属性表集合就像数据库的表,而且表之间也有关联,常量池则存放着其他表所需要的所有字面量。了解完类的数据结构后,我们需要来观察 JVM 是如何使用这些从硬盘上或者网络传输过来的 CLASS 文件。


4.2 加载机制


4.2.1 类的入口


在我们探究 JVM 如何使用 CLASS 文件之前,我们快速回忆一下编写好的 C 语言文件是如何执行的?我们从 C 的 HelloWorld 入手看看先。


#include <stdio.h>int main() {/* my first program in C */printf("Hello, World! \n");return 0;}编辑完保存为 hello.c 文本文件,然后安装 gcc 编译器(GNU C/C++)


./a.outHello, World!这个过程就是 gcc 编译器将 hello.c 文本文件编译成机器指令集,然后读取到内存直接在计算机的 CPU 运行。从操作系统层面看的话,就是一个进程的启动到结束的生命周期。


下面我们看 JAVA 是怎么运行的。学习 JAVA 开发的第一件事就是先下载 JDK 安装包,安装完配置好环境变量,然后写一个名字为 helloWorld 的类,然后编译执行,我们来观察一下发生了什么事情?


先看源码,有够简单了吧。


package com.zooncool.example.theory.jvm;public class HelloWorld {public static void main(String[] args) {System.out.println("my classLoader is " + HelloWorld.class.getClassLoader());}}编译执行


java -cp src/main/java/ com.zooncool.example.theory.jvm.HelloWorldmy classLoader is sun.misc.Launcher$AppClassLoader@2a139a55 对比 C 语言在命令行直接运行编译后的 a.out 二进制文件,JAVA 的则是在命令行执行 java classFile,从命令的区别我们知道操作系统启动的是 java 进程,而 HelloWorld 类只是命令行的入参,在操作系统来看 java 也就是一个普通的应用进程而已,而这个进程就是 JVM 的执行形态(JVM 静态就是硬盘里 JDK 包下的二进制文件集合)。


学习过 JAVA 的都知道入口方法是 public static void main(String[] args),缺一不可,那我猜执行 java 命令时 JVM 对该入口方法做了唯一验证,通过了才允许启动 JVM 进程,下面我们来看这个入口方法有啥特点。


去掉 public 限定 java -cp src/main/java/ com.zooncool.example.theory.jvm.HelloWorld 错误: 在类 com.zooncool.example.theory.jvm.HelloWorld 中找不到 main 方法, 请将 main 方法定义为:public static void main(String[] args)否则 JavaFX 应用程序类必须扩展 javafx.application.Application 说名入口方法需要被 public 修饰,当然 JVM 调用 main 方法是底层的 JNI 方法调用不受修饰符影响。


去掉 static 限定 java -cp src/main/java/ com.zooncool.example.theory.jvm.HelloWorld 错误: main 方法不是类 com.zooncool.example.theory.jvm.HelloWorld 中的 static, 请将 main 方法定义为:public static void main(String[] args)我们是从类对象调用而不是类创建的对象才调用,索引需要静态修饰


返回类型改为 int java -cp src/main/java/ com.zooncool.example.theory.jvm.HelloWorld 错误: main 方法必须返回类 com.zooncool.example.theory.jvm.HelloWorld 中的空类型值, 请将 main 方法定义为:public static void main(String[] args)void 返回类型让 JVM 调用后无需关心调用者的使用情况,执行完就停止,简化 JVM 的设计。


方法签名改为 main1 java -cp src/main/java/ com.zooncool.example.theory.jvm.HelloWorld 错误: 在类 com.zooncool.example.theory.jvm.HelloWorld 中找不到 main 方法, 请将 main 方法定义为:public static void main(String[] args)否则 JavaFX 应用程序类必须扩展 javafx.application.Application 这个我也不清楚,可能是约定俗成吧,毕竟 C/C++也是用 main 方法的。


说了这么多 main 方法的规则,其实我们关心的只有两点:


HelloWorld 类是如何被 JVM 使用的 HelloWorld 类里面的 main 方法是如何被执行的关于 JVM 如何使用 HelloWorld 下文我们会详细讲到。


我们知道 JVM 是由 C/C++语言实现的,那么 JVM 跟 CLASS 打交道则需要 JNI(Java Native Interface)这座桥梁,当我们在命令行执行 java 时,由 C/C++实现的 java 应用通过 JNI 找到了 HelloWorld 里面符合规范的 main 方法,然后开始调用。我们来看下 java 命令的源码就知道了


/*


  • Get the application's main class./if (jarfile != 0) {mainClassName = GetMainClassName(env, jarfile);... ...mainClass = LoadClass(env, classname);if(mainClass == NULL) { / exception occured /... .../ Get the application's main method */mainID = (env)->GetStaticMethodID(env, mainClass, "main", "([Ljava/lang/String;)V");... ...{/ Make sure the main method is public */jint mods;jmethodID mid;jobject obj = (env)->ToReflectedMethod(env, mainClass, mainID, JNI_TRUE);... .../ Build argument array /mainArgs = NewPlatformStringArray(env, argv, argc);if (mainArgs == NULL) {ReportExceptionDescription(env);goto leave;}/ Invoke main method. */(*env)->CallStaticVoidMethod(env, mainClass, mainID, mainArgs);4.2.2 类加载器


上一节我们留了一个核心的环节,就是 JVM 在执行类的入口之前,首先得找到类再然后再把类装到 JVM 实例里面,也即是 JVM 进程维护的内存区域内。我们当然知道是一个叫做类加载器的工具把类加载到 JVM 实例里面,抛开细节从操作系统层面观察,那么就是 JVM 实例在运行过程中通过 IO 从硬盘或者网络读取 CLASS 二进制文件,然后在 JVM 管辖的内存区域存放对应的文件。我们目前还不知道类加载器的实现,但是我们从功能上判断无非就是读取文件到内存,这个是很普通也很简单的操作。


如果类加载器是 C/C++实现的话,那么大概就是如下代码就可以实现


char *fgets( char *buf, int n, FILE *fp );如果是 JAVA 实现,那么也很简单


InputStream f = new FileInputStream("theory/jvm/HelloWorld.class");从操作系统层面看的话,如果只是加载,以上代码就足以把类文件加载到 JVM 内存里面了。但是结果就是乱糟糟的把一堆毫无秩序的类文件往内存里面扔,没有良好的管理也没法用,所以需要我们需要设计一套规则来管理存放内存里面的 CLASS 文件,我们称为类加载的设计模式或者类加载机制,这个下文会重点解释。


根据官网的定义 A class loader is an object that is responsible for loading classes. 类加载器就是负责加载类的。我们知道启动 JVM 的时候会把 JRE 默认的一些类加载到内存,这部分类使用的加载器是 JVM 默认内置的由 C/C++实现的,比如我们上文加载的 HelloWorld.class。但是内置的类加载器有明确的范围限定,也就是只能加载指定路径下的 jar 包(类文件的集合)。如果只是加载 JRE 的类,那可玩的花样就少很多,JRE 只是提供了底层所需的类,更多的业务需要我们从外部加载类来支持,所以我们需要指定新的规则,以方便我们加载外部路径的类文件。


系统默认加载器


Bootstrap class loader 作用:启动类加载器,加载 JDK 核心类类加载器:C/C++实现类加载路径: /jre/libURL[] urls = sun.misc.Launcher.getBootstrapClassPath().getURLs();/Library/Java/JavaVirtualMachines/jdk1.8.0_181.jdk/Contents/Home/jre/lib/resources.jar.../Library/Java/JavaVirtualMachines/jdk1.8.0_181.jdk/Contents/Home/jre/lib/rt.jar 实现原理:本地方法由 C++实现 Extensions class loader 作用:扩展类加载器,加载 JAVA 扩展类库。类加载器:JAVA 实现类加载路径: /jre/lib/extSystem.out.println(System.getProperty("java.ext.dirs"));/Library/Java/JavaVirtualMachines/jdk1.8.0_181.jdk/Contents/Home/jre/lib/ext:实现原理:扩展类加载器 ExtClassLoader 本质上也是 URLClassLoaderLauncher.java//构造方法返回扩展类加载器 public Launcher() {//定义扩展类加载器 Launcher.ExtClassLoader var1;try {//1、获取扩展类加载器 var1 = Launcher.ExtClassLoader.getExtClassLoader();} catch (IOException var10) {throw new InternalError("Could not create extension class loader", var10);}...}//扩展类加载器 static class ExtClassLoader extends URLClassLoader {private static volatile Launcher.ExtClassLoader instance;//2、获取扩展类加载器实现 public static Launcher.ExtClassLoader getExtClassLoader() throws IOException {if (instance == null) {Class var0 = Launcher.ExtClassLoader.class;synchronized(Launcher.ExtClassLoader.class) {if (instance == null) {//3、构造扩展类加载器 instance = createExtClassLoader();}}}return instance;}//4、构造扩展类加载器具体实现 private static Launcher.ExtClassLoader createExtClassLoader() throws IOException {try {return (Launcher.ExtClassLoader)AccessController.doPrivileged(new PrivilegedExceptionAction<Launcher.ExtClassLoader>() {public Launcher.ExtClassLoader run() throws IOException {//5、获取扩展类加载器加载目标类的目录 File[] var1 = Launcher.ExtClassLoader.getExtDirs();int var2 = var1.length;for(int var3 = 0; var3 < var2; ++var3) {MetaIndex.registerDirectory(var1[var3]);}//7、构造扩展类加载器 return new Launcher.ExtClassLoader(var1);}});} catch (PrivilegedActionException var1) {throw (IOException)var1.getException();}}//6、扩展类加载器目录路径 private static File[] getExtDirs() {String var0 = System.getProperty("java.ext.dirs");File[] var1;if (var0 != null) {StringTokenizer var2 = new StringTokenizer(var0, File.pathSeparator);int var3 = var2.countTokens();var1 = new File[var3];for(int var4 = 0; var4 < var3; ++var4) {var1[var4] = new File(var2.nextToken());}} else {var1 = new File[0];}return var1;}//8、扩展类加载器构造方法 public ExtClassLoader(File[] var1) throws IOException {super(getExtURLs(var1), (ClassLoader)null, Launcher.factory);SharedSecrets.getJavaNetAccess().getURLClassPath(this).initLookupCache(this);}}System class loader 作用:系统类加载器,加载应用指定环境变量路径下的类类加载器:sun.misc.Launcher$AppClassLoader 类加载路径:-classpath 下面的所有类实现原理:系统类加载器 AppClassLoader 本质上也是 URLClassLoaderLauncher.java//构造方法返回系统类加载器 public Launcher() {try {//获取系统类加载器 this.loader = Launcher.AppClassLoader.getAppClassLoader(var1);} catch (IOException var9) {throw new InternalError("Could not create application class loader", var9);}}static class AppClassLoader extends URLClassLoader {final URLClassPath ucp = SharedSecrets.getJavaNetAccess().getURLClassPath(this);//系统类加载器实现逻辑 public static ClassLoader getAppClassLoader(final ClassLoader var0) throws IOException {//类比扩展类加载器,相似的逻辑 final String var1 = System.getProperty("java.class.path");final File[] var2 = var1 == null ? new File[0] : Launcher.getClassPath(var1);return (ClassLoader)AccessController.doPrivileged(new PrivilegedAction<Launcher.AppClassLoader>() {public Launcher.AppClassLoader run() {URL[] var1x = var1 == null ? new URL[0] : Launcher.pathToURLs(var2);return new Launcher.AppClassLoader(var1x, var0);}});}//系统类加载器构造方法 AppClassLoader(URL[] var1, ClassLoader var2) {super(var1, var2, Launcher.factory);this.ucp.initLookupCache(this);}}通过上文运行 HelloWorld 我们知道 JVM 系统默认加载的类大改是 1560 个,如下图


自定义类加载器


内置类加载器只加载了最少需要的核心 JAVA 基础类和环境变量下的类,但是我们应用往往需要依赖第三方中间件来完成额外的业务,那么如何把它们的类加载进来就显得格外重要了。幸好 JVM 提供了自定义类加载器,可以很方便的完成自定义操作,最终目的也是把外部的类文件加载到 JVM 内存。通过继承 ClassLoader 类并且复写 findClass 和 loadClass 方法就可以达到自定义获取 CLASS 文件的目的。


首先我们看 ClassLoader 的核心方法 loadClass


protected Class<?> loadClass(String name, boolean resolve)throws ClassNotFoundException{synchronized (getClassLoadingLock(name)) {// First, check if the class has already been loaded,看缓存有没有没有才去找 Class<?> c = findLoadedClass(name);if (c == null) {long t0 = System.nanoTime();try {//先看是不是最顶层,如果不是则 parent 为空,然后获取父类 if (parent != null) {c = parent.loadClass(name, false);} else {//如果为空则说明应用启动类加载器,让它去加载 c = findBootstrapClassOrNull(name);}} catch (ClassNotFoundException e) {// ClassNotFoundException thrown if class not found// from the non-null parent class loader}if (c == null) {// If still not found, then invoke findClass in order//如果还是没有就调用自己的方法,确保调用自己方法前都使用了父类方法,如此递归三次到顶 long t1 = System.nanoTime();c = findClass(name);// this is the defining class loader; record the statssun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);sun.misc.PerfCounter.getFindClasses().increment();}}if (resolve) {resolveClass(c);}return c;}}protected Class<?> findClass(String name) throws ClassNotFoundException {throw new ClassNotFoundException(name);}通过复写 loadClass 方法,我们甚至可以读取一份加了密的文件,然后在内存里面解密,这样别人反编译你的源码也没用,因为 class 是经过加密的,也就是理论上我们通过自定义类加载器可以做到为所欲为,但是有个重要的原则下文介绍类加载器设计模式会提到。


一下给出一个自定义类加载器极简的案例,来说明自定义类加载器的实现。


package com.zooncool.example.theory.jvm;import java.io.FileInputStream;import static java.lang.System.out;public class ClassIsolationPrinciple {public static void main(String[] args) {try {String className = "com.zooncool.example.theory.jvm.ClassIsolationPrinciple$Demo"; //定义要加载类的全限定名 Class<?> class1 = Demo.class; //第一个类又系统默认类加载器加载//第二个类 MyClassLoader 为自定义类加载器,自定义的目的是覆盖加载类的逻辑 Class<?> class2 = new MyClassLoader("target/classes").loadClass(className);out.println("-----------------class name-----------------");out.println(class1.getName());out.println(class2.getName());out.println("-----------------classLoader name-----------------");out.println(class1.getClassLoader());out.println(class2.getClassLoader());Demo.example = 1;//这里修改的系统类加载器加载的那个类的对象,而自定义加载器加载进去的类的对象保持不变,也即是同时存在内存,但没有修改 example 的值。out.println("-----------------field value-----------------");out.println(class1.getDeclaredField("example").get(null));out.println(class2.getDeclaredField("example").get(null));} catch (ClassNotFoundException e) {e.printStackTrace();} catch (NoSuchFieldException e) {e.printStackTrace();} catch (IllegalAccessException e) {e.printStackTrace();}}public static class Demo {public static int example = 0;}public static class MyClassLoader extends ClassLoader{private String classPath;public MyClassLoader(String classPath) {this.classPath = classPath;}//自定义类加载器继承了 ClassLoader,称为一个可以加载类的加载器,同时覆盖了 loadClass 方法,实现自己的逻辑 @Overridepublic Class<?> loadClass(String name) throws ClassNotFoundException {if(!name.contains("java.lang")){//排除掉加载系统默认需要加载的内心类,因为些类只能又默认类加载器去加载,第三方加载会抛异常,具体原因下文解释 byte[] data = new byte[0];try {data = loadByte(name);} catch (Exception e) {e.printStackTrace();}return defineClass(name,data,0,data.length);}else{return super.loadClass(name);}}//把影片的二进制类文件读入内存字节流 private byte[] loadByte(String name) throws Exception {name = name.replaceAll("\.", "/");String dir = classPath + "/" + name + ".class";FileInputStream fis = new FileInputStream(dir);int len = fis.available();byte[] data = new byte[len];fis.read(data);fis.close();return data;}}}执行结果如下,我们可以看到加载到内存方法区的两个类的包名+名称是一样的,而对应的类加载器却不一样,而且输出被加载类的值也是不一样的。


-----------------class name-----------------com.zooncool.example.theory.jvm.ClassIsolationPrinciple2Demo-----------------classLoader name-----------------sun.misc.LauncherMyClassLoader@511d50c0-----------------field value-----------------104.2.3 设计模式


现有的加载器分为内置类加载器和自定义加载器,不管它们是通过 C 或者 JAVA 实现的最终都是为了把外部的 CLASS 文件加载到 JVM 内存里面。那么我们就需要设计一套规则来管理组织内存里面的 CLASS 文件,下面我们就来介绍下通过这套规则如何来协调好内置类加载器和自定义类加载器之间的权责。


我们知道通过自定义类加载器可以干出很多黑科技,但是有个基本的雷区就是,不能随便替代 JAVA 的核心基础类,或者说即是你写了一个跟核心类一模一样的类,JVM 也不会使用。你想一下,如果为所欲为的你可以把最基础本的 java.lang.Object 都换成你自己定义的同名类,然后搞个后门进去,而且 JVM 还使用的话,那谁还敢用 JAVA 了是吧,所以我们会介绍一个重要的原则,在此之前我们先介绍一下内置类加载器和自定义类加载器是如何协同的。


双亲委派机制定义:某个特定的类加载器在接到加载类的请求时,首先将加载任务委托给父类加载器,依次递归,如果父类加载器可以完成类加载任务,就成功返回;只有父类加载器无法完成此加载任务时,才自己去加载。实现:参考上文 loadClass 方法的源码和注释,通过最多三次递归可以到启动类加载器,如果还是找不到这调用自定义方法。


双亲委派机制很好理解,目的就是为了不重复加载已有的类,提高效率,还有就是强制从父类加载器开始逐级搜索类文件,确保核心基础类优先加载。下面介绍的是破坏双亲委派机制,了解为什么要破坏这种看似稳固的双亲委派机制。


破坏委派机制定义:打破类加载自上而上委托的约束。实现:1、继承 ClassLoader 并且重写 loadClass 方法体,覆盖依赖上层类加载器的逻辑;2、”启动类加载器”可以指定“线程上下文类加载器”为任意类加载器,即是“父类加载器”委托“子类加载器”去加载不属于它加载范围的类文件;说明:双亲委派机制的好处上面我们已经提过了,但是由于一些历史原因(JDK1.2 加上双亲委派机制前的 JDK1.1 就已经存在,为了向前兼容不得不开这个后门让 1.2 版本的类加载器拥有 1.1 随意加载的功能)。还有就是 JNDI 的服务调用机制,例如调用 JDBC 需要从外部加载相关类到 JVM 实例的内存空间。介绍完内置类加载器和自定义类加载器的协同关系后,我们要重点强调上文提到的重要原则。


唯一标识定义:JVM 实例由类加载器+类的全限定包名和类名组成类的唯一标志。实现:加载类的时候,JVM 判断类是否来自相同的加载器,如果相同而且全限定名则直接返回内存已有的类。说明:上文我们提到如何防止相同类的后门问题,有了这个黄金法则,即使相同的类路径和类,但是由于是由自定义类加载器加载的,即使编译通过能被加载到内存,也无法使用,因为 JVM 核心类是由内置类加载器加载标志和使用的,从而保证了 JVM 的安全加载。通过缓存类加载器和全限定包名和类名作为类唯一索引,加载重复类则抛异常提示”attempted duplicate class definition for name”。原理:双亲委派机制父类检查缓存,源码我们介绍 loadClass 方法的时候已经讲过,破坏双亲委派的自定义类加载器在加载类二进制字节码后需要调用 defineClass 方法,而该方法同样会从 JVM 方法区检索缓存类,存在的话则提示重复定义。4.2.4 加载过程


至此我们已经深刻认识到类加载器的工作原理及其存在的意义,下面我们将介绍类从外部介质加载使用到卸载整个闭环的生命周期。


加载


上文花了不少的篇幅说明了类的结构和类是如何被加载到 JVM 内存里面的,那究竟什么时候 JVM 才会触发类加载器去加载外部的 CLASS 文件呢?通常有如下四种情况会触发到:


显式字节码指令集(new/getstatic/putstatic/invokestatic):对应的场景就是创建对象或者调用到类文件的静态变量/静态方法/静态代码块反射:通过对象反射获取类对象时继承:创建子类触发父类加载入口:包含 main 方法的类首先被加载 JVM 只定了类加载器的规范,但却不明确规定类加载器的目标文件,把加载的具体逻辑充分交给了用户,包括重硬盘加载的 CLASS 类到网络,中间文件等,只要加载进去内存的二进制数据流符合 JVM 规定的格式,都是合法的。


链接


类加载器加载完类到 JVM 实例的指定内存区域(方法区下文会提到)后,是使用前会经过验证,准备解析的阶段。


验证:主要包含对类文件对应内存二进制数据的格式、语义关联、语法逻辑和符合引用的验证,如果验证不通过则跑出 VerifyError 的错误。但是该阶段并非强制执行,可以通过-Xverify:none 来关闭,提高性能。准备:但我们验证通过时,内存的方法区存放的是被“紧密压缩”的数据段,这个时候会对 static 的变量进行内存分配,也就是扩展内存段的空间,为该变量匹配对应类型的内存空间,但还未初始化数据,也就是 0 或者 null 的值。解析:我们知道类的数据结构类似一个数据库,里面多张不同类型的“表”紧凑的挨在一起,最大的节省类占用的空间。多数表都会应用到常量池表里面的字面量,这个时候就是把引用的字面量转化为直接的变量空间。比如某一个复杂类变量字面量在类文件里只占 2 个字节,但是通过常量池引用的转换为实际的变量类型,需要占用 32 个字节。所以经过解析阶段后,类在方法区占用的空间就会膨胀,长得更像一个”类“了。初始化


方法区经过解析后类已经为各个变量占好坑了,初始化就是把变量的初始值和构造方法的内容初始化到变量的空间里面。这时候我们介质的类二进制文件所定义的内容,已经完全被“翻译”方法区的某一段内存空间了。万事俱备只待使用了。


使用


使用呼应了我们加载类的触发条件,也即是触发类加载的条件也是类应用的条件,该操作会在初始化完成后进行。


卸载


我们知道 JVM 有垃圾回收机制(下文会详细介绍),不需要我们操心,总体上有三个条件会触发垃圾回收期清理方法区的空间:


类对应实例被回收类对应加载器被回收类无反射引用本节结束我们已经对整个类的生命周期烂熟于胸了,下面我们来介绍类加载机制最核心的几种应用场景,来加深对类加载技术的认识。


4.3 应用场景


通过前文的剖析我们已经非常清楚类加载器的工作原理,那么我们该如何利用类加载器的特点,最大限度的发挥它的作用呢?


4.3.1 热部署


背景


热部署这个词汇我们经常听说也经常提起,但是却很少能够准确的描述出它的定义。说到热部署我们第一时间想到的可能是生产上的机器更新代码后无需重启应用容器就能更新服务,这样的好处就是服务无需中断可持续运行,那么与之对应的冷部署当然就是要重启应用容器实例了。还有可能会想到的是使用 IDE 工具开发时不需要重启服务,修改代码后即时生效,这看起来可能都是服务无需重启,但背后的运行机制确截然不同,首先我们需要对热部署下一个准确的定义。


热部署(Hot Deployment):热部署是应用容器自动更新应用的一种能力。首先热部署应用容器拥有的一种能力,这种能力是容器本身设计出来的,跟具体的 IDE 开发工具无关。而且热部署无需重启服务器,应用可以保持用户态不受影响。上文提到我们开发环境使用 IDE 工具通常也可以设置无需重启的功能,有别于热部署的是此时我们应用的是 JVM 的本身附带的热替换能力(HotSwap)。热部署和热替换是两个完全不同概念,在开发过程中也常常相互配合使用,导致我们很多人经常混淆概念,所以接下来我们来剖析热部署的实现原理,而热替换的高级特性我们会在下文字节码增强的章节中介绍。


原理


从热部署的定义我们知道它是应用容器蕴含的一项能力,要达到的目的就是在服务没有重启的情况下更新应用,也就是把新的代码编译后产生的新类文件替换掉内存里的旧类文件。结合前文我们介绍的类加载器特性,这似乎也不是很难,分两步应该可以完成。由于同一个类加载器只能加载一次类文件,那么新增一个类加载器把新的类文件加载进内存。此时内存里面同时存在新旧的两个类(类名路径一样,但是类加载器不一样),要做的就是如何使用新的类,同时卸载旧的类及其对象,完成这两步其实也就是热部署的过程了。也即是通过使用新的类加载器,重新加载应用的类,从而达到新代码热部署。


实现


理解了热部署的工作原理,下面通过一系列极简的例子来一步步实现热部署,为了方便读者演示,以下例子我尽量都在一个 java 文件里面完成所有功能,运行的时候复制下去就可以跑起来。


实现自定义类加载器参考 4.2.2 中自定义类加载器区别系统默认加载器的案例,从该案例实践中我们可以将相同的类(包名+类名),不同”版本“(类加载器不一样)的类同时加载进 JVM 内存方法区。


替换自定义类加载器既然一个类通过不同类加载器可以被多次加载到 JVM 内存里面,那么类的经过修改编译后再加载进内存。有别于上一步给出的例子只是修改对象的值,这次我们是直接修改类的内容,从应用的视角看其实就是应用更新,那如何做到在线程运行不中断的情况下更换新类呢?


下面给出的也是一个很简单的例子,ClassReloading 启动 main 方法通过死循环不断创建类加载器,同时不断加载类而且执行类的方法。注意 new MyClassLoader(“target/classes”)的路径更加编译的 class 路径来修改,其他直接复制过去就可以执行演示了。


package com.zooncool.example.theory.jvm;import java.io.FileInputStream;import java.lang.reflect.InvocationTargetException;public class ClassReloading {public static void main(String[] args)throws NoSuchMethodException, ClassNotFoundException, IllegalAccessException, InstantiationException,InvocationTargetException, InterruptedException {for (;;){//用死循环让线程持续运行未中断状态//通过反射调用目标类的入口方法 String className = "com.zooncool.example.theory.jvm.ClassReloading$User";Class<?> target = new MyClassLoader("target/classes").loadClass(className);//加载进来的类,通过反射调用 execute 方法 target.getDeclaredMethod("execute").invoke(targetClass.newInstance());//HelloWorld.class.getDeclaredMethod("execute").invoke(HelloWorld.class.newInstance());//如果换成系统默认类加载器的话,因为双亲委派原则,默认使用应用类加载器,而且能加载一次//休眠是为了在删除旧类编译新类的这段时间内不执行加载动作//不然会找不到类文件 Thread.sleep(10000);}}//自定义类加载器加载的目标类 public static class User {public void execute() throws InterruptedException {//say();ask();}public void ask(){System.out.println("what is your name");}public void say(){System.out.println("my name is lucy");}}//下面是自定义类加载器,跟第一个例子一样,可略过 public static class MyClassLoader extends ClassLoader{...}}ClassReloading 线程执行过程不断轮流注释 say()和 ask()代码,然后编译类,观察程序输出。


如下输出结果,我们可以看出每一次循环调用都新创建一个自定义类加载器,然后通过反射创建对象调用方法,在修改代码编译后,新的类就会通过反射创建对象执行新的代码业务,而主线程则一直没有中断运行。读到这里,其实我们已经基本触达了热部署的本质了,也就是实现了手动无中断部署。但是缺点就是需要我们手动编译代码,而且内存不断新增类加载器和对象,如果速度过快而且频繁更新,还可能造成堆溢出,下一个例子我们将增加一些机制来保证旧的类和对象能被垃圾收集器自动回收。


what is your namewhat is your namewhat is your name//修改代码,编译新类 my name is lucymy name is lucywhat is your name//修改代码,编译新类回收自定义类加载器通常情况下类加载器会持有该加载器加载过的所有类的引用,所有如果类是经过系统默认类加载器加载的话,那就很难被垃圾收集器回收,除非符合根节点不可达原则才会被回收。


下面继续给出一个很简单的例子,我们知道 ClassReloading 只是不断创建新的类加载器来加载新类从而更新类的方法。下面的例子我们模拟 WEB 应用,更新整个应用的上下文 Context。下面代码本质上跟上个例子的功能是一样的,只不过我们通过加载 Model 层、DAO 层和 Service 层来模拟 web 应用,显得更加真实。


package com.zooncool.example.theory.jvm;import java.io.FileInputStream;import java.lang.reflect.InvocationTargetException;//应用上下文热加载 public class ContextReloading {public static void main(String[] args)throws NoSuchMethodException, ClassNotFoundException, IllegalAccessException, InstantiationException,InvocationTargetException, InterruptedException {for (;;){Object context = newContext();//创建应用上下文 invokeContext(context);//通过上下文对象 context 调用业务方法 Thread.sleep(5000);}}//创建应用的上下文,context 是整个应用的 GC roots,创建完返回对象之前调用 init()初始化对象 public static Object newContext()throws ClassNotFoundException, NoSuchMethodException, IllegalAccessException, InstantiationException,InvocationTargetException {String className = "com.zooncool.example.theory.jvm.ContextReloading$Context";//通过自定义类加载器加载 Context 类 Class<?> contextClass = new MyClassLoader("target/classes").loadClass(className);Object context = contextClass.newInstance();//通过反射创建对象 contextClass.getDeclaredMethod("init").invoke(context);//通过反射调用初始化方法 init()return context;}//业务方法,调用 context 的业务方法 showUser()public static void invokeContext(Object context)throws NoSuchMethodException, InvocationTargetException, IllegalAccessException {context.getClass().getDeclaredMethod("showUser").invoke(context);}public static class Context{private UserService userService = new UserService();public String showUser(){return userService.getUserMessage();}//初始化对象 public void init(){UserDao userDao = new UserDao();userDao.setUser(new User());userService.setUserDao(userDao);}}public static class UserService{private UserDao userDao;public String getUserMessage(){return userDao.getUserName();}public void setUserDao(UserDao userDao) {this.userDao = userDao;}}public static class UserDao{private User user;public String getUserName(){//关键操作,运行 main 方法后切换下面方法,编译后下一次调用生效 return user.getName();//return user.getFullName();}public void setUser(User user) {this.user = user;}}public static class User{private String name = "lucy";private String fullName = "hank.lucy";public String getName() {System.out.println("my name is " + name);return name;}public String getFullName() {System.out.println("my full name is " + fullName);return name;}}//跟之前的类加载器一模一样,可以略过 public static class MyClassLoader extends ClassLoader{...}}输出结果跟上一个例子相似,可以自己运行试试。我们更新业务方法编译通过后,无需重启 main 方法,新的业务就能生效,而且也解决了旧类卸载的核心问题,因为 context 的应用对象的跟节点,context 是由我们自定义类加载器所加载,由于 User/Dao/Service 都是依赖 context,所以其类也是又自定义类加载器所加载。根据 GC roots 原理,在创建新的自定义类加载器之后,旧的类加载器已经没有任何引用链可访达,符合 GC 回收规则,将会被 GC 收集器回收释放内存。至此已经完成应用热部署的流程,但是细心的朋友可能会发现,我们热部署的策略是整个上下文 context 都替换成新的,那么用户的状态也将无法保留。而实际情况是我们只需要动态更新某些模块的功能,而不是全局。这个其实也好办,就是我们从业务上把需要热部署的由自定义类加载器加载,而持久化的类资源则由系统默认类加载器去完成。


自动加载类加载器其实设计到代码设计优雅问题,基本上我们拿出设计模式 23 章经对号入座基本可以解决问题,毕竟这是前人经过千万实践锤炼出来的软件构建内功心法。那么针对我们热部署的场景,如果想把热部署细节封装出来,那代理模式无疑是最符合要求的,也就是咱们弄出个代理对象来面向用户,把类加载器的更替,回收,隔离等细节都放在代理对象里面完成,而对于用户来说是透明无感知的,那么终端用户体验起来就是纯粹的热部署了。至于如何实现自动热部署,方式也很简单,监听我们部署的目录,如果文件时间和大小发生变化,则判断应用需要更新,这时候就触发类加载器的创建和旧对象的回收,这个时候也可以引入观察者模式来实现。由于篇幅限制,本例子就留给读者朋友自行设计,相信也是不难完成的。


案例


上一节我们深入浅出的从自定义类加载器的开始引入,到实现多个类加载器加载同个类文件,最后完成旧类加载器和对象的回收,整个流程阐述了热部署的实现细节。那么这一节我们介绍现有实现热部署的通用解决方案,本质就是对上文原理的实现,加上性能和设计上的优化,注意本节我们应用的只是类加载器的技术,后面章节还会介绍的字节码层面的底层操作技术。


OSGIOSGI(Open Service Gateway Initiative)是一套开发和部署应用程序的 java 框架。我们从官网可以看到 OSGI 其实是一套规范,好比 Servlet 定义了服务端对于处理来自网络请求的一套规范,比如 init,service,destroy 的生命周期。然后我们通过实行这套规范来实现与客户端的交互,在调用 init 初始化完 Servlet 对象后通过多线程模式使用 service 响应网络请求。如果从响应模式比较我们还可以了解下 Webflux 的规范,以上两种都是处理网络请求的方式,当然你举例说 CGI 也是一种处理网络请求的规范,CGI 采用的是多进程方式来处理网络请求,我们暂时不对这两种规范进行优劣评价,只是说明在处理网络请求的场景下可以采用不同的规范来实现。


好了现在回到 OSGi,有了上面的铺垫,相信对我们理解 OSGI 大有帮助。我们说 OSGI 首先是一种规范,既然是规范我们就要看看都规范了啥,比如 Servlet 也是一种规范,它规范了生命周期,规定应用容器中 WEB-INF/classes 目录或 WEB-INF/lib 目录下的 jar 包才会被 Web 容器处理。同样 OSGI 的实现框架对管辖的 Bundle 下面的目录组织和文本格式也有严格规范,更重要的是 OSGI 对模块化架构生命周期的管理。而模块化也不只是把系统拆分成不同的 JAR 包形成模块而已,真正的模块化必须将模块中类的引入/导出、隐藏、依赖、版本管理贯穿到生命周期管理中去。


定义:OSGI 是脱胎于(OSGI Alliance)技术联盟由一组规范和对应子规范共同定义的 JAVA 动态模块化技术。实现该规范的 OSGI 框架(如 Apache Felix)使应用程序的模块能够在本地或者网络中实现端到端的通信,目前已经发布了第 7 版。OSGI 有很多优点诸如热部署,类隔离,高内聚,低耦合的优势,但同时也带来了性能损耗,而且基于 OSGI 目前的规范繁多复杂,开发门槛较高。


组成:执行环境,安全层,模块层,生命周期层,服务层,框架 API


核心服务:


事件服务(Event Admin Service),


包管理服务(Package Admin Service)


日志服务(Log Service)


配置管理服务(Configuration Admin Service)


HTTP 服务(HTTP Service)


用户管理服务(User Admin Service)


设备访问服务(Device Access Service)


IO 连接器服务(IO Connector Service)


声明式服务(Declarative Services)


其他 OSGi 标准服务


本节我们讨论的核心是热部署,所以我们不打算在这里讲解全部得 OSGI 技术,在上文实现热部署后我们重点来剖析 OSGI 关于热部署的机制。至于 OSGI 模块化技术和 java9 的模块化的对比和关联,后面有时间会开个专题专门介绍模块化技术。


为了让学习变得轻松、高效,今天给大家免费分享一套阿里架构师传授的一套教学资源。帮助大家在成为架构师的道路上披荆斩棘。


这套视频课程详细讲解了(Spring,MyBatis,Netty 源码分析,高并发、高性能、分布式、微服务架构的原理,JVM 性能优化、分布式架构)等这些成为架构师必备的内容!加扣群:926611793 即可免费领取


从类加载器技术应用的角度切入我们知道 OSGI 规范也是打破双亲委派机制,除了框架层面需要依赖 JVM 默认类加载器之外,其他 Bundle(OSGI 定义的模块单元)都是由各自的类加载器来加载,而 OSGI 框架就负责模块生命周期,模块交互这些核心功能,同时创建各个 Bundle 的类加载器,用于直接加载 Bundle 定义的 jar 包。由于打破双亲委派模式,Bundle 类加载器不再是双亲委派模型中的树状结构,而是进一步发展为更加复杂的网状结构(因为各个 Bundle 之间有相互依赖关系),当收到类加载请求时,OSGi 将按照下面的顺序进行类搜索:


1)将以 java.*开头的类委派给父类加载器加载。


2)否则,将委派列表名单内(比如 sun 或者 javax 这类核心类的包加入白名单)的类委派给父类加载器加载。


3)否则,将 Import 列表中的类委派给 Export 这个类的 Bundle 的类加载器加载。


4)否则,查找当前 Bundle 的 ClassPath,使用自己的类加载器加载。


5)否则,查找类是否在自己的 Fragment Bundle(OSGI 框架缓存包)中,如果在,则委派给 Fragment Bundle 的类加载器加载。


6)否则,查找 Dynamic Import 列表的 Bundle,委派给对应 Bundle 的类加载器加载。


7)否则,类查找失败。


这一系列的类加载操作,其实跟我们上节实现的自定义类加载技术本质上是一样的,只不过实现 OSGI 规范的框架需要提供模块之间的注册通信组件,还有模块的生命周期管理,版本管理。OSGI 也只是 JVM 上面运行的一个普通应用实例,只不过通过模块内聚,版本管理,服务依赖一系列的管理,实现了模块的即时更新,实现了热部署。


其他热部署解决方案多数也是利用类加载器的特点做文章,当然不止是类加载器,还会应用字节码技术,下面我们主要简单列举应用类加载器实现的热部署解决方案。


GroovyGroovy 兼顾动态脚本语言的功能,使用的时候无外乎也是通过 GroovyClassLoader 来加载脚本文件,转为 JVM 的类对象。那么每次更新 groovy 脚本就可以动态更新应用,也就达到了热部署的功能了。


Class groovyClass = classLoader.parseClass(new GroovyCodeSource(sourceFile));GroovyObject instance = (GroovyObject)groovyClass.newInstance();//proxyClojureJSPJSP 其实翻译为 Servlet 后也是由对应新的类加载器去加载,这跟我们上节讲的流程一模一样,所以这里就补展开讲解了。介绍完热部署技术,可能很多同学对热部署的需求已经没有那么强烈,毕竟热部署过程中带来的弊端也不容忽视,比如替换旧的类加载器过程会产生大量的内存碎片,导致 JVM 进行高负荷的 GC 工作,反复进行热部署还会导致 JVM 内存不足而导致内存溢出,有时候甚至还不如直接重启应用来得更快一点,而且随着分布式架构的演进和微服务的流行,应用重启也早就实现服务编排化,配合丰富的部署策略,也可以同样保证系统稳定持续服务,我们更多的是通过热部署技术来深刻认识到 JVM 加载类的技术演进。


4.3.2 类隔离


背景


先介绍一下类隔离的背景,我们费了那么大的劲设计出类加载器,如果只是用于加载外部类字节流那就过于浪费了。通常我们的应用依赖不同的第三方类库经常会出现不同版本的类库,如果只是使用系统内置的类加载器的话,那么一个类库只能加载唯一的一个版本,想加载其他版本的时候会从缓存里面发现已经存在而停止加载。但是我们的不同业务以来的往往是不同版本的类库,这时候就会出现 ClassNotFoundException。为什么只有运行的是才会出现这个异常呢,因为编译的时候我们通常会使用 MAVEN 等编译工具把冲突的版本排除掉。另外一种情况是 WEB 容器的内核依赖的第三方类库需要跟应用依赖的第三方类库隔离开来,避免一些安全隐患,不然如果共用的话,应用升级依赖版本就会导致 WEB 容器不稳定。


基于以上的介绍我们知道类隔离实在是刚需,那么接下来介绍一下如何实现这个刚需。


原理


首先我们要了解一下原理,其实原理很简单,真的很简单,请允许我总结为“唯一标识原理”。我们知道内存里面定位类实例的坐标<类加载器,类全限定名>。那么由这两个因子组合起来我们可以得出一种普遍的应用,用不同类加载器来加载类相同类(类全限定名一致,版本不一致)是可以实现的,也就是在 JVM 看来,有相同类全名的类是完全不同的两个实例,但是在业务视角我们却可以视为相同的类。


public static void main(String[] args) {Class<?> userClass1 = User.class;Class<?> userClass2 = new DynamicClassLoader("target/classes").load("qj.blog.classreloading.example1.StaticInt$User");out.println("Seems to be the same class:");out.println(userClass1.getName());out.println(userClass2.getName());out.println();out.println("But why there are 2 different class loaders:");out.println(userClass1.getClassLoader());out.println(userClass2.getClassLoader());out.println();User.age = 11;out.println("And different age values:");out.println((int) ReflectUtil.getStaticFieldValue("age", userClass1));out.println((int) ReflectUtil.getStaticFieldValue("age", userClass2));}public static class User {public static int age = 10;}实现


原理很简单,比如我们知道 Spring 容器本质就是一个生产和管理 bean 的集合对象,但是却包含了大量的优秀设计模式和复杂的框架实现。同理隔离容器虽然原理很简单,但是要实现一个高性能可扩展的高可用隔离容器,却不是那么简单。我们上文谈的场景是在内存运行的时候才发现问题,介绍内存隔离技术之前,我们先普及更为通用的冲突解决方法。


冲突排除冲突总是先发生在编译时期,那么基本 Maven 工具可以帮我们完成大部分的工作,Maven 的工作模式就是将我们第三方类库的所有依赖都依次检索,最终排除掉产生冲突的 jar 包版本。冲突适配当我们无法通过简单的排除来解决的时候,另外一个方法就是重新装配第三方类库,这里我们要介绍一个开源工具 jarjar ( https://github.com/shevek/jarjar )。该工具包可以通过字节码技术将我们依赖的第三方类库重命名,同时修改代码里面对第三方类库引用的路径。这样如果出现同名第三方类库的话,通过该“硬编码”的方式修改其中一个类库,从而消除了冲突。冲突隔离上面两种方式在小型系统比较适合,也比较敏捷高效。但是对于分布式大型系统的话,通过硬编码方式来解决冲突就难以完成了。办法就是通过隔离容器,从逻辑上区分类库的作用域,从而对内存的类进行隔离。5.内存管理


5.1 内存结构


5.1.1 逻辑分区


JVM 内存从应用逻辑上可分为如下区域。


程序计数器:字节码行号指示器,每个线程需要一个程序计数器虚拟机栈:方法执行时创建栈帧(存储局部变量,操作栈,动态链接,方法出口)编译时期就能确定占用空间大小,线程请求的栈深度超过 jvm 运行深度时抛 StackOverflowError,当 jvm 栈无法申请到空闲内存时抛 OutOfMemoryError,通过-Xss,-Xsx 来配置初始内存本地方法栈:执行本地方法,如操作系统 native 接口堆:存放对象的空间,通过-Xmx,-Xms 配置堆大小,当堆无法申请到内存时抛 OutOfMemoryError 方法区:存储类数据,常量,常量池,静态变量,通过 MaxPermSize 参数配置对象访问:初始化一个对象,其引用存放于栈帧,对象存放于堆内存,对象包含属性信息和该对象父类、接口等类型数据(该类型数据存储在方法区空间,对象拥有类型数据的地址)而实际上 JVM 内存分类实际上的物理分区还有更为详细,整体上分为堆内存和非堆内存,具体介绍如下。


5.1.2 内存模型


堆内存


堆内存是运行时的数据区,从中分配所有 java 类实例和数组的内存,可以理解为目标应用依赖的对象。堆在 JVM 启动时创建,并且在应用程序运行时可能会增大或减小。可以使用-Xms 选项指定堆的大小。堆可以是固定大小或可变大小,具体取决于垃圾收集策略。可以使用-Xmx 选项设置最大堆大小。默认情况下,最大堆大小设置为 64 MB。


JVM 堆内存在物理上分为两部分:新生代和老年代。新生代是为分配新对象而保留堆空间。当新生代占用完时,Minor GC 垃圾收集器会对新生代区域执行垃圾回收动作,其中在新生代中生活了足够长的所有对象被迁移到老年代,从而释放新生代空间以进行更多的对象分配。此垃圾收集称为 Minor GC。新生代分为三个子区域:伊甸园 Eden 区和两个幸存区 S0 和 S1。


关于新生代内存空间:


大多数新创建的对象都位于 Eden 区内存空间当 Eden 区填满对象时,执行 Minor GC 并将所有幸存对象移动到其中一个幸存区空间 Minor GC 还会检查幸存区对象并将其移动到其他幸存者空间,也即是幸存区总有一个是空的在多次 GC 后还存活的对象被移动到老年代内存空间。至于经过多少次 GC 晋升老年代则由参数配置,通常为 15 当老年区填满时,老年区同样会执行垃圾回收,老年区还包含那些经过多 Minor GC 后还存活的长寿对象。垃圾收集器在老年代内存中执行的回收称为 Major GC,通常需要更长的时间。


非堆内存


JVM 的堆以外内存称为非堆内存。也即是 JVM 自身预留的内存区域,包含 JVM 缓存空间,类结构如常量池、字段和方法数据,方法,构造方法。类非堆内存的默认最大大小为 64 MB。可以使用-XX:MaxPermSize VM 选项更改此选项,非堆内存通常包含如下性质的区域空间:


元空间(Metaspace)在 Java 8 以上版本已经没有 Perm Gen 这块区域了,这也意味着不会再由关于“java.lang.OutOfMemoryError:PermGen”内存问题存在了。与驻留在 Java 堆中的 Perm Gen 不同,Metaspace 不是堆的一部分。类元数据多数情况下都是从本地内存中分配的。默认情况下,元空间会自动增加其大小(直接又底层操作系统提供),而 Perm Gen 始终具有固定的上限。可以使用两个新标志来设置 Metaspace 的大小,它们是:“ - XX:MetaspaceSize ”和“ -XX:MaxMetaspaceSize ”。Metaspace 背后的含义是类的生命周期及其元数据与类加载器的生命周期相匹配。也就是说,只要类加载器处于活动状态,元数据就会在元数据空间中保持活动状态,并且无法释放。


代码缓存运行 Java 程序时,它以分层方式执行代码。在第一层,它使用客户端编译器(C1 编译器)来编译代码。分析数据用于服务器编译的第二层(C2 编译器),以优化的方式编译该代码。默认情况下,Java 7 中未启用分层编译,但在 Java 8 中启用了分层编译。实时(JIT)编译器将编译的代码存储在称为代码缓存的区域中。它是一个保存已编译代码的特殊堆。如果该区域的大小超过阈值,则该区域将被刷新,并且 GC 不会重新定位这些对象。Java 8 中已经解决了一些性能问题和编译器未重新启用的问题,并且在 Java 7 中避免这些问题的解决方案之一是将代码缓存的大小增加到一个永远不会达到的程度。


方法区方法区域是 Perm Gen 中空间的一部分,用于存储类结构(运行时常量和静态变量)以及方法和构造函数的代码。


内存池内存池由 JVM 内存管理器创建,用于创建不可变对象池。内存池可以属于 Heap 或 Perm Gen,具体取决于 JVM 内存管理器实现。


常量池常量包含类运行时常量和静态方法,常量池是方法区域的一部分。


Java 堆栈内存 Java 堆栈内存用于执行线程。它们包含特定于方法的特定值,以及对从该方法引用的堆中其他对象的引用。


Java 堆内存配置项 Java 提供了许多内存配置项,我们可以使用它们来设置内存大小及其比例,常用的如下:


VM Switch 描述- Xms 用于在 JVM 启动时设置初始堆大小-Xmx 用于设置最大堆大小-Xmn 设置新生区的大小,剩下的空间用于老年区-XX:PermGen 用于设置永久区存初始大小-XX:MaxPermGen 用于设置 Perm Gen 的最大尺寸-XX:SurvivorRatio 提供 Eden 区域的比例-XX:NewRatio 用于提供老年代/新生代大小的比例,默认值为 2


5.2 垃圾回收


5.2.1 垃圾回收策略


流程


垃圾收集是释放堆中的空间以分配新对象的过程。垃圾收集器是 JVM 管理的进程,它可以查看内存中的所有对象,并找出程序任何部分未引用的对象,删除并回收空间以分配给其他对象。通常会经过如下步骤:


标记:标记哪些对象被使用,哪些已经是无法触达的无用对象删除:删除无用对象并回收要分配给其他对象压缩:性能考虑,在删除无用的对象后,会将所有幸存对象集中移动到一起,腾出整段空间策略


虚拟机栈、本地栈和程序计数器在编译完毕后已经可以确定所需内存空间,程序执行完毕后也会自动释放所有内存空间,所以不需要进行动态回收优化。JVM 内存调优主要针对堆和方法区两大区域的内存。通常对象分为 Strong、sfot、weak 和 phantom 四种类型,强引用不会被回收,软引用在内存达到溢出边界时回收,弱引用在每次回收周期时回收,虚引用专门被标记为回收对象,具体回收策略如下:


对象优先在 Eden 区分配:新生对象回收策略 Minor GC(频繁)老年代对象回收策略 Full GC/Major GC(慢)大对象直接进入老年代:超过 3m 的对象直接进入老年区 -XX:PretenureSizeThreshold=3145728(3M)长期存货对象进入老年区:Survivor 区中的对象经历一次 Minor GC 年龄增加一岁,超过 15 岁进入老年区-XX:MaxTenuringThreshold=15 动态对象年龄判定:设置 Survivor 区对象占用一半空间以上的对象进入老年区算法


垃圾收集有如下常用的算法:


标记-清除复制标记-整理分代收集(新生用复制,老年用标记-整理)5.2.2 垃圾回收器


分类


serial 收集器:单线程,主要用于 client 模式 ParNew 收集器:多线程版的 serial,主要用于 server 模式 Parallel Scavenge 收集器:线程可控吞吐量(用户代码时间/用户代码时间+垃圾收集时间),自动调节吞吐量,用户新生代内存区 Serial Old 收集器:老年版本 serialParallel Old 收集器:老年版本 Parallel ScavengeCMS(Concurrent Mark Sweep)收集器:停顿时间短,并发收集 G1 收集器:分块标记整理,不产生碎片配置


串行 GC(-XX:+ UseSerialGC):串行 GC 使用简单的标记-扫描-整理方法,用于新生代和老年代的垃圾收集,即 Minor 和 Major GC 并行 GC(-XX:+ UseParallelGC):并行 GC 与串行 GC 相同,不同之处在于它为新生代垃圾收集生成 N 个线程,其中 N 是系统中的 CPU 核心数。我们可以使用-XX:ParallelGCThreads = n JVM 选项来控制线程数并行旧 GC(-XX:+ UseParallelOldGC):这与 Parallel GC 相同,只是它为新生代和老年代垃圾收集使用多个线程并发标记扫描(CMS)收集器(-XX:+ UseConcMarkSweepGC):CMS 也称为并发低暂停收集器。它为老年代做垃圾收集。CMS 收集器尝试通过在应用程序线程内同时执行大多数垃圾收集工作来最小化由于垃圾收集而导致的暂停。年轻一代的 CMS 收集器使用与并行收集器相同的算法。我们可以使用-XX 限制 CMS 收集器中的线程数 :ParallelCMSThreads = nG1 垃圾收集器(-XX:+ UseG1GC):G1 从长远看要是替换 CMS 收集器。G1 收集器是并行,并发和递增紧凑的低暂停垃圾收集器。G1 收集器不像其他收集器那样工作,并且没有年轻和老一代空间的概念。它将堆空间划分为多个大小相等的堆区域。当调用垃圾收集器时,它首先收集具有较少实时数据的区域,因此称为“Garbage First”也即是 G16.执行引擎


6.1 执行流程


类加载器加载的类文件字节码数据流由基于 JVM 指令集架构的执行引擎来执行。执行引擎以指令为单位读取 Java 字节码。我们知道汇编执行的流程是 CPU 执行每一行的汇编指令,同样 JVM 执行引擎就像 CPU 一个接一个地执行机器命令。字节码的每个命令都包含一个 1 字节的 OpCode 和附加的操作数。执行引擎获取一个 OpCode 并使用操作数执行任务,然后执行下一个 OpCode。但 Java 是用人们可以理解的语言编写的,而不是用机器直接执行的语言编写的。因此执行引擎必须将字节码更改为 JVM 中的机器可以执行的语言。字节码可以通过以下两种方式之一转化为合适的语言。


解释器:逐个读取,解释和执行字节码指令。当它逐个解释和执行指令时,它可以快速解释一个字节码,但是同时也只能相对缓慢的地执行解释结果,这是解释语言的缺点。JIT(实时)编译器:引入了 JIT 编译器来弥补解释器的缺点。执行引擎首先作为解释器运行,并在适当的时候,JIT 编译器编译整个字节码以将其更改为本机代码。之后,执行引擎不再解释该方法,而是直接使用本机代码执行。本地代码中的执行比逐个解释指令要快得多。由于本机代码存储在高速缓存中,因此可以快速执行编译的代码。但是,JIT 编译器编译代码需要花费更多的时间,而不是解释器逐个解释代码。因此,如果代码只执行一次,最好是选择解释而不是编译。因此,使用 JIT 编译器的 JVM 在内部检查方法执行的频率,并仅在频率高于某个级别时编译方法。


JVM 规范中未定义执行引擎的运行方式。因此,JVM 厂商使用各种技术改进其执行引擎,并引入各种类型的 JIT 编译器。 大多数 JIT 编译器运行如下图所示:


JIT 编译器将字节码转换为中间级表达式 IR,以执行优化,然后将表达式转换为本机代码。Oracle Hotspot VM 使用名为 Hotspot Compiler 的 JIT 编译器。它被称为 Hotspot,因为 Hotspot Compiler 通过分析搜索需要以最高优先级进行编译的“Hotspot”,然后将热点编译为本机代码。如果不再频繁调用编译了字节码的方法,换句话说,如果该方法不再是热点,则 Hotspot VM 将从缓存中删除本机代码并以解释器模式运行。Hotspot VM 分为服务器 VM 和客户端 VM,两个 VM 使用不同的 JIT 编译器。


大多数 Java 性能改进都是通过改进执行引擎来实现的。除了 JIT 编译器之外,还引入了各种优化技术,因此可以不断改进 JVM 性能。初始 JVM 和最新 JVM 之间的最大区别是执行引擎。


下面我们通过下图可以看出 JAVA 执行的流程。


6.2 栈帧结构


每个方法调用开始到执行完成的过程,对应这一个栈帧在虚拟机栈里面从入栈到出栈的过程。


栈帧包含:局部变量表,操作数栈,动态连接,方法返回方法调用:方法调用不等于方法执行,而且确定调用方法的版本。方法调用字节码指令:invokestatic,invokespecial,invokevirtual,invokeinterface 静态分派:静态类型,实际类型,编译器重载时通过参数的静态类型来确定方法的版本。(选方法)动态分派:invokevirtual 指令把类方法符号引用解析到不同直接引用上,来确定栈顶的实际对象(选对象)单分派:静态多分派,相同指令有多个方法版本。多分派:动态单分派,方法接受者只能确定唯一一个。下图是 JVM 实例执行方法是的内存布局。


6.3 早期编译


javac 编译器:解析与符号表填充,注解处理,生成字节码 java 语法糖:语法糖有助于代码开发,但是编译后就会解开糖衣,还原到基础语法的 class 二进制文件重载要求方法具备不同的特征签名(不包括返回值),但是 class 文件中,只要描述不是完全一致的方法就可以共存。6.4 晚期编译


HotSpot 虚拟机内的即时编译


解析模式 -Xint


编译模式 -Xcomp


混合模式 Mixed mode


分层编译:解释执行 -> C1(Client Compiler)编译 -> C2 编译(Server Compiler)


触发条件:基于采样的热点探测,基于计数器的热点探测


7.性能调优


7.1 调优原则


我们知道调优的前提是,程序没有达到我们的预期要求,那么第一步要做的是衡量我们的预期。程序不可能十全十美,我们要做的是通过各种指标来衡量系统的性能,最终整体达到我们的要求。


7.1.1 环境


首先我们要了解系统的运行环境,包括操作系统层面的差异,JVM 版本,位数,乃至于硬件的时钟周期,总线设计甚至机房温度,都可能是我们需要考虑的前置条件。


7.1.2 度量


首先我们要先给出系统的预期指标,在特定的硬件/软件的配置,然后给出目标指标,比如系统整体输出接口的 QPS,RT,或者更进一层,IO 读写,cpu 的 load 指标,内存的使用率,GC 情况都是我们需要预先考察的对象。


7.1.3 监测


确定了环境前置条件,分析了度量指标,第三步是通过工具来监测指标,下一节提供了常用 JVM 调优工具,可以通过不同工具的组合来发现定位问题,结合 JVM 的工作机制已经操作系统层面的调度流程,按图索骥来发现问题,找出问题后才能进行优化。


7.1.4 原则


总体的调优原则如下图


图片来源《Java Performance》


7.2 调优参数


上节给出了 JVM 性能调优的原则,我们理清思路后应用不同的 JVM 工具来发现系统存在的问题,下面列举的是常用的 JVM 参数,通过这些参数指标可以更快的帮助我们定位出问题所在。


7.2.1 内存查询


最常见的与性能相关的做法之一是根据应用程序要求初始化堆内存。这就是我们应该指定最小和最大堆大小的原因。以下参数可用于实现它:


-Xms<heap size>[unit] -Xmx<heap size>[unit]unit 表示要初始化内存(由 堆大小 表示)的单元。单位可以标记为 GB 的 “g” ,MB 的 “m” 和 KB 的 “k” 。例如 JVM 分配最小 2 GB 和最大 5 GB:


-Xms2G -Xmx5G 从 Java 8 开始 Metaspace 的大小未被定义,一旦达到限制 JVM 会自动增加它,为了避免不必要的不稳定性,我们可以设置 Metaspace 大小:


-XX:MaxMetaspaceSize=<metaspace size>[unit]默认情况下 YG 的最小大小为 1310 MB ,最大大小不受限制,我们可以明确地指定它们:


-XX:NewSize=<young size>[unit]-XX:MaxNewSize=<young size>[unit]7.2.2 垃圾回收


JVM 有四种类型的 GC 实现:


串行垃圾收集器并行垃圾收集器 CMS 垃圾收集器 G1 垃圾收集器可以使用以下参数声明这些实现:


-XX:+UseSerialGC-XX:+UseParallelGC-XX:+USeParNewGC-XX:+UseG1GC7.2.3GC 记录


要严格监视应用程序运行状况,我们应始终检查 JVM 的垃圾收集性能,使用以下参数,我们可以记录 GC 活动:


-XX:+UseGCLogFileRotation-XX:NumberOfGCLogFiles=< number of log files >-XX:GCLogFileSize=< file size >[ unit ]-Xloggc:/path/to/gc.logUseGCLogFileRotation 指定日志文件滚动的政策,就像 log4j 的,s4lj 等 NumberOfGCLogFiles 表示单个应用程序记录生命周期日志文件的最大数量。 GCLogFileSize 指定文件的最大大小。 loggc 表示其位置。这里要注意的是,还有两个可用的 JVM 参数( -XX:+ PrintGCTimeStamps 和 -XX:+ PrintGCDateStamps ),可用于在 GC 日志中打印日期时间戳。


7.2.4 内存溢出


大型应用程序面临内存不足的错误是很常见的,这是一个非常关键的场景,很难复制以解决问题。


这就是 JVM 带有一些参数的原因,这些参数将堆内存转储到一个物理文件中,以后可以用它来查找泄漏:


-XX:+HeapDumpOnOutOfMemoryError-XX:HeapDumpPath=./java_pid<pid>.hprof-XX:OnOutOfMemoryError="< cmd args >;< cmd args >"-XX:+UseGCOverheadLimit 这里有几点需要注意:


在 OutOfMemoryError 的情况下, HeapDumpOnOutOfMemoryError 指示 JVM 将堆转储到物理文件中 HeapDumpPath 表示要写入文件的路径; 任何文件名都可以给出; 但是如果 JVM 在名称中找到 标记,则导致内存不足错误的进程 ID 将以 .hprof 格式附加到文件名 OnOutOfMemoryError 用于发出紧急命令,以便在出现内存不足错误时执行; 应该在 cmd args 的空间中使用正确的命令。例如,如果我们想在内存不足时重新启动服务器,我们可以设置参数:-XX:OnOutOfMemoryError="shutdown -r"UseGCOverheadLimit 是一种策略,用于限制在抛出 OutOfMemory 错误之前在 GC 中花费的 VM 时间的比例 7.2.5 其他配置


-server :启用“Server Hotspot VM”; 默认情况下,此参数在 64 位 JVM 中使用-XX:+ UseStringDeduplication : Java 8 引入了这个 JVM 参数,通过创建相同 String 的 太多实例来减少不必要的内存使用 ; 这通过将重复的 String 值减少到单个全局 char []数组来优化堆内存-XX:+ UseLWPSynchronization :设置基于 LWP ( 轻量级进程 )的同步策略而不是基于线程的同步-XX:LargePageSizeInBytes:设置用于 Java 堆的大页面大小; 它采用 GB / MB / KB 的参数; 通过更大的页面大小,我们可以更好地利用虚拟内存硬件资源; 但是这可能会导致 PermGen 的空间大小增加,从而可以强制减小 Java 堆空间的大小-XX:MaxHeapFreeRatio :设置 GC 后堆的最大自由百分比,以避免收缩-XX:MinHeapFreeRatio :设置 GC 后堆的最小自由百分比以避免扩展,监视堆使用情况-XX:SurvivorRatio :Eden 区 /幸存者空间大小的比例-XX:+ UseLargePages :如果系统支持,则使用大页面内存; 如果使用此 JVM 参数,OpenJDK 7 往往会崩溃-XX:+ UseStringCache:启用 字符串 池中可用的常用分配字符串的缓存-XX:+ UseCompressedStrings :对 String 对象使用 byte [] 类型,可以用纯 ASCII 格式表示-XX:+ OptimizeStringConcat:它尽可能优化 字符串 连接操作 7.3 调优工具


7.3.1 命令行工具


虚拟机进程状况工具:jps -lvm 诊断命令工具:jcmd 用来发送诊断命令请求到 JVM,这些请求是控制 Java 的运行记录,它必须在运行 JVM 的同一台机器上使用,并且具有用于启动 JVM 的相同有效用户和分组,可以使用以下命令创建堆转储(hprof 转储):jcmd GC.heap_dump filename =虚拟机统计信息监视工具:jstat 提供有关运行的应用程序的性能和资源消耗的信息。在诊断性能问题时,可以使用该工具,特别是与堆大小调整和垃圾回收相关的问题。jstat 不需要虚拟机启动任何特殊配置。jstat -gc pid interval countjava 配置信息工具:jinfojinfo -flag pidjava 内存映像工具:jmap 用于生成堆转储文件 jmap -dump:format=b,file=java.bin pid 虚拟机堆转储快照分析工具:jhatjhat file 分析堆转储文件,通过浏览器访问分析文件 java 堆栈跟踪工具:jstack 用于生成虚拟机当前时刻的线程快照 threaddump 或者 Javacorejstack [ option ] vmid 堆和 CPU 分析工具:HPROFHPROF 是每个 JDK 版本附带的堆和 CPU 分析工具。它是一个动态链接库(DLL),它使用 Java 虚拟机工具接口(JVMTI)与 JVM 连接。该工具将分析信息以 ASCII 或二进制格式写入文件或套接字。HPROF 工具能够显示 CPU 使用情况,堆分配统计信息和监视争用配置文件。此外,它还可以报告 JVM 中所有监视器和线程的完整堆转储和状态。在诊断问题方面,HPROF 在分析性能,锁争用,内存泄漏和其他问题时非常有用。java -agentlib:hprof = heap = sites target.class7.3.2 可视化工具


jconsolejvisualvm8.字节增强


我们从类加载的应用介绍了热部署和类隔离两大应用场景,但是基于类加载器的技术始终只是独立于 JVM 内核功能而存在的,也就是所有实现都只是基于最基础的类加载机制,并无应用其他 JVM 高级特性,本章节我们开始从字节增强的层面介绍 JVM 的一些高级特性。


说到字节增强我们最先想到的是字节码,也就是本文最开头所要研究的 class 文件,任何合法的源码编译成 class 后被类加载器加载进 JVM 的方法区,也就是以字节码的形态存活在 JVM 的内存空间。这也就是我们为什么现有讲明白类的结构和加载过程,而字节码增强技术不只是在内存里面对 class 的字节码进行操纵,更为复杂的是 class 联动的上下游对象生命周期的管理。


首先我们回忆一下我们开发过程中最为熟悉的一个场景就是本地 debug 调试代码。可能很多同学都已经习惯在 IDE 上对某句代码打上断点,然后逐步往下追踪代码执行的步骤。我们进一步想想,这个是怎么实现的,是一股什么样的力量能把已经跑起来的线程踩下刹车,一步一步往前挪?我们知道线程运行其实就是在 JVM 的栈空间上不断的把代码对应的 JVM 指令集不断的送到 CPU 执行。那能阻止这个流程的力量也肯定是发生在 JVM 范围内,所以我们可以很轻松的预测到这肯定是 JVM 提供的机制,而不是 IDE 真的有这样的能力,只不过是 JVM 把这种能力封装成接口暴露出去,然后提供给 IDE 调用,而 IDE 只不过是通过界面交互来调用这些接口而已。那么下面我们就来介绍 JVM 这种重要的能力。


8.1JPDA


上面所讲的 JVM 提供的程序运行断点能力,其实 JVM 提供的一个工具箱 JVMTI(JVM TOOL Interface)提供的接口,而这个工具箱是一套叫做 JPDA 的架构定义的,本节我们就来聊聊 JPDA。


JPDA(Java Platform Debugger Architecture)Java 平台调试架构,既不是一个应用程序,也不是调试工具,而是定义了一系列设计良好的接口和协议用于调试 java 代码,我们将会从三个层面来讲解 JPDA。


8.1.1 概念


JVMTIJVMTI(Java Virtual Machine Tool Interface)Java 虚拟机调试接口,处于最底层,是我们上文所提到的 JVM 开放的能力,JPDA 规定了 JDK 必须提供一个叫做 JVMTI(Java6 之前是由 JVMPI 和 JVMDI 组成,Java6 开始废弃掉统一为 JVMTI)的工具箱,也就是定义了一系列接口能力,比如获取栈帧、设置断点、断点响应等接口,具体开放的能力参考 JVMDI 官方 API 文档。JDWPJDWP(Java Debug Wire Protocol)Java 调试连线协议,存在在中间层,定义信息格式,定义调试者和被调试程序之间请求的协议转换,位于 JDI 下一层,JDI 更为抽象,JDWP 则关注实现。也就是说 JVM 定义好提供的能力,但是如何调用 JVM 提供的接口也是需要规范的,就比如我们 Servlet 容器也接收正确合法的 HTTP 请求就可以成功调用接口。JPDA 同样也规范了调用 JVMTI 接口需要传入数据的规范,也就是请求包的格式,类别 HTTP 的数据包格式。但是 JPDA 并不关心请求来源,也就是说只要调用 JVMTI 的请求方式和数据格式对了就可以,不论是来做远程调用还是本地调用。JDWP 制定了调试者和被调试应用的字节流动机制,但没有限定具体实现,可以是远程的 socket 连接,或者本机的共享内存,当然还有自定义实现的通信协议。既然只是规范了调用协议,并不局限请求来源,而且也没限制语言限制,所以非 java 语言只要发起调用符合规范就可以,这个大大丰富了异构应用场景,具体的协议细节可以参考 JDWP 官方规范文档。JDIJDI(Java Debug Interface)Java 调试接口处在最上层,基于 Java 开发的调试接口,也就是我们调试客户端,客户端代码封装在 jdk 下面 tools.jar 的 com.sun.jdi 包里面,java 程序可以直接调用的接口集合,具体提供的功能可以参考 JDI 官方 API 文档。


8.1.2 原理


介绍完 JPDA 的架构体系后,我们了解到 JAVA 调试平台各个层级的作用,这一节我们更近一步讲解 JPDA 各个层面的工作原理,以及三个层级结合起来时如何交互的。


JVMTI


我们 JVMTI 是 JVM 提供的一套本地接口,包含了非常丰富的功能,我们调试和优化代码需要操作 JVM,多数情况下就是调用到 JVMTI,从官网我们可以看到,JVMTI 包含了对 JVM 线程/内存/堆/栈/类/方法/变量/事件/定时器处理等的 20 多项功能。但其实我们通常不是直接调用 JVMTI,而是创建一个代理客户端,我们可以自由的定义对 JVMTI 的操作然后打包到代理客户端里面如 libagent.so。当目标程序执行时会启动 JVM,这个时候在目标程序运行前会加载代理客户端,所以代理客户端是跟目标程序运行在同一个进程上。这样一来外部请求就通过代理客户端间接调用到 JVMTI,这样的好处是我们可以在客户端 Agent 里面定制高级功能,而且代理客户端编译打包成一个动态链接库之后可以复用,提高效率。我们简单描述一下代理客户端 Agent 的工作流程。


建立代理客户端首先需要定义 Agent 的入口函数,犹如 Java 类的 main 方法一样:


JNIEXPORT jint JNICALL Agent_OnLoad(JavaVM *vm, char *options, void *reserved);然后 JVM 在启动的时候就会把 JVMTI 的指针 JavaVM 传给代理的入口函数,options 则是传参,有了这个指针后代理就可以充分调用 JVMTI 的函数了。


//设置断点,参数是调试目标方法和行数位置 jvmtiError SetBreakpoint(jvmtiEnv* env,jmethodID method,jlocation location);//当目标程序执行到指定断点,目标线程则被挂起 jvmtiError SuspendThread(jvmtiEnv* env,jthread thread);当然除了 JVM 启动时可以加载代理,运行过程中也是可以的,这个下文我们讲字节码增强还会再说到。


JNIEXPORT jint JNICALL Agent_OnAttach(JavaVM* vm, char *options, void *reserved);有兴趣的同学可以自己动手写一个 Agent 试试,通过调用 JVMTI 接口可以实现自己定制化的调试工具。


JDWP


上文我们知道调用 JVMTI 需要建立一个代理客户端,但是假如我建立了包含通用功能的 Agent 想开发出去给所有调试器使用,有一种方式是资深开发者通过阅读我的文档后进行开发调用,还有另外一种方式就是我在我的 Agent 里面加入了 JDWP 协议模块,这样调试器就可以不用关心我的接口细节,只需按照阅读的协议发起请求即可。JDWP 是调试器和 JVM 中间的协议规范,类似 HTTP 协议一样,JDWP 也定义规范了握手协议和报文格式。


调试器发起请求的握手流程:


1)调试器发送一段包含“JDWP-Handshake”的 14 个 bytes 的字符串


2)JVM 回复同样的内容“JDWP-Handshake”


完成握手流程后就可以像 HTTP 一样向 JVM 的代理客户端发送请求数据,同时回复所需参数。请求和回复的数据帧也有严格的结构,请求的数据格式为 Command Packet,回复的格式为 Reply Packet,包含包头和数据两部分,具体格式参考官网。实际上 JDWP 却是也是通过建立代理客户端来实现报文格式的规范,也就是 JDWP Agent 里面的 JDWPTI 实现了 JDWP 对协议的定义。JDWP 的功能是由 JDWP 传输接口(Java Debug Wire Protocol Transport Interface)实现的,具体流程其实跟 JVMTI 差不多,也是讲 JDWPTI 编译打包成代理库后,在 JVM 启动的时候加载到目标进程。那么调试器调用的过程就是 JDWP Agent 接收到请求后,调用 JVMTI Agent,JDWP 负责定义好报文数据,而 JDWPTI 则是具体的执行命令和响应事件。


JDI


前面已经解释了 JVMTI 和 JDWP 的工作原理和交互机制,剩下的就是搞清楚面向用户的 JDI 是如何运行的。首先 JDI 位于 JPDA 的最顶层入口,它的实现是通过 JAVA 语言编写的,所以可以理解为 Java 调试客户端对 JDI 接口的封装调用,比如我们熟悉的 IDE 界面启动调试,或者 JAVA 的命令行调试客户端 JDB。


通常我们设置好目标程序的断点之后启动程序,然后通过调试器启动程序之前,调试器会先获取 JVM 管理器,然后通过 JVM 管理器对象 virtualMachineManager 获取连接器 Connector,调试器与虚拟机获得链接后就可以启动目标程序了。如下代码:


VirtualMachineManager virtualMachineManager = Bootstrap.virtualMachineManager();JDI 完成调试需要实现的功能有三个模块:数据、链接、事件


数据调试器要调试的程序在目标 JVM 上,那么调试之前肯定需要将目标程序的执行环境同步过来,不然我们压根就不知道要调试什么,所以需要一种镜像机制,把目标程序的堆栈方法区包含的数据以及接收到的事件请求都映射到调试器上面。那么 JDI 的底层接口 Mirror 就是干这样的事,具体数据结构可以查询文档。链接我们知道调试器跟目标 JVM 直接的通讯是双向的,所以链接双方都可以发起。一个调试器可以链接多个目标 JVM,但是一个目标虚拟机只能提供给一个调试器,不然就乱套了不知道听谁指令了。JDI 定义三种链接器:启动链接器(LaunchingConnector)、依附链接器(AttachingConnector)、监听链接器(ListeningConnector)和。分别对应的场景是目标程序 JVM 启动时发起链接、调试器中途请求接入目标程序 JVM 和调试器监听到被调试程序返回请求时发起的链接。事件也就是调试过程中对目标 JVM 返回请求的响应。讲解完 JPDA 体系的实现原理,我们再次梳理一下调试的整个流程:


调试器 —> JDI 客户端 —> JDWP Agent—> JVMTI Agent —>> JVMTI —> Application


8.1.3 实现


现在我们已经对整个 JPDA 结构有了深入理解,接下来我们就通过对这些朴素的原理来实现程序的断点调试。当然我们不会在这里介绍从 IDE 的 UI 断点调试的过程,因为对这套是使用已经非常熟悉了,我们知道 IDE 的 UI 断点调试本质上是调试器客户端对 JDI 的调用,那我们就通过一个调试的案例来解释一下这背后的原理。


搭建服务


首先我们需要先搭建一个可供调试的 web 服务,这里我首选 springboot+来搭建,通过官网生成样例 project 或者 maven 插件都可以,具体的太基础的就不在这里演示,该服务只提供一个 Controller 包含的一个简单方法。如果使用 Tomcat 部署,则可以通过自有的开关 catalina jpda start 来启动 debug 模式。


package com.zooncool.debug.rest;import org.springframework.web.bind.annotation.GetMapping;import org.springframework.web.bind.annotation.RequestParam;import org.springframework.web.bind.annotation.RestController;@RestController("/debug")public class DebugController {@GetMappingpublic String ask(@RequestParam("name") String name) {String message = "are you ok?" + name;return message;}}启动服务


搭建好服务之后我们先启动服务,我们通过 maven 来启动服务,其中涉及到的一些参数下面解释。


mvn spring-boot:run -Drun.jvmArguments="-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=n,address=8001"或者 mvn spring-boot:run -Drun.jvmArguments="-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=8001"mvn:maven 的脚本命令这个不用解释 Spring-boot:run:启动 springboot 工程-Drun.jvmArguments:执行 jvm 环境的参数,里面的参数值才是关键-XdebugXdebug 开启调试模式,为非标准参数,也就是可能在其他 JVM 上面是不可用的,Java5 之后提供了标准的执行参数 agentlib,下面两种参数同样可以开启 debug 模式,但是在 JIT 方面有所差异,这里先不展开。java -agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=8001java -Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8001Xrunjdwp/jdwp=transport:表示连接模式是本地内存共享还是远程 socket 连接 server:y 表示打开 socket 监听调试器的请求;n 表示被调试程序像客户端一样主动连接调试器 suspend:y 表示被调试程序需要等到调试器的连接请求之后才能启动运行,在此之前都是挂起的,n 表示被调试程序无需等待直接运行。address:被调试程序启动 debug 模式后监听请求的地址和端口,地址缺省为本地。执行完上述命令后,就等着我们调试器的请求接入到目标程序了。


调试接入


我们知道 java 的调试器客户端为 jdb,下面我们就使用 jdb 来接入我们的目标程序。


#jdb 通过 attach 参数选择本地目标程序,同时附上目标程序的源码,回想之前我们讲到的 JDI 的镜像接口,就是把目标程序的堆栈结构同步过来,如果能我们提供的源码对应上,那就可以在源码上面显示断点标志 $ jdb -attach localhost:8001 -sourcepath /Users/linzhenhua/Documents/repositories/practice/stackify-master/remote-debugging/src/main/java/设置未捕获的 java.lang.Throwable 设置延迟的未捕获的 java.lang.Throwable 正在初始化 jdb...#stop,选择对应方法设置断点


stop in com.zooncool.debug.rest.DebugController.ask(java.lang.String)设置断点 com.zooncool.debug.rest.DebugController.ask(java.lang.String)#如果我们设置不存在的方法为断点,则会有错误提示 stop in com.zooncool.debug.rest.DebugController.ask2(java.lang.String)无法设置断点 com.zooncool.debug.rest.DebugController.ask2(java.lang.String): com.zooncool.debug.rest.DebugController 中没有方法 ask2#这时候我们已经设置完断点,就可以发起个 HTTP 请求 #http://localhost:7001/remote-debugging/debug/ask?name=Jack#发起请求后我们回到 jdb 控制台,观察是否命中断点断点命中: "线程=http-nio-7001-exec-5", com.zooncool.debug.rest.DebugController.ask(), 行=14 bci=014 String message = "are you ok?" + name;#list,对照源码,确实是进入 ask 方法第一行命中断点,也就是 14 行,这时候我们可以查看源码 http-nio-7001-exec-5[1] list10 @RestController("/debug")11 public class DebugController {12 @GetMapping13 public String ask(@RequestParam("name") String name) {14 => String message = "are you ok?" + name;15 return message;16 }17 }#locals,观察完源码,我们想获取 name 的传参,跟 URL 传入的一致 http-nio-7001-exec-5[1] locals 方法参数:name = "Jack"本地变量:#print name,打印入参 http-nio-7001-exec-5[1] print namename = "Jack"#where,查询方法调用的栈帧,从 web 容器入口调用方法到目标方法的调用链路 http-nio-7001-exec-5[1] where[1] com.zooncool.debug.rest.DebugController.ask (DebugController.java:14)...[55] java.lang.Thread.run (Thread.java:748)#step,下一步到下一行代码 http-nio-7001-exec-5[1] step 已完成的步骤: "线程=http-nio-7001-exec-5", com.zooncool.debug.rest.DebugController.ask(), 行=15 bci=2015 return message;#step up,完成当前方法的调用 http-nio-7001-exec-5[1] step up 已完成的步骤: "线程=http-nio-7001-exec-5", sun.reflect.NativeMethodAccessorImpl.invoke(), 行=62 bci=103#cont,结束调试,执行完毕 http-nio-7001-exec-5[1] cont


#clear,完成调试任务,清除断点


clear 断点集:断点 com.zooncool.debug.rest.DebugController.ask(java.lang.String)断点 com.zooncool.debug.rest.DebugController.ask2(java.lang.String)#选择一个断点删除 clear com.zooncool.debug.rest.DebugController.ask(java.lang.String)已删除: 断点 com.zooncool.debug.rest.DebugController.ask(java.lang.String)我们已经完成了命令行调试的全部流程,stop/list/locals/print name/where/step/step up/cont/clear 这些命令其实就是 IDE 的 UI 后台调用的脚本。而这些脚本就是基于 JDI 层面的接口所提供的能力,下面我们还有重点观察一个核心功能,先从头再设置一下断点。


#stop,选择对应方法设置断点


stop in com.zooncool.debug.rest.DebugController.ask(java.lang.String)设置断点 com.zooncool.debug.rest.DebugController.ask(java.lang.String)#这时候我们已经设置完断点,就可以发起个 HTTP 请求 #http://localhost:7001/remote-debugging/debug/ask?name=Jack#发起请求后我们回到 jdb 控制台,观察是否命中断点断点命中: "线程=http-nio-7001-exec-5", com.zooncool.debug.rest.DebugController.ask(), 行=14 bci=014 String message = "are you ok?" + name;#print name,打印入参 http-nio-7001-exec-5[1] print namename = "Jack"#如果这个时候我们想替换掉 Jack,换成 Lucyhttp-nio-7001-exec-6[1] set name = "Lucy"name = "Lucy" = "Lucy"#进入下一步 http-nio-7001-exec-6[1] step 已完成的步骤: "线程=http-nio-7001-exec-6", com.zooncool.debug.rest.DebugController.ask(), 行=15 bci=2015 return message;#查看变量,我们发现 name 的值已经被修改了 http-nio-7001-exec-6[1] locals 方法参数:name = "Lucy"本地变量:message = "are you ok?Lucy"至此我们已经完成了 JPDA 的原理解析到调试实践,也理解了 JAVA 调试的工作机制,其中留下一个重要的彩蛋就是通过 JPDA 进入调试模式,我们可以动态的修改 JVM 内存对象和类的内容,这也讲引出下文我们要介绍的字节码增强技术。


8.2 热替换


8.2.1 概念


终于来到热替换这节了,前文我们做了好多铺垫,介绍热替换之前我们稍稍回顾一下热部署。我们知道热部署是“独立”于 JVM 之外的一门对类加载器应用的技术,通常是应用容器借助自定义类加载器的迭代,无需重启 JVM 缺能更新代码从而达到热部署,也就是说热部署是 JVM 之外容器提供的一种能力。而本节我们介绍的热替换技术是实打实 JVM 提供的能力,是 JVM 提供的一种能够实时更新内存类结构的一种能力,这种实时更新 JVM 方法区类结构的能力当然也是无需重启 JVM 实例。


热替换 HotSwap 是 Sun 公司在 Java 1.4 版本引入的一种新实验性技术,也就是上一节我们介绍 JPDA 提到的调试模式下可以动态替换类结构的彩蛋,这个功能被集成到 JPDA 框架的接口集合里面,首先我们定义好热替换的概念。


热替换(HotSwap):使用字节码增强技术替换 JVM 内存里面类的结构,包括对应类的对象,而不需要重启虚拟机。


8.2.2 原理


前文从宏观上介绍了 JVM 实例的内存布局和垃圾回收机制,微观上也解释了类的结构和类加载机制,上一节又学习了 JAVA 的调试框架,基本上我们对 JVM 的核心模块都已经摸透了,剩下的就是攻克字节码增强的技术了。而之前讲的字节码增强技术也仅仅是放在 JPDA 里面作为实验性技术,而且仅仅局限在方法体和变量的修改,无法动态修改方法签名或者增删方法,因为字节码增强涉及到垃圾回收机制,类结构变更,对象引用,即时编译等复杂问题。在 HotSwap 被引进后至今,JCP 也未能通过正式的字节码增强实现。


JAVA 是一门静态语言,而字节码增强所要达的效果就是让 Java 像动态语言一样跑起来,无需重启服务器。下面我们介绍字节码增强的基本原理。


反射代理反射代理不能直接修改内存方法区的字节码,但是可以抽象出一层代理,通过内存新增实例来实现类的更新原生接口 jdk 上层提供面向 java 语言的字节码增强接口 java.lang.instrument,通过实现 ClassFileTransformer 接口来操作 JVM 方法区的类文件字节码。JVMTI 代理 JVM 的 JVMTI 接口包含了操作方法区类文件字节码的函数,通过创建代理,将 JVMTI 的指针 JavaVM 传给代理,从而拥有 JVM 本地操作字节码的方法引用。类加载器织入字节码增强接口加上类加载器的织入,结合起来也是一种热替换技术。JVM 增强直接新增 JVM 分支,增加字节码增强功能。8.2.3 实现


但是尽管字节码增强是一门复杂的技术,这并不妨碍我们进一步的探索,下面我们介绍几种常见的实现方案。


InstrumentationAspectJASMDCEVMJREBELCGLIBjavassistBCEL 具体的我会挑两个具有代表性的工具深入讲解,篇幅所限,这里就补展开了。


9.总结


为了让学习变得轻松、高效,今天给大家免费分享一套阿里架构师传授的一套教学资源。帮助大家在成为架构师的道路上披荆斩棘。


这套视频课程详细讲解了(Spring,MyBatis,Netty 源码分析,高并发、高性能、分布式、微服务架构的原理,JVM 性能优化、分布式架构)等这些成为架构师必备的内容!


资料已整理成文档,免费获取,需要的小伙伴可以+ VX: mxk6072


JVM 是程序发展至今的一颗隗宝,是程序设计和工程实现的完美结合。JVM 作为作为三大工业级程序语言为首 JAVA 的根基,本文试图在瀚如烟海的 JVM 海洋中找出其中最耀眼的冰山,并力求用简洁的逻辑线索把各个冰山串起来,在脑海中对 JVM 的观感有更加立体的认识。更近一步的认识 JVM 对程序设计的功力提示大有裨益,而本文也只是将海平面上的冰山链接起来,但这只是冰山一角,JVM 更多的底层设计和实现细节还远远没有涉及到,而且也不乏知识盲区而没有提及到的,路漫漫其修远兮,JVM 本身也在不断的推陈出新,借此机会总结出 JVM 的核心体系,以此回顾对 JVM 知识的查漏补缺,也是一次 JVM 的认知升级。最后还是例牌来两张图结束 JVM 的介绍,希望对更的同学有帮助。

用户头像

钟奕礼

关注

还未添加个人签名 2021-03-24 加入

还未添加个人简介

评论

发布
暂无评论
JVM 核心知识体系_Java_钟奕礼_InfoQ写作社区