为什么在.NET中,boxing是一种未缓存的基本值类型,而Java不是?

11

考虑以下情况:

int a = 42;

// Reference equality on two boxed ints with the same value
Console.WriteLine( (object)a == (object)a ); // False

// Same thing - listed only for clarity
Console.WriteLine(ReferenceEquals(a, a));  // False
显然,每个装箱指令都会分配一个封装的Int32的单独实例,这就是为什么它们之间的引用相等性失败的原因。此页面似乎表明这是指定行为:
Box 指令将“原始”(未装箱)值类型转换为对象引用(类型 O)。 这通过创建一个新对象并将数据从值类型复制到新分配的对象来完成。
但这必须是这种情况吗?为什么CLR不选择保存装箱的Int32值的“缓存”,或者甚至更强大的,对于所有基本值类型(它们都是不可变的)使用公共值?我知道Java有类似的东西。
在没有泛型的时代,它是否有助于减少大量由小整数组成的大型ArrayList的内存要求以及GC工作量?我也确信存在一些现代.NET应用程序,它们使用泛型,但由于某种原因(反射、接口赋值等),会产生大量的装箱分配,而可以通过(似乎很简单的)优化来大幅减少。
那么原因是什么呢?我没有考虑的一些性能影响(我怀疑测试项目是否在缓存中将导致净性能损失,但我知道什么呢)?实现困难?与不安全代码有关的问题?破坏向后兼容性(我想不出任何良好编写的程序应该依赖于现有行为的原因)?还是其他原因?
编辑:我真正建议的是“常见”的基元的静态缓存,就像Java做的那样。有关示例实现,请参见Jon Skeet的答案。我理解,针对任意的可能是可变的值类型或动态地在运行时“记忆”实例是完全不同的问题。
编辑:更改标题以确保清晰度。

只有 Int32 应该具有这种“缓存”行为,还是所有基本值类型都应该具有?用户定义的值类型呢?对于后者可能是“不”,那么前者呢? - jason
就像我之前提到的,我认为它应该适用于“所有原始值类型的共同值”。我没有完全思考过这个问题,也没有答案,所以我才提出了这个问题。 :) - Ani
6个回答

11

我觉得令人信服的一个理由是一致性。正如你所说,Java确实在某个范围内缓存装箱值...这意味着很容易编写在一段时间内有效的代码:

// Passes in all my tests. Shame it fails if they're > 127...
if (value1 == value2) {
    // Do something
}

我曾经被这个问题困扰过 - 尽管是在测试代码中而不是生产环境中,但这个问题依然很让人头疼,因为它会在给定范围之外显著改变行为。

别忘了,任何条件行为都会增加所有装箱操作的成本 - 因此,在某些情况下,如果不使用缓存,实际上会更慢(因为首先必须检查是否使用缓存)。

当然,如果你真的想编写自己的缓存框操作,你可以这样做:

public static class Int32Extensions
{
    private static readonly object[] BoxedIntegers = CreateCache();

    private static object[] CreateCache()
    {
        object[] ret = new object[256];
        for (int i = -128; i < 128; i++)
        {
            ret[i + 128] = i;
        }
    }

    public object Box(this int i)
    {
        return (i >= -128 && i < 128) ? BoxedIntegers[i + 128] : (object) i;
    }
}

然后像这样使用:

object y = 100.Box();
object z = 100.Box();

if (y == z)
{
    // Cache is working
}

