文章详情

短信预约-IT技能 免费直播动态提醒

请输入下面的图形验证码

提交验证

短信预约提醒成功

V8 新生代垃圾回收的实现

2024-12-02 02:51

关注

前言:因为最近在做一些 gc track 的事情,所以打算了解一下 V8 GC 的实现。介绍 V8 GC 的文章网上已经有很多,就不打算再重复介绍。本文主要介绍一下新生代 GC 的实现,代码参考 V8 10.2,因为 GC 的实现非常复杂,只能介绍一些大致的实现,读者需要对 V8 GC 有一定的了解,比如新生代是分为 from 和 to 两个 space,然后在 GC 时是如何处理的。

说到 GC 首先需要介绍内存,具体来说,是堆内存,V8 把内存分为新生代和老生代,其中老生代又分为很多种类型,不过本文只关注新生代。下面先来看一下在 V8 初始化的过程中,涉及到新生代的部分,具体逻辑在 Heap::SetUpSpaces 函数。

void Heap::SetUpSpaces(...) {
// 分配内存
space_[NEW_SPACE] = new_space_ = new NewSpace(
this,
memory_allocator_->data_page_allocator(),
initial_semispace_size_,
max_semi_space_size_,
new_allocation_info);
// 初始化 GC 调度对象
scavenge_job_.reset(new ScavengeJob());
scavenge_task_observer_.reset(
new ScavengeTaskObserver(
this,
ScavengeJob::YoungGenerationTaskTriggerSize(this))
);
new_space()->AddAllocationObserver(scavenge_task_observer_.get());
// 初始化 GC 收集器
scavenger_collector_.reset(new ScavengerCollector(this));
}

在 V8 的堆中,通过 new_space_ 字段记录了新生代的堆内存对象,另外还有几个和 GC 相关的逻辑,scavenge_job_ 和 scavenge_task_observer_ 是处理 GC 对象,下面来逐个分析下。

1、 分配内存

NewSpace::NewSpace(Heap* heap, v8::PageAllocator* page_allocator,
size_t initial_semispace_capacity,
size_t max_semispace_capacity,
LinearAllocationArea* allocation_info) ...,
to_space_(heap, kToSpace),
from_space_(heap, kFromSpace) {
to_space_.SetUp(initial_semispace_capacity, max_semispace_capacity);
from_space_.SetUp(initial_semispace_capacity, max_semispace_capacity);
to_space_.Commit();
}

NewSpace 中初始化了 from 和 to 两个 space。from 和 to 两个 space 是用 SemiSpace 表示。看一下它的 SetUp 方法。

void SemiSpace::SetUp(size_t initial_capacity, size_t maximum_capacity) {
minimum_capacity_ = RoundDown(initial_capacity, Page::kPageSize);
target_capacity_ = minimum_capacity_;
maximum_capacity_ = RoundDown(maximum_capacity, Page::kPageSize);
}

SetUp 初始化了该 space 的内存大小字段,但是还没有分配内存。SetUp 执行完之后接着调了 to space 的 Commit 的方法(没有调 from space 的 Commit 方法,根据 V8 的注释,因为 from space 是在 GC 时才需要的,这里大概是用了懒初始化)。接着看 Commit。

bool SemiSpace::Commit() {
// 计算需要多少 Page
const int num_pages = static_cast<int>(target_capacity_ / Page::kPageSize);
for (int pages_added = 0; pages_added < num_pages; pages_added++) {
// 分配 Page
Page* new_page = heap()->memory_allocator()->AllocatePage(
MemoryAllocator::AllocationMode::kUsePool, this, NOT_EXECUTABLE);
// 保存起来
memory_chunk_list_.PushBack(new_page);
}
}

Commit 根据需要的内存计算出 Page 数,然后分配内存,Page 是内存管理的单位,一块内存是由多个 Page 组成的。至此,新生代的内存分配完毕。

2、 GC 处理

首先看一下 ScavengeJob。ScavengeJob 是管理 GC 调度的。

class ScavengeJob {
public:
ScavengeJob() V8_NOEXCEPT = default;
// 判断是否需要发起 GC
void ScheduleTaskIfNeeded(Heap* heap);
// 发起 GC 的阈值
static size_t YoungGenerationTaskTriggerSize(Heap* heap);

private:
class Task;
// 判断内存是否达到了阈值
static bool YoungGenerationSizeTaskTriggerReached(Heap* heap);

void set_task_pending(bool value) { task_pending_ = value; }

bool task_pending_ = false;
};

