内存优化:Boxing

boxing · 浏览次数 : 17

小编点评

本文将探讨应用程序中内存分配的问题,特别是在多次触发垃圾回收(GC)时的性能影响。文章将通过一系列的文章,分享内存流量分析的经验,并探讨如何通过优化代码来解决这些问题。 **1. 垃圾回收的代价** * 垃圾回收是应用程序中消耗资源的主要活动之一。 * 许多开发人员往往忽视了这一部分的性能开销。 * 通过性能分析,可以找出哪些方法导致了大量的内存分配。 **2. 良好的代码设计与内存泄漏风险** * 破坏代码设计的常见原因之一是值类型的装箱与解箱操作。 * 这些操作可以在不经意间导致更多的内存分配与释放。 * 通过dotMemory等工具,可以定位到这些不愉快的装箱和解箱操作。 **3. 如何避免内存泄漏** * 为了减少内存泄漏的风险,开发者应该确保在使用完毕后正确地释放资源。 * 这可以通过使用智能指针、弱引用等方式实现。 * 在dotMemory中,可以通过堆分配视图来寻找那些未正确释放的对象。 **4. 改善内存性能的一般建议** * 性能优化不应过早考虑;应该先提高代码的可读性。 * 适当的时机再来进行微观优化,如内存分析,可以取得更好的整体性能。 * 例如,在C# 7及以后的版本中,使用`ref locals`和`in`关键字可以提高性能。 **5. 结论** * 应用程序的性能不仅仅取决于内存使用,还与代码质量密切相关。 * 通过分析内存分配和使用,可以逐步改善应用程序的性能。 * 此外,正确的代码设计可以确保内存使用的效率和可持续性。 总的来说,内存性能优化是应用程序开发中的一个重要课题。通过学习如何运用工具分析和改进代码,开发者不仅能够提升应用程序的整体响应速度、稳定性,还可以为最终用户提供更为愉快、高效的使用体验。

正文

dotMemory

如今,许多开发人员都熟悉性能分析的工作流程:在分析器下运行应用程序,测量方法的执行时间,识别占用时间较多的方法,并致力于优化它们。然而,这种情况并没有涵盖到一个重要的性能指标:应用程序多次GC所分配的时间。当然,你可以评估GC所需的总时间,但是它从哪里来,如何减少呢? “普通”性能分析不会给你任何线索。

垃圾收集总是由高内存流量引起的:分配的内存越多,需要收集的内存就越多。众所周知,内存流量优化应该在内存分析器的帮助下完成。它允许你确定对象是如何分配和收集的,以及这些分配背后保留了哪些方法。理论上看起来很简单,对吧?然而,在实践中,许多开发人员最终都会这样说:“好吧,我的应用程序中的一些流量是由一些系统类生成的,这些系统类的名称是我一生中第一次看到的。我想这可能是因为一些糟糕的代码设计。现在我该怎么做?”

这就是这篇文章的主题。实际上,这将是一系列文章,我将在其中分享我的内存流量分析经验:我认为什么是“糟糕的代码设计”,如何在内存中找到其踪迹,当然还有我认为的最佳实践。

简单的例子:如果您在堆中看到值类型的对象,那么装箱肯定是罪魁祸首。装箱总是意味着额外的内存分配,因此移除它很可能会让您的应用程序变得更好。

该系列的第一篇文章将重点关注装箱。如果检测到“bad memory pattern”,该去哪里查找以及如何采取行动?

本系列中描述的最佳实践使我们能够将 .NET 产品中某些算法的性能提高 20%-50%。

您需要什么工具

在我们进一步讨论之前,先看看我们需要的工具。我们在 JetBrains 使用的工具列表非常简短:

  • dotMemory 内存分析器。无论您试图查找什么问题,分析算法始终相同:
    • 在启用内存流量收集的情况下开始分析您的应用程序。
    • 在您感兴趣的方法或功能完成工作后收集内存快照。
    • 打开快照并选择内存流量视图。
  • Heap Allocations Viewer插件。该插件会突出显示代码中分配内存的所有位置。这不是必须的,但它使编码更加方便,并且在某种意义上“迫使”您避免过度分配。

