说说new一个Java对象这件事儿——Java对象创建流程

说说new一个Java对象这件事儿------Java对象创建流程

PS:本文参考的JDK21 源码版本是:jdk-21-ga

Java对象的创建流程

好多苦逼大龄程序员天天被催婚、找不到对象,但是不用愁!本文手把手教学怎么在Java里面new一个对象带回家!

在面向对象编程语言中,对象是类的实例,在编程中一个类往往会对应一个或者多个对象。因此,当我们需要使用到类中非static的成员变量和方法的时候,就需要创建对应类的对象来进行使用。

假设我们要创建一个Object类,在Java代码中我们会使用new关键字来创建Object类的一个对象:

auto 复制代码
Object obj = new Object();  //创建一个Object对象,触发类加载和对象创建

如果此时Object类是首次调用,则JVM将.class字节码文件经过加载、验证、准备、解析和链接等步骤加载到JVM内存中的方法区。

接下来JVM就会开启真正的对象创建过程,依次给对象分配内存、初始化对象、设置对象头和调用构造方法,最后再返回对象的引用方便Java程序访问对象。

完整来说,Java对象的创建分为如下6个步骤:

  1. 类加载: 当程序运行时,JVM会将类的字节码(xxx.class文件)加载到内存中,包括加载、验证、准备、解析和链接等步骤,此时类的字节码文件会被存放在JVM内存的方法区当中。
  2. 分配内存: 在内存中分配对象所需的内存空间。具体的内存分配方式有很多种,包括堆上的对象分配、栈上的对象分配等,在主流的Java虚拟机中,大部分对象的内存分配发生在堆上。
  3. 初始化零值: 将对象的实例变量初始化为零值,数值类型的变量初始化为0,引用类型的变量初始化为null。
  4. 设置对象头: 对象头包含了一些用于管理对象的元信息,如哈希码、锁信息等。
  5. 调用构造方法: 执行对象的构造方法,进行对象的初始化。构造方法是类中用于初始化对象的特殊方法。
  6. 对象引用: 返回对象的引用,可以将引用赋给类变量、实例变量、局部变量等,从而使得程序可以操作这个对象。

通过上面的6个步骤,对象就成功创建了,Java代码中也拿到了对象的引用,可以通过引用去访问对象的相关属性值,下面详细介绍下对象流程当中的关键步骤,类加载流程则可以参考我前面的文章。

好的,对JDK 21源码不感兴趣的同学就可以到此为止,目前已经说清楚了Java对象创建的总体流程了。由于发现网上现存的大多数bolgs都是到此为止,个人感觉其实并没有把对象创建流程理解的很清楚,接下来我将从JDK21 的HotSpot源码角度来解析对象的创建流程。

源码分析------跟踪new关键字

创建Java类

一段朴实无华的Java代码,这里只是为了调用new关键字创建一个Java对象:

auto 复制代码
package com.tsinghualei;

public class NewObj {
    public NewObj() {
    }

    public static void main(String[] args) {
        new NewObj();
    }
}

java p -v 编译NewObj.class文件

这里编译Java源代码的步骤我就不赘述了,不会的可以参考我的文章:"Java大厦的基石------Java Class文件构成"

jclasslib查看字节码文件

main方法字节码如下:

auto 复制代码
new #7 <com/tsinghualei/NewObj>
3 dup
4 invokespecial #9 <com/tsinghualei/NewObj.<init> : ()V>
7 astore_1
8 return

上述字节码当中的核心命令为invokespecial #9,这个命令的作用就是调用Java类的方法来完成类的初始化,JVM在执行改指令之前会执行类的初始化相关代码(假设类字节码文件已实现加载到内存,具体类加载流程可以参考我的其它文章)------Java类一生有多长------Java类的生命周期(类加载机制)。

involespecial指令对应的HotSpot代码为:hotspot/src/cpu/x86/vm/templateTable_x86.cpp,需要注意的这里是针对x86平台实现的平台相关代码,不同的平台,如arm对应的相同的文件是:hotspot/cpu/arm/templateTable_arm.cpp,其中调用的想法都是平台特有方法,这也是JVM虚拟的意义,帮我们屏蔽平台特性,实现了跨平台。

Java中new关键字入口HotSpot代码(TemplateTable::_new())

hotspot/cpu/arm/templateTable_arm.cpp 中TemplateTable::_new()方法源码,这段代码是C++语言的汇编代码,用于实现某个类的实例化操作。下面是对代码的逐行解释:

