关于JVM、Dalvik、ART的基础分析

声明

  • Android的Framework层及应用层基本是Java代码编写的,Java代码运行避不开就是Java虚拟机;
  • 很好奇的一点就是,想从基本理论上(简单地)分析下移动设备Android的虚拟机Dalvik、ART与传统PC设备上JVM之间的相同和不同之处;

0 写在前面的

    Dalvik VM是Android虚拟机,从Android系统诞生之日起它一直是Google等厂商合作开发的Android移动设备平台的核心组成部分之一。在Android 4.4发布时,Google推出了ART运行环境机制,这种机制的运行速度更快、 效率更高,慢慢已经完全取代Dalvik VM成为唯一的运行机制。Dalvik VM和ART都可以支持已转换为.dex格式的Java应用程序的运行。

  • .dex格式是专为Dalvik设计的一种压缩格式,适合内存和处理器速度有限的系统;
  • 大多数虚拟机(包括JVM)都是一种堆栈机器,而Dalvik虚拟机则是基于寄存器的(基于栈的机器需要更多指令,而基于寄存器的机器指令更大)

1 Dalvik VM 和 JVM的区别

    Dalvik VM与大多数虚拟机和真正的JavaVM不同, 它们是堆栈机器(Stack Machine), 而Dalvik VM是基于寄存器的架构。此外,两种方法的相对优势取决于所选择的解释/编译策略。 但总的来说, 基于stack的机器必须使用指令来载入stack上的数据,或使用指令来操纵数据。 所以与基于寄存器的机器相比,Dalvik VM需要更多的指令。然而,在寄存器的指令必须编码源和目的地寄存器,因此指令更大。

    为了满足低内存要求而不断优化, DalvikVM 存在如下所示的独特特征:

  1. Dalvik VM 很小, 使用的空间也小;
  2. Dalvik VM 常量池己被修改为只使用32位的索引,以简化解释器;
  3. Dalvik VM 使用自己的字节码,而非Java字节码;

    此外, DalvikVM 被设计来满足可高效运行多种虚拟机实例。 DalvikVM 和标准 Java 虚拟机 JVM之间最大差别就是:

  1. Dalvik VM 基于寄存器,JVM基于堆栈;
  2. 运行环境:Dalvik VM经过优化,可以在有限的内存中同时运行多个虚拟机实例,且每个Dalvik VM应用作为一个独立的Linux进程执行;
  3. DalvikVM 通过 Zygote 进行 Class Preloading, Zygote 会完成虚拟机的初始化,也是与JVM的不同之处;当系统需要创建一个应用程序时, Zygote 就会fock 自身,快速地创建和初始化一个DVM 实例,用于应用程序的运行。对于一些只读的系统库,所有的DVM 实例都会和Zygote 共享一块内存区域,节省了内存开销。
  4. DVM 拥有预加载一共享的机制,不同应用之间在运行时可以共享相同的类,拥有更高的效率。而R币4 机制不存在这种共享机制,不同的程序,打包以后的程序都是彼此独立的,即便它们在包里使用了同样的类,运行时也都是单独加载和运行的,无法进行共享。
  5. Java 虚拟机运行的是 Java 字节码, 而Dalvik 虚拟机运行的则是其专有的文件格式 dex ( Dalvik Executable)。
  6. Java程序中Java类会被编译成一个或多个字节码文件(.class)然后打包到JAR文件, 而后Java虚拟机会从相应的CLASS文件和JAR文件中获取相应的字节码; Android应用虽然也是使用Java语言编写,但在编译成.class文件后,还会通过一个dx工具将应用所有的CLASS文件转换成一个DEX文件,然后Dalvik VM从其中读取指令和数据。

         

如图所示,.jar 文件里面包含多个.class 文件,每个.class 文件里面包含了该类的常量池、类信息、属性等。当NM 加载该.jar 文件的时候,会加载里面的所有的.class 文件,NM 的这种加载方式很慢,对于内存有限的移动设备并不合适。而在.apk文件中只包含了一个.dex文件,这个.dex文件将所有的.class里面所包含的信息全部整合在一起了,这样再加载就加快了速度。.class 文件存在很多的冗余信息, dex 工具会去除元余信息,并把所有的.class文件整合到.dex文件中,减少了I/O 操作,加快了类的查找速度。

