您好!
欢迎来到京东云开发者社区
登录
首页
博文
课程
大赛
工具
用户中心
开源
首页
博文
课程
大赛
工具
开源
更多
用户中心
开发者社区
>
博文
>
详解Java对象模型Oop-Klass
分享
打开微信扫码分享
点击前往QQ分享
点击前往微博分享
点击复制链接
详解Java对象模型Oop-Klass
自猿其说Tech
2021-12-20
IP归属:未知
25720浏览
计算机编程
### 1 引言 Java作为一门面向对象的编程语言,Java中的每一个对象(不包括基础类型)都继承自Object对象, Object可以表示所有的Java对象。但是,在JVM层面,对象还能用Object来表示吗?答案显然是不可以的,拿HotSpot JVM来说,其是由C++实现,其中是没有Object对象的,而是通过特有的一套模型来实现Java语言的各种特性,也就是Oop-Klass对象模型。 下文中所有描述都基于JDK 1.8版本,JVM指的都是HotSpot JVM ### 2 Load Java文件经过编译器处理后,形成Class文件,虚拟机需要把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,即虚拟机的类加载(Class Loading)机制。其中,加载(Load)是类加载(Class Loading)过程中的一个阶段。 就是说每一个Java类在被虚拟机使用前,都需要被加载(Load),那我们就看下加载(Load)阶段做了什么事情。 从《深入理解Java虚拟机》一书中我们得知,在加载(Load)阶段,虚拟机需要完成3件事情: 1. 通过一个类的全限定名来获取定义此类的二进制字节流 1. 将这个字节流所代表的静态存储结构转化为运行时数据结构 1. 在内存中生成一个代表这个类的java.lang.Class对象,作为这个类的各种数据的访问入口 有点懵,什么是运行时数据结构?什么是java.lang.Class对象?既然一时无法明白书上描述的内容具体含义是什么,那我们就从源码入手,看下能否得到一些启发。 从Java类加载器双亲委派模型中可知,如果应用程序没有自定义过自己的类加载器,一般情况下,程序中默认的类加载器就是sun.misc.Launcher$AppClassLoader,通过追踪该类的类加载方法loadClass,其核心调用为defineClass方法。可以发现其中不同支路的defineClass都会定向为native方法defineClass0、defineClass1、defineClass2。这三个native方法的源码实现可见ClassLoader.c,最终都指向了JVM_DefineClassWithSource方法。 ![](//img1.jcloudcs.com/developer.jdcloud.com/acf8190e-fee7-498e-a3b8-0afe962883d220211220145238.png) 其内部直接调用了jvm_define_class_common方法 ![](//img1.jcloudcs.com/developer.jdcloud.com/7bdf537e-f718-4af6-9745-9dcf7a87b2a120211220145300.png) 我们看到,jvm_define_class_common方法中调用了SystemDictionary::resolve_from_stream方法,创建了一个Klass类型的对象。继续看SystemDictionary::resolve_from_stream方法的实 ![](//img1.jcloudcs.com/developer.jdcloud.com/3b4dbaab-5f0e-4f1b-80b6-2045f3f324a620211220145316.png) 从方法注释可以看出,resolve_from_stream方法根据class文件的字节流,向系统中添加了一个klass对象,其中核心方法是调用了ClassFileParser(st).parseClassFile方法 ![](//img1.jcloudcs.com/developer.jdcloud.com/cd20fa39-b047-4d80-8b32-cd9ef223bc5a20211220145330.png) 简化parseClassFile方法主流程后,发现该方法中首先调用了InstanceKlass::allocate_instance_klass方法创建一个klass指针,并以该指针作为入参,调用了java_lang_Class::create_mirror方法,依据注释,是分配了一个mirror镜像,并初始化静态属性。 InstanceKlass::allocate_instance_klass方法中只是调用了InstanceKlass类的构造函数创建一个 InstanceKlass对象,没有其他特殊逻辑,由此InstanceKlass对象创建完成,即本节开头提到虚拟机在加载(Load)阶段做的三件事中的第二件:将这个字节流所代表的静态存储结构转化为运行时数据结构,这个InstanceKlass对象就代表了该目标类运行时数据结构。 parseClassFile方法中是用的Klass类型作为了InstanceKlass::allocate_instance_klass方法的返回值,而InstanceKlass::allocate_instance_klass方法实际返回的类型是InstanceKlass,因此可以看出InstanceKlass是Klass的一个子类。 我们继续看下java_lang_Class::create_mirror方法 ![](//img1.jcloudcs.com/developer.jdcloud.com/fadaafc6-4a4d-4de8-9b17-9c4df4db7d8820211220145356.png) 这个方法中,主要做了两件事情: 1)分配mirror镜像 SystemDictionary::Class_klass()返回的是指向java.lang.Class类对应的InstanceMirrorKlass对象的指针,然后调用了InstanceMirrorKlass::allocate_instance方法分配mirror镜像,依据注释,该方法分配了一个mirror镜像,即创建了一个java.lang.Class类的实例,即本节开头提到虚拟机在加载(Load)阶段做的三件事中的第三件:在内存中生成一个代表这个类的java.lang.Class对象。 java.lang.Class也是一个类,和Person、Student等这些自定义的类一样,在Java语言层面,都属于类的定义,只不过java.lang.Class类的功能和自定义类的功能有着很大的区别。至此,本节开头的两个疑问:什么是运行时数据结构?什么是java.lang.Class对象?都得到了回答。 在加载 (Load)目标类的时候,除了会创建目标类的InstanceKlass实例,还会创建一个该目标类对应java.lang.Class对象。 2)初始化mirror镜像中的静态属性 表明类的静态属性都保存在了java.lang.Class对象中 InstanceMirrorKlass::allocate_instance方法 ![](//img1.jcloudcs.com/developer.jdcloud.com/bc198baa-9baa-407d-98ab-27d64e33a34320211220145457.png) 可以看到,主要是调用CollectedHeap::Class_obj_allocate方法在JVM堆内存上创建一个instanceOop对象, java.lang.Class类对象在JVM中的类型为instanceOop ![](//img1.jcloudcs.com/developer.jdcloud.com/b71286ea-8215-4182-8bc6-b4774f53233720211220145518.png) Class_obj_allocate中有两个入参需要注意一下: 1)KlassHandle klass 往回追溯一下调用次方法的地方,发现klass是一个指向java.lang.Class类对应的InstanceMirrorKlass对象的指针 2)KlassHandle real_klass 这个参数需要追溯到ClassFileParser(st).parseClassFile方法,real_klass代表了刚创建好的InstanceKlass类型的指针 Class_obj_allocate方法首先在堆内存空间中创建了一个oop类型的mirror对象,然后设置该对象的对象头 要注意,该对象头中的Klass Pointer指针指向了入参klass,即指向了java.lang.Class类对应的InstanceMirrorKlass对象 此后将创建的mirror对象中的某个指针指向real_klass,即指向了在ClassFileParser(st).parseClassFile方法中创建的InstanceKlass对象。然后再将在ClassFileParser(st).parseClassFile方法中创建的InstanceKlass对象中的java_mirror指针指向了刚创建的mirror对象(java.lang.Class对象)。 至此,完成了对类的加载(Load)流程的分析,我们发现,在整个整个加载(Load)流程中,主要都是在围绕Klass、InstanceKlass、InstanceMirrorKlass、oop这几个类型的对象进行操作,我们先根据已分析的源码逻辑,对这几个类型的对象进行简单梳理,在完成类的加载(Load)后,内存中对象间的关系大致如下图所示: ![](//img1.jcloudcs.com/developer.jdcloud.com/45c24a54-28ab-470a-a88e-78a87488b67420211220145558.png) 类的加载(Load)阶段,JVM创建了两个对象,分别是InstanceOop对象和InstanceKlass对象。 其中InstanceOop对象就是该目标类对应的java.lang.Class类的对象,该对象的对象头中的klass pointer指针指向了java.lang.Class类在内存中的运行时数据结构,一个InstanceMirrorKlass对象;另外该InstanceOop对象还有一个指针ptr,指向了该目标类在内存中的运行时数据结构,一个InstanceKlass对象;此外,该InstanceOop对象中还保存了目标类中的静态属性,该InstanceOop对象亦被称作Java Mirror对象。 InstanceKlass对象代表了该目标类在内存中的运行时数据结构。 这里提到的类的加载(Load)指的是非java.lang.Class类的加载,上图中的InstanceMirrorKlass对象就是由java.lang.Class类经过加载后,JVM创建的对象,代表了java.lang.Class这个类的运行时数据结构。 ### 3 new 在虚拟机完成类加载(Class Loading)后,得到了可以被虚拟机直接使用的Java类型。在Java语言中最常用的创建对象的方式就是通过new关键字创建对象,想要了解Java对象在内存中具体的对象模型,就需要看下JVM在创建对象的时候都做了哪些事情(对象创建的完整过程在Hotspot中的源码可见bytecodeIntepreter.cpp) 当JVM读取到字节码指令new时,JVM执行流程如下: ![](//img1.jcloudcs.com/developer.jdcloud.com/71c459a3-0320-4d01-85af-4167180edd2220211220145643.png) 首先从当前线程的栈帧中的操作数栈中获取要new的目标类的符号引用在常量池中的索引 (详见文末的注1),根据该索引,从常量池中找出对应的符号引用。根据该符号引用是否已经解析成直接引用,后续创建流程分为两个流程分支:类已解析 和 类未解析。 #### 3.1 类已解析 1)确保该类已经完成初始化 ![](//img1.jcloudcs.com/developer.jdcloud.com/1ad9d780-3916-4aa8-b74d-1552d24a85d320211220145706.png) 首先校验该直接引用是否是Klass指针及InstanceKlass指针,并确保目标类已经完成初始化,并且可以使用快速分配的方式创建对象。否则会执行类未解析流程。 2)为新对象分配内存空间 ![](//img1.jcloudcs.com/developer.jdcloud.com/0655a2d3-2a30-4ee5-a170-ad4d8b6ff8b420211220145737.png) 获取目标类对象的大小(在编译期已经确定),如果开启了TLAB设置,则在TLAB区域分配对象内存。否则尝试在Eden区域分配对象内存。通过不断尝试占用 当前未分配内存空间的起始地址 往后的 目标类对象的大小 这段内存空间,为对象分配内存空间,即指针碰撞。 3)内存空间初始化及对象头设置 ![](//img1.jcloudcs.com/developer.jdcloud.com/5e0394c4-4ad2-4990-82ed-cb7ec11b889820211220145759.png) 如果需要初始化内存空间,则将分配的内存空间都初始化为0,并设置对象头中的Mark Word 和 Klass Pointer。 可以看到,在类已解析流程中,主要是创建了一个oop对象,并设置该oop对象的对象头中的Mark Word及Klass Pointer。其中,Klass Pointer指针指向了k_entry,往回追溯一下,发现k_entry指向的是该目标类的InstanceKlass对象 #### 3.2 类未解析 调用 InterpreterRuntime::_new 方法 第一步获取常量池中的直接引用(详见注1.1),如果该目标类还未初始化,则先进行初始化(此部分代码跳过)。最后调用allocate_instance方法创建oop类型的对象。 ![](//img1.jcloudcs.com/developer.jdcloud.com/8f21bb62-65bf-4824-9c3a-6c9d59dc013a20211220145851.png) 看下allocate_instance方法 ![](//img1.jcloudcs.com/developer.jdcloud.com/746d582d-381f-4748-8bd9-52545920ccb620211220145910.png) 可以看到主要是调用的CollectedHeap类中的obj_allocate方法创建了一个instanceOop对象,继续跟踪此方法 ![](//img1.jcloudcs.com/developer.jdcloud.com/358decec-49f5-4931-931d-6cbf964203c620211220150134.png) 该方法中主要执行了两个步骤:创建oop对象,并设置对象的对象头中的Mark Word和Klass Pointer。 至此,new字节码执行完成。在类未解析流程中,首先初始化目标类,然后通过common_mem_allocate_init方法,在堆内存中创建了目标类的一个oop对象,并设置对象的对象头中的Mark Word和Klass Pointer,这里可以看到,该oop对象的Klass Pointer指针指向的是入参klass,往回追溯一下,发现该klass是该目标类对应的InstanceKlass对象,这里和类已解析流程对应上了。 此外,跟一下common_mem_allocate_init方法,可以发现,不同的垃圾回收器,其分配内存空间的方式是不同的。 执行完new指令后,我们可以看到,JVM创建了一个oop对象,这个oop对象就是我们经常说的Java对象在JVM层面的呈现方式。此外,还将oop对象中的Klass Pointer指针指向了该目标类的InstanceKlass对象。基于此,我们可以把第一节Load中的 内存中对象间的关系图进行丰富,得到下图: ![](//img1.jcloudcs.com/developer.jdcloud.com/edba97e6-a84c-4895-b31a-9baaa778abda20211220150259.png) 至此我们初步得到了InstanceMirrorKlass、InstanceOop、InstanceKlass这些对象在内存的相互关系,但是,这些对象到底是什么,他们的数据结构是什么样的,我们都还不清,下面就进行详细了解吧。 ### 4 Oop Oop的全称是Ordinary Object Pointer,意为普通对象指针,而不是Object-Oriented Programming,是HotSpot JVM用来表示Java对象的实例信息的一个体系。其中oop是Oop体系中的最高父类,其继承体系如下, ![](//img1.jcloudcs.com/developer.jdcloud.com/0f84f0f2-3ebe-4501-8dc6-d8df22162a4420211220150344.png) UML类图: ![](//img1.jcloudcs.com/developer.jdcloud.com/7f9e7465-0b21-46c3-af47-6b77cd83dd5a20211220150559.png) 在Java应用程序运行过程中,每创建一个Java对象,在JVM内部也会相应创建一个OOP对象来表示Java对象,OOP类的共同基类型是oopDesc*。根据JVM内部使用的对象类型,具有多种oopDesc子类,其中instanceOopDesc表示普通Java对象实例,arrayOopDesc表示数组对象实例。数组对象实例下又可以分为对象数组实例objArrayOopDesc*和基础数据类型数组实例typeArrayOopDesc*。 回看下Load与new这两节,可以发现,无论是在加载(Load)阶段生成java.lang.Class类的instanceOop即java mirror时,还是在执行new字节码创建生成java对象的instanceOop时,都是调用的collectedHeap中的方法进行的内存分配与创建,因此,instanceOop都是分配在了堆内存空间中 Java对象实例instanceOopDesc的存储结构如图所示: ![](//img1.jcloudcs.com/developer.jdcloud.com/f61a280e-36f7-41e1-b06c-ce90152d3ad420211220150919.png) 其主要由对象头、实例数据以及自动对齐三部分组成。 计算Java对象占用内存大小时,就是计算的就是这三部分 1)对象头 从oop.hpp源码来看,对象头中包含了两部分,一部分是markOop类型的对象,用于存储对象的运行时记录信息,如hashCode、GC分代年龄、锁状态等;另一部分是Klass指针的联合体,用于指向当前对象所属的Klass对象,如果开启了指针压缩(-XX:+UseCompressedOops),则使用压缩后的narrowKlass类型指针,如果没有启用指针压缩,则使用Klass类型指针。 此处不对对象头中的内容进行展开,但其中内容值得仔细研究,涉及到很多锁、GC方面的知识 ![](//img1.jcloudcs.com/developer.jdcloud.com/c4a0296e-6105-479c-9cce-fba9dc5145f220211220150949.png) 2)实例数据 JVM将Java对象的成员变量也保存在了oop对象中,并提供了一系列的get、set方法,如果成员变量是非基础类型,即普通对象,oop中保存的是其在内存中的地址。 ![](//img1.jcloudcs.com/developer.jdcloud.com/dfd5ca8d-6c0f-4c02-a286-1834e767d7ab20211220151025.png) 自动对齐 JVM要求Java的对象占用的内存大小应该是8的倍数个字节,所以在对象头+实例数据所占的内存不满足8的倍数个字节是,会使用一定数量的字节将对象所占内存的大小补充至8的倍数个字节。 ### 5 Klass A Klass provides: 1: language level class object (method dictionary etc.) 2: provide vm dispatch behavior for the object - 以上是HotSpot JVM对Klass的定义klass.hpp,可以看出,Klass类主要提供了两个功能:用于表示Java语言层面的类的对象,其中保存了Java对象的类型信息,包括类名、限定符、常量池、方法字典等。一个class文件被JVM加载之后,就会形成一个Klass对象存储在内存中 - 为Java对象提供方法调用分派机制 与Oop一样,Klass也有一个继承体系,并在oopsHierarchy.hpp中进行了相关描述: ![](//img1.jcloudcs.com/developer.jdcloud.com/7cd8de30-3f45-4c6e-96a5-d7fedbedfa8520211220151116.png) UML类图: ![](//img1.jcloudcs.com/developer.jdcloud.com/036a1554-880b-4b22-93b3-165f8e11797220211220151133.png) 其各自功能如下: 1)instanceKlass:表示一个普通Java类,包含了该类运行时所有信息,即运行时数据结构 - instanceMirrorKlass:表示java.lang.Class类的运行时数据结构 - instanceMirrorKlass只用于描述java.lang.Class这个特定的类的运行时数据结构 - instanceClassLoaderKlass:表示类加载器ClassLoader体系下所有类的运行时数据结构 2)instanceRefKlass:表示java.lang.ref.Reference类及其子类的运行时数据结构 - arrayKlass:表示数组类的运行时数据结构 - objArrayKlass:表示对象数组的运行时数据结构 - typeArrayKlass:表示基础数据类型数组的运行时数据结构 Klass类主要数据结构: ![](//img1.jcloudcs.com/developer.jdcloud.com/3844f298-e61c-4d1a-b656-a204814b91a320211220151212.png) 如上述代码片段所示,Klass继承了Metadata,而Metadata继承了MetaspaceObj,MetaspaceObj就是JDK 1.8中“元空间”的实现,这就意味着,Java对象的类型信息存储在元空间,而不是在堆中。另外,Klass中还存储了当前类所属的java.lang.Class对象对应的oop,即java mirror,以及其父类、子类的Klass指针。 InstanceKlass继承了Klass,其主要的数据结构: ![](//img1.jcloudcs.com/developer.jdcloud.com/2413a38b-5c76-4b5b-8a6f-20a982b9702f20211220151235.png) 其中,_methods数组对象代表该类的所有方法;_fields成员变量代表当前类所有字段信息,包括成员变量和静态变量,但是不是保存的变量的真实内容。_fields中的每个元素代表了当前field的偏移量信息,这些偏移量用于在oop中找到对应field的地址。成员变量的field偏移量用于在Java Object对应的oop中找到对应的field地址;静态变量的field偏移量用于在java mirror对应的oop中找到对应的field地址。 InstanceKlass中有两个字段:_vtable_len与_itable_len,其分别代表了该类的虚函数表的长度及接口函数表的长度,与之相关的虚函数表vatble、接口函数表itable是Java实现多态的基础,有兴趣的同学可以了解一下。 至此,我们了解了Oop与Klass的相关概念,以及其各自所处的内存区域,那么我们可以通过一段具体的代码示例,来描述Java对象模型了。代码示例: ![](//img1.jcloudcs.com/developer.jdcloud.com/e01212de-8b00-48e5-9b32-a38262f767c820211220151255.png) 当应用程序代码执行 Animal animalA = new Animal(); Animal animalB = new Animal();这两行代码后,通过前面几节的分析,内存中的对象布局为: ![](//img1.jcloudcs.com/developer.jdcloud.com/141b94ac-f71e-4215-973e-de50398ea41020211220151312.png) 在创建了两个Animal类型的对象animalA和animalB后,在堆内存中,会有两个instanceOop类型的java对象instanceOop:animalA和instanceOop:animalB,它们中的_klass指针都指向了元空间中描述Animal类运行时数据结构的instanceKlass对象instanceKlass:Animal(一个)。instanceKlass:Animal中的methods数组中包含一个指向描述了say方法的Method对象的Method指针,此外instanceKlass:Animal中的_java_mirror指向了堆内存中的Animal类对应java.lang.Class对象instanceOop:java mirror-Animal(一个)。 instanceOop:java mirror-Animal中的_klass指针又指向了元空间中的java.lang.Class类的运行时数据结构instanceMirrorKlass对象instanceMirrorKlass:java.lang.Class(一个)。这就是Java对象模型Oop-Klass。 ### 6 总结 本文结合Java对象创建的具体过程:Java classLoader -> class文件的加载(Load) -> Klass对象的创建 -> 创建对象(new)-> Oop对象的创建,从JVM源码层面对该过程的主体流程进行了分析介绍。在HotSpot JVM中,创建Java对象时,并不是直接单独创建一个C++对象与之形成映射,而是采用oop-klass模型,分别创建了oop、klass、java-mirror对象,该模型也是Java中能够使用Class cls = obj.getClass();这样的方式进行反射调用的基础,通过oop -> klass ->java mirror调用链,获取到java.lang.Class对象,进行反射相关方法的使用。 #### 注1:根据类索引查找全限定名 首先看下两个示例类的定义 ![](//img1.jcloudcs.com/developer.jdcloud.com/4359f45b-b974-42a7-a8e8-25373f1a2a0620211220151345.png) ![](//img1.jcloudcs.com/developer.jdcloud.com/d6478640-3d5c-4f06-bd65-b099ea02a76720211220151353.png) 通过javap -verbose Client.class命令查看Client类的字节码信息 ![](//img1.jcloudcs.com/developer.jdcloud.com/843b3ece-25fa-4782-9cff-798837c5fe2820211220151408.png) 该字节码信息中主要包含了两部分信息: 1. Client类的常量池信息; 1. Client类中所有方法的字节码指令,包含构造函数、自定义实例方法以及静态方法,由于没有示例代码中没有静态方法,所以只能看到构造函数Client()和自定义示例方法doSomething()的字节码信息。 从doSomething()方法的字节码中可以看到,执行Animal animal = new Animal()这一行代码时,执行的字节码指令是new,其后紧跟的#2代表了Animal类在Client类常量池中的类索引,用于确定Animal的全限定名。在常量池表中可以看到,第二个常量信息为CONSTANT_Class_Info类型的常量池结构,其代表了类或接口的符号引用(参见《深入理解Java虚拟机》第2版6.3.2节)。 回看JVM源码中的u2 index = Bytes::get_Java_u2(pc+1)。JVM在执行new字节码指令时,会将#2对应的操作数Ox0002写入调用doSomething()方法的线程的栈帧中的操作数栈中,即完成入栈操作。随后通过Bytes::get_Java_u2(pc+1)方法完成出栈操作,获取到要创建的类(Animal)在常量池中索引index。再通过constants->tag_at(index)即可获得constantTag类型的Animal类的符号引用,通过constantTag对象的is_unresolved_klass方法就可以知道Animal类有没有完成解析。如果该类已经完成了解析,即可根据该索引,通过ConstantPool对象的klass_at方法,获取该类的直接引用,即该类的instanceKlass对象的内存地址。 附:JDK8 HotSpot JVM源码http://hg.openjdk.java.net/jdk8/jdk8/hotspot ------------ ###### 自猿其说Tech-京东物流技术发展部 ###### 作者:柏海龙
原创文章,需联系作者,授权转载
上一篇:安全测试在敏捷团队中的落地
下一篇:Log4j2远程执行代码漏洞本地重现及分析
相关文章
Taro小程序跨端开发入门实战
Flutter For Web实践
配运基础数据缓存瘦身实践
自猿其说Tech
文章数
426
阅读量
2149964
作者其他文章
01
深入JDK中的Optional
本文将从Optional所解决的问题开始,逐层解剖,由浅入深,文中会出现Optioanl方法之间的对比,实践,误用情况分析,优缺点等。与大家一起,对这项Java8中的新特性,进行理解和深入。
01
Taro小程序跨端开发入门实战
为了让小程序开发更简单,更高效,我们采用 Taro 作为首选框架,我们将使用 Taro 的实践经验整理了出来,主要内容围绕着什么是 Taro,为什么用 Taro,以及 Taro 如何使用(正确使用的姿势),还有 Taro 背后的一些设计思想来进行展开,让大家能够对 Taro 有个完整的认识。
01
Flutter For Web实践
Flutter For Web 已经发布一年多时间,它的发布意味着我们可以真正地使用一套代码、一套资源部署整个大前端系统(包括:iOS、Android、Web)。渠道研发组经过一段时间的探索,使用Flutter For Web技术开发了移动端可视化编程平台—Flutter乐高,在这里希望和大家分享下使用Flutter For Web实践过程和踩坑实践
01
配运基础数据缓存瘦身实践
在基础数据的常规能力当中,数据的存取是最基础也是最重要的能力,为了整体提高数据的读取能力,缓存技术在基础数据的场景中得到了广泛的使用,下面会重点展示一下配运组近期针对数据缓存做的瘦身实践。
自猿其说Tech
文章数
426
阅读量
2149964
作者其他文章
01
深入JDK中的Optional
01
Taro小程序跨端开发入门实战
01
Flutter For Web实践
01
配运基础数据缓存瘦身实践
添加企业微信
获取1V1专业服务
扫码关注
京东云开发者公众号