auto 复制代码
// 定义TemplateTable类的_new()方法
void TemplateTable::_new() {
  // 调用transition方法,参数为vtos和atos
  transition(vtos, atos);
  // 获取常量池中索引为1的2字节无符号整数,存储到rdx寄存器中
  __ get_unsigned_2_byte_index_at_bcp(rdx, 1);
  // 定义一些标签用于后续的跳转
  Label slow_case;
  Label slow_case_no_pop;
  Label done;
  Label initialize_header;

  // 获取常量池和标签,存储到rcx和rax寄存器中
  __ get_cpool_and_tags(rcx, rax);

  // 确保即将实例化的类已经被解析
  const int tags_offset = Array<u1>::base_offset_in_bytes();
  __ cmpb(Address(rax, rdx, Address::times_1, tags_offset), JVM_CONSTANT_Class);
  __ jcc(Assembler::notEqual, slow_case_no_pop);

  // 获取InstanceKlass
  __ load_resolved_klass_at_index(rcx, rcx, rdx);
  // 保存klass的上下文,以初始化头部时使用
  __ push(rcx);

  // 确保klass已初始化并且没有finalizer
  __ cmpb(Address(rcx, InstanceKlass::init_state_offset()), InstanceKlass::fully_initialized);
  __ jcc(Assembler::notEqual, slow_case);

  // 获取InstanceKlass中的实例大小(以字节为单位)
  __ movl(rdx, Address(rcx, Klass::layout_helper_offset()));
  // 检查是否有finalizer或存在其他问题
  __ testl(rdx, Klass::_lh_instance_slow_path_bit);
  __ jcc(Assembler::notZero, slow_case);

  // 分配实例:
  // 如果启用TLAB:
  //   尝试在TLAB中分配
  //   如果失败,进入slow path
  //   初始化分配
  //   退出
  // 进入slow path
  const Register thread = LP64_ONLY(r15_thread) NOT_LP64(rcx);
  if (UseTLAB) {
    NOT_LP64(__ get_thread(thread);)
    __ tlab_allocate(thread, rax, rdx, 0, rcx, rbx, slow_case);
    if (ZeroTLAB) {
      // 字段已经被清除
      __ jmp(initialize_header);
    }

    // 实例化前已初始化对象。如果对象大小为零,直接进入头部初始化
    __ decrement(rdx, sizeof(oopDesc));
    __ jcc(Assembler::zero, initialize_header);

    // 初始化顶层对象字段,将rdx除以8,检查是否为奇数并测试是否为零
    __ xorl(rcx, rcx);
    __ shrl(rdx, LogBytesPerLong);
    __ bind(initialize_header);
    __ movptr(Address(rax, rdx, Address::times_8, sizeof(oopDesc) - 1*oopSize), rcx);
    NOT_LP64(__ movptr(Address(rax, rdx, Address::times_8, sizeof(oopDesc) - 2*oopSize), rcx));
    __ decrement(rdx);
    __ jcc(Assembler::notZero, initialize_header);

    // 仅初始化对象头部
    __ movptr(Address(rax, oopDesc::mark_offset_in_bytes()), (intptr_t)markWord::prototype().value());
    __ pop(rcx);
#ifdef _LP64
    __ xorl(rsi, rsi);
    __ store_klass_gap(rax, rsi);
#endif

    // 触发dtrace事件以进行fastpath
    {
      SkipIfEqual skip_if(_masm, &DTraceAllocProbes, 0, rscratch1);
      __ push(atos);
      __ call_VM_leaf(
           CAST_FROM_FN_PTR(address, static_cast<int (*)(oopDesc*)>(SharedRuntime::dtrace_object_alloc)), rax);
      __ pop(atos);
    }

    __ jmp(done);
  }

  // slow case
  __ bind(slow_case);
  __ pop(rcx);
  __ bind(slow_case_no_pop);

  Register rarg1 = LP64_ONLY(c_rarg1) NOT_LP64(rax);
  Register rarg2 = LP64_ONLY(c_rarg2) NOT_LP64(rdx);

  // 获取常量池
  __ get_constant_pool(rarg1);
  // 获取常量池中索引为1的2字节无符号整数,存储到rarg2寄存器中
  __ get_unsigned_2_byte_index_at_bcp(rarg2, 1);
  // 调用InterpreterRuntime::_new方法
  call_VM(rax, CAST_FROM_FN_PTR(address, InterpreterRuntime::_new), rarg1, rarg2);
  __ verify_oop(rax);

  // 继续执行
  __ bind(done);
}
}