2 Dalvik VM 的主要特征

    在 Dalvik 虚拟机中, 一个应用中会定义很多类, 编译完成后即会有很多相应的 CLASS 文件,CLASS 文件间会有不少冗余的信息;而DEX文件格式会把所有的CLASS 文件内容整合到一个文件中。这样,除了减少整体的文件尺寸和I/0操作,也提高了类的查找速度。 在每个类文件中的常量池,都是在DEX文件中由一个常量池负责管理。

    每一个 Android 应用都运行在一个 Dalvik虚拟机实例里, 而每一个虚拟机实例都是一个独立的进程空间。虚拟机的线程机制、 内存分配和管理、 Mutex 等都是依赖底层操作系统实现的。所有 Android 应用的线程都对应一个 Linux 线程, 虚拟机因而可以更多地依赖操作系统的线程调度和管理机制。不同的应用在不同的进程空间里运行,加之对不同来源的应用都是用不同的Linux用户来运行,可以最大程度保护应用的安全和独立运行。

    Zygote 是一个虚拟机进程,同时也是一个虚拟机实例的孵化器,每当系统要求执行一个Android 应用程序,Zygote 就会 FORK (孕育)出一个子进程来执行该应用程序。 这样做的好处有:

  1. Zygote 进程是在系统启动时产生的,它会完成虚拟机的初始化、库的加载、预置类库的加载和初始化等操作;
  2. 当系统需要一个新的虚拟机实例时,Zygote 通过复制自身,最快速地提供一个系统;
  3. 对于一些只读性的系统库来说,所有虚拟机实例都和 Zygote 共享一块内存区域,大大节省了内存开销;

    相对于基于堆栈的虚拟机实现,基于寄存器的虚拟机实现虽然在硬件通用性上要差一些,但是它在代码的执行效率上却更胜一筹。在基于寄存器的虚拟机里,可以更为有效地减少冗余指令的分发和减少内存的读写访问

3 Dalvik VM的架构

    在 Android 源码中,Dalvik 虚拟机的实现位于 “ dalvik/ ” 目录下,其中:

  1. “ dalvik/vm ” 是虚拟机的实现部分,将会编译成 libdvm.so;
  2. “dalvik/libdex” 将会编译成 libdex.a 静态库,作为 dex工具使用;
  3. “ dalvik/dexdump” 是.dex文件的反编译工具;
  4. 虚拟机的可执行程序位于 “ dalvik/dalvikvm" 中,将会编译成 dalvikvm 可执行文件。

        

3.1 Dalvik VM源码目录

    以Android 4.4源码为例,其源码目录如下:

maxingrong@soc02:~/Nexus7_Project/aosp_android_4.4.2/dalvik$ ls
Android.mk    dexdump  dexlist  docs  hit     MODULE_LICENSE_APACHE2  opcode-gen  tests  unit-tests
CleanSpec.mk  dexgen   dexopt   dx    libdex  NOTICE                  README.txt  tools  vm
  1. Android.mk:是虚拟机编译的makefile文件;
  2. dalvikvm:此目录是虚拟机命令行调用入口文件的目录, 主要用来解释命令行参数, 调用库函数接口等;
  3. dexdump:此目录是生成dex 文件反编译查看工具, 主要用来查看编译出来的代码文件是否正确, 查看编译出来的文件结构如何;
  4. dexlist:此目录是生成查看dex文件里所有类的方法的工具;
  5. dexopt:此目录是生成 dex优化工具;
  6. docs:此目录是保存Dalvik虚拟机相关帮助文档;
  7. dvz:此目录是生成从Zygote请求生成虚拟机实例的工具;
  8. dx:此目录是生成从Java字节码转换为Dalvik机器码的工具;
  9. hit:此目录是生成显示堆栈信息/对象信息的工具;
  10. libcore:此目录是Dalvik虚拟机的核心类库, 提供给上层的应用程序调用;
  11. libcore-disabled:此目录是一些禁用的库;
  12. libdex:此目录是生成主机和设备处理DEX文件的库;
  13. libnativehelper:此目录是Dalvik虚拟核心库的支持库函数;
  14. MODULE _LICENSE_APACHE2:这个是APCHE2的版权声明文件;
  15. NOTICE : 这个文件是说明虚拟机源码的版权注意事项;
  16. README .txt: 这个文件是说明本目录相关内容和版权;
  17. run-core-tests.sh: 这个文件是用来运行核心库测试;
  18. tests:此目录是保存 测试相关 测试用例;
  19. tools:此目录是保存 些编译/运行相关的工具;
  20. vm:此目录保存了VM绝大部分代码,包括读取指令、指令执行等;

