欢迎光临散文网 会员登陆 & 注册

java类加载的故事一、各种热加载机制

2020-11-02 12:49 作者:楼兰java宝藏圈  | 我要投稿

故事起源:  

从第一天学JAVA,就开始写一个JAVA文件,定义main方法,然后写下大名鼎鼎的System.out.println("Hello World")。然后,javac编译成class文件,再java执行,就能开始我们JAVA的愉快之旅。这个过程简单愉悦,但是,一直都没有深究这Hello World是怎样如晴天霹雳一般出现在屏幕上的。学习JAVA一段时间之后,开始用Eclipse愉快的开发着各种各样的框架,有一天,经理让我去linux上把项目部署起来。兴奋的一上手,才突然发现,原来服务器上没有Eclipse!面对一大堆的jar包,完全不知道要怎么跑。再到多年以后,SpringBoot横空出世,tomcat,jetty等中间件隐藏幕后,强大的J2EE又回归到了JAVA指令来运行,各种部署调优,才发现,这强大听话的JAVA,运行底层别有洞天。

故事发展:  

对JAVA底层的了解,从实用角度,莫过于反射和类加载了。这些底层的机制,在平常开发中用到的可能不多,但是在各种高大上的框架开发中,被大量的运用。   之前的博客《JAVA基于注解的报表映射》中讨论过通过反射和注解,基于JAVABEAN快速完成一个MVC的报表页面,这一设想目前已经实现在了自己的GenUI项目中,再结合freemarker生成很少量,基本不带什么逻辑的controller、service等WEB应用代码,最终的效果可以只需要在数据库中建好表,就可以一键生成针对表的增删改查以及导出的管理页面的全部功能。并且有完善的权限机制对页面进行权限管理,而且针对复杂查询的场景,留有很方便的扩展支持,并且支持图形化的报表开发。很多大型应用,UI管理只是其中很不愿花功夫但又很重要的一小块,用GenUI快速搭建,还是能省不少事情的。有兴趣的可以到码云了解下。

故事继续:  

既然谈到了反射,后面肯定绕不开的就是类加载了。下面就结合自己的理解,谈谈JAVA类加载那些事。  

为了节约时间,先来个大纲把。

  1. JAVA类加载的基础知识--简略  

  2. 外部Jar包加载

  3. 实现自定义加载 --不停机热加载

  4. 实现CLASS防反编译  

一、类加载的基础知识:

先来个简单粗暴的main方法,看看类加载器到底是什么玩意。

 public class LoaderDemo1 {
 
  public static void main(String[] args) throws Exception {
   //java指令可以通过增加-verbose:class -verbose:gc 参数在启动时打印出类加载情况
   //BootStrap Classloader,加载java基础类。这个属性不能在java指令中指定,推断不是由java语言处理。。
   System.out.println("BootStrap ClassLoader加载目录:"+System.getProperty("sun.boot.class.path"));
   //Extention Classloader 加载JAVA_HOME/ext下的jar包。 可通过-D java.ext.dirs另行指定目录
      System.out.println("Extention ClassLoader加载目录:"+System.getProperty("java.ext.dirs"));
   //AppClassLoader 加载CLASSPATH,应用下的Jar包。可通过-D java.class.path另行指定目录
   System.out.println("AppClassLoader加载目录:"+System.getProperty("java.class.path"));
   
   //父子关系 AppClassLoader <- ExtClassLoader <- BootStrap Classloader
   ClassLoader cl1 = LoaderDemo1.class.getClassLoader();
   System.out.println("cl1 > "+cl1);
   System.out.println("parent of cl1 > "+cl1.getParent());
   //BootStrap Classloader由C++开发,是JVM虚拟机的一部分,本身不是JAVA类。
   System.out.println("grant parent of cl1 > "+cl1.getParent().getParent());
   //String,Int等基础类由BootStrap Classloader加载。
   ClassLoader cl2 = String.class.getClassLoader();
   System.out.println("cl2 > "+ cl2);
   System.out.println(cl1.loadClass("java.util.List").getClass().getClassLoader());
  }
 }