这段代码主要实现了一个类的实例化过程,包括对类的解析、初始化、分配内存等操作,后面我将结合HotSpot源代码来说明Java对象的创建流程。

从HotSpot源码理解------Java类创建流程

类加载

Java类加载流程一般是在Java类的静态成员变量,静态成员函数被调用或者类的对象首次创建时会被加载,具体的类加载流程可以参考文章《Java类一生有多长------Java类的生命周期(类加载机制、双亲委派机制)》。在HotSpot中的new关键字相关源码当中,在创建对象之前会先检查类是否加载,加载类的部分主要由以下几行组成:

cpp 复制代码
// 获取常量池引用
__ get_cpool_and_tags(rcx, rax);

// 检查类是否已加载
const int tags_offset = Array<>::base_offset_in_bytes();
__ cmpb(Address(rax, rdx, Address::times_1, tags_offset), JVM_CONSTANT_Class);
__ jcc(Assembler::notEqual, slow_case_no_pop);

// 获取已加载的类信息
__ load_resolved_klass_at_index(rcx, rcx, rdx);
__ push(rcx);  // 保存类引用

这些代码的主要作用是加载并检查类是否已经加载,具体来说:

  1. __ get_cpool_and_tags(rcx, rax);:获取常量池引用以及与类相关的标签信息。
  2. __ cmpb(Address(rax, rdx, Address::times_1, tags_offset), JVM_CONSTANT_Class);:通过比较常量池中指定索引处的项的标签是否为 JVM_CONSTANT_Class 来判断类是否已加载。
  3. __ jcc(Assembler::notEqual, slow_case_no_pop);:如果类未加载,则跳转到 slow_case_no_pop 标签,执行相应的慢路径逻辑。
  4. __ load_resolved_klass_at_index(rcx, rcx, rdx);:如果类已加载,通过索引从常量池中加载已解析的类(InstanceKlass)。
  5. __ push(rcx);:保存加载的类引用到栈上,以备后续使用。

这部分代码是加载并检查类的逻辑,如果类已经加载,则将其引用保存在栈上供后续使用,如果类未加载,则会通过跳转到慢路径执行相应的慢路径逻辑。

分配内存(InterpreterRuntime::_new)

在LAB缓存进行快速分配

cpp 复制代码
if (UseTLAB) {
    NOT_LP64(__ get_thread(thread);)
    __ tlab_allocate(thread, rax, rdx, 0, rcx, rbx, slow_case);
    // ... (省略其他部分)
}

如果启用了TLAB(Thread-Local Allocation Buffer),则尝试在TLAB中分配内存。如果分配失败,将跳转到slow_case标签,如果分配成功,将继续执行后续的对象初始化步骤。

慢分配

如果LAB当中快速分配失败,则会走慢路径(slow case)入口代码如下:

cpp 复制代码
// 调用InterpreterRuntime::_new方法
call_VM(rax, CAST_FROM_FN_PTR(address, InterpreterRuntime::_new), rarg1, rarg2);

方法call_VM(rax, CAST_FROM_FN_PTR(address, InterpreterRuntime::_new), rarg1, rarg2);调用 VM 中的 hotspot/share/interpreter/InterpreterRuntime::_new 方法,该方法通常用于慢路径中的对象分配,其代码如下:

auto 复制代码
// 创建新对象
JRT_ENTRY(void, InterpreterRuntime::_new(JavaThread* current, ConstantPool* pool, int index))
  Klass* k = pool->klass_at(index, CHECK);  // 从常量池获取类信息
  InstanceKlass* klass = InstanceKlass::cast(k);

  // 确保不实例化抽象类
  klass->check_valid_for_instantiation(true, CHECK);

  // 初始化类,主要初始化类中的静态变量、静态代码块
  klass->initialize(CHECK);

  // 分配对象,为对象分配内存,并实际新建对象
  oop obj = klass->allocate_instance(CHECK);
  
  current->set_vm_result(obj);
  
JRT_END

