找回密码
 会员注册
查看: 19|回复: 0

解密JVM崩溃(Crash):如何通过日志分析揭开神秘面纱

[复制链接]

3

主题

0

回帖

10

积分

新手上路

积分
10
发表于 2024-10-9 08:06:39 | 显示全部楼层 |阅读模式
目录一、前言二、什么是崩溃?三、一个例子四、崩溃日志详解????1. 文件路径????2. 信息摘要五、core文件? ? 1. 问题调用栈? ? 2. 帧快照? ? 3. 汇编源码还原? ? 4. 内存映射六、一些经验 ? ??1. 虚拟机崩溃的原因分类? ? 2. 留意JNI? ? 3. 敢于怀疑七、写在最后一前言当使用Java来构建一个复杂的软件系统时,系统偶发性崩溃(也会被称为Crash)是一个不小的挑战。这种情况不出现则已,一出现可能会对系统的稳定性和可靠性产生相当大的负面影响,同时也会给终端用户带来不良体验。在本文中,我们将基于崩溃的现场进行深入探讨以及如何通过技术手段来识别、调试和解决这些问题。同时我们将深入研究如何利用现代开发工具和最佳实践来减少系统崩溃的可能性,进而提高系统的稳定性和可靠性。?二什么是崩溃?简而言之,就是我们常说的 - 系统挂了,进程消失了。当发生一般的问题情况时,研发人员就像是消防员一样,需要火速赶到现场,分析日志,检查堆栈,然后试图重现并解决问题。这个过程就像一场紧急救援任务,需要迅速、果断的行动,同时也需要谨慎对待,以避免引入更多问题。但偶发性崩溃会更难处理。因为系统留给我们可用于排查的信息并不够多,甚至于重启后这样的崩溃再也不会发生。这个解决问题的过程就像是一场复杂的推理游戏,需要耐心和技巧。幸运的是,虚拟机还是给我们留下了一点蛛丝马迹供我们来进行深入的定位和跟踪。?三一个例子为了更进一步深入的探寻虚拟机崩溃背后的细节,我尝试给出一段异常代码:import sun.misc.Unsafe;import java.lang.reflect.Field;public class Crash { public static void main(String[] args) throws NoSuchFieldException, IllegalAccessException { Field unsafeField = Unsafe.class.getDeclaredField("theUnsafe"); unsafeField.setAccessible(true); Unsafe unsafe = (Unsafe) unsafeField.get(null); unsafe.getInt(-1); }}Unsafe类是一种非常强大且危险的工具,它提供了直接操作内存和执行低级别代码的能力。这个类通常被用于开发高性能的并发框架和底层操作系统相关的功能。由于使用Unsafe类可以绕过Java语言的一些安全检查,因此需要非常小心地使用,否则可能会导致严重的内存错误和安全漏洞。如上代码所示,我们尝试在一个非法的内存区域读取数据。当执行完成后我们立刻会在控制台看到如下输出:好了,我们的程序此刻已经崩溃了。虚拟机在也在崩溃的控制台日志清楚的提示我们“A fatal error has been detected by the Java Runtime Environment.”四崩溃日志详解上述控制台日志只是对于当前虚拟机崩溃的一个概要说明。在崩溃时虚拟机会帮我们生成两份日志文件。一份是进程的coredump文件,由操作系统负责生成;另一份是Java虚拟机自己在崩溃时生成。这两部分日志对我们问题的排查都有极大的作用。这里我们着重介绍Java虚拟机生成的这部分日志的细节。文件路径当Java程序崩溃时,默认情况下会在当前进程运行目录来生成形如hs_err_pid%p.log?的文件。这里的%p为通配符,系统会自动转化为当前的进程PID。例如Java进程编号为1941生成的文件名即为hs_err_pid1941.log。同时,虚拟机还提供了-XX:ErrorFile选项来帮助我们自定义文件的生成位置。比如我们想要把文件存储到系统任意指定目录下,可以指定启动参数形如:-XX:ErrorFile=/home/admin/hs_err_pid%p.log。需要注意的是,如果由于一些异常原因在工作目录无法存储(比如写入权限不足),则该文件会被存储在操作系统的临时目录下。在Linux操作系统环境下,这个地址是/tmp。?信息概要崩溃日志包含在系统崩溃发生时获取到的相关信息,具体包括如下(包括但不限于):导致进程崩溃的操作异常或信号版本和配置信息导致进程崩溃的线程的详细信息和线程的堆栈跟踪正在运行的线程列表及其状态关于堆的摘要信息加载的本地库列表命令行参数环境变量关于操作系统和CPU的详细信息而在Java虚拟机崩溃时,生产的崩溃日志文件默认会分为4大部分内容:提供崩溃简要描述的头部信息线程信息进程信息系统信息为了方便查阅,以下的章节中,我在认为相对关键的地方都打上了星号(※),用来表示该小节内容在问题排查时要多留意,是日志能给出线索的相对高发地点。?头部信息一个示例:虚拟机收到意外信号发生崩溃如上所示,崩溃日志的开始部分其实就是我们在控制台刚才看到的那部分内容,崩溃日志文件的头部用简明扼要的方式描述了Java进程崩溃的根本原因。在这个区域我们暂时只需要关心3个信息。※ 崩溃原因崩溃的具体原因可见图中第二行,这里一般会给出系统崩溃的原因。我们可以简单的分析为下图: SIGSEGV (0xb) at pc=0x417789d7, pid=21139, tid=1024 | | | | +--- 线程ID | | | +------------- 进程ID | | +--------------------------- 程序计数器 | +--------------------------------------- 信号编号 +---------------------------------------------- 信号名称这里有三个信息极为重要:信号名称、程序计数器、线程ID。它会是我们后续排查问题用到的前置信息,在此先按下不表,下文中会陆续说明。※ 问题帧栈第二个关键的点是出问题的帧栈,这等于是告诉了我们代码是运行在什么地方触发了上面的崩溃原因。比如下方的示例,我们可以看到是执行到了虚拟机内部代码(用大写字母V来表示)的函数Unsafe_GetNativeInt进而触发了系统崩溃。# Problematic frame:# V [libjvm.so+0xa6d702] Unsafe_GetNativeInt+0x52栈帧(frame)表示程序运行时函数调用栈中的某一帧。简单来说可以理解为调用某个方法。这里帧栈前面的大写字母也有特定含义。※ 核心转储文件头部信息里有一句话也很重要。这意味着进程在崩溃的时候顺便帮我们生成了核心转储文件(coredump)。关于核心转储文件,它是操作系统在进程收到某些信号而终止运行时,将此时进程地址空间的内容以及有关进程状态的其他信息写入一个磁盘文件。相比Java本身的崩溃文件,他后续还可以使用诸如gdb的工具进行调试。核心转储文件对于问题调试还是非常有用的:完整性和全局视角:操作系统的coredump包含了整个进程的内存转储,包括了JVM 本身的内存以及 JVM 进程所依赖的操作系统资源的状态。这提供了更全面的视角,有助于诊断问题的根源。内存信息:操作系统的coredump包含了进程的完整内存镜像,包括了堆和栈的信息,这对于分析内存状态以及发现内存相关的问题非常重要。操作系统资源状态:coredump还可以提供操作系统级别的资源状态信息,例如打开的文件、网络连接等。这些信息可以帮助诊断和解决一些与操作系统资源相关的问题。与调试器的兼容性:一些调试器可能更喜欢使用操作系统的coredump进行分析和调试。在某些情况下,使用coredump可能更容易进行深入的调试和分析。线程信息这部分包含了刚刚崩溃的线程的信息,大部分情况下,这里是我们需要核心关注的点。虚拟机从线程视角清晰的描绘了当系统崩溃的那一时刻的数据情况。※ 线程概要?虚拟机开门见山的给出了线程的基本信息。我们可以用下方的图示来简要说明:7feed0009800 JavaThread "main" [_thread_in_vm, id=37696, stack] | | | | | +--------- 线程栈的范围 | | | | +----------------- 线程ID | | | +----------------------------- 线程状态(仅限Java线程) | | +------------------------------------------- name(线程名称) | +---------------------------------------------------- type(线程类型) +-------------------------------------------------------------- pointer(线程内存指针)上述线程信息我们可以主动先尝试忽略部分内容(比如线程内存指针是指向虚拟机内部线程结构的指针,大部分情况下我们排查问题都用不到),我们这里可以优先关注2个内容,以上图为例:※ 线程类型如上图所示,我们的线程类型是JavaThread。在Hotspot虚拟机中,线程的类型有很多种:JavaThreadVMThreadCompilerThreadGCTaskThreadWatcherThreadConcurrentMarkSweepThreadJvmtiAgentThreadServiceThreadCodeCacheSweeperThread...它们有的负责Java代码的编译、有的负责GC的执行、有的负责缓存的清理,最终各司其职完成了虚拟机内部的各种操作。上述例子中的JavaThread我们暂时可以认为他是一个标准的Java代码启动的线程。※ 线程状态上述信息里还有一个关键信息是_thread_in_vm,它表示了线程当前所处的状态。这些状态基本上和我们熟知的线程状态所对应。在一些比较极限的场景下,还会有一些瞬时的“中转”状态。Hotspot虚拟机在这里将线程的状态划分的更为清楚,便于技术人员第一时间知道线程到底处于一种什么样的状态下,进而可以更快速的定位到具体的问题。※ 信号量概要接下来,虚拟机在这里描述了导致进程终止的意外信号。在本文的示例中,异常码是11,它对应的信号量是SIGSEGV,这是崩溃时最为常见的一种信号量,意味着当前进程正在从非法内存值读取内容(si_addr)。简单来说,每个进程所能读写的内存是有一定范围的,如果超出这个范围进行内存读写是会被操作系统认为是非法的操作。SIGSEGV同时下面有2种不同的si_code,分别是SEGV_MAPERR(未映射的地址)、SEGV_ACCERR(无效的访问许可)。我们知道了信号量的概念后,再聊点题外话,SIGSEGV的本质是内存的非法访问,这其实是非常严重的错误。所以很多语言碰到类似问题会默认让系统崩溃(因为非法访问的后果谁也不知道,所以干脆崩了算了避免更意外的情况),Java虚拟机当然也遵从了这个约定。但是在Java虚拟机内部,有两种特殊的场景本质也是内存非法访问且接收到了SIGSEGV,但系统并没有崩溃且还能继续运行:NullPointerExceptionStackoverflowError这种例外情况我们需要特殊说明。我们先来看看一个标准的SIGSEGV信号到来时进程的时序:进程正常执行指令内存非法访问进程收到操作系统发来的SIGSEGV信号量若进程注册了信号回调函数,则执行,否则默认结束进程上面的关键就在最后的回调函数。如果进程主动注册了信号回调函数,则可以在注册的函数内部选择性的忽略指定信号(这样系统就不会挂了),来提升整体的代码稳定性和健壮性。Java虚拟机内部的处理源码感兴趣的可以见下方:==================== os_linux_x86.cpp ==================== extern "C" JNIEXPORT intJVM_handle_linux_signal(int sig, siginfo_t* info, void* ucVoid, int abort_if_unrecognized) { // 这里处理StackoverflowError的情况 // Handle ALL stack overflow variations here if (sig == SIGSEGV) { address addr = (address) info->si_addr; // check if fault address is within thread stack if (thread->on_local_stack(addr)) { // stack overflow if (thread->in_stack_yellow_reserved_zone(addr)) { if (thread->thread_state() == _thread_in_Java) { if (thread->in_stack_reserved_zone(addr)) { frame fr; if (os:inux::get_frame_at_stack_banging_point(thread, uc, &fr)) { assert(fr.is_java_frame(), "Must be a Java frame"); frame activation = SharedRuntime::look_for_reserved_stack_annotated_method(thread, fr); if (activation.sp() != NULL) { thread->disable_stack_reserved_zone(); if (activation.is_interpreted_frame()) { thread->set_reserved_stack_activation((address)( activation.fp() + frame::interpreter_frame_initial_sp_offset)); } else { thread->set_reserved_stack_activation((address)activation.unextended_sp()); } return 1; } } } // Throw a stack overflow exception. Guard pages will be reenabled // while unwinding the stack. thread->disable_stack_yellow_reserved_zone(); stub = SharedRuntime::continuation_for_implicit_exception(thread, pc, SharedRuntime::STACK_OVERFLOW); } else { // Thread was in the vm or native code. Return and try to finish. thread->disable_stack_yellow_reserved_zone(); return 1; } } else if (thread->in_stack_red_zone(addr)) { // Fatal red zone violation. Disable the guard pages and fall through // to handle_unexpected_exception way down below. thread->disable_stack_red_zone(); tty->print_raw_cr("An irrecoverable stack overflow has occurred."); // This is a likely cause, but hard to verify. Let's just print // it as a hint. tty->print_raw_cr("Please check if any of your loaded .so files has " "enabled executable stack (see man page execstack(8))"); } else { // Accessing stack address below sp may cause SEGV if current // thread has MAP_GROWSDOWN stack. This should only happen when // current thread was created by user code with MAP_GROWSDOWN flag // and then attached to VM. See notes in os_linux.cpp. if (thread->osthread()->expanding_stack() == 0) { thread->osthread()->set_expanding_stack(); if (os:inux::manually_expand_stack(thread, addr)) { thread->osthread()->clear_expanding_stack(); return 1; } thread->osthread()->clear_expanding_stack(); } else { fatal("recursive segv. expanding stack."); } } } } ...... // 这里处理NullPointerException的情况 if (sig == SIGSEGV & !MacroAssembler::needs_explicit_null_check((intptr_t)info->si_addr)) { // Determination of interpreter/vtable stub/compiled code null exception stub = SharedRuntime::continuation_for_implicit_exception(thread, pc, SharedRuntime::IMPLICIT_NULL); } } } ... // StackoverflowError和NullPointerException会主动返回并被记录, 系统不挂 if (stub != NULL) { // save all thread context in case we need to restore it if (thread != NULL) thread->set_saved_exception_pc(pc); os:inux::ucontext_set_pc(uc, stub); return true; } ... // 虚拟机不主动处理的信号到达这里会触发系统挂掉 VMError::report_and_die(t, sig, pc, info, ucVoid); ShouldNotReachHere(); return true; // Mute compiler}※ 寄存器错误日志中的下一个信息显示了致命错误发生时的寄存器上下文。这个输出的确切格式取决于处理器。当与[指令上下文]这一节结合来看时,寄存器的数值可能会很有用。在当前基本都是x86-64的架构下,寄存器的简单描述如下:但我们这里如果单纯记每个寄存器的作用的话未免过于枯燥和乏味,我们需要的是找到系统崩溃的原因。除了和指令结合使用,这里也可以优先关注下通用寄存器中的内存值,很多时候是个初步的突破口。以上图为例,64位操作系统下用户空间地址范围为[0-0x00007FFFFFFFF000],我们对照上述寄存器内的数据来看很快便发现R12寄存器的地址是0xffffffffffffffff。这显然不是一个合法的地址,从而也进一步的印证了日志开头给出的SIGSEGV错误。?栈地址进一步的,虚拟机给出了出问题的线程的帧栈地址数据,如果不考虑后面的core文件分析的情况下,大多数场景下这些密密麻麻的字符对我们来说可参考的意义并不大,可以简短了解即可。虚拟机给出了sp指针指向的地址,这会和上面的rsp寄存器(栈顶)位置对应。X86-64的机器上,每一行对应了16个字节,后续两列每一列的内容16进制输出。※ 指令上下文指令上下文输出了出问题前后的64字节的操作码。这在一些比较难以排查的问题场景下还是很有极大作用的。简单来说,虽然我们处在Java的语言环境里,但和操作系统的背后交互其实还是一条条毫无感情的机器码。而上述地址对应的指令数据就是背后要执行的机器码。但因为机器码过于晦涩,我们的前人才搞出了汇编语言这么个东西让开发的过程变得不那么繁琐。而这些操作码可以通过反汇编器进行解码,以生成崩溃位置附近的指令。上述pc地址对应的汇编是(网上有很多在线反汇编解码工具):0x00007faaf1397702: 45 8B 24 24 mov r12d, dword ptr [r12]0x00007faaf1397706: C6 80 3C 03 00 00 00 mov byte ptr [rax + 0x33c], 00x00007faaf139770d: 48 8B 7B 50 mov rdi, qword ptr [rbx + 0x50]由于我们已经已知R12的地址有问题,无法读取自己管控内存地址之外的数据。所以我们很快可以初步判定是这一行汇编的问题:mov r12d, dword ptr [r12]。它的含义是从R12寄存器取双字读取32位的值,然后保存在R12寄存器的低位中。在结合上下文函数帧知道出错的地方是unsafe的getInt方法,而getInt方法的入参的含义本身就需要传入准确的地址信息,那么这个问题的答案就基本已经付出水面了。※ 寄存器内容这部分可以看做是对于之前的寄存器那一节的具体说明。之前相对晦涩的地址,在这里虚拟机给出了当前地址代表的具体内容,以截图为例:RAX、RBX、R15是线程RCX、R11指向动态链接库中RDX、RSP、RBP、R14均指向线程中R8、R13指向某个具体方法R10指向解释模式中的某个代码片段RSI、RDI、R9、R12均指向一个未知的值(an unknown value)这里还是要说明,未知的值并不一定是问题,但是如果地址本身就是一个非法地址则是需要重点关注的事情。※ 帧栈明细?按照顺序,接下来的输出是线程帧栈的细节,如上所示。首先给出的是问题栈的区间和栈顶地址以及剩余的空间,接着,如果可能的话会打印堆栈帧且最多打印 100 个帧。对于 C/C++ 帧,可能还会打印库名称。然后为了更清楚的看到调用帧的明细,虚拟机把这里的内容分成了两部分:Native frames?Java frames??简单来说他们的区别是Native帧没有考虑到由运行时编译器内联的Java方法,而Java帧会考虑内联。同时Native帧会打印线程的所有函数调用,相对Java帧会更详细。?进程信息※ 进程里的线程?首先映入眼帘的是进程内的线程信息,这里展示了虚拟机内部的Java线程以及其它线程信息。特别需要注意的是“=>”这个符号,它标识了哪个线程是当前的线程。具体线程的描述过程在线程信息这一节已经说过,在此不做赘述。※ 虚拟机安全状态接下来的内容是虚拟机的安全点状态信息。注意这里的状态描述 not at safepoint。这里的虚拟机状态可分为3种情况:not at safepoint (正常情况)at safepoint (所有线程处于安全点等待VM进行一些重要的操作。如GC、Debug等)synchronizing (这是个一个瞬时的状态。VM等待所有线程到达safepoint,已经达到safepoint的线程会挂起)很自然的,如果你看到这里的VM state显示的如果是at safepoint,那就要稍微留意一下和GC相关的细节(虽然safepoint并不代表一定有GC)。关于safepoint更多的细节,可见之前我写的一篇文章Java程序陷入时间裂缝:探索代码深处的神秘停顿|得物技术?锁&监视器接下来,虚拟机给出了当前线程拥有的互斥锁Mutex和监视器Monitor列表。特别需要注意的是:这些互斥锁和监视器是虚拟机内部的锁和监视器,而不是与Java对象相关的锁和监视器。所以绝大多数的崩溃文件下,这里的输出都是None。※ 内存使用摘要?这里是虚拟机堆区的内容,对于Java程序员来说,这块内容看着就熟悉多了。我们逐一来分析看看。内存基础模型heap address: 0x00000006c0000000size: 4096 MB这里交代了内存的基本信息,heap address 说明了当前我们进程的虚拟内存地址的起始地址。而size则表示当前进程预留(申请)了多大的内存,如图所示为4096MB,即4G。这些信息得记下来,如果遇到诸如物理内存不足的问题,这些都是关键的上下文信息。Compressed Oops mode: Zero basedOop shift amount: 3这里的 Compressed Oops mode 稍微有点难懂,它表示了虚拟机在内部对于对象的压缩模式(也称之为压缩指针技术)。在32位系统上,对象指针通常占用4个字节(32位),而在64位系统上,对象指针通常需要8个字节(64位)。这意味着在64位系统上,对象指针的大小会比在32位系统上大。但因为我们现在基本上都在使用64位的系统,为了节省内存空间,JVM引入了压缩指针技术。这种技术通过在64位系统上使用更小的指针来表示对象的引用,从而减小了对象指针的大小。在压缩指针模式下,JVM会将对象指针压缩为32位,然后通过一些额外的计算来还原成对应的64位地址。在大内存服务场景下,带来的收益会相当可观。然后在Hotspot内部定义了四种模式分为四种 UnscaledNarrowOop(32位)、ZeroBasedNarrowOop(无基址的32位压缩)、DisjointBaseNarrowOop(分离基址压缩指针)、HeapBasedNarrowOop(指定基地址的32位压缩,可以用-XX:HeapBaseMinAddress来指定)。可以看到我们系统默认时会使用压缩指针来完成空间的节省。再来看看Oop shift amount,它实际上代表了内存对象压缩的偏移算法。在讲这个之前要提一下当前Hotspot虚拟机的一个重要基础:HotSpot虚拟机的内存模型要求对象起始地址和对象大小必须是8字节的整数倍,换句话说就是任何对象的大小都必须是8字节的整数倍,如果大小不足会强制填充(向上对齐)到8字节的整数倍。比如一个对象实际大小为12字节,那么实际在Hotspot内部存储会被对齐为16字节。这个基础的前提极为重要,虚拟机内部的多处优化都是因为有这个内存限制的前提才得以进行。我们继续说回Oop shift amount。它的值是3,虚拟机采用的对象压缩方式是地址右移,所以它的根本意思就是内存需要右移3位来存储,内存真正使用时按照左移3位来使用。由于对象和内存起始地址都是按照8字节对齐,所以内存地址的后3位必然是0,该过程不会丢失任何信息。所以一个完整的内存换算公式如下所示: + ( << 3) + 以上述截图中的地址0x00000006c02bb8e8来举例,它实际经过压缩后在虚拟机内部存储的地址是0 + (0x00000006c02bb8e8<<<3)>”来表示)。内存映射最后介绍的指令是i proc m(是info proc mappings的缩写)。这里输出含义跟上面提到的【内存映射明细】 类似。就不过多赘述了,总之还是告诉了开发者内存的上下范围界及其对应映射的文件在哪里。六一些经验虚拟机崩溃的原因分类?虽然系统崩溃的原因千奇百怪,但是大多数情况下,我们都可以将错误原因都可分为2种情况:内存非法访问物理内存不足内存非法访问最为常见,如本文中介绍的例子就是这样一种严重的错误情况。其次还有一种场景比较重要,那就是物理内存不足。当系统物理内存耗尽时它的错误原因大概长相如下图所示:物理内存不足系统崩溃虚拟机给出的原因及其解决方案物理内存的崩溃类型相比内存非法访问会更友好,它直接给出了研发可能的解决方案。留意JNIJNI(Java Native Interface)是Java与C语言之间进行交互的重要机制,但进行有效的JNI编程并不简单。开发者必须深入理解Java虚拟机的内部工作原理,以避免潜在的错误和问题。尤其是对于那些主要从事C语言开发的人员来说,缺乏对JVM原理的了解可能会导致程序在运行时出现意外的崩溃。在之前的章节中,我们提到了NullPointerException的机制。其本质是虚拟机拦截SIGSEGV信号并抛出异常而不终止进程。然而,如果第三方C语言组件在没有充分理解Java虚拟机的情况下,错误地注册了SIGSEGV信号处理回调函数,那么在Java进程出现NullPointerException时,系统会因为SIGSEGV的回调被覆盖导致进程崩溃。因此,在使用JNI时(包括应用依赖的第三方JNI组件),开发者必须特别注意,确保对JVM的工作原理有充分的认识,以维护系统的稳定性和可靠性。敢于怀疑Java虚拟机自身bug虽然相对概率小,但是在出现极端问题时也要适当考虑,毕竟大家都是人,写个bug么在所难免。避免持续性的陷入bug自证的死结中。可以来https://bugs.openjdk.org/projects/JDK/issues/ 利用你崩溃时的关键字尝试找找是否已经有人提出过类似的系统崩溃问题,有时候可能会事半功倍。?七写在最后在深入探讨JVM崩溃的各种细节及其解决方案后,我们可以看到,理解和掌握JVM的内在机制不仅对开发者至关重要,也对整个应用的稳定性和性能有着深远的影响。通过合理的配置、监控工具的使用和适当的调优策略,我们可以有效地降低JVM崩溃的风险。然而,面对复杂的系统,完全避免所有崩溃是困难的。关键在于具备快速诊断和恢复的能力,这要求我们在日常开发和运维中,持续关注系统的健康状态,并及时进行故障排查。希望本文能够为你在JVM的使用和维护上提供一些有价值的见解与帮助。?往期回顾1.?得物App白屏优化系列|图片库篇2.?基于MySQL内核的SQL限流设计与实现|得物技术3.?得物Flink内核探索实践4.?链路级资损防控之资损字段防控实践|得物技术5.?B端常用交互方式的量化及优化实践和指引|得物技术文 / 财神关注得物技术,每周一、三更新技术干货要是觉得文章对你有帮助的话,欢迎评论转发点赞~未经得物技术许可严禁转载,否则依法追究法律责任。“扫码添加小助手微信如有任何疑问,或想要了解更多技术资讯,请添加小助手微信:
回复

使用道具 举报

您需要登录后才可以回帖 登录 | 会员注册

本版积分规则

QQ|手机版|心飞设计-版权所有:微度网络信息技术服务中心 ( 鲁ICP备17032091号-12 )|网站地图

GMT+8, 2025-1-5 08:38 , Processed in 0.718161 second(s), 26 queries .

Powered by Discuz! X3.5

© 2001-2025 Discuz! Team.

快速回复 返回顶部 返回列表