• 虚幻4蓝图虚拟机剖析


    前言

    这里,我们打算对虚幻4 中蓝图虚拟机的实现做一个大概的讲解,如果对其它的脚本语言的实现有比较清楚的认识,理解起来会容易很多,我们先会对相关术语进行一个简单的介绍,然后会对蓝图虚拟机的实现做一个讲解。

    术语

    编程语言一般分为编译语言和解释型语言。

    编译型语言

    程序在执行之前需要一个专门的编译过程,把程序编译成 为机器语言的文件,运行时不需要重新翻译,直接使用编译的结果就行了。程序执行效率高,依赖编译器,跨平台性差些。如C、C++、Delphi等.

    解释性语言

    编写的程序不进行预先编译,以文本方式存储程序代码。在发布程序时,看起来省了道编译工序。但是,在运行程序的时候,解释性语言必须先解释再运行。

    然而关于Java、C#等是否为解释型语言存在争议,因为它们主流的实现并不是直接解释执行的,而是也编译成字节码,然后再运行在jvm等虚拟机上的。

    UE4中蓝图的实现更像是lua的实现方式,它并不能独立运行,而是作为一种嵌入宿主语言的一种扩展脚本,lua可以直接解释执行,也可以编译成字节码并保存到磁盘上,下次调用可以直接加载编译好的字节码执行。

    什么是虚拟机

    虚拟机最初由波佩克[a]与戈德堡定义为有效的、独立的真实机器的副本。当前包括跟任何真实机器无关的虚拟机。虚拟机根据它们的运用和与直接机器的相关性分为两大类。系统虚拟机(如VirtualBox)提供一个可以运行完整操作系统的完整系统平台。相反的,程序虚拟机(如Java JVM)为运行单个计算机程序设计,这意謂它支持单个进程。虚拟机的一个本质特点是运行在虚拟机上的软件被局限在虚拟机提供的资源里——它不能超出虚拟世界。

    而这里我们主要关心的是程序虚拟机,VM既然被称为"机器",一般认为输入是满足某种指令集架构(instruction set architecture,ISA)的指令序列,中间转换为目标ISA的指令序列并加以执行,输出为程序的执行结果的,就是VM。源与目标ISA可以是同一种,这是所谓same-ISA VM。

    分类

    虚拟机实现分为基于寄存器的虚拟机和基于栈的虚拟机。

    三地址指令

    a = b + c;

    如果把它变成这种形式:

    add a, b, c

    那看起来就更像机器指令了,对吧?这种就是所谓"三地址指令"(3-address instruction),一般形式为:

    op dest, src1, src2

    许多操作都是二元运算+赋值。三地址指令正好可以指定两个源和一个目标,能非常灵活的支持二元操作与赋值的组合。ARM处理器的主要指令集就是三地址形式的。

    二地址指令

    a += b;

    变成:

    add a, b

    这就是所谓"二地址指令",一般形式为:

    op dest, src

    它要支持二元操作,就只能把其中一个源同时也作为目标。上面的add a, b在执行过后,就会破坏a原有的值,而b的值保持不变。x86系列的处理器就是二地址形式的。

    一地址指令

    显然,指令集可以是任意"n地址"的,n属于自然数。那么一地址形式的指令集是怎样的呢?

    想像一下这样一组指令序列:

    add 5

    sub 3

    这只指定了操作的源,那目标是什么?一般来说,这种运算的目标是被称为"累加器"(accumulator)的专用寄存器,所有运算都靠更新累加器的状态来完成。那么上面两条指令用C来写就类似:

    C代码 收藏代码

    acc += 5;

    acc -= 3;

    只不过acc是"隐藏"的目标。基于累加器的架构近来比较少见了,在很老的机器上繁荣过一段时间。

    零地址指令

    那"n地址"的n如果是0的话呢?

    看这样一段Java字节码:

    Java bytecode代码 收藏代码

    iconst_1

    iconst_2

    iadd

    istore_0

    注意那个iadd(表示整型加法)指令并没有任何参数。连源都无法指定了,零地址指令有什么用??

    零地址意味着源与目标都是隐含参数,其实现依赖于一种常见的数据结构——没错,就是栈。上面的iconst_1、iconst_2两条指令,分别向一个叫做"求值栈"(evaluation stack,也叫做operand stack"操作数栈"或者expression stack"表达式栈")的地方压入整型常量1、2。iadd指令则从求值栈顶弹出2个值,将值相加,然后把结果压回到栈顶。istore_0指令从求值栈顶弹出一个值,并将值保存到局部变量区的第一个位置(slot 0)。

    零地址形式的指令集一般就是通过"基于栈的架构"来实现的。请一定要注意,这个栈是指"求值栈",而不是与系统调用栈(system call stack,或者就叫system stack)。千万别弄混了。有些虚拟机把求值栈实现在系统调用栈上,但两者概念上不是一个东西。

    由于指令的源与目标都是隐含的,零地址指令的"密度"可以非常高——可以用更少空间放下更多条指令。因此在空间紧缺的环境中,零地址指令是种可取的设计。但零地址指令要完成一件事情,一般会比二地址或者三地址指令许多更多条指令。上面Java字节码做的加法,如果用x86指令两条就能完成了:

    mov eax, 1

    add eax, 2

    基于栈与基于寄存器结构的区别

    1. 保存临时值的位置不同
    • 基于栈:将临时值保存在求值栈上。
    • 基于寄存器:将临时值保存在寄存器中。
    1. 代码所占体积不同
    • 基于栈:代码紧凑,体积小,但所需要的代码条件多
    • 基于寄存器:代码相对大些,但所需要的代码条件少

    基于栈中的"栈"指的是"求值栈",JVM中"求值栈"被称为"操作数栈"。

    栈帧

    栈帧也叫过程活动记录,是编译器用来实现过程/函数调用的一种数据结构。从逻辑上讲,栈帧就是一个函数执行的环境:函数参数、函数的局部变量、函数执行完后返回到哪里等等。

    蓝图虚拟机的实现

    前面我们已经简单得介绍了虚拟机的相关术语,接下来我们来具体讲解下虚幻4中蓝图虚拟机的实现。

    字节码

    虚拟机的字节码在Script.h文件中,这里我们把它全部列出来,因为是专用的脚本语言,所以它里面会有一些特殊的字节码,如代理相关的代码(EX_BindDelegate、EX_AddMulticastDelegate),当然常用的语句也是有的,比如赋值、无条件跳转指令、条件跳转指令、switch等。

      1 //
      2 
      3 // Evaluatable expression item types.
      4 
      5 //
      6 
      7 enum EExprToken
      8 
      9 {
     10 
     11     // Variable references.
     12 
     13     EX_LocalVariable        = 0x00,    // A local variable.
     14 
     15     EX_InstanceVariable        = 0x01,    // An object variable.
     16 
     17     EX_DefaultVariable        = 0x02, // Default variable for a class context.
     18 
     19     //                        = 0x03,
     20 
     21     EX_Return                = 0x04,    // Return from function.
     22 
     23     //                        = 0x05,
     24 
     25     EX_Jump                    = 0x06,    // Goto a local address in code.
     26 
     27     EX_JumpIfNot            = 0x07,    // Goto if not expression.
     28 
     29     //                        = 0x08,
     30 
     31     EX_Assert                = 0x09,    // Assertion.
     32 
     33     //                        = 0x0A,
     34 
     35     EX_Nothing                = 0x0B,    // No operation.
     36 
     37     //                        = 0x0C,
     38 
     39     //                        = 0x0D,
     40 
     41     //                        = 0x0E,
     42 
     43     EX_Let                    = 0x0F,    // Assign an arbitrary size value to a variable.
     44 
     45     //                        = 0x10,
     46 
     47     //                        = 0x11,
     48 
     49     EX_ClassContext            = 0x12,    // Class default object context.
     50 
     51     EX_MetaCast = 0x13, // Metaclass cast.
     52 
     53     EX_LetBool                = 0x14, // Let boolean variable.
     54 
     55     EX_EndParmValue            = 0x15,    // end of default value for optional function parameter
     56 
     57     EX_EndFunctionParms        = 0x16,    // End of function call parameters.
     58 
     59     EX_Self                    = 0x17,    // Self object.
     60 
     61     EX_Skip                    = 0x18,    // Skippable expression.
     62 
     63     EX_Context                = 0x19,    // Call a function through an object context.
     64 
     65     EX_Context_FailSilent    = 0x1A, // Call a function through an object context (can fail silently if the context is NULL; only generated for functions that don't have output or return values).
     66 
     67     EX_VirtualFunction        = 0x1B,    // A function call with parameters.
     68 
     69     EX_FinalFunction        = 0x1C,    // A prebound function call with parameters.
     70 
     71     EX_IntConst                = 0x1D,    // Int constant.
     72 
     73     EX_FloatConst            = 0x1E,    // Floating point constant.
     74 
     75     EX_StringConst            = 0x1F,    // String constant.
     76 
     77     EX_ObjectConst         = 0x20,    // An object constant.
     78 
     79     EX_NameConst            = 0x21,    // A name constant.
     80 
     81     EX_RotationConst        = 0x22,    // A rotation constant.
     82 
     83     EX_VectorConst            = 0x23,    // A vector constant.
     84 
     85     EX_ByteConst            = 0x24,    // A byte constant.
     86 
     87     EX_IntZero                = 0x25,    // Zero.
     88 
     89     EX_IntOne                = 0x26,    // One.
     90 
     91     EX_True                    = 0x27,    // Bool True.
     92 
     93     EX_False                = 0x28,    // Bool False.
     94 
     95     EX_TextConst            = 0x29, // FText constant
     96 
     97     EX_NoObject                = 0x2A,    // NoObject.
     98 
     99     EX_TransformConst        = 0x2B, // A transform constant
    100 
    101     EX_IntConstByte            = 0x2C,    // Int constant that requires 1 byte.
    102 
    103     EX_NoInterface            = 0x2D, // A null interface (similar to EX_NoObject, but for interfaces)
    104 
    105     EX_DynamicCast            = 0x2E,    // Safe dynamic class casting.
    106 
    107     EX_StructConst            = 0x2F, // An arbitrary UStruct constant
    108 
    109     EX_EndStructConst        = 0x30, // End of UStruct constant
    110 
    111     EX_SetArray                = 0x31, // Set the value of arbitrary array
    112 
    113     EX_EndArray                = 0x32,
    114 
    115     //                        = 0x33,
    116 
    117     EX_UnicodeStringConst = 0x34, // Unicode string constant.
    118 
    119     EX_Int64Const            = 0x35,    // 64-bit integer constant.
    120 
    121     EX_UInt64Const            = 0x36,    // 64-bit unsigned integer constant.
    122 
    123     //                        = 0x37,
    124 
    125     EX_PrimitiveCast        = 0x38,    // A casting operator for primitives which reads the type as the subsequent byte
    126 
    127     //                        = 0x39,
    128 
    129     //                        = 0x3A,
    130 
    131     //                        = 0x3B,
    132 
    133     //                        = 0x3C,
    134 
    135     //                        = 0x3D,
    136 
    137     //                        = 0x3E,
    138 
    139     //                        = 0x3F,
    140 
    141     //                        = 0x40,
    142 
    143     //                        = 0x41,
    144 
    145     EX_StructMemberContext    = 0x42, // Context expression to address a property within a struct
    146 
    147     EX_LetMulticastDelegate    = 0x43, // Assignment to a multi-cast delegate
    148 
    149     EX_LetDelegate            = 0x44, // Assignment to a delegate
    150 
    151     //                        = 0x45,
    152 
    153     //                        = 0x46, // CST_ObjectToInterface
    154 
    155     //                        = 0x47, // CST_ObjectToBool
    156 
    157     EX_LocalOutVariable        = 0x48, // local out (pass by reference) function parameter
    158 
    159     //                        = 0x49, // CST_InterfaceToBool
    160 
    161     EX_DeprecatedOp4A        = 0x4A,
    162 
    163     EX_InstanceDelegate        = 0x4B,    // const reference to a delegate or normal function object
    164 
    165     EX_PushExecutionFlow    = 0x4C, // push an address on to the execution flow stack for future execution when a EX_PopExecutionFlow is executed. Execution continues on normally and doesn't change to the pushed address.
    166 
    167     EX_PopExecutionFlow        = 0x4D, // continue execution at the last address previously pushed onto the execution flow stack.
    168 
    169     EX_ComputedJump            = 0x4E,    // Goto a local address in code, specified by an integer value.
    170 
    171     EX_PopExecutionFlowIfNot = 0x4F, // continue execution at the last address previously pushed onto the execution flow stack, if the condition is not true.
    172 
    173     EX_Breakpoint            = 0x50, // Breakpoint. Only observed in the editor, otherwise it behaves like EX_Nothing.
    174 
    175     EX_InterfaceContext        = 0x51,    // Call a function through a native interface variable
    176 
    177     EX_ObjToInterfaceCast = 0x52,    // Converting an object reference to native interface variable
    178 
    179     EX_EndOfScript            = 0x53, // Last byte in script code
    180 
    181     EX_CrossInterfaceCast    = 0x54, // Converting an interface variable reference to native interface variable
    182 
    183     EX_InterfaceToObjCast = 0x55, // Converting an interface variable reference to an object
    184 
    185     //                        = 0x56,
    186 
    187     //                        = 0x57,
    188 
    189     //                        = 0x58,
    190 
    191     //                        = 0x59,
    192 
    193     EX_WireTracepoint        = 0x5A, // Trace point. Only observed in the editor, otherwise it behaves like EX_Nothing.
    194 
    195     EX_SkipOffsetConst        = 0x5B, // A CodeSizeSkipOffset constant
    196 
    197     EX_AddMulticastDelegate = 0x5C, // Adds a delegate to a multicast delegate's targets
    198 
    199     EX_ClearMulticastDelegate = 0x5D, // Clears all delegates in a multicast target
    200 
    201     EX_Tracepoint            = 0x5E, // Trace point. Only observed in the editor, otherwise it behaves like EX_Nothing.
    202 
    203     EX_LetObj                = 0x5F,    // assign to any object ref pointer
    204 
    205     EX_LetWeakObjPtr        = 0x60, // assign to a weak object pointer
    206 
    207     EX_BindDelegate            = 0x61, // bind object and name to delegate
    208 
    209     EX_RemoveMulticastDelegate = 0x62, // Remove a delegate from a multicast delegate's targets
    210 
    211     EX_CallMulticastDelegate = 0x63, // Call multicast delegate
    212 
    213     EX_LetValueOnPersistentFrame = 0x64,
    214 
    215     EX_ArrayConst            = 0x65,
    216 
    217     EX_EndArrayConst        = 0x66,
    218 
    219     EX_AssetConst            = 0x67,
    220 
    221     EX_CallMath                = 0x68, // static pure function from on local call space
    222 
    223     EX_SwitchValue            = 0x69,
    224 
    225     EX_InstrumentationEvent    = 0x6A, // Instrumentation event
    226 
    227     EX_ArrayGetByRef        = 0x6B,
    228 
    229     EX_Max                    = 0x100,
    230 
    231 };

    栈帧

    在Stack.h中我们可以找到FFrame的定义,虽然它定义的是一个结构体,但是执行当前代码的逻辑是封装在这里面的。下面让我们看一下它的数据成员:

     1   // Variables.
     2 
     3     UFunction* Node;
     4 
     5     UObject* Object;
     6 
     7     uint8* Code;
     8 
     9     uint8* Locals;
    10 
    11  
    12 
    13     UProperty* MostRecentProperty;
    14 
    15     uint8* MostRecentPropertyAddress;
    16 
    17  
    18 
    19     /** The execution flow stack for compiled Kismet code */
    20 
    21     FlowStackType FlowStack;
    22 
    23  
    24 
    25     /** Previous frame on the stack */
    26 
    27     FFrame* PreviousFrame;
    28 
    29  
    30 
    31     /** contains information on any out parameters */
    32 
    33     FOutParmRec* OutParms;
    34 
    35  
    36 
    37     /** If a class is compiled in then this is set to the property chain for compiled-in functions. In that case, we follow the links to setup the args instead of executing by code. */
    38 
    39     UField* PropertyChainForCompiledIn;
    40 
    41  
    42 
    43     /** Currently executed native function */
    44 
    45     UFunction* CurrentNativeFunction;
    46 
    47  
    48 
    49     bool bArrayContextFailed;

    我们可以看到,它里面保存了当前执行的脚本函数,执行该脚本的UObject,当前代码的执行位置,局部变量,上一个栈帧,调用返回的参数(不是返回值),当前执行的原生函数等。而调用函数的返回值是放在了函数调用之前保存,调用结束后再恢复。大致如下所示:

    1 uint8 * SaveCode = Stack.Code;
    2 
    3 // Call function
    4 
    5 ….
    6 
    7 Stack.Code = SaveCode

    下面我们列出FFrame中跟执行相关的重要函数:

      1     // Functions.
      2 
      3     COREUOBJECT_API void Step( UObject* Context, RESULT_DECL );
      4 
      5  
      6 
      7     /** Replacement for Step that uses an explicitly specified property to unpack arguments **/
      8 
      9     COREUOBJECT_API void StepExplicitProperty(void*const Result, UProperty* Property);
     10 
     11  
     12 
     13     /** Replacement for Step that checks the for byte code, and if none exists, then PropertyChainForCompiledIn is used. Also, makes an effort to verify that the params are in the correct order and the types are compatible. **/
     14 
     15     template<class TProperty>
     16 
     17     FORCEINLINE_DEBUGGABLE void StepCompiledIn(void*const Result);
     18 
     19  
     20 
     21     /** Replacement for Step that checks the for byte code, and if none exists, then PropertyChainForCompiledIn is used. Also, makes an effort to verify that the params are in the correct order and the types are compatible. **/
     22 
     23     template<class TProperty, typename TNativeType>
     24 
     25     FORCEINLINE_DEBUGGABLE TNativeType& StepCompiledInRef(void*const TemporaryBuffer);
     26 
     27  
     28 
     29     COREUOBJECT_API virtual void Serialize( const TCHAR* V, ELogVerbosity::Type Verbosity, const class FName& Category ) override;
     30 
     31     
     32 
     33     COREUOBJECT_API static void KismetExecutionMessage(const TCHAR* Message, ELogVerbosity::Type Verbosity, FName WarningId = FName());
     34 
     35  
     36 
     37     /** Returns the current script op code */
     38 
     39     const uint8 PeekCode() const { return *Code; }
     40 
     41  
     42 
     43     /** Skips over the number of op codes specified by NumOps */
     44 
     45     void SkipCode(const int32 NumOps) { Code += NumOps; }
     46 
     47  
     48 
     49     template<typename TNumericType>
     50 
     51     TNumericType ReadInt();
     52 
     53     float ReadFloat();
     54 
     55     FName ReadName();
     56 
     57     UObject* ReadObject();
     58 
     59     int32 ReadWord();
     60 
     61     UProperty* ReadProperty();
     62 
     63  
     64 
     65     /** May return null */
     66 
     67     UProperty* ReadPropertyUnchecked();
     68 
     69  
     70 
     71     /**
     72 
     73      * Reads a value from the bytestream, which represents the number of bytes to advance
     74 
     75      * the code pointer for certain expressions.
     76 
     77      *
     78 
     79      * @param    ExpressionField        receives a pointer to the field representing the expression; used by various execs
     80 
     81      *                                to drive VM logic
     82 
     83      */
     84 
     85     CodeSkipSizeType ReadCodeSkipCount();
     86 
     87  
     88 
     89     /**
     90 
     91      * Reads a value from the bytestream which represents the number of bytes that should be zero'd out if a NULL context
     92 
     93      * is encountered
     94 
     95      *
     96 
     97      * @param    ExpressionField        receives a pointer to the field representing the expression; used by various execs
     98 
     99      *                                to drive VM logic
    100 
    101      */
    102 
    103     VariableSizeType ReadVariableSize(UProperty** ExpressionField);

    像ReadInt()、ReadFloat()、ReadObject()等这些函数,我们看到它的名字就知道它是做什么的,就是从代码中读取相应的int、float、UObject等。这里我们主要说下Step()函数,它的代码如下所示:

    1 void FFrame::Step(UObject *Context, RESULT_DECL)
    2 
    3 {
    4 
    5     int32 B = *Code++;
    6 
    7     (Context->*GNatives[B])(*this,RESULT_PARAM);
    8 
    9 }

    可以看到,它的主要作用就是取出指令,然后在原生函数数组中找到对应的函数去执行。

    字节码对应函数

    前面我们列出了所有的虚拟机的所有字节码,那么对应每个字节码具体执行部分的代码在哪里呢,具体可以到ScriptCore.cpp中查找定义,我们可以看到每个字节码对应的原生函数都在GNatives和GCasts里面:

    它们的声明如下:

    1 /** The type of a native function callable by script */
    2 
    3 typedef void (UObject::*Native)( FFrame& TheStack, RESULT_DECL );
    4 
    5 Native GCasts[];
    6 
    7 Native GNatives[EX_Max];

    这样它都会对每一个原生函数调用一下注册方法,通过IMPLEMENT_VM_FUNCTION和IMPLEMENT_CAST_FUNCTION宏实现。

    具体代码如下图所示:

     1 #define IMPLEMENT_FUNCTION(cls,func) 
     2 
     3     static FNativeFunctionRegistrar cls##func##Registar(cls::StaticClass(),#func,(Native)&cls::func);
     4 
     5  
     6 
     7 #define IMPLEMENT_CAST_FUNCTION(cls, CastIndex, func) 
     8 
     9     IMPLEMENT_FUNCTION(cls, func); 
    10 
    11     static uint8 cls##func##CastTemp = GRegisterCast( CastIndex, (Native)&cls::func );
    12 
    13  
    14 
    15 #define IMPLEMENT_VM_FUNCTION(BytecodeIndex, func) 
    16 
    17     IMPLEMENT_FUNCTION(UObject, func) 
    18 
    19     static uint8 UObject##func##BytecodeTemp = GRegisterNative( BytecodeIndex, (Native)&UObject::func );

    可以看到,它是定义了一个全局静态对象,这样就会在程序的main函数执行前就已经把函数放在数组中对应的位置了,这样在虚拟机执行时就可以直接调用到对应的原生函数了。

    执行流程

    我们前面讲蓝图的时候讲过蓝图如何跟C++交互,包括蓝图调用C++代码,以及从C++代码调用到蓝图里面去。

    C++调用蓝图函数

     1 UFUNCTION(BlueprintImplementableEvent, Category = "AReflectionStudyGameMode")
     2 
     3 void ImplementableFuncTest();
     4 
     5  
     6 
     7 void AReflectionStudyGameMode::ImplementableFuncTest()
     8 
     9 {
    10 
    11 ProcessEvent(FindFunctionChecked(REFLECTIONSTUDY_ImplementableFuncTest),NULL);
    12 
    13 }

    因为我们这个函数没有参数,所有ProcessEvent中传了一个NULL,如果是有参数和返回值等,那么UHT会自动生成一个结构体用于存储参数和返回值等,这样当在C++里面调用函数时,就会去找REFLECTIONSTUDY_ImplementableFuncTest这个名字对应的蓝图UFunction,如果找到那么就会调用ProcessEvent来做进一步的处理。

    ProcessEvent流程

    蓝图调用C++函数

     1 UFUNCTION(BlueprintCallable, Category = "AReflectionStudyGameMode")
     2 
     3 void CallableFuncTest();
     4 
     5  
     6 
     7 DECLARE_FUNCTION(execCallableFuncTest) 
     8 
     9 { 
    10 
    11 P_FINISH; 
    12 
    13 P_NATIVE_BEGIN; 
    14 
    15 this->CallableFuncTest(); 
    16 
    17 P_NATIVE_END; 
    18 
    19 }

    如果是通过蓝图调用的C++函数,那么UHT会生成如上的代码,并且如果有参数的话,会调用P_GET_UBOOL等来获取对应的参数,如果有返回值的话也会将返回值赋值。

    总结

    至此,加上前面我们对蓝图编译的剖析,加上蓝图虚拟机的讲解,我们已经对蓝图的实现原理有一个比较深入的了解,本文并没有对蓝图的前身unrealscript进行详细的讲解。有了这个比较深入的认识后(如果想要有深刻的认识,必须自己去看代码),相信大家在设计蓝图时会更游刃有余。当然如果有错误的地方也请大家指正,欢迎大家踊跃讨论。接下来可能会把重心放到虚幻4渲染相关的模块上,包括渲染API跨平台相关,多线程渲染,渲染流程,以及渲染算法上面,可能中间也会穿插一些其他的模块(比如动画、AI等),欢迎大家持续关注,如果你有想提前了解的章节,也欢迎在下面留言,我可能会根据大家的留言来做优先级调整。

    参考文章

    1. https://www.usenix.org/legacy/events/vee05/full_papers/p153-yunhe.pdf
    2. http://rednaxelafx.iteye.com/blog/492667
    3. http://www.zhihu.com/question/19608553
    4. https://zh.wikipedia.org/wiki/%E8%99%9B%E6%93%AC%E6%A9%9F%E5%99%A8
    5. Java Program in Action 莫枢
  • 相关阅读:
    解决Maven下载依赖慢
    Spring Boot系列教程六:日志输出配置log4j2
    Spring Boot系列教程三:使用devtools实现热部署
    Spring Boot系列教程五:使用properties配置文件实现多环境配置
    Spring Boot系列教程四:配置文件详解properties
    Spring Boot系列教程二:创建第一个web工程 hello world
    Spring Boot系列教程一:Eclipse安装spring-tool-suite插件
    Spring Boot系列教程十:Spring boot集成MyBatis
    vim入门一 常用指令
    Linux IO多路复用之epoll网络编程(含源码)
  • 原文地址:https://www.cnblogs.com/ghl_carmack/p/6060383.html
Copyright © 2020-2023  润新知