3.2 dx工具

    在Android虚拟机中,dx工具是用来转换Java Class成为DEX格式, 但不是全部。多个类型包含在一个DEX文件之中。多个类型中重复的字符串和其他常数包括会存放在DEX之中只有一次,以节省空间。Java字节码( betecode) 转换成Dalvik虚拟机所使用的替代指令集。一个未压缩DEX文件通常是稍稍小于一个己经压缩.Jar文档。

    当启动Android系统时,DalvikVM 监视所有的程序(APK),并且创建依存关系树,为每个程序优化代码并存储在Dalvik缓存中。DalvikVM 第一次加载后会生成Cache文件,以提供下次快速加载,所以第一次会变得很慢。

3.3 Dalvik VM的进程管理

    Dalvik VM进程管理是依赖于Linux的进程体系结构的, 如要为应用程序创建一个进程,它会使用Linux的fork机制来复制一个进程(复制进程往往比创建进程效率更高)。

    Zygote是一个虚拟机进程,同时也是一个虚拟机实例的孵化器,它通过init进程启动。首先会孵化出System_Server (A ndroid绝大多系统服务的守护进程,它会监听Socket等待请求命令,当有一个应用程序启动时,就会向它发出请求,Zygote就会FO RK出一个新的应用程序进程)。每当系统要求执行一个Android应用程序时,Zygote就会运用Linux的FORK进制产生一个子进程来执行该应用程序。

4 ART主要特性

    随着Android 4.4和Android L版本的推出,DalvikVM系统逐渐被谷歌抛弃,取而代之的是ART运行机制。它与传统的Dalvik模式不同,ART模式可以实现更为流畅的安卓系统体验。

4.1 什么是ART模式

    Google喜欢收购公司,ART模式就是来源自其收购的名为Flexycore的公司, 该公司一直致力于Android系统的优化,而ART模式也是在该公司的优化方案上演进而来。ART 的机制与Dalvik不同。在Dalvik下, 应用每次运行的时候,字节码都需要通过即时编译器转换为机器码,这会拖慢应用的运行效率:而在ART环境中,应用在第一次安装的时候, 字节码就会预先编译成机器码, 使其成为真正的本地应用。这个过程称为预编译(AOT,Ahead Of Time)。这样,应用的启动和执行都会变得更加快速。

    ART模式与Dalvik模式最大的不同在于, 在启用ART模式后, 系统在安装应用的时候会进行一次预编译, 在安装应用程序时会先将代码转换为机器语言存储在本地, 这样在运行程序时就不会每次都进行一次编译了, 执行效率也大大提升。从这方面来看, ART模式确实能够改善Android平台一直以来在兼容性方面的妥协, 但另一方面, 应用经过预编译后的容量, 以及应用是否兼容该模式也是需要重点考虑的问题。

为了解决上面的缺点, Android 7.0 版本中的ART 加入了即时编译器JIT ,作为AOT 的一个补充,在应用程序安装时并不会将字节码全部编译成机器码,而是在运行中将热点代码编译成机器码,从而缩短应用程序的安装时间井节省了存储空间。

4.1.1 背景

    与iOS相比,Android的用户体验总是差强人意。随着Google的全力推动和硬件厂商的响应,Android 还是跨越各种阻碍, 逐渐壮大起来了。在此过程中, Google也在经历着重大的变化。从Android4.0开始,Android拥有了自己的设计语言和应用设计指导。与此同时, Google 也在着手解决卡顿问题,Android4.1使系统和应用运行都更加顺畅,而Android4.2提升了内存管理,使得系统能够顺利运行在硬件配置低端的设备上。

    但所有这些都没有解决核心问题, 那就是应用运行环境: DalvikVM效率并不是最高的。从Android4.4开始, Google开发者引进了新的Android运行环境ART。Android官方将其作为其新的虚拟机,以替代旧的Dalvik VM。

    根据一些基准测试,新的运行环境能够使大多数应用的执行时间减半。这意味着, CPU消耗大、运行时间长的应用能够更加快速地完成,而一般的应用也能更加流畅, 比如动画效果更顺畅,触控反馈更加即时。在多核处理器的设备上,多数情况下只需激活少量的核心,或者能够更好地利用ARM的big.LITTLE架构。另外,它将会显著提开电池的续航能力以及系统的性能。

    预编译也会带来一些缺点:1. 机器码占用的存储空间更大。字节码变为机器码之后,可能会增加10%~20%,不过在应用包中,可执行的代码常常只是一部分。比如最新的Google+ APK是28.3MB,但是代码只有6.9MB。2.应用的安装时间会变长。至于延长多少时间,取决于应用本身;

