看重影了?Ruby 中是如何共享字符串的

英文原文地址:
Seeing double: how Ruby shares string values

你知道当你使用字符串的时候,ruby是如何为他们分配内存空间的吗?下面我们来看几段代码

str = "Lorem ipsum dolor sit amet, consectetur adipisicing elit"
str2 = str

str = "Lorem ipsum dolor sit amet, consectetur adipisicing elit"
str2 = String.new(str)

str = "Lorem ipsum dolor sit amet, consectetur adipisicing elit"
str2 = str.dup
str2.upcase!

str = "Lorem ipsum dolor sit amet, consectetur adipisicing elit"
str2 = str[1..-1]

然后剧情并不像你想象的那样! MRI 1.8 和 1.9 解释器使用了一种叫做copy on write的优化机制来避免较大字符串不必要的复制过程。在之前的文章为什么Ruby 1.9 处理长度不大于23字节的字符串速度会更快中所说的,今天我们就将深入Ruby内部来窥探copy on write机制是如何工作的。

两个变量引用同一个StringObject

两周之前 我曾用下面这个例子来阐述Ruby是如何共享字符串的:
str = “Lorem ipsum dolor sit amet, consectetur adipisicing elit”
str2 = str
下图展示了str和str2在内存中指向的情况:
这里写图片描述
就像 Evan Phoenix 在评论中指出的那样,用这张图来阐述共享字符串的问题确实是不严谨的,因为实际上这根本不涉及到共享的问题,因为 str 和 str2 根本就是一个字符串。
为了了解RString的结构,也为了证明这确实是在Ruby解释器中发生的过程,我写了一个简单的 C 扩展程序,通过它我们可以知道某一个 RString 十六进制地址以及在这个RString中ptr指针的16进制值(ptr指向了字符串存放的实际地址).想知道RString是如何工作的?
为了使用这段代码,我只需要包含它并创建一个DEBUG类的实例对象,并调用 display_string 方法:

require_relative 'display_string'
debug = Debug.new

str = "Lorem ipsum dolor sit amet, consectetur adipisicing elit"
str2 = str

puts "str:"
debug.display_string str

puts
puts "str2:"
debug.display_string str2

运行上段代码后会得到下面的输出:

$ ruby test.rb
str:
DEBUG: RString = 0x7fd64a84f620
DEBUG: ptr     = 0x7fd64a416fe0 -> "Lorem ipsum dolor sit amet, consectetur adipisicing elit"
DEBUG: len     = 56

str2:
DEBUG: RString = 0x7fd64a84f620
DEBUG: ptr     = 0x7fd64a416fe0 -> "Lorem ipsum dolor sit amet, consectetur adipisicing elit"
DEBUG: len     = 56

不出意外,str 和 str2 都指向了同一个16进制地址为 0x7fd64a84f620 的 RString ,当然 ptr 也都是指向了 0x7fd64a416fe0 。

两个字符串对象共享一个string

虽然上面那个例子举得并不准确,但是有时ruby字符串对象确实会共享同一个string。我们来看下面这个例子:

str = "Lorem ipsum dolor sit amet, consectetur adipisicing elit"
str2 = str.dup

调用 Object.dup 方法会另创建一个 RString 结构,它指向了前者同一个 string 。当然,你也可以用 String.new 方法来达到同样的效果:

str = "Lorem ipsum dolor sit amet, consectetur adipisicing elit"
str2 = String.new(str)

它们的内存结构如图:
这里写图片描述
这才是真正意义上字符串共享。如上图所以,str 和 str2 指向了两个不同的 RString 结构地址, 而这两个 RString 的 ptr 指向了同一个字符串对象,也就是说 str 和 str2 共享同一个字符串对象。 值得注意的一点是,str2 中包含了一个 VALUE shared 指向了 str 的 RString 地址,用来标识 str2 是共享的 str 的对象。
这么做的好处有两点:

  • 字符串只存在一个也就意味着可以节省内存
  • 节省了调用 malloc 来从 heap 中分配内存的时间

为了证明想法是正确的,我们继续调用 display_string 代码:

require_relative 'display_string'
debug = Debug.new

str = "Lorem ipsum dolor sit amet, consectetur adipisicing elit"
str2 = str.dup

puts "str:"
debug.display_string str
puts
puts "str2:"
debug.display_string str2

运行结果如下图

$ ruby test.rb
str:
DEBUG: RString = 0x7fdd2904f4a8
DEBUG: ptr     = 0x7fdd28d16fe0 -> "Lorem ipsum dolor sit amet, consectetur adipisicing elit"
DEBUG: len     = 56

str2:
DEBUG: RString = 0x7fdd2904f430
DEBUG: ptr     = 0x7fdd28d16fe0 -> "Lorem ipsum dolor sit amet, consectetur adipisicing elit"
DEBUG: len     = 56

我们可以看到 str 和 str2 确实指向了两个不同的 RString , 而这两个 RString 的 ptr 指向了同一个 字符串对象。
这种共享字符串的机制实际上是 Ruby 解释器内部进行的一种优化,作为一名程序员,你认为他们是两个不同的字符串就可以了。

注意:当字符串长度小于或等于23个字节的时候,虚拟机不会进行这种优化,而是直接将字符串值存放到 RString 结构体中。另一方面将,当字符串很短的时候,共享这个字符串并不会给我们带啦多少性能和内存上的提升。

写拷贝(copy on write)

当然故事可没这么简单就结束了。这是我们不禁要问,当我们要修改共享字符串的时候改如何应对呢?我们来看下面一个例子:

str  = "Lorem ipsum dolor sit amet, consectetur adipisicing elit"
str2 = str.dup

如果我们修改 str2 的值之后,情况会变成什么样呢?

