This is an automated email from the ASF dual-hosted git repository.

yuqi1129 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gravitino.git


The following commit(s) were added to refs/heads/main by this push:
     new d9c9237410 [#10093] feat(core): Introduce ClassLoaderPool to share 
ClassLoaders across same-type catalogs (#10480)
d9c9237410 is described below

commit d9c9237410259ea3f396ef4ef207a3ab2312666f
Author: YangJie <[email protected]>
AuthorDate: Tue Jul 21 16:00:36 2026 +0800

    [#10093] feat(core): Introduce ClassLoaderPool to share ClassLoaders across 
same-type catalogs (#10480)
    
    ### What changes were proposed in this pull request?
    Introduce a `ClassLoaderPool` with reference counting to share
    `IsolatedClassLoader` instances across catalogs of the same type, and
    centralize ClassLoader resource cleanup into the pool's lifecycle. Add
    `gravitino.catalog.classloader.sharing.enabled` as a safety-net toggle
    to disable sharing if needed.
    
    **Core mechanism:** Catalogs with identical isolation-relevant
    properties share a single `IsolatedClassLoader`. The isolation key is
    built from the provider plus the package, authorization provider,
    authentication type, Kerberos principal/keytab, and backend URIs
    (`metastore.uris`, `jdbc-url`, `uri`, `fs.defaultFS`). The pool uses
    `ConcurrentHashMap.compute()` for atomic acquire/release, and runs
    cleanup (JDBC driver deregistration, then the shared
    `ClassLoaderResourceCleanerUtils` pass over Hadoop FileSystem,
    ThreadLocals, commons-logging, cloud SDKs, and shutdown hooks) only when
    the last catalog releases the shared ClassLoader.
    
    When sharing is disabled via
    `gravitino.catalog.classloader.sharing.enabled=false`, each catalog gets
    its own dedicated ClassLoader without going through the pool — restoring
    the pre-PR behavior as a fallback.
    
    **New classes:**
    - `ClassLoaderKey` — `Map<String, String>`-based key for ClassLoader
    sharing, decoupled from specific property names
    - `ClassLoaderPool` — thread-safe pool with reference counting and
    lifecycle management
    - `PooledClassLoaderEntry` — holds a shared ClassLoader and its
    reference count
    
    **Changes to existing classes:**
    - `CatalogManager` — integrates the pool into catalog creation, test
    connection, and close paths; adds a non-pooled path gated by
    `classLoaderSharingEnabled`; extracts `initCatalogWrapper()` to remove
    duplication between the pooled and non-pooled paths; fixes ClassLoader
    leaks in `testConnection()` and `getResolvedProperties()`
    - `Configs` — adds `gravitino.catalog.classloader.sharing.enabled`
    - `IsolatedClassLoader` — exposes `getInternalClassLoader()` so the pool
    can run cleanup on the underlying `URLClassLoader`
    - Removes per-catalog cleanup calls from `JdbcCatalogOperations`,
    `IcebergCatalogOperations`, `IcebergCatalogWrapper`,
    `PaimonCatalogOperations`, `FilesetCatalogOperations`,
    `HiveCatalogOperations`, and `CatalogWrapperForREST`, since cleanup now
    happens once in the pool on final release
    
    ### Why are the changes needed?
    Concurrent catalog creation with different names but the same provider
    type causes `OutOfMemoryError: Metaspace`. Each catalog creates an
    independent `IsolatedClassLoader` that loads all provider JARs into
    Metaspace. With `MaxMetaspaceSize=512m` (default) and Iceberg catalogs
    consuming ~30-80 MB each, ~10 catalogs exhaust the limit.
    
    This patch addresses three causes:
    1. **No ClassLoader sharing** — same-type catalogs loaded identical
    classes into separate Metaspace regions
    2. **ClassLoader leak in `testConnection()`** — the throwaway wrapper
    was never closed after a connection test
    3. **Inconsistent cleanup** — only 2 of 9+ catalog types called
    `ClassLoaderResourceCleanerUtils`; centralizing it in the pool ensures
    every type is cleaned on final release
    
    Fix: #10093
    
    ### Does this PR introduce _any_ user-facing change?
    Yes. A new optional server configuration is added (since 2.0.0):
    
    - `gravitino.catalog.classloader.sharing.enabled` — whether to share
    ClassLoaders across catalogs with identical isolation-relevant
    properties. Default is `true`. Set to `false` to give each catalog its
    own dedicated ClassLoader as in previous releases.
    
    ### How was this patch tested?
    **Unit tests** (`TestClassLoaderPool` — 19 tests): acquire/release
    semantics, reference counting, concurrent access with 20 threads,
    close-during-acquire race, double-release resilience, Kerberos key
    isolation, backend URI isolation (metastore URIs, JDBC URLs, `uri`,
    fs.defaultFS), authorization provider isolation, package property
    isolation.
    
    **Integration tests** (`TestClassLoaderPoolIntegration` — 6 tests):
    same-type catalogs share one ClassLoader; dropping one doesn't affect
    others; `testConnection` on the same key doesn't break a live catalog;
    manager close cleans up the pool; with sharing disabled each catalog
    gets a distinct ClassLoader; with sharing disabled dropping one doesn't
    affect another.
    
    **Docker integration tests** (`@Tag("gravitino-docker-test")`, real
    MySQL): `IcebergClassLoaderPoolIT` (3 tests) verifies over a live JDBC
    backend that catalogs on the same `uri` share a ClassLoader while
    different `uri`s isolate, and that dropping a shared catalog / running
    `testConnection` keeps a sibling usable. `TestMultipleJDBCLoad` covers
    multiple Iceberg JDBC catalogs on one server. Both scope Iceberg's
    metadata lookups with `nullCatalogMeansCurrent=true` (test-only) so
    catalogs on separate databases of a shared MySQL container don't see
    each other's `iceberg_tables`.
    
    **Existing tests**: `TestCatalogManager` passes without modification;
    `TestJdbcCatalogOperations` was trimmed (the driver-deregistration path
    it covered is now handled by the pool).
    
    **Benchmark** (JDK 17, `-XX:MaxMetaspaceSize=512m`, `fileset` provider,
    10 concurrent threads):
    
    Metaspace growth (committed KB):
    
    | Catalogs | Baseline (`main`) | ClassLoaderPool | Reduction |
    |---|---|---|---|
    | 100 | +890 | +261 | 3.4x |
    | 500 | +3,280 | +82 | 40x |
    | 1,000 | +6,416 | +9 | 713x |
    | 5,000 | +13,969 | +67 | 209x |
    | 10,000 | +40,394 | +11 | **3,672x** |
    
    Classes loaded:
    
    | Catalogs | Baseline | ClassLoaderPool | Reduction |
    |---|---|---|---|
    | 1,000 | 21,772 | 11,387 | 48% |
    | 10,000 | 60,373 | 11,961 | **80%** |
    
    Baseline Metaspace grows O(N) with catalog count. The pool stays flat at
    ~8.7 MB — O(number of distinct keys). No OOM or performance regression
    on either version. For Iceberg catalogs (~50 MB/ClassLoader), baseline
    OOMs at ~10 catalogs; with the pool, catalogs sharing the same key reuse
    a single ClassLoader, so Metaspace scales with the number of distinct
    configurations rather than the number of catalog instances.
---
 .../catalog/fileset/FilesetCatalogOperations.java  |   3 -
 .../catalog/hive/HiveCatalogOperations.java        |   2 -
 .../catalog/jdbc/JdbcCatalogOperations.java        |  17 -
 .../catalog/jdbc/TestJdbcCatalogOperations.java    |  71 +---
 .../iceberg/IcebergCatalogOperations.java          |   2 -
 .../integration/test/IcebergClassLoaderPoolIT.java | 265 +++++++++++++
 .../integration/test/TestMultipleJDBCLoad.java     |  12 +-
 .../lakehouse/paimon/PaimonCatalogOperations.java  |   2 -
 conf/gravitino.conf.template                       |   4 +
 .../main/java/org/apache/gravitino/Configs.java    |  12 +
 .../apache/gravitino/catalog/CatalogManager.java   | 207 ++++++++--
 .../org/apache/gravitino/utils/ClassLoaderKey.java |  70 ++++
 .../apache/gravitino/utils/ClassLoaderPool.java    | 216 +++++++++++
 .../gravitino/utils/IsolatedClassLoader.java       |  10 +
 .../gravitino/utils/PooledClassLoaderEntry.java    | 101 +++++
 .../catalog/TestClassLoaderPoolIntegration.java    | 349 +++++++++++++++++
 .../gravitino/utils/TestClassLoaderPool.java       | 428 +++++++++++++++++++++
 docs/gravitino-server-config.md                    |   1 +
 .../iceberg/common/ops/IcebergCatalogWrapper.java  |  74 ----
 .../iceberg/service/CatalogWrapperForREST.java     |   5 -
 .../integration/test/util/TestDatabaseName.java    |  14 +
 21 files changed, 1654 insertions(+), 211 deletions(-)

diff --git 
a/catalogs/catalog-fileset/src/main/java/org/apache/gravitino/catalog/fileset/FilesetCatalogOperations.java
 
b/catalogs/catalog-fileset/src/main/java/org/apache/gravitino/catalog/fileset/FilesetCatalogOperations.java
index 329acf7495..6f2e7b3b86 100644
--- 
a/catalogs/catalog-fileset/src/main/java/org/apache/gravitino/catalog/fileset/FilesetCatalogOperations.java
+++ 
b/catalogs/catalog-fileset/src/main/java/org/apache/gravitino/catalog/fileset/FilesetCatalogOperations.java
@@ -104,7 +104,6 @@ import org.apache.gravitino.meta.FilesetEntity;
 import org.apache.gravitino.meta.SchemaEntity;
 import org.apache.gravitino.metrics.MetricsSystem;
 import org.apache.gravitino.metrics.source.FilesetCatalogMetricsSource;
-import org.apache.gravitino.utils.ClassLoaderResourceCleanerUtils;
 import org.apache.gravitino.utils.FilesetUtil;
 import org.apache.gravitino.utils.NameIdentifierUtil;
 import org.apache.gravitino.utils.NamespaceUtil;
@@ -993,8 +992,6 @@ public class FilesetCatalogOperations extends 
ManagedSchemaOperations
     if (metricsSystem != null) {
       metricsSystem.unregister(catalogMetricsSource);
     }
-
-    
ClassLoaderResourceCleanerUtils.closeClassLoaderResource(this.getClass().getClassLoader());
   }
 
   private void validateLocationHierarchy(
diff --git 
a/catalogs/catalog-hive/src/main/java/org/apache/gravitino/catalog/hive/HiveCatalogOperations.java
 
b/catalogs/catalog-hive/src/main/java/org/apache/gravitino/catalog/hive/HiveCatalogOperations.java
index 99bcc1431d..7013930a18 100644
--- 
a/catalogs/catalog-hive/src/main/java/org/apache/gravitino/catalog/hive/HiveCatalogOperations.java
+++ 
b/catalogs/catalog-hive/src/main/java/org/apache/gravitino/catalog/hive/HiveCatalogOperations.java
@@ -89,7 +89,6 @@ import 
org.apache.gravitino.rel.expressions.transforms.Transform;
 import org.apache.gravitino.rel.expressions.transforms.Transforms;
 import org.apache.gravitino.rel.indexes.Index;
 import org.apache.gravitino.rel.types.Type;
-import org.apache.gravitino.utils.ClassLoaderResourceCleanerUtils;
 import org.apache.gravitino.utils.PrincipalUtils;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -197,7 +196,6 @@ public class HiveCatalogOperations
       clientPool.close();
       clientPool = null;
     }
-    
ClassLoaderResourceCleanerUtils.closeClassLoaderResource(this.getClass().getClassLoader());
   }
 
   /**
diff --git 
a/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/JdbcCatalogOperations.java
 
b/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/JdbcCatalogOperations.java
index e7d4fde6a5..94850f38a3 100644
--- 
a/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/JdbcCatalogOperations.java
+++ 
b/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/JdbcCatalogOperations.java
@@ -75,7 +75,6 @@ import 
org.apache.gravitino.rel.expressions.distributions.Distribution;
 import org.apache.gravitino.rel.expressions.sorts.SortOrder;
 import org.apache.gravitino.rel.expressions.transforms.Transform;
 import org.apache.gravitino.rel.indexes.Index;
-import org.apache.gravitino.utils.IsolatedClassLoader;
 import org.apache.gravitino.utils.MapUtils;
 import org.apache.gravitino.utils.PrincipalUtils;
 import org.slf4j.Logger;
@@ -201,14 +200,6 @@ public class JdbcCatalogOperations implements 
CatalogOperations, SupportsSchemas
       metricsSystem.unregister(catalogMetricsSource);
     }
     DataSourceUtils.closeDataSource(dataSource);
-    try {
-      Driver driver = getDriver();
-      if (driver != null) {
-        deregisterDriver(driver);
-      }
-    } catch (SQLException e) {
-      LOG.warn("Failed to deregister JDBC driver", e);
-    }
   }
 
   /**
@@ -622,12 +613,4 @@ public class JdbcCatalogOperations implements 
CatalogOperations, SupportsSchemas
   private static String currentUser() {
     return PrincipalUtils.getCurrentUserName();
   }
-
-  public void deregisterDriver(Driver driver) throws SQLException {
-    if (driver.getClass().getClassLoader().getClass()
-        == IsolatedClassLoader.CUSTOM_CLASS_LOADER_CLASS) {
-      DriverManager.deregisterDriver(driver);
-      LOG.info("Driver {} has been deregistered...", driver);
-    }
-  }
 }
diff --git 
a/catalogs/catalog-jdbc-common/src/test/java/org/apache/gravitino/catalog/jdbc/TestJdbcCatalogOperations.java
 
b/catalogs/catalog-jdbc-common/src/test/java/org/apache/gravitino/catalog/jdbc/TestJdbcCatalogOperations.java
index d7f711e20f..964e632db2 100644
--- 
a/catalogs/catalog-jdbc-common/src/test/java/org/apache/gravitino/catalog/jdbc/TestJdbcCatalogOperations.java
+++ 
b/catalogs/catalog-jdbc-common/src/test/java/org/apache/gravitino/catalog/jdbc/TestJdbcCatalogOperations.java
@@ -20,8 +20,6 @@ package org.apache.gravitino.catalog.jdbc;
 
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Maps;
-import java.sql.Driver;
-import java.sql.DriverManager;
 import java.sql.SQLException;
 import java.util.HashMap;
 import javax.sql.DataSource;
@@ -29,8 +27,6 @@ import org.apache.commons.dbcp2.BasicDataSource;
 import org.apache.gravitino.Catalog;
 import org.apache.gravitino.NameIdentifier;
 import org.apache.gravitino.catalog.jdbc.config.JdbcConfig;
-import org.apache.gravitino.catalog.jdbc.converter.JdbcExceptionConverter;
-import org.apache.gravitino.catalog.jdbc.converter.JdbcTypeConverter;
 import 
org.apache.gravitino.catalog.jdbc.converter.SqliteColumnDefaultValueConverter;
 import org.apache.gravitino.catalog.jdbc.converter.SqliteExceptionConverter;
 import org.apache.gravitino.catalog.jdbc.converter.SqliteTypeConverter;
@@ -81,76 +77,15 @@ public class TestJdbcCatalogOperations {
   }
 
   @Test
-  public void testCloseDeregisterDriver() throws SQLException {
-    TestableJdbcCatalogOperations catalogOperations =
-        new TestableJdbcCatalogOperations(
-            new SqliteExceptionConverter(),
-            new SqliteTypeConverter(),
-            new SqliteDatabaseOperations("/illegal/path"),
-            new SqliteTableOperations(),
-            new SqliteColumnDefaultValueConverter());
-    
catalogOperations.setDriver(DriverManager.getDriver("jdbc:sqlite::memory:"));
-
-    Assertions.assertDoesNotThrow(catalogOperations::close);
-    Assertions.assertTrue(catalogOperations.isDeregisterCalled());
-  }
-
-  @Test
-  public void testCloseIgnoreGetDriverException() {
-    TestableJdbcCatalogOperations catalogOperations =
-        new TestableJdbcCatalogOperations(
+  public void testCloseDoesNotThrow() {
+    JdbcCatalogOperations catalogOperations =
+        new JdbcCatalogOperations(
             new SqliteExceptionConverter(),
             new SqliteTypeConverter(),
             new SqliteDatabaseOperations("/illegal/path"),
             new SqliteTableOperations(),
             new SqliteColumnDefaultValueConverter());
-    catalogOperations.setThrowExceptionInGetDriver(true);
 
     Assertions.assertDoesNotThrow(catalogOperations::close);
   }
-
-  private static class TestableJdbcCatalogOperations extends 
JdbcCatalogOperations {
-    private Driver driver;
-    private boolean deregisterCalled;
-    private boolean throwExceptionInGetDriver;
-
-    private TestableJdbcCatalogOperations(
-        JdbcExceptionConverter exceptionConverter,
-        JdbcTypeConverter jdbcTypeConverter,
-        SqliteDatabaseOperations databaseOperation,
-        SqliteTableOperations tableOperation,
-        SqliteColumnDefaultValueConverter columnDefaultValueConverter) {
-      super(
-          exceptionConverter,
-          jdbcTypeConverter,
-          databaseOperation,
-          tableOperation,
-          columnDefaultValueConverter);
-    }
-
-    @Override
-    protected Driver getDriver() throws SQLException {
-      if (throwExceptionInGetDriver) {
-        throw new SQLException("failed to get driver");
-      }
-      return driver;
-    }
-
-    @Override
-    public void deregisterDriver(Driver driver) {
-      this.deregisterCalled = true;
-    }
-
-    private void setDriver(Driver driver) {
-      this.driver = driver;
-    }
-
-    private void setThrowExceptionInGetDriver(boolean 
throwExceptionInGetDriver) {
-      this.throwExceptionInGetDriver = throwExceptionInGetDriver;
-    }
-
-    private boolean isDeregisterCalled() {
-      return deregisterCalled;
-    }
-  }
 }
diff --git 
a/catalogs/catalog-lakehouse-iceberg/src/main/java/org/apache/gravitino/catalog/lakehouse/iceberg/IcebergCatalogOperations.java
 
b/catalogs/catalog-lakehouse-iceberg/src/main/java/org/apache/gravitino/catalog/lakehouse/iceberg/IcebergCatalogOperations.java
index 3a75bad5ee..6e7346f636 100644
--- 
a/catalogs/catalog-lakehouse-iceberg/src/main/java/org/apache/gravitino/catalog/lakehouse/iceberg/IcebergCatalogOperations.java
+++ 
b/catalogs/catalog-lakehouse-iceberg/src/main/java/org/apache/gravitino/catalog/lakehouse/iceberg/IcebergCatalogOperations.java
@@ -72,7 +72,6 @@ import 
org.apache.gravitino.rel.expressions.distributions.Distributions;
 import org.apache.gravitino.rel.expressions.sorts.SortOrder;
 import org.apache.gravitino.rel.expressions.transforms.Transform;
 import org.apache.gravitino.rel.indexes.Index;
-import org.apache.gravitino.utils.ClassLoaderResourceCleanerUtils;
 import org.apache.gravitino.utils.HierarchicalSchemaUtil;
 import org.apache.gravitino.utils.MapUtils;
 import org.apache.gravitino.utils.PrincipalUtils;
@@ -175,7 +174,6 @@ public class IcebergCatalogOperations
     if (null != icebergCatalogWrapper) {
       try {
         icebergCatalogWrapper.close();
-        
ClassLoaderResourceCleanerUtils.closeClassLoaderResource(this.getClass().getClassLoader());
       } catch (Exception e) {
         LOG.warn("Failed to close Iceberg catalog", e);
       }
diff --git 
a/catalogs/catalog-lakehouse-iceberg/src/test/java/org/apache/gravitino/catalog/lakehouse/iceberg/integration/test/IcebergClassLoaderPoolIT.java
 
b/catalogs/catalog-lakehouse-iceberg/src/test/java/org/apache/gravitino/catalog/lakehouse/iceberg/integration/test/IcebergClassLoaderPoolIT.java
new file mode 100644
index 0000000000..b763087229
--- /dev/null
+++ 
b/catalogs/catalog-lakehouse-iceberg/src/test/java/org/apache/gravitino/catalog/lakehouse/iceberg/integration/test/IcebergClassLoaderPoolIT.java
@@ -0,0 +1,265 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.gravitino.catalog.lakehouse.iceberg.integration.test;
+
+import static 
org.apache.gravitino.catalog.lakehouse.iceberg.IcebergCatalogPropertiesMetadata.GRAVITINO_JDBC_PASSWORD;
+import static 
org.apache.gravitino.catalog.lakehouse.iceberg.IcebergCatalogPropertiesMetadata.GRAVITINO_JDBC_USER;
+
+import com.google.common.collect.Maps;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import org.apache.commons.lang3.reflect.FieldUtils;
+import org.apache.gravitino.Catalog;
+import org.apache.gravitino.GravitinoEnv;
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.catalog.CatalogManager;
+import org.apache.gravitino.client.GravitinoMetalake;
+import org.apache.gravitino.iceberg.common.IcebergConfig;
+import org.apache.gravitino.integration.test.container.MySQLContainer;
+import org.apache.gravitino.integration.test.util.BaseIT;
+import org.apache.gravitino.integration.test.util.ITUtils;
+import org.apache.gravitino.integration.test.util.TestDatabaseName;
+import org.apache.gravitino.utils.RandomNameUtils;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Assumptions;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Docker-backed integration tests for the {@code ClassLoaderPool} using a 
real MySQL Iceberg JDBC
+ * backend. These complement the counting-only unit tests in {@code 
TestClassLoaderPoolIntegration}
+ * by exercising the two runtime properties the pool actually claims:
+ *
+ * <ul>
+ *   <li><b>Key isolation (the {@code uri} blind spot):</b> Iceberg's JDBC 
backend uses the {@code
+ *       uri} catalog property, not {@code jdbc-url}. Two MySQL-backed Iceberg 
catalogs on different
+ *       databases must NOT share a ClassLoader (otherwise they would 
cross-contaminate the
+ *       per-ClassLoader {@code DriverManager} registry); two catalogs on the 
same {@code uri} must
+ *       share one.
+ *   <li><b>Lifecycle / no premature cleanup:</b> dropping one catalog that 
shares a ClassLoader
+ *       must not deregister the JDBC driver (or shut down MySQL's {@code
+ *       AbandonedConnectionCleanupThread}) while a sibling catalog is still 
live, and {@code
+ *       testConnection} must acquire+release the shared key without breaking 
a live catalog.
+ * </ul>
+ *
+ * <p>ClassLoader-identity assertions require reflecting into the in-process 
server, so they run
+ * only in embedded mode; the end-to-end "still works after drop / 
testConnection" assertions run in
+ * any mode via the client.
+ */
+@Tag("gravitino-docker-test")
+public class IcebergClassLoaderPoolIT extends BaseIT {
+
+  private static final Logger LOG = 
LoggerFactory.getLogger(IcebergClassLoaderPoolIT.class);
+
+  private static final TestDatabaseName DB_A =
+      TestDatabaseName.MYSQL_TEST_ICEBERG_CLASSLOADER_POOL_A;
+  private static final TestDatabaseName DB_B =
+      TestDatabaseName.MYSQL_TEST_ICEBERG_CLASSLOADER_POOL_B;
+  private static final String PROVIDER = "lakehouse-iceberg";
+
+  private static MySQLContainer mySQLContainer;
+
+  // Metalakes created by tests, dropped (force) in tearDown so no state leaks 
across tests/runs
+  // (important in deploy mode where the entity store persists between runs).
+  private final List<String> createdMetalakes = new ArrayList<>();
+
+  @BeforeAll
+  public void startup() throws IOException {
+    containerSuite.startMySQLContainer(DB_A);
+    mySQLContainer = containerSuite.getMySQLContainer();
+    // Second database on the same MySQL container to produce a distinct 
backend URI.
+    mySQLContainer.createDatabase(DB_B);
+  }
+
+  @AfterEach
+  public void cleanup() {
+    for (String metalakeName : createdMetalakes) {
+      try {
+        client.dropMetalake(metalakeName, true);
+      } catch (Exception e) {
+        LOG.warn("Failed to drop metalake {} during cleanup", metalakeName, e);
+      }
+    }
+    createdMetalakes.clear();
+  }
+
+  private GravitinoMetalake createMetalake(String name) {
+    GravitinoMetalake metalake = client.createMetalake(name, "comment", 
Collections.emptyMap());
+    createdMetalakes.add(name);
+    return metalake;
+  }
+
+  private Map<String, String> icebergMysqlConf(TestDatabaseName db) throws 
Exception {
+    Map<String, String> conf = Maps.newHashMap();
+    // Scope JDBC metadata lookups to the connected database via 
nullCatalogMeansCurrent=true.
+    // These tests deliberately place DB_A and DB_B on the SAME MySQL server 
(to produce two
+    // distinct backend URIs for the ClassLoader isolation assertion). 
Iceberg's JdbcCatalog
+    // checks whether its `iceberg_tables` control table already exists with
+    // getTables(catalog=null, schema=null, "iceberg_tables", null) before 
creating it. Under
+    // Connector/J's default (nullCatalogMeansCurrent=false) that call scans 
EVERY database on the
+    // server, so a catalog on DB_B would "see" DB_A's iceberg_tables, skip 
creating its own, and
+    // then fail real queries with "table doesn't exist". Setting 
nullCatalogMeansCurrent=true
+    // scopes the check to the current database. (This mirrors the recommended 
setting for running
+    // multiple Iceberg JDBC catalogs against separate databases of one MySQL 
server.)
+    conf.put(
+        IcebergConfig.CATALOG_URI.getKey(),
+        mySQLContainer.getJdbcUrl(db) + "?nullCatalogMeansCurrent=true");
+    conf.put(IcebergConfig.CATALOG_BACKEND.getKey(), "jdbc");
+    // Unique backend name per catalog so each catalog's JDBC namespace 
registry
+    // (Iceberg's iceberg_namespace_properties, keyed by the initialize() 
name) is isolated even
+    // when catalogs share a database. Without this, all catalogs default to 
backend name "jdbc"
+    // and would see each other's namespaces, causing cross-test contamination.
+    // `catalog-backend-name`
+    // is NOT in DEFAULT_ISOLATION_PROPERTY_KEYS, so catalogs on the same 
`uri` still share a
+    // ClassLoader (which is what the sharing/lifecycle assertions rely on).
+    conf.put(
+        IcebergConfig.CATALOG_BACKEND_NAME.getKey(), 
RandomNameUtils.genRandomName("clp_backend"));
+    // Unique warehouse dir per catalog to avoid stale /tmp state leaking 
across catalogs and runs.
+    conf.put(
+        IcebergConfig.CATALOG_WAREHOUSE.getKey(),
+        "file:///tmp/" + RandomNameUtils.genRandomName("iceberg_clp_wh"));
+    conf.put(IcebergConfig.JDBC_DRIVER.getKey(), 
mySQLContainer.getDriverClassName(db));
+    conf.put(GRAVITINO_JDBC_USER, mySQLContainer.getUsername());
+    conf.put(GRAVITINO_JDBC_PASSWORD, mySQLContainer.getPassword());
+    return conf;
+  }
+
+  private boolean isEmbedded() {
+    return ITUtils.EMBEDDED_TEST_MODE.equals(testMode);
+  }
+
+  private Object classLoaderOf(String metalake, String catalog) throws 
IllegalAccessException {
+    CatalogManager catalogManager = 
GravitinoEnv.getInstance().catalogManager();
+    // Use loadCatalogAndWrap (rather than the Caffeine cache directly) so 
this test module does not
+    // need caffeine on its compile classpath. The catalog was already loaded 
server-side above, so
+    // this returns the cached wrapper instance.
+    CatalogManager.CatalogWrapper wrapper =
+        catalogManager.loadCatalogAndWrap(NameIdentifier.of(metalake, 
catalog));
+    Assertions.assertNotNull(wrapper, "wrapper should be cached for " + 
metalake + "." + catalog);
+    return FieldUtils.readField(wrapper, "classLoader", true);
+  }
+
+  @Test
+  public void testSameUriShareAndDifferentUriIsolateClassLoader() throws 
Exception {
+    Assumptions.assumeTrue(
+        isEmbedded(),
+        "ClassLoader-identity assertions require the in-process server 
(embedded mode)");
+
+    String metalakeName = RandomNameUtils.genRandomName("clp_metalake");
+    GravitinoMetalake metalake = createMetalake(metalakeName);
+
+    // Two catalogs on the SAME uri (DB_A) must share one ClassLoader.
+    String sameA1 = RandomNameUtils.genRandomName("clp_same_a1");
+    String sameA2 = RandomNameUtils.genRandomName("clp_same_a2");
+    metalake.createCatalog(
+        sameA1, Catalog.Type.RELATIONAL, PROVIDER, "comment", 
icebergMysqlConf(DB_A));
+    metalake.createCatalog(
+        sameA2, Catalog.Type.RELATIONAL, PROVIDER, "comment", 
icebergMysqlConf(DB_A));
+
+    // Force server-side load so both wrappers are cached.
+    metalake.loadCatalog(sameA1);
+    metalake.loadCatalog(sameA2);
+
+    Assertions.assertSame(
+        classLoaderOf(metalakeName, sameA1),
+        classLoaderOf(metalakeName, sameA2),
+        "Iceberg catalogs sharing the same backend uri must share a 
ClassLoader");
+
+    // A catalog on a DIFFERENT uri (DB_B) must NOT share the ClassLoader — 
this is the `uri`
+    // isolation-key fix; without `uri` in DEFAULT_ISOLATION_PROPERTY_KEYS 
these would collide.
+    String otherB = RandomNameUtils.genRandomName("clp_other_b");
+    metalake.createCatalog(
+        otherB, Catalog.Type.RELATIONAL, PROVIDER, "comment", 
icebergMysqlConf(DB_B));
+    metalake.loadCatalog(otherB);
+
+    Assertions.assertNotSame(
+        classLoaderOf(metalakeName, sameA1),
+        classLoaderOf(metalakeName, otherB),
+        "Iceberg catalogs with different backend uris must NOT share a 
ClassLoader");
+  }
+
+  @Test
+  public void testDropOneSharedCatalogKeepsSiblingUsable() throws Exception {
+    String metalakeName = RandomNameUtils.genRandomName("clp_metalake");
+    GravitinoMetalake metalake = createMetalake(metalakeName);
+
+    String catalog1 = RandomNameUtils.genRandomName("clp_drop_1");
+    String catalog2 = RandomNameUtils.genRandomName("clp_drop_2");
+    metalake.createCatalog(
+        catalog1, Catalog.Type.RELATIONAL, PROVIDER, "comment", 
icebergMysqlConf(DB_A));
+    Catalog cat2 =
+        metalake.createCatalog(
+            catalog2, Catalog.Type.RELATIONAL, PROVIDER, "comment", 
icebergMysqlConf(DB_A));
+
+    // Exercise catalog2's real JDBC backend once so its driver is 
registered/used.
+    Assertions.assertEquals(0, cat2.asSchemas().listSchemas().length);
+
+    // Drop catalog1 — since it shares the pooled ClassLoader (and the MySQL 
driver /
+    // AbandonedConnectionCleanupThread) with catalog2, the final-release 
cleanup must NOT run.
+    metalake.disableCatalog(catalog1);
+    Assertions.assertTrue(metalake.dropCatalog(catalog1, true));
+
+    // Issue a REAL query against catalog2 after catalog1's drop. If the 
shared driver had been
+    // deregistered / the cleanup thread shut down, this JDBC call would fail.
+    Catalog reloaded = metalake.loadCatalog(catalog2);
+    String schemaName = RandomNameUtils.genRandomName("clp_schema");
+    Assertions.assertDoesNotThrow(
+        () -> reloaded.asSchemas().createSchema(schemaName, null, 
Collections.emptyMap()));
+    Assertions.assertTrue(reloaded.asSchemas().schemaExists(schemaName));
+  }
+
+  @Test
+  public void testTestConnectionKeepsLiveSharedCatalogUsable() throws 
Exception {
+    String metalakeName = RandomNameUtils.genRandomName("clp_metalake");
+    GravitinoMetalake metalake = createMetalake(metalakeName);
+
+    String liveCatalog = RandomNameUtils.genRandomName("clp_live");
+    Catalog live =
+        metalake.createCatalog(
+            liveCatalog, Catalog.Type.RELATIONAL, PROVIDER, "comment", 
icebergMysqlConf(DB_A));
+    Assertions.assertEquals(0, live.asSchemas().listSchemas().length);
+
+    // testConnection builds a throwaway wrapper that acquires + releases the 
SAME pooled key.
+    // The release must only decrement the refCount, not run the destructive 
final cleanup, because
+    // the live catalog still shares the ClassLoader / driver.
+    String probeCatalog = RandomNameUtils.genRandomName("clp_probe");
+    Assertions.assertDoesNotThrow(
+        () ->
+            metalake.testConnection(
+                probeCatalog,
+                Catalog.Type.RELATIONAL,
+                PROVIDER,
+                "comment",
+                icebergMysqlConf(DB_A)));
+
+    // The live catalog must still perform real JDBC work after the probe's 
acquire/release cycle.
+    Catalog reloaded = metalake.loadCatalog(liveCatalog);
+    String schemaName = RandomNameUtils.genRandomName("clp_schema");
+    Assertions.assertDoesNotThrow(
+        () -> reloaded.asSchemas().createSchema(schemaName, null, 
Collections.emptyMap()));
+    Assertions.assertTrue(reloaded.asSchemas().schemaExists(schemaName));
+  }
+}
diff --git 
a/catalogs/catalog-lakehouse-iceberg/src/test/java/org/apache/gravitino/catalog/lakehouse/iceberg/integration/test/TestMultipleJDBCLoad.java
 
b/catalogs/catalog-lakehouse-iceberg/src/test/java/org/apache/gravitino/catalog/lakehouse/iceberg/integration/test/TestMultipleJDBCLoad.java
index c2e5077c7b..ca9763efdb 100644
--- 
a/catalogs/catalog-lakehouse-iceberg/src/test/java/org/apache/gravitino/catalog/lakehouse/iceberg/integration/test/TestMultipleJDBCLoad.java
+++ 
b/catalogs/catalog-lakehouse-iceberg/src/test/java/org/apache/gravitino/catalog/lakehouse/iceberg/integration/test/TestMultipleJDBCLoad.java
@@ -76,7 +76,14 @@ public class TestMultipleJDBCLoad extends BaseIT {
             + "?user="
             + mySQLContainer.getUsername()
             + "&password="
-            + mySQLContainer.getPassword();
+            + mySQLContainer.getPassword()
+            // Scope Iceberg's iceberg_tables existence check to the current 
database. Other tests
+            // in this module (e.g. IcebergClassLoaderPoolIT) create Iceberg 
JDBC catalogs in other
+            // databases of the SAME shared MySQL container. Under 
Connector/J's default
+            // (nullCatalogMeansCurrent=false), Iceberg's getTables(null, 
null, "iceberg_tables",
+            // null) check would see those other databases' control tables and 
skip creating this
+            // catalog's own, breaking later queries. See 
IcebergClassLoaderPoolIT for details.
+            + "&nullCatalogMeansCurrent=true";
 
     icebergMysqlConf.put(IcebergConfig.CATALOG_URI.getKey(), jdbcUrl);
     icebergMysqlConf.put(IcebergConfig.CATALOG_BACKEND.getKey(), "jdbc");
@@ -121,7 +128,8 @@ public class TestMultipleJDBCLoad extends BaseIT {
     Map<String, String> icebergMysqlConf = Maps.newHashMap();
 
     icebergMysqlConf.put(
-        IcebergConfig.CATALOG_URI.getKey(), 
mySQLContainer.getJdbcUrl(TEST_DB_NAME));
+        IcebergConfig.CATALOG_URI.getKey(),
+        mySQLContainer.getJdbcUrl(TEST_DB_NAME) + 
"?nullCatalogMeansCurrent=true");
     icebergMysqlConf.put(IcebergConfig.CATALOG_BACKEND.getKey(), "jdbc");
     icebergMysqlConf.put(IcebergConfig.CATALOG_WAREHOUSE.getKey(), 
"file:///tmp/iceberg-jdbc");
     icebergMysqlConf.put(
diff --git 
a/catalogs/catalog-lakehouse-paimon/src/main/java/org/apache/gravitino/catalog/lakehouse/paimon/PaimonCatalogOperations.java
 
b/catalogs/catalog-lakehouse-paimon/src/main/java/org/apache/gravitino/catalog/lakehouse/paimon/PaimonCatalogOperations.java
index e3cc127615..bcfc07d0c9 100644
--- 
a/catalogs/catalog-lakehouse-paimon/src/main/java/org/apache/gravitino/catalog/lakehouse/paimon/PaimonCatalogOperations.java
+++ 
b/catalogs/catalog-lakehouse-paimon/src/main/java/org/apache/gravitino/catalog/lakehouse/paimon/PaimonCatalogOperations.java
@@ -70,7 +70,6 @@ import 
org.apache.gravitino.rel.expressions.distributions.Strategy;
 import org.apache.gravitino.rel.expressions.sorts.SortOrder;
 import org.apache.gravitino.rel.expressions.transforms.Transform;
 import org.apache.gravitino.rel.indexes.Index;
-import org.apache.gravitino.utils.ClassLoaderResourceCleanerUtils;
 import org.apache.gravitino.utils.MapUtils;
 import org.apache.gravitino.utils.PrincipalUtils;
 import org.apache.paimon.catalog.Catalog;
@@ -559,7 +558,6 @@ public class PaimonCatalogOperations
     if (paimonCatalogOps != null) {
       try {
         paimonCatalogOps.close();
-        
ClassLoaderResourceCleanerUtils.closeClassLoaderResource(this.getClass().getClassLoader());
       } catch (Exception e) {
         throw new RuntimeException(e);
       }
diff --git a/conf/gravitino.conf.template b/conf/gravitino.conf.template
index 8336246d6a..4517c07137 100644
--- a/conf/gravitino.conf.template
+++ b/conf/gravitino.conf.template
@@ -60,6 +60,10 @@ gravitino.entity.store.relational.jdbcPassword = gravitino
 # THE CONFIGURATION FOR Gravitino CATALOG
 # The interval in milliseconds to evict the catalog cache
 gravitino.catalog.cache.evictionIntervalMs = 3600000
+# Whether to share a ClassLoader across catalogs that have identical 
isolation-relevant properties.
+# When true (default), such catalogs reuse one ClassLoader, reducing Metaspace 
usage. Set to false
+# to give every catalog its own ClassLoader, as in releases before 2.0.0.
+gravitino.catalog.classloader.sharing.enabled = true
 
 # THE CONFIGURATION FOR Gravitino Entity Cache
 # Whether to enable the cached Entity store.
diff --git a/core/src/main/java/org/apache/gravitino/Configs.java 
b/core/src/main/java/org/apache/gravitino/Configs.java
index 134f36f2e5..08702ae734 100644
--- a/core/src/main/java/org/apache/gravitino/Configs.java
+++ b/core/src/main/java/org/apache/gravitino/Configs.java
@@ -220,6 +220,18 @@ public class Configs {
           .booleanConf()
           .createWithDefault(true);
 
+  public static final ConfigEntry<Boolean> CATALOG_CLASSLOADER_SHARING_ENABLED 
=
+      new ConfigBuilder("gravitino.catalog.classloader.sharing.enabled")
+          .doc(
+              "Whether to share ClassLoaders across catalogs with identical 
isolation-relevant "
+                  + "properties. When true (default), catalogs with the same 
isolation key reuse "
+                  + "a single ClassLoader, significantly reducing Metaspace 
memory usage. When "
+                  + "false, each catalog gets its own dedicated ClassLoader as 
in previous "
+                  + "releases.")
+          .version(ConfigConstants.VERSION_2_0_0)
+          .booleanConf()
+          .createWithDefault(true);
+
   public static final ConfigEntry<String> AUTHENTICATOR =
       new ConfigBuilder("gravitino.authenticator")
           .doc(
diff --git 
a/core/src/main/java/org/apache/gravitino/catalog/CatalogManager.java 
b/core/src/main/java/org/apache/gravitino/catalog/CatalogManager.java
index 75a8c0f72a..35d0c3fdb5 100644
--- a/core/src/main/java/org/apache/gravitino/catalog/CatalogManager.java
+++ b/core/src/main/java/org/apache/gravitino/catalog/CatalogManager.java
@@ -108,8 +108,11 @@ import org.apache.gravitino.rel.TableCatalog;
 import org.apache.gravitino.rel.ViewCatalog;
 import org.apache.gravitino.storage.IdGenerator;
 import org.apache.gravitino.storage.relational.SupportsEntityChangeLog;
+import org.apache.gravitino.utils.ClassLoaderKey;
+import org.apache.gravitino.utils.ClassLoaderPool;
 import org.apache.gravitino.utils.IsolatedClassLoader;
 import org.apache.gravitino.utils.NamespaceUtil;
+import org.apache.gravitino.utils.PooledClassLoaderEntry;
 import org.apache.gravitino.utils.PrincipalUtils;
 import org.apache.gravitino.utils.ThrowableFunction;
 import org.slf4j.Logger;
@@ -125,15 +128,53 @@ public class CatalogManager implements CatalogDispatcher, 
Closeable {
   private static final Set<String> CONTRIB_CATALOGS_TYPES =
       ImmutableSet.of("jdbc-oceanbase", "jdbc-clickhouse", "jdbc-hologres");
 
+  // Isolation property keys included in the ClassLoaderKey. They cover the 
catalog property
+  // dimensions that determine the classpath or anchor per-ClassLoader static 
state.
+  //
+  // Classpath dimensions:
+  //   - package: determines which JARs are loaded
+  //   - authorization-provider: determines which authorization plugin JARs 
are loaded
+  // Static-state dimensions:
+  //   - authentication.type/kerberos.principal/kerberos.keytab-uri: Hadoop 
UGI is per-ClassLoader
+  //   - metastore.uris: HiveConf static configuration space
+  //   - jdbc-url: JDBC DriverManager global registry per ClassLoader (JDBC 
catalogs)
+  //   - uri: backend URI for Iceberg/Paimon/Hudi catalogs. Their JDBC 
backends register drivers
+  //     under this key (not "jdbc-url") in the per-ClassLoader DriverManager 
registry, so it must
+  //     be an isolation dimension — otherwise two MySQL-backed Iceberg 
catalogs on different
+  //     databases would share one ClassLoader and cross-contaminate the 
driver registry.
+  //   - fs.defaultFS: Hadoop FileSystem.CACHE per ClassLoader
+  static final Set<String> DEFAULT_ISOLATION_PROPERTY_KEYS =
+      ImmutableSet.of(
+          Catalog.PROPERTY_PACKAGE,
+          Catalog.AUTHORIZATION_PROVIDER,
+          "authentication.type",
+          "authentication.kerberos.principal",
+          "authentication.kerberos.keytab-uri",
+          "metastore.uris",
+          "jdbc-url",
+          "uri",
+          "fs.defaultFS");
+
   /** Wrapper class for a catalog instance and its class loader. */
   public static class CatalogWrapper {
 
     private BaseCatalog catalog;
     private IsolatedClassLoader classLoader;
+    private ClassLoaderPool pool;
+    private PooledClassLoaderEntry poolEntry;
+    private boolean closed = false;
+
+    /** Non-pooled constructor: each catalog owns its ClassLoader exclusively. 
*/
+    CatalogWrapper(IsolatedClassLoader classLoader) {
+      this.classLoader = classLoader;
+    }
 
-    public CatalogWrapper(BaseCatalog catalog, IsolatedClassLoader 
classLoader) {
-      this.catalog = catalog;
+    /** Pooled constructor: ClassLoader is managed by the pool with reference 
counting. */
+    CatalogWrapper(
+        IsolatedClassLoader classLoader, ClassLoaderPool pool, 
PooledClassLoaderEntry poolEntry) {
       this.classLoader = classLoader;
+      this.pool = pool;
+      this.poolEntry = poolEntry;
     }
 
     public BaseCatalog catalog() {
@@ -243,7 +284,13 @@ public class CatalogManager implements CatalogDispatcher, 
Closeable {
       return classLoader.withClassLoader(cl -> catalog.capability());
     }
 
-    public void close() {
+    public synchronized void close() {
+      if (closed) {
+        // Idempotent: a second close() must not re-run pool release or 
classloader cleanup.
+        return;
+      }
+      closed = true;
+
       try {
         classLoader.withClassLoader(
             cl -> {
@@ -255,9 +302,18 @@ public class CatalogManager implements CatalogDispatcher, 
Closeable {
             });
       } catch (Exception e) {
         LOG.warn("Failed to close catalog", e);
+      } finally {
+        // Release the pool reference (or clean up the dedicated ClassLoader) 
in a finally so a
+        // failure while closing the catalog cannot permanently leak the 
pooled ClassLoader
+        // reference (close() is idempotent, so a retry would otherwise skip 
this).
+        if (poolEntry != null) {
+          pool.release(poolEntry);
+          poolEntry = null;
+        } else if (pool == null) {
+          // Non-pooled path (e.g., sharing disabled or 
CATALOG_LOAD_ISOLATED=false)
+          ClassLoaderPool.cleanupClassLoader(classLoader);
+        }
       }
-
-      classLoader.close();
     }
 
     private SupportsSchemas asSchemas() {
@@ -291,6 +347,10 @@ public class CatalogManager implements CatalogDispatcher, 
Closeable {
 
   private final Config config;
 
+  private final ClassLoaderPool classLoaderPool = new ClassLoaderPool();
+
+  private final boolean classLoaderSharingEnabled;
+
   @Getter private final Cache<NameIdentifier, CatalogWrapper> catalogCache;
 
   private final EntityStore store;
@@ -318,6 +378,7 @@ public class CatalogManager implements CatalogDispatcher, 
Closeable {
     this.config = config;
     this.store = store;
     this.idGenerator = idGenerator;
+    this.classLoaderSharingEnabled = 
config.get(Configs.CATALOG_CLASSLOADER_SHARING_ENABLED);
 
     long cacheEvictionIntervalInMs = 
config.get(Configs.CATALOG_CACHE_EVICTION_INTERVAL_MS);
     this.catalogCache =
@@ -371,6 +432,7 @@ public class CatalogManager implements CatalogDispatcher, 
Closeable {
       localMutationCounts.clear();
     }
     catalogCache.invalidateAll();
+    classLoaderPool.close();
   }
 
   /**
@@ -647,11 +709,15 @@ public class CatalogManager implements CatalogDispatcher, 
Closeable {
               .build();
 
       CatalogWrapper wrapper = createCatalogWrapper(dummyEntity, mergedConfig);
-      wrapper.doWithCatalogOps(
-          c -> {
-            c.testConnection(ident, type, provider, comment, mergedConfig);
-            return null;
-          });
+      try {
+        wrapper.doWithCatalogOps(
+            c -> {
+              c.testConnection(ident, type, provider, comment, mergedConfig);
+              return null;
+            });
+      } finally {
+        wrapper.close();
+      }
     } catch (GravitinoRuntimeException e) {
       throw e;
     } catch (Exception e) {
@@ -1123,34 +1189,85 @@ public class CatalogManager implements 
CatalogDispatcher, Closeable {
     Map<String, String> conf = entity.getProperties();
     String provider = entity.getProvider();
 
+    if (!classLoaderSharingEnabled) {
+      return createNonPooledCatalogWrapper(provider, conf, entity, 
propsToValidate);
+    }
+
+    ClassLoaderKey key = buildClassLoaderKey(provider, conf);
+    PooledClassLoaderEntry poolEntry =
+        classLoaderPool.acquire(key, () -> createClassLoader(provider, conf));
+    try {
+      CatalogWrapper wrapper =
+          initCatalogWrapper(
+              new CatalogWrapper(poolEntry.classLoader(), classLoaderPool, 
poolEntry),
+              entity,
+              propsToValidate);
+      return wrapper;
+    } catch (Exception e) {
+      classLoaderPool.release(poolEntry);
+      throw e;
+    }
+  }
+
+  private CatalogWrapper createNonPooledCatalogWrapper(
+      String provider,
+      Map<String, String> conf,
+      CatalogEntity entity,
+      @Nullable Map<String, String> propsToValidate) {
     IsolatedClassLoader classLoader = createClassLoader(provider, conf);
-    BaseCatalog<?> catalog = createBaseCatalog(classLoader, entity);
-
-    CatalogWrapper wrapper = new CatalogWrapper(catalog, classLoader);
-    // Validate catalog properties and initialize the config
-    classLoader.withClassLoader(
-        cl -> {
-          validatePropertyForCreate(catalog.catalogPropertiesMetadata(), 
propsToValidate);
-
-          // Call wrapper.catalog.properties() to make BaseCatalog#properties 
in IsolatedClassLoader
-          // not null. Why do we do this? Because wrapper.catalog.properties() 
needs to be called in
-          // the IsolatedClassLoader, as it needs to load the specific catalog 
class
-          // such as HiveCatalog or similar. To simplify, we will preload the 
value of properties
-          // so that AppClassLoader can get the value of properties.
-          wrapper.catalog.properties();
-          wrapper.catalog.capability();
-
-          // Eagerly initialize opted-in catalogs so a misconfiguration fails 
at create rather than
-          // on first use. The backend translates failures into the 
caller-error vs
-          // dependency-unavailable taxonomy; both types are forwarded by the 
passthrough below.
-          if (propsToValidate != null && 
wrapper.catalog.shouldValidateOnCreate()) {
-            wrapper.catalog.ops();
-          }
-          return null;
-        },
-        IllegalArgumentException.class,
-        ConnectionFailedException.class);
+    try {
+      return initCatalogWrapper(new CatalogWrapper(classLoader), entity, 
propsToValidate);
+    } catch (Exception e) {
+      ClassLoaderPool.cleanupClassLoader(classLoader);
+      throw e;
+    }
+  }
 
+  /**
+   * Creates the catalog instance, validates properties, and preloads 
property/capability values
+   * into the given wrapper.
+   */
+  private CatalogWrapper initCatalogWrapper(
+      CatalogWrapper wrapper, CatalogEntity entity, @Nullable Map<String, 
String> propsToValidate) {
+    BaseCatalog<?> catalog = createBaseCatalog(wrapper.classLoader, entity);
+    wrapper.catalog = catalog;
+    try {
+      wrapper.classLoader.withClassLoader(
+          cl -> {
+            validatePropertyForCreate(catalog.catalogPropertiesMetadata(), 
propsToValidate);
+            // Preload properties() and capability() inside the 
IsolatedClassLoader so that
+            // AppClassLoader can read them later without needing the isolated 
context.
+            catalog.properties();
+            catalog.capability();
+
+            // Eagerly initialize opted-in catalogs so a misconfiguration 
fails at create rather
+            // than on first use. The backend translates failures into the 
caller-error vs
+            // dependency-unavailable taxonomy; both types are forwarded by 
the passthrough below.
+            if (propsToValidate != null && catalog.shouldValidateOnCreate()) {
+              catalog.ops();
+            }
+            return null;
+          },
+          IllegalArgumentException.class,
+          ConnectionFailedException.class);
+    } catch (RuntimeException e) {
+      // Close the partially-initialized catalog (which releases its 
authorizationPlugin and other
+      // resources) before the caller tears down or releases the ClassLoader. 
Otherwise a creation
+      // failure — e.g. invalid properties on a catalog that has an 
authorization-provider — leaks
+      // the plugin instance, since the caller's catch only releases the 
ClassLoader, not the
+      // catalog.
+      try {
+        wrapper.classLoader.withClassLoader(
+            cl -> {
+              catalog.close();
+              return null;
+            });
+      } catch (Exception closeEx) {
+        LOG.warn("Failed to close catalog after initialization failure", 
closeEx);
+      }
+      wrapper.catalog = null;
+      throw e;
+    }
     return wrapper;
   }
 
@@ -1162,6 +1279,11 @@ public class CatalogManager implements 
CatalogDispatcher, Closeable {
    * @return The resolved properties.
    */
   private Map<String, String> getResolvedProperties(CatalogEntity entity) {
+    // Resolve properties through the cached wrapper (loadCatalogAndWrap), 
which reuses the
+    // pooled/dedicated ClassLoader and the CatalogWrapper cache. This avoids 
building and tearing
+    // down a throwaway BaseCatalog (and leaking its authorizationPlugin) on 
every listCatalogsInfo
+    // call, and keeps the classLoaderSharingEnabled branching in a single 
place
+    // (createCatalogWrapper).
     CatalogWrapper catalogWrapper = 
loadCatalogAndWrap(entity.nameIdentifier());
     return catalogWrapper.classLoader.withClassLoader(
         cl -> catalogWrapper.catalog.properties(), RuntimeException.class);
@@ -1175,6 +1297,19 @@ public class CatalogManager implements 
CatalogDispatcher, Closeable {
     return catalog;
   }
 
+  private ClassLoaderKey buildClassLoaderKey(String provider, Map<String, 
String> conf) {
+    Map<String, String> isolationProps = new HashMap<>();
+    if (conf != null) {
+      for (String key : DEFAULT_ISOLATION_PROPERTY_KEYS) {
+        String value = conf.get(key);
+        if (value != null) {
+          isolationProps.put(key, value);
+        }
+      }
+    }
+    return new ClassLoaderKey(provider, isolationProps.isEmpty() ? null : 
isolationProps);
+  }
+
   private IsolatedClassLoader createClassLoader(String provider, Map<String, 
String> conf) {
     if (config.get(Configs.CATALOG_LOAD_ISOLATED)) {
       String catalogPkgPath = buildPkgPath(conf, provider);
diff --git a/core/src/main/java/org/apache/gravitino/utils/ClassLoaderKey.java 
b/core/src/main/java/org/apache/gravitino/utils/ClassLoaderKey.java
new file mode 100644
index 0000000000..fbc5677c8e
--- /dev/null
+++ b/core/src/main/java/org/apache/gravitino/utils/ClassLoaderKey.java
@@ -0,0 +1,70 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.gravitino.utils;
+
+import java.util.Collections;
+import java.util.Map;
+import java.util.Objects;
+import java.util.TreeMap;
+import javax.annotation.Nullable;
+
+/**
+ * Key used to identify a shared ClassLoader in the {@link ClassLoaderPool}. 
Stores isolation
+ * properties as a generic {@code Map<String, String>}, decoupled from any 
specific property names.
+ * This makes the pool infrastructure key-agnostic — only the logic that 
builds the key (in {@code
+ * CatalogManager}) needs to know which properties matter.
+ */
+public class ClassLoaderKey {
+
+  private final String provider;
+  private final Map<String, String> properties;
+
+  /**
+   * Constructs a ClassLoaderKey.
+   *
+   * @param provider The catalog provider name (e.g., "hive", 
"lakehouse-iceberg").
+   * @param properties The isolation-relevant properties extracted from the 
catalog configuration,
+   *     or null if none.
+   */
+  public ClassLoaderKey(String provider, @Nullable Map<String, String> 
properties) {
+    this.provider = Objects.requireNonNull(provider, "provider must not be 
null");
+    this.properties =
+        properties == null
+            ? Collections.emptyMap()
+            : Collections.unmodifiableMap(new TreeMap<>(properties));
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (this == o) return true;
+    if (!(o instanceof ClassLoaderKey)) return false;
+    ClassLoaderKey that = (ClassLoaderKey) o;
+    return Objects.equals(provider, that.provider) && 
Objects.equals(properties, that.properties);
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(provider, properties);
+  }
+
+  @Override
+  public String toString() {
+    return "ClassLoaderKey{" + "provider='" + provider + '\'' + ", 
properties=" + properties + '}';
+  }
+}
diff --git a/core/src/main/java/org/apache/gravitino/utils/ClassLoaderPool.java 
b/core/src/main/java/org/apache/gravitino/utils/ClassLoaderPool.java
new file mode 100644
index 0000000000..fd376fb0e1
--- /dev/null
+++ b/core/src/main/java/org/apache/gravitino/utils/ClassLoaderPool.java
@@ -0,0 +1,216 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.gravitino.utils;
+
+import java.io.Closeable;
+import java.net.URLClassLoader;
+import java.sql.Driver;
+import java.sql.DriverManager;
+import java.util.Enumeration;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.function.Supplier;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * A pool that manages shared {@link IsolatedClassLoader} instances across 
catalogs with identical
+ * isolation-relevant properties (package, authorization provider, Kerberos 
identity, metastore
+ * URIs, JDBC URL, default filesystem). Sharing ClassLoaders across 
same-configuration catalogs
+ * significantly reduces Metaspace memory usage.
+ *
+ * <p>Thread safety is guaranteed through {@link ConcurrentHashMap#compute} 
for all acquire/release
+ * operations.
+ *
+ * <p><b>Concurrency note:</b> the classloader factory (JAR scanning in {@code 
acquire}) and {@link
+ * #doFinalCleanup} (thread interruption and reflective ThreadLocal cleanup in 
{@code release}) run
+ * inside {@code compute()}, which holds the per-key bin lock for the 
duration. This serializes
+ * create/drop of catalogs that map to the <em>same</em> key. Operations on 
different keys hash to
+ * different bins and proceed concurrently, so for the expected pool size (a 
handful of distinct
+ * configurations) the added latency is acceptable and keeps 
acquire/release/cleanup atomic.
+ */
+public class ClassLoaderPool implements Closeable {
+
+  private static final Logger LOG = 
LoggerFactory.getLogger(ClassLoaderPool.class);
+
+  private final ConcurrentHashMap<ClassLoaderKey, PooledClassLoaderEntry> pool 
=
+      new ConcurrentHashMap<>();
+
+  private final AtomicBoolean closed = new AtomicBoolean(false);
+
+  /**
+   * Acquires a ClassLoader entry for the given key. If an entry already 
exists, increments the
+   * reference count. Otherwise, creates a new entry using the provided 
factory.
+   *
+   * @param key The key identifying the ClassLoader configuration.
+   * @param factory A supplier that creates a new IsolatedClassLoader when 
needed.
+   * @return The pooled ClassLoader entry.
+   * @throws IllegalStateException if the pool has been closed.
+   */
+  public PooledClassLoaderEntry acquire(ClassLoaderKey key, 
Supplier<IsolatedClassLoader> factory) {
+    return pool.compute(
+        key,
+        (k, existing) -> {
+          if (closed.get()) {
+            throw new IllegalStateException("ClassLoaderPool is already 
closed");
+          }
+          if (existing != null) {
+            existing.incrementRefCount();
+            LOG.debug("Reusing ClassLoader for key {}, refCount={}.", key, 
existing.refCount());
+            return existing;
+          }
+          // If the factory throws (e.g., invalid classpath), the exception 
propagates to the
+          // caller and ConcurrentHashMap leaves the key unmapped.
+          IsolatedClassLoader classLoader = factory.get();
+          PooledClassLoaderEntry newEntry = new PooledClassLoaderEntry(k, 
classLoader);
+          LOG.info("Created new ClassLoader for key {}, refCount=1.", key);
+          return newEntry;
+        });
+  }
+
+  /**
+   * Releases a ClassLoader entry. Decrements the reference count and, if it 
reaches zero, performs
+   * final cleanup and removes the entry from the pool.
+   *
+   * @param entry The pooled ClassLoader entry to release.
+   */
+  public void release(PooledClassLoaderEntry entry) {
+    pool.compute(
+        entry.key(),
+        (k, existing) -> {
+          if (existing == null) {
+            LOG.warn("Attempted to release a ClassLoader entry that is not in 
the pool: {}", k);
+            return null;
+          }
+          int newCount = existing.decrementRefCount();
+          LOG.debug("Released ClassLoader for key {}, refCount={}.", k, 
newCount);
+          if (newCount <= 0) {
+            doFinalCleanup(existing);
+            return null; // Remove from map
+          }
+          return existing;
+        });
+  }
+
+  /**
+   * Returns the current number of distinct ClassLoader entries in the pool.
+   *
+   * @return The pool size.
+   */
+  public int size() {
+    return pool.size();
+  }
+
+  /**
+   * Closes the pool and cleans up all ClassLoader entries. After this method 
returns, {@link
+   * #acquire} will throw {@link IllegalStateException}. Uses per-key {@code 
compute()} to ensure
+   * atomic removal and cleanup, preventing races with concurrent release 
operations.
+   */
+  @Override
+  public void close() {
+    closed.set(true);
+    // Drain with a loop to catch entries inserted by concurrent acquire() 
calls that were
+    // already past the closed check when we set the flag. Since closed=true 
prevents any new
+    // entries from being created, this loop is guaranteed to terminate.
+    while (!pool.isEmpty()) {
+      pool.keySet().forEach(this::removeAndCleanup);
+    }
+  }
+
+  private void removeAndCleanup(ClassLoaderKey key) {
+    pool.compute(
+        key,
+        (k, existing) -> {
+          if (existing != null) {
+            if (existing.refCount() > 0) {
+              LOG.warn(
+                  "Force-closing ClassLoader for key {} with {} active 
reference(s)"
+                      + " (pool shutting down).",
+                  k,
+                  existing.refCount());
+            } else {
+              LOG.info("Closing pooled ClassLoader for key {}.", k);
+            }
+            doFinalCleanup(existing);
+          }
+          return null;
+        });
+  }
+
+  /**
+   * Performs final cleanup when a ClassLoader's reference count reaches zero. 
This includes:
+   *
+   * <ol>
+   *   <li>Deregistering all JDBC drivers loaded by the ClassLoader
+   *   <li>Cleaning up ClassLoader resources (ThreadLocals, Hadoop FileSystem, 
etc.)
+   *   <li>Closing the ClassLoader itself
+   * </ol>
+   */
+  private void doFinalCleanup(PooledClassLoaderEntry entry) {
+    if (!entry.markCleanedUp()) {
+      LOG.debug("ClassLoader for key {} already cleaned up, skipping.", 
entry.key());
+      return;
+    }
+
+    cleanupClassLoader(entry.classLoader());
+    LOG.info("ClassLoader for key {} has been fully cleaned up.", entry.key());
+  }
+
+  /**
+   * Performs full resource cleanup for an {@link IsolatedClassLoader}: 
deregisters JDBC drivers,
+   * cleans up ClassLoader-scoped resources (ThreadLocals, Hadoop FileSystem, 
etc.), and closes the
+   * ClassLoader.
+   *
+   * @param classLoader The IsolatedClassLoader to clean up.
+   */
+  public static void cleanupClassLoader(IsolatedClassLoader classLoader) {
+    try {
+      URLClassLoader internalCl = classLoader.getInternalClassLoader();
+      if (internalCl != null) {
+        deregisterAllDrivers(internalCl);
+        ClassLoaderResourceCleanerUtils.closeClassLoaderResource(internalCl);
+      }
+    } catch (Exception e) {
+      LOG.warn("Error during ClassLoader resource cleanup", e);
+    }
+    classLoader.close();
+  }
+
+  /**
+   * Deregisters all JDBC drivers that were loaded by the given ClassLoader.
+   *
+   * @param classLoader The ClassLoader whose drivers should be deregistered.
+   */
+  private static void deregisterAllDrivers(ClassLoader classLoader) {
+    // DriverManager.getDrivers() returns a snapshot in JDK 9+, so iterating 
while
+    // calling deregisterDriver() is safe.
+    Enumeration<Driver> drivers = DriverManager.getDrivers();
+    while (drivers.hasMoreElements()) {
+      Driver driver = drivers.nextElement();
+      if (driver.getClass().getClassLoader() == classLoader) {
+        try {
+          DriverManager.deregisterDriver(driver);
+          LOG.info("Deregistered JDBC driver {} for ClassLoader.", driver);
+        } catch (Exception e) {
+          LOG.warn("Failed to deregister JDBC driver {}", driver, e);
+        }
+      }
+    }
+  }
+}
diff --git 
a/core/src/main/java/org/apache/gravitino/utils/IsolatedClassLoader.java 
b/core/src/main/java/org/apache/gravitino/utils/IsolatedClassLoader.java
index 82eb7213e7..ef83295720 100644
--- a/core/src/main/java/org/apache/gravitino/utils/IsolatedClassLoader.java
+++ b/core/src/main/java/org/apache/gravitino/utils/IsolatedClassLoader.java
@@ -171,6 +171,16 @@ public class IsolatedClassLoader implements Closeable {
         classPathContents, Collections.emptyList(), Collections.emptyList());
   }
 
+  /**
+   * Returns the internal URLClassLoader used by this IsolatedClassLoader. 
This is used by {@link
+   * ClassLoaderPool} for resource cleanup (e.g., JDBC driver deregistration, 
ThreadLocal cleanup).
+   *
+   * @return The internal URLClassLoader, or null if not yet initialized.
+   */
+  public URLClassLoader getInternalClassLoader() {
+    return classLoader;
+  }
+
   /** Closes the class loader. */
   @Override
   public void close() {
diff --git 
a/core/src/main/java/org/apache/gravitino/utils/PooledClassLoaderEntry.java 
b/core/src/main/java/org/apache/gravitino/utils/PooledClassLoaderEntry.java
new file mode 100644
index 0000000000..eed5acdbe4
--- /dev/null
+++ b/core/src/main/java/org/apache/gravitino/utils/PooledClassLoaderEntry.java
@@ -0,0 +1,101 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.gravitino.utils;
+
+import com.google.common.annotations.VisibleForTesting;
+
+/**
+ * An entry in the {@link ClassLoaderPool} that holds a shared {@link 
IsolatedClassLoader} and
+ * tracks its reference count. When the reference count drops to zero, the 
ClassLoader can be safely
+ * cleaned up and removed from the pool.
+ *
+ * <p><b>Threading contract:</b> {@link #incrementRefCount()} and {@link 
#decrementRefCount()} must
+ * only be called inside {@link 
java.util.concurrent.ConcurrentHashMap#compute}, which serializes
+ * access per key. A plain {@code int} is used instead of {@code 
AtomicInteger} to make this
+ * contract explicit — misuse outside {@code compute()} will produce data 
races that are immediately
+ * visible rather than silently incorrect.
+ */
+public class PooledClassLoaderEntry {
+
+  private final ClassLoaderKey key;
+  private final IsolatedClassLoader classLoader;
+  private int refCount = 1;
+  private boolean cleanedUp = false;
+
+  /**
+   * Constructs a PooledClassLoaderEntry.
+   *
+   * @param key The key identifying this ClassLoader in the pool.
+   * @param classLoader The isolated ClassLoader instance.
+   */
+  public PooledClassLoaderEntry(ClassLoaderKey key, IsolatedClassLoader 
classLoader) {
+    this.key = key;
+    this.classLoader = classLoader;
+  }
+
+  /** Returns the key identifying this ClassLoader in the pool. */
+  public ClassLoaderKey key() {
+    return key;
+  }
+
+  /** Returns the isolated ClassLoader instance. */
+  public IsolatedClassLoader classLoader() {
+    return classLoader;
+  }
+
+  /**
+   * Returns the current reference count. Note: outside of {@code 
ConcurrentHashMap.compute()}, the
+   * returned value may be stale.
+   */
+  @VisibleForTesting
+  int refCount() {
+    return refCount;
+  }
+
+  /**
+   * Increments the reference count and returns the new value. Must only be 
called inside {@link
+   * java.util.concurrent.ConcurrentHashMap#compute}.
+   */
+  int incrementRefCount() {
+    return ++refCount;
+  }
+
+  /**
+   * Decrements the reference count and returns the new value. Must only be 
called inside {@link
+   * java.util.concurrent.ConcurrentHashMap#compute}.
+   */
+  int decrementRefCount() {
+    if (refCount <= 0) {
+      throw new IllegalStateException("Cannot decrement refCount below zero 
for key: " + key);
+    }
+    return --refCount;
+  }
+
+  /**
+   * Marks this entry as cleaned up. Returns {@code true} if this is the first 
call, {@code false}
+   * if cleanup was already performed. Used as a defense-in-depth guard 
against double cleanup.
+   */
+  boolean markCleanedUp() {
+    if (cleanedUp) {
+      return false;
+    }
+    cleanedUp = true;
+    return true;
+  }
+}
diff --git 
a/core/src/test/java/org/apache/gravitino/catalog/TestClassLoaderPoolIntegration.java
 
b/core/src/test/java/org/apache/gravitino/catalog/TestClassLoaderPoolIntegration.java
new file mode 100644
index 0000000000..6e59027004
--- /dev/null
+++ 
b/core/src/test/java/org/apache/gravitino/catalog/TestClassLoaderPoolIntegration.java
@@ -0,0 +1,349 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.gravitino.catalog;
+
+import com.google.common.collect.ImmutableMap;
+import java.io.IOException;
+import java.time.Instant;
+import java.util.Map;
+import org.apache.commons.lang3.reflect.FieldUtils;
+import org.apache.gravitino.Catalog;
+import org.apache.gravitino.Config;
+import org.apache.gravitino.Configs;
+import org.apache.gravitino.GravitinoEnv;
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.Namespace;
+import org.apache.gravitino.Schema;
+import org.apache.gravitino.lock.LockManager;
+import org.apache.gravitino.meta.AuditInfo;
+import org.apache.gravitino.meta.BaseMetalake;
+import org.apache.gravitino.meta.SchemaVersion;
+import org.apache.gravitino.storage.RandomIdGenerator;
+import org.apache.gravitino.storage.memory.TestMemoryEntityStore;
+import 
org.apache.gravitino.storage.memory.TestMemoryEntityStore.InMemoryEntityStore;
+import org.apache.gravitino.utils.ClassLoaderPool;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Integration tests for ClassLoaderPool with CatalogManager. Tests that 
same-type catalogs share a
+ * ClassLoader and that closing one catalog does not affect others of the same 
type.
+ */
+public class TestClassLoaderPoolIntegration {
+
+  private static CatalogManager catalogManager;
+  private static InMemoryEntityStore entityStore;
+  private static Config config;
+  private static final String METALAKE = "metalake";
+  private static final String PROVIDER = "test";
+
+  private static BaseMetalake metalakeEntity =
+      BaseMetalake.builder()
+          .withId(1L)
+          .withName(METALAKE)
+          .withAuditInfo(
+              
AuditInfo.builder().withCreator("test").withCreateTime(Instant.now()).build())
+          .withVersion(SchemaVersion.V_0_1)
+          .build();
+
+  @BeforeAll
+  public static void setUp() throws IOException, IllegalAccessException {
+    config = new Config(false) {};
+    config.set(Configs.CATALOG_LOAD_ISOLATED, false);
+
+    entityStore = new TestMemoryEntityStore.InMemoryEntityStore();
+    entityStore.initialize(config);
+    entityStore.put(metalakeEntity, true);
+
+    FieldUtils.writeField(GravitinoEnv.getInstance(), "lockManager", new 
LockManager(config), true);
+  }
+
+  @AfterAll
+  public static void tearDown() throws IOException {
+    if (catalogManager != null) {
+      catalogManager.close();
+    }
+    if (entityStore != null) {
+      entityStore.close();
+    }
+  }
+
+  @BeforeEach
+  public void beforeEach() throws IOException {
+    catalogManager = new CatalogManager(config, entityStore, new 
RandomIdGenerator());
+  }
+
+  @AfterEach
+  public void afterEach() throws IOException {
+    if (catalogManager != null) {
+      catalogManager.close();
+    }
+    entityStore.clear();
+    entityStore.put(metalakeEntity, true);
+  }
+
+  @Test
+  public void testSameTypeCatalogsShareClassLoader() throws 
IllegalAccessException {
+    Map<String, String> props =
+        ImmutableMap.of("key1", "value1", "key2", "value2", "key5-1", 
"value3");
+
+    Catalog catalog1 =
+        catalogManager.createCatalog(
+            NameIdentifier.of(METALAKE, "catalog1"),
+            Catalog.Type.RELATIONAL,
+            PROVIDER,
+            "test catalog 1",
+            props);
+    Catalog catalog2 =
+        catalogManager.createCatalog(
+            NameIdentifier.of(METALAKE, "catalog2"),
+            Catalog.Type.RELATIONAL,
+            PROVIDER,
+            "test catalog 2",
+            props);
+
+    Assertions.assertNotNull(catalog1);
+    Assertions.assertNotNull(catalog2);
+
+    // Both catalogs should use the same provider which means they share 
ClassLoader
+    CatalogManager.CatalogWrapper wrapper1 =
+        
catalogManager.getCatalogCache().getIfPresent(NameIdentifier.of(METALAKE, 
"catalog1"));
+    CatalogManager.CatalogWrapper wrapper2 =
+        
catalogManager.getCatalogCache().getIfPresent(NameIdentifier.of(METALAKE, 
"catalog2"));
+
+    Assertions.assertNotNull(wrapper1);
+    Assertions.assertNotNull(wrapper2);
+
+    // Verify they actually share the same ClassLoader instance
+    Object classLoader1 = FieldUtils.readField(wrapper1, "classLoader", true);
+    Object classLoader2 = FieldUtils.readField(wrapper2, "classLoader", true);
+    Assertions.assertSame(
+        classLoader1, classLoader2, "Same-type catalogs should share a 
ClassLoader");
+  }
+
+  @Test
+  public void testClosingOneCatalogDoesNotAffectOthers() {
+    Map<String, String> props =
+        ImmutableMap.of("key1", "value1", "key2", "value2", "key5-1", 
"value3");
+
+    catalogManager.createCatalog(
+        NameIdentifier.of(METALAKE, "catalog1"),
+        Catalog.Type.RELATIONAL,
+        PROVIDER,
+        "test catalog 1",
+        props);
+    catalogManager.createCatalog(
+        NameIdentifier.of(METALAKE, "catalog2"),
+        Catalog.Type.RELATIONAL,
+        PROVIDER,
+        "test catalog 2",
+        props);
+
+    // Drop catalog1 (shares the pooled ClassLoader with catalog2). Releasing 
catalog1 must not
+    // trigger the final-release cleanup while catalog2 still holds a 
reference.
+    catalogManager.disableCatalog(NameIdentifier.of(METALAKE, "catalog1"));
+    catalogManager.dropCatalog(NameIdentifier.of(METALAKE, "catalog1"));
+
+    // catalog2 must still be functionally usable after catalog1's drop. Note: 
with
+    // CATALOG_LOAD_ISOLATED=false the ClassLoader is an empty in-process 
loader, so this is an
+    // end-to-end smoke check that the shared wrapper is not broken by the 
drop. The real proof that
+    // the shared ClassLoader/driver is not prematurely cleaned up (which only 
matters with real
+    // provider jars) lives in the docker-tagged IcebergClassLoaderPoolIT.
+    NameIdentifier catalog2 = NameIdentifier.of(METALAKE, "catalog2");
+    Schema schema =
+        Assertions.assertDoesNotThrow(
+            () ->
+                catalogManager
+                    .loadCatalogAndWrap(catalog2)
+                    .doWithSchemaOps(
+                        schemaOps ->
+                            schemaOps.createSchema(
+                                NameIdentifier.of(METALAKE, "catalog2", 
"schema1"),
+                                "comment",
+                                ImmutableMap.of())));
+    Assertions.assertNotNull(schema);
+    Assertions.assertDoesNotThrow(
+        () ->
+            catalogManager
+                .loadCatalogAndWrap(catalog2)
+                .doWithSchemaOps(
+                    schemaOps -> schemaOps.listSchemas(Namespace.of(METALAKE, 
"catalog2"))));
+  }
+
+  @Test
+  public void testTestConnectionDoesNotBreakLiveCatalogSharingSameKey()
+      throws IllegalAccessException {
+    Map<String, String> props =
+        ImmutableMap.of("key1", "value1", "key2", "value2", "key5-1", 
"value3");
+
+    // A live catalog holds a reference to the pooled ClassLoader for this key.
+    NameIdentifier liveIdent = NameIdentifier.of(METALAKE, "live_catalog");
+    catalogManager.createCatalog(
+        liveIdent, Catalog.Type.RELATIONAL, PROVIDER, "live catalog", props);
+
+    CatalogManager.CatalogWrapper liveWrapper =
+        catalogManager.getCatalogCache().getIfPresent(liveIdent);
+    Assertions.assertNotNull(liveWrapper);
+
+    ClassLoaderPool pool =
+        (ClassLoaderPool) FieldUtils.readField(catalogManager, 
"classLoaderPool", true);
+    Assertions.assertEquals(1, pool.size(), "one pooled entry for the shared 
key");
+
+    // testConnection creates a throwaway wrapper that acquires and then 
releases the same key
+    // synchronously (acquire on create, release in its finally). The release 
must only decrement
+    // the reference count, not run final cleanup, because the live catalog 
still shares the key.
+    Assertions.assertDoesNotThrow(
+        () ->
+            catalogManager.testConnection(
+                NameIdentifier.of(METALAKE, "probe_catalog"),
+                Catalog.Type.RELATIONAL,
+                PROVIDER,
+                "probe",
+                props));
+
+    // Strong, non-racy check: testConnection's acquire+release is 
synchronous, so the pooled entry
+    // for the live catalog's key must survive (size stays 1). A refCount that 
wrongly hit 0 would
+    // have removed the entry (size 0) and destroyed the live catalog's 
ClassLoader.
+    Assertions.assertEquals(
+        1, pool.size(), "shared-key entry must survive testConnection's 
acquire/release");
+
+    // The live catalog must remain usable after testConnection's 
acquire/release cycle.
+    Assertions.assertDoesNotThrow(
+        () ->
+            catalogManager
+                .loadCatalogAndWrap(liveIdent)
+                .doWithSchemaOps(
+                    schemaOps -> schemaOps.listSchemas(Namespace.of(METALAKE, 
"live_catalog"))));
+  }
+
+  @Test
+  public void testSharingDisabledEachCatalogGetsOwnClassLoader() throws 
IllegalAccessException {
+    Config noSharingConfig = new Config(false) {};
+    noSharingConfig.set(Configs.CATALOG_LOAD_ISOLATED, false);
+    noSharingConfig.set(Configs.CATALOG_CLASSLOADER_SHARING_ENABLED, false);
+
+    CatalogManager noSharingManager =
+        new CatalogManager(noSharingConfig, entityStore, new 
RandomIdGenerator());
+    try {
+      Map<String, String> props =
+          ImmutableMap.of("key1", "value1", "key2", "value2", "key5-1", 
"value3");
+
+      noSharingManager.createCatalog(
+          NameIdentifier.of(METALAKE, "catalog1"),
+          Catalog.Type.RELATIONAL,
+          PROVIDER,
+          "test catalog 1",
+          props);
+      noSharingManager.createCatalog(
+          NameIdentifier.of(METALAKE, "catalog2"),
+          Catalog.Type.RELATIONAL,
+          PROVIDER,
+          "test catalog 2",
+          props);
+
+      CatalogManager.CatalogWrapper wrapper1 =
+          
noSharingManager.getCatalogCache().getIfPresent(NameIdentifier.of(METALAKE, 
"catalog1"));
+      CatalogManager.CatalogWrapper wrapper2 =
+          
noSharingManager.getCatalogCache().getIfPresent(NameIdentifier.of(METALAKE, 
"catalog2"));
+
+      Assertions.assertNotNull(wrapper1);
+      Assertions.assertNotNull(wrapper2);
+
+      // With sharing disabled, each catalog should have its own ClassLoader 
instance
+      Object classLoader1 = FieldUtils.readField(wrapper1, "classLoader", 
true);
+      Object classLoader2 = FieldUtils.readField(wrapper2, "classLoader", 
true);
+      Assertions.assertNotSame(
+          classLoader1,
+          classLoader2,
+          "With sharing disabled, catalogs should NOT share a ClassLoader");
+
+      // Non-pooled wrappers should not hold a pool reference
+      Object pool1 = FieldUtils.readField(wrapper1, "pool", true);
+      Assertions.assertNull(pool1, "Non-pooled wrapper should not reference 
the pool");
+    } finally {
+      noSharingManager.close();
+    }
+  }
+
+  @Test
+  public void testSharingDisabledDropOneCatalogDoesNotAffectOther() {
+    Config noSharingConfig = new Config(false) {};
+    noSharingConfig.set(Configs.CATALOG_LOAD_ISOLATED, false);
+    noSharingConfig.set(Configs.CATALOG_CLASSLOADER_SHARING_ENABLED, false);
+
+    CatalogManager noSharingManager =
+        new CatalogManager(noSharingConfig, entityStore, new 
RandomIdGenerator());
+    try {
+      Map<String, String> props =
+          ImmutableMap.of("key1", "value1", "key2", "value2", "key5-1", 
"value3");
+
+      noSharingManager.createCatalog(
+          NameIdentifier.of(METALAKE, "catalog1"),
+          Catalog.Type.RELATIONAL,
+          PROVIDER,
+          "test catalog 1",
+          props);
+      noSharingManager.createCatalog(
+          NameIdentifier.of(METALAKE, "catalog2"),
+          Catalog.Type.RELATIONAL,
+          PROVIDER,
+          "test catalog 2",
+          props);
+
+      // Drop catalog1
+      noSharingManager.disableCatalog(NameIdentifier.of(METALAKE, "catalog1"));
+      noSharingManager.dropCatalog(NameIdentifier.of(METALAKE, "catalog1"));
+
+      // catalog2 should still be loadable
+      Catalog loaded =
+          Assertions.assertDoesNotThrow(
+              () -> noSharingManager.loadCatalog(NameIdentifier.of(METALAKE, 
"catalog2")));
+      Assertions.assertNotNull(loaded);
+    } finally {
+      noSharingManager.close();
+    }
+  }
+
+  @Test
+  public void testClassLoaderPoolCleanupOnManagerClose() throws 
IllegalAccessException {
+    Map<String, String> props =
+        ImmutableMap.of("key1", "value1", "key2", "value2", "key5-1", 
"value3");
+
+    catalogManager.createCatalog(
+        NameIdentifier.of(METALAKE, "catalog1"),
+        Catalog.Type.RELATIONAL,
+        PROVIDER,
+        "test catalog 1",
+        props);
+
+    ClassLoaderPool pool =
+        (ClassLoaderPool) FieldUtils.readField(catalogManager, 
"classLoaderPool", true);
+    Assertions.assertNotNull(pool);
+
+    catalogManager.close();
+
+    // After close, the pool should be empty
+    Assertions.assertEquals(0, pool.size());
+    catalogManager = null;
+  }
+}
diff --git 
a/core/src/test/java/org/apache/gravitino/utils/TestClassLoaderPool.java 
b/core/src/test/java/org/apache/gravitino/utils/TestClassLoaderPool.java
new file mode 100644
index 0000000000..7af4836002
--- /dev/null
+++ b/core/src/test/java/org/apache/gravitino/utils/TestClassLoaderPool.java
@@ -0,0 +1,428 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.gravitino.utils;
+
+import com.google.common.collect.ImmutableMap;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CyclicBarrier;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+public class TestClassLoaderPool {
+
+  private IsolatedClassLoader createDummyClassLoader() {
+    return new IsolatedClassLoader(
+        Collections.emptyList(), Collections.emptyList(), 
Collections.emptyList());
+  }
+
+  private ClassLoaderKey simpleKey(String provider) {
+    return new ClassLoaderKey(provider, null);
+  }
+
+  @Test
+  public void testAcquireFirstTimeCreatesNewEntry() {
+    try (ClassLoaderPool pool = new ClassLoaderPool()) {
+      ClassLoaderKey key = simpleKey("iceberg");
+      PooledClassLoaderEntry entry = pool.acquire(key, 
this::createDummyClassLoader);
+
+      Assertions.assertNotNull(entry);
+      Assertions.assertEquals(1, entry.refCount());
+      Assertions.assertNotNull(entry.classLoader());
+      Assertions.assertEquals(1, pool.size());
+    }
+  }
+
+  @Test
+  public void testAcquireSameKeyReusesEntry() {
+    try (ClassLoaderPool pool = new ClassLoaderPool()) {
+      ClassLoaderKey key = simpleKey("iceberg");
+      PooledClassLoaderEntry entry1 = pool.acquire(key, 
this::createDummyClassLoader);
+      PooledClassLoaderEntry entry2 = pool.acquire(key, 
this::createDummyClassLoader);
+
+      Assertions.assertSame(entry1, entry2);
+      Assertions.assertEquals(2, entry1.refCount());
+      Assertions.assertEquals(1, pool.size());
+    }
+  }
+
+  @Test
+  public void testDifferentKeysCreateDifferentEntries() {
+    try (ClassLoaderPool pool = new ClassLoaderPool()) {
+      ClassLoaderKey key1 = simpleKey("iceberg");
+      ClassLoaderKey key2 = simpleKey("hive");
+
+      PooledClassLoaderEntry entry1 = pool.acquire(key1, 
this::createDummyClassLoader);
+      PooledClassLoaderEntry entry2 = pool.acquire(key2, 
this::createDummyClassLoader);
+
+      Assertions.assertNotSame(entry1, entry2);
+      Assertions.assertEquals(1, entry1.refCount());
+      Assertions.assertEquals(1, entry2.refCount());
+      Assertions.assertEquals(2, pool.size());
+    }
+  }
+
+  @Test
+  public void testReleaseDecrementsRefCount() {
+    try (ClassLoaderPool pool = new ClassLoaderPool()) {
+      ClassLoaderKey key = simpleKey("iceberg");
+      PooledClassLoaderEntry entry = pool.acquire(key, 
this::createDummyClassLoader);
+      pool.acquire(key, this::createDummyClassLoader);
+      Assertions.assertEquals(2, entry.refCount());
+
+      pool.release(entry);
+      Assertions.assertEquals(1, entry.refCount());
+      Assertions.assertEquals(1, pool.size());
+    }
+  }
+
+  @Test
+  public void testReleaseToZeroRemovesEntry() {
+    try (ClassLoaderPool pool = new ClassLoaderPool()) {
+      ClassLoaderKey key = simpleKey("iceberg");
+      PooledClassLoaderEntry entry = pool.acquire(key, 
this::createDummyClassLoader);
+      Assertions.assertEquals(1, pool.size());
+
+      pool.release(entry);
+      Assertions.assertEquals(0, pool.size());
+    }
+  }
+
+  @Test
+  public void testDoubleReleaseIsHandledGracefully() {
+    try (ClassLoaderPool pool = new ClassLoaderPool()) {
+      ClassLoaderKey key = simpleKey("iceberg");
+      PooledClassLoaderEntry entry = pool.acquire(key, 
this::createDummyClassLoader);
+
+      pool.release(entry); // refCount 1 -> 0, entry removed
+      Assertions.assertEquals(0, pool.size());
+
+      // Second release: entry is already removed from the pool, should log a 
warning
+      // but not throw or corrupt state
+      Assertions.assertDoesNotThrow(() -> pool.release(entry));
+      Assertions.assertEquals(0, pool.size());
+
+      // Verify the key can be re-acquired cleanly with no corrupted state
+      PooledClassLoaderEntry fresh = pool.acquire(key, 
this::createDummyClassLoader);
+      Assertions.assertNotNull(fresh);
+      Assertions.assertEquals(1, fresh.refCount());
+      Assertions.assertEquals(1, pool.size());
+    }
+  }
+
+  @Test
+  public void testReleaseOneDoesNotAffectOtherSameType() {
+    try (ClassLoaderPool pool = new ClassLoaderPool()) {
+      ClassLoaderKey key = simpleKey("iceberg");
+      PooledClassLoaderEntry entryA = pool.acquire(key, 
this::createDummyClassLoader);
+      pool.acquire(key, this::createDummyClassLoader); // entryB shares same 
entry
+      Assertions.assertEquals(2, entryA.refCount());
+
+      pool.release(entryA);
+      Assertions.assertEquals(1, entryA.refCount());
+      Assertions.assertEquals(1, pool.size());
+    }
+  }
+
+  @Test
+  public void testConcurrentAcquireAndRelease() throws Exception {
+    ClassLoaderPool pool = new ClassLoaderPool();
+    ClassLoaderKey key = simpleKey("iceberg");
+    int threadCount = 20;
+    CyclicBarrier barrier = new CyclicBarrier(threadCount);
+    ExecutorService executor = Executors.newFixedThreadPool(threadCount);
+
+    List<Future<PooledClassLoaderEntry>> futures = new ArrayList<>();
+    for (int i = 0; i < threadCount; i++) {
+      futures.add(
+          executor.submit(
+              () -> {
+                barrier.await();
+                return pool.acquire(key, this::createDummyClassLoader);
+              }));
+    }
+
+    List<PooledClassLoaderEntry> entries = new ArrayList<>();
+    for (Future<PooledClassLoaderEntry> future : futures) {
+      entries.add(future.get(5, TimeUnit.SECONDS));
+    }
+
+    // All should reference the same entry
+    PooledClassLoaderEntry first = entries.get(0);
+    for (PooledClassLoaderEntry entry : entries) {
+      Assertions.assertSame(first, entry);
+    }
+    Assertions.assertEquals(threadCount, first.refCount());
+    Assertions.assertEquals(1, pool.size());
+
+    // Now release all concurrently
+    CyclicBarrier releaseBarrier = new CyclicBarrier(threadCount);
+    List<Future<?>> releaseFutures = new ArrayList<>();
+    for (int i = 0; i < threadCount; i++) {
+      releaseFutures.add(
+          executor.submit(
+              () -> {
+                releaseBarrier.await();
+                pool.release(first);
+                return null;
+              }));
+    }
+
+    for (Future<?> future : releaseFutures) {
+      future.get(5, TimeUnit.SECONDS);
+    }
+
+    Assertions.assertEquals(0, pool.size());
+
+    executor.shutdown();
+    pool.close();
+  }
+
+  @Test
+  public void testCloseCleanupAllEntries() {
+    ClassLoaderPool pool = new ClassLoaderPool();
+    ClassLoaderKey key1 = simpleKey("iceberg");
+    ClassLoaderKey key2 = simpleKey("hive");
+    pool.acquire(key1, this::createDummyClassLoader);
+    pool.acquire(key2, this::createDummyClassLoader);
+    Assertions.assertEquals(2, pool.size());
+
+    pool.close();
+    Assertions.assertEquals(0, pool.size());
+  }
+
+  @Test
+  public void testKeyWithPackageProperty() {
+    try (ClassLoaderPool pool = new ClassLoaderPool()) {
+      ClassLoaderKey key1 = new ClassLoaderKey("iceberg", 
ImmutableMap.of("package", "/path/a"));
+      ClassLoaderKey key2 = new ClassLoaderKey("iceberg", 
ImmutableMap.of("package", "/path/b"));
+
+      PooledClassLoaderEntry entry1 = pool.acquire(key1, 
this::createDummyClassLoader);
+      PooledClassLoaderEntry entry2 = pool.acquire(key2, 
this::createDummyClassLoader);
+
+      Assertions.assertNotSame(entry1, entry2);
+      Assertions.assertEquals(2, pool.size());
+    }
+  }
+
+  @Test
+  public void testKeyWithAuthorizationProvider() {
+    try (ClassLoaderPool pool = new ClassLoaderPool()) {
+      ClassLoaderKey key1 =
+          new ClassLoaderKey("iceberg", 
ImmutableMap.of("authorization-provider", "ranger"));
+      ClassLoaderKey key2 = simpleKey("iceberg");
+
+      PooledClassLoaderEntry entry1 = pool.acquire(key1, 
this::createDummyClassLoader);
+      PooledClassLoaderEntry entry2 = pool.acquire(key2, 
this::createDummyClassLoader);
+
+      Assertions.assertNotSame(entry1, entry2);
+      Assertions.assertEquals(2, pool.size());
+    }
+  }
+
+  @Test
+  public void testDifferentKerberosPrincipalsCreateDifferentEntries() {
+    try (ClassLoaderPool pool = new ClassLoaderPool()) {
+      Map<String, String> props1 =
+          ImmutableMap.of(
+              "authentication.type", "kerberos",
+              "authentication.kerberos.principal", "user-A@REALM",
+              "authentication.kerberos.keytab-uri", "/keytabs/a.keytab");
+      Map<String, String> props2 =
+          ImmutableMap.of(
+              "authentication.type", "kerberos",
+              "authentication.kerberos.principal", "user-B@REALM",
+              "authentication.kerberos.keytab-uri", "/keytabs/b.keytab");
+
+      ClassLoaderKey key1 = new ClassLoaderKey("iceberg", props1);
+      ClassLoaderKey key2 = new ClassLoaderKey("iceberg", props2);
+      ClassLoaderKey key3 = simpleKey("iceberg");
+
+      PooledClassLoaderEntry entry1 = pool.acquire(key1, 
this::createDummyClassLoader);
+      PooledClassLoaderEntry entry2 = pool.acquire(key2, 
this::createDummyClassLoader);
+      PooledClassLoaderEntry entry3 = pool.acquire(key3, 
this::createDummyClassLoader);
+
+      Assertions.assertNotSame(entry1, entry2);
+      Assertions.assertNotSame(entry1, entry3);
+      Assertions.assertNotSame(entry2, entry3);
+      Assertions.assertEquals(3, pool.size());
+    }
+  }
+
+  @Test
+  public void testSameKerberosPrincipalSharesEntry() {
+    try (ClassLoaderPool pool = new ClassLoaderPool()) {
+      Map<String, String> props =
+          ImmutableMap.of(
+              "authentication.type", "kerberos",
+              "authentication.kerberos.principal", "user-A@REALM",
+              "authentication.kerberos.keytab-uri", "/keytabs/a.keytab");
+
+      ClassLoaderKey key1 = new ClassLoaderKey("iceberg", props);
+      ClassLoaderKey key2 = new ClassLoaderKey("iceberg", props);
+
+      PooledClassLoaderEntry entry1 = pool.acquire(key1, 
this::createDummyClassLoader);
+      PooledClassLoaderEntry entry2 = pool.acquire(key2, 
this::createDummyClassLoader);
+
+      Assertions.assertSame(entry1, entry2);
+      Assertions.assertEquals(2, entry1.refCount());
+      Assertions.assertEquals(1, pool.size());
+    }
+  }
+
+  @Test
+  public void testDifferentMetastoreUrisCreateDifferentEntries() {
+    try (ClassLoaderPool pool = new ClassLoaderPool()) {
+      ClassLoaderKey key1 =
+          new ClassLoaderKey(
+              "lakehouse-iceberg", ImmutableMap.of("metastore.uris", 
"thrift://hms-A:9083"));
+      ClassLoaderKey key2 =
+          new ClassLoaderKey(
+              "lakehouse-iceberg", ImmutableMap.of("metastore.uris", 
"thrift://hms-B:9083"));
+
+      PooledClassLoaderEntry entry1 = pool.acquire(key1, 
this::createDummyClassLoader);
+      PooledClassLoaderEntry entry2 = pool.acquire(key2, 
this::createDummyClassLoader);
+
+      Assertions.assertNotSame(entry1, entry2);
+      Assertions.assertEquals(2, pool.size());
+    }
+  }
+
+  @Test
+  public void testSameMetastoreUrisShareEntry() {
+    try (ClassLoaderPool pool = new ClassLoaderPool()) {
+      ClassLoaderKey key1 =
+          new ClassLoaderKey(
+              "lakehouse-iceberg", ImmutableMap.of("metastore.uris", 
"thrift://hms-A:9083"));
+      ClassLoaderKey key2 =
+          new ClassLoaderKey(
+              "lakehouse-iceberg", ImmutableMap.of("metastore.uris", 
"thrift://hms-A:9083"));
+
+      PooledClassLoaderEntry entry1 = pool.acquire(key1, 
this::createDummyClassLoader);
+      PooledClassLoaderEntry entry2 = pool.acquire(key2, 
this::createDummyClassLoader);
+
+      Assertions.assertSame(entry1, entry2);
+      Assertions.assertEquals(2, entry1.refCount());
+      Assertions.assertEquals(1, pool.size());
+    }
+  }
+
+  @Test
+  public void testDifferentJdbcUrlsCreateDifferentEntries() {
+    try (ClassLoaderPool pool = new ClassLoaderPool()) {
+      ClassLoaderKey key1 =
+          new ClassLoaderKey(
+              "jdbc-mysql", ImmutableMap.of("jdbc-url", 
"jdbc:mysql://host-A:3306/db"));
+      ClassLoaderKey key2 =
+          new ClassLoaderKey(
+              "jdbc-mysql", ImmutableMap.of("jdbc-url", 
"jdbc:mysql://host-B:3306/db"));
+
+      PooledClassLoaderEntry entry1 = pool.acquire(key1, 
this::createDummyClassLoader);
+      PooledClassLoaderEntry entry2 = pool.acquire(key2, 
this::createDummyClassLoader);
+
+      Assertions.assertNotSame(entry1, entry2);
+      Assertions.assertEquals(2, pool.size());
+    }
+  }
+
+  @Test
+  public void testDifferentDefaultFsCreateDifferentEntries() {
+    try (ClassLoaderPool pool = new ClassLoaderPool()) {
+      ClassLoaderKey key1 =
+          new ClassLoaderKey(
+              "lakehouse-iceberg",
+              ImmutableMap.of(
+                  "metastore.uris", "thrift://hms:9083",
+                  "fs.defaultFS", "hdfs://cluster-A:8020"));
+      ClassLoaderKey key2 =
+          new ClassLoaderKey(
+              "lakehouse-iceberg",
+              ImmutableMap.of(
+                  "metastore.uris", "thrift://hms:9083",
+                  "fs.defaultFS", "hdfs://cluster-B:8020"));
+
+      PooledClassLoaderEntry entry1 = pool.acquire(key1, 
this::createDummyClassLoader);
+      PooledClassLoaderEntry entry2 = pool.acquire(key2, 
this::createDummyClassLoader);
+
+      Assertions.assertNotSame(entry1, entry2);
+      Assertions.assertEquals(2, pool.size());
+    }
+  }
+
+  @Test
+  public void testAcquireAfterCloseThrows() {
+    ClassLoaderPool pool = new ClassLoaderPool();
+    pool.close();
+    Assertions.assertThrows(
+        IllegalStateException.class,
+        () -> pool.acquire(simpleKey("iceberg"), 
this::createDummyClassLoader));
+  }
+
+  @Test
+  public void testConcurrentAcquireDuringClose() throws Exception {
+    int threadCount = 10;
+    ExecutorService executor = Executors.newFixedThreadPool(threadCount);
+    ClassLoaderPool pool = new ClassLoaderPool();
+
+    // Pre-populate the pool with entries that have active refCounts > 0
+    // to exercise the force-cleanup path in close()
+    ClassLoaderKey heldKey = simpleKey("held");
+    pool.acquire(heldKey, this::createDummyClassLoader); // refCount=1, never 
released
+
+    // Race: some threads try to acquire while another thread closes
+    CyclicBarrier barrier = new CyclicBarrier(threadCount + 1);
+    List<Future<?>> futures = new ArrayList<>();
+    for (int i = 0; i < threadCount; i++) {
+      final int idx = i;
+      futures.add(
+          executor.submit(
+              () -> {
+                barrier.await();
+                ClassLoaderKey k = simpleKey("type-" + idx);
+                try {
+                  PooledClassLoaderEntry e = pool.acquire(k, 
this::createDummyClassLoader);
+                  // If acquire succeeded, release it
+                  pool.release(e);
+                } catch (IllegalStateException expected) {
+                  // Pool was closed before or during acquire — expected
+                }
+                return null;
+              }));
+    }
+
+    // Close from the main thread racing with acquires — this force-cleans
+    // the held entry (refCount > 0) and drains any concurrent insertions
+    barrier.await();
+    pool.close();
+
+    for (Future<?> future : futures) {
+      future.get(5, TimeUnit.SECONDS);
+    }
+
+    // After close + all threads done, pool must be empty
+    Assertions.assertEquals(0, pool.size());
+    executor.shutdown();
+  }
+}
diff --git a/docs/gravitino-server-config.md b/docs/gravitino-server-config.md
index 14ba65d06a..242ac37dee 100644
--- a/docs/gravitino-server-config.md
+++ b/docs/gravitino-server-config.md
@@ -159,6 +159,7 @@ The Gravitino server uses a tree lock to ensure data 
consistency. The tree lock
 
|----------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------|----------|---------------|
 | `gravitino.catalog.cache.evictionIntervalMs`           | The interval in 
milliseconds to evict the catalog cache; default 3600000ms(1h).                 
                                                                                
                                                                                
                                                                                
                                                         | `3600000` | No | 
0.1.0 |
 | `gravitino.catalog.classloader.isolated`               | Whether to use an 
isolated classloader for catalog. If `true`, an isolated classloader loads all 
catalog-related libraries and configurations, not the AppClassLoader. The 
default value is `true`.                                                        
                                                                                
                                                               | `true`    | No 
| 0.1.0 |
+| `gravitino.catalog.classloader.sharing.enabled`        | Whether to share 
one ClassLoader across catalogs that have identical isolation-relevant 
properties. When `true` (default), such catalogs reuse a single ClassLoader, 
reducing Metaspace usage; when `false`, each catalog gets its own ClassLoader 
as in releases before 2.0.0.                                                    
                                                                      | `true`  
  | No | 2.0.0 |
 | `gravitino.catalog.credential.backfillToProperties`    | For backward 
compatibility only: if `true`, the server exposes hidden catalog credentials 
(such as jdbc-user and jdbc-password) in the catalog properties response so 
that connectors that do not support credential vending can still read them. 
**Enabling this is a security risk** — credentials are visible to anyone who 
can read catalog properties. Disable once all connectors are upgraded.       | 
`false`   | No | 1.3.0 |
 
 ### Schema configuration
diff --git 
a/iceberg/iceberg-common/src/main/java/org/apache/gravitino/iceberg/common/ops/IcebergCatalogWrapper.java
 
b/iceberg/iceberg-common/src/main/java/org/apache/gravitino/iceberg/common/ops/IcebergCatalogWrapper.java
index dc74534896..8d2196ecca 100644
--- 
a/iceberg/iceberg-common/src/main/java/org/apache/gravitino/iceberg/common/ops/IcebergCatalogWrapper.java
+++ 
b/iceberg/iceberg-common/src/main/java/org/apache/gravitino/iceberg/common/ops/IcebergCatalogWrapper.java
@@ -19,8 +19,6 @@
 package org.apache.gravitino.iceberg.common.ops;
 
 import com.google.common.base.Preconditions;
-import java.sql.Driver;
-import java.sql.DriverManager;
 import java.util.Locale;
 import java.util.Map;
 import java.util.Optional;
@@ -34,7 +32,6 @@ import 
org.apache.gravitino.iceberg.common.cache.SupportsMetadataLocation;
 import org.apache.gravitino.iceberg.common.cache.TableMetadataCache;
 import org.apache.gravitino.iceberg.common.utils.IcebergCatalogUtil;
 import org.apache.gravitino.utils.ClassUtils;
-import org.apache.gravitino.utils.IsolatedClassLoader;
 import org.apache.hadoop.conf.Configuration;
 import org.apache.hadoop.security.UserGroupInformation;
 import org.apache.iceberg.BaseTable;
@@ -81,7 +78,6 @@ public class IcebergCatalogWrapper implements AutoCloseable {
   private volatile SupportsNamespaces asNamespaceCatalog;
   private final IcebergCatalogBackend catalogBackend;
   private final IcebergConfig icebergConfig;
-  private String catalogUri = null;
   private volatile TableMetadataCache metadataCache;
   private final Configuration configuration;
 
@@ -97,9 +93,6 @@ public class IcebergCatalogWrapper implements AutoCloseable {
         throw new IllegalArgumentException("The 'warehouse' parameter must 
have a value.");
       }
     }
-    if (!IcebergCatalogBackend.MEMORY.equals(catalogBackend)) {
-      this.catalogUri = icebergConfig.get(IcebergConfig.CATALOG_URI);
-    }
     Map<String, String> catalogPropertiesMap = 
icebergConfig.getIcebergCatalogProperties();
     this.configuration = FileSystemUtils.createConfiguration(null, 
catalogPropertiesMap);
   }
@@ -405,79 +398,12 @@ public class IcebergCatalogWrapper implements 
AutoCloseable {
     if (cache != null) {
       cache.close();
     }
-
-    // For Iceberg REST server which use the same classloader when recreating 
catalog wrapper, the
-    // Driver couldn't be reloaded after deregister()
-    if (useDifferentClassLoader()) {
-      closeJdbcDriverResources();
-    }
   }
 
   public boolean isRESTCatalog() {
     return getCatalog() instanceof RESTCatalog;
   }
 
-  /**
-   * Whether the wrapper is recreated with a different classloader.
-   *
-   * <p>Returning {@code true} allows JDBC drivers loaded by an isolated 
classloader to be
-   * deregistered when the wrapper closes so the classloader can be garbage 
collected. Implementors
-   * that intentionally reuse the same classloader (for example, an Iceberg 
REST server instance)
-   * should override and return {@code false} to skip deregistration.
-   */
-  protected boolean useDifferentClassLoader() {
-    return true;
-  }
-
-  private void closeJdbcDriverResources() {
-    // Because each catalog in Gravitino has its own classloader, after a 
catalog is no longer used
-    // for a long time or dropped, the instance of classloader needs to be 
released. In order to
-    // let JVM GC remove the classloader, we need to release the resources of 
the classloader. The
-    // resources include the driver of the catalog backend and the
-    // AbandonedConnectionCleanupThread of MySQL. For more information about
-    // AbandonedConnectionCleanupThread, please refer to the corresponding 
java doc of MySQL
-    // driver.
-    if (catalogUri != null && catalogUri.contains("mysql")) {
-      closeMySQLCatalogResource();
-    } else if (catalogUri != null && catalogUri.contains("postgresql")) {
-      closePostgreSQLCatalogResource();
-    }
-  }
-
-  private void closeMySQLCatalogResource() {
-    try {
-      // Close thread AbandonedConnectionCleanupThread if we are using 
`com.mysql.cj.jdbc.Driver`,
-      // for driver `com.mysql.jdbc.Driver` (deprecated), the daemon thread 
maybe not this one.
-      Class.forName("com.mysql.cj.jdbc.AbandonedConnectionCleanupThread")
-          .getMethod("uncheckedShutdown")
-          .invoke(null);
-      LOG.info("AbandonedConnectionCleanupThread has been shutdown...");
-
-      // Unload the MySQL driver, only Unload the driver if it is loaded by
-      // IsolatedClassLoader.
-      closeDriverLoadedByIsolatedClassLoader(catalogUri);
-    } catch (Exception e) {
-      LOG.warn("Failed to shutdown AbandonedConnectionCleanupThread or 
deregister MySQL driver", e);
-    }
-  }
-
-  private void closeDriverLoadedByIsolatedClassLoader(String uri) {
-    try {
-      Driver driver = DriverManager.getDriver(uri);
-      if (driver.getClass().getClassLoader().getClass()
-          == IsolatedClassLoader.CUSTOM_CLASS_LOADER_CLASS) {
-        DriverManager.deregisterDriver(driver);
-        LOG.info("Driver {} has been deregistered...", driver);
-      }
-    } catch (Exception e) {
-      LOG.warn("Failed to deregister driver", e);
-    }
-  }
-
-  private void closePostgreSQLCatalogResource() {
-    closeDriverLoadedByIsolatedClassLoader(catalogUri);
-  }
-
   @Getter
   @Setter
   public static final class IcebergTableChange {
diff --git 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/CatalogWrapperForREST.java
 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/CatalogWrapperForREST.java
index a2749325ae..1af51f5295 100644
--- 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/CatalogWrapperForREST.java
+++ 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/CatalogWrapperForREST.java
@@ -271,11 +271,6 @@ public class CatalogWrapperForREST extends 
IcebergCatalogWrapper {
     }
   }
 
-  @Override
-  protected boolean useDifferentClassLoader() {
-    return false;
-  }
-
   @VisibleForTesting
   protected LoadTableResponse injectCredentialConfig(
       TableIdentifier tableIdentifier,
diff --git 
a/integration-test-common/src/test/java/org/apache/gravitino/integration/test/util/TestDatabaseName.java
 
b/integration-test-common/src/test/java/org/apache/gravitino/integration/test/util/TestDatabaseName.java
index 8e7db655b6..e127c0bf6f 100644
--- 
a/integration-test-common/src/test/java/org/apache/gravitino/integration/test/util/TestDatabaseName.java
+++ 
b/integration-test-common/src/test/java/org/apache/gravitino/integration/test/util/TestDatabaseName.java
@@ -63,6 +63,20 @@ public enum TestDatabaseName {
   /** Represents the MySQL database used for testing catalog credential 
integration. */
   MYSQL_CATALOG_CREDENTIAL_IT,
 
+  /**
+   * Represents the first MySQL-backed database used by
+   * lakehouse.iceberg.integration.test.IcebergClassLoaderPoolIT to verify 
ClassLoader isolation and
+   * sharing across Iceberg catalogs whose JDBC backend URI differs only by 
database name.
+   */
+  MYSQL_TEST_ICEBERG_CLASSLOADER_POOL_A,
+
+  /**
+   * Represents the second MySQL-backed database used by
+   * lakehouse.iceberg.integration.test.IcebergClassLoaderPoolIT (different 
{@code uri} than {@link
+   * #MYSQL_TEST_ICEBERG_CLASSLOADER_POOL_A}).
+   */
+  MYSQL_TEST_ICEBERG_CLASSLOADER_POOL_B,
+
   PG_JDBC_BACKEND,
 
   /** Represents the PostgreSQL database for CatalogPostgreSqlIT. */

Reply via email to