看看执行结果,找找里面的ClassLoader看看

 BootStrap ClassLoader加载目录:D:\Java\jdk1.8.0_144\jre\lib\resources.jar;D:\Java\jdk1.8.0_144\jre\lib\rt.jar;D:\Java\jdk1.8.0_144\jre\lib\sunrsasign.jar;D:\Java\jdk1.8.0_144\jre\lib\jsse.jar;D:\Java\jdk1.8.0_144\jre\lib\jce.jar;D:\Java\jdk1.8.0_144\jre\lib\charsets.jar;D:\Java\jdk1.8.0_144\jre\lib\jfr.jar;D:\Java\jdk1.8.0_144\jre\classes
 Extention ClassLoader加载目录:D:\Java\jdk1.8.0_144\jre\lib\ext;C:\WINDOWS\Sun\Java\lib\ext
 AppClassLoader加载目录:D:\Java\jdk1.8.0_144\jre\lib\resources.jar;D:\Java\jdk1.8.0_144\jre\lib\rt.jar;D:\Java\jdk1.8.0_144\jre\lib\jsse.jar;D:\Java\jdk1.8.0_144\jre\lib\jce.jar;D:\Java\jdk1.8.0_144\jre\lib\charsets.jar;D:\Java\jdk1.8.0_144\jre\lib\jfr.jar;D:\Java\jdk1.8.0_144\jre\lib\ext\access-bridge-64.jar;D:\Java\jdk1.8.0_144\jre\lib\ext\cldrdata.jar;D:\Java\jdk1.8.0_144\jre\lib\ext\dnsns.jar;D:\Java\jdk1.8.0_144\jre\lib\ext\jaccess.jar;D:\Java\jdk1.8.0_144\jre\lib\ext\jfxrt.jar;D:\Java\jdk1.8.0_144\jre\lib\ext\localedata.jar;D:\Java\jdk1.8.0_144\jre\lib\ext\nashorn.jar;D:\Java\jdk1.8.0_144\jre\lib\ext\sunec.jar;D:\Java\jdk1.8.0_144\jre\lib\ext\sunjce_provider.jar;D:\Java\jdk1.8.0_144\jre\lib\ext\sunmscapi.jar;D:\Java\jdk1.8.0_144\jre\lib\ext\sunpkcs11.jar;D:\Java\jdk1.8.0_144\jre\lib\ext\zipfs.jar;F:\workspace-oxygen\ClassLoaderDemo\bin
 cl1 > sun.misc.Launcher$AppClassLoader@2a139a55
 parent of cl1 > sun.misc.Launcher$ExtClassLoader@7852e922
 grant parent of cl1 > null
 cl2 > null
 null

     1. JAVA类加载类型:  

JAVA的类加载器,父子关系如下:

 BootStrap ClassLoader -> Extention ClassLoader -> APP ClassLoader

其中,BootStrap ClassLoader是基础类加载器,是C++编写的,本身也是JVM虚拟机的一部分,屏蔽了系统之间的差异。他本身不是JAVA类,在JAVA中是不可见的。他主要负责加载#JRE_HOME#/lib下的主要jar包,如rt.jar;charset.jar等。如java.lang.String,java.lang.System,java.util.List等就是这个东东加载出来的。加载的jar包路径最终会保存到 sun.boot.class.path 这个属性中。--查看源码就能看到,BootStrapClass的声明是native,一个原生态的方法。   ExtentionClassLoader JAVA扩展类加载器,主要加载#JRE_HOME#/lib/ext下的jar包,另外,可扩展加载 -D java.ext.dirs选项目录下的jar包。   APPClassLoader加载当前应用的classPath中的所有类。JAVA运行时通过指定ClassPath或者-cp指定依赖包,就是通过这个东东加载进JVM的。  

     2. 双亲委派模型:  