2
@Ani:与装箱不同,字符串不能仅通过值类型进行操作而神奇地出现。 - Jon Skeet
2
@Ani:它们目前是默默发生的,尽管不一定是在BCL或第三方代码中。我真的对这种优化感到无动于衷——它感觉像是增加了复杂性,却没有证据表明它会对性能产生有意义的影响。当然,如果你想测试一些重要的应用程序,做出明确使用缓存的更改,然后根据数据进行争论,那就是另一回事了。在此之前,我会更喜欢保持一致性 :) 你可能不认为这是一个理由,但作为被Java的行为所困扰的人,这对我来说是有意义的 :) - Jon Skeet
1
在你意外地编写了使用引用相等性但想要值相等性的代码的情况下。那么它将在简单的测试案例中正常工作(即命中缓存的案例),就好像你已经实现了值相等性,但一旦你遇到超出缓存范围的情况,它会突然失败。 - CodesInChaos
1
这种优化在.NET中的好处比Java小得多,因为.NET泛型不需要装箱。 - CodesInChaos
2
你认为大部分应用程序中的拳击都发生在BCL内部,你同意吗?我不同意这种说法。它通常发生在调用那些需要“object”但你提供了值类型的API时。这通常是你自己的代码调用BCL代码时的情况,而不是BCL代码本身内部。或者我漏掉了一些重要的情况吗? - CodesInChaos
显示剩余12条评论

3
我虽然不能读心,但以下是一些因素:
1)缓存值类型可能会导致不可预知性 - 比较两个相等的装箱值可能会根据缓存命中和实现而为真或为假。哎呀!
2)装箱值类型的寿命很短 - 那么您要在缓存中保存多长时间?现在,您要么有很多将不再使用的缓存值,要么需要使GC实现更复杂以跟踪缓存值类型的寿命。
考虑到这些缺点,潜在的优势是什么?在执行大量长期装箱相等值类型的应用程序中,可以减小内存占用。由于这种优势只会影响少数应用程序,并且可以通过更改代码来解决,因此我同意C#规范编写者的决定。

寿命问题是一个有趣的问题。我相信可能会有一些非常聪明的解决方案,但为什么不永久缓存小值呢? - Ani
1
假设我有一个循环,将我收到的所有支票(N张)的检查号码打包,并将每个检查号码都打包两次。使用缓存可以节省N x boxed value大小的内存。但是,如果该循环在服务器应用程序中仅出现一次(在启动时),那么我刚刚消耗了N x boxed value大小的内存。如果我是一家企业,迄今为止已经收到了10万张支票,我是否想要暂时保存这么多内存,只是为了永久分配相同数量的内存?当我需要检查缓存命中时,其中有多少被分页? - Philip Rieck
5
@Ani:一个永久缓存的缓存被称为“内存泄漏”。 - Eric Lippert
1
@Eric Lippert:我再次强调,我所说的是“静态”缓存的“小”值,例如-128到127。你为什么要将其描述为内存泄漏? - Ani
@EricLippert: 我认为“内存泄漏”这个术语仅适用于额外内存需求可能无限增长的情况。如果代码使用了一个 Object[65536] 用于保存整数,并在第一次使用时缓存每个值,则在32/64位系统上,最大内存浪费量将永远不会超过1/2兆字节。 - supercat
显示剩余2条评论

3

盒装值对象并不一定是不可变的。例如,通过接口可以更改盒装值类型中的值。

因此,如果将值类型装箱始终基于相同的原始值返回相同的实例,则会创建可能不合适的引用(例如,两个不同的值类型实例具有相同的值,最终得到相同的引用,尽管它们不应该)。

public interface IBoxed
{
    int X { get; set; }
    int Y { get; set; }
}

public struct BoxMe : IBoxed
{
    public int X { get; set; }

    public int Y { get; set; }
}

public static void Test()
{
    BoxMe original = new BoxMe()
                        {
                            X = 1,
                            Y = 2
                        };
    
    object boxed1 = (object) original;
    object boxed2 = (object) original;

    ((IBoxed) boxed1).X = 3;
    ((IBoxed) boxed1).Y = 4;

    Console.WriteLine("original.X = " + original.X);
    Console.WriteLine("original.Y = " + original.Y);
    Console.WriteLine("boxed1.X = " + ((IBoxed)boxed1).X);
    Console.WriteLine("boxed1.Y = " + ((IBoxed)boxed1).Y);
    Console.WriteLine("boxed2.X = " + ((IBoxed)boxed2).X);
    Console.WriteLine("boxed2.Y = " + ((IBoxed)boxed2).Y);
}

