这在很大程度上取决于您的内存访问模式。
第一个肯定更紧凑。
Java 中的用户定义类型会带来一些开销,例如每个对象的指针开销(64 位上的 8 个字节)。例如,Integer 可以占用 16 个字节(object 为 8 个字节,int 为 4 个字节,对齐为 4 个字节),而int 仅占用 4 个字节。它类似于class,C++ 中的虚函数存储vptr。
鉴于此,如果我们查看MemeProp 的内存使用情况,我们会得到这样的结果:
class MemeProp {
// invisible 8 byte pointer with 8-byte alignment requirements
int a; // 4 bytes
int b; // 4 bytes
int c; // 4 bytes
// 4 bytes of padding for alignment of invisible field
}
生成的内存大小为每个 MemeProp 实例 24 字节。当我们取其中的一百个时,最终总内存使用量为 2400 字节。
同时,您的 3 个数组每个包含 100 个ints 只需要略多于 1200 个字节(存储长度和指针的数组开销需要一点点额外)。这非常接近第二个版本的一半。
顺序访问
当您按顺序处理数据时,速度和大小往往是齐头并进的。如果一个页面和缓存行可以容纳更多数据,那么在机器指令在较大表示和较紧表示之间变化不大的情况下,您的代码通常会更快地消耗它。
因此,从顺序访问的角度来看,需要一半内存的第一个版本可能会快很多(在某些情况下可能快两倍)。
随机访问
然而,随机访问是另一种情况。假设a、b 和c 是同样热门的字段,总是在紧密循环中一起访问,这些循环对这个结构具有随机访问模式。
在这种情况下,您的第二个版本实际上可能会更好。这是因为它为MemeProp 对象提供了一个连续的布局,其中a、b 和c 最终会在内存中彼此相邻(无论垃圾收集器最终如何重新排列MemeProp 实例的内存布局)。
在您的第一个版本中,您的 a、b 和 c 数组分布在内存中。它们之间的步幅永远不能小于 400 字节。当我们访问a[65]、b[65] 和c[65] 时,如果最终访问某个随机元素(例如第 66 个元素),这等同于潜在的更多缓存未命中。如果这是我们第一次访问这些字段,我们最终会出现 3 次缓存未命中。然后我们可能会访问a[7]、b[7] 和c[7],这些相对于a[65]、b[65] 和b[65] 和c[65] 相距228 个字节,我们最终可能会多出3 个缓存未命中.
可能比两者都好
假设您需要随机 AoS 样式的访问,并且始终一起访问所有字段。在这种情况下,最优化的表示可能是这样的:
class Meme {
private int[] abc = new int[100 * 3];
}
这最终占用了所有三个解决方案中最少的内存量,并保证单个 MemeProp 的 abc 字段彼此相邻。
当然,在某些情况下您的里程可能会有所不同,但如果您需要随机访问和顺序访问,这可能是这三者中最强的候选者。
热/冷场分离
为了完整起见,让我们考虑一种情况,即您的内存访问模式是顺序的,但并非所有字段 (a/b/c) 都被一起访问。相反,您有一个性能关键循环,它同时访问a 和b,以及一些仅访问c 的非性能关键代码。在这种情况下,您可能会从这样的表示中获得最佳结果:
class Meme {
private int[] ab = new int[100 * 2];
private int[] c = new int[100];
}
这使得我们的数据看起来像这样:
abababab...
ccccc...
...与此相反:
abcabcabcabc...
在这种情况下,通过吊出c并将其放入单独的数组中,它不再与a和b字段交错,允许计算机“消费”这些相关数据(a和b 在那些性能关键的循环中)以更快的速度移动此内存的连续块到更快但更小的内存形式(物理映射页面,CPU 缓存行)。
SoA 访问模式
最后,假设您要分别访问所有字段。每个关键循环只访问a,只访问b,或者只访问c。在这种情况下,您的第一个表示可能是最快的,尤其是如果您的编译器可以发出高效的 SIMD 指令,这些指令可以并行处理多个此类字段。
缓存行中的相关数据
如果您发现所有这些令人困惑,我不怪您,但是计算机体系结构向导 harold 曾经在此站点上告诉过我一些事情。他以最优雅的方式总结了这一切,目标应该是避免在缓存行中加载不相关数据,该缓存行只会被加载和驱逐而不被使用。尽管我在所有的分析会话中都对此有了一些直觉,但我从来没有找到一种简洁而优雅的方式来表达所有这些缓存未命中的情况。
我们的硬件和操作系统希望将内存从较大但较慢的内存形式转移到较小但较快的内存形式。当它这样做时,它往往会“抓住少数人的记忆”。好吧,如果你想从碗里抓一把 M&M,但你只对吃绿色的 M&M 感兴趣,那么抓一把混合的 M&M 只挑出绿色的,然后把其他的都还回去,效率会很低。那些到碗里。在这种情况下,如果你有一碗只装满绿色 M&M 巧克力豆,它会变得更加高效,如果我使用一个非常粗略但希望有帮助的类比,那么当你试图确定一个有效的内存布局时,这就是你的目标。如果您想在关键循环中访问的只是类比绿色 M&M,请不要将它们与红色、蓝色、黄色等混合(交错数据)。而是让所有绿色 M&M 彼此相邻在内存中,这样当你一把抓起东西时,你只会得到你想要的。
面向数据的设计
如果您预计这些 MemeProps 会出现大量输入、循环场景,那么您正在做的事情之一是在集合级别、Meme 级别设计您的外部公共接口,并将 MemeProp 字段转换为一个私人细节。
这可能是您衡量之前最有效的策略,即确定您批量处理数据的位置(尽管 100 并不完全是批量,我希望您的实际场景更大),并设计您的公共界面相应地。
例如,如果您正在设计一个Image 类并且性能是一个关键目标,那么您希望避免暴露一个提供逐像素操作的Pixel 对象。更好的是在批量Image 或Scanline 级别设计此接口,允许批量处理一堆像素。
这为您提供了更多的回旋余地来测量和调整数据表示,而不是具有一万个客户端依赖于一些表示单个像素的细粒度 Pixel 对象接口的设计,例如
因此,无论如何,最安全的选择是测量,但最好在界面设计的适当粗略水平上进行设计。