双薪委派模型

  一个ClassLoader要加载一个class时,不会自己上来就加载,他会判断类是否已经加载过,加载过则返回缓存的类Class;没有加载成功,则向上级加载器进行加载申请,如果上级加载器加载过,就返回上级加载器加载的类;重复申请,直到BootStrap ClassLoader。如果所有父级ClassLoader都没有加载过,那就由该ClassLoader自行加载。从下向上进行委托,从上向下进行查找。这也就解释了为什么永远可以用System.out.println进行打印而不怕被覆盖掉。   但是这里要搞清楚一点,父加载器并不是rt.jar中的父类。JAVA底层的东西不能全部用JAVA来解释。JDK的类加载器集成关系大致如下:  

JDK类加载器

  其中这个URLCLassLoader,可以通过指定的URL加载Class.用这个类就能很容易的实现运行时加载某个固定目录甚至是网络上的class类了。  

二、外部Jar包加载:

前面说要简略,但是最后还是说得罗里吧嗦。下面就尽量简单点把。   为了更容易理解,下面就设计实现一个简单的场景:发工资。每个人的工资是额定的,但是碰上了一个无良经理,到手之前,要经过经理审核,他偷偷克扣掉一部分后再发给别人。那我们简单模拟下这个计算过程。

 public class SalaryCalDemo {
  public static void main(String[] args) throws Exception {
   Double salary = 1999.99;
   Double money = 0.00;
   while(true) {
    money = calSalary(salary);
    System.out.println("实际到手Money:"+money);
    Thread.sleep(1000);
   }
  }
  //计算薪水
  private static Double calSalary(Double salary) {
   SalaryCaler caler = new SalaryCaler();
   return caler.cal(salary);
  }
 }

这就是计算薪水的主逻辑,用一个while(true)用来模拟不停机场景,比如我们的OA系统。线程休眠模拟用户的不定时请求,比如每月计算一次薪水。   calSalary方法专门用来进行薪水计算。  这样按照面向对象的思想,定义一个计算器,专门进行计算:

 public class SalaryCaler {
  public Double cal(Double salary) {
   return salary*0.8;
  }
 }

好了。需求就这么愉快的实现了。然后呢?经理发现这计算代码和发工资代码在一起,那发工资的人事不是就知道工资是怎么计算的了吗?那不行, 那我克扣别人工资的事情他不就全都知道了?   于是,万能的程序员找到了下面的解决办法:   代码在一起不安全是吧。那我把计算器SalaryCaler部署到另外一个工程,发布成一个jar包。让薪水计算程序动态的去加载jar包。这样发工资的人不就看不到代码了吗? 好。于是我们用Eclipse可以轻松的把这个SalaryCaler类export成一个Jar包。把他放到F:\lib目录下,让计算程序去这个jar包里获取计算器的实现。于是,就有了下面这个样子。

  //计算薪水
  private static Double calSalary(Double salary) throws Exception {
      //运行时加载外部jar。加载一次就不能再更新。jar包删掉也不行。
   URL url = new URL("file://F:/lib/");
   URLClassLoader urlClassLoader = new URLClassLoader(new URL[] {url});
   Class<?> objClass = urlClassLoader.loadClass("com.roy.SalaryCaler");
   Object obj = objClass.newInstance();
   return (Double)objClass.getMethod("cal", Double.class).invoke(obj, salary);
  }

执行起来很完美,也完成了经理的要求。然后经理就开始了下一步折腾。  

三、实现自定义加载 --不停机热加载  

经理偷偷达到了他的目的,但是,突然上面要开始彻查。经理赶紧要求程序员把计算方法改掉,克扣的那一部分返还回去。   好吧。程序员赶紧修改计算方法。

 public class SalaryCaler {
  public Double calSalary(Double salary) {
   return salary;
  }
 }

