Java游戏服热更新

作者: 小圣996 | 来源:发表于2020-04-27 12:40 被阅读0次

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

    有时候,游戏服线上出了逻辑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<VirtualMachineDescriptor> 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<Integer, Integer> map = new ConcurrentHashMap<Integer, Integer>();
        
        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<Long> 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游戏服热更新

        本文链接:https://www.haomeiwen.com/subject/mxsjwhtx.html