Skip to content

fix: avoid redundant Fury instance recreation to prevent FGC#1553

Open
sunhailin-Leo wants to merge 1 commit intomasterfrom
fix/fury-fgc-classloader-reuse
Open

fix: avoid redundant Fury instance recreation to prevent FGC#1553
sunhailin-Leo wants to merge 1 commit intomasterfrom
fix/fury-fgc-classloader-reuse

Conversation

@sunhailin-Leo
Copy link
Collaborator

@sunhailin-Leo sunhailin-Leo commented Mar 24, 2026

What problem does this PR solve?

Fixes #1424

Root Cause

In the current implementation, every encode/decode call unconditionally invokes:

fury.setClassLoader(contextClassLoader);   // before try
fury.clearClassLoader(contextClassLoader); // in finally

ThreadLocalFury.clearClassLoader() discards the current thread-local Fury instance, causing a new Fury instance to be created on the next call. Since each Fury instance holds its own ClassResolver (a large heap object), this results in:

  • Fury instance count growing proportionally to TPS rather than thread count
  • Under 20000 TPS: 4581 Fury instances observed in heap
  • Frequent Full GC triggered by old-gen pressure

Solution

Introduce boundClassLoaderHolder (ThreadLocal<ClassLoader>) to track the ClassLoader currently bound to the Fury instance per thread.

setClassLoader/clearClassLoader are now only called when the thread's ContextClassLoader actually changes (e.g. in SOFAArk multi-module environments). In typical single-ClassLoader deployments, the Fury instance is reused across all calls on the same thread.

private boolean switchClassLoaderIfNeeded(ClassLoader contextClassLoader) {
    ClassLoader boundClassLoader = boundClassLoaderHolder.get();
    if (boundClassLoader == contextClassLoader) {
        return false; // reuse existing instance
    }
    fury.setClassLoader(contextClassLoader);
    boundClassLoaderHolder.set(contextClassLoader);
    return true;
}

Impact

Scenario Before After
Normal RPC (same ClassLoader) Fury instance recreated every call Fury instance reused per thread
SOFAArk multi-module (ClassLoader changes) Fury instance recreated every call Fury instance switched only on change
20000 TPS 4581 Fury instances ~thread count Fury instances

Changes

  • codec/codec-sofa-fury/src/main/java/com/alipay/sofa/rpc/codec/fury/FurySerializer.java
    • Added boundClassLoaderHolder field
    • Replaced unconditional setClassLoader/clearClassLoader with conditional switchClassLoaderIfNeeded
    • Added private method switchClassLoaderIfNeeded

Summary by CodeRabbit

  • Bug Fixes
    • Improved ClassLoader context handling in serialization operations to ensure proper class loader management during encode and decode processes.

Introduce boundClassLoaderHolder (ThreadLocal<ClassLoader>) to track
the ClassLoader currently bound to the Fury instance per thread.

Previously, every encode/decode call unconditionally invoked
setClassLoader + clearClassLoader, causing ThreadLocalFury to discard
and recreate Fury instances on every RPC call. Under high TPS (e.g.
20000 TPS), this produced thousands of Fury instances in the heap,
each holding a large ClassResolver, leading to frequent Full GC.

With this fix, setClassLoader/clearClassLoader are only called when
the thread's ContextClassLoader actually changes (e.g. in SOFAArk
multi-module environments). In typical single-ClassLoader deployments,
the Fury instance is reused across calls on the same thread, keeping
instance count proportional to thread count rather than TPS.

Fixes #1424
@sofastack-cla sofastack-cla bot added bug Something isn't working cla:yes CLA is ok size/M labels Mar 24, 2026
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 24, 2026

📝 Walkthrough

Walkthrough

Modified FurySerializer to optimize class loader handling by introducing a thread-local tracker that conditionally switches class loaders only when they differ from the currently bound one, thereby reducing unnecessary Fury instance creation and clearing operations.

Changes

Cohort / File(s) Summary
Class Loader Switch Optimization
codec/codec-sofa-fury/src/main/java/com/alipay/sofa/rpc/codec/fury/FurySerializer.java
Added ThreadLocal<ClassLoader> field to track the bound class loader. Introduced switchClassLoaderIfNeeded() helper to conditionally switch class loaders only when they differ from the currently bound one. Updated encode() and decode() methods to use this helper and conditionally clear class loaders only when switched, reducing unnecessary Fury instance creation.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

