软件的实现由变量以及变量之上的计算逻辑组成,在计算机运行期间,变量主要依靠内存承载。因此,高效使用内存成为高性能编码优化的重要手段之一。在软件编码过程中,不同实现方式对内存的影响主要体现在三个场景:内存的空间与布局、内存的申请与释放、内存的读取与修改。
然而,据观察,很多研发团队在软件开发阶段往往不关注内存使用优化。当业务上线后,随着用户规模快速增长,各种内存引起的性能问题便逐渐暴露,如内存空间不够、内存操作导致较大的时延抖动等。到这时,他们才意识到内存使用优化的重要性,但此时与内存相关的代码实现已侵入业务各个地方,调整重构变得极为困难。
可见,我们应在编码实现过程中掌握优化内存使用的技巧和方法,以避免软件后期出现严重性能问题。今天这节课,将从这三个场景出发,带你了解如何通过不同的编码方式调整优化内存使用效率,提升软件性能。
在此之前,需先说明一点:不同编程语言的语法和解析运行机制差异很大,在高性能编码实现的技巧手法上也各不相同。所以,今天主要以使用范围和人群广泛的 Java 语言为例,讲解如何从内存使用角度进行高性能编码,开发性能更优越的软件。在一些特定场景下,还会选用一些 C/C++ 代码片段进行对比分析,以助你理解背后的原理与意义。
好了,接下来,一起看看如何通过代码实现来优化内存的空间与布局吧。
内存的空间与布局优化
首先需要明确的是,通过编码实现手段减少对内存空间的使用,不但能够帮助节省软件运行期间占用的内存开销,还可以减少代码运行期间对内存空间的操作,进而提升软件运行速度。那么问题来了:我们要如何通过编码实现来减少使用的内存空间呢?这里为你总结了三种优化思路,虽然它们的关注视角有所不同,但最终都能够改善内存使用的性能。当你理解了这三种思路背后的原理,自然就会在编码过程中朝着提升内存使用效率的方向改进。
按照变量存储信息量选择对应的类型定义
第一个手段是:按照变量存储信息量来选择对应的类型定义。
这该如何理解呢?先来看一个具体的例子。这个例子针对的是学生年龄的数据信息,你可以先想一想,一个在校学生的年龄有效范围会是多少呢?首先,基于常识判断,可以认为该学生年龄是小于 100 岁的。因此针对这个场景,在 Java 中使用一个 byte 基本类型,差不多就能满足信息的存取需求了。然而,若使用 long 类型来保存,就会造成额外空间的浪费,并且也会潜在地影响程序的执行速度。所以,这是一个没有根据存储信息量来选择类型定义的反例。遗憾的是,很多开发工程师在实际的代码开发过程中,并没有关注到这样的代码实现细节。
好,现在我们换个思路,看看有没有更极致的优化内存空间的方法。首先问你一个问题,在计算机上最小的存储单位是多大呢?答案是一个 bit 位。那么这里,我们来看下在 C/C++ 中是如何记录该学生的结构体定义的,如下所示:
struct Student{unsigned char gender: 1;unsigned char gradeId: 3; //年级号(1~6)unsigned char classId: 5; //班级号(1~20)};
可以看到,在这个代码结构体中,使用了一个字节表示该学生的性别(gender)、年级号(gradeId)和班级号(classId)。因为小学一共只有 6 个年级,所以年级号(gradeId)使用 3 个 bit 位保存就足够了,其他字段也是相同原理。这样一来,由于在 C/C++ 语言中支持位域操作(即可以针对 bit 位来记录变量信息),我们就可以进一步缩减内存空间。而利用 bit 位来节省内存,正是嵌入式或高性能系统中重要的内存优化手段之一。
但比较遗憾的是,Java 语言并不提供原生位域能力,因此直接使用 bit 位变量来压缩内存空间会有点不方便。不过,Java 中有 BitSet 这个类型,它可以支持位操作,但其主要思想是通过压缩存储来节省空间开销。比如,假设要保存元素值为 64 以内且不重复的数组:[1,3,5,6,10,11,12,25,44,56,2,55],如果使用正常 byte 数组来保存的话,可能需要十几个字节才可以。而使用 BitSet,使用每一 bit 位来表示一个数字,那么用 8 个字节就可以记录很多个数字了(当然,Java 使用 BitSet 压缩存储的应用场景也并不只限于这种方式)。
不过这样问题就来了:针对 C/C++ 语言的位域优化实现,Java 是否也可以实现这样类似的功能呢?其实当然是可以的,但可能需要借助位运算。这里我们来看一段 Java 代码示例,同样是实现类在一个字节保存 classID,gradeID,gender 等多个信息的能力。从中我们会观察到,在 Java 内也可以使用一个字节,来保存多个有效的字段信息。
public class Student {byte data; // |classID(4bit) |gradeID(3bit)| gender(1bit)|Student() {data = 0;}public void setGender(boolean isMale) {data = (byte) (isMale ? (data | 0x01) : (data & 0xFE));}public boolean isMale() {return (data & 0x01) == (byte) 1 ? true : false;}public byte getGradeId() {return (byte) ((data & 0x0E) >> 1);}public void setGradeId(byte gradeID) {data = (byte) (data | ((gradeID & 0x07) << 1));}public byte getClassId() {return (byte) ((data & 0xF0) >> 4);}public void setClassId(byte ClassId) {data = (byte) (data | ((ClassId & 0x0F) << 4));}}
当然,上面这种实现可以在一些极端性能场景下(比如有大规模实例对象的场景)使用,但我并不建议你经常使用,因为它会导致代码的实现复杂度提升。实际上,针对 Java 语言而言,在绝大部分的应用场景下,选择合适的变量类型就已经能很大程度上减少内存空间的浪费了。
对齐对象实例内的变量空间
好,我们再来了解下第二个手段:对象实例内变量空间对齐。
我们知道,如果一个变量的内存起始地址是字节对齐的,那么对这个变量的读取就是高效且安全的,因为不需要将变量分为多个指令周期进行读取和拼凑。所以这里,我们来看下这个代码片段,这是一个 C/C++ 的结构体定义(这是不考虑 bit 位压缩存储的实现):
struct Student{unsigned char flag;unsigned int classId;unsigned short gradeId;};
器指令显示,告知编译器不做这样的优化)。但很明显,这样会带来一个副作用,就是空间浪费。而解决这个问题的手段,就是手动调整字段顺序来实现字节对齐。比如说,可以把 classID,gradleID 放到结构体的前面来定义:
struct Student{unsigned int classID;unsigned short gradeID;unsigned char flag;};
这样一来,我们就可以在满足字节对齐的场景下,最大化地节省内存空间。比较幸运的是,JVM 在优化的过程中,使用了字段重排优化技术,这个技术是通过将相同类型的字段放在一起,来减少一些补白操作。所以在通常情况下,你只需要根据变量选择合适的字段类型即可。
尽量使用栈上的内存
好的,现在我们来学习第三个手段:尽量使用栈上内存。
栈内存是程序调用栈上使用的内存空间,当调用栈退出后可以被自动回收重复利用。在 C/C++ 中,所有的局部变量、结构体和类的实例都可以在调用栈上临时分配。而在 Java 中,虽然不能显式地在栈上分配对象,但对于变量而言,可以选择将其定义在函数内或者类对象上,这样就能避免在堆上分配资源。这里需要说明的是,如果尽量将变量定义在函数内,对性能会更加有利。
因为在 Java 中,若将变量定义在类对象中,申请内存后,由于内存回收需要依赖 GC 实现,所以可能在很长一段时间内,这块内存都不能再被回收利用。另外,虽然在 JVM 编译优化中有一些栈上分配的优化机制,但它们需要满足很多条件才可以实现,所以在开发代码时不能完全依赖这个机制。
好啦,以上就是通过调整代码实现来优化内存空间与布局的常用方法和思路,它们的目标都是基于缩减内存空间来实现优化性能的效果。不过,仅通过这个方式来提升内存效率还是不够的。所以接下来,我们再来看看针对内存的申请与释放这一实现方式来说,还有哪些手段也可以提升软件性能。
内存的申请与释放优化
对 Java 语言而言,针对一个对象的 new 操作是非常耗时的,不仅需要动态在堆空间上分配内存并进行初始化,而且在使用结束之后,还需要基于 GC 来管理跟踪释放流程。那么针对内存的申请和释放过程,我们可以通过编码实现来优化它的性能吗?答案当然是可以的。这里常规的优化思路主要有三点,下面为你详细介绍。
首先是调整内存申请释放发生的时间点。这个优化思路的出发点是:将内存申请操作提前到软件程序的启动阶段,从而减少运行期间申请内存资源的开销。
这里我们同样来看一个代码片段,这是一个单例模式的示例:
public class Singleton {private static final Singleton instance = new Singleton();private Singleton() {}public static Singleton getInstance() {return instance;}}
从中我们可以观察到,程序在类初始化的过程中会自动去创建对象实例,这样就可以实现两个优化点:程序在运行期间直接使用对象,而不需要再进行动态申请内存;同时,由于静态对象实例也不会被 JVM 的 GC 垃圾回收,所以在一定程度上减少了 GC 的负荷。
减少内存的申请与释放次数
好了,除了调整内存申请发生的时机之外,我们还可以通过减少内存的申请与释放次数来优化性能。我们知道,在 Java 语言中,一共有 8 种基本类型,分别是 byte、short、int、long、float、double、boolean、char。那么首先,在业务允许的情况下,我们会优先使用基本类型,避免使用包装类型(如 Integer 等),这样可以减少额外的内存申请操作。其次,在编码过程中,我们也要避免写一些冗余的对象申请操作,如下代码所示:
String str = "test ";String str_2 = new String("test ");
可以看到,第一行代码里的 str 并不会动态申请对象,而第二行的 str_2 则额外申请了一次内存,从而就会导致额外的性能损耗。最后,在 Java 中也是最重要的一点,就是尽量使用预分配方式。比如,你可以看看下面给出的这段代码示例,其核心逻辑就是将内存提前申请好,以避免支出运行期动态申请空间的开销:
StringBuilder sb = new StringBuilder(1024);sb.append("test append alloc")
如果你在 StringBuilder 的构造函数中通过参数提前指定空间大小,并且一次性分配好,那么就可以避免在调用 append 的操作中触发额外的内存申请操作,进而提升性能。而且,这种预分配空间的机制在 Java 的各种数据结构类型中,如 ArrayList、HashMap 等的使用过程中都可以使用。
定制化内存申请与释放实现
首先,需要一个查找算法来寻找合适的内存大小空间;
其次,要考虑在并发场景下通过同步手段保证线程安全性;
最后,要注意当堆空间不足时,有可能会触发内核态 API 调用,从而造成内核态与用户态切换的更大开销(目前不确定在 JVM 实现中是否会发生这个阶段,但在 C/C++ 中会存在)。
实际上,在以往参与的很多 C/C++ 开发高性能的系统中,定制化内存申请与释放是一个非常重要的性能优化手段。举个简单的例子,针对固定大小的内存申请操作,可以基于队列的出队和入队等类似算法实现,来改善内存申请与释放速度。
不过,在一些高实时性嵌入式系统中,动态内存使用是被禁止的,程序的所有内存都需要通过静态预分配与管理来实现。
而在 Java 中,如果一些业务逻辑内频繁地申请和释放对象操作对性能影响比较大,那有办法优化解决吗?当然有!这里要介绍的一个性能优化手段是对象池共享技术。对象池本质上是通过集合来管理已经申请过的对象,如果线程需要这种类型的对象,就直接从集合中取一个元素,但使用完一定要归还,否则会造成内存泄漏。另外,在使用对象池来管理一些创建比较耗时的对象时,还可以通过减少对象申请与创建的过程,来提高软件的运行速度和性能。
简而言之,对于内存的申请与释放优化,就是通过减少内存的申请和释放操作,或提升内存申请和释放的速度,来达到提升软件运行性能的目的。
那么下面,我们接着来看看要如何优化内存的读取和修改,来进一步提升软件性能。
内存的读取与修改优化
在 CPU 中,Cache 的存取与替换都是以缓存行(Cacheline)为单位,而且在不同的 CPU 体系架构实现中,缓存行的长度也各不相同,具体在 8 字节到 128 字节之间不等。因此,如果我们能够把变量空间按照缓存行对齐,那么就可以提升 Cache 的读写效率,从而达到提升性能的效果。既然如此,具体我们该怎么做呢?这里我们先来看一个例子,这个例子主要用来说明变量 A、变量 B 是否在一个 Cacheline 中,并会对 Cache 的读取操作产生影响。

如上图的左半部分所示,如果两个变量不在一个 Cacheline 中,那么在读取变量 A、变量 B 时,就需要读取两个 Cacheline。而在图的右侧,由于两个变量在一个 Cacheline 中,所以只需要读取一个 Cacheline 即可。也就是说,在编码的过程中,需要充分利用局部性原理,把经常一起使用的变量放在一起,从而最大化地实现 Cacheline 的长度对齐,以优化提升软件的运行效率。
注意:由于 CPU 的指令预取技术,通常情况下在串行程序中,Cacheline 对齐对性能的影响可能不是那么明显,所以很容易被我们忽视。
好,除此之外,我们还要知道在多核并发的场景下,由于 Cacheline 没有对齐,造成的伪共享(False Sharing),也会显著地影响程序的运行效率。如下图所示:

已知在 Core 1 上需要更新变量 X,而 Core 2 需要读取变量 Y。但是由于变量 X 与 Y 在一个 Cacheline 中,它们会映射在相同的内存地址上,所以每次当 Core1 上更新变量 X 之后,就会造成 Core 2 上的变量 Y 对应的 Cacheline 失效,需要重新读取,进而影响性能。所以在并发系统的设计中,针对 Cacheline 未对齐造成软件性能受影响的场景,我们可以通过显式字段的冗余来实现 Cacheline 对齐,以避免这种情况的发生。好在 Java 8 中引入了 sun.misc.Contended 注解,它可以针对性地识别并发场景下存在的 Cacheline 伪共享问题。
那么,除了伪共享问题外,在实现编码的过程中,还有一些手段也可以优化内存读取和修改的性能。比如说,在 Java 语言中,可以借助一些 native 方法来优化拷贝赋值数据的性能,其中最常用的就是使用接口的 System.arraycopy 方法、对象的 clone 方法。再进一步,对内存拷贝的性能优化极限就是零拷贝,可以通过业务逻辑或算法优化来尽量减少拷贝操作,从而进一步优化性能。而对于 C/C++ 语言来说,对一块内存进行修改时,使用 memcopy 操作性能优于直接赋值操作,这是因为 memcopy 在汇编过程中使用了特殊的汇编指令来优化连续内存的拷贝操作。