在这段代码中,klass->initialize(CHECK);oop obj = klass->allocate_instance(CHECK); 两行代码分别完成了不同的任务:

  1. 初始化类: klass->initialize(CHECK);:这一行代码是用于初始化类的阶段。在Java虚拟机中,类的初始化是一个重要的阶段,其中类的静态成员变量被赋予初始值,静态代码块被执行等。这个过程确保了在类被首次使用之前,它的状态是正确的。这一行代码可能会执行一些与类初始化相关的工作,包括静态变量的初始化等。
  2. 分配内存: oop obj = klass->allocate_instance(CHECK);:这一行代码是用于为特定的类分配一个新的对象实例。在Java中,当你通过new关键字创建一个对象时,实际上是在内存中为该对象的实例分配内存空间。这一行代码调用了allocate_instance方法,该方法会分配对象的内存空间并返回对象的引用。

在对象的创建过程中,通常需要先确保类已经初始化(因为对象的创建可能涉及到类的初始化过程,即类的静态成员变量,静态代码块已经执行),然后再分配对象的内存空间。这两个步骤通常是密切相关的,但在代码中可以分开来执行,以提高灵活性。

好了,对象的内存分配就解析道这里为止了,不然hotspot/share/oops/instanceKlass::allocate_instance方法跟踪下去又是很冗长的一部分内容,在这个方法里面JVM会根据Java对象的大小去堆中分配年轻代/老年代内存。这里我们只需要通过HotSpot代码知道JVM创建对象之间会去分配内存即可。

初始化零值

cpp 复制代码
// 定义线程寄存器,如果是64位架构,则使用r15_thread,否则使用rcx
const Register thread = LP64_ONLY(r15_thread) NOT_LP64(rcx);
// ...(省略其他部分)

// 初始化对象的顶层字段和剩余的对象字段
__ decrement(rdx, sizeof(oopDesc));
__ jcc(Assembler::zero, initialize_header);

// 使用异或操作清零rcx寄存器,并将rdx右移LogBytesPerLong位(相当于除以2^(LogBytesPerLong))
__ xorl(rcx, rcx);
__ shrl(rdx, LogBytesPerLong);

// 初始化剩余的对象字段
{ Label loop;
  __ bind(loop);
  // ...(省略其他部分)
  __ decrement(rdx);
  __ jcc(Assembler::notZero, loop);
}

// 仅初始化对象头部
__ bind(initialize_header);
// ...(省略其他部分)

这段代码的主要作用是对对象的字段进行初始化,这个初始化过程主要是将分配好的内存区域全部置为0值,包括顶层字段和剩余的对象字段。

在初始化过程中,__ decrement(rdx, sizeof(oopDesc)); 将寄存器 rdx 的值减去 sizeof(oopDesc) 的大小,然后检查是否为零。如果减去 sizeof(oopDesc) 后的值为零,说明对象的大小正好是 sizeof(oopDesc) ,也就是说这是一个空对象(没有额外字段),那么就直接跳转到 initialize_header 标签处。

如果不是,则继续执行接下来的loop,通过异或操作将rcx寄存器清零,这一步之后,对象中所分配的内存区域全部都置为了0,这样以来对象的所有成员变量都被置为了零值。这样做的目的是清空对象的所有内存区域,方便后续的初始化MarkWord,以及对象的其它信息。

然后,将rdx右移LogBytesPerLong位,最后通过循环逐个初始化剩余的对象字段。最后,跳转到initialize_header标签,就到了对象初始化的下一个步骤,设置对象头部。

设置对象头

对于设置对象头部的部分,代码是这样的:

cpp 复制代码
// Initialize object header only
__ bind(initialize_header);
__ movptr(Address(rax, oopDesc::mark_offset_in_bytes()),
(intptr_t)markWord::prototype().value()); // 设置对象头部

这段代码的作用是设置对象的头部信息。在这里,__ movptr 是一个汇编指令,用于将数据从一个位置移动到另一个位置。具体来说:

  • Address(rax, oopDesc::mark_offset_in_bytes()) 创建了一个地址对象,该地址指向 rax 寄存器中的地址加上 oopDesc::mark_offset_in_bytes() 的偏移量处。这个地址通常用于访问对象的头部信息。

  • (intptr_t)markWord::prototype().value() 计算了 markWord 类的原型的数值,并将其转换为 intptr_t 类型。这个数值通常包含有关对象的标记信息,例如对象的锁定状态等。

  • __ movptr 汇编指令将上述计算得到的标记信息复制到对象的头部地址中,从而设置了对象的头部信息。

这段代码的目的是将一个标记字(mark word)的数值设置到对象的头部,这个标记字包含了对象的一些元信息,如锁定状态等。这是在对象初始化过程中的一部分,用于确保对象的正确状态。

MarkWord