Boxing

装箱是将值类型转换为引用类型。 例如:

int i = 5;
object o = i; // 发生装箱

为什么这是个问题?值类型存储在栈中,而引用类型存储在托管堆中。因此,要将整数值分配给对象,CLR 必须从栈中取出该值并将其复制到堆中。当然,这种移动会影响应用程序的性能。

一个对象的至少占用3个指针单元:对象头(object header)、方法表指针(method table ref)、预留单元(首字段地址/数组长度)

在x64系统3个指针单元意味24字节的开销,而一个int类型本身只占用4字节,其次,栈内存的由执行线程方法栈管理,方法内声明的local变量、字面量更是能够在IL编译期就预算出栈容量,效率远高于运行时堆内存GC体系

如何发现

使用 dotMemory,找到boxing是一项基本任务:

  1. 打开View memory allocations视图。
  2. 查找值类型的对象(Group by Types),这些都是boxing的结果。
  3. 确定分配这些对象并生成大部分流量的方法。

当我们尝试将值类型赋值给引用类型时,Heap Allocation Viewer插件也会提示闭包分配的事实:

​ Boxing allocation: conversion from value type 'int' to reference type 'object'

从性能角度来看,您更感兴趣的是这种闭包发生的频率。例如,如果带有装箱分配的代码只被调用一次,那么优化它不会有太大帮助。考虑到这一点,dotMemory 在检测闭包是否引起真正问题方面要可靠得多。

如何修复

在解决装箱问题之前,请确保它确实会产生大量流量。如果是这样,你的任务就很明确:重写代码以消除装箱。当你引入某些值类型时,请确保不会在代码中的任何位置将值类型转换为引用类型。例如,一个常见的错误是将值类型的变量传递给使用字符串的方法(例如 String.Format):

int i = 5;
string.Format("i = {0}", i); // 引发box

一个简单的修复方法是调用恰当的值类型 ToString() 方法:

int i = 5;
string.Format("i = {0}", i.ToString());

Resize Collections

动态大小的集合(例如 Dictionary, List, HashSet, 和 StringBuilder )具有以下特性: 当集合大小超过当前边界时,.NET 会调整集合的大小并在内存中重新定义整个集合。显然,如果这种情况频繁发生,应用程序的性能将会受到影响。

如何发现

使用 dotMemory 比对两个快照

  1. 打开View memory allocations视图

  2. 找到产生大内存流量的集合类型

  3. 看看是否与 Dictionary<>.ResizeList<>.SetCapacityStringBuilder.ExpandByABlock等等集合扩容有关

如何修复

如果“resize”方法造成的流量很大,唯一的解决方案是减少需要调整大小的情况数量。尝试预测所需的大小并用该大小初始化集合。

var list = new List<string>(1000); // 初始容量1000

此外请记住,任何大于或等于 85,000 字节的分配都会在大对象堆 (LOH) 上进行。在 LOH 中分配内存会带来一些性能损失:由于 LOH 未压缩,因此在分配时需要 CLR 和空闲列表之间进行一些额外的交互。然而,在某些情况下,在 LOH 中分配对象是有意义的,例如,在必须承受应用程序的整个生命周期的大型集合(例如缓存)的情况下。

Enumerating Collections

使用动态集合时,请注意枚举它们的方式。这里典型的主要头痛是使用 foreach 枚举一个集合,只知道它实现了 IEnumerable 接口。考虑以下示例:

class EnumerableTest
{
	private void Foo(IEnumerable<string> sList)
    {
		foreach (var s in sList)
        {
            
		}
	}
	public void Goo()
    {
		var list = new List<string>();
		for (int i = 0; i < 1000; i++)
        {
			Foo(list);
		}
    }
}

Foo 方法中的列表被转换为 IEnumerable 接口,这意味着枚举器的进一步装箱,因为List<T>.Enumerator是结构体。

public struct Enumerator : IEnumerator<T>, IEnumerator, IDisposable
{
    public T Current { get; }

    object IEnumerator.Current { get; }

