大家好,又见面了,我是你们的朋友全栈君。
图1 dex
当然也可以通过下面的图12 DexFile的文件格式,了解更清楚。
dex文件是Android系统中的一种文件,是一种特殊的数据格式,和APK、jar 等格式文件类似。 能够被DVM识别,加载并执行的文件格式。 简单说就是优化后的android版.exe。每个apk安装包里都有。包含应用程序的全部操作指令以及运行时数据。 相对于PC上的java虚拟机能运行.class;android上的Davlik虚拟机能运行.dex。
当java程序编译成class后,还需要使用dx工具将所有的class文件整合到一个dex文件,目的是其中各个类能够共享数据,在一定程度上降低了冗余,同时也是文件结构更加经凑,实验表明,dex文件是传统jar文件大小的50%左右
图2 apk中的dex文件
为何要研究dex格式?因为dex里面包含了所有app代码,利用反编译工具可以获取java源码。理解并修改dex文件,就能更好的apk破解和防破解。
使用dex文件的最大目的是实现安全管理,但在追求安全的前提下,一定要注意对dex文件实现优化处理。
注意:并不是只有Java才可以生成dex文件,C和C++也可以生成dex文件
dx --dex --output TestMain.dex TestMain.class
,就会生成TestMain.dex文件。adb push TestMain.dex /storage/emulated/0
命令,然后通过adb shell
命令进入手机,后执行dalvikvm -cp /sdcard/TestMain.dex TestMain
,就会打印出Hello World!
如下图3所示(使用AS的终端,没有用Windows的cmd命令)
图3 手动运行dex文件
注意:
图4 dex文件概貌
通过010Editor工具(图片来自网络) 大图这里
图5 注:图片来自网络
下图6是TestMain.dex通过010Editor工具得到的 大图这里
图6 010Editor 检测TestMain.dex结果
图7是通过010Editor工具检测TestMain.dex得到的 Template Result结果 大图这里
图7 010Editor检测TemplateResult结果
通过dexdump 命令查看(注意)
利用build-tools 下的dexdump 命令查看,dexdump -d -l plain TestMain.dex
,得到下面的结果
F:\>dexdump -d -l plain TestMain.dex
Processing 'TestMain.dex'...
Opened 'TestMain.dex', DEX version '035'
Class #0 -
Class descriptor : 'LTestMain;'
Access flags : 0x0001 (PUBLIC)
Superclass : 'Ljava/lang/Object;'
Interfaces -
Static fields -
Instance fields -
#0 : (in LTestMain;)
name : 'mX'
type : 'I'
access : 0x0001 (PUBLIC)
Direct methods -
#0 : (in LTestMain;)
name : '<init>'
type : '()V'
access : 0x10001 (PUBLIC CONSTRUCTOR)
code -
registers : 2
ins : 1
outs : 1
insns size : 7 16-bit code units
00015c: |[00015c] TestMain.<init>:()V
00016c: 7010 0400 0100 |0000: invoke-direct {v1}, Ljava/lang/Object;.<init>:()V // method@0004
000172: 1200 |0003: const/4 v0, #int 0 // #0
000174: 5910 0000 |0004: iput v0, v1, LTestMain;.mX:I // field@0000
000178: 0e00 |0006: return-void catches : (none)
positions :
0x0000 line=11
0x0003 line=3
0x0006 line=12
locals :
0x0000 - 0x0007 reg=1 this LTestMain;
#1 : (in LTestMain;)
name : 'main'
type : '([Ljava/lang/String;)V'
access : 0x0009 (PUBLIC STATIC)
code -
registers : 4
ins : 1
outs : 2
insns size : 16 16-bit code units
00017c: |[00017c] TestMain.main:([Ljava/lang/String;)V
00018c: 2200 0100 |0000: new-instance v0, LTestMain; // type@0001
000190: 7010 0000 0000 |0002: invoke-direct {v0}, LTestMain;.<init>:()V // method@0000
000196: 6e10 0200 0000 |0005: invoke-virtual {v0}, LTestMain;.test:()V // method@0002
00019c: 6201 0100 |0008: sget-object v1, Ljava/lang/System;.out:Ljava/io/PrintStream; // field@0001
0001a0: 1a02 0100 |000a: const-string v2, "Hello World!" // string@0001
0001a4: 6e20 0300 2100 |000c: invoke-virtual {v1, v2}, Ljava/io/PrintStream;.println:(Ljava/lang/String;)V // method@0003
0001aa: 0e00 |000f: return-void catches : (none)
positions :
0x0000 line=6
0x0005 line=7
0x0008 line=8
0x000f line=9
locals :
0x0005 - 0x0010 reg=0 testMainObject LTestMain;
0x0000 - 0x0010 reg=3 args [Ljava/lang/String;
Virtual methods -
#0 : (in LTestMain;)
name : 'test'
type : '()V'
access : 0x0001 (PUBLIC)
code -
registers : 1
ins : 1
outs : 0
insns size : 1 16-bit code units
0001ac: |[0001ac] TestMain.test:()V
0001bc: 0e00 |0000: return-void catches : (none)
positions :
0x0000 line=15
locals :
0x0000 - 0x0001 reg=0 this LTestMain;
source_file_idx : 8 (TestMain.java)
更多内容参考:官网介绍—–>Dalvik 可执行文件格式
记录整个工程中所有类的信息,记住的整个工程所有类的信息
图8 dex文件结构
上图中的文件头部分,记录了dex文件的信息,所有字段大致的一个分部;索引区部分,主要包含字符串、类型、方法原型、域、方法的索引;索引区最终又被存储在数据区,其中链接数据区,主要存储动态链接库,so库的信息。
源码:/dalvik/libdex/DexFile.h:DexFile
struct DexFile {
/* directly-mapped "opt" header */
const DexOptHeader* pOptHeader;
/* pointers to directly-mapped structs and arrays in base DEX */
const DexHeader* pHeader;
const DexStringId* pStringIds;
const DexTypeId* pTypeIds;
const DexFieldId* pFieldIds;
const DexMethodId* pMethodIds;
const DexProtoId* pProtoIds;
const DexClassDef* pClassDefs;
const DexLink* pLinkData;
};
具体可查看Android源码官网的关于dex文件结构的详解,如下图9(大图这里)
图9 dex文件结构详解
总结:
数据名称 | 解释 |
---|---|
header | dex文件头部,记录整个dex文件的相关属性 |
string_ids | 字符串数据索引,记录了每个字符串在数据区的偏移量 |
type_ids | 类似数据索引,记录了每个类型的字符串索引 |
proto_ids | 原型数据索引,记录了方法声明的字符串,返回类型字符串,参数列表 |
field_ids | 字段数据索引,记录了所属类,类型以及方法名 |
method_ids | 类方法索引,记录方法所属类名,方法声明以及方法名等信息 |
class_defs | 类定义数据索引,记录指定类各类信息,包括接口,超类,类数据偏移量 |
data | 数据区,保存了各个类的真是数据 |
link_data | 连接数据区 |
DEX 文件中会出现的数据类型
类型 | 含义 |
---|---|
u1 | 等同于uint8_t,表示 1 字节的无符号 数 |
u2 | 等同于 uint16_t,表示 2 字节的无符号数 |
u4 | 等同于 uint32_t,表示 4 字节的无符号数 |
u8 | 等同于 uint64_t,表示 8 字节的无符号数 |
sleb128 | 有符号 LEB128,可变长度 1~5 字节 |
uleb128 | 无符号 LEB128,可变长度 1~5 字节 |
uleb128p1 | 无符号 LEB128 值加1,可变长 1~5 字节 |
/dalvik/libdex/DexFile.h中定义如下
typedef uint8_t u1;
typedef uint16_t u2;
typedef uint32_t u4;
typedef uint64_t u8;
typedef int8_t s1;
typedef int16_t s2;
typedef int32_t s4;
typedef int64_t s8;
LEB128 LEB128(“Little-Endian Base 128”)表示任意有符号或无符号整数的可变长度编码。该格式借鉴了 DWARF3 规范。在 .dex 文件中,LEB128 仅用于对 32 位数字进行编码。
每个 LEB128 编码值均由 1-5 个字节组成,共同表示一个 32 位的值。每个字节均已设置其最高有效位(序列中的最后一个字节除外,其最高有效位已清除)。每个字节的剩余 7 位均为有效负荷,即第一个字节中有 7 个最低有效位,第二个字节中也是 7 个,依此类推。对于有符号 LEB128 (sleb128),序列中最后一个字节的最高有效负荷位会进行符号扩展,以生成最终值。在无符号情况 (uleb128) 下,任何未明确表示的位都会被解译为 0。 大图这里
图10 双字节 LEB128 值的按位图
变量 uleb128p1 用于表示一个有符号值,其表示法是编码为 uleb128 的值加 1。这使得编码 -1(或被视为无符号值 0xffffffff)成为一个单字节(但没有任何其他负数),并且该编码在下面这些明确说明的情况下非常实用:所表示的数值必须为非负数或 -1(或 0xffffffff);不允许任何其他负值(或不太可能需要使用较大的无符号值)。 以下是这类格式的一些示例:
编码序列 | As sleb128 | As uleb128 | As uleb128p1 |
---|---|---|---|
00 | 0 | 0 | -1 |
01 | 1 | 1 | 0 |
7f | -1 | 127 | 126 |
80 | 7f | -128 | 16256 |
dex文件头 Dex文件头主要包括校验和以及其他结构的偏移地址和长度信息。 源码位于 /dalvik/libdex/DexFile.h:DexHeader
struct DexHeader {
u1 magic[8]; /* includes version number */
u4 checksum; /* adler32 checksum */
u1 signature[kSHA1DigestLen]; /* SHA-1 hash */
u4 fileSize; /* length of entire file */
u4 headerSize; /* offset to start of next section */
u4 endianTag;
u4 linkSize;
u4 linkOff;
u4 mapOff;
u4 stringIdsSize;
u4 stringIdsOff;
u4 typeIdsSize;
u4 typeIdsOff;
u4 protoIdsSize;
u4 protoIdsOff;
u4 fieldIdsSize;
u4 fieldIdsOff;
u4 methodIdsSize;
u4 methodIdsOff;
u4 classDefsSize;
u4 classDefsOff;
u4 dataSize;
u4 dataOff;
};
具体详解如下图5所示 大图这里
图11 dex文件头信息
各个字段详解摘要 mapOff 字段 指定 DexMapList 结构距离 Dex 头的偏移 DexMapList 结构体:
struct DexMapList {
u4 size; // DexMapItem 的个数
DexMapItem list[1]; // DexMapItem 结构
};
struct DexMapItem
{
u2 type; // kDexType 开头的类型
u2 unused; // 未使用,用于对齐
u4 size; // 指定类型的个数
u4 offset; // 指定类型数据的文件偏移
};
type:一个枚举常量
enum
{
kDexTypeHeaderItem = 0x0000, // 对应 DexHeader
kDexTypeStringIdItem = 0x0001, // 对应 stringIdsSize 与 stringIdsOff 字段
kDexTypeTypeIdItem = 0x0002, // 对应 typeIdsSize 与 typeIdsOff 字段
kDexTypeProtoIdItem = 0x0003, // 对应 protoIdsSize 与 protoIdsOff 字段
kDexTypeFieldIdItem = 0x0004, // 对应 fieldIdsSize 与 fieldIdsOff 字段
kDexTypeMethodIdItem = 0x0005, // 对应 methodIdsSize 与 methodIdsOff 字段
kDexTypeClassDefItem = 0x0006, // 对应 classDefsSize 与 classDefsOff 字段
kDexTypeMapList = 0x1000,
kDexTypeTypeList = 0x1001,
kDexTypeAnnotationSetRefList = 0x1002,
kDexTypeAnnotationSetItem = 0x1003,
kDexTypeClassDataItem = 0x2000,
kDexTypeCodeItem = 0x2001,
kDexTypeStringDataItem = 0x2002,
kDexTypeDebugInfoItem = 0x2003,
kDexTypeAnnotationItem = 0x2004,
kDexTypeEncodeArrayItem = 0x2005,
kDexTypeAnnotationsDirectoryItem = 0x2006
};
DexStringId 结构体(stringIdsSize 与 stringIdsOff 字段)
typedef struct _DexStringId {
u4 stringDataOff; // 指向 MUTF-8 字符串的偏移
}DexStringId, *PDexStringId;
MUTF-8 编码:
DexTypeId 结构体(typeIdsSize 与 typeIdsOff 字段) 是一个类型结构体
typedef struct _DexTypeId {
u4 descriptorIdx; // 指向 DexStringId 列表的索引
}DexTypeId, *PDexTypeId;
DexProtoId 结构体(protoIdsSize 与 protoIdsOff 字段) 是一个方法声明结构体,方法声明 = 返回类型 + 参数列表
typedef struct _DexProtoId {
u4 shortyIdx; // 方法声明字符串,指向 DexStringId 列表的索引
u4 returnTypeIdx; // 方法返回类型字符串,指向 DexStringId 列表的索引
u4 parametersOff; // 方法的参数列表,指向 DexTypeList 结构体的偏移
}DexProtoId, *PDexProtoId;
DexTypeList 结构体:
typedef struct _DexTypeList {
u4 size; // 接下来 DexTypeItem 的个数
DexTypeItem* list; // DexTypeItem 结构
}DexTypeList, *PDexTypeList;
DexTypeItem 结构体:
typedef struct _DexTypeItem {
u2 typeIdx; // 指向 DexTypeId 列表的索引
}DexTypeItem, *PDexTypeItem;
typeIdx:DexTypeId 列表的索引
DexFieldId 结构体(fieldIdsSize 与 fieldIdsOff 字段) 指明了字段所有的类、字段的类型以及字段名
typedef struct _DexFieldId {
u2 classIdx; // 类的类型,指向 DexTypeId 列表的索引
u2 typeIdx; // 字段的类型,指向 DexTypeId 列表的索引
u4 nameIdx; // 字段名,指向 DexStringId 列表的索引
}DexFieldId, *PDexFieldId;
DexMethodId 结构体(methodIdsSize 与 methodIdsOff 字段) 方法结构体
typedef struct _DexMethodId {
u2 classIdx; // 类的类型,指向 DexTypeId 列表的索引
u2 protoIdx; // 声明的类型,指向 DexProtoId 列表的索引
u4 nameIdx; // 方法名,指向 DexStringId 列表的索引
}DexMethodId, *PDexMethodId;
DexClassDef 结构体(classDefsSize 和 classDefsOff 字段) 类结构体
typedef struct _DexClassDef {
u4 classIdx; // 类的类型,指向 DexTypeId 列表的索引
u4 accessFlags; // 访问标志
u4 superclassIdx; // 父类类型,指向 DexTypeId 列表的索引
u4 interfacesOff; // 接口,指向 DexTypeList 的偏移,否则为0
u4 sourceFileIdx; // 源文件名,指向 DexStringId 列表的索引
u4 annotationsOff; // 注解,指向 DexAnnotationsDirectoryItem 结构,或者为 0
u4 classDataOff; // 指向 DexClassData 结构的偏移,类的数据部分
u4 staticValuesOff; // 指向 DexEncodedArray 结构的偏移,记录了类中的静态数据,主要是静态方法
}DexClassDef, *PDexClassDef;
DexClassData 结构体:
typedef struct _DexClassData {
DexClassDataHeader header; // 指定字段与方法的个数
DexField* staticFields; // 静态字段,DexField 结构
DexField* instanceFields; // 实例字段,DexField 结构
DexMethod* directMethods; // 直接方法,DexMethod 结构
DexMethod* virtualMethods; // 虚方法,DexMethod 结构
}DexClassData, *PDexClassData;
DexClassDataHeader 结构体:
typedef struct _DexClassDataHeader {
uleb128 staticFieldsSize; // 静态字段个数
uleb128 instanceFieldsSize; // 实例字段个数
uleb128 directMethodsSize; // 直接方法个数
uleb128 virtualMethodsSize; // 虚方法个数
}DexClassDataHeader, *PDexClassDataHeader;
DexField 结构体:
typedef struct _DexField {
uleb128 fieldIdx; // 指向 DexFieldId 的索引
uleb128 accessFlags; // 访问标志
}DexField, *PDexField;
DexMethod 结构体:
typedef struct _DexMethod {
uleb128 methodIdx; // 指向 DexMethodId 的索引
uleb128 accessFlags; // 访问标志
uleb128 codeOff; // 指向 DexCode 结构的偏移
}DexMethod, *PDexMethod;
DexCode 结构体:
typedef struct _DexCode {
u2 registersSize; // 使用的寄存器个数
u2 insSize; // 参数个数
u2 outsSize; // 调用其他方法时使用的寄存器个数
u2 triesSize; // Try/Catch 个数
u4 debbugInfoOff; // 指向调试信息的偏移
u4 insnsSize; // 指令集个数,以 2 字节为单位
u2* insns; // 指令集
}DexCode, *PDexCode;
还有一些不太常见的结构体,要用的时候再去看看就行了。Dex 文件的整体结构就这样,就是一个多层索引的结构。
string_ids(字符串索引) 这一区域存储的是Dex文件字符串资源的索引信息,该索引信息是目标字符串在Dex文件数据区所在的真实物理偏移量。
源码位于 /dalvik/libdex/DexFile.h:DexStringId
struct DexStringId {
u4 stringDataOff; /* file offset to string_data_item */
};
stringDataOff记录了目标字符串在Dex文件中的实际偏移量,虚拟机想读取该字符串时,只需将Dex文件在内存中的起始地址加上stringDataOff所指的偏移量,就是该字符串在内存中的实际物理地址。 在Dex文件中,每个每个字符串对应一个DexStringId,大小4B。另外虚拟机通过DexHeader中的String_ids_size获得当前Dex文件中的字符串的总数,通过乘法就可对该索引资源进行访问。
DexLink
struct DexLink {
u1 bleargh;
};
在Android系统中, java 源文件会被编译为“ .jar ” 格式的dex类型文件, 在代码中称为dexfile 。在加载Class 之前, 必先读取相应的jar文件。通常我们使用read()函数来读取文件中的内容。但在Dalvik中使用mmap() 函数。和read()不同, mmap()函数会将dex文件映射到内存中,这样通过普通的内存读取操作即可访问dexfile中的内容。
Dexfile的文件格式如图12 所示, 主要有三部分组成:头部,索引,数据。通过头部可知索引的位置和数同,可知数据区的起始位置。其中classDefsOff 指定了ClassDef 在文件的起始位置, dataOff 指定了数据在文件的起始位置, ClassDef 即可理解为Class 的索引。通过读取ClassDef 可获知Class 的基本信息,其中classDataOff 指定了Class 数据在数据区的位置。 大图这里
图12 DexFile的文件格式
在将dexfile文件映射到内存后,会调用dexFileParse()函数对其分析,分析的结果存放于名为DexFile的数据结构中。DexFile 中的baseAddr指向映射区的起始位置, pClassDefs 指向ClassDefs(即class索引)的起始位置。由于在查找class 时,都是使用class的名字进行查找的,所以为了加快查找速度, 创建了一个hash表。在hash表中对class 名字进行hash,并生成index。这些操作都是在对文件解析时所完成的,这样虽然在加载过程中比较耗时,但是在运行过程中可节省大量查找时间。
解析完后, 接下来开始加载class文件。在此需要将加载类用ClassObject来保存,所以在此需要先分析和ClassObject 相关的几个数据结构。
首先在文件Object.h 中可以看到如下对结构体Object 的定义。(android2.3.7源码)
typedef struct Object {
/* ptr to class object */
ClassObject* clazz;
/*
* A word containing either a "thin" lock or a "fat" monitor. See
* the comments in Sync.c for a description of its layout.
*/
u4 lock;
} Object;
通过结构体Object定义了基本类的实现,这里有如下两个变量。
下面会有更多的结构体定义:
struct DataObject {
Object obj; /* MUST be first item */
/* variable #of u4 slots; u8 uses 2 slots */
u4 instanceData[1];
};
struct StringObject {
Object obj; /* MUST be first item */
/* variable #of u4 slots; u8 uses 2 slots */
u4 instanceData[1];
};
我们看到最熟悉的一个词StringObject ,把这个结构体展开后是下面的样子。
struct StringObject {
/* ptr t o class object */
ClassObject* clazz ;
/* variable #of u4 slots; u8 uses 2 slots */
u4 lock;
u4 instanceData[1]; };
由此不难发现, 任何对象的内存结构体中第一行都是Object结构体,而这个结构体第一个总是一个ClassObejct,第二个总是lock 。按照C++中的技巧,这些结构体可以当成Object结构体使用,因此所有的类在内存中都具有“对象”的功能,即可以找到一个类(ClassObject),可以有一个锁(lock) 。
StringObject是对String类进行管理的数据对象,ArrayObejct是数据相关的管理。
在解析完文件后, 接下来需要加载Class 的具体内容。在Dalvik中, 由数据结构ClassObject负责存放加载的信息。如图13所示,加载过程会在内存中alloc几个区域,分别存放directMethods 、virtualMethods 、sfields 、ifields 。这些信息是从dex 文件的数据区中读取的,首先会读取Class 的详细信息,从中获得directMethod 、virtua!Method 、sfield 、ifield 等的信息,然后再读取。在此需要注意, 在C lassObj ect 结构中有个名为super 的成员,通过super成员可以指向它的超类。 大图这里
图13 加载过程
对Android dex 文件进行优化来说, 需要注意的一点是dex文件的结构是紧凑的,但是我们还是要想方设法地进行提高程序的运行速度,我们就仍然需要对dex文件进行进一步优化。
调整所有字段的字节序( LITTLE_ENDIAN),和对齐结构中的每一个域来验证dex文件中的所有类,并对一些特定的类进行优化或对方法里的操作码进行优化。优化后的文件大小会有所增加, 大约是原Android dex文件的1~4 倍。
优化时机 优化发生的时机有两个:
如下图14所示代码调用流程
图14 代码调用流程
每一个Android应用都运行在一个Dalvik虚拟机实例里,而每一个虚拟机实例都是一个独立的进程空间。虚拟机的线程机制,内存分配和管理, Mutex等都是依赖底层操作系统而实现的。
所有Android应用的线程都对应一个Linux线程(可参考—-理解Android线程创建流程),虚拟机因而可以更多地依赖操作系统的线程调度和管理机制。不同的应用在不同的进程空间里运行,加之对不同来源的应用都使用不同的Linux用户来运行,可以最大限度地保护应用的安全和独立运行。
Zygote是一个虚拟机进程,同时也是一个虚拟机实例的孵化器,每当系统要求执行一个Android应用程序,Zygote就会孵化出一个子进程来执行该应用程序。这样做的好处显而易见:Zygote进程是在系统启动时产生的,它会完成虚拟机的初始化,库的载,预置类库的加载和初始化等操作,而在系统需要一个新的虚拟机实例时,Zygote通过复制自身,最快速地提供一个虚拟机实例。另外,对于一些只读的系统库,所有虚拟机实例都和Zygote 共享一块内存区域,大大节省了内存开销。
Android 应用所使用的编程语言是Java语言,和Java SE 一样,编译时使用Oracle JDK 将Java源程序编程成标准的Java 字节码文件(. class 文件)。而后通过工具软件DX 把所有的字节码文件转成Android dex 文件(classes . dex) 。最后使用Android 打包工具(aapt)将dex 文件、资源文件以及AndroidManifest.xml 文件(二进制格式)组合成一个应用程序包(APK) 。应用程序包可以被发布到手机上运行。
图15 Android应用编译及运行流程
odex 是Optimized dex 的简写,也就是优化后的dex 文件。为什么要优化呢?主要还是为了提高Dalvik 虚拟机的运行速度。但是odex 不是简单的、通用的优化,而是在其优化过程中,依赖系统已经编译好的其他模块,简单点说:
通过利用dexopt得到test.odex,接着利用dexdump得到其内容,最后可以利用Beyond Compare比较这两个文件的差异。 如下图所示
图16 test.dex 和test.odex 差异
图16中,绿色框中是test.dex的内容,红色框中是test.odex的内容,这也是两个文件的差异内容:
vtable是虚表的意思,一般在OOP实现中用得很多。vtable一定比methodtable快么?那倒是有可能。我个人猜测:
注意: odex文件由dexopt生成,这个工具在SDK里没有,只能由源码生成。odex文件的生成有三种方式:
实际上dex转odex是利用了dalvik vm,里边也会运行dalvik vm的相关方法。
总结:
深入理解Android之Java虚拟机Dalvik Androidsource之Dalvik 字节码 Androidsource之Dalvik 可执行文件格式(dex文件) Android安全–Dex文件格式详解 详细描述了dex/odex指令的格式—–>Dalvik opcodes 解释器中对 标号 的使用 A deep dive into DEX file format Dex文件格式详解 android中Dex文件结构详解 Dex文件及Dalvik字节码格式解析 Dex 文件格式详解 Dex文件格式详解 Android关于Dex拆分(MultiDex)技术详解
发布者:全栈程序员栈长,转载请注明出处:https://javaforall.cn/124925.html原文链接:https://javaforall.cn