一通忙乎,重新打jar包,放到F:\lib。然后需要重启整个OA系统。   嗯,程序员刚放心呼了一口气。经理又来了。这样每次调整计算方法都要重启应用,这可不行。OA系统那么多人要用,每次都重启,别人不是一下就知道我的手段了吗?要让别人在不知情的情况下偷偷实现。   麻烦又来了,程序员发现,用URLClassLoader加载jar包,那只能一次性加载。加载完成后,jar包即使更新甚至删除,SalaryCalDemo中获取到的都是第一次加载出来的结果。因为按照JDK的类加载机制,ClassLoader会把加载过的类缓存起来,下次如果发现缓存中有,就会返回缓存中的类,不会重新加载了。JDK中现成的类加载器看来是不行了,于是要开始实现自己的类加载器,实现热加载,即jar包或者class文件,一丢上去就能生效。 有了这个思路,下面的东西就不卖关子了。简单说明一下开发一个自定义类加载器的步骤:

  1. 编写一个类继承  ClassLoader抽象类或者其子类  

  2. 复写他的findClass()方法 --注意虽然实际调用时loadClass()方法,但是这个是在ClassLoader基类中的方法,最好不要覆盖。而findClass()方法为JDK的API中明确提供的一个扩展点。

  3. 在findClass()方法中调用defineClas()方法实现最终加载 下面改造的代码实现了两种热加载方式。  SalaryClassLoader实现了从文件系统中加载class文件。而SalaryJARLoader实现了从jar包中加载class文件。两者实现的效果都是热加载,即将SalaryCaler导出成class或者jar包,扔到指定目录上,就能即使更新新的cal方法实现。  

SalaryCalDemo

 public class SalaryCalDemo {
  public static void main(String[] args) throws Exception {
   Double salary = 1999.99;
   Double money = 0.00;
   //模拟不停机状态
   while (true) {
    try {
     //外部jar或者class文件替换过程中,读取会有异常。加try保证程序不退出
     money = calSalary(salary);
     System.out.println("实际到手Money:" + money);
    }catch(Exception e) {
     System.out.println("加载出现异常 :"+e.getMessage());
    }
    Thread.sleep(1000);
   }
  }
 
  // 计算薪水
  private static Double calSalary(Double salary) throws Exception {
   // 启动加载
   // SalaryCaler caler = new SalaryCaler();
   // return caler.cal(salary);
   // 运行时加载。不能热更新
 //  URL url = new URL("file://F:/lib/");
 //  URLClassLoader urlClassLoader = new URLClassLoader(new URL[] {url});
 //  Class<?> objClass = urlClassLoader.loadClass("com.roy.SalaryCaler");
 //  Object obj = objClass.newInstance();
 //  return (Double)objClass.getMethod("cal", Double.class).invoke(obj, salary);
   // 运行时加载文件系统中的class文件。每次运行都重新加载。
   // String libPath="F:\\lib\\";
   // SalaryClassLoader classloader = new SalaryClassLoader(libPath);
   // Class<?> objClass = classloader.loadClass("com.roy.SalaryCaler");
   // Object obj = objClass.newInstance();
   // return (Double)objClass.getMethod("cal", Double.class).invoke(obj, salary);
   // 运行时加载jar包中的class文件。每次运行都重新加载。
   String jarPath = "F:/lib/SalaryCaler.jar";
   SalaryJARLoader classloader = new SalaryJARLoader(jarPath);
   Class<?> objClass = classloader.loadClass("com.roy.SalaryCaler");
   Object obj = objClass.newInstance();
   return (Double) objClass.getMethod("cal", Double.class).invoke(obj, salary);
  }
 }

SalaryClassLoader

