荣耀存于心,而非留于形。 --亚索
有时候,游戏服线上出了逻辑bug时,及因此可能导致玩家内存数据也错乱时,我们希望不停服就能修复bug或玩家数据,以避免停服维护可能造成的巨大损失,这在java中是有好些方法能够做到的。这就是常说中的热更。
这些方法的原理,其实都是利用java agent技术实现的。java agent可以理解为是JVM的一个“插件”,我们可以使用agent技术构建一个独立于应用程序的代理程序(即为Agent),用来协助监测、运行甚至替换其他JVM上的程序。通俗来说,就是可以利用它来热更。而实现java agent的功能在Java中是用Instrument实现的,位于rt.jar下的java.lang.instrument下。Instrument的最大作用,就是类定义动态改变和操作。
注:在cmd中输入java -help 就能看到java的启动选项javaagent
其中 agentlib 参数就用来跟要加载的 agent 的名字,如jdwp;javaagent 参数指定jvm启动时装入的java语言代理。jarpath文件中的mainfest文件必须有Premain-Class(启动前捆绑)或Agent-Class(运行时捆绑)属性。
需要注意的是,我们说的游戏服热更,通常指的一个类里的 方法体 热更,而不是指完全替换掉这个已经加载的类。我们应该知道,同一个类加载器只能加载这个类一次,再次加载时就会报错。我们利用java agent仅能在类class文件加载之前,或在运行期对已加载类做拦截,进而对字节码做变更。从而实现方法体的热更。(一个类被加载进JVM后,这个类的类信息、常量池,静态变量、域(Field)信息、方法信息等便已存在方法区了,这个类在堆中生成的对象可能正被很多地方引用着,而一个类的卸载又是时间不确定性的,因为不知GC何时才会回收,因此JVM难以做到完全替换一个类。但方法体里的逻辑,通常是放在栈中的,属于线程私有的,当它被一个对象重新调用时,通过对方法体的字节码修改,便能做到热更。这也可以看出,一个类的热更,是有诸多限制的,通过很多人已做的临床试验,如果一个类含有匿名内部类或lambda表达式,通常是不能热更的,进而导致这个类里的bug方法体也不能热更了,因此我们在写代码时一定要尽量避免写匿名内部类或lambda表达式。)(lambda表达式通常都带 -> 表示,另外有些匿名内部类在Windows的JVM中能热更,在linux上JVM却不能热更。)
1.利用JRE自带的jdwp agent
通常在远程游戏服的java的启动脚本里添上 -Xdebug -Xrunjdwp:transport=dt_socket,address=9990,server=y,suspend=n 就可以做热更。我们的eclipse热更也是这个原理。其中address=9990代表远程服务器的热更端口。然后在本地的开发工具如eclipse上,点击Debug Configurations,在里面的Remote Java Application选项中新建一个远程应用,点Browse选上对应的游戏服工程,然后填入远程服务器ip和上面启动项里的address端口,就可以连接远程游戏服修改方法体进行热更了,注意这个方法里的新逻辑,必须在重新被调用后才能生效。并且修改完后,需注意点击disconnect远程连接,即关闭远程连接,如果直接暴力叉掉,可能导致不能再次连上远程游戏服了。这在游戏服较少时可以这么做,如果游戏服较多,一个个连上去改那就麻烦死了。
另附上这种方式启动脚本里的各种参数意义:
2.利用Java agent 在启动时加载 instrument agent
Java SE 5时,java虚拟机提供了一套”代理”程序机制,可以支持第三方工具程序以代理的方式连接和访问 JVM,并利用 JVMTI (Java Virtual Machine Tool Interface)提供的丰富的编程接口,完成很多跟 JVM 相关的功能,比如类定义动态改变和操作。但在 Java SE 5 中,这种类定义动态改变和操作机制,Instrument 要求在运行前利用命令行参数或者系统参数来设置代理类,并编写一个Java类,在里面实现premain方法。
即在游戏服的启动参数里,需用
-javaagent:D:\Repositories\hotswap-agent\1.0\hotswap-agent-1.0.jar
指定代理的jar包,在这个jar包里实现premain(String agentArgs, Instrumentation inst)方法,并在这个premain方法里,实现我们的热更。当游戏服的main方法启动前,JVM会先进入这个代理包下的premain方法,因为它只在启动时进入,如果在游戏服长时间运行后,发现一个逻辑bug需要热更,这便需要一种方法能一直延续premain方法的“寿命”,使之在游戏服运行过程中,如有热更需要,便在此方法中执行。
因此,我们可以这样设计,在premain方法中,另开一个线程(或单线程池),这个线程每隔比如1s扫描某个文件夹,当文件夹中放入class文件时,便加载重定义这个class类,这个class文件,即我们的游戏逻辑bug类。
因此,我们的启动参数可添加如下:
-javaagent:D:\Repositories\hotswap-agent\1.0\hotswap-agent-1.0.jar="classPath=D:\svn-workspace\xykp-server\target\classes,interval=1000,logLevel=ALL"
实现premain的方法类:
public class HotSwapAgent {
private static final Logger log = Logger.getLogger(HotSwapAgent.class.getName());
private final Instrumentation instrumentation;
private final String classPath;
private int interval;
private Level logLevel;
public static void premain(String agentArgs, Instrumentation inst) {
init(agentArgs, inst);
}
private static void init(String agentArgs, Instrumentation inst) {
initArgs();//从上述的启动参数里,解析出classPath,interval,logLevel
new HotSwapAgent(inst);
}
public HotSwapAgent(Instrumentation inst) {
this.instrumentation = inst;
log.setUseParentHandlers(false);
log.setLevel(logLevel);
ConsoleHandler consoleHandler = new ConsoleHandler();
consoleHandler.setLevel(logLevel);
log.addHandler(consoleHandler);
HotSwapMonitor monitor = new HotSwapMonitor(this.instrumentation, this.classPath, this.interval);
monitor.start();
log.info("class path: " + this.classPath);
log.info("scan interval (ms): " + this.interval);
log.info("log level: " + this.logLevel);
}
}
HotSwapMonitor的实现为:
public class HotSwapMonitor implements Runnable {
private String classPath;
private Instrumentation instrumentation;
ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor();
private int interval;
private static final Logger logger = Logger.getLogger(HotSwapMonitor.class.getName());
public HotSwapMonitor(Instrumentation instrumentation, String classPath, int interval) {
this.instrumentation = instrumentation;
this.classPath = classPath;
this.interval = interval;
}
public void start() {
this.executor.scheduleAtFixedRate(this, 0L, (long) this.interval, TimeUnit.MILLISECONDS);
}
public void run() {
try {
this.scanClassFile();
} catch (Exception e) {
logger.log(Level.SEVERE, "error", e);
}
}
public void scanClassFile() throws Exception {
File path = new File(this.classPath);
File[] files = path.listFiles();
if (files != null) {
String classFilePath = null;
boolean success = false;
long now = System.currentTimeMillis();
File[] bakFiles = files;
int fileNum = files.length;
for (int i = 0; i < fileNum; ++i) {
File file = bakFiles[i];
if (this.isClassFile(file)) {
classFilePath = file.getPath();
this.reloadClass(classFilePath);
logger.fine(String.format("Reload %s success", classFilePath));
file.delete();
success = true;
}
}
if (success) {
logger.fine(String.format("Reload success, cost time:%sms", System.currentTimeMillis() - now));
}
}
}
private void reloadClass(String classFilePath) throws Exception {
File file = new File(classFilePath);
byte[] buff = new byte[(int) file.length()];//将class文件的二进制码读入
DataInputStream in = new DataInputStream(new FileInputStream(file));
in.readFully(buff);
in.close();
HotSwapClassLoader loader = new HotSwapClassLoader();//定义一个类加载器
Class> updateCalss = loader.findClass(buff);//找到该类
ClassDefinition definition = new ClassDefinition(Class.forName(updateCalss.getName()), buff);
this.instrumentation.redefineClasses(new ClassDefinition[]{definition});
}
private boolean isClassFile(File file) {
return file.getName().contains(".class");
}
}
在这里,定义了一个单线程池,每隔1000ms扫描启动参数里D:\svn-workspace\xykp-server\target\classes该文件夹(注意在linux下,即游戏服部署路径中的class文件夹),如果有热更的class文件,最终调用instrumentation.redefineClasses(new ClassDefinition[]{definition});实现类重定义。
我们知道由于类加载器的作用域,父加载器是看不到子加载器中的类的,而子加载器是能看到父加载器中类的,同级别的自定义类加载器是相互不可见的。在游戏服中,游戏逻辑类应该都是应用加载器(或系统加载器)加载的,在reloadClass(String classFilePath)方法中,我们定义了一个HotSwapClassLoader();那在这里如何让HotSwapClassLoader对应用加载器可见呢?
public class HotSwapClassLoader extends ClassLoader {
public HotSwapClassLoader() {
super(Thread.currentThread().getContextClassLoader());
}
public Class> findClass(byte[] b) throws ClassNotFoundException {
return this.defineClass((String) null, b, 0, b.length);//利用父类ClassLoader中defineClass方法找到(重定义)此类
}
}
在这里,只要用线程的上下文加载器记录游戏服启动时的加载器就可以了,此时的线程上下文加载器记录的是根加载器,即null,因为instrument位于rt.jar下,再用它打破类加载的双亲委派模型,最终由应用加载器再加载这个类。
3.利用Java agent 在运行时加载 instrument agent
上述方法中,有个前提,那就是必须在启动参数里添加代理热更包,但是有些游戏公司可能不知道此方法的,或者忘了在启动里添加此参数,这时又不想停服希望能热更的,Java SE 6便提供了此方法:在JVM启动后,仍可以引入代理类,实现热更。但在它的代理类中,需实现agentmain(String agentArgs, Instrumentation inst)方法。
这种热更的实现,主要是采用JVM的attach机制实现的,如下:
VirtualMachine vm = VirtualMachine.attach(pid);
vm.loadAgent(agentPath, agentArgs);
它的原理是利用进程间通信来做的,即在游戏服进程外,另起一个进程,即我们的热更进程,通过attach机制发送信号给游戏服,从而让游戏服加载热更代理包,实现热更。
因此,我们还要写个程序,去给游戏服发信号:
public class AgentMainTest {
public static void main(String[] args){
List listAfter = null;
try {
listAfter = VirtualMachine.list();
for (VirtualMachineDescriptor vmd : listAfter) { //取出物理机上的所有游戏服
System.out.println( "displayName:" + vmd.displayName());
System.out.println( "toString:" + vmd.toString());
if (!vmd.displayName().contains("xykp.gameserver")) { //游戏服进程标记
continue;
}
VirtualMachine vm = VirtualMachine.attach(vmd);
vm.loadAgent("D:\\Repositories\\hotswap-agent\\1.0\\hotswap-agent-1.0.jar", "classPath=D:\\svn-workspace\\xykp\\target\\classes,interval=1000,logLevel=FINE");//热更包,及参数
vm.detach();
}
} catch (Exception e) {
e.printStackTrace();
}
}
}
然后在游戏服执行此程序,遍历该物理机上的所有游戏服,发信号通知它们加载热更代理包,然后进入代理包中的agentmain方法,实现热更。
public static void premain(String agentArgs, Instrumentation inst) {
init(agentArgs, inst);
}
public static void agentmain(String agentArgs, Instrumentation inst) {
init(agentArgs, inst);
}
此后,如果想方便后续的热更,所有热更逻辑与2中方法一样。
在2和3方法中,我们都提到了一个代理热更包hotswap-agent-1.0.jar,这个包即是将2中的HotSwapAgent、HotSwapMonitor、HotSwapClassLoader类打成hotswap-agent-1.0.jar包的,包名可随意取,打包方法根据是否maven项目或其他类型项目而不同,但是还有一个重要文件也要打进去,那就是MANNIFEST.MF文件,它相当于一个配置文件,用于指引Instrument采用何种方式热更。
Manifest-Version: 1.0
Ant-Version: Apache Ant 1.9.6
Created-By: 1.8.0_131-b11 (Oracle Corporation)
Agent-Class: xykp.HotSwapAgent
Premain-Class: xykp.HotSwapAgent
Can-Redefine-Classes: true
此外,这两种方式,不仅能热更已加载的类,还能加载新的类,因为新的类是第一次加载,那么我们在新类中就可以做很多事情了。
比如2和3方法中,通常只能热更方法体,其实我们还可以加入private static 方法的,而public static或其他非static方法是不能添加的,删除方法也只限于private static方法,但是如果我们引入新类,在新类中,我们是可以添加任何方法的。
public class HeroHandler2 {
public static final int a = 55;
public Map map = new ConcurrentHashMap();
static{
System.out.println("HeroHandler2 loaded~~~~~~~~~~~~~~~, a: " + a);
RoleRepository repository = SpringProxy.getBean(RoleRepository.class);
Role role = repository.get(1001L);
role.setNick("Steven");
repository.save(role);
}
public void add(){
System.out.println("HeroHandler2 add() called~~~~~~~~~~~~~~~~~");
}
private static void add2(){
System.out.println("HeroHandler2 add2() called~~~~~~~~~~~~~~~~~");
}
public static void add3(){
System.out.println("HeroHandler2 add3() called~~~~~~~~~~~~~~~~~");
}
}
在游戏服的其他逻辑里,我们就可以调用这个新类的方法了,以此换种方式调用非static方法及增加其它Field域:
@HandlerMethod
public void toggle(PlayerSession session, HeroToggleLockReq_1241026 req) {
heroService.toggleHeroLock(session.getIdentity(), req.getHeroId());
HeroHandler2 handler2 = new HeroHandler2();
handler2.add();
HeroHandler2.add3();
}
另外,当这个类第一次加载时,是会执行static代码块里的代码的,在这个static代码中,我们甚至可以添加修复玩家内存数据逻辑,此为修复玩家内存数据的一种方式。另一种方式可以用groovy修改,将在后续博文介绍。
但是,需要注意的一点是,第2第3种方式新增新类时,新类的class文件虽然放到指定的扫描目录下了,但是它在项目结构的源目录中,还必须存在一份class文件,否则加载时会报classNotFoundException错误,因此如何把新类的class文件放到源目录下成为能否新增新类的关键。有的线上项目是打成jar包的,这种热更 新增 新类的方式可能不能实现,因为需要把这个class文件放入已打成jar包的相应目录下,而这个jar包又是放在远程linux服务器上且运行着的。有的项目不是打成jar包的才能这么做,从而增加新类。通常我们热更只需热更方法体足矣,这样便能修复线上大部分bug。
还有,线上项目的日志系统通常都是用log4j或slf4j的,而第2种方式是在main函数前启动的,此时日志系统都还没加载,因此这个热更包采用了JDK自带的java.util.logging日志系统,但是这样logging打印的日志就不会出现在log4j日志文件中了,导致文件是否加载成功只能通过class文件是否被删除来判断(在该篇文章的代码逻辑中),为什么没有热更成功,却无从得知了,因此我们需要一种方式使logging打印的日志也被记录下来或打印在log4j或slf4j日志文件中,从而方便我们查看为什么没有热更成功。可以这样加个新类:
public class LoggerPrint {
private static FileHandler fileHandler;
static{
try {
String path = LoggerPrint.class.getProtectionDomain().getCodeSource().getLocation().getPath();
File file = new File(path);
fileHandler = new FileHandler(file.getParentFile().getParentFile().getPath()+"/hotswap.log");//日志文件名及存放路径,如果路径中包含文件夹,要确保文件夹存在,否则会导致游戏启动不起来
fileHandler.setFormatter(new SimpleFormatter());//logging默认为xml格式的日志,不方便查阅,因此用我们常见的日志格式输出
} catch (SecurityException | IOException e) {
e.printStackTrace();
}
}
public static FileHandler getFileHandler() {
return fileHandler;
}
}
在HotSwapAgent或HotSwapMonitor中,只需添加一个静态代码块即可打印日志了:
static{
FileHandler fileHandler = LoggerPrint.getFileHandler();
logger.addHandler(fileHandler);
}
至于第2第3种方式更多原理及调用流程问题,请参考阿里大神李嘉鹏的博文,墙裂推荐:
JVM 源码分析之 javaagent 原理完全解读
JVM Attach机制实现
在这文章中,指出了热更的限制条件:
对比新老类,并要求如下:
父类是同一个
实现的接口数也要相同,并且是相同的接口
类访问符必须一致
字段数和字段名要一致
新增的方法必须是 private static/final 的
可以删除修改方法