4.1.2 Dalvik时期的系统性能提升

    Dalvik虚拟机作为Android平台的核心组成部分之一, 允许在有限的内存资源中同时运行多个虚拟机实例。Dalvik虚拟机通过以下方式提升性能。

  1. DEX代码安装时或第一次动态加载时odex化处理;
  2. Android2.2版本提供了JIT机制提升性能, 号称性能提升3-5倍;
  3. 提升硬件配置, 如更多核CPU、更高频率CPU、更大的RAM等;

    即便如此Android的系统流畅度与iOS系统还是有一定差距。Android代码必须运行在Dalvik虚拟机上,而iOS直接是本地代码,性能差距一目了然。如果Android系统想拥有与iOS系统相同的系统性能, Dalvik虚拟机运行机制就成为Android系统性能提升唯一的障碍。

4.1.3 ART架构

    ART完全兼容Dalvik的字节码格式dex, 因此, 开发者编写软件不会受到影响,也无需担心兼容性问题。ART的一大变化是,它不仅支持即时编译(JIT), 而且支持预先编译(AOT)在Dalvik 上, 每次软件运行,都需从字节码编译为原生代码,ART可以只编译一次。然后,软件每次运行时, 执行编译好的原生代码。预先编译也为新的优化带来了可能性。同时, 这也会明显改善电池续航, 因为软件运行时不用编译了, 从而减少了CPU 的使用频率, 降低了能耗

    ART也有一些缺点。1.设备首次启动以及应用的首次启动时间会变长;2.原生代码占用空间更大,不过现在设备的空间是足够的;

4.1.4 垃圾回收

    Android 虚拟机是自动内存管理,这种方式的优点是开发者无需担心内存管理, 缺点是开发者失去了控制权,依赖于系统本身的机制。Dalvik的垃圾回收机制是造成系统卡顿的原因之一。在Dalvik 虚拟机下, 启动垃圾回收机制会造成两次暂停(一次在遍历阶段, 一次在标记阶段)。所谓暂停,就是应用的所有线程都不再执行。如果暂停时间过长,应用渲染中就会出现掉帧。用户体验上来说,就是应用运行的时候出现卡顿。

    Google 宣称, Neuxs 5 的平均暂停时间是54ms, 结果就是, 每次垃圾回收启动, 平均掉帧是4帧。如果应用编写得不好, 情况会更加糟糕。Anandtech 测试了FIFA 游戏。在 Dalvik 环境下: 启动应用的几秒内, 垃圾回收启动9 次, 应用暂停时间总和为603 ms, 总共掉帧为214帧。在 ART 环境下: 情况有了极大改善。同样时间里, 应用暂停时间总和为12.364ms (4次前台垃圾回收, 2 次后台垃圾回收), 总共掉帧是63 帧。

    ART能够做到这一点,是因为应用本身做了垃圾回收的一些工作。**垃圾回收启动后,不再是两次暂停, 而是一次暂停。**在遍历阶段,应用不需要暂停,而标记阶段的暂停时间也大大缩短,因为Google 使用了一种新技术(packard pre-cleaning),在暂停前就做了许多事情,减轻了暂停时的工作量。Google 承诺,他们己经把平均暂停时间降到了3 ms,远远超过Dalvik的垃圾回收。

    Google 还改进了内存分配系统, 使分配速度加快了10 倍。垃圾回收算法也进行了修改, 以增强用户体验, 避免应用被打断。

