重庆分公司,新征程启航
为企业提供网站建设、域名注册、服务器等服务
文章概述
内存管理对于每种开发语言来说都是一个十分重要的话题;即使像Java这种拥有“复杂”垃圾收集器的语言,也会面临GC带来的各种困扰。
C++程序设计中的很多bug也是因为内存管理不善导致的,而且难以发现和排除;如何有条理地管理内存,对于C++开发尤为重要。
“他山之石可以攻玉”,在研究如何做好C++内存管理之前,我们也可以看下其他语言是怎么做内存管理的,有什么模式或者模型能为我们所借鉴,能够更好地帮助我们理解和做好C++的内存管理。
01
不同语言的内存管理机制介绍
C/C++、Java、Objective-C/Swift 和Golang 是几种使用广泛的语言,内存管理机制也相对典型。
1.1 C/C++
C 也常被称作“可移植的汇编”,诞生之初主要是解决汇编语言的移植问题,在嵌入式和操作系统等相对底层的开发领域应用广泛;对于复杂的业务问题,因为它没有面向对象的能力(不好抽象业务逻辑),显得难以应付。
C++ 是一种很弱的面向对象的语言(模版和Interface几乎是一种相互违背的思想)。为了兼容几乎所有的C特性,背上了比较重的历史包袱。在C++11之后,这种现象有了比较大程度的改善,各种新的语言特性可以让C++开发者开发出更优雅、健壮的代码。
C语言的内存管理是典型的手动管理机制,通过malloc申请,free释放。
C++语言除了手动管理之外,还拥有弱的“垃圾回收机制”(智能指针的支持)。
C/C++中常见的内存管理问题有:
a. 数组访问越界
(Java语言可抛出ArrayIndexOutOfBoundsException)
b. 内存访问超越生命周期
‣ 栈弹出之后,依旧进行访问
(函数返回内部栈地址)
‣ 堆内存释放,依旧进行访问
c. 内存泄露 (没有释放不再使用的内存)
d. 悬空指针导致的问题
‣ 指针指向内存释放之后,指针没有复位(设置为nullptr)
‣ 使用没有复位(不为null)的无效内存(已释放或者未申请的内存)
e. C++独有的问题
‣ 非预期内的拷贝构造函数调用带来的过度复制(性能问题)
‣ 不合理的复制、拷贝构造函数的实现,导致的意外数据共享(没有设置为nocopyable)
1.2 Java
Java 是一种面向对象的现代语言,有着丰富的语言特性和开发生态。Java语言是为了实现下一代智能家电的通用系统而设计的。在借鉴C++语言的基础上,又摒弃了C++
的一些复杂特性(可能降低软件开发质量)。
比如:
a. 不允许多继承
b. 更纯粹的interface
c. 所有皆对象(基础类型除外)
d. non-static方法默认支持多态
e. 等等
不想“有心栽花花不开,无心插柳柳成荫”。
Java 在家电市场毫无起色,却因为优异的网络编程支持能力、平台无关性、垃圾回收等能力,加上恰逢互联网时代的到来,而后在企业级市场上大放异彩。
Java 因为有虚拟机的支持(先编译成字节码,由虚拟机解释成不同平台的“语言”),可以做到“一次编译,到处运行”。
Java 目前在后台开发、大数据以及App开发领域(Kotlin也是类Java语言)有着非常广泛的应用。
Java 的内存管理依托于JVM的垃圾回收器(Garbage Collections)
一般而言,垃圾回收的步骤包括两步:
a. 找到可被回收的对象;
b. 进行内存回收和整理
JVM(HotSpot)的GC也是如此。
(一)可收回对象判断
JVM GC基于可达分析,来查找可回收对象;可以避免引用计数方案的循环引用问题。
(图1 基于根的可达对象分析)
(二)可回收策略和算法
几乎所有的垃圾回收器,都存在STW问题,高效回收以及降低对业务代码执行的影响是一件很难的事情。为了尽可能地优化性能,GC采用 分代收集 和 标记-清除/标记-清除-整理/标记-复制 进行内存回收。
‣ 分代收集 (新生代和老年代)
不难理解,新申请的内存比较大的概率可以在不久后删除;如果一个内存存在比较久了,那么接下来被回收的概率就会比较低;新生代的回收会比较轻量和高效,老年代的GC相对会比较重。
- G1之前基本都是下图这种典型的分代内存模型。
(图2 典型的内存分代模型)
- G1仍然保留了新生代和老年代的概念,但是新生代和老年代的内存区域不再固定,都是一系列的动态集合。
(图3 G1的内存分代模型)
‣ 标记-清除/标记-清除-整理/标记-复制
- 标记-清除 方法相对简单、高效,但是会存在内存碎片;
- 标记-清除-整理 可以解决内存碎片的问题,但是会增加GC的持续时间(好处大于坏处);
- 标记-复制 方法类似于ping-pang机制,需要有两片内存区域;在内存清理阶段,会将存活对象统一放到一个区域,而释放另外一个区域;和整理方法一样,也不会产生内存碎片,并且复制和标记可以同时进行,但是需要更多的内存区域。
JVM有多种垃圾收集器可供选择,需要根据业务需求(低延迟or高吞吐)进行权衡,CMS和G1使用相对较多。
a. CMS用于老年代的垃圾回收,追求最短停顿;
b. G1老年代和新生代都可以使用,并且相对高效;
c. Java11 推出的Z Garbage Collector(ZGC)有着不错的性能,目前基本可以投入生产。
(https://docs.oracle.com/en/java/javase/11/gctuning/z-garbage-collector1.html)
1.3 Objective-C/Swift
Objective-C 是基于C语言发展出的面向对象的开发语言(Objective);Objective-C的语法相对繁琐、不够便捷,所以苹果在2014推出了Swift,拥有脚本语言般的表现力。
Objective-C 的内存管理基于简单的引用计数,
可以分为两类:
‣ MRR:Manual Retain-Release
(图4 Objective-C MRR机制)
‣ ARC:Automatic Reference Counting
ARC底层还是MRR,只是由编译器在恰当的位置帮我们插入retain和release。是否开启ARC支持和编译器版本以及编译器选项有关。
1.4 Golang
Golang 也是具备垃圾回收的一种语言,主要应用在后端开发领域;回收策略也是基于可达对象分析和标记-清除-整理/复制算法。
和Java的比对,可以参考以下链接:
https://blog.mooncascade.com/go-vs-java-we-chose-go-and-you-should-too/
02
引用模型对对象生命周期的影响
不同的引用类型对对象的生命周期影响不一样,从语义上可分为三类:
‣ 强引用(Strong reference)
- 强引用对象,不可以被回收
- 如果是基于引用计数,引用计数会被影响
‣ 软引用(Soft reference)
- 非必要不回收,比如JVM在OOM之前会尝试对Soft reference对象进行回收
- 如果基于引用计数,会退化为弱引用
‣ 弱引用(Weak reference)
- 不影响对象生命期
- 如果基于引用计数,不会影响引用计数
03
C++的内存管理方案
原则:尽量使用智能指针,不要担心智能指针带来性能损耗。
3.1 手动管理内存
在某些场景下,C++需要手动管理内存;我们可以使用一些技巧来更安全地使用和管理内存。
a. 避免悬空指针
(点击查看大图)
b. 基于Allocator 策略进行内存分配
- 通过Allocator可以改变stl容器的内存分配机制,比如为vector在栈上分配内存;或者使用内存池进行内存管理;
(点击查看大图)
3.2 COM 接口式内存管理
COM (Component Object Model)是微软在1993年提出的一种二进制兼容的方案或者标准,其中的思想还是挺值得插件开发借鉴(非windows平台)。
3.2.1 使用COM接口的优势
(一)COM接口可以解决插件开发领域的两个典型的兼容问题
a. 接口的内存布局结构变化带来的兼容问题
(图5 接口的内存布局变化导致的兼容问题)
b. 不同的编译器、不同系统源码库带来的兼容问题
(图6 内存管理不同版本带来的兼容问题)
(二)COM接口为什么可以解决上述的问题?
a. COM强调面向接口,插件的边界只能是interface,COM接口不允许有任何的数据域
(图7 COM严格以接口为边界)
b. COM接口需要暴露AddRef和Release接口,用来进行闭环(插件申请插件释放)的内存管理
3.2.2 COM接口例子
‣ 场景:
Application需要一个插件来提供读和写的功能
‣ 原则:
所有的接口都要继承 IUnknown ,发布的interface都需要有唯一ID
‣ DEMO:
a. com.h
(点击查看大图)
b. interface.h 插件对外发布的接口
(点击查看大图)
c. export_api.h 是插件的唯一接口暴露点
(点击查看大图)
d. interface_impl.cpp 插件的功能实现,可以使用继承,也可以使用聚合的方法
(点击查看大图)
e. 插件的使用
(点击查看大图)
‣ Output:
(点击查看大图)
3.3 C++ 智能指针
C++11的很多特性都是先从boost引入技术报告(TR),然后进入到C++标准,智能指针就是如此。
(图8 C++标准演进)
不同类型智能指针的比较:
(点击查看大图)
3.3.1 shared_ptr
shared_ptr是使用最广泛的智能指针,可以进行所有权共享;当没有任何人持有,引用计数为0的时候内存自动释放。
智能指针内部有两个重要的块:
a. 数据块 指向内存地址的指针
b. 控制块 存放引用计数等信息
(图9 shared_ptr原理)
3.3.2 weak_ptr
weak_ptr是shared_ptr的伴生品,weak_ptr没有独立存在意义。
(图10 weak_ptr和shared_ptr的关系)
weak_ptr 可以解决share_ptr在两个场景下的问题:
a. shared_ptr的循环引用,会造成内存泄露
b. 观察者模式 被观察对象subject不应该影响observer的生命周期
(点击查看大图)
Output:
(点击查看大图)
3.3.3 unique_ptr
unique_ptr 指向对象的所有权独享,在出作用域unique_ptr析构时释放内存(和boost::scoped_ptr类似)。
如果要转移所有权,需要使用std::move。(类似的有std::thread,所有权独占)
(图11 unique_ptr的所有权转移)
3.3.4 intrusive_ptr
侵入式(智能)指针,和share_ptr用起来很像。intrusive_ptr提供了自定义引用计数的能力,适合用来管理第三方接口。比如用intrusive_ptr来管理COM接口。
只需要实现 IUnknown 类型的 intrusive_ptr_add_ref 和 intrusive_ptr_release 方法,就可以像share_ptr一样来使用COM接口了。
(点击查看大图)
Output:
(点击查看大图)
3.3.5 utilities
a. 使用make_shared/make_unique构造智能指针,减少构造性能损耗;(https://en.cppreference.com/w/cpp/memory/shared_ptr/make_shared)
b. owner-based (as opposed to value-based) order (https://en.cppreference.com/w/cpp/memory/shared_ptr/owner_before)
‣ owner-based order
可以看作为控制块的比较,看受智能指针影响生命期的对象是不是一个;
‣ value-based order
可以看作数据块的比较,比较内存地址
(点击查看大图)
Output:
(点击查看大图)
c. shared_from_this
‣ 使用场景:
一个被智能指针管理的对象(class A的对象),在对象的内部,又要调用一个使用std::shared_ptr的函数。
‣ 网络场景示例:
connection表示一个链接,连接成功之后,在 run 函数内部调用 async_run ,实现异步读操作;这个时候需要把自己的智能指针传递进去,从而进行生命期的托管。
(点击查看大图)
模拟一个网络连接产生
(点击查看大图)
测试
(点击查看大图)
即使马上将connection变量释放,出了作用域之后,我们仍然可以进行read操作。
- 例子使用的Thread pool
(点击查看大图)
std::enable_shared_from_this本质上是一个语法糖,在基类中使用weak_ptr来帮我们避免了循环引用(自己引用自己)。
(点击查看大图)
谢谢观看!
你是否还在寻找稳定的海外服务器提供商?创新互联www.cdcxhl.cn海外机房具备T级流量清洗系统配攻击溯源,准确流量调度确保服务器高可用性,企业级服务器适合批量采购,新人活动首月15元起,快前往官网查看详情吧