ScavengeJob 记录了内存达到多少时需要发起 GC,并实现了发起 GC 的逻辑。我们先看一下阈值。

size_t ScavengeJob::YoungGenerationTaskTriggerSize(Heap* heap) {
// FLAG_scavenge_task_trigger = 80
return heap->new_space()->Capacity() * FLAG_scavenge_task_trigger / 100;
}
bool ScavengeJob::YoungGenerationSizeTaskTriggerReached(Heap* heap) {
return heap->new_space()->Size() >= YoungGenerationTaskTriggerSize(heap);
}

V8 默认逻辑是内存达到 80% 时触发 GC,可以通过 scavenge_task_trigger flag 进行控制。V8 会调用 ScheduleTaskIfNeeded 判断是否需要发起 GC。

void ScavengeJob::ScheduleTaskIfNeeded(Heap* heap) {
if (FLAG_scavenge_task && !task_pending_ && !heap->IsTearingDown() &&
YoungGenerationSizeTaskTriggerReached(heap)) {
v8::Isolate* isolate = reinterpret_cast<v8::Isolate*>(heap->isolate());
auto taskrunner = V8::GetCurrentPlatform()->GetForegroundTaskRunner(isolate);
if (taskrunner->NonNestableTasksEnabled()) {
taskrunner->PostNonNestableTask(
std::make_unique<Task>(heap->isolate(), this)
);
task_pending_ = true;
}
}
}

ScheduleTaskIfNeeded 首先判断内存是否达到了阈值,是的就给线程池提交一个 GC 人物。V8 中有一个 platform 的概念,比如在 Node.js 里是 NodePlatform,这个对象内部有一个线程池,V8 会把 GC 任务提交到线程池中等待处理。一个 GC 任务由 Task 对象表示。

class ScavengeJob::Task : public CancelableTask {
public:
Task(Isolate* isolate, ScavengeJob* job)
: CancelableTask(isolate), isolate_(isolate), job_(job) {}
// CancelableTask overrides.
void RunInternal() override;
Isolate* isolate() const { return isolate_; }
private:
Isolate* const isolate_;
ScavengeJob* const job_;
};

Task 继承了 CancelableTask,并且内部有一个 ScavengeJob 对象。

class V8_EXPORT_PRIVATE CancelableTask : public Cancelable,
NON_EXPORTED_BASE(public Task) {
public:
// Task overrides.
void Run() final {
if (TryRun()) {
RunInternal();
}
}

virtual void RunInternal() = 0;
};

当任务给线程池调度执行时,CancelableTask 的 Run 函数会被执行,从而执行 RunInternal 函数,该函数由子类实现。接着看 ScavengeJob::Task 中关于这个函数的实现。

void ScavengeJob::Task::RunInternal() {
VMState<GC> state(isolate());
if (ScavengeJob::YoungGenerationSizeTaskTriggerReached(isolate()->heap())) {
isolate()->heap()->CollectGarbage(NEW_SPACE,
GarbageCollectionReason::kTask);
}
job_->set_task_pending(false);
}

这里再次进行了内存是否达到阈值的判断,如果达到了就直接进行 GC,下面看 CollectGarbage。

bool Heap::CollectGarbage(AllocationSpace space,
GarbageCollectionReason gc_reason,
const v8::GCCallbackFlags gc_callback_flags) {
const char* collector_reason = nullptr;
// 根据 space 选择 GC 回收器类型,这里是新生代,选择的是 SCAVENGER,具体可以参考 SelectGarbageCollector 逻辑
GarbageCollector collector = SelectGarbageCollector(space, &collector_reason);
// 根据 GC 回收器类型选择 GC 类型,这个就是我们在 gc track 时拿到的类型
GCType gc_type = GetGCTypeFromGarbageCollector(collector);
{
GCCallbacksScope scope(this);
// 执行“开始 GC” 回调,我们注册的 GC track 回调在这里被执行
if (scope.CheckReenter()) {
CallGCPrologueCallbacks(gc_type, kNoGCCallbackFlags);
}
}
// 执行 GC
PerformGarbageCollection(collector, gc_reason, collector_reason, gc_callback_flags);
// 执行 “GC 执行完”回调
{
GCCallbacksScope scope(this);
if (scope.CheckReenter()) {
CallGCEpilogueCallbacks(gc_type, gc_callback_flags);
}
}
}

接着看 PerformGarbageCollection。