A clever rabbit optimized today,
Less Fury instances cluttering the way,
Thread-local remembrance, a smart little trick,
Now switching class loaders only when you must pick,
Full GCs take fewer hops! 🐰✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 20.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: avoiding redundant Fury instance recreation to prevent Full GC, which directly aligns with the core objective of the PR.
Linked Issues check ✅ Passed All coding objectives from #1424 are met: Fury instances are reused per thread via boundClassLoaderHolder, ClassLoader switching is conditional rather than unconditional, and the solution prevents unbounded growth of Fury instances.
Out of Scope Changes check ✅ Passed All changes are within scope of #1424; the PR modifies only FurySerializer to add conditional ClassLoader switching and thread-local reuse tracking without introducing unrelated functionality.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/fury-fgc-classloader-reuse

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
codec/codec-sofa-fury/src/main/java/com/alipay/sofa/rpc/codec/fury/FurySerializer.java (1)

118-143: 🛠️ Refactor suggestion | 🟠 Major

Suggestion: Consider removing the finally blocks entirely.

Given the critical issue above, if the fix is applied, all three methods (encode and both decode overloads) can have their finally blocks removed entirely. The classLoaderSwitched flag becomes unnecessary, simplifying the code and achieving the intended per-thread Fury reuse.

Also applies to: 145-169, 171-194

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@codec/codec-sofa-fury/src/main/java/com/alipay/sofa/rpc/codec/fury/FurySerializer.java`
around lines 118 - 143, Remove the redundant finally blocks and related
classloader cleanup logic from FurySerializer.encode and both decode overloads:
eliminate the classLoaderSwitched boolean, the switchClassLoaderIfNeeded(...)
call, and the finally body that calls fury.clearClassLoader(contextClassLoader)
and boundClassLoaderHolder.remove(); simplify the methods to rely on the
corrected classloader handling elsewhere so they only perform the try/catch
(preserving the existing exception handling that throws
buildSerializeError/buildDeserializeError).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@codec/codec-sofa-fury/src/main/java/com/alipay/sofa/rpc/codec/fury/FurySerializer.java`:
- Around line 138-141: The current logic in FurySerializer (use of
boundClassLoaderHolder.remove() in finally and the classLoaderSwitched flag)
clears the ThreadLocal on every call so Fury instances cannot be reused across
sequential calls; change the binding logic to only switch when the current
context ClassLoader actually differs from the bound one (compare
boundClassLoader vs contextClassLoader), do not call
boundClassLoaderHolder.remove() in the finally block, and remove the
classLoaderSwitched flag/related finally cleanup so the ClassLoader binding (and
associated Fury instance) persists per-thread; apply the same change to both
decode(...) overloads and ensure any call to
fury.clearClassLoader(contextClassLoader) is only invoked when you genuinely
unbind due to a ClassLoader change.

---

Outside diff comments:
In
`@codec/codec-sofa-fury/src/main/java/com/alipay/sofa/rpc/codec/fury/FurySerializer.java`:
- Around line 118-143: Remove the redundant finally blocks and related
classloader cleanup logic from FurySerializer.encode and both decode overloads:
eliminate the classLoaderSwitched boolean, the switchClassLoaderIfNeeded(...)
call, and the finally body that calls fury.clearClassLoader(contextClassLoader)
and boundClassLoaderHolder.remove(); simplify the methods to rely on the
corrected classloader handling elsewhere so they only perform the try/catch
(preserving the existing exception handling that throws
buildSerializeError/buildDeserializeError).

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: c06cf949-5e69-4964-a8c0-1bd6bae88dea

📥 Commits

Reviewing files that changed from the base of the PR and between ec810c2 and d4dca53.

📒 Files selected for processing (1)
  • codec/codec-sofa-fury/src/main/java/com/alipay/sofa/rpc/codec/fury/FurySerializer.java