//加载文件系统中的class文件 public class SalaryClassLoader extends SecureClassLoader{ private String libPath; public SalaryClassLoader(String libPath) { this.libPath = libPath; } @Override protected Class<?> findClass(String fullClassName) throws ClassNotFoundException { String classFilepath = this.getFileName(fullClassName);         File file = new File(libPath,classFilepath);         try {          System.out.println("重新加载类:"+file.getPath());             FileInputStream is = new FileInputStream(file);             ByteArrayOutputStream bos = new ByteArrayOutputStream();             int len = 0;             try {                 while ((len = is.read()) != -1) {                     bos.write(len);                 }             } catch (IOException e) {                 e.printStackTrace();             }             byte[] data = bos.toByteArray();             is.close();             bos.close();             //重新加載类,老的类会等待GC             return defineClass(fullClassName,data,0,data.length);         } catch (IOException e) {             e.printStackTrace();         }         //自己定义类不成功,就交由父类去加载         return super.findClass(fullClassName); }    //获取要加载 的class文件名     private String getFileName(String name) {         // TODO Auto-generated method stub         int index = name.lastIndexOf('.');         if(index == -1){              return name+".class";         }else{             return name.substring(index+1)+".class";         }     } }

SalaryJARLoader

//加载jar包中的class文件 public class SalaryJARLoader extends SecureClassLoader { private String jarPath; public SalaryJARLoader(String jarPath) { this.jarPath = jarPath; } @Override protected Class<?> findClass(String fullClassName) throws ClassNotFoundException { String classFilepath = fullClassName.replace('.', '/').concat(".class"); try {     //访问jar包的url URL jarURL = new URL("jar:file:/" + jarPath + "!/" + classFilepath); System.out.println("重新加载类:"+jarURL); InputStream is = jarURL.openStream(); ByteArrayOutputStream bos = new ByteArrayOutputStream(); int len = 0; while ((len = is.read()) != -1) { bos.write(len); } byte[] data = bos.toByteArray(); is.close(); bos.close(); return defineClass(fullClassName, data, 0, data.length); } catch (Exception e) { e.printStackTrace(); throw new ClassNotFoundException(e.getMessage()); }  } }

忽忽悠悠忙乎了半天,程序员终于又一次成功的帮助恶毒的经理达到了他的目的。   这种热加载貌似很好用嘛,那为什么又没有大面积的使用呢?大名鼎鼎的SpringBoot的devtools支持热更新也只是支持自动重启应用。因为这样的实现暴露了热加载几个很重要的问题。我大致总结出来的问题如下:

  1. 这种热加载机制使用相当别扭,不但各种各样的反射让人晕头转向,而且绕过了JAVA语言所有的静态检查,方法不对?参数类型不对?这些原本能在编译之前就暴露出来的问题全部拖到了运行时,对接口包的质量要求不是一般的高。虽然能用定义接口,外部jar包继承接口的方式做一定的优化,但是,人家按不按你的来,谁知道呢。

  2. 还一个问题,这种频繁的加载类-卸载类,对JVM虚拟机的内存是一种不小的负担。会在JVM堆内存创建出大量的类,要靠GC进行处理。但是GC,大家都懂的,什么时候干活是谁也说不准的事情。而且,这些大量热加载占用的内存,是很难预估的,这也导致无法通过JVM参数提前申请好内存,内存随时容易崩溃。 --说到这一点,就再提一下对这种热加载机制减少内存的一个方法。一般可以做一个缓存,把class文件的最后修改时间保存起来。每次加载时,通过最后修改时间来判断jar包或者class文件是否有修改。有修改就重新加载,没有修改就从缓存中拿。而且,这种重新判断加载的操作,再尽量控制下操作频率,在某些特定的场合,比如我们的这个迷你计算引擎,是完全可以用好的。这种扩展的实现,就不花功夫继续丢人了,有兴趣的自己扩展。  

  3. 前面两点总结完了,又该轮到恶毒的经理出场了。虽然目前为止,OA系统的人事人员已经接触不到SalaryCaler的源码了,但是,OA系统总还是要接触到SalaryCaler的class文件的。而这时,碰到懂行的人,用jad等工具,还是很容易反编译出SalaryCaler的源代码,那不还是能够知道薪水是怎么计算出来的吗?而且,更可怕的是,class文件虽然是二进制文件,理论上是很难被篡改的。但是如果是只在二进制文件中修改几个小的参数,百度上搜一搜,会发现方法一大堆。那这样,岂不是谁都可以任意修改自己的到手的工资了?那公司就玩不下去了。那好。下面就来讨论最后一个问题,class如何防反编译。

