Java游戏服热更新

荣耀存于心,而非留于形。 --亚索

有时候,游戏服线上出了逻辑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


java agent.png

其中 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远程连接,即关闭远程连接,如果直接暴力叉掉,可能导致不能再次连上远程游戏服了。这在游戏服较少时可以这么做,如果游戏服较多,一个个连上去改那就麻烦死了。

远程debug游戏服.png

另附上这种方式启动脚本里的各种参数意义:
java agentlib各参数意义.png

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 的
可以删除修改方法

你可能感兴趣的:(Java游戏服热更新)