继续跟踪到类hotspot/share/oops/markWord.cpp的构造函数,发现其会是一个空构造函数,因此其内容在调用构造函数的时候都已经被初始化好了,都是一些默认值:

auto 复制代码
// markWord 类表示 HotSpot 虚拟机对象头的标记字段
class markWord {
 private:
  uintptr_t _value;  // markWord 的内部值

 public:
  // 显式构造函数,用于根据给定的值创建 markWord 实例
  explicit markWord(uintptr_t value) : _value(value) {}

  // 默认构造函数,默认初始化不设置 _value 的值
  markWord() = default;         // Doesn't initialize _value.

  // 获取 markWord 的值
  uintptr_t value() const { return _value; }

  // 常量定义
  static const int age_bits                       = 4;  // 年龄字段所占位数
  static const int lock_bits                      = 2;  // 锁定状态字段所占位数
  static const int first_unused_gap_bits          = 1;  // 未使用的第一个间隙的位数
  static const int max_hash_bits                  = BitsPerWord - age_bits - lock_bits - first_unused_gap_bits;
  static const int hash_bits                      = max_hash_bits > 31 ? 31 : max_hash_bits;  // 哈希字段所占位数
  static const int second_unused_gap_bits         = LP64_ONLY(1) NOT_LP64(0);  // 未使用的第二个间隙的位数

  static const int lock_shift                     = 0;  // 锁定状态字段的偏移量
  static const int age_shift                      = lock_bits + first_unused_gap_bits;  // 年龄字段的偏移量
  static const int hash_shift                     = age_shift + age_bits + second_unused_gap_bits;  // 哈希字段的偏移量

  // 锁定状态字段的掩码
  static const uintptr_t lock_mask                = right_n_bits(lock_bits);
  // 锁定状态字段在原地的掩码
  static const uintptr_t lock_mask_in_place       = lock_mask << lock_shift;
  // 年龄字段的掩码
  static const uintptr_t age_mask                 = right_n_bits(age_bits);
  // 年龄字段在原地的掩码
  static const uintptr_t age_mask_in_place        = age_mask << age_shift;
  // 哈希字段的掩码
  static const uintptr_t hash_mask                = right_n_bits(hash_bits);
  // 哈希字段在原地的掩码
  static const uintptr_t hash_mask_in_place       = hash_mask << hash_shift;

  // 锁定状态字段的锁定值
  static const uintptr_t locked_value             = 0;
  // 锁定状态字段的未锁定值
  static const uintptr_t unlocked_value           = 1;
  // 锁定状态字段的监视器值
  static const uintptr_t monitor_value            = 2;
  // 锁定状态字段的标记值
  static const uintptr_t marked_value             = 3;

  // 未分配哈希值
  static const uintptr_t no_hash                  = 0 ;
  // 未分配哈希值在原地的掩码
  static const uintptr_t no_hash_in_place         = (address_word)no_hash << hash_shift;
  // 未锁定状态字段在原地的掩码
  static const uintptr_t no_lock_in_place         = unlocked_value;

  // 年龄字段的最大值
  static const uint max_age                       = age_mask;
};

如上面代码所示,在Java HotSpot虚拟机中,每个对象都有一个对象头,其中的 markWord 负责存储对象的一些元信息和标记信息,主要作用如下:

  1. 存储标记信息: ``部分位用于标记对象的状态。常见的标记包括对象是否被锁定、对象的监视器状态、对象的标记状态等。通过这些标记,虚拟机可以了解对象的运行时状态,例如对象是否被锁定以及是否需要进行垃圾回收。
  2. 存储对象年龄: ``包含一个用于表示对象年龄的字段。对象年龄是为了支持Java虚拟机的分代垃圾回收机制。对象在经过一次垃圾回收后,如果存活下来,其年龄会增加,当达到一定年龄后,对象可能会被晋升到老年代。
  3. 存储哈希信息: ``部分位用于存储对象的哈希值。哈希值在一些场景下可以用于优化操作,例如在并发标记时,可以通过哈希值来判断对象是否已经被标记。
  4. 存储锁信息: 一些位用于表示对象的锁状态,包括是否被锁定、监视器锁状态等。这对于实现Java中的同步机制至关重要。

markWord 的作用是在对象头中存储一些关键的元信息,以便虚拟机在运行时能够有效地管理对象的状态、支持垃圾回收、实现同步等功能。这部分内容,对应的就是大家耳熟能祥的Java对象头MarkWord的构成,本文主要为了阐述Java对象的创建流程,因此对于MarkWord的结构图就不给出了,可以参考我的文章Java对象内存结构。