str  = "Lorem ipsum dolor sit amet, consectetur adipisicing elit"
str2 = str.dup
str2.upcase!

现在 str 和 str2 变成了这样:

puts str
=> "Lorem ipsum dolor sit amet, consectetur adipisicing elit"

puts str2
=> "LOREM IPSUM DOLOR SIT AMET, CONSECTETUR ADIPISICING ELIT"

显然 str 和 str2 不可能再指向同一个字符串了,发生了什么呢?
当调用 upcase! 时,Ruby解释器会从heap中为 str2 新建一个拷贝,就像这样:
这里写图片描述
然后解释器会在新的拷贝上调用upcase!方法:
这里写图片描述
就如 Simon Russell 说的那样,这种算法参考了 copy on wirte,意思是 str 和 str2 共享同一个字符串,只有当他们其中一个被修改的时候才会改变。
让我们看一下这两个 RString 的值:

require_relative 'display_string'
debug = Debug.new

str = "Lorem ipsum dolor sit amet, consectetur adipisicing elit"
str2 = str.dup
str2.upcase!

puts "str:"
debug.display_string str
puts
puts "str2:"
debug.display_string str2

运行结果如下:

str:
DEBUG: RString = 0x7fa46b04ef90
DEBUG: ptr     = 0x7fa46ac8b1d0 -> "Lorem ipsum dolor sit amet, consectetur adipisicing elit"
DEBUG: len     = 56

str2:
DEBUG: RString = 0x7fa46b04ef68
DEBUG: ptr     = 0x7fa46ac2e560 -> "LOREM IPSUM DOLOR SIT AMET, CONSECTETUR ADIPISICING ELIT"
DEBUG: len     = 56

可以看出,两个 RString 结构体的 ptr 指向了不同的对象-它们不再共享。这些过程对于 Ruby 开发者来说是透明的。

String.slice 调用下 copy on wirte 是如何工作的

Robert Sander 建议我研究一下 String.slice 方法时写拷贝的工作过程,我发现在某种角度讲,写拷贝简直就是为 slice 方法设计的。举例来说:

str = "Lorem ipsum dolor sit amet, consectetur adipisicing elit"
str2 = str[1..25]

此时内存的结构如图所示:
这里写图片描述
此时 str 和 str2 指向了两个不同的字符串。然而当字串长度小于24时,解释器依然进行了优化,如下面的例子:

str = "Lorem ipsum dolor sit amet, consectetur adipisicing elit"
str2 = str[1..4]

这里写图片描述

一个比较有趣的优化是,当你截取的子串包含了父串尾部时,此时子串其实是指向了父串的字符串,如下面这个例子:

str = "Lorem ipsum dolor sit amet, consectetur adipisicing elit"
str2 = str[1..-1]

这里写图片描述

我们依然可以通过C扩展小程序来验证这个事实。

require_relative 'display_string'
debug = Debug.new

str = "Lorem ipsum dolor sit amet, consectetur adipisicing elit"
str2 = str[1..-1]

puts "str:"
debug.display_string str
puts
puts "str2:"
debug.display_string str2

此时,str2 的 ptr 其实是指向了 str ptr+1 的位置。

$ ruby test.rb
str:
DEBUG: RString = 0x7fb71b04efa0
DEBUG: ptr     = 0x7fb71ad007a0 -> "Lorem ipsum dolor sit amet, consectetur adipisicing elit"
DEBUG: len     = 56

str2:
DEBUG: RString = 0x7fb71b04ef78
DEBUG: ptr     = 0x7fb71ad007a1 -> "orem ipsum dolor sit amet, consectetur adipisicing elit"
DEBUG: len     = 55

对于 Ruby 开发者来讲,使用 str.slice 或者是 str[a..b] 意味着:

  • 创建长度小于24字节的字符串是最快的
  • 创建包含父串尾部的字符串也是很快的 ( str[x..-1])
  • 其他方式创建字符串是最慢的

结论

作为一名ruby 程序员,你大可以放心的复制一个大字符串,而不用担心性能和空间消耗的问题,因为在背后,ruby解释器为你做了很多重要的优化。大部分程序员在使用字符串复制的时候多少都会有点敏感和担忧,写拷贝这项重要的优化机制一定程度上较少了这些担忧。
然后你还需要谨记的是,当改变字符串时,虚拟机还是会创建新的字符串,大多数情况下这是无法避免的。无论如何,了解 Ruby 写拷贝的机制能帮助你能更潇洒的处理字符串。

附录:‘display_string’ C 扩展程序

#include "ruby.h"

static VALUE display_string(VALUE self, VALUE str) {
    char *ptr;
    printf("DEBUG: RString = 0x%lx\n", str);
    ptr = RSTRING_PTR(str);
    printf("DEBUG: ptr     = 0x%lx -> \"%s\"\n", (VALUE)ptr, ptr);
    printf("DEBUG: len     = %ld\n", RSTRING_LEN(str));
    return Qnil;
}

void Init_display_string() {
    VALUE klass;
    klass = rb_define_class("Debug", rb_cObject);
    rb_define_method(klass, "display_string", display_string, 1);
}

这段代码的作用是创建一个新的名字叫 Debug 的 Ruby 类,它包含了一个名字叫 display_string 的方法。它接受一个字符串参数并输出它所属的 RString 结构体和字符串真实的地址。
若要使用这段代码,请新建一个名字叫 display_string.c的文件,并将代码拷贝到该文件中。然后在同一个目录中创建一个名叫extconf.rb的文件,该文件内如下:

require 'mkmf'
create_makefile("display_string")

然后使用 $ ruby extconf.rb 命令新建一个 C Makefile 文件,最后使用 $ make 命令编译它。
这样,再该目录下的ruby文件就可以使用这段 Ruby 脚本了。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值