size_t Heap::PerformGarbageCollection(
GarbageCollector collector, GarbageCollectionReason gc_reason,
const char* collector_reason, const v8::GCCallbackFlags gc_callback_flags) {
switch (collector) {
case GarbageCollector::MARK_COMPACTOR:
MarkCompact();
break;
case GarbageCollector::MINOR_MARK_COMPACTOR:
MinorMarkCompact();
break;
case GarbageCollector::SCAVENGER:
Scavenge();
break;
}
}

继续调用 Scavenge。

void Heap::Scavenge() {
// 进行 from spaceto space 的翻转
new_space()->Flip();
new_space()->ResetLinearAllocationArea();
// We also flip the young generation large object space. All large objects
// will be in the from space.
new_lo_space()->Flip();
new_lo_space()->ResetPendingObject();
// Implements Cheney's copying algorithm
scavenger_collector_->CollectGarbage();
}

Scavenge 是真正执行 GC 的地方,首先第一步进行 from space 和 to space 的翻转,然后执行 GC。我们看看翻转的逻辑。

void NewSpace::Flip() { SemiSpace::Swap(&from_space_, &to_space_); }
void SemiSpace::Swap(SemiSpace* from, SemiSpace* to) {
auto saved_to_space_flags = to->current_page()->GetFlags();
// We swap all properties but id_.
std::swap(from->target_capacity_, to->target_capacity_);
std::swap(from->maximum_capacity_, to->maximum_capacity_);
std::swap(from->minimum_capacity_, to->minimum_capacity_);
std::swap(from->age_mark_, to->age_mark_);
std::swap(from->memory_chunk_list_, to->memory_chunk_list_);
std::swap(from->current_page_, to->current_page_);
std::swap(from->external_backing_store_bytes_,
to->external_backing_store_bytes_);
std::swap(from->committed_physical_memory_, to->committed_physical_memory_);
to->FixPagesFlags(saved_to_space_flags, Page::kCopyOnFlipFlagsMask);
from->FixPagesFlags(Page::NO_FLAGS, Page::NO_FLAGS);
}

这里只是进行了一些字段的交换,真正的逻辑在 GC 收集器中。

void ScavengerCollector::CollectGarbage() {
ScopedFullHeapCrashKey collect_full_heap_dump_if_crash(isolate_);

std::vector<std::unique_ptr<Scavenger>> scavengers;
Scavenger::EmptyChunksList empty_chunks;
// 计算需要提交多少个 GC 任务
const int num_scavenge_tasks = NumberOfScavengeTasks();
Scavenger::CopiedList copied_list;
Scavenger::PromotionList promotion_list;
EphemeronTableList ephemeron_table_list;

{
for (int i = 0; i < num_scavenge_tasks; ++i) {
scavengers.emplace_back(
new Scavenger(this, heap_, is_logging, &empty_chunks, &copied_list,
&promotion_list, &ephemeron_table_list, i));
}
// 拿到 heap 中的所有内存块
std::vector<std::pair<ParallelWorkItem, MemoryChunk*>> memory_chunks;
RememberedSet<OLD_TO_NEW>::IterateMemoryChunks(
heap_, [&memory_chunks](MemoryChunk* chunk) {
memory_chunks.emplace_back(ParallelWorkItem{}, chunk);
});
// 遍历堆对象迭代器
RootScavengeVisitor root_scavenge_visitor(scavengers[kMainThreadId].get());
{
// 遍历堆对象
heap_->IterateRoots(&root_scavenge_visitor, options);
// 遍历 global handle 对象
isolate_->global_handles()->IterateYoungStrongAndDependentRoots( &root_scavenge_visitor);
scavengers[kMainThreadId]->Publish();
}
// 提交 GC 任务
{
// Parallel phase scavenging all copied and promoted objects.
V8::GetCurrentPlatform()
->PostJob(v8::TaskPriority::kUserBlocking,
std::make_unique<JobTask>(this, &scavengers,
std::move(memory_chunks),
&copied_list, &promotion_list))
->Join();
}
}
// 回收 ArrayBuffer 内存,比如 Node.jsBuffer
{
TRACE_GC(heap_->tracer(), GCTracer::Scope::SCAVENGER_SWEEP_ARRAY_BUFFERS);
SweepArrayBufferExtensions();
}
}

这里的逻辑非常多,除了回收新生代对象的内存,还会处理 global handle 和 ArrayBuffer 的内存。不过这里我们只关注一般的新生代对象。接着遍历堆对象的过程。