完成对象创建

cpp 复制代码
__ jmp(done);

__ jmp(done); 这行代码表示无条件跳转到标签 done 处,即跳出当前代码块,结束整个逻辑流程。在前面的代码中,我们可以看到 done 标签的定义:

而在后续的代码中应该有对 done 标签进行定义和使用。在上下文中,这行跳转代码的目的是在成功创建对象后,跳转到 done 标签处,表示对象创建的整个过程已经完成。在 done 标签处可能会包含一些清理工作或者后续的逻辑。由于这部分代码片段是不完整的,具体的 done 标签的定义和使用需要在其他地方查找。

在上面的_new汇编代码当中,已经创建好的Java对象的内存地址一般不是直接返回的,而是通常是通过寄存器(如 rax)来传递。在这里,call_VM 是一个调用虚拟机方法的宏,而 rax 寄存器用于保存返回值。以下是可能的返回对象内存地址的简化代码片段:

assembly 复制代码
// 调用InterpreterRuntime::_new方法
call_VM(rax, CAST_FROM_FN_PTR(address, InterpreterRuntime::_new), rarg1, rarg2);
__ verify_oop(rax);

// 返回rax中的对象地址

这部分代码调用了 InterpreterRuntime::_new 方法,将返回值保存在 rax 寄存器中。然后,__ verify_oop(rax) 用于验证 rax 中的值是否是有效的 Java 对象引用。在实际应用中,rax 可能会作为返回值传递给调用方。

至此,JVM负责的对象创建的过程结束了,主要是加载类文件、分配内存、初始化对象、初始化MarkWord,完成上述步骤之后,会将Java对象的内存地址传递给Java层代码,后续再由Java层代码负责调用构造方法和返回引用。

总结

本文通过追踪HotSpot虚拟机的源码,详细解析了Java对象的创建流程。整个过程包括类加载、内存分配、对象初始化、MarkWord设置等关键步骤。

首先,通过类加载机制,虚拟机获取常量池引用以及相关标签信息,并检查目标类是否已加载。

接着,根据类的加载状态执行相应的逻辑,包括通过索引从常量池加载已解析的类信息,并将类引用保存在栈上。

接下来,通过InterpreterRuntime::_new方法进行内存分配,其中包括对TLAB的快速分配和慢路径分配。在慢路径分配中,进行类的初始化、对象的内存分配以及对象的初始化工作。对象的初始化过程包括零值初始化和设置对象头信息。

最后,通过返回对象的内存地址完成整个对象创建的过程。

在具体的代码分析中,重点介绍了markWord类的作用和结构,它负责存储对象的元信息、标记信息、年龄信息、哈希信息以及锁信息等。markWord的设置是对象创建过程中的关键一环,为虚拟机提供了管理对象状态、支持垃圾回收和同步操作的基础。

总体而言,深入理解Java对象创建的底层实现对于理解Java虚拟机的工作原理和性能优化具有重要意义。这篇文章通过源码解析,帮助读者更清晰地理解了Java对象创建的整个流程及关键步骤。

更多优质内容

微信公众号:ByteRaccoon、知乎\稀土掘金\小红书都叫:浣熊say

PS:本文当中有些链接跳转不过去,是因为掘金禁止跳转微信公众号的链接,对连接内容感兴趣的,可以直接看我的微信公众号原文,里面的文章可以正常跳转。

相关推荐
customer081 小时前
【开源免费】基于SpringBoot+Vue.JS周边产品销售网站(JAVA毕业设计)
java·vue.js·spring boot·后端·spring cloud·java-ee·开源
Yaml42 小时前
智能化健身房管理:Spring Boot与Vue的创新解决方案
前端·spring boot·后端·mysql·vue·健身房管理
小码编匠3 小时前
一款 C# 编写的神经网络计算图框架
后端·神经网络·c#
AskHarries3 小时前
Java字节码增强库ByteBuddy
java·后端
佳佳_3 小时前
Spring Boot 应用启动时打印配置类信息
spring boot·后端
许野平5 小时前
Rust: 利用 chrono 库实现日期和字符串互相转换
开发语言·后端·rust·字符串·转换·日期·chrono
BiteCode_咬一口代码5 小时前
信息泄露!默认密码的危害,记一次网络安全研究
后端
齐 飞6 小时前
MongoDB笔记01-概念与安装
前端·数据库·笔记·后端·mongodb