C++ 内存 堆和栈的区别、内存管理、程序的内存区域(Section)和启动过程、内存对齐的使用场景

C++ 的内存知识不仅是编写高效、安全代码的基础,也是深入理解计算机系统工作原理的关键。对内存管理的深入理解可以帮助开发者写出更高质量的代码,提高项目的成功率。因此,掌握这些知识对任何 C++ 开发者来说都是非常重要的。

1. 堆和栈的区别

堆(Heap)和栈(Stack)是C++程序中两种不同的内存管理区域,它们在内存的使用、分配方式、生命周期和管理机制上有明显区别。

1.1. 栈(Stack)

栈是由操作系统自动管理的内存区域,用来存储局部变量、函数参数和返回值等数据。栈的内存分配遵循"后进先出"(LIFO)的原则。

  • 分配和释放:自动分配和释放,内存管理由编译器控制。
  • 存储内容:函数参数、局部变量、返回地址。
  • 生命周期:栈上的变量在其作用域结束时自动释放。
  • 大小限制:栈的大小通常有限,由操作系统设定,超过栈空间可能导致栈溢出。
  • 访问速度:由于栈结构的连续性,访问速度快。

栈示例

void func() {

int localVar = 10; // localVar 在栈上分配

} // 当函数结束时,localVar 自动从栈上释放

1.2. 堆(Heap)

堆是由程序员手动管理的动态内存区域,通常用于需要在程序运行时动态分配内存的数据,比如使用 newmalloc 函数分配的内存。堆的内存分配没有固定的顺序。

  • 分配和释放 :由程序员手动管理,需调用 new/deletemalloc/free
  • 存储内容:动态分配的内存(如对象、数组等)。
  • 生命周期:堆上的内存一直存在,直到程序员显式释放它,否则会造成内存泄漏。
  • 大小限制:堆的大小取决于系统的可用内存,通常比栈大。
  • 访问速度:堆内存的访问速度比栈稍慢,因为堆是分散的。

堆示例

void func() {

int* heapVar = new int(10); // heapVar 在堆上分配

delete heapVar; // 需要手动释放堆内存

}

1.3. 堆和栈的区别总结
特点 栈(Stack) 堆(Heap)
内存分配 自动分配 手动分配
分配方式 LIFO(后进先出) 随机分配
管理机制 由操作系统或编译器自动管理 由程序员手动管理
大小 通常较小 通常较大
分配速度 较慢
生命周期 作用域结束自动释放 需手动释放,否则内存泄漏
常见错误 栈溢出 内存泄漏、悬挂指针

这种区别使得栈适用于小且生命周期明确的数据,堆则适用于需要在运行时动态管理内存的数据。

2. C++ 内存管理

C++ 内存管理包括静态内存分配和动态内存分配两种方式。在内存管理中,程序员需要了解变量的生命周期、作用域以及如何避免常见的内存管理错误,如内存泄漏和悬挂指针。下面将详细介绍 C++ 内存管理的几个关键点。

2.1. 内存区域划分

C++ 程序运行时,内存大致分为以下几个区域:

  • 栈(Stack):用于局部变量、函数调用、参数等的内存区域,由操作系统自动管理,详见上一问题。
  • 堆(Heap):用于动态分配的内存,需手动管理,详见上一问题。
  • 静态/全局内存区:用于存储全局变量和静态变量,在程序启动时分配,程序结束时释放。
  • 代码段:存储程序的机器指令,通常是只读的。
  • 常量区:用于存储常量,例如字符串字面量。
2.2. 静态内存分配

静态内存分配是在编译时确定内存大小的分配方式,主要包括:

  • 全局变量:全局变量在整个程序生命周期中一直存在。
  • 静态变量:局部静态变量的生命周期与程序一致,但作用域局限于其定义的函数内。

静态内存示例

int globalVar = 10; // 全局变量,程序运行期间存在

void func() {

static int staticVar = 5; // 静态变量,生命周期为整个程序,但仅在该函数内有效

}

2.3. 动态内存分配

动态内存分配是在程序运行时根据需要分配内存,并在不需要时显式释放。C++ 提供了 new/delete 以及 C 风格的 malloc/free 进行动态内存管理。

  • new/delete:C++ 的动态内存管理关键字,用于对象的创建和销毁。
  • malloc/free:C 语言中的内存管理函数,也可以在 C++ 中使用,但需要注意手动调用构造函数和析构函数。