void Heap::IterateRoots(RootVisitor* v, base::EnumSet<SkipRoot> options) {
v->VisitRootPointers(Root::kStrongRootList, nullptr,
roots_table().strong_roots_begin(),
roots_table().strong_roots_end());
// ... 省略非常多逻辑
}

Heap 对象提供了迭代的接口,具体迭代逻辑由 Visitor 实现,这里是 RootScavengeVisitor。

void RootScavengeVisitor::VisitRootPointer(Root root, const char* description,
FullObjectSlot p) {
ScavengePointer(p);
}
void RootScavengeVisitor::ScavengePointer(FullObjectSlot p) {
Object object = *p;
// 如果是新生代对象则处理
if (Heap::InYoungGeneration(object)) {
scavenger_->ScavengeObject(FullHeapObjectSlot(p), HeapObject::cast(object));
}
}

接着看 ScavengeObject。

template <typename THeapObjectSlot>
SlotCallbackResult Scavenger::ScavengeObject(THeapObjectSlot p,
HeapObject object) {
return EvacuateObject(p, map, object);
}
template <typename THeapObjectSlot>
SlotCallbackResult Scavenger::EvacuateObject(THeapObjectSlot slot, Map map,
HeapObject source) {
int size = source.SizeFromMap(map);
// 堆对象的大小
VisitorId visitor_id = map.visitor_id();
switch (visitor_id) {
// ... 省略其他 case
default:
return EvacuateObjectDefault(map, slot, source, size,
Map::ObjectFieldsFrom(visitor_id));
}
}
emplate <typename THeapObjectSlot, Scavenger::PromotionHeapChoice promotion_heap_choice>
SlotCallbackResult Scavenger::EvacuateObjectDefault(
Map map, THeapObjectSlot slot, HeapObject object, int object_size,
ObjectFields object_fields) {
// 是否可以晋升到老生代,新生代对象经过 nGC 还存活则可以晋升到老生代(n = 1
if (!heap()->ShouldBePromoted(object.address())) {
// 不能晋升则移到 from space
result = SemiSpaceCopyObject(map, slot, object, object_size, object_fields);
}
// 否则晋升到老生代
result = PromoteObject<THeapObjectSlot, promotion_heap_choice>(
map, slot, object, object_size, object_fields);
// 晋升失败则 fallback,移到 from
SemiSpaceCopyObject(map, slot, object, object_size, object_fields);
}

接着看 SemiSpaceCopyObject 和 PromoteObject。

template <typename THeapObjectSlot>
CopyAndForwardResult Scavenger::SemiSpaceCopyObject(
Map map, THeapObjectSlot slot, HeapObject object, int object_size,
ObjectFields object_fields) {
AllocationAlignment alignment = HeapObject::RequiredAlignment(map);
// 在 from space 分配一块新的内存,把 to space 的对象移动过去
AllocationResult allocation = allocator_.Allocate(
NEW_SPACE, object_size, AllocationOrigin::kGC, alignment);
// 进行对象迁移
MigrateObject(map, object, target, object_size, kPromoteIntoLocalHeap);
}
bool Scavenger::MigrateObject(Map map, HeapObject source, HeapObject target,
int size,
PromotionHeapChoice promotion_heap_choice) {
// 内存复制,比如通过 memmove
heap()->CopyBlock(target.address() + kTaggedSize,
source.address() + kTaggedSize, size - kTaggedSize);

// 触发对象移动事件,比如 heap_profiler 回监听这个事件
if (V8_UNLIKELY(is_logging_)) {
heap()->OnMoveEvent(target, source, size);
}
}

至此就完成了对象的迁移。接着看对象的晋升。

template <typename THeapObjectSlot,
Scavenger::PromotionHeapChoice promotion_heap_choice>
CopyAndForwardResult Scavenger::PromoteObject(Map map, THeapObjectSlot slot,
HeapObject object,
int object_size,
ObjectFields object_fields) {
AllocationAlignment alignment = HeapObject::RequiredAlignment(map);
AllocationResult allocation;
// 在老生代分配一块内存
allocation = allocator_.Allocate(OLD_SPACE, object_size,
AllocationOrigin::kGC, alignment);

HeapObject target;
allocation.To(&target);
// 迁移过去
MigrateObject(map, object, target, object_size, promotion_heap_choice);
}

对象的晋升本质上也是内存的复制,只不过是复制到了老生代的内存。完成了 from space 和 to space 对象的处理后,还需要另外的任务需要处理。具体由提交给线程池的 JobTask 对象实现。

V8::GetCurrentPlatform()
->PostJob(v8::TaskPriority::kUserBlocking,
std::make_unique<JobTask>(this, &scavengers,
std::move(memory_chunks),
&copied_list, &promotion_list))
->Join();

