Skip to content

JRuby retains object references and prevents the garbage collection of objects which were referenced in the current scope #8632

Description

@meineerde

Environment Information

$ jruby -v
jruby 9.4.11.0 (3.1.4) 2025-01-29 9b107851a3 OpenJDK 64-Bit Server VM 23.0.2 on 23.0.2 +jit [arm64-darwin]

$ uname -a
Darwin hostname.local 24.3.0 Darwin Kernel Version 24.3.0: Thu Jan  2 20:24:22 PST 2025; root:xnu-11215.81.4~3/RELEASE_ARM64_T6041 arm64

Expected Behavior

While testing my WeakSet gem, it turned out that JRuby seems to hold on to object references in the current method when they should have been removed already. This prevents the unexpectedly referenced object from being garbage collected.

Generally, I would have expected the following behavior (which works in CRuby/MRI):

# Create an object with a string reference via the variable
string = +"Hello World"

# Remove the object reference, allowing the String to be garbage collected...
string = nil

Afterwards, I would expect that the String object is garbage collected, even if the execution is still contained within the current method. It turns out that this is not the case.

Actual Behavior

Unfortunately, it turns out that on JRuby, the string is only garbage collected after leaving the scope where it was assigned to a variable. The following example shows this by using an ObjectSpace::WeakMap.

Consider the following "setup" for all the three examples below:

begin
  require "java"
  $jruby = true
rescue LoadError
  $jruby = false
end
require "timeout"

def garbage_collect_until(timeout = 5)
  started = Process.clock_gettime(Process::CLOCK_MONOTONIC)

  while Process.clock_gettime(Process::CLOCK_MONOTONIC) - started < timeout do
    GC.start
    if $jruby
      Java::JavaLang::System.gc
      Java::JavaLang::System.runFinalization
    end

    return true if yield
  end
  
  raise Timeout::Error, "Timeout waiting for garbage collection"
end

With that, the failing base-case for JRuby would be as follows. Note that this succeeds on CRuby/MRI, usually after just one loop in garbage_collect_until. Interestingly, TruffleRuby appears to show the same behavior as JRuby here.

weak_map = ObjectSpace::WeakMap.new
obj = +"Hello World"
weak_map[obj.__id__] = obj

weak_map.size
# => 1

# Remove the object reference
obj = nil

garbage_collect_until { weak_map.size == 0 }
# => Timeout waiting for garbage collection (Timeout::Error)

In contrast, both of the following two example work on JRuby. This appears to indicate that these object references are cleanup up when leaving the (method or thread) scope.

  1. Using a thread to set the value

    weak_map = ObjectSpace::WeakMap.new
    
    Thread.new do
      obj = +"Hello World"
      weak_map[obj.__id__] = obj
      
      weak_map.size
    end.value
    # => 1
    
    weak_map.size
    # => 1 (this may not always hold if there was a garbage collect run in-between)
    
    garbage_collect_until { weak_map.size == 0 }
    # => true
    
    weak_map.size
    # => 0
  2. Using a separate method

    def add_hello_world(weak_map)
      obj = +"Hello World"
      weak_map[obj.__id__] = obj
    
      weak_map.size
    end
    
    weak_map = ObjectSpace::WeakMap.new
    add_hello_world(weak_map)
    # => 1
    
    weak_map.size
    # => 1 (this may not always hold if there was a garbage collect run in-between)
    
    garbage_collect_until { weak_map.size == 0 }
    # => true
    
    weak_map.size
    # => 0

By fixing this issue, it may be possible to re-enable the WeakRef tests in
test/mri/excludes/TestWeakRef.rb (and by introducing Java-specific means to force garbage collection in the actual tests in test/mri/test_weakref.rb.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions