本文从JVM的最小元素类的结构出发,介绍类加载器的工作原理和应用场景,思考类加载器存在的意义。进而描述JVM逻辑内存的分布和管理方式,同时列举常用的JVM调优工具和使用方法,最后介绍高级特性JDPA框架和字节码增强技术,实现热替换。
2、如何应用类加载器的工作原理进行将应用辗转腾挪?
8、JVM字节码增强技术有哪些?
类结构,类加载器,加载,链接,初始化,双亲委派,热部署,隔离,堆,栈,方法区,计数器,内存回收,执行引擎,调优工具,JVMTI,JDWP,JDI,热替换,字节码,ASM,CGLIB,DCEVM
作为三大工业级别语言之一的JAVA如此受企业青睐有加,离不开她背后JVM的默默复出。只是由于JAVA过于成功以至于我们常常忘了JVM平台上还运行着像
Clojure/Groovy/Kotlin/Scala/JRuby/Jython这样的语言。我们享受着JVM带来跨平台“一次编译到处执行”台的便利和自动内存回收的安逸。本文从JVM的最小元素类的结构出发,介绍类加载器的工作原理和应用场景,思考类加载器存在的意义。进而描述JVM逻辑内存的分布和管理方式,同时列举常用的JVM调优工具和使用方法,最后介绍高级特性JDPA框架和字节码增强技术,实现热替换。从微观到宏观,从静态到动态,从基础到高阶介绍JVM的知识体系。
我们知道不只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.1类的入口
在我们探究JVM如何使用CLASS文件之前,我们快速回忆一下编写好的C语言文件是如何执行的?我们从C的HelloWorld入手看看先。
#include
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;
/**
* Created with IntelliJ IDEA. User: linzhenhua Date: 2019/1/3 Time: 11:56 PM
* @author linzhenhua
*/
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方法调用不受修饰符影响。
$ 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)
我们是从类对象调用而不是类创建的对象才调用,索引需要静态修饰。
$ 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的设计。
$ 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方法的规则,其实我们关心的只有两点:
关于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只是提供了底层所需的类,更多的业务需要我们从外部加载类来支持,所以我们需要指定新的规则,以方便我们加载外部路径的类文件。
系统默认加载器
作用:启动类加载器,加载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
/Library/Java/JavaVirtualMachines/jdk1.8.0_181.jdk/Contents/Home/jre/lib/sunrsasig.jar
/Library/Java/JavaVirtualMachines/jdk1.8.0_181.jdk/Contents/Home/jre/lib/jsse.jar
/Library/Java/JavaVirtualMachines/jdk1.8.0_181.jdk/Contents/Home/jre/lib/jce.jar
/Library/Java/JavaVirtualMachines/jdk1.8.0_181.jdk/Contents/Home/jre/lib/charsets.jar
/Library/Java/JavaVirtualMachines/jdk1.8.0_181.jdk/Contents/Home/jre/lib/jfr.jar
/Library/Java/JavaVirtualMachines/jdk1.8.0_181.jdk/Contents/Home/jre/classes
实现原理:本地方法由C++实现
作用:扩展类加载器,加载JAVA扩展类库。
类加载器:JAVA实现
类加载路径:
/jre/lib/ext
System.out.println(System.getProperty("java.ext.dirs"));
/Users/linzhenhua/Library/Java/Extensions:
/Library/Java/JavaVirtualMachines/jdk1.8.0_181.jdk/Contents/Home/jre/lib/ext:
/Library/Java/Extensions:
/Network/Library/Java/Extensions:
/System/Library/Java/Extensions:/usr/lib/java
实现原理:扩展类加载器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() {
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() {
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
// to find the class.
//如果还是没有就调用自己的方法,确保调用自己方法前都使用了父类方法,如此递归三次到顶
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了是吧,所以我们会介绍一个重要的原则,在此之前我们先介绍一下内置类加载器和自定义类加载器是如何协同的。
双亲委派机制很好理解,目的就是为了不重复加载已有的类,提高效率,还有就是强制从父类加载器开始逐级搜索类文件,确保核心基础类优先加载。下面介绍的是破坏双亲委派机制,了解为什么要破坏这种看似稳固的双亲委派机制。
-
破坏委派机制
定义:打破类加载自上而上委托的约束。
实现:
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方法区检索缓存类,存在的话则提示重复定义。
URLClassLoader.java 443
/*
* Defines a Class using the class bytes obtained from the specified
* Resource. The resulting Class must be resolved before it can be
* used.
*/
private Class> defineClass(String name, Resource res) throws IOException {
//加载资源
long t0 = System.nanoTime();
int i = name.lastIndexOf('.');
URL url = res.getCodeSourceURL();
if (i != -1) {
String pkgname = name.substring(0, i);
// Check if package already loaded.
Manifest man = res.getManifest();
definePackageInternal(pkgname, man, url);
}
// Now read the class bytes and define the class
java.nio.ByteBuffer bb = res.getByteBuffer();
if (bb != null) {
// Use (direct) ByteBuffer:
CodeSigner[] signers = res.getCodeSigners();
CodeSource cs = new CodeSource(url, signers);
sun.misc.PerfCounter.getReadClassBytesTime().addElapsedTimeFrom(t0);
//字节码转为内存类格式规范
return defineClass(name, bb, cs);
} else {
byte[] b = res.getBytes();
// must read certificates AFTER reading bytes.
CodeSigner[] signers = res.getCodeSigners();
CodeSource cs = new CodeSource(url, signers);
sun.misc.PerfCounter.getReadClassBytesTime().addElapsedTimeFrom(t0);
//字节码转为内存类格式规范
return defineClass(name, b, 0, b.length, cs);
}
}
//调用类定义方法
protected final Class> defineClass(String name,
byte[] b, int off, int len,
CodeSource cs)
{
return defineClass(name, b, off, len, getProtectionDomain(cs));
}
//调用类定义方法
protected final Class> defineClass(String name, byte[] b, int off, int len,
ProtectionDomain protectionDomain)
throws ClassFormatError{
protectionDomain = preDefineClass(name, protectionDomain);
String source = defineClassSourceLocation(protectionDomain);
//本地方法,定义类
Class> c = defineClass1(name, b, off, len, protectionDomain, source);
postDefineClass(c, protectionDomain);
return c;
}
//本地方法,将加载的字节码定义为规范的class对象
private native Class> defineClass1(String name, byte[] b, int off, int len,
ProtectionDomain pd, String source);
4.2.4加载过程
至此我们已经深刻认识到类加载器的工作原理及其存在的意义,下面我们将介绍类从外部介质加载使用到卸载整个闭环的生命周期。
加载
上文花了不少的篇幅说明了类的结构和类是如何被加载到JVM内存里面的,那究竟什么时候JVM才会触发类加载器去加载外部的CLASS文件呢?通常有如下四种情况会触发到:
JVM只定了类加载器的规范,但却不明确规定类加载器的目标文件,把加载的具体逻辑充分交给了用户,包括重硬盘加载的CLASS类到网络,中间文件等,只要加载进去内存的二进制数据流符合JVM规定的格式,都是合法的。
链接
类加载器加载完类到JVM实例的指定内存区域(方法区下文会提到)后,是使用前会经过验证,准备解析的阶段。
-
验证:主要包含对类文件对应内存二进制数据的格式、语义关联、语法逻辑和符合引用的验证,如果验证不通过则跑出VerifyError的错误。但是该阶段并非强制执行,可以通过-Xverify:none来关闭,提高性能。
-
准备:但我们验证通过时,内存的方法区存放的是被“紧密压缩”的数据段,这个时候会对static的变量进行内存分配,也就是扩展内存段的空间,为该变量匹配对应类型的内存空间,但还未初始化数据,也就是0或者null的值。
-
解析:我们知道类的数据结构类似一个数据库,里面多张不同类型的“表”紧凑的挨在一起,最大的节省类占用的空间。多数表都会应用到常量池表里面的字面量,这个时候就是把引用的字面量转化为直接的变量空间。比如某一个复杂类变量字面量在类文件里只占2个字节,但是通过常量池引用的转换为实际的变量类型,需要占用32个字节。所以经过解析阶段后,类在方法区占用的空间就会膨胀,长得更像一个”类“了。
初始化
方法区经过解析后,类已经为各个变量分配好了内存空间。初始化阶段则是将变量的初始值和构造方法的内容初始化到这些变量的空间中。此时,类的二进制文件所定义的内容已经完全被“翻译”并加载到方法区的某一段内存空间了。万事俱备只待使用了。
使用
使用呼应了我们加载类的触发条件,也即是触发类加载的条件也是类应用的条件,该操作会在初始化完成后进行。
卸载
我们知道JVM有垃圾回收机制(下文会详细介绍),不需要我们操心,总体上有三个条件会触发垃圾回收期清理方法区的空间:
本节结束我们已经对整个类的生命周期烂熟于胸了,下面我们来介绍类加载机制最核心的几种应用场景,来加深对类加载技术的认识。
通过前文的剖析我们已经非常清楚类加载器的工作原理,那么我们该如何利用类加载器的特点,最大限度的发挥它的作用呢?
4.3.1热部署
背景
热部署这个词汇我们经常听说也经常提起,但是却很少能够准确的描述出它的定义。说到热部署我们第一时间想到的可能是生产上的机器更新代码后无需重启应用容器就能更新服务,这样的好处就是服务无需中断可持续运行,那么与之对应的冷部署当然就是要重启应用容器实例了。还有可能会想到的是使用IDE工具开发时不需要重启服务,修改代码后即时生效,这看起来可能都是服务无需重启,但背后的运行机制确截然不同,首先我们需要对热部署下一个准确的定义。
首先热部署应用容器拥有的一种能力,这种能力是容器本身设计出来的,跟具体的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{
private String classPath;
public MyClassLoader(String classPath) {
this.classPath = classPath;
}
@Override
public Class> loadClass(String name) throws ClassNotFoundException {
if(!name.contains("java")){
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;
}
}
}
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;
/**
* 应用上下文热加载
* Created with IntelliJ IDEA. User: linzhenhua Date: 2019/2/24 Time: 10:25 PM
* @author linzhenhua
*/
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{
private String classPath;
public MyClassLoader(String classPath) {
this.classPath = classPath;
}
@Override
public Class> loadClass(String name) throws ClassNotFoundException {
if(!name.contains("java")){
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;
}
}
}
输出结果跟上一个例子相似,可以自己运行试试。我们更新业务方法编译通过后,无需重启main方法,新的业务就能生效,而且也解决了旧类卸载的核心问题,因为context的应用对象的跟节点,context是由我们自定义类加载器所加载,由于User/Dao/Service都是依赖context,所以其类也是由自定义类加载器所加载。根据GC roots原理,在创建新的自定义类加载器之后,旧的类加载器已经没有任何引用链可访达,符合GC回收规则,将会被GC收集器回收释放内存。至此已经完成应用热部署的流程,但是细心的朋友可能会发现,我们热部署的策略是整个上下文context都替换成新的,那么用户的状态也将无法保留。而实际情况是我们只需要动态更新某些模块的功能,而不是全局。这个其实也好办,就是我们从业务上把需要热部署的由自定义类加载器加载,而持久化的类资源则由系统默认类加载器去完成。
我们上个例子已经完美的演示了热部署的整个流程,本节我们将更加接近实际的应用场景,也就是上文提到的保持用户态。我们知道默认系统类加载器遵循双亲委派原则,也就是系统类加载器对特定类只加载一次,如果要再次加载该类,JVM会从缓存中检测到已存在然后从缓存中获取,而不会再次加载,我们利用这个特性来保存用户态。至于之前写好的自定义类加载器,则是我们热部署所需要的,下面我们过一下这个例子,延续上一个例子,我们增加了Cache类表示缓存用户态,重点关注Cache赋值的注释。
package com.zooncool.example.theory.jvm;
import java.io.FileInputStream;
import java.lang.reflect.InvocationTargetException;
/**
* 应用上下文热加载,增加缓存用户态
* Created with IntelliJ IDEA. User: linzhenhua Date: 2019/2/24 Time: 10:25 PM
* @author linzhenhua
*/
public class ContextStaticReloading {
public static void main(String[] args)
throws NoSuchMethodException, ClassNotFoundException, IllegalAccessException, InstantiationException,
InvocationTargetException, InterruptedException, NoSuchFieldException {
Cache cache = new Cache();//根据GCRoots可达原则,Cache会被系统类加载器加载,而且不会被GC回收
cache.setName("Jack");
for (;;){
Object context = newContext(cache);//把cache赋给context,注意context是由自定义类加载器的,所以每次循环都会被新版本的context替换,而cache不会被GC所以一直保持用户态。
invokeContext(context);
Thread.sleep(5000);
}
}
public static Object newContext(Cache cache)
throws ClassNotFoundException, NoSuchMethodException, IllegalAccessException, InstantiationException,
InvocationTargetException, NoSuchFieldException {
String className = "com.zooncool.example.theory.jvm.ContextStaticReloading$Context";
Class> contextClass = new MyClassLoader("target/classes").loadClass(className);
Object context = contextClass.newInstance();
contextClass.getDeclaredField("cache").set(context,cache);//初始化之前显把context对象里面的cache进行赋值,这里要特别注意的是,我们传入的cache对象是由系统类加载器加载的。而Context类里面的Cache类却是由自定义类加载器MyClassLoader加载,这样赋值会造成类型和值不一致,导致抛出IllegalArgumentException异常。解决思路很简单,让他们保持一致就行,但是由于我们Cache必须又系统默认类加载器加载,那么只能让Context里面的Cache显性的指定系统类加载器来加载(因为不指定的话,根据类加载器依赖传导原则,依赖类复用被依赖的类的加载器)。本例子很简单,就是在复写loadClass方法的时候,增加条件!name.contains("Cache"),让MyClassLoader加载Cache时抛给系统类加载器。
contextClass.getDeclaredMethod("init").invoke(context);
return context;
}
public static void invokeContext(Object context)
throws NoSuchMethodException, InvocationTargetException, IllegalAccessException {
context.getClass().getDeclaredMethod("showUser").invoke(context);
}
public static class Context{
public Cache cache;
private UserService userService = new UserService();
public void init(){
User user = new User();
user.setName("Rose");
UserDao userDao = new UserDao();
userDao.setUser(user);
userService.setUserDao(userDao);
}
public void showUser(){
System.out.println("from context name is " + userService.getUserMessage());
System.out.println("from cache name is " + cache.getName());
System.out.println();
}
}
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(){
return user.getName();
}
public void setUser(User user) {
this.user = user;
}
}
public static class User{
private String name;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
public static class Cache{
private String name;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
public static class MyClassLoader extends ClassLoader{
private String classPath;
public MyClassLoader(String classPath) {
this.classPath = classPath;
}
@Override
public Class> loadClass(String name) throws ClassNotFoundException {
if(!name.contains("java") && !name.contains("Cache") ){//就是这里加条件过滤Cache
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;
}
}
}
以上代码重点关注注释部分,main运行后修改cache和user的name值,然后编译,我们会发现user的name即时改变,而cache的却没有变化,从而实现了用户态的固化。
cache.setName("Jack");
user.setName("Rose");
from context name is Rose
from cache name is Jack
from context name is Lucy
from cache name is Jack
from context name is hanmei
from cache name is Jack
到了这里相信我们对类加载器的辗转腾挪已经烂熟于胸,虽然我们已经实现了应用热部署,常态数据持久化,但是如果从设计上考虑这个流程无疑还不够优雅,因为我们的实现热部署的逻辑完全暴露给了用户,这在安全的维度看不可接受的。下一节我们将进一步考虑如何优雅的实现热部署。
其实设计到代码设计优雅问题,基本上我们拿出设计模式23章经对号入座基本可以解决问题,毕竟这是前人经过千万实践锤炼出来的软件构建内功心法。那么针对我们热部署的场景,如果想把热部署细节封装出来,那代理模式无疑是最符合要求的,也就是咱们弄出个代理对象来面向用户,把类加载器的更替,回收,隔离等细节都放在代理对象里面完成,而对于用户来说是透明无感知的,那么终端用户体验起来就是纯粹的热部署了。至于如何实现自动热部署,方式也很简单,监听我们部署的目录,如果文件时间和大小发生变化,则判断应用需要更新,这时候就触发类加载器的创建和旧对象的回收,这个时候也可以引入观察者模式来实现。由于篇幅限制,本例子就留给读者朋友自行设计,相信也是不难完成的。
案例
上一节我们深入浅出的从自定义类加载器的开始引入,到实现多个类加载器加载同个类文件,最后完成旧类加载器和对象的回收,整个流程阐述了热部署的实现细节。那么这一节我们介绍现有实现热部署的通用解决方案,本质就是对上文原理的实现,加上性能和设计上的优化,注意本节我们应用的只是类加载器的技术,后面章节还会介绍的字节码层面的底层操作技术。
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兼顾动态脚本语言的功能,使用的时候无外乎也是通过GroovyClassLoader来加载脚本文件,转为JVM的类对象。那么每次更新groovy脚本就可以动态更新应用,也就达到了热部署的功能了。
Class groovyClass = classLoader.parseClass(new GroovyCodeSource(sourceFile));
GroovyObject instance = (GroovyObject)groovyClass.newInstance();//proxy
介绍完热部署技术,可能很多同学对热部署的需求已经没有那么强烈,毕竟热部署过程中带来的弊端也不容忽视,比如替换旧的类加载器过程会产生大量的内存碎片,导致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
)。该工具包可以通过字节码技术将我们依赖的第三方类库重命名,同时修改代码里面对第三方类库引用的路径。这样如果出现同名第三方类库的话,通过该“硬编码”的方式修改其中一个类库,从而消除了冲突。
上面两种方式在小型系统比较适合,也比较敏捷高效。但是对于分布式大型系统的话,通过硬编码方式来解决冲突就难以完成了。办法就是通过隔离容器,从逻辑上区分类库的作用域,从而对内存的类进行隔离。
容器
类隔离容器很好理解,就是创建一个类容器,容器进行了分区,然后不同的分区对应不用的类加载器,这样相同的类名分布在不同的分区也可以独立的运行,说白了就是给不同性质的类分配不同的类加载器。首先我们要讲的是我们非常熟悉的Tomcat中间件,我们对它已经很熟悉,以至于都忘了Tomcat底层在类隔离方面做出的优良设计,下面我们就从源码开始来分析Tomcat是如何设计类加载器规则来做到类隔离的。
首先当然是寻找源码的入口main方法了,对于JVM来说,所有的启动指令都是始于main的调用,main除了启动JVM以外最重要的作用是创建不同用途的类加载器,然后把我们写的一堆Tomcat源码编译后的CLASS文件加载进JVM内存。
//Bootstrap.java 457
public static void main(String args[]) {
if (daemon == null) {
// Don't set daemon until init() has completed
Bootstrap bootstrap = new Bootstrap();
try {
//1、初始化守护进程
bootstrap.init();
} catch (Throwable t) {
handleThrowable(t);
t.printStackTrace();
return;
}
daemon = bootstrap;
} else {
// When running as a service the call to stop will be on a new
// thread so make sure the correct class loader is used to prevent
// a range of class not found exceptions.
Thread.currentThread().setContextClassLoader(daemon.catalinaLoader);
}
//下面的逻辑是执行startup.sh脚本里面执行的exec "$PRGDIR"/"$EXECUTABLE" start "$@"
//篇幅限制,省略
}
追踪初始化流程之前我们要先观察下列的类加载器
//Bootstrap.java 136
ClassLoader commonLoader = null;//公共类加载器
ClassLoader catalinaLoader = null;//
ClassLoader sharedLoader = null;//
继续追踪初始化方法的逻辑
//Bootstrap.java 255
/**
* Initialize daemon.
* @throws Exception Fatal initialization error
*/
public void init() throws Exception {
//2、初始化类加载器
initClassLoaders();
Thread.currentThread().setContextClassLoader(catalinaLoader);
SecurityClassLoad.securityClassLoad(catalinaLoader);
// Load our startup class and call its process() method
if (log.isDebugEnabled())
log.debug("Loading startup class");
//6、通过类加载器catalinaLoader加载Catalina,并通过反射创建对象startupInstance
Class> startupClass = catalinaLoader.loadClass("org.apache.catalina.startup.Catalina");
Object startupInstance = startupClass.getConstructor().newInstance();
// Set the shared extensions class loader
if (log.isDebugEnabled())
log.debug("Setting startup class properties");
String methodName = "setParentClassLoader";
Class> paramTypes[] = new Class[1];
paramTypes[0] = Class.forName("java.lang.ClassLoader");
Object paramValues[] = new Object[1];
paramValues[0] = sharedLoader;
//7、startupInstance实例调用setParentClassLoader方法设置父类加载器为sharedLoader
Method method =
startupInstance.getClass().getMethod(methodName, paramTypes);
method.invoke(startupInstance, paramValues);
catalinaDaemon = startupInstance;
}
//Bootstrap.java 144
private void initClassLoaders() {
try {
//3.1、创建通用类加载器
commonLoader = createClassLoader("common", null);
if( commonLoader == null ) {
// no config file, default to this loader - we might be in a 'single' env.
commonLoader=this.getClass().getClassLoader();
}
//3.2、创建系统类加载器
catalinaLoader = createClassLoader("server", commonLoader);
//3.3、创建共享类加载器
sharedLoader = createClassLoader("shared", commonLoader);
} catch (Throwable t) {
handleThrowable(t);
log.error("Class loader creation threw exception", t);
System.exit(1);
}
}
创建类加载器具体逻辑,返回ClassLoader。
//Bootstrap.java 161
//不同功能类加载器通过配置文件加载不同路径的类库,父加载器显式指定parent
//通过parent传参方式,我们知道commonLoader的父类加载器是系统类加载器
//而catalinaLoader和sharedLoader的父类加载器则是commonLoader
private ClassLoader createClassLoader(String name, ClassLoader parent)
throws Exception {
//省略从catalina.properties读取common.loader,server.loader,shared.loader加载类库逻辑
//common.loader="${catalina.base}/lib","${catalina.base}/lib/*.jar"...
//server.loader=
//shared.loader=
//4、工厂类ClassLoaderFactory创建类加载器
return ClassLoaderFactory.createClassLoader(repositories, parent);
}
//ClassLoaderFactory.java 151
//创建类加载器
public static ClassLoader createClassLoader(List repositories,
final ClassLoader parent)
throws Exception {
// Construct the "class path" for this class loader
Set set = new LinkedHashSet<>();
//省略从repositories循环迭代出Repository然后装配URL的逻辑,最终就是指定类加载器所要加载类的路径
//5、创建URLClassLoader类加载器
return AccessController.doPrivileged(
new PrivilegedAction() {
@Override
public URLClassLoader run() {
if (parent == null)
return new URLClassLoader(array);
else
return new URLClassLoader(array, parent);
}
});
}
经过源码的解析,我们知道commonLoader为catalinaLoader和sharedLoader的父类加载器,接下来我们继续分析隔离容器还包含其他什么样的类加载器。
具体流程放在《Tomcat源码解析》专题来展开分析,下面我们给出结论,总结起来Tomcat的类加载器结构如下:
理解了Tomcat的类加载机制后,我们来介绍另外一个类加载容器。类隔离容器要完成的功能说白了就是把引起冲突的类隔离起来,然后把相同版本的类共享出来避免浪费内存空间。
具体介绍参考《Pandora容器设计的本质》
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选项更改此选项,非堆内存通常包含如下性质的区域空间:
在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提供了许多内存配置项,我们可以使用它们来设置内存大小及其比例,常用的如下:
5.2.1垃圾回收策略
流程
垃圾收集是释放堆中的空间以分配新对象的过程。垃圾收集器是JVM管理的进程,它可以查看内存中的所有对象,并找出程序任何部分未引用的对象,删除并回收空间以分配给其他对象。通常会经过如下步骤:
策略
虚拟机栈、本地栈和程序计数器在编译完毕后已经可以确定所需内存空间,程序执行完毕后也会自动释放所有内存空间,所以不需要进行动态回收优化。JVM内存调优主要针对堆和方法区两大区域的内存。通常对象分为Strong、s
o
ft、weak和phantom四种类型,强引用不会被回收,软引用在内存达到溢出边界时回收,弱引用在每次回收周期时回收,虚引用专门被标记为回收对象,具体回收策略如下:
算法
垃圾收集有如下常用的算法:
5.2.2 垃圾回收器
分类
-
serial收集器:单线程,主要用于client模式
-
ParNew收集器:多线程版的serial,主要用于server模式
-
Parallel Scavenge收集器:线程可控吞吐量(用户代码时间/用户代码时间+垃圾收集时间),自动调节吞吐量,用户新生代内存区
-
-
Parallel Old收集器:老年版本Parallel Scavenge
-
CMS(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 = n
-
G1垃圾收集器(-XX:+ UseG1GC):G1从长远看要是替换CMS收集器。G1收集器是并行,并发和递增紧凑的低暂停垃圾收集器。G1收集器不像其他收集器那样工作,并且没有年轻和老一代空间的概念。它将堆空间划分为多个大小相等的堆区域。当调用垃圾收集器时,它首先收集具有较少实时数据的区域,因此称为“Garbage First”也即是G1
类加载器加载的类文件字节码数据流由基于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执行的流程。
每个方法调用开始到执行完成的过程,对应这一个栈帧在虚拟机栈里面从入栈到出栈的过程。
-
栈帧包含:局部变量表,操作数栈,动态连接,方法返回
-
方法调用:方法调用不等于方法执行,而且确定调用方法的版本。
-
方法调用字节码指令:invokestatic,invokespecial,invokevirtual,invokeinterface
-
静态分派:静态类型,实际类型,编译器重载时通过参数的静态类型来确定方法的版本。(选方法)
-
动态分派:invokevirtual指令把类方法符号引用解析到不同直接引用上,来确定栈顶的实际对象(选对象)
-
-
多分派:动态单分派,方法接受者只能确定唯一一个。
下图是JVM实例执行方法是的内存布局。
-
javac编译器:解析与符号表填充,注解处理,生成字节码
-
java语法糖:语法糖有助于代码开发,但是编译后就会解开糖衣,还原到基础语法的class二进制文件
重载要求方法具备不同的特征签名(不包括返回值),但是class文件中,只要描述不是完全一致的方法就可以共存,如:
publicString foo(List<String> arg){
finalintvar = 0;
return"";
}
publicint foo(List<Integer> arg){
intvar = 0;
return0;
}
HotSpot虚拟机内的即时编译
解析模式 -Xint
编译模式 -Xcomp
混合模式 Mixed mode
分层编译:解释执行 -> C1(Client Compiler)编译 -> C2编译(Server Compiler)
触发条件:基于采样的热点探测,基于计数器的热点探测
我们知道调优的前提是,程序没有达到我们的预期要求,那么第一步要做的是衡量我们的预期。程序不可能十全十美,我们要做的是通过各种指标来衡量系统的性能,最终整体达到我们的要求。
7.1.1 环境
首先我们要了解系统的运行环境,包括操作系统层面的差异,JVM版本,位数,乃至于硬件的时钟周期,总线设计甚至机房温度,都可能是我们需要考虑的前置条件。
7.1.2 度量
首先我们要先给出系统的预期指标,在特定的硬件/软件的配置,然后给出目标指标,比如系统整体输出接口的QPS,RT,或者更进一层,IO读写,cpu的load指标,内存的使用率,GC情况都是我们需要预先考察的对象。
7.1.3 监测
确定了环境前置条件,分析了度量指标,第三步是通过工具来监测指标,下一节提供了常用JVM调优工具,可以通过不同工具的组合来发现定位问题,结合JVM的工作机制已经操作系统层面的调度流程,按图索骥来发现问题,找出问题后才能进行优化。
7.1.4 原则
总体的调优原则如下图:
图片来源《Java Performance》
上节给出了JVM性能调优的原则,我们理清思路后应用不同的JVM工具来发现系统存在的问题,下面列举的是常用的JVM参数,通过这些参数指标可以更快的帮助我们定位出问题所在。
7.2.1内存查询