4.1.5 64bit支持

    ART支持64位系统,这会带来性能上的提升,加密能力的大幅改进,同时保持与现有32位应用的兼容性。与苹果不同的是,Google 使用了指针压缩,以避免转换到64位后,空间占用大幅增加,其虚拟机仍然是32 位指针。

    Google 宣称, 现有Play Store 上应用中, 85%都可以转移到64位, 剩下的15%有原生代码,需要重新编译。

    综上所述,Google兑现了其提升性能的承诺, 解决了困扰Android性能的诸多问题。原来Android的一些致命弱点是因为非原生应用和自动内存管理系统,ART在这些方面做出了大量改进。

4.1.6 ART运行时堆

    与DVM 的GC不同的是,ART采用了多种垃圾收集方案,每个方案会运行不同的垃圾收集器,默认是采用了CMS (Concurrent Mark-Sweep)方案,该方案主要使用了sticky-CMS和partial-CMS。根据不同的CMS方案,ART的运行时堆的空间也会有不同的划分,默认是由4个Space和多个辅助数据结构组成的,4个Space分别是Zygote Space 、Allocation Space 、Image Space 和Large Object Space 。Zygote Space 、Allocation Space 和DVM 中的作用是一样的,Image Space用来存放一些预加载类,Large Object Space用来分配一些大对象(默认大小为12阻),其中Zygote Space和Image Space是进程间共享的。采用标记一清除算法的运行时堆空间划分如下图所示的 “采用标记一清除算怯的运行时堆空间划分”。

    除了这四个Space, ART 的Java 堆中还包括两个Mod Union Table ,一个Card Table,两个Heap Bitmap ,两个Object Map ,以及三个Object Stack。

4.2 ART优化机制简介

    安装应用程序时,Dalvik Runtime采用的代码优化方式为:dex2opt;ART Runtime采用的代码优化方式为:dex2oat;

    Dalvik运行环境和ART运行环境产生的优化代码路径及文件名都在:/data/dalvik-cache/arm中。ART环境产生的优化代码文件大小明显大于Dalvik环境产生的大。

    以cm-14.1 Android系统源码为例,ART源码目录为:

maxingrong@soc02:~/LineageOS/art$ ls
Android.mk  build         cmdline   dalvikvm  dexdump  disassembler  libart_fake             NOTICE   patchoat  runtime      test
benchmark   CleanSpec.mk  compiler  dex2oat   dexlist  imgdiag       MODULE_LICENSE_APACHE2  oatdump  profman   sigchainlib  tools
  1. compiler:主要负责Dalvik字节码到本地代码的转换,编译为 libart-compiler.so;
  2. dex2oat:完成DEX文件到ELF文件转换,编译为dex2oat;
  3. runtime:Android ART 运行时源代码,编译为 libart.so;

5 Dalvik 和 ART 在Android系统中的创建

    正如我在[日更-2019.4.20、21] cm-14.1 Android系统启动过程分析(二)-Zygote进程启动过程 init进程在启动Zygote时调用app_main.cpp中的main函数:

    其所在源码目录为:frameworks/base/cmds/app_process/app_main.cpp

int main(int argc, char* const argv[])
{
......
    //若zygote为true,说明当前运行在Zygote进程中,调用AppRuntime的start函数
    if (zygote) {
        runtime.start("com.android.internal.os.ZygoteInit", args, zygote);
    } else if (className) {
        runtime.start("com.android.internal.os.RuntimeInit", args, zygote);
    } else {
        fprintf(stderr, "Error: no class name or --zygote supplied.\n");
        app_usage();
        LOG_ALWAYS_FATAL("app_process: no class name or --zygote supplied.");
        return 10;
    }
}

    当zygote = true时,说明进程运行在Zygote进程中,则会调用AppRuntime的start方法,其所在源码目录为:~/LineageOS/frameworks/base/core/jni/AndroidRuntime.cpp

void AndroidRuntime::start(const char* className, const Vector<String8>& options, bool zygote)
{
......
    JniInvocation jni_invocation;
    //【5-1】
    jni_invocation.Init(NULL);
    JNIEnv* env;
    //【5-2】启动Java虚拟机;
    if (startVm(&mJavaVM, &env, zygote) != 0) {
            return;
        }
    onVmCreated(env);
    //【5-3】为Java虚拟机注册JNI方法;
    if (startReg(env) < 0) {
        ALOGE("Unable to register all android natives\n");
        return;
    }
}

    在注释【5-2】处调用startVm函数来创建Java虚拟机,在注释【5-3】处调用startReg函数来为虚拟机注册JNI方法。

    在注释【5-1】处调用jni_invocation的Init方法,它主要作用是初始化ART或者DVM的环境,初始化完毕后会调用startVm函数来