产生以下输出:

original.X = 1

original.Y = 2

boxed1.X = 3

boxed1.Y = 4

boxed2.X = 1

boxed2.Y = 2

如果装箱不创建新实例,则boxed1和boxed2将具有相同的值,这在它们是从不同的原始值类型实例创建时是不合适的。


好的,但我想这个功能主要会针对基本数据类型。 - Ani
@Ani:所有的装箱值类型,即使是基元类型,都是可变的。即使一个值不允许任何改变机制,也可以用相同类型的另一个实例的内容(原始值或所有公共和私有结构字段的内容)替换装箱值类型的内容。 - supercat

1

这个很容易解释:拆箱/装箱是快速的。在.NET 1.x时代就需要它了。JIT编译器生成机器代码后,只会生成一小部分CPU指令,全部内联而无需方法调用。不包括可空类型和大型结构体等特殊情况。

查找缓存值的工作会大大降低此代码的速度。


+1:谢谢,有一些有效的观点。我的几个想法是:1.我建议的对unboxing没有任何影响。2.缓存查找只是一个数组边界检查,可能会跟随索引的数组检索。它真的那么昂贵吗?假设在缓存方案中缓存命中的次数比不命中的次数多,堆分配+未来GC成本可能更高(所有这些都是假设)。3.最后,这不仅仅是关于速度的问题。这也涉及到空间。在许多.NET 1.x应用程序中,这种优化是否会大大减少内存使用?--总之,这很可能是一个净性能胜利,你怎么看? - Ani
速度就是一切,.NET 1.0必须与非托管编译器竞争,并赢得那些习惯于C编译代码性能的程序员的心。我清楚地记得看着早期的Java版本并毫不犹豫地将其驳回,认为它只适合做脚本工作。是的,仅在装箱时昂贵。但是昂贵,您无法缓存每个可能的值类型值。 CPU在测试值时遭受的分支错误预测对性能来说是致命的。以速度换取内存是非常常见的结果。 - Hans Passant

0
我认为在运行时填充缓存可能不是一个好主意,但我认为在64位系统上定义大约80亿个64万亿个可能的对象引用值作为整数或浮点文字可能是合理的,并且在任何系统上都要预先装箱所有原始文字。测试引用类型的前31位是否持有某个值可能比内存引用更便宜。

正如我在回答中所指出的那样,这个理论的问题在于,将对象引用有时作为内存地址,有时作为其他东西会对非常常见的操作造成速度惩罚,而换取的最多只是在较不常见的操作上略微加速。泛型的可用性意味着装箱值类型原语相对较少使用,因此加速使用它们的操作将产生相对较少的好处。 - supercat

0
除了已经列出的答案之外,.NET 中至少使用普通垃圾回收器时,对象引用在内部存储为直接指针这一事实也应该被考虑到。这意味着当执行垃圾回收时,系统必须更新每个被移动的对象的每个引用,但这也意味着“主线”操作可以非常快。如果对象引用有时是直接指针,有时是其他东西,那么每次解引用对象都需要额外的代码。由于在执行.NET程序期间,对象解引用是最常见的操作之一,即使这里减慢了5%,除非有令人惊叹的加速,否则这将是毁灭性的。例如,“64位紧凑型”模型可能比现有模型(其中每个引用都是64位直接指针)提供更好的性能,其中每个对象引用是指向对象表的32位索引。解除引用操作将需要额外的表查找,这将是不好的,但对象引用将更小,因此允许一次缓存更多的对象引用。在某些情况下,这可能是一个重大的性能胜利(可能足够值得-也可能不值得)。然而,允许对象引用有时是直接内存指针,有时是其他东西是否真的会带来很大的优势尚不清楚。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接