    public void Dispose();

    public bool MoveNext();

    void IEnumerator.Reset();
}

如何发现

  1. 打开View memory allocations视图
  2. 找到值类型System.Collections.Generic.List+Enumerator并检查生成的流量。
  3. 查找生成这些对象的方法。
  4. Heap Allocation Viewer插件也会提示您有关隐藏分配的信息:

如何修复

避免将集合强制转换为接口。在上面的示例中,最佳解决方案是创建一个接受 List<string> 集合的 Foo 方法重载。

private void Foo(List<string> sList)
{
    foreach (var s in sList)
    {
        
    }
}

如果我们在修复后分析代码,会发现 Foo 方法不再创建枚举器。

don’t prematurely optimize

易读性应该在多数时候成为我们编码的第一原则,而非的性能优先或内存优先。本文讨论的一切都是微观优化,定期进行内存分析是良好的习惯

例如,交换a和b,从第一直觉上我们会编写出以下代码:

int a = 5;
int b = 10;

var temp = a;
a = b;
b = temp;

// 在c# 7+我们甚至可以用元组,进一步增强可阅读性
(a, b) = (b, a);

但是下面这种写法通过按位运算,可以不必申请额外空间来存储temp

a = a ^ b;
b = a ^ b;
a = a ^ b;

但这并不是我们鼓励的:过早的在编码初期进行优化,丧失可读性。在99%的情况下,我们的代码应该只依赖语义,剩下的,交给探查器!

上文Boxing提到的string.Format案例,只能代表今天,而不是明天。也许下一个将在IL编译时甚至JIT中去解决值类型装箱问题,Enumerating Collections也是同一个道理。

int i = 5;
string.Format("i = {0}", i); // 引发box

DefaultInterpolatedStringHandler

.net6引入的ref结构DefaultInterpolatedStringHandler,就是一个很好的案例

$"..." 这种字符串插值(String Interpolation)语法是在 C# 6.0 中引入的。

var i = 5;
var str = $"i = {i}"; // box

在.net6之前,上面的写法会发生装箱,生成的IL如下:

IL_001a: ldarg.0      // this
IL_001b: ldstr        "i = {0}"
IL_0020: ldarg.0      // this
IL_0021: ldfld        int32 Fake.EventBus.RabbitMQ.RabbitMqEventBus/'<ProcessingEventAsync>d__19'::'<i>5__1'
IL_0026: box          [netstandard]System.Int32
IL_002b: call         string [netstandard]System.String::Format(string, object)
IL_0030: stfld        string Fake.EventBus.RabbitMQ.RabbitMqEventBus/'<ProcessingEventAsync>d__19'::'<str>5__2'

而从.net6开始,生成的IL发生了变化,由原来调用的System.String::Format(string, object),变成了DefaultInterpolatedStringHandler,装箱也不见了,内部细节感兴趣的自己去阅读源码,内部用到了高性能的Span,unsafe和ArrayPool

IL_0014: ldloca.s     V_3
IL_0016: ldc.i4.4
IL_0017: ldc.i4.1
IL_0018: call         instance void [System.Runtime]System.Runtime.CompilerServices.DefaultInterpolatedStringHandler::.ctor(int32, int32)
IL_001d: ldloca.s     V_3
IL_001f: ldstr        "i = "
IL_0024: call         instance void [System.Runtime]System.Runtime.CompilerServices.DefaultInterpolatedStringHandler::AppendLiteral(string)
IL_0029: nop
IL_002a: ldloca.s     V_3
IL_002c: ldloc.0      // i
IL_002d: call         instance void [System.Runtime]System.Runtime.CompilerServices.DefaultInterpolatedStringHandler::AppendFormatted<int32>(!!0/*int32*/)
IL_0032: nop
IL_0033: ldloca.s     V_3
IL_0035: call         instance string [System.Runtime]System.Runtime.CompilerServices.DefaultInterpolatedStringHandler::ToStringAndClear()
IL_003a: stloc.1      // str

不要过早优化

不要过早优化!!!

不要过早优化!!!

