Android Runtime堆内存动态扩展策略原理(51)

码字不易,请大佬们点点关注,谢谢~

一、堆内存动态扩展概述

在Android系统中,应用程序的内存使用情况复杂多变,Android Runtime(ART)的堆内存动态扩展策略是保障应用稳定运行的关键机制。该策略允许堆内存根据应用实际需求动态调整大小,避免因内存不足导致应用崩溃,同时也能在内存使用低谷时释放资源,提高系统整体资源利用率。

从源码角度来看,堆内存动态扩展涉及art/runtime目录下多个源文件协同工作。核心逻辑集中在heap.ccheap_tuning.cc等文件中,通过一系列判断条件、内存分配与回收操作,实现堆内存的动态调整。接下来,我们将深入分析其原理与实现细节。

二、动态扩展的触发条件

2.1 内存分配失败触发

当应用尝试分配新对象时,如果当前堆内存无法满足分配需求,就会触发堆内存动态扩展。在art/runtime/heap/internals.ccHeap::AllocObject方法中,详细实现了对象分配逻辑:

cpp 复制代码
mirror::Object* Heap::AllocObject(Class* clazz, size_t extra_bytes,
                                  PretenureFlag pretenure_flag,
                                  bool from_quick_alloc) {
  // 检查快速分配路径是否可用
  if (from_quick_alloc && ShouldUseQuickAlloc(clazz)) {
    // 尝试快速分配对象
    mirror::Object* result = QuickAllocObject(clazz, extra_bytes, pretenure_flag);
    if (result != nullptr) {
      return result;
    }
  }
  // 进入慢速分配路径,检查堆内存是否充足
  if (AvailableMemory() < CalculateObjectSize(clazz, extra_bytes)) {
    // 内存不足,触发动态扩展相关处理
    HandleAllocationFailure(clazz, extra_bytes, pretenure_flag);
    // 再次检查内存,尝试分配
    if (AvailableMemory() >= CalculateObjectSize(clazz, extra_bytes)) {
      return AllocateObjectFromHeap(clazz, extra_bytes, pretenure_flag);
    }
  }
  // 内存仍然不足,返回空指针表示分配失败
  return nullptr;
}

AvailableMemory()小于对象所需大小时,会调用HandleAllocationFailure方法,该方法可能会触发垃圾回收或尝试扩展堆内存。如果垃圾回收后内存仍不足,就会启动堆内存动态扩展流程。

2.2 系统内存状态触发

除了内存分配失败,系统内存状态也会影响堆内存扩展。在art/runtime/gc/heap_tuning.cc中,系统会定期监测整体内存使用情况:

cpp 复制代码
void HeapTuner::MonitorSystemMemory() {
  // 获取系统可用内存
  size_t system_free_memory = GetSystemFreeMemory();
  // 获取当前堆内存使用比例
  float heap_usage_ratio = CalculateHeapUsageRatio();
  // 判断系统内存是否紧张且堆内存使用接近阈值
  if (system_free_memory < kSystemMemoryThreshold && heap_usage_ratio > kHeapUsageThreshold) {
    // 触发堆内存扩展评估
    EvaluateHeapExpansion();
  }
}

当系统可用内存低于kSystemMemoryThreshold,且堆内存使用比例超过kHeapUsageThreshold时,会进行堆内存扩展评估,决定是否需要扩展堆内存以应对潜在的内存压力。

三、内存需求评估与扩展大小计算

3.1 内存需求评估

在触发堆内存扩展后,首先要评估当前实际内存需求。art/runtime/gc/heap_tuning.cc中的EvaluateMemoryRequirement方法负责此任务:

cpp 复制代码
size_t HeapTuner::EvaluateMemoryRequirement() {
  // 统计近期内存分配失败的对象大小总和
  size_t failed_allocation_sum = CalculateFailedAllocationSum();
  // 考虑未来一段时间内可能的内存增长趋势
  size_t predicted_growth = PredictMemoryGrowth();
  // 计算总内存需求
  return failed_allocation_sum + predicted_growth;
}

该方法通过计算近期内存分配失败的对象大小总和,并结合对未来内存增长的预测,得出较为准确的内存需求值。其中,CalculateFailedAllocationSum会遍历记录的内存分配失败信息,累加失败对象的大小;PredictMemoryGrowth则基于历史内存使用数据和应用当前运行状态,预估未来内存增长情况。

3.2 扩展大小计算

确定内存需求后,需要计算合适的堆内存扩展大小。在art/runtime/gc/heap_tuning.ccCalculateHeapExpansionSize方法中实现了扩展大小的计算逻辑:

cpp 复制代码
size_t HeapTuner::CalculateHeapExpansionSize(size_t required_memory) {
  // 获取当前堆内存大小
  size_t current_heap_size = GetCurrentHeapSize();
  // 计算扩展比例,这里假设一种简单的比例计算方式
  float expansion_ratio = CalculateExpansionRatio(required_memory, current_heap_size);
  // 计算扩展大小
  return static_cast<size_t>(current_heap_size * expansion_ratio);
}

CalculateExpansionRatio方法会根据内存需求和当前堆内存大小,综合考虑系统资源状况、应用优先级等因素,确定合适的扩展比例。例如,如果系统内存充足且应用对性能要求较高,可能会采用较大的扩展比例;反之,如果系统内存紧张,扩展比例会相对较小,以避免过度占用系统资源。

四、堆内存扩展的实现流程

4.1 内存申请

在确定扩展大小后,ART会向系统申请额外的内存。在art/runtime/heap.ccHeap::ExpandHeap方法中实现了内存申请操作:

cpp 复制代码
bool Heap::ExpandHeap(size_t size) {
  // 使用mmap系统调用申请内存
  void* new_memory = mmap(nullptr, size, PROT_READ | PROT_WRITE,
                          MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
  if (new_memory == MAP_FAILED) {
    // 内存申请失败,处理错误情况
    HandleMemoryAllocationFailure();
    return false;
  }
  // 记录新申请的内存区域
  AddMemoryRegion(new_memory, size);
  return true;
}

通过mmap系统调用,ART在虚拟内存空间中映射一段新的内存区域。如果申请失败,会调用HandleMemoryAllocationFailure方法进行错误处理,可能包括释放已分配的部分内存、触发更激进的垃圾回收等操作。

4.2 内存整合与布局调整

申请到新内存后,需要将其整合到现有的堆内存中,并调整内存布局。在art/runtime/heap.ccHeap::IntegrateNewMemory方法中实现了相关逻辑:

cpp 复制代码
void Heap::IntegrateNewMemory(void* new_memory, size_t size) {
  // 根据堆内存的分代布局,确定新内存的分配位置
  if (ShouldAllocateToYoungGeneration(size)) {
    // 分配到年轻代
    young_space_->Expand(new_memory, size);
  } else if (ShouldAllocateToOldGeneration(size)) {
    // 分配到老年代
    old_space_->Expand(new_memory, size);
  } else {
    // 分配到大对象空间
    large_object_space_->Expand(new_memory, size);
  }
  // 更新堆内存的相关元数据,如总大小、各代空间大小等
  UpdateHeapMetadata();
}

该方法会根据对象大小和分代策略,将新内存分配到合适的区域(年轻代、老年代或大对象空间)。分配完成后,更新堆内存的元数据,确保后续内存管理操作的准确性。

五、对象数据迁移与引用更新

5.1 对象数据迁移

当堆内存扩展后,部分对象可能需要迁移到新的内存区域,以充分利用新分配的内存空间。在年轻代或老年代扩展时,涉及对象数据迁移操作。以年轻代扩展为例,在art/runtime/gc/space/eden_space.ccEdenSpace::Expand方法中实现了对象迁移逻辑:

cpp 复制代码
void EdenSpace::Expand(void* new_memory, size_t size) {
  // 保存当前Eden空间中的存活对象
  std::vector<mirror::Object*> live_objects;
  IterateObjects([&live_objects](mirror::Object* obj) {
    if (obj->IsAlive()) {
      live_objects.push_back(obj);
    }
  });
  // 调整Eden空间的边界,纳入新内存
  SetEnd(reinterpret_cast<uint8_t*>(GetEnd()) + size);
  // 将存活对象迁移到新的内存位置
  for (mirror::Object* obj : live_objects) {
    void* new_location = AllocateMemoryForObject(obj);
    memcpy(new_location, obj, obj->GetSize());
    // 更新对象的内存地址
    UpdateObjectAddress(obj, new_location);
  }
}

该方法先遍历Eden空间,保存存活对象。然后调整Eden空间边界,将新内存纳入。最后将存活对象迁移到新内存位置,并更新对象的内存地址,确保对象的引用关系正确。

5.2 引用更新

对象数据迁移后,需要更新所有指向这些对象的引用,以保证应用程序能够正确访问对象。在art/runtime/gc/reference_table.ccReferenceTable::UpdateReferences方法中实现了引用更新逻辑:

cpp 复制代码
void ReferenceTable::UpdateReferences() {
  // 遍历引用表中的所有引用
  for (auto& entry : references_) {
    mirror::Object* obj = entry.second;
    // 检查对象是否已迁移
    if (obj->HasBeenMoved()) {
      // 获取对象的新地址
      void* new_address = obj->GetNewAddress();
      // 更新引用指向的地址
      entry.second = reinterpret_cast<mirror::Object*>(new_address);
    }
  }
}

该方法遍历引用表,检查每个引用指向的对象是否已迁移。如果对象已迁移,获取其新地址并更新引用,确保应用程序中的所有引用都指向正确的对象位置。

六、动态扩展与垃圾回收的协同工作

6.1 扩展前的垃圾回收

在触发堆内存扩展前,ART通常会先尝试执行垃圾回收,以释放部分内存,减少扩展需求。在art/runtime/gc/heap.ccHandleAllocationFailure方法中,当内存分配失败时,会优先触发垃圾回收:

cpp 复制代码
void Heap::HandleAllocationFailure(Class* clazz, size_t extra_bytes, PretenureFlag pretenure_flag) {
  // 尝试执行年轻代垃圾回收
  if (ShouldTriggerMinorGc()) {
    Scavenger scavenger(this);
    scavenger.Collect(kGcTypeYoung, kGcCauseAllocationFailure);
  }
  // 如果年轻代垃圾回收后内存仍不足,尝试执行老年代垃圾回收
  if (AvailableMemory() < CalculateObjectSize(clazz, extra_bytes) && ShouldTriggerMajorGc()) {
    MarkSweepCompact marksweep_compact(this);
    marksweep_compact.Collect(kGcTypeOld, kGcCauseAllocationFailure);
  }
  // 如果垃圾回收后内存仍不足,才进行堆内存扩展
  if (AvailableMemory() < CalculateObjectSize(clazz, extra_bytes)) {
    HeapTuner tuner(this);
    size_t expansion_size = tuner.CalculateHeapExpansionSize(CalculateObjectSize(clazz, extra_bytes));
    ExpandHeap(expansion_size);
  }
}

通过先执行垃圾回收,尽可能释放已占用的内存,减少堆内存扩展的频率和大小,提高内存使用效率。

6.2 扩展后的垃圾回收优化

堆内存扩展后,内存布局发生变化,此时垃圾回收算法也会进行相应优化。例如,在对象数据迁移后,垃圾回收器会利用新的内存布局信息,更高效地标记和回收垃圾对象。在art/runtime/gc/collector/marksweep_compact.ccMarkSweepCompact::Collect方法中,会根据扩展后的内存布局调整标记和整理策略:

cpp 复制代码
void MarkSweepCompact::Collect(GcType gc_type, GcCause gc_cause) {
  // 更新内存布局信息
  UpdateMemoryLayoutInfo();
  // 执行标记阶段
  MarkObjects();
  // 根据新的内存布局执行更优化的整理操作
  CompactObjectsWithNewLayout();
  // 更新对象引用关系
  UpdateReferences();
}

UpdateMemoryLayoutInfo方法会获取扩展后的内存布局信息,CompactObjectsWithNewLayout方法则根据新布局进行更高效的对象整理,减少内存碎片,提高内存利用率。

七、多线程环境下的动态扩展处理

7.1 并发控制机制

在多线程环境下,多个线程可能同时触发堆内存扩展,为了保证扩展操作的正确性和一致性,ART采用了并发控制机制。在art/runtime/heap.ccHeap::ExpandHeap方法中,使用互斥锁来保护堆内存扩展操作:

cpp 复制代码
bool Heap::ExpandHeap(size_t size) {
  std::lock_guard<std::mutex> lock(heap_mutex_);
  // 使用mmap系统调用申请内存
  void* new_memory = mmap(nullptr, size, PROT_READ | PROT_WRITE,
                          MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
  if (new_memory == MAP_FAILED) {
    // 内存申请失败,处理错误情况
    HandleMemoryAllocationFailure();
    return false;
  }
  // 记录新申请的内存区域
  AddMemoryRegion(new_memory, size);
  return true;
}

通过std::lock_guard在扩展操作期间锁定heap_mutex_,确保同一时刻只有一个线程能够执行堆内存扩展,避免多个线程同时申请内存导致的冲突和错误。

7.2 线程安全的数据迁移与引用更新

在多线程环境下进行对象数据迁移和引用更新时,也需要保证线程安全。在art/runtime/gc/reference_table.ccReferenceTable::UpdateReferences方法中,使用读写锁来保护引用表的更新操作:

cpp 复制代码
void ReferenceTable::UpdateReferences() {
  std::unique_lock<std::shared_mutex> lock(reference_table_mutex_);
  // 遍历引用表中的所有引用
  for (auto& entry : references_) {
    mirror::Object* obj = entry.second;
    // 检查对象是否已迁移
    if (obj->HasBeenMoved()) {
      // 获取对象的新地址
      void* new_address = obj->GetNewAddress();
      // 更新引用指向的地址
      entry.second = reinterpret_cast<mirror::Object*>(new_address);
    }
  }
}

std::unique_lock在更新引用表时获取写锁,确保在更新过程中其他线程无法同时修改引用表,保证引用更新操作的线程安全性。

八、动态扩展策略的性能优化

8.1 减少扩展频率

频繁的堆内存扩展会带来较大的性能开销,ART通过多种方式减少扩展频率。在art/runtime/gc/heap_tuning.cc中,通过优化内存需求评估算法,更准确地预测内存需求,避免不必要的扩展:

cpp 复制代码
size_t HeapTuner::PredictMemoryGrowth() {
  // 分析历史内存使用数据,采用更复杂的预测模型
  std::vector<size_t> historical_usage = GetHistoricalMemoryUsage();
  // 使用时间序列分析等算法预测未来内存增长
  size_t predicted_growth = AnalyzeHistoricalData(historical_usage);
  return predicted_growth;
}

通过更准确的内存增长预测,避免因预测不准确导致的频繁扩展。同时,在垃圾回收过程中,采用更高效的回收策略,尽量释放更多内存,减少扩展需求。

8.2 优化内存分配与迁移效率

在堆内存扩展过程中,优化内存分配和对象迁移效率可以显著提升性能。在内存分配方面,采用更高效的内存分配算法,如伙伴系统(Buddy System)或 slab 分配器,减少内存分配的时间开销。在对象迁移方面,采用并行迁移技术,利用多核处理器的优势,加快对象迁移速度。在art/runtime/gc/space/eden_space.ccEdenSpace::Expand方法中,可以引入并行迁移逻辑:

cpp 复制代码
void EdenSpace::Expand(void* new_memory, size_t size) {
  // 保存当前Eden空间中的存活对象
  std::vector<mirror::Object*> live_objects;
  IterateObjects([&live_objects](mirror::Object* obj) {
    if (obj->IsAlive()) {
      live_objects.push_back(obj);
    }
  });
  // 调整Eden空间的边界,纳入新内存
  SetEnd(reinterpret_cast<uint8_t*>(GetEnd()) + size);
  // 并行迁移存活对象
  std::vector<std::thread> threads;
  const size_t num_threads = std::thread::hardware_concurrency();
  const size_t batch_size = live_objects.size() / num_threads;
  for (size_t i = 0; i < num_threads; ++i) {
    size_t start = i * batch_size;
    size_t end = (i == num_threads - 1)? live_objects.size() : (i + 1) * batch_size;
    threads.emplace_back([this, &live_objects, start, end]() {
      for (size_t j = start; j < end; ++j) {
        mirror::Object* obj = live_objects[j];
        void* new_location = AllocateMemory

九、动态扩展与系统资源的交互协调

9.1 与Linux内核的内存交互

Android Runtime的堆内存动态扩展离不开与Linux内核的紧密交互。ART通过系统调用向Linux内核申请内存资源,最常用的是mmap系统调用。在art/runtime/heap.ccHeap::ExpandHeap方法中,通过mmap实现内存申请:

cpp 复制代码
bool Heap::ExpandHeap(size_t size) {
    // 使用mmap系统调用申请匿名内存,PROT_READ | PROT_WRITE表示内存可读可写,
    // MAP_PRIVATE | MAP_ANONYMOUS表示创建私有匿名映射
    void* new_memory = mmap(nullptr, size, PROT_READ | PROT_WRITE,
                            MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
    if (new_memory == MAP_FAILED) {
        // 内存申请失败,调用错误处理函数
        HandleMemoryAllocationFailure();
        return false;
    }
    // 将新申请的内存区域添加到堆内存管理范围
    AddMemoryRegion(new_memory, size);
    return true;
}

mmap系统调用返回一个指向新分配内存区域的指针,如果返回MAP_FAILED,则表示内存申请失败。此时,ART会调用HandleMemoryAllocationFailure方法进行处理,可能包括触发更激进的垃圾回收、释放部分已分配内存,或者向应用抛出内存不足的异常。

当ART不再需要某些内存时,会通过munmap系统调用将内存归还给Linux内核。这种交互方式使得ART能够在Linux内核提供的内存管理框架下,灵活地调整堆内存大小,同时也依赖于内核的内存分配策略和资源调度机制 。例如,当系统内存紧张时,Linux内核可能会对ART的内存申请进行限制,导致mmap调用失败,此时ART需要采取相应的应对措施。

9.2 与其他进程的资源竞争处理

在Android系统中,多个应用进程同时运行,它们之间存在内存资源竞争。ART的堆内存动态扩展策略需要考虑与其他进程的资源协调,避免过度占用内存导致系统整体性能下降或其他进程崩溃。

系统会根据进程的优先级和资源使用情况进行调度。对于前台应用进程,系统会优先保障其内存需求;而对于后台进程,在内存紧张时可能会被系统杀死以释放内存。ART在进行堆内存扩展时,会参考系统的进程优先级信息。在art/runtime/gc/heap_tuning.cc中,可能存在相关逻辑来判断当前进程的优先级:

cpp 复制代码
void HeapTuner::EvaluateHeapExpansion() {
    // 获取当前进程的优先级
    int process_priority = GetProcessPriority();
    // 如果是前台进程,且内存需求迫切,可以适当放宽扩展条件
    if (process_priority == FOREGROUND_PRIORITY && IsMemoryDemandUrgent()) {
        size_t expansion_size = CalculateHeapExpansionSize(EstimateMemoryRequirement());
        Heap::Current()->ExpandHeap(expansion_size);
    } else {
        // 对于后台进程或内存需求不紧急的情况,谨慎进行扩展
        // 可能采取更保守的扩展策略,或者等待系统资源更充裕时再扩展
    }
}

此外,当系统内存不足时,Linux内核会通过OOM(Out Of Memory) killer机制杀死某些进程来释放内存。ART会尽量避免自身进程因内存使用不当而被OOM killer选中。通过合理的堆内存动态扩展策略,以及与系统内存管理机制的协同,ART可以在保证自身应用正常运行的同时,维护系统的整体稳定性 。

十、动态扩展策略在不同场景下的应用与挑战

10.1 高并发应用场景

在高并发应用场景下,如即时通讯应用、在线游戏等,多个线程会同时进行大量的内存分配和对象创建操作,这对堆内存动态扩展策略提出了更高的要求。

高并发场景下,线程之间对内存资源的竞争加剧,容易导致频繁的内存分配失败,从而触发堆内存扩展。为了应对这种情况,ART在多线程并发控制方面采用了更精细的锁机制和优化策略。除了前文提到的互斥锁和读写锁,还会使用无锁数据结构和CAS(Compare and Swap)操作来减少锁竞争带来的性能损耗。

在对象数据迁移和引用更新过程中,高并发也增加了复杂性。例如,当多个线程同时迁移对象时,需要确保数据的一致性和完整性。ART可能会采用版本号机制或事务性内存的思想,对对象迁移和引用更新操作进行管理。每个对象维护一个版本号,在迁移和更新引用时,只有版本号匹配的操作才能成功执行,避免因并发操作导致的数据错误 。

然而,高并发场景下的动态扩展也面临挑战。频繁的扩展操作会带来较大的性能开销,包括内存申请、数据迁移和引用更新等操作的时间成本。此外,锁竞争和同步机制也会影响应用的并发性能。因此,如何在保证内存充足的前提下,尽量减少扩展频率,优化并发控制策略,是高并发场景下堆内存动态扩展策略需要解决的关键问题 。

10.2 内存敏感型应用场景

对于内存敏感型应用,如嵌入式设备上的轻量级应用、运行在低配置设备上的应用等,内存资源非常有限,堆内存动态扩展策略需要更加谨慎。

在这类应用中,ART会采用更严格的内存需求评估算法,尽量准确地预测内存需求,避免过度扩展堆内存。可能会采用更保守的扩展比例,并且在扩展前会进行更充分的垃圾回收,尽可能释放已占用的内存。在art/runtime/gc/heap_tuning.cc中,可能会有特殊的配置和算法来适应内存敏感型应用:

cpp 复制代码
void HeapTuner::TuneForMemorySensitiveApp() {
    // 降低扩展比例阈值
    SetExpansionRatioThreshold(LOW_MEMORY_EXPANSION_RATIO);
    // 增加垃圾回收的频率和强度
    IncreaseGCRecoveryEfficiency();
    // 优化内存需求预测模型,使其更精准
    OptimizeMemoryRequirementPredictionModel();
}

内存敏感型应用场景下的挑战在于,既要保证应用的正常运行,满足其内存需求,又不能过度占用内存导致系统资源耗尽。此外,由于设备性能有限,垃圾回收和内存扩展等操作对应用性能的影响更为显著,需要在内存管理和性能之间找到更好的平衡点 。同时,还需要考虑与其他系统组件的资源共享和协调,确保整个系统的稳定运行 。

十一、动态扩展策略的发展与演进

11.1 从Dalvik到ART的策略变化

在Android系统从Dalvik虚拟机过渡到ART运行时的过程中,堆内存动态扩展策略发生了显著的变化。

Dalvik采用基于JIT(Just - In - Time)编译的方式,其内存管理机制相对简单。在堆内存扩展方面,Dalvik的策略较为被动,通常在内存分配失败时才会进行扩展,并且扩展过程中的优化措施较少。垃圾回收算法也相对低效,导致在内存紧张时,堆内存扩展频繁,应用性能下降明显。

而ART采用AOT(Ahead - Of - Time)编译技术,对内存管理进行了全面优化。ART的堆内存动态扩展策略更加主动和智能。在内存需求评估方面,引入了更复杂的预测模型,能够更准确地预估内存增长趋势。在扩展过程中,采用了更高效的内存分配算法和对象迁移策略,减少了扩展操作带来的性能开销。例如,ART的分代式垃圾回收机制与动态扩展策略紧密结合,年轻代、老年代和大对象空间的扩展方式和时机都经过精心设计,提高了内存使用效率 。

此外,ART在多线程并发控制和与系统资源交互方面也进行了大量改进,使得堆内存动态扩展策略在多进程环境下更加稳定和高效,这是Dalvik所无法比拟的。

相关推荐
猫头虎5 分钟前
【Python系列PyCharm实战】ModuleNotFoundError: No module named ‘sklearn’ 系列Bug解决方案大全
android·开发语言·python·pycharm·bug·database·sklearn
鹤渺7 分钟前
React Native 搭建iOS与Android开发环境
android·react native·ios
金疙瘩1 小时前
TypeScript 联合类型与交叉类型详解
面试
哆啦美玲1 小时前
Callback 🥊 Promise 🥊 Async/Await:谁才是异步之王?
前端·javascript·面试
liang_jy2 小时前
Android 窗口显示(一)—— Activity、Window 和 View 之间的联系
android·面试
用户2018792831672 小时前
快递分拣中心里的 LinkedList 冒险:从源码到实战的趣味解析
android
海的诗篇_2 小时前
前端开发面试题总结-vue2框架篇(三)
前端·javascript·css·面试·vue·html
工呈士3 小时前
TCP/IP 协议详解
前端·后端·面试
前端小巷子3 小时前
跨标签页通信(二):Service Worker
前端·面试·浏览器
comelong3 小时前
还有人不知道IntersectionObserver也可以实现懒加载吗
前端·javascript·面试