diff --git a/lib/memory/cache.rb b/lib/memory/cache.rb index c9209c7..11bee09 100644 --- a/lib/memory/cache.rb +++ b/lib/memory/cache.rb @@ -63,12 +63,12 @@ def lookup_class_name(klass) # @parameter obj [String] The string object to cache. # @returns [String] A cached copy of the string (truncated to 64 characters). def lookup_string(obj) - # This string is shortened to 64 characters which is what the string report shows. The string report can still list unique strings longer than 64 characters separately because the object_id of the shortened string will be different. - @string_cache[obj] ||= String.new << obj[0, 64] + # This string is shortened to 64 characters which is what the string report shows. The string report (by value) can still list unique strings longer than 64 characters separately because the `object_id` of the shortened string will be different. + @string_cache[obj] ||= obj[0, 64] rescue RuntimeError => error # It is possible for the String to be temporarily locked from another Fiber which raises an error when we try to use it as a hash key. i.e: `Socket#read` locks a buffer string while reading data into it. In this case we `#dup`` the string to get an unlocked copy. if error.message == "can't modify string; temporarily locked" - @string_cache[obj.dup] ||= String.new << obj[0, 64] + @string_cache[obj.dup] ||= obj[0, 64] else raise end diff --git a/test/memory/sampler.rb b/test/memory/sampler.rb index 8844729..755ebdc 100644 --- a/test/memory/sampler.rb +++ b/test/memory/sampler.rb @@ -59,6 +59,48 @@ class MyThing memory.report # buffer string is locked while reading ObjectSpace#each_object end + it "does not retain string references" do + # Ruby 3.2 has different shared string behaviour: + skip_unless_minimum_ruby_version("3.3") + + # Strings longer than 23 characters share a reference to a "shared" frozen string which should also be GC'd + sampler.run do + 5.times do |i| + short_text = "SHORT TEXT ##{i}" + short_text.dup + + long_text = "LONG TEXT ##{i} 12345678901234567890123456789012345678901234567890" + long_text.dup + + very_long_text = "VERY LONG TEXT ##{i} 12345678901234567890123456789012345678901234567890 12345678901234567890123456789012345678901234567890 12345678901234567890123456789012345678901234567890 12345678901234567890123456789012345678901234567890 12345678901234567890123456789012345678901234567890" + very_long_text.dup + + # Prevent the last frozen string from being the return value of the block: + nil + end + end + + # 30 strings should be allocated (5 iterations * 6 strings per iteration): + # - short_text (interpolated result) + # - short_text.dup + # - long_text (interpolated result) + # - long_text.dup + # - very_long_text (interpolated result) + # - very_long_text.dup + expect(sampler.allocated.size).to be == 30 + + # Get unique string values: + string_allocations = sampler.allocated.select{|a| a.class_name == "String"} + unique_strings = string_allocations.group_by(&:value).size + + # 15 unique strings (5 iterations * 3 unique strings per iteration): + expect(unique_strings).to be == 15 + + # No strings should be retained (all were eligible for GC): + retained_strings = string_allocations.select(&:retained) + expect(retained_strings.size).to be == 0 + end + with "#as_json" do it "returns allocation count" do x = nil