目录
1.问题
1、如何理解类文件结构布局?
2、如何应用类加载器的工作原理进行将应用辗转腾挪?
3、热部署与热替换有何区别,如何隔离类冲突?
4、JVM如何管理内存,有何内存淘汰机制?
5、JVM执行引擎的工作机制是什么?
6、JVM调优应该遵循什么原则,使用什么工具?
7、JPDA架构是什么,如何应用代码热替换?
8、JVM字节码增强技术有哪些?
2.关键词
类结构、类加载器、加载、链接、初始化、双亲委派、热部署、隔离、堆、栈、方法区、计数器、内存回收、执行引擎、调优工具、JVMTI、JDWP、JDI、热替换、字节码、ASM、CGLIB、DCEVM
3.全文概要(文末有惊喜)
作为三大工业级别语言之一的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++)
$ gcc hello.c
$ ./a.out
Hello, 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());
}
}
编译执行
$ javac src/main/java/com/zooncool/example/theory/jvm/HelloWorld.java
$ java -cp src/main/java/ com.zooncool.example.theory.jvm.HelloWorld
my 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限定
$ javac src/main/java/com/zooncool/example/theory/jvm/HelloWorld.java $ 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限定
$ javac src/main/java/com/zooncool/example/theory/jvm/HelloWorld.java $ 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
$ javac src/main/java/com/zooncool/example/theory/jvm/HelloWorld.java $ 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
$ javac src/main/java/com/zooncool/example/theory/jvm/HelloWorld.java $ 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/lib
URL[] 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/ext
System.out.println(System.getProperty("java.ext.dirs")); /Library/Java/JavaVirtualMachines/jdk1.8.0_181.jdk/Contents/Home/jre/lib/ext:
实现原理:扩展类加载器ExtClassLoader本质上也是URLClassLoader
Launcher.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本质上也是URLClassLoader
Launcher.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 stats
sun.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方法,实现自己的逻辑
@Override
public 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.ClassIsolationPrinciple2$Demo
com.zooncool.example.theory.jvm.ClassIsolationPrinciple2$Demo
-----------------classLoader name-----------------
sun.misc.Launcher$AppClassLoader@18b4aac2
com.zooncool.example.theory.jvm.ClassIsolationPrinciple2$MyClassLoader@511d50c0
-----------------field value-----------------
1
0
4.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 name
what is your name
what is your name//修改代码,编译新类
my name is lucy
my name is lucy
what 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章经对号入座基本可以解决问题,毕竟这是前人经过千万实践锤炼出来的软件构建内功心法。那么针对我们热部署的场景,如果想把热部署细节封装出来,那代理模式无疑是最符合要求的,也就是咱们弄出个代理对象来面向用户,把类加载器的更替,回收,隔离等细节都放在代理对象里面完成,而对于用户来说是透明无感知的,那么终端用户体验起来就是纯粹的热部署了。至于如何实现自动热部署,方式也很简单,监听我们部署的目录,如果文件时间和大小发生变化,则判断应用需要更新,这时候就触发类加载器的创建和旧对象的回收,这个时候也可以引入观察者模式来实现。由于篇幅限制,本例子就留给读者朋友自行设计,相信也是不难完成的。
案例
上一节我们深入浅出的从自定义类加载器的开始引入,到实现多个类加载器加载同个类文件,最后完成旧类加载器和对象的回收,整个流程阐述了热部署的实现细节。那么这一节我们介绍现有实现热部署的通用解决方案,本质就是对上文原理的实现,加上性能和设计上的优化,注意本节我们应用的只是类加载器的技术,后面章节还会介绍的字节码层面的底层操作技术。
-
OSGI
OSGI(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的模块化的对比和关联,后面有时间会开个专题专门介绍模块化技术。
从类加载器技术应用的角度切入我们知道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上面运行的一个普通应用实例,只不过通过模块内聚,版本管理,服务依赖一系列的管理,实现了模块的即时更新,实现了热部署。
其他热部署解决方案多数也是利用类加载器的特点做文章,当然不止是类加载器,还会应用字节码技术,下面我们主要简单列举应用类加载器实现的热部署解决方案。
-
Groovy
Groovy兼顾动态脚本语言的功能,使用的时候无外乎也是通过GroovyClassLoader来加载脚本文件,转为JVM的类对象。那么每次更新groovy脚本就可以动态更新应用,也就达到了热部署的功能了。
Class groovyClass = classLoader.parseClass(new GroovyCodeSource(sourceFile));
GroovyObject instance = (GroovyObject)groovyClass.newInstance();//proxy
-
Clojure
-
JSP
JSP其实翻译为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)。该工具包可以通过字节码技术将我们依赖的第三方类库重命名,同时修改代码里面对第三方类库引用的路径。这样如果出现同名第三方类库的话,通过该“硬编码”的方式修改其中一个类库,从而消除了冲突。
冲突隔离
上面两种方式在小型系统比较适合,也比较敏捷高效。但是对于分布式大型系统的话,通过硬编码方式来解决冲突就难以完成了。办法就是通过隔离容器,从逻辑上区分类库的作用域,从而对内存的类进行隔离。
参考
公众号:编程原理林振华 编程原理