四、Class防反编译    

首先说明一点背景,这个问题其实已经不是上面的应用程序员能够深入的范畴了。既要防止非法的反编译,又要保证class能够正常的加载,这是算法工程师之间攻防博弈的战场。我等应用程序员只能稍微讨论下思路,权当抛砖引玉了。

java编译出来的class文件,已经是01组成的二进制文件了。而防止反编译的方式,当然最常用的就是混淆。通过一些算法,把其中的一些0和1给打乱了,别人不就反编译不过来了吗?对我等程序员, 高大上的算法不懂,但是常用的异或、位移、截取等算法还是可以凑合上一点点的。稍微改一改,class文件反编译的难度就会大上很多。

那回到我们上面的故事,看我们自定义的两个classLoader的实现,其实对class文件的读取都是通过流的方式一个一个字节的读取出来的。那如果要混淆业务代码,可以在class生成之后,再启动一个应用程序,将SalaryCaler编译生成的class文件以流的方式读取出来,做一定的修改后再保存下来,扔到热加载目录上。然后加载过程中,再通过反向的操作把二进制文件给读回来,给类加载器去加载。这样,就完成了Class防反编译。这种实现,参照上面的两个自定义类加载器,很容易实现,玩法也很随意,就不多说了。但是可以很负责任的说,这种思路是可行的。

另外,这种方式有什么用?我们享一下,通过篡改jar包的二进制流,我们是不是可以在class文件二进制流的前面自己添加一个字节,0或者1来代表文件的版本?那在我们的服务端和打包端对这个版本进行匹配,是不是就可以分离出两个不同的运行版本?如果再对版本进行配置化,那是不是就可以快速实现一个灰度发布了?是不是又玩出了一片新天地?

当然,继续深入,有人还会说,这个class文件是被篡改了无法反编译,但是classloader里面的加载算法还是可以看到,那别人还是可以通过反向操作把class给还原出来。这个情况,有一些方式可以避免,例如将热加载的路径换成一个网络地址,这样即便你能通过算法弄出一个有问题的class,也无法注入到我们的类加载中来。例如Drools就支持从maven库中加载规则文件,这也是这种思想的一个体现。   最后就这个安全问题,提一提我对这方面的看法了。   一是世上本很难有完全的安全策略。安全问题永远是一个博弈的过程,只能适当,没有完全。所以,关于class反编译带来的安全风险,基本不可能完全通过软件层面解决。多途径的结合,例如上面引入网络加载地址,就能在网络层面增加很多安全策略。   二是关于安全与性能的平衡。在我们这个场景中,对class文件每增加一层混淆计算,固然能够增加破解、篡改的难度,提高安全性。但是,对于正常的加载,也同样会增加性能的消耗,而且对于我们实现的这种运行时的热加载,消耗会更为明显。所以我觉得最终的平衡点是在保障性能的前提下,让破解攻击要付出的代价远远大于能获得的收益,而不是想办法彻底的断绝破解攻击的可能。  

故事完结:  

最后说明下,这博文固然参照了很多网上的资料,甚至有部分内容就是直接复制粘贴的,但是整体绝对都是经过自己消化整理出来的。所有代码都是自己重新整理编写,亲测可用,各位放心。不过最好有兴趣的朋友还是自己都跑一遍。所有的实验,最后的代码长这样:  

实验最终代码

故事最后:

还有向大家推荐下我的GenUI项目,期待让它多历练历练,维护更新成一个稳定可靠的版本。期待下一个故事。。。。。。

另外,我们邪恶的经理和万能的程序员的故事并没有就此结束,请大家继续等待续集 。

对我们之前提到的Drools规则引擎,也会准备写一个完整的博客,请大家支持。


java类加载的故事一、各种热加载机制的评论 (共 条)

分享到微博请遵守国家法律