Status: Design Proposal (Technically Reviewed)
Version: 1.0
Created: 2026-03-26
Supersedes: destroy_support.md, weak_references.md, auto_close.md
Related: moo_support.md (Phases 30-31)
This document covers Perl's object lifecycle management in PerlOnJava:
- DESTROY - Destructor methods called when objects become unreachable
- Weak References - References that don't prevent garbage collection
These features are tightly coupled: weak references become undef when the referent's
last strong reference is gone, which is the same moment DESTROY is called.
Perl uses reference counting with deterministic destruction:
{ my $obj = Foo->new; } # DESTROY called HERE, immediately
print "after block\n"; # Object is already destroyedJava uses garbage collection with non-deterministic cleanup:
- Objects are collected "sometime later" (or never, if memory isn't pressured)
PhantomReference/Cleaner: By the time we're notified, the object is GONEfinalize(): Deprecated since Java 18, unreliable
This affects both DESTROY (timing) and weak references (when they become undef).
From perlobj documentation:
# Basic DESTROY - called when last reference goes away
package Foo;
sub DESTROY {
my $self = shift;
print "Foo destroyed\n";
}
{ my $obj = Foo->new; } # DESTROY called at block exitKey behaviors:
$_[0]is read-only in DESTROY- DESTROY exceptions are warnings with "(in cleanup)", don't propagate
- Must localize global status variables:
local($., $@, $!, $^E, $?) - If AUTOLOAD exists but no DESTROY, AUTOLOAD is called with "DESTROY"
${^GLOBAL_PHASE} eq 'DESTRUCT'detects global destruction phase- Global destruction order is unpredictable
$obj (RuntimeScalar)
└── type = HASHREFERENCE
└── value → RuntimeHash
└── blessId = 42 (maps to "MyClass")
└── elements = {...}
When $obj goes out of scope, the RuntimeScalar becomes unreachable. But Java's GC
doesn't tell us about this until potentially much later.
- Tied variables: DESTROY already works via
TieScalar.tiedDestroy()/tieCallIfExists("DESTROY") - Regular blessed objects: No DESTROY support yet
- Existing infrastructure:
DeferBlockprovides scope-exit callbacks
Static analysis cannot always determine object lifetimes:
my $file = IO::File->new("test.txt");
push @global_array, $file; # Now $file lives beyond its scopeEven though $file goes out of scope, it's still alive in @global_array. This is why
we need multiple strategies: scope-based for simple cases, GC-based for escaped references.
org.perlonjava.backend.jvm.Local.localTeardown() can run cleanup when a variable leaves scope.
This provides deterministic cleanup for simple lexical cases.
Java's AutoCloseable provides deterministic cleanup but requires explicit scope:
public class MyResource implements AutoCloseable {
@Override
public void close() {
// Cleanup logic
}
}
try (MyResource resource = new MyResource()) {
// Use resource
} // Automatically calls close() hereThis pattern could be used for IO::File and similar resources internally.
The key insight: PhantomReference.get() always returns null, so we cannot access
the object directly. Instead, track cleanup state separately:
// In RuntimeIO class
private static final ReferenceQueue<RuntimeIO> referenceQueue = new ReferenceQueue<>();
private static final Map<PhantomReference<RuntimeIO>, IOHandle> phantomToHandle =
new ConcurrentHashMap<>();
// When opening a file
public static RuntimeIO open(String fileName, String mode) {
RuntimeIO fh = new RuntimeIO();
// ... existing open logic ...
// Create phantom reference for cleanup
PhantomReference<RuntimeIO> phantomRef = new PhantomReference<>(fh, referenceQueue);
phantomToHandle.put(phantomRef, fh.ioHandle);
return fh;
}
// Cleanup thread or periodic check
private static void processPhantomReferences() {
PhantomReference<? extends RuntimeIO> ref;
while ((ref = (PhantomReference<? extends RuntimeIO>) referenceQueue.poll()) != null) {
IOHandle handle = phantomToHandle.remove(ref);
if (handle != null && openHandles.containsKey(handle)) {
try {
handle.close();
openHandles.remove(handle);
} catch (Exception e) {
// Log cleanup failure
}
}
ref.clear();
}
}Combine multiple strategies:
- Use
Local.localTeardown()for deterministic cleanup in simple lexical cases - Use PhantomReferences/Cleaner for complex cases where static analysis fails
- Keep LRU cache as safety net for resource limits (e.g., max open file handles)
- Provide explicit
close()methods for user control
The key insight: most DESTROY calls happen at predictable scope boundaries. We can handle these deterministically and fall back to GC-based cleanup for edge cases.
public class DestructorRegistry {
// WeakHashMap: when RuntimeHash is only reachable through here, it's eligible
private static final WeakHashMap<RuntimeBase, String> registered = new WeakHashMap<>();
public static void register(RuntimeBase obj, String packageName) {
// Check if package has DESTROY
RuntimeScalar destroyRef = GlobalVariable.getGlobalCodeRef(packageName + "::DESTROY");
if (destroyRef.value != null && ((RuntimeCode)destroyRef.value).defined()) {
registered.put(obj, packageName);
}
}
public static void triggerDestroy(RuntimeBase obj) {
String pkg = registered.remove(obj);
if (pkg != null) {
callDestroy(obj, pkg);
}
}
}public static RuntimeScalar bless(RuntimeScalar ref, RuntimeScalar className) {
// ... existing code ...
((RuntimeBase) ref.value).setBlessId(blessId);
// NEW: Register for DESTROY callback
DestructorRegistry.register((RuntimeBase) ref.value, className.toString());
return ref;
}use Scalar::Util qw(weaken isweak unweaken);
my $strong = { data => "test" };
my $weak = $strong;
weaken($weak);
print $weak->{data}; # Works: "test"
undef $strong; # Referent's refcount -> 0
print defined $weak; # Prints 0 - $weak is now undefPrimary use case - break circular references:
package Parent;
sub add_child {
my ($self, $child) = @_;
push @{$self->{children}}, $child;
$child->{parent} = $self;
weaken($child->{parent}); # Without this, circular ref!
}weaken(), unweaken(), and isweak() are stubs:
// ScalarUtil.java - always returns false
public static RuntimeList isweak(RuntimeArray args, int ctx) {
return new RuntimeList(scalarFalse);
}This causes 20+ test failures in Moo's accessor-weaken tests.
Adding a field to RuntimeScalar has significant implications:
RuntimeScalaris the most frequently instantiated object- Millions of instances in typical programs
- Adding even a
booleanfield adds 4-8 bytes per instance (alignment) - Estimated impact: 4-8 MB additional memory per million scalars
Alternative approaches (to avoid per-scalar overhead):
-
External WeakHashMap registry:
WeakHashMap<RuntimeScalar, Boolean>- Only allocates for weak refs (rare)
- Lookup overhead on
isweak()calls
-
Sentinel wrapper type:
value = new WeakRefWrapper(originalValue)- Check
instanceof WeakRefWrapperinstead of flag - No new field, but type check overhead
- Check
-
Bit-packing in
typefield: Use unused high bitstypeisintbut only uses ~20 enum values- Requires careful bit masking everywhere
-
RuntimeScalarWeak subclass: Separate class for weak refs
- Only weak refs pay memory cost
- But changes reference identity behavior
public class WeakRefRegistry {
private static final Map<RuntimeScalar, WeakReference<Object>> registry =
Collections.synchronizedMap(new IdentityHashMap<>());
public static void weaken(RuntimeScalar ref) {
if (RuntimeScalarType.isReference(ref.type)) {
registry.put(ref, new WeakReference<>(ref.value));
}
}
public static boolean isweak(RuntimeScalar ref) {
return registry.containsKey(ref);
}
public static void unweaken(RuntimeScalar ref) {
registry.remove(ref);
}
}// In RuntimeScalar - getter that unwraps
public Object getValue() {
if (value instanceof WeakReference<?> weakRef) {
Object referent = weakRef.get();
if (referent == null) {
// Referent was collected - become undef
this.type = UNDEF;
this.value = null;
}
return referent;
}
return value;
}Both DESTROY and weak references need:
- Reference counting (at least for blessed objects)
- Scope-exit hooks (existing
DeferBlockinfrastructure) - GC integration via
CleanerAPI
Java's Cleaner (since Java 9) is the modern replacement for finalize():
private static final Cleaner cleaner = Cleaner.create();
// CRITICAL: cleaning action must NOT hold strong reference to object
record CleanupState(RuntimeHash data, String className) implements Runnable {
public void run() {
callDestroy(data, className); // For DESTROY
// Also clears weak references to this object
}
}
// Register at bless() time
Cleaner.Cleanable cleanable = cleaner.register(runtimeScalar,
new CleanupState(hash, packageName));Critical caveat:
"The cleaning action must not refer to the object being registered. If so, the object will not become phantom reachable and the cleaning action will not be invoked."
This is why we use a separate CleanupState record, not a lambda capturing the object.
- Already works for tied variables
- Generalize to blessed objects using same
tieCallIfExists("DESTROY")approach - Effort: 2-4 hours
- External registry approach (no memory impact)
isweak()returns correct valuesweaken()/unweaken()track status- Weak refs DON'T auto-undef yet (flag-only)
- Effort: 2-4 hours
- Tests enabled: Moo accessor-weaken tests that check
isweak()
- Hook into
RuntimeScalar.undefine()and assignment operators - Call DESTROY when blessed ref is overwritten
- Clear weak references to the object
- Effort: 4-8 hours
- Leverage existing
DeferBlock/DynamicVariableManagerinfrastructure - Register blessed lexicals for scope-exit cleanup
- Ref-count tracking for blessed objects only
- Effort: 8-16 hours
- For objects that escape scope tracking
- Companion object pattern (like Jython's FinalizeTrigger)
- Weak refs become undef when GC runs
- Effort: 8-16 hours
- Implement
${^GLOBAL_PHASE}special variable - Add shutdown hook:
Runtime.getRuntime().addShutdownHook(...) - Handle
Devel::GlobalDestructioncompatibility - Effort: 4-8 hours
$weak ──────────────────────┐
▼
$strong ─────────────► RuntimeHash
blessId=42
elements={...}
$weak ───► WeakReference ─ ─ ─► RuntimeHash (weak link)
blessId=42
$strong ─────────────────────► elements={...}
▲
│ (strong link)
When $strong goes away and GC runs:
- RuntimeHash becomes phantom-reachable
- Cleaner triggers cleanup action
- DESTROY is called (if defined)
- Weak refs to this object become undef
- Companion object pattern:
FinalizeTriggerholds reference to Python object - Registration at construction: Classes call
FinalizeTrigger.ensureFinalizer(this) - Object resurrection: Finalizer called once; must re-register if resurrected
- GC-driven timing: Non-deterministic, like Java GC
- Current: Uses
Object.finalize()internally - Migration to Cleaner: Issue #8328 (JRuby 10.1.0.0) eliminating finalization
- Key limitation: Finalizer receives object ID only, NOT the object itself
| Feature | Jython __del__ |
JRuby finalizer | Java Cleaner |
Perl DESTROY |
|---|---|---|---|---|
| Timing | GC-driven | GC-driven | GC-driven | Refcount (deterministic) |
| Object access | Yes (FinalizeTrigger) | No (only ID) | No (capture state separately) | Yes ($_[0]) |
| Resurrection | Yes (manual) | No | No | Yes (store $_[0]) |
Lesson: Use Cleaner + companion object pattern to provide object access in DESTROY.
If DESTROY stores $_[0] somewhere, Perl keeps the object alive:
package Immortal;
our @saved;
sub DESTROY { push @saved, $_[0] } # Object survives!Need to re-register after DESTROY returns if still reachable.
In Perl, circular refs prevent DESTROY until global destruction. In our approach, they may be destroyed earlier (when scope exits) - often desired.
Perl warns but continues:
try {
callDestroy(obj, pkg);
} catch (Exception e) {
Warnings.warn("(in cleanup) " + e.getMessage());
}In Perl, copying a weak ref creates a strong ref:
my $weak = $strong;
weaken($weak);
my $copy = $weak; # $copy is STRONG, not weak
ok(!isweak($copy)); # trueObjects in package variables never go out of scope until global destruction. Need shutdown hook:
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
GlobalContext.setGlobalPhase("DESTRUCT");
DestructorRegistry.runGlobalDestruction();
}));# src/test/resources/unit/object_lifecycle.t
use Test::More;
use Scalar::Util qw(weaken isweak unweaken);
# === DESTROY Tests ===
subtest 'Basic DESTROY' => sub {
my @log;
package DestroyTest {
sub new { bless {}, shift }
sub DESTROY { push @log, "destroyed" }
}
{ my $obj = DestroyTest->new; }
is_deeply(\@log, ["destroyed"], "DESTROY called at scope exit");
};
subtest 'Multiple references' => sub {
my @log;
package MultiRef {
sub new { bless {}, shift }
sub DESTROY { push @log, "destroyed" }
}
my $obj1 = MultiRef->new;
my $obj2 = $obj1;
undef $obj1;
is_deeply(\@log, [], "DESTROY not called with refs remaining");
undef $obj2;
is_deeply(\@log, ["destroyed"], "DESTROY called when last ref gone");
};
subtest 'Exception in DESTROY' => sub {
my $ran_after = 0;
package ExceptionDestroy {
sub new { bless {}, shift }
sub DESTROY { die "DESTROY error" }
}
{ my $obj = ExceptionDestroy->new; }
$ran_after = 1;
ok($ran_after, "Execution continues after DESTROY exception");
};
# === Weak Reference Tests ===
subtest 'isweak flag' => sub {
my $ref = \my %hash;
ok(!isweak($ref), "not weak initially");
weaken($ref);
ok(isweak($ref), "weak after weaken");
unweaken($ref);
ok(!isweak($ref), "not weak after unweaken");
};
subtest 'Weak ref still works' => sub {
my $strong = { key => "value" };
my $weak = $strong;
weaken($weak);
is($weak->{key}, "value", "can access through weak ref");
};
subtest 'Copy of weak ref is strong' => sub {
my $strong = { key => "value" };
my $weak = $strong;
weaken($weak);
my $copy = $weak;
ok(!isweak($copy), "copy is strong");
};
# Phase 2+ test - weak ref becomes undef
subtest 'Weak ref becomes undef' => sub {
plan skip_all => "Requires Phase 5 implementation";
my $strong = { key => "value" };
my $weak = $strong;
weaken($weak);
undef $strong;
ok(!defined($weak), "weak ref is undef after strong ref gone");
};
done_testing();ScalarUtil.java- Implement weaken/isweak/unweaken with registryReferenceOperators.java- Add DESTROY registration at bless()DestructorRegistry.java- NEW: Track blessed objects with DESTROY
RuntimeScalar.java- Hook undefine() and assignment for cleanupDynamicVariableManager.java- Extend for DESTROY scope trackingBytecodeCompiler.java/EmitBlock.java- Emit scope-exit cleanup
CleanerRegistry.java- NEW: Cleaner-based fallback- Value access points need weak ref checking
GlobalContext.java- Add${^GLOBAL_PHASE}supportMain.javaor entry point - Add shutdown hook
-
Should we implement reference counting for blessed objects?
- Recommendation: Yes, but only for blessed objects. Minimal overhead since most data is unblessed.
-
External registry vs. field for weak refs?
- Recommendation: External registry. Memory impact of per-scalar field is unacceptable.
-
Should DESTROY registration be opt-in?
- Recommendation: No. Perl semantics require automatic DESTROY. Register at bless() time.
-
WeakReference vs SoftReference?
- Recommendation: WeakReference. More aggressive GC matches Perl's immediate semantics better.
-
Should copying weak ref preserve weakness?
- Recommendation: No (matches Perl). Copy creates strong reference.
- Perl perlobj documentation: https://perldoc.perl.org/perlobj#Destructors
- Java Cleaner API: https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/lang/ref/Cleaner.html
- Jython FinalizablePyObject: https://www.javadoc.io/static/org.python/jython-standalone/2.7.1/org/python/core/finalization/FinalizablePyObject.html
- JRuby issue #8328 "Eliminate all uses of finalization": jruby/jruby#8328
- JRuby issue #8465 "WeakReference updates for Java 21": jruby/jruby#8465