动态内存分配示例

// 使用 new 和 delete

int* ptr = new int(20); // 动态分配内存

delete ptr; // 释放内存

// 使用 malloc 和 free

int* cPtr = (int*)malloc(sizeof(int)); // 动态分配内存

free(cPtr); // 释放内存

2.4. 常见内存管理错误

C++ 手动管理内存容易产生一些错误,主要包括以下几类:

  1. 内存泄漏(Memory Leak): 动态分配的内存没有及时释放,导致内存资源一直占用,程序长时间运行时可能耗尽内存。

    • 原因 :调用 newmalloc 分配内存后,忘记调用 deletefree 释放。
    • 解决方法:每次分配内存后,确保在合适的时机释放内存。

    示例 :void memoryLeak() {

    int* leakPtr = new int(5);

    // 忘记 delete,造成内存泄漏

    }

  2. 悬挂指针(Dangling Pointer): 指针指向的内存已经释放,但指针仍然在使用该内存,导致程序异常或崩溃。

    • 原因:释放指针指向的内存后,仍然使用该指针。
    • 解决方法 :释放指针后将其置为 nullptr

    示例 :void danglingPointer() {

    int* ptr = new int(10);

    delete ptr;

    ptr = nullptr; // 避免悬挂指针

    }

  3. 野指针(Wild Pointer): 指针没有初始化,指向随机的内存地址,可能导致程序不稳定或崩溃。

    • 原因:定义指针时没有初始化。
    • 解决方法 :初始化所有指针为 nullptr

    示例 :void wildPointer() {

    int* wildPtr; // 未初始化

    wildPtr = nullptr; // 初始化为 nullptr

    }

  4. 双重释放(Double Free): 同一块内存被释放多次,可能导致程序崩溃或异常行为。

    • 原因 :调用 deletefree 多次。
    • 解决方法 :每块内存只调用一次 deletefree,并及时将指针置为 nullptr

    示例 :void doubleFree() {

    int* ptr = new int(10);

    delete ptr;

    // delete ptr; // 再次调用会导致错误

    ptr = nullptr;

    }

2.5. 智能指针

为了简化内存管理并避免上述错误,C++11 引入了智能指针,它们能够自动管理内存,不需要显式调用 delete

  • std::unique_ptr:独占所有权的智能指针,不能被复制。
  • std::shared_ptr:共享所有权的智能指针,多个指针可以指向同一对象,引用计数为 0 时释放内存。
  • std::weak_ptr :辅助 shared_ptr,不会增加引用计数,用于解决循环引用问题。

智能指针示例

#include <memory>

void smartPointer() {

std::unique_ptr<int> uniquePtr(new int(10)); // 使用 unique_ptr 管理内存

std::shared_ptr<int> sharedPtr = std::make_shared<int>(20); // 使用 shared_ptr

}

2.6. 总结

C++ 的内存管理需要程序员手动管理堆上的内存,这虽然带来了灵活性,但也带来了复杂性和潜在的内存管理问题。C++11 及以后版本引入的智能指针大大简化了内存管理,使内存泄漏和悬挂指针等问题更容易避免。

C++ 内存管理的关键是明确变量的生命周期、合理使用堆和栈、并且及时释放不再使用的资源。

3. malloc 和局部变量分配在堆还是栈?

在 C++ 中,malloc 和局部变量的内存分配分别发生在堆和栈中,下面详细解释两者的差异。

3.1. malloc 分配的内存在堆(Heap)
  • malloc(Memory Allocation) 是 C 语言的动态内存分配函数,在 C++ 中也可以使用。它用于在 上分配指定大小的内存。malloc 返回一个指向已分配内存的指针,但不会调用构造函数(这也是它与 C++ 的 new 关键字的不同点)。
  • 分配的内存不会自动释放,必须通过 free 函数手动释放,否则会造成内存泄漏。

示例

#include <cstdlib> // 包含 malloc 和 free 函数

void func() {

int* ptr = (int*)malloc(sizeof(int)); // 在堆上分配内存

*ptr = 10; // 使用堆内存

free(ptr); // 手动释放堆内存

}

总结malloc 分配的内存在 中,由程序员手动管理。

3.2. 局部变量分配在栈(Stack)
  • 局部变量 是指在函数内部定义的变量,它们的内存分配发生在 中。栈内存由操作系统自动管理,函数返回后,局部变量占用的内存会自动释放。
  • 栈的特点是分配快且自动管理,局部变量不需要显式地释放。

示例

void func() {

int localVar = 20; // 在栈上分配内存

} // 当函数返回时,localVar 自动从栈中释放

总结 :局部变量分配在 中,自动管理,不需要手动释放。

3.3. 总结对比
特性 malloc 分配的内存 局部变量
内存位置 堆(Heap) 栈(Stack)
管理方式 需手动释放(使用 free 自动释放
内存分配速度 较慢,因为堆是分散存储的 较快,因为栈是连续存储的
生命周期 由程序员控制,直到手动释放 在函数作用域结束时自动销毁

总结malloc 分配的内存位于 中,必须手动释放;局部变量分配在 中,生命周期短且自动管理。

4. 程序的内存区域(Section)和启动过程

在 C++ 程序中,内存通常分为多个区域(Section),每个区域都有不同的作用,分别用于存储代码、数据和动态分配的内存。理解程序的内存结构有助于更好地进行内存管理。

4.1. 程序的内存区域(Sections)
  1. 代码段(Text Segment)

    • 存储程序的机器指令,通常是只读的,防止指令被意外修改。
    • 包含函数和方法的编译后代码。
    • 作用:提供执行的代码。
  2. 数据段(Data Segment): 数据段可以分为以下两个子区域:

    • 初始化数据段(Initialized Data Segment)
      • 存储已初始化的全局变量和静态变量。程序启动时,数据段中的变量已经被赋予了初始值。
      • 作用:用于全局和静态变量的存储,这些变量在整个程序执行期间存在。
    • 未初始化数据段(BSS Segment, Block Started by Symbol)
      • 存储未初始化的全局变量和静态变量。这些变量在程序启动时被自动初始化为零。
      • 作用:为未初始化的全局和静态变量预留空间,初始值为 0。
  3. 堆(Heap)

    • 堆是由程序员手动管理的内存区域,用于动态内存分配。
    • 动态分配的内存由 malloccallocreallocnew 操作符分配,程序员需要通过 freedelete 显式释放。
    • 作用:为需要在运行时动态分配内存的数据存储提供空间,适合长生命周期或较大内存的数据。
  4. 栈(Stack)

    • 栈是为函数调用分配的内存区域,用于存储局部变量、函数参数、返回地址等。
    • 栈的内存是由操作系统自动分配和释放的。栈采用"后进先出"(LIFO)的方式进行管理,函数结束时,栈上的内存自动释放。
    • 作用:存储局部变量、函数参数和调用信息,管理短生命周期的数据。
4.2. 程序的启动过程

程序的启动包括以下几个主要步骤:

  1. 加载器加载程序

    • 操作系统的加载器将程序的代码段、数据段和栈初始化,并将程序加载到内存。
    • 加载器还会分配一个初始的栈空间,并准备好程序的入口点。
  2. 初始化全局和静态变量

    • 在程序的初始化阶段,全局和静态变量会根据它们的初始值(数据段)或默认值(未初始化数据段)进行初始化。
  3. 程序的入口点(main 函数)

    • 加载器执行程序的入口函数(通常是 main())。程序开始从代码段中的指令地址执行。
  4. 执行代码

    • 程序的代码执行过程中,栈上会根据函数调用、局部变量等不断分配和释放内存。同时,堆上也可能动态分配内存(通过 newmalloc)。
  5. 终止阶段

    • main() 函数返回时,操作系统会清理进程的资源,包括释放栈和堆上的动态内存。
4.3. 如何判断数据分配在栈上还是堆上?

可以通过以下几种方式判断数据是分配在栈上还是堆上:

可以通过以下几种方式判断数据是分配在栈上还是堆上:

  1. 静态分析

    • 如果是局部变量(定义在函数内部的变量),通常分配在栈上。
    • 如果是通过 newmalloc 分配的内存,分配在堆上。
  2. 生命周期

    • 栈上的数据是局部的,随着函数调用的结束而自动释放。栈上的数据生命周期较短。
    • 堆上的数据是动态分配的,它的生命周期由程序员控制,直到显式释放它。
  3. 内存管理方式

    • 栈上的内存由操作系统自动管理,函数结束后自动释放。
    • 堆上的内存必须由程序员手动管理(通过 deletefree 释放)。
  4. 调试工具 : 使用调试工具或内存分析工具(如 valgrind)可以动态跟踪内存分配情况,帮助判断某段数据是否分配在堆上或栈上。

4.4. 总结
  • 程序的内存分区:代码段(存储指令)、数据段(存储全局/静态变量)、堆(动态内存)、栈(局部变量、函数调用)。
  • 程序的启动过程 :加载器加载程序、初始化全局变量、执行 main() 函数、清理内存。
  • 判断数据分配位置 :局部变量分配在栈上,动态分配的内存(new/malloc)分配在堆上,栈由系统管理,堆需要手动释放。

C++ 程序的内存区域和作用

内存区域 作用 内存分配位置 管理方式 生命周期
代码段(Text Segment) 存储程序的机器指令(代码)。通常是只读的,防止代码被修改。 固定内存区域 操作系统管理 程序运行时保持存在
数据段(Data Segment) 存储已初始化的全局变量和静态变量。 固定内存区域 操作系统管理 程序结束时释放
BSS段(BSS Segment) 存储未初始化的全局变量和静态变量,程序启动时被初始化为零。 固定内存区域 操作系统管理 程序结束时释放
堆(Heap) 用于动态分配内存(通过 mallocnew 分配)。 动态增长/缩减 程序员手动管理(通过 deletefree 由程序员控制,直到显式释放
栈(Stack) 存储局部变量、函数参数、返回地址等。 动态增长/缩减 操作系统自动管理 函数调用结束时自动释放

程序启动过程概述

步骤 说明
加载器加载程序 操作系统加载程序,将代码段、数据段、栈等加载到内存。
初始化全局/静态变量 初始化全局变量和静态变量,未初始化的变量被赋值为 0。
执行 main() 函数 执行程序的入口函数 main(),程序开始运行。
函数调用及内存分配 在栈上分配局部变量、函数参数,在堆上根据需要动态分配内存。
程序结束/资源清理 程序执行结束后,操作系统释放栈内存,程序员需要手动释放堆上的动态内存。

5. 初始化为0的全局变量存储位置

在 C++ 中,初始化为0的全局变量存储在 BSS 段(Block Started by Symbol)中,而不是数据段(Data Segment)。下面详细解释这两个区域的区别,以及为什么初始化为0的全局变量位于BSS段。

5.1. BSS 段(BSS Segment)
  • BSS 段用于存储未初始化的全局变量和静态变量。根据 C++ 标准,未显式初始化的全局变量在程序启动时自动初始化为0。
  • 由于 BSS 段只存储未初始化的全局变量和静态变量,因此它的大小通常较小,且在程序加载时只占用必要的内存空间(未初始化的内存不会在可执行文件中显式存储)。
5.2. 数据段(Data Segment)
  • 数据段用于存储已初始化的全局变量和静态变量。这些变量在程序开始运行之前已经赋予了特定的初始值。
  • 如果全局变量在定义时被初始化为一个非零值(例如 int a = 5;),则它会存储在数据段中。

5.3. 总结

变量类型 存储位置 初始化值
已初始化的全局变量 数据段(Data Segment) 非零值
未初始化的全局变量 BSS 段(BSS Segment) 默认初始化为 0

因此,初始化为0的全局变量存储在 BSS 段中。这种设计可以节省内存,因为未初始化的变量不会占用额外的空间,而只是在运行时动态分配。

6. C++ 中内存对齐的使用场景

6.1. 什么是内存对齐

内存对齐是指将数据存储在内存中的特定地址边界上,以提高内存访问的效率。在计算机体系结构中,处理器通常对不同类型的数据有特定的对齐要求。数据类型的对齐方式决定了它在内存中的起始地址。

例如:

  • 对于一个 4 字节的整型变量,其地址应该是 4 的倍数。
  • 对于一个 8 字节的双精度浮点数,其地址应该是 8 的倍数。

内存对齐的目标是使数据的存取更加高效,减少 CPU 访问内存时的开销。

6.2. 为什么要进行内存对齐
  1. 提高性能

    • 许多现代 CPU 在访问内存时,如果数据不符合对齐要求,可能会导致多次内存访问。未对齐的访问通常会增加 CPU 的负担,因为 CPU 需要进行额外的计算来获取正确的数据。
    • 通过内存对齐,程序可以在一次内存访问中获取所需数据,从而提高数据访问速度。
  2. 减少内存访问错误

    • 一些体系结构不支持未对齐的访问,若程序尝试访问不对齐的地址,会导致运行时错误(如总线错误)。内存对齐可以避免这些潜在的问题。
  3. 更有效地利用缓存

    • 内存对齐可以改善数据在缓存中的存储效果。对齐的数据更容易适配缓存行,提高缓存的命中率,从而进一步提升程序的运行效率。
6.3. 内存对齐的使用场景
  1. 结构体对齐

    • 在定义结构体时,各字段的内存对齐很重要。未对齐的字段会导致结构体占用更多的内存。编译器会根据字段的对齐要求在结构体内部插入填充字节(padding)以满足对齐。

    • 例如:

      复制代码
      struct AlignedStruct {
          char a;     // 1 byte
          int b;      // 4 bytes (需要对齐到4的边界)
      };

      在这个例子中,可能会在 char a 后面插入 3 个填充字节,以使 int b 从 4 的倍数地址开始。

  2. 数组和动态内存分配

    • 对于数组,编译器会按照数组元素的对齐要求分配内存。
    • 当使用 newmalloc 动态分配内存时,内存对齐也是默认处理的,以确保返回的指针符合对齐要求。
  3. 跨平台开发

    • 不同的计算机体系结构(如 x86 和 ARM)可能有不同的内存对齐要求。在开发跨平台应用时,理解内存对齐非常重要,以确保在不同平台上程序的正确性和性能。
  4. 性能优化

    • 在性能敏感的应用程序中(如游戏开发、实时系统等),通过合理的内存对齐可以显著提高内存访问速度和整体性能。

6.4. 总结

  • 内存对齐是将数据存储在特定的地址边界上,以提高内存访问的效率。
  • 原因包括提高性能、减少内存访问错误和更有效地利用缓存。
  • 使用场景包括结构体对齐、数组和动态内存分配、跨平台开发和性能优化等。

通过内存对齐,C++ 程序可以在高效利用内存的同时,提升程序性能,避免潜在的运行时错误。

内存对齐应用于的三种数据类型

  1. 结构体(struct)
  2. 类(class)
  3. 联合体(union)

内存对齐原则

以下是结构体、类和联合体内存对齐的四个原则:

1. 对齐要求
  • 每个数据类型都有其特定的对齐要求。例如,char 的对齐要求通常是 1 字节,int 是 4 字节,double 是 8 字节。结构体的对齐要求通常是其最大成员的对齐要求。
  • 对齐要求决定了数据在内存中存放的起始地址,所有数据成员的地址必须是其对齐要求的倍数。
2. 成员顺序
  • 在结构体或类中,成员的声明顺序会影响内存对齐的效果。编译器会根据每个成员的对齐要求插入填充字节(padding)以满足对齐要求。
  • 一般建议将对齐要求较高的成员放在前面,以减少填充字节的数量,从而有效利用内存。
3. 填充字节(Padding)
  • 为了满足对齐要求,编译器可能会在数据成员之间插入填充字节。这会导致结构体、类的实际大小比其成员大小的总和要大。
  • 填充字节的插入通常是为了确保每个成员的地址都符合其对齐要求。
4. 结构体和类的整体对齐
  • 结构体和类的整体大小(即它们的内存占用)通常会被调整为其最大成员的对齐要求的倍数。也就是说,结构体或类的大小必须是其最大对齐要求的倍数,以确保其在数组中的每个元素也满足对齐要求。
  • 例如,如果一个结构体的最大成员是 8 字节的 double,则该结构体的大小也应为 8 的倍数。

示例代码

以下是一个示例代码,展示了内存对齐的影响:

#include <iostream>

#include <cstddef>

struct Example {

char a; // 1 byte

int b; // 4 bytes (requires 4-byte alignment)

short c; // 2 bytes

// Padding: 2 bytes to align the structure size to 4 bytes.

};

class ExampleClass {

public:

char a; // 1 byte

double b; // 8 bytes (requires 8-byte alignment)

// Padding: 7 bytes to align the class size to 8 bytes.

};

union ExampleUnion {

int x; // 4 bytes

char y; // 1 byte

double z; // 8 bytes (requires 8-byte alignment)

// Size of union will be the size of its largest member (8 bytes).

};

int main() {

std::cout << "Size of struct: " << sizeof(Example) << " bytes\n";

std::cout << "Size of class: " << sizeof(ExampleClass) << " bytes\n";

std::cout << "Size of union: " << sizeof(ExampleUnion) << " bytes\n";

return 0;

}

输出:

Size of struct: 12 bytes

Size of class: 16 bytes

Size of union: 8 bytes

1. struct Example 内存布局

  • char a 占用 1 个字节。
  • int b 通常要求对齐到 4 字节边界,所以 int b 会在第 4 个字节处开始存储,占用 4 字节。
  • short c 占用 2 字节。
  • 由于 struct 的总大小通常要求是它最大对齐成员的倍数(在这个例子中是 4 字节),所以在末尾可能会插入 2 个填充字节,使结构体的总大小变成 12 字节。

内存布局示例:

成员 大小(字节) 偏移量(字节)
a 1 0
填充 3 1-3
b 4 4-7
c 2 8-9
填充 2 10-11
  • 总大小:12 字节。

2. class ExampleClass 内存布局

  • char a 占用 1 字节。
  • double b 通常要求对齐到 8 字节边界,因此 double b 会从第 8 个字节开始存储,前面会有 7 个字节的填充以满足对齐要求。
  • 由于 double b 占用 8 字节,类的总大小必须是 8 字节的倍数,因此最终类的大小是 16 字节。

内存布局示例:

成员 大小(字节) 偏移量(字节)
a 1 0
填充 7 1-7
b 8 8-15
  • 总大小:16 字节。

3. union ExampleUnion 内存布局

  • 联合体(union)的所有成员共享同一块内存区域。也就是说,xyz 都会存储在同一个内存地址。
  • int x 占用 4 字节,char y 占用 1 字节,double z 占用 8 字节。
  • 联合体的总大小取决于它的最大成员的大小。在这个例子中,z 是最大成员,因此联合体的大小是 8 字节。

内存布局示例:

成员 大小(字节) 偏移量(字节)
x/y/z 8 0-7
  • 总大小:8 字节。

4. 输出解释

main 函数中,我们使用 sizeof 运算符来获取 structclassunion 的大小。

解释:
  • struct Example 占用 12 字节,尽管它的成员大小加起来不到 12 字节,但由于内存对齐要求,填充字节使得总大小为 12 字节。
  • class ExampleClass 占用 16 字节,因为 double 要求对齐到 8 字节,并且在 char 后面插入了 7 个字节的填充。
  • union ExampleUnion 占用 8 字节,因为 union 的总大小由最大成员的大小决定,在这个例子中是 double z 的 8 字节。

总结

  • 结构体和类的内存布局遵循对齐原则,因此可能会有填充字节,以确保内存对齐。
  • 联合体的所有成员共享同一块内存,大小由最大成员决定。
  • 内存对齐有助于提高程序性能,但同时也可能导致内存的额外浪费。
相关推荐
‘’林花谢了春红‘’1 小时前
C++ list (链表)容器
c++·链表·list
----云烟----1 小时前
QT中QString类的各种使用
开发语言·qt
lsx2024062 小时前
SQL SELECT 语句:基础与进阶应用
开发语言
开心工作室_kaic2 小时前
ssm161基于web的资源共享平台的共享与开发+jsp(论文+源码)_kaic
java·开发语言·前端
向宇it2 小时前
【unity小技巧】unity 什么是反射?反射的作用?反射的使用场景?反射的缺点?常用的反射操作?反射常见示例
开发语言·游戏·unity·c#·游戏引擎
武子康2 小时前
Java-06 深入浅出 MyBatis - 一对一模型 SqlMapConfig 与 Mapper 详细讲解测试
java·开发语言·数据仓库·sql·mybatis·springboot·springcloud
转世成为计算机大神3 小时前
易考八股文之Java中的设计模式?
java·开发语言·设计模式
机器视觉知识推荐、就业指导3 小时前
C++设计模式:建造者模式(Builder) 房屋建造案例
c++
宅小海3 小时前
scala String
大数据·开发语言·scala
qq_327342733 小时前
Java实现离线身份证号码OCR识别
java·开发语言