|
故事起源
从第一天学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类加载那些事。
为了节约时间,先来个大纲把。
[list=1,
[*,JAVA类加载的基础知识–简略
[*,外部Jar包加载
[*,实现自定义加载 –不停机热加载
[*,实现CLASS防反编译
[/list,一、类加载的基础知识:
先来个简单粗暴的main方法,看看类加载器到底是什么玩意。
看看执行结果,找找里面的ClassLoader看看
1. JAVA类加载类型:JAVA的类加载器,父子关系如下:
BootStrapClassLoader -> ExtentionClassLoader -> APPClassLoader
其中,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的类加载器集成关系大致如下:
二、外部Jar包加载:
前面说要简略,但是最后还是说得罗里吧嗦。下面就尽量简单点把。为了更容易理解,下面就设计实现一个简单的场景:发工资。每个人的工资是额定的,但是碰上了一个无良经理,到手之前,要经过经理审核,他偷偷克扣掉一部分后再发给别人。那我们简单模拟下这个计算过程。
这就是计算薪水的主逻辑,用一个while(true)用来模拟不停机场景,比如我们的OA系统。线程休眠模拟用户的不定时请求,比如每月计算一次薪水。calSalary方法专门用来进行薪水计算。 这样按照面向对象的思想,定义一个计算器,专门进行计算:
好了。需求就这么愉快的实现了。然后呢?经理发现这计算代码和发工资代码在一起,那发工资的人事不是就知道工资是怎么计算的了吗?那不行, 那我克扣别人工资的事情他不就全都知道了?于是,万能的程序员找到了下面的解决办法:代码在一起不安全是吧。那我把计算器SalaryCaler部署到另外一个工程,发布成一个jar包。让薪水计算程序动态的去加载jar包。这样发工资的人不就看不到代码了吗?好。于是我们用Eclipse可以轻松的把这个SalaryCaler类export成一个Jar包。把他放到F:\lib目录下,让计算程序去这个jar包里获取计算器的实现。于是,就有了下面这个样子。
执行起来很完美,也完成了经理的要求。然后经理就开始了下一步折腾。
三、实现自定义加载 –不停机热加载
经理偷偷达到了他的目的,但是,突然上面要开始彻查。经理赶紧要求程序员把计算方法改掉,克扣的那一部分返还回去。好吧。程序员赶紧修改计算方法。
一通忙乎,重新打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方法实现。[i,* SalaryCalDemo *[/i,
SalaryClassLoader
SalaryJARLoader
忽忽悠悠忙乎了半天,程序员终于又一次成功的帮助恶毒的经理达到了他的目的。这种热加载貌似很好用嘛,那为什么又没有大面积的使用呢?大名鼎鼎的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防反编译。这种实现,参照上面的两个自定义类加载器,很容易实现,玩法也很随意,就不多说了。但是可以很负责任的说,这种思路是可行的。当然,继续深入,有人还会说,这个class文件是被篡改了无法反编译,但是classloader里面的加载算法还是可以看到,那别人还是可以通过反向操作把class给还原出来。这个情况,有一些方式可以避免,例如将热加载的路径换成一个网络地址,这样即便你能通过算法弄出一个有问题的class,也无法注入到我们的类加载中来。例如[i,Drools[/i,就支持从maven库中加载规则文件,这也是这种思想的一个体现。最后就这个安全问题,提一提我对这方面的看法了。一是世上本很难有完全的安全策略。安全问题永远是一个博弈的过程,只能适当,没有完全。所以,关于class反编译带来的安全风险,基本不可能完全通过软件层面解决。多途径的结合,例如上面引入网络加载地址,就能在网络层面增加很多安全策略。二是关于安全与性能的平衡。在我们这个场景中,对class文件每增加一层混淆计算,固然能够增加破解、篡改的难度,提高安全性。但是,对于正常的加载,也同样会增加性能的消耗,而且对于我们实现的这种运行时的热加载,消耗会更为明显。所以我觉得最终的平衡点是在保障性能的前提下,让破解攻击要付出的代价远远大于能获得的收益,而不是想办法彻底的断绝破解攻击的可能。
故事完结:
还有向大家推荐下我的GenUI项目,期待让它多历练历练,维护更新成一个稳定可靠的版本。期待下一个故事。。。。。。 |
|