Comment on lines +138 to +141
if (classLoaderSwitched) {
fury.clearClassLoader(contextClassLoader);
boundClassLoaderHolder.remove();
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Critical: Optimization ineffective for sequential calls due to remove() in finally.

The finally blocks call boundClassLoaderHolder.remove() whenever classLoaderSwitched is true. Since the first call on a thread always switches (from null to the context ClassLoader), every sequential call follows this pattern:

  1. Call 1: boundClassLoader=null, context=CL1 → switch=true → finally removes ThreadLocal value
  2. Call 2: boundClassLoader=null (was removed) → switch=true → Fury instance recreated again

This defeats the PR's stated goal of reusing Fury instances per thread. The optimization only works for nested calls (e.g., inner encode() within outer encode()), not the common case of sequential RPC calls.

To achieve per-thread Fury reuse, the ClassLoader binding should persist across calls, only changing when the context ClassLoader actually differs.

🐛 Proposed fix: Don't clear in finally; only switch on actual ClassLoader change
 `@Override`
 public AbstractByteBuf encode(final Object object, final Map<String, String> context) throws SofaRpcException {
     if (object == null) {
         throw buildSerializeError("Unsupported null message!");
     }
     ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader();
-    boolean classLoaderSwitched = switchClassLoaderIfNeeded(contextClassLoader);
+    switchClassLoaderIfNeeded(contextClassLoader);
     try {
         // ... existing try block ...
     } catch (Exception e) {
         throw buildSerializeError(e.getMessage(), e);
-    } finally {
-        if (classLoaderSwitched) {
-            fury.clearClassLoader(contextClassLoader);
-            boundClassLoaderHolder.remove();
-        }
     }
+    // No finally cleanup - let the ClassLoader binding persist for reuse
 }

 private void switchClassLoaderIfNeeded(ClassLoader contextClassLoader) {
     ClassLoader boundClassLoader = boundClassLoaderHolder.get();
     if (boundClassLoader == contextClassLoader) {
-        // ClassLoader unchanged: reuse the existing Fury instance on this thread
-        return false;
+        return; // Same ClassLoader - Fury instance already bound correctly
+    }
+    // ClassLoader changed (or first call on this thread)
+    if (boundClassLoader != null) {
+        // Clear the previous ClassLoader binding before switching
+        fury.clearClassLoader(boundClassLoader);
     }
     fury.setClassLoader(contextClassLoader);
     boundClassLoaderHolder.set(contextClassLoader);
-    return true;
 }

Apply the same pattern to both decode() methods: remove the classLoaderSwitched flag, remove the finally blocks, and let the ClassLoader binding persist.

Also applies to: 164-167, 189-192

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@codec/codec-sofa-fury/src/main/java/com/alipay/sofa/rpc/codec/fury/FurySerializer.java`
around lines 138 - 141, The current logic in FurySerializer (use of
boundClassLoaderHolder.remove() in finally and the classLoaderSwitched flag)
clears the ThreadLocal on every call so Fury instances cannot be reused across
sequential calls; change the binding logic to only switch when the current
context ClassLoader actually differs from the bound one (compare
boundClassLoader vs contextClassLoader), do not call
boundClassLoaderHolder.remove() in the finally block, and remove the
classLoaderSwitched flag/related finally cleanup so the ClassLoader binding (and
associated Fury instance) persists per-thread; apply the same change to both
decode(...) overloads and ensure any call to
fury.clearClassLoader(contextClassLoader) is only invoked when you genuinely
unbind due to a ClassLoader change.

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR aims to reduce Full GC pressure in the Fury-based serializer by avoiding per-call thread-local Fury instance churn caused by unconditional setClassLoader / clearClassLoader calls, addressing issue #1424.

Changes:

  • Added a per-thread boundClassLoaderHolder to track the currently bound ClassLoader.
  • Introduced switchClassLoaderIfNeeded(...) and wired it into encode/decode paths to make ClassLoader switching conditional.
  • Adjusted finally blocks to conditionally call clearClassLoader.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +164 to +167
if (classLoaderSwitched) {
fury.clearClassLoader(contextClassLoader);
boundClassLoaderHolder.remove();
}
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same issue as in encode: removing boundClassLoaderHolder in finally resets the per-thread bound ClassLoader every time, so subsequent calls will always consider the ClassLoader “switched” and re-run setClassLoader/clearClassLoader on every decode.

Copilot uses AI. Check for mistakes.
Comment on lines +189 to +192
if (classLoaderSwitched) {
fury.clearClassLoader(contextClassLoader);
boundClassLoaderHolder.remove();
}
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same issue as the other methods: boundClassLoaderHolder.remove() in finally wipes the cached ClassLoader, so switchClassLoaderIfNeeded can’t ever observe “unchanged” across calls and will keep switching every time.

Copilot uses AI. Check for mistakes.
Comment on lines 123 to 125
ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader();
boolean classLoaderSwitched = switchClassLoaderIfNeeded(contextClassLoader);
try {
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are existing unit tests for FurySerializer, but none exercise the new “switch ClassLoader only when needed” behavior. Please add tests that (1) call encode/decode multiple times with the same thread context ClassLoader and verify the switch path is not taken after the first call, and (2) change the thread context ClassLoader between calls and verify the switch path is taken.

Copilot uses AI. Check for mistakes.
Comment on lines +138 to +141
if (classLoaderSwitched) {
fury.clearClassLoader(contextClassLoader);
boundClassLoaderHolder.remove();
}
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

boundClassLoaderHolder.remove() here defeats the tracking across calls: on the next invocation boundClassLoaderHolder.get() becomes null again, so switchClassLoaderIfNeeded(...) will always treat it as a switch and keep calling setClassLoader (and then clearClassLoader) every call. That makes the optimization ineffective and likely preserves the original Fury instance churn. Consider keeping the bound ClassLoader in the ThreadLocal across calls and only switching when the thread context ClassLoader actually changes (and avoid clearing/removing in the normal path).

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working cla:yes CLA is ok size/M

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Frequently creating Fury instances, leading to FGC

2 participants