来看一下该对象 Run 的实现。

void ScavengerCollector::JobTask::Run(JobDelegate* delegate) {
Scavenger* scavenger = (*scavengers_)[delegate->GetTaskId()].get();
ProcessItems(delegate, scavenger);
}
void ScavengerCollector::JobTask::ProcessItems(JobDelegate* delegate,
Scavenger* scavenger) {
double scavenging_time = 0.0;
{
TimedScope scope(&scavenging_time);
// 并行处理内存
ConcurrentScavengePages(scavenger);
scavenger->Process(delegate);
}
}
void ScavengerCollector::JobTask::ConcurrentScavengePages(
Scavenger* scavenger) {
while (remaining_memory_chunks_.load(std::memory_order_relaxed) > 0) {
base::Optional<size_t> index = generator_.GetNext();
if (!index) return;
for (size_t i = *index; i < memory_chunks_.size(); ++i) {
auto& work_item = memory_chunks_[i];
if (!work_item.first.TryAcquire()) break;
scavenger->ScavengePage(work_item.second);
if (remaining_memory_chunks_.fetch_sub(1, std::memory_order_relaxed) <=
1) {
return;
}
}
}
}

具体看 scavenger->ScavengePage(work_item.second) 。

void Scavenger::ScavengePage(MemoryChunk* page) {
CodePageMemoryModificationScope memory_modification_scope(page);
if (page->slot_set<OLD_TO_NEW, AccessMode::ATOMIC>() != nullptr) {
InvalidatedSlotsFilter filter = InvalidatedSlotsFilter::OldToNew(page);
RememberedSet<OLD_TO_NEW>::IterateAndTrackEmptyBuckets(
page,
[this, &filter](MaybeObjectSlot slot) {
if (!filter.IsValid(slot.address())) return REMOVE_SLOT;
return CheckAndScavengeObject(heap_, slot);
},
&empty_chunks_local_);
}
if (page->invalidated_slots<OLD_TO_NEW>() != nullptr) {
page->ReleaseInvalidatedSlots<OLD_TO_NEW>();
}

RememberedSet<OLD_TO_NEW>::IterateTyped(
page, [=](SlotType type, Address addr) {
return UpdateTypedSlotHelper::UpdateTypedSlot(
heap_, type, addr, [this](FullMaybeObjectSlot slot) {
return CheckAndScavengeObject(heap(), slot);
});
});

AddPageToSweeperIfNecessary(page);
}

大概就是进行了数据的更新和内存的回收。至此,GC 的流程就大致分析完了。

触发 GC

刚出分析了 GC 的处理过程,接下来看看什么时候会触发 GC。相关代码如下。

scavenge_task_observer_.reset(new ScavengeTaskObserver(this, ScavengeJob::YoungGenerationTaskTriggerSize(this)));
new_space()->AddAllocationObserver(scavenge_task_observer_.get());

V8 初始化时,给新生代对象注册了一个内存分配的观察者,首先看一下观察者的实现。

class ScavengeTaskObserver : public AllocationObserver {
public:
ScavengeTaskObserver(Heap* heap, intptr_t step_size)
: AllocationObserver(step_size), heap_(heap) {}

void Step(int bytes_allocated, Address, size_t) override {
heap_->ScheduleScavengeTaskIfNeeded();
}
private:
Heap* heap_;
};

观察者的实现很简单,V8 在分配内存的过程中会执行观察者的 Step 方法,该方法会判断是否需要 GC,下面是 ScheduleScavengeTaskIfNeeded 的实现。

void Heap::ScheduleScavengeTaskIfNeeded() {
scavenge_job_->ScheduleTaskIfNeeded(this);
}
void ScavengeJob::ScheduleTaskIfNeeded(Heap* heap) {
if (FLAG_scavenge_task && !task_pending_ && !heap->IsTearingDown() &&
YoungGenerationSizeTaskTriggerReached(heap)) {
v8::Isolate* isolate = reinterpret_cast<v8::Isolate*>(heap->isolate());
auto taskrunner =
V8::GetCurrentPlatform()->GetForegroundTaskRunner(isolate);
if (taskrunner->NonNestableTasksEnabled()) {
taskrunner->PostNonNestableTask(
std::make_unique<Task>(heap->isolate(), this));
task_pending_ = true;
}
}
}

这个过程刚出已经分析过了。接下来再往前看,什么时候会调用 Step。创建完观察者后,会把观察者注册到 newSpace 中。