启动相应的虚拟机
。该方法所在源码目录:~/LineageOS/libnativehelper/JniInvocation.cpp

bool JniInvocation::Init(const char* library) {
#ifdef __ANDROID__
  char buffer[PROP_VALUE_MAX];
#else
  char* buffer = NULL;
#endif
  //【5-4】返回libart.so或libdvm.so;
  library = GetLibrary(library, buffer);
  // Load with RTLD_NODELETE in order to ensure that libart.so is not unmapped when it is closed.
  // This is due to the fact that it is possible that some threads might have yet to finish
  // exiting even after JNI_DeleteJavaVM returns, which can lead to segfaults if the library is
  // unloaded.
  const int kDlopenFlags = RTLD_NOW | RTLD_NODELETE;
  //【5-5】利用dlopen函数加载此库;
  handle_ = dlopen(library, kDlopenFlags);
  if (handle_ == NULL) {
    if (strcmp(library, kLibraryFallback) == 0) {
      // Nothing else to try.
      ALOGE("Failed to dlopen %s: %s", library, dlerror());
      return false;
    }
......
  return true;
}

    在注释【5-4】处调用了GetLibrary函数:

//【5-6】代表在Android平台
#ifdef __ANDROID__
//【5-7】persist.sys.dalvik. vm.lib.2 是一个系统属性,它的取值可以为libdvm.so 或者libart.so ,值为libdv皿so 说明当前用的是DVM ,值为libart.so说明当前用的是ART;
static const char* kLibrarySystemProperty = "persist.sys.dalvik.vm.lib.2";
static const char* kDebuggableSystemProperty = "ro.debuggable";
#endif
static const char* kLibraryFallback = "libart.so";

template<typename T> void UNUSED(const T&) {}

const char* JniInvocation::GetLibrary(const char* library, char* buffer) {
#ifdef __ANDROID__
  const char* default_library;

  char debuggable[PROP_VALUE_MAX];
  __system_property_get(kDebuggableSystemProperty, debuggable);
  //【5-8】如果debuggable 不等于” l ”,说明当前不是Debug 模式构建的,是不允许动态更改虚拟机动态库的;
  if (strcmp(debuggable, "1") != 0) {
    // Not a debuggable build.
    // Do not allow arbitrary library. Ignore the library parameter. This
    // will also ignore the default library, but initialize to fallback
    // for cleanliness.
    //【5-9】将libart.so 赋值给library;
    library = kLibraryFallback;
    【5-10】将libart.so 赋值给default_library;
    default_library = kLibraryFallback;
  } else {
    // Debuggable build.
    // Accept the library parameter. For the case it is NULL, load the default
    // library from the system property.
    if (buffer != NULL) {
      //【5-11】如果是Debug 模式构建会在注释6 处读取persist.sys.dalvik.vm.lib.2 配置中是否有传人的参数buffer,如果有就将default_library赋值为buffer,如果没有将default_library赋值为libart.so;
      if (__system_property_get(kLibrarySystemProperty, buffer) > 0) {
        default_library = buffer;
      } else {
        default_library = kLibraryFallback;
      }
    } else {
      // No buffer given, just use default fallback.
      default_library = kLibraryFallback;
    }
  }
#else
  UNUSED(buffer);
  //【5-12】如果不是在Android 平台,default_library的值为libart.so
  const char* default_library = kLibraryFallback;
#endif
  if (library == NULL) {
    //【5-13】如果library 为NULL 就将default_library 赋值给library 井返回该library;
    library = default_library;
  }

  return library;
}

    综上,虚拟机实例是在Zygote 进程中诞生的,这样Zygote进程就持有了DVM或者ART的实例,以后Zygote进程fork自身创建应用程序进程时,应用程序进程也得到了DVM或者ART的实例,这样就不需要每次启动应用程序进程都要创建DVM或者ART,从而加快了应用程序进程的启动速度

Enjoy it !!

发布了48 篇原创文章 · 获赞 5 · 访问量 7802

猜你喜欢

转载自blog.csdn.net/Xiaoma_Pedro/article/details/103901750