不要过早优化!!!

本系列参考jetbrains官方团队的博客:https://blog.jetbrains.com/dotnet,加以作者的个人理解做出的二次创作,如有侵权请联系删除:2357729423@qq.com。

与内存优化:Boxing相似的内容:

内存优化:Boxing

dotMemory 如今,许多开发人员都熟悉性能分析的工作流程:在分析器下运行应用程序,测量方法的执行时间,识别占用时间较多的方法,并致力于优化它们。然而,这种情况并没有涵盖到一个重要的性能指标:应用程序多次GC所分配的时间。当然,你可以评估GC所需的总时间,但是它从哪里来,如何减少呢? “普通”性

[转帖]内存优化表MOT管理

目录 1.MOT持久性 1.1 MOT日志记录:WAL重做日志 1.2 MOT日志类型 1.3 配置日志 1.4 MOT检查点 2.MOT恢复 3.MOT复制和高可用 4.MOT内存管理 5.MOT VACUUM清理 6.MOT统计 7.MOT监控 7.1 表和索引大小 7.2 MOT全局内存详情

[转帖]内存优化(开启内存大页vm.nr_hugepages)

大页内存(hugepages) 为优化内存管理引入了hugepages 可以自定义设置、将原来标准内存也4k设置为更大。 hugepages 优点: 使得Oracle SGA 不可交换; 减轻 TLB 的压力; 减少页表的开销; 减少页表查询的开销; 提升内存访问的整体性能; oracle建议设置h

[转帖]Redis 内存优化在 vivo 的探索与实践

https://www.jianshu.com/p/0849b526f0f4 一、 背景 使用过 Redis 的同学应该都知道,它基于键值对(key-value)的内存数据库,所有数据存放在内存中,内存在 Redis 中扮演一个核心角色,所有的操作都是围绕它进行。 我们在实际维护过程中经常会被问到如

JVM 内存大对象监控和优化实践

服务器内存问题是影响应用程序性能和稳定性的重要因素之一,需要及时排查和优化。本文介绍了某核心服务内存问题排查与解决过程。首先在JVM与大对象优化上进行了有效的实践,其次在故障转移与大对象监控上提出了可靠的落地方案。最后,总结了内存优化需要考虑的其他问题。

[转帖]一张图搞定redis内存优化及配置

https://www.jianshu.com/p/3195663af83e Redis内存优化及配置.png Redis优化及配置 Redis所有的数据都在内存中,而内存又是非常宝贵的资源。常用的内存优化方案有如下几部分:一、配置优化二、缩减键值对象三、命令处理四、缓存淘汰方案 一、配置优化 Li

[转帖]linux性能优化-内存回收

linux文件页、脏页、匿名页 缓存和缓冲区,就属于可回收内存。它们在内存管理中,通常被叫做文件页(File-backed Page)。通过内存映射获取的文件映射页,也是一种常见的文件页。它也可以被释放掉,下次再访问的时候,从文件重新读取。 大部分文件页,都可以直接回收,以后有需要时,再从磁盘重新读

[转帖]PostgreSQL(三) 内存参数优化和原理(work_mem)内存表 pgfincore插件使用方法

1.常用内存参数 1.1 shared_buffers shared_buffers是PostgreSQL用于共享缓冲区的内存,是由8kb大小的块所形成的数组。PostgreSQL在进行更新、查询等操作时,首先从磁盘把数据读取到内存,之后进行更新,最后将数据写回磁盘。shared_buffers可以

LLM优化:开源星火13B显卡及内存占用优化

本文主要是针对开源星火13B的显存及内存占用过大的一个代码优化。核心思想是使用CPU预加载模型,再转换至GPU。

[转帖]JVM内存非典型术语介绍(shallow/retained/rss/reserved/committed)

https://www.jianshu.com/p/871d6bb3a32d 背景 ​ 在服务器性能优化内存这一项时,有一些现象很诡异。如top显示的RES很大,但是实际jvm堆内存占用很小,同时使用nmt发现committed更大。所以决定写这篇wiki大概介绍一下 JVM中如何计算一个对象的实际