new_space()->AddAllocationObserver(scavenge_task_observer_.get());

看一下 AddAllocationObserver。

void Space::AddAllocationObserver(AllocationObserver* observer) {
allocation_counter_.AddAllocationObserver(observer);
}

那么 allocation_counter_ 又是什么呢?allocation_counter_ 是 AllocationCounter 对象。

class AllocationCounter final {
public:
AllocationCounter() = default;
V8_EXPORT_PRIVATE void AddAllocationObserver(AllocationObserver* observer);

V8_EXPORT_PRIVATE void RemoveAllocationObserver(AllocationObserver* observer);

V8_EXPORT_PRIVATE void AdvanceAllocationObservers(size_t allocated);

V8_EXPORT_PRIVATE void InvokeAllocationObservers(Address soon_object,
size_t object_size,
size_t aligned_object_size);
private:
struct AllocationObserverCounter final {
AllocationObserverCounter(AllocationObserver* observer, size_t prev_counter,
size_t next_counter)
: observer_(observer),
prev_counter_(prev_counter),
next_counter_(next_counter) {}

AllocationObserver* observer_;
};
std::vector<AllocationObserverCounter> observers_;
};

AllocationCounter 里记录了多个 AllocationObserverCounter 对象,而 AllocationObserverCounter 对象封装了 AllocationObserver 对象。来看一下 AddAllocationObserver 方法的实现。

void AllocationCounter::AddAllocationObserver(AllocationObserver* observer) {
observers_.push_back(AllocationObserverCounter(observer, current_counter_,
observer_next_counter));
}

newSpace 通过 AllocationCounter 管理了多个观察者,接着看调用观察者的时机,也就是分配内存的时候。

AllocationResult SpaceWithLinearArea::AllocateRawAligned(
int size_in_bytes, AllocationAlignment alignment, AllocationOrigin origin) {
// 分配内存
AllocationResult result = AllocateFastAligned(
size_in_bytes, &aligned_size_in_bytes, alignment, origin);
// 调用观察者
InvokeAllocationObservers(result.ToAddress(), size_in_bytes,
aligned_size_in_bytes, max_aligned_size);

return result;
}

接着看 InvokeAllocationObservers。

void AllocationCounter::InvokeAllocationObservers(Address soon_object,
size_t object_size,
size_t aligned_object_size) {
for (AllocationObserverCounter& aoc : observers_) {
if (aoc.next_counter_ - current_counter_ <= aligned_object_size) {
{
// 执行观察者的 Step 方法,也就是刚出分析 GC 处理时提到的
aoc.observer_->Step(
static_cast<int>(current_counter_ - aoc.prev_counter_), soon_object,
object_size);
}
}
}
}

至此,所有的过程分析完毕。

4 、总结

V8 的 GC 经过多年的优化已经变得非常高效,和其他优化技术一起实现了 V8 引擎的高性能。具体的实现非常复杂,涉及的逻辑非常多,时间有限,也就只能大致分析一下,了解基础的原理。

来源:编程杂技内容投诉

免责声明:

① 本站未注明“稿件来源”的信息均来自网络整理。其文字、图片和音视频稿件的所属权归原作者所有。本站收集整理出于非商业性的教育和科研之目的,并不意味着本站赞同其观点或证实其内容的真实性。仅作为临时的测试数据,供内部测试之用。本站并未授权任何人以任何方式主动获取本站任何信息。

② 本站未注明“稿件来源”的临时测试数据将在测试完成后最终做删除处理。有问题或投稿请发送至: 邮箱/279061341@qq.com QQ/279061341

软考中级精品资料免费领

  • 历年真题答案解析
  • 备考技巧名师总结
  • 高频考点精准押题
  • 2024年上半年信息系统项目管理师第二批次真题及答案解析(完整版)

    难度     813人已做
    查看
  • 【考后总结】2024年5月26日信息系统项目管理师第2批次考情分析

    难度     354人已做
    查看
  • 【考后总结】2024年5月25日信息系统项目管理师第1批次考情分析

    难度     318人已做
    查看
  • 2024年上半年软考高项第一、二批次真题考点汇总(完整版)

    难度     435人已做
    查看
  • 2024年上半年系统架构设计师考试综合知识真题

    难度     224人已做
    查看

相关文章

发现更多好内容

猜你喜欢

AI推送时光机
位置:首页-资讯-后端开发
咦!没有更多了?去看看其它编程学习网 内容吧
首页课程
资料下载
问答资讯