一键释放iOS 64位App潜力
itomcoil 2025-05-14 14:04 23 浏览
作者:eddiecmchen,PCG客户端开发工程师
| 导语 把我的iPhone XR扶起来,它还能再顶一会儿~
背景
远在iOS 11时期(2017年),苹果就发公告要求所有需要上架AppStore的应用都必须支持64位。32位应用不再支持上架与运行。
升级64位应用有什么好处呢?(以下内容纯摘抄,客官可以直接跳过)
- 指针字长更长,可使用的虚拟内存更大,摆脱32位下受限的4G内存空间
- 16 bit = 65,536 bytes (64 Kilobytes)
- 32 bit = 4,294,967,296 bytes (4 Gigabytes)
- 64 bit = 18,446,744,073,709,551,616 (16 Exabytes)
- 寄存器更多,减少内存读写,加快执行速度
这里我们要注意的是:虚拟内存确实比纯32位多了,但是App到底能用多少,是否跟宣传一样接近16EB?下面将会展开聊聊,我们先来看一个Crash。
一个长期存在的幽灵
我们先来看下面的一个内存导致的崩溃,JSC在使用bmalloc尝试进行内存分配时,提示OOM导致了SIGTRAP。
Last Exception :
0 JavaScriptCore 0x000000018b777570 _pas_panic_on_out_of_memory_error
1 JavaScriptCore 0x000000018b72e918 _bmalloc_try_iso_allocate_impl_impl_slow
2 JavaScriptCore 0x000000018b73d3d8 _bmalloc_heap_config_specialized_local_allocator_try_allocate_small_segregated_slow + 5952
3 JavaScriptCore 0x000000018b7276f8 _bmalloc_allocate_impl_casual_case + 800
4 JavaScriptCore 0x000000018c60d494 JSC::PropertyTable::create(JSC::VM&, unsigned int) + 244
5 JavaScriptCore 0x000000018c66ba74 JSC::Structure::materializePropertyTable(JSC::VM&, bool) + 324
6 JavaScriptCore 0x000000018c66dfac JSC::Structure::changePrototypeTransition(JSC::VM&, JSC::Structure*, JSC::JSValue, JSC::DeferredStructureTransitionWatchpointFire&) + 612
7 JavaScriptCore 0x000000018c559930 JSC::JSObject::setPrototypeDirect(JSC::VM&, JSC::JSValue) + 192
8 JavaScriptCore 0x000000018c559e40 JSC::JSObject::setPrototypeWithCycleCheck(JSC::VM&, JSC::JSGlobalObject*, JSC::JSValue, bool) + 316
9 JavaScriptCore 0x000000018c4f580c JSC::globalFuncProtoSetter(JSC::JSGlobalObject*, JSC::CallFrame*) + 192
10 JavaScriptCore 0x000000018ba1f7a8 _vmEntryToNative + 280
11 JavaScriptCore 0x000000018c1b0cd0 JSC::Interpreter::executeCall(JSC::JSGlobalObject*, JSC::JSObject*, JSC::CallData const&, JSC::JSValue, JSC::ArgList const&) + 616
12 JavaScriptCore 0x000000018c474ecc JSC::GetterSetter::callSetter(JSC::JSGlobalObject*, JSC::JSValue, JSC::JSValue, bool) + 212
13 JavaScriptCore 0x000000018c5b6264 JSC::JSGenericTypedArrayView<JSC::Uint8Adaptor>::put(JSC::JSCell*, JSC::JSGlobalObject*, JSC::PropertyName, JSC::JSValue, JSC::PutPropertySlot&) + 612
14 JavaScriptCore 0x000000018c2c2ecc _llint_slow_path_put_by_id + 3244
// 忽略多余重复堆栈
37 JavaScriptCore 0x000000018ba1f5fc _vmEntryToJavaScript + 264
38 JavaScriptCore 0x000000018c1b0c7c JSC::Interpreter::executeCall(JSC::JSGlobalObject*, JSC::JSObject*, JSC::CallData const&, JSC::JSValue, JSC::ArgList const&) + 532
39 JavaScriptCore 0x000000018bac7ae4 _JSObjectCallAsFunction + 568
40 mttlite 0x0000000102a54914 hippy::napi::JSCCtx::CallFunction(std::__1::shared_ptr<hippy::napi::CtxValue> const&, unsigned long, std::__1::shared_ptr<hippy::napi::CtxValue> const*) (js_native_api_value_jsc.cc:406)
41 mttlite 0x0000000102a664e0 _ZNSt3__110__function6__funcIZN11TimerModule5StartERKN5hippy4napi12CallbackInfoEbE3$_4NS_9allocatorIS8_EEFvvEEclEv (memory:3237)
42 mttlite 0x0000000102a63018 hippy::base::TaskRunner::Run() (memory:3237)
43 mttlite 0x0000000102a64974 ThreadEntry (thread.cc:0)
44 libsystem_pthread.dylib 0x00000001dc129348 __pthread_start + 116
------
Exception Type: SIGTRAP
Exception Codes: fault addr: 0x000000018b777570
Crashed Thread: 48 hippy.js
这个OOM问题,与iOS上常见的OOM不一样。按照常规的理解,当App内存不足的时候,正常会触发系统的Jetsam机制杀死App。在系统日志中会留下Jetsam相关日志,理论上不会在Bugly等异常上报中发现。但这一类崩溃却一直在产生上报,并且低内存的崩溃堆栈表现形式有很多种。
以上的JSC崩溃问题已经存在很长一段时间了(至少2年),而且崩溃堆栈都集中在JSC执行JS代码的过程中,长期缺乏JS相关的监控与Debug工具导致该问题一直无法解决。
虽然堆栈上有明确的原因说明是OOM,但我们观察到有不少用户实际上物理内存空间还是足够的:
两年前,冲浪的时候偶然看来了来自微视同学的Case总结:《OOM与内存》
当时跟hippy SDK的同事也讨论过是否存在类似的内存不足情况。但由于大家对JSC黑盒都不熟悉,而且崩溃的JS堆栈也不确切。当时的建议是:少在后台加载JSC。最终也并没有解决该问题。
两年后,当浏览器集成flutter,类似的JS崩溃直接翻倍(21H2 0.08% -> 22H1 0.16%)。没办法,还是要看类似JSC和Dart VM的内存分配机制是怎样的,再挖掘一下是否存在解(缓)决(解)方案。
JSC、DartVM的虚拟内存分配
翻阅相关虚拟机的内存管理相关代码,可以找到底层的内存分配基本实现都是基于mmap处理的。
// WebKit bmalloc VMAllocate
inline void* tryVMAllocate(size_t vmSize, VMTag usage = VMTag::Malloc)
{
vmValidate(vmSize);
void* result = mmap(0, vmSize, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANON | BMALLOC_NORESERVE, static_cast<int>(usage), 0);
if (result == MAP_FAILED)
return nullptr;
return result;
}
// Dart VM的虚拟内存
VirtualMemory* VirtualMemory::Allocate(intptr_t size,
bool is_executable,
const char* name) {
ASSERT(Utils::IsAligned(size, PageSize()));
const int prot = PROT_READ | PROT_WRITE | (is_executable ? PROT_EXEC : 0);
int map_flags = MAP_PRIVATE | MAP_ANONYMOUS;
#if (defined(DART_HOST_OS_MACOS) && !defined(DART_HOST_OS_IOS))
if (is_executable && IsAtLeastOS10_14()) {
map_flags |= MAP_JIT;
}
#endif // defined(DART_HOST_OS_MACOS)
// Some 64-bit microarchitectures store only the low 32-bits of targets as
// part of indirect branch prediction, predicting that the target's upper bits
// will be same as the call instruction's address. This leads to misprediction
// for indirect calls crossing a 4GB boundary. We ask mmap to place our
// generated code near the VM binary to avoid this.
void* hint = is_executable ? reinterpret_cast<void*>(&Allocate) : nullptr;
void* address = mmap(hint, size, prot, map_flags, -1, 0);
if (address == MAP_FAILED) {
return nullptr;
}
return new VirtualMemory(address, size);
}
VirtualMemory::~VirtualMemory() {
if (address_ != nullptr) {
if (munmap(address_, size_) != 0) {
int error = errno;
const int kBufferSize = 1024;
char error_buf[kBufferSize];
FATAL("munmap error: %d (%s)", error,
Utils::StrError(error, error_buf, kBufferSize));
}
}
}
当map_flags包含MAP_ANON时,并且fd传入-1时,mmap将直接使用虚拟内存进行分配,不需要依赖文件描述符。
mmap在xnu上的实现
/*
* mmap stub, with preemptory failures due to extra parameter checking
* mandated for conformance.
*
* This is for UNIX03 only.
*/
void *
mmap(void *addr, size_t len, int prot, int flags, int fildes, off_t off)
{
/*
* Preemptory failures:
*
* o off is not a multiple of the page size
* o flags does not contain either MAP_PRIVATE or MAP_SHARED
* o len is zero
*/
extern void cerror_nocancel(int);
if ((off & PAGE_MASK) ||
(((flags & MAP_PRIVATE) != MAP_PRIVATE) &&
((flags & MAP_SHARED) != MAP_SHARED)) ||
(len == 0)) {
cerror_nocancel(EINVAL);
return(MAP_FAILED);
}
void *ptr = __mmap(addr, len, prot, flags, fildes, off);
if (__syscall_logger) {
int stackLoggingFlags = stack_logging_type_vm_allocate;
if (flags & MAP_ANON) {
stackLoggingFlags |= (fildes & VM_FLAGS_ALIAS_MASK);
} else {
stackLoggingFlags |= stack_logging_type_mapped_file_or_shared_mem;
}
__syscall_logger(stackLoggingFlags, (uintptr_t)mach_task_self(), (uintptr_t)len, 0, (uintptr_t)ptr, 0);
}
return ptr;
}
上面的调用会传递到内核kern_mman.c的实现函数mmap(proc_t p, struct mmap_args *uap, user_addr_t *retval)
/*
* XXX Internally, we use VM_PROT_* somewhat interchangeably, but the correct
* XXX usage is PROT_* from an interface perspective. Thus the values of
* XXX VM_PROT_* and PROT_* need to correspond.
*/
int
mmap(proc_t p, struct mmap_args *uap, user_addr_t *retval)
{
/*
* 上面忽略了一部分代码
*/
result = vm_map_enter_mem_object(user_map,
&user_addr, user_size,
0, alloc_flags, vmk_flags,
tag,
IPC_PORT_NULL, 0, FALSE,
prot, maxprot,
(flags & MAP_SHARED) ?
VM_INHERIT_SHARE :
VM_INHERIT_DEFAULT);
/* If a non-binding address was specified for this anonymous
* mapping, retry the mapping with a zero base
* in the event the mapping operation failed due to
* lack of space between the address and the map's maximum.
*/
if ((result == KERN_NO_SPACE) && ((flags & MAP_FIXED) == 0) && user_addr && (num_retries++ == 0)) {
user_addr = vm_map_page_size(user_map);
goto map_anon_retry;
}
/*
* 下面忽略了一部分代码
*/
}
其中又会调用vm_map.c内部的vm_map_enter_mem_object,而该方法最终会在vm_map_enter中依据对象进行内存分配:
// 下面这个只截了个头,大概带一下,我也没调过代码~
/*
* Routine: vm_map_enter
*
* Description:
* Allocate a range in the specified virtual address map.
* The resulting range will refer to memory defined by
* the given memory object and offset into that object.
*
* Arguments are as defined in the vm_map call.
*/
kern_return_t
vm_map_enter(
vm_map_t map,
vm_map_offset_t *address, /* IN/OUT */
vm_map_size_t size,
vm_map_offset_t mask,
int flags,
vm_map_kernel_flags_t vmk_flags,
vm_tag_t alias,
vm_object_t object,
vm_object_offset_t offset,
boolean_t needs_copy,
vm_prot_t cur_protection,
vm_prot_t max_protection,
vm_inherit_t inheritance)
其中vm_map_enter在分配过程中会对hole_entry→vme_end作判断,vme_end即最大的可分配空间。
xnu上虚拟内存的分配范围
本来我只是观察到苹果在iOS15上增加了com.apple.developer.kernel.increased-memory-limit的能力声明。本着死马当活马医的想法,尝试在新版本上添加该声明以缓解一部分问题。
结果偶然看到部分开发者提问:该能力可配合com.apple.developer.kernel.extended-virtual-addressing使用。看到后我一下子反应过来,顺手搜到了今年二月国外有大佬做了相关的探索:
Size Matters: An Exploration of Virtual Memory on iOS
文章阐述了iOS的内存管理机制和虚拟内存空间分配在不同的机型上存在上限,代码如下:
#define ARM64_MIN_MAX_ADDRESS (SHARED_REGION_BASE_ARM64 + SHARED_REGION_SIZE_ARM64 + 0x20000000) // end of shared region + 512MB for various purposes
const vm_map_offset_t min_max_offset = ARM64_MIN_MAX_ADDRESS; // end of shared region + 512MB for various purposes
if (arm64_pmap_max_offset_default) {
max_offset_ret = arm64_pmap_max_offset_default;
} else if (max_mem > 0xC0000000) {
max_offset_ret = min_max_offset + 0x138000000; // Max offset is 13.375GB for devices with > 3GB of memory
} else if (max_mem > 0x40000000) {
max_offset_ret = min_max_offset + 0x38000000; // Max offset is 9.375GB for devices with > 1GB and <= 3GB of memory
} else {
max_offset_ret = min_max_offset;
}
并且总结了一个上限值与机型表格:
RAM | Address Space | Usable | Devices |
> 3 GiB | 15.375 GiB | 7.375 GiB | - iPhone XS – iPhone 13 |
> 1 GiB | 11.375 GiB | 3.375 GiB | - iPhone 6s – X, SE, XR |
<= 1 GiB | 10.5 GiB | 2.5 GiB | - iPhone 5s, iPhone 6 |
而xnu的源码(pmap.c)中还透露了内核内存分配存在jumbo机制。当iOS App带有指定的能力声明时,xnu内核将会以jumbo模式运行,虚拟内存地址空间将会直接分配为最大值64GB:
if (option == ARM_PMAP_MAX_OFFSET_JUMBO) {
if (arm64_pmap_max_offset_default) {
// Allow the boot-arg to override jumbo size
max_offset_ret = arm64_pmap_max_offset_default;
} else {
max_offset_ret = MACH_VM_MAX_ADDRESS; // Max offset is 64GB for pmaps with special "jumbo" blessing
}
}
并且该上限值会在进程启动时进行调整,具体代码可以在kern_exec.c中找到:
/*
* Apply the requested maximum address.
*/
if (error == 0 && imgp->ip_px_sa != NULL) {
struct _posix_spawnattr *psa = (struct _posix_spawnattr *) imgp->ip_px_sa;
if (psa->psa_max_addr) {
vm_map_set_max_addr(get_task_map(new_task), (vm_map_offset_t)psa->psa_max_addr);
}
}
甚少文档记录的entitlement
com.apple.developer.kernel.extended-virtual-addressing
苹果的文档仅有一句话说明该能力:
Use this entitlement if your app has specific needs that require a larger addressable space. For example, games that memory map assets to stream to the GPU may benefit from a larger address space.
举个例子:有的游戏需要将资源通过mmap的形式传递到GPU中渲染时,更大的地址空间可提高其运行效率。
描述上看,配置该选项时,将开启上面xnu的jumbo mode,地址的扩充刚好能解决上面的崩溃问题。
做一次极限测试
为验证地址分配的极限值,简单做个实验(测试设备使用iPhone XR iOS 16 Beta 2):
通过malloc进行连续的内存分配(也可以用vm_allocate,阈值不一样),阈值卡在1009字节(为什么是1009字节,这里可以参考【ios 内核】源码解读(3) 详解ios是怎么malloc的(上) - 钟路成的博客 (luchengzhong.github.io))。
for (size_t i = 0; i < SIZE_T_MAX; i++) {
void *a = malloc(1009);
if (a == NULL) {
NSLog(@"error count: %lu", i);
break;
}
}
结果如下:
size = 1009 > SMALL_THRESHOLD (64位系统下1008字节,32位系统下496)
内存扩展前malloc失败阈值约 7065482 * 1009 = 6.63 GB
内存扩展后malloc失败阈值约 56753881 * 1009 = 53.33 GB
当然,在xnu的单元测试代码中,也可找到jumbo mode相关的测试代码,与上面的测试结果完全一致,即最多可分配53GB的空间。
#define GB (1ULL * 1024 * 1024 * 1024)
/*
* This test expects the entitlement to be the enabling factor for a process to
* allocate at least this many GB of VA space. i.e. with the entitlement, n GB
* must be allocatable; whereas without it, it must be less.
* This value was determined experimentally to fit on applicable devices and to
* be clearly distinguishable from the default VA limit.
*/
#define ALLOC_TEST_GB 53
T_DECL(TESTNAME,
"Verify that a required entitlement is present in order to be granted an extra-large "
"VA space on arm64",
T_META_NAMESPACE("xnu.vm"),
T_META_CHECK_LEAKS(false))
{
int i;
void *res;
if (!dt_64_bit_kernel()) {
T_SKIP("This test is only applicable to arm64");
}
T_LOG("Attemping to allocate VA space in 1 GB chunks.");
for (i = 0; i < (ALLOC_TEST_GB * 2); i++) {
res = mmap(NULL, 1 * GB, PROT_NONE, MAP_PRIVATE | MAP_ANON, 0, 0);
if (res == MAP_FAILED) {
if (errno != ENOMEM) {
T_WITH_ERRNO;
T_LOG("mmap failed: stopped at %d of %d GB allocated", i, ALLOC_TEST_GB);
}
break;
} else {
T_LOG("%d: %p\n", i, res);
}
}
#if defined(ENTITLED)
T_EXPECT_GE_INT(i, ALLOC_TEST_GB, "Allocate at least %d GB of VA space", ALLOC_TEST_GB);
#else
T_EXPECT_LT_INT(i, ALLOC_TEST_GB, "Not permitted to allocate %d GB of VA space", ALLOC_TEST_GB);
#endif
}
可见,当开启com.apple.developer.kernel.extended-virtual-addressing时,内核的可分配空间确实有明显提升。
上线效果与结论
从QQ浏览器的上线效果来看,JS相关的内存分配Crash在14.0以上系统几乎全部消失。上线第一天App崩溃率环比下降接近50%,效果显著。
简单总结:
- 苹果很少在公开文档中说明64位App在虚拟内存使用上存在限制。而且很多App也并没有像浏览器内一样,为业务灵活性而选择将hippy、flutter等技术进行大规模的组合使用,所以可能很多App其实并不会遇到虚拟内存不足的情况。
- 上线效果也说明浏览器在混合开发的场景下,内存优化仍然存在很大的空间。因为Extended Virtual Addressing仅能缓解虚拟内存不足的情况,并不意味着App的物理内存也得到增加,对FOOM的治理仍然需要持续。
- 鉴于司内有不少的著名组件都会使用mmap机制进行内存管理,建议在使用相关组件时,控制好mmap的大小。
- 如果有需要在iPhone 12 Pro、M1 iPad、M1上运行应用,并希望解放更多的物理内存,建议增加com.apple.developer.kernel.increased-memory-limit的能力声明,实测在iPhone 13 Pro下可以增加1GB的可用物理内存。
- ReactNative和类似框架在项目中使用较多的,建议需要考虑多个Context的复用,减少创建重复内容,司内外都有实践证明该措施十分有效。
- 对于flutter一类的内存优化,可翻阅engine的相关代码。flutter vm在创建时允许外部传参控制vm行为,包括:old heap size、leak vm等。合适的参数可比较有效控制内存占用。
以上源码相关的内容仅个人阅读理解,如有错误请指出。
相关推荐
- 最强聚类模型,层次聚类 !!_层次聚类的优缺点
-
哈喽,我是小白~咱们今天聊聊层次聚类,这种聚类方法在后面的使用,也是非常频繁的~首先,聚类很好理解,聚类(Clustering)就是把一堆“东西”自动分组。这些“东西”可以是人、...
- python决策树用于分类和回归问题实际应用案例
-
决策树(DecisionTrees)通过树状结构进行决策,在每个节点上根据特征进行分支。用于分类和回归问题。实际应用案例:预测一个顾客是否会流失。决策树是一种基于树状结构的机器学习算法,用于解决分类...
- Python教程(四十五):推荐系统-个性化推荐算法
-
今日目标o理解推荐系统的基本概念和类型o掌握协同过滤算法(用户和物品)o学会基于内容的推荐方法o了解矩阵分解和深度学习推荐o掌握推荐系统评估和优化技术推荐系统概述推荐系统是信息过滤系统,用于...
- 简单学Python——NumPy库7——排序和去重
-
NumPy数组排序主要用sort方法,sort方法只能将数值按升充排列(可以用[::-1]的切片方式实现降序排序),并且不改变原数组。例如:importnumpyasnpa=np.array(...
- PyTorch实战:TorchVision目标检测模型微调完
-
PyTorch实战:TorchVision目标检测模型微调完整教程一、什么是微调(Finetuning)?微调(Finetuning)是指在已经预训练好的模型基础上,使用自己的数据对模型进行进一步训练...
- C4.5算法解释_简述c4.5算法的基本思想
-
C4.5算法是ID3算法的改进版,它在特征选择上采用了信息增益比来解决ID3算法对取值较多的特征有偏好的问题。C4.5算法也是一种用于决策树构建的算法,它同样基于信息熵的概念。C4.5算法的步骤如下:...
- Python中的数据聚类及可视化分析实践
-
探索如何通过聚类分析揭露糖尿病预测数据集的特征!我们将运用Python的强力工具,深入挖掘数据,以直观的可视化揭示不同特征间的关系。一同探索聚类分析在糖尿病预测中的实践!所有这些可视化都可以通过数据操...
- 用Python来统计大乐透号码的概率分布
-
用Python来统计大乐透号码的概率分布,可以按照以下步骤进行:导入所需的库:使用Python中的numpy库生成数字序列,使用matplotlib库生成概率分布图。读取大乐透历史数据:从网络上找到大...
- python:支持向量机监督学习算法用于二分类和多分类问题示例
-
监督学习-支持向量机(SVM)支持向量机(SupportVectorMachine,简称SVM)是一种常用的监督学习算法,用于解决分类和回归问题。SVM的目标是找到一个最优的超平面,将不同类别的...
- 25个例子学会Pandas Groupby 操作
-
groupby是Pandas在数据分析中最常用的函数之一。它用于根据给定列中的不同值对数据点(即行)进行分组,分组后的数据可以计算生成组的聚合值。如果我们有一个包含汽车品牌和价格信息的数据集,那么可以...
- 数据挖掘流程_数据挖掘流程主要有哪些步骤
-
数据挖掘流程1.了解需求,确认目标说一下几点思考方法:做什么?目的是什么?目标是什么?为什么要做?有什么价值和意义?如何去做?完整解决方案是什么?2.获取数据pandas读取数据pd.read.c...
- 使用Python寻找图像最常见的颜色_python 以图找图
-
如果我们知道图像或对象最常见的是哪种颜色,那么可以解决图像处理中的几个用例,例如在农业领域,我们可能需要确定水果的成熟度。我们可以简单地检查一下水果的颜色是否在预定的范围内,看看它是成熟的,腐烂的,还...
- 财务预算分析全网最佳实践:从每月分析到每天分析
-
原文链接如下:「链接」掌握本文的方法,你就掌握了企业预算精细化分析的能力,全网首发。数据模拟稍微有点问题,不要在意数据细节,先看下最终效果。在编制财务预算或业务预算的过程中,通常预算的所有数据都是按月...
- 常用数据工具去重方法_数据去重公式
-
在数据处理中,去除重复数据是确保数据质量和分析准确性的关键步骤。特别是在处理多列数据时,保留唯一值组合能够有效清理数据集,避免冗余信息对分析结果的干扰。不同的工具和编程语言提供了多种方法来实现多列去重...
- Python教程(四十):PyTorch深度学习-动态计算图
-
今日目标o理解PyTorch的基本概念和动态计算图o掌握PyTorch张量操作和自动求导o学会构建神经网络模型o了解PyTorch的高级特性o掌握模型训练和部署PyTorch概述PyTorc...
- 一周热门
- 最近发表
- 标签列表
-
- ps图案在哪里 (33)
- super().__init__ (33)
- python 获取日期 (34)
- 0xa (36)
- super().__init__()详解 (33)
- python安装包在哪里找 (33)
- linux查看python版本信息 (35)
- python怎么改成中文 (35)
- php文件怎么在浏览器运行 (33)
- eval在python中的意思 (33)
- python安装opencv库 (35)
- python div (34)
- sticky css (33)
- python中random.randint()函数 (34)
- python去掉字符串中的指定字符 (33)
- python入门经典100题 (34)
- anaconda安装路径 (34)
- yield和return的区别 (33)
- 1到10的阶乘之和是多少 (35)
- python安装sklearn库 (33)
- dom和bom区别 (33)
- js 替换指定位置的字符 (33)
- python判断元素是否存在 (33)
- sorted key (33)
- shutil.copy() (33)