diff --git a/org.eclipse.jgit/META-INF/MANIFEST.MF b/org.eclipse.jgit/META-INF/MANIFEST.MF
index 28887948afb1bf01feb0e589608ebe9960d5f4c7..e897b24d0fc9179f294cf7cd073ad9091e673feb 100644
--- a/org.eclipse.jgit/META-INF/MANIFEST.MF
+++ b/org.eclipse.jgit/META-INF/MANIFEST.MF
@@ -6,6 +6,8 @@ Bundle-SymbolicName: org.eclipse.jgit
 Bundle-Version: 6.10.0.qualifier
 Bundle-Localization: OSGI-INF/l10n/plugin
 Bundle-Vendor: %Bundle-Vendor
+Bundle-ActivationPolicy: lazy
+Service-Component: OSGI-INF/org.eclipse.jgit.internal.util.CleanupService.xml
 Eclipse-ExtensibleAPI: true
 Export-Package: org.eclipse.jgit.annotations;version="6.10.0",
  org.eclipse.jgit.api;version="6.10.0";
diff --git a/org.eclipse.jgit/OSGI-INF/org.eclipse.jgit.internal.util.CleanupService.xml b/org.eclipse.jgit/OSGI-INF/org.eclipse.jgit.internal.util.CleanupService.xml
new file mode 100644
index 0000000000000000000000000000000000000000..8d97374c66ec20387ce0cdd9e6d1e3e2ad6fa268
--- /dev/null
+++ b/org.eclipse.jgit/OSGI-INF/org.eclipse.jgit.internal.util.CleanupService.xml
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<scr:component xmlns:scr="http://www.osgi.org/xmlns/scr/v1.1.0" activate="start" deactivate="shutDown" name="org.eclipse.jgit.internal.util.CleanupService">
+   <implementation class="org.eclipse.jgit.internal.util.CleanupService"/>
+</scr:component>
\ No newline at end of file
diff --git a/org.eclipse.jgit/resources/org/eclipse/jgit/internal/JGitText.properties b/org.eclipse.jgit/resources/org/eclipse/jgit/internal/JGitText.properties
index bbfd0b0d3a4832972264c8599a9d0ebd271830bd..19c90086aa09a2cf27dbff538e164c1d0b597249 100644
--- a/org.eclipse.jgit/resources/org/eclipse/jgit/internal/JGitText.properties
+++ b/org.eclipse.jgit/resources/org/eclipse/jgit/internal/JGitText.properties
@@ -716,6 +716,7 @@ shortReadOfBlock=Short read of block.
 shortReadOfOptionalDIRCExtensionExpectedAnotherBytes=Short read of optional DIRC extension {0}; expected another {1} bytes within the section.
 shortSkipOfBlock=Short skip of block.
 shutdownCleanup=Cleanup {} during JVM shutdown
+shutdownCleanupFailed=Cleanup during JVM shutdown failed
 shutdownCleanupListenerFailed=Cleanup of {0} during JVM shutdown failed
 signatureVerificationError=Signature verification failed
 signatureVerificationUnavailable=No signature verifier registered
diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/internal/JGitText.java b/org.eclipse.jgit/src/org/eclipse/jgit/internal/JGitText.java
index ef464e317243617c1d18f5932954bc707096f5aa..700b54a7a626c90f299ac85794d6db87c91ed62b 100644
--- a/org.eclipse.jgit/src/org/eclipse/jgit/internal/JGitText.java
+++ b/org.eclipse.jgit/src/org/eclipse/jgit/internal/JGitText.java
@@ -745,6 +745,7 @@ public static JGitText get() {
 	/***/ public String shortReadOfOptionalDIRCExtensionExpectedAnotherBytes;
 	/***/ public String shortSkipOfBlock;
 	/***/ public String shutdownCleanup;
+	/***/ public String shutdownCleanupFailed;
 	/***/ public String shutdownCleanupListenerFailed;
 	/***/ public String signatureVerificationError;
 	/***/ public String signatureVerificationUnavailable;
diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/internal/util/CleanupService.java b/org.eclipse.jgit/src/org/eclipse/jgit/internal/util/CleanupService.java
new file mode 100644
index 0000000000000000000000000000000000000000..76e09307aba0b8ec726d0f97a0242194ea36d1b4
--- /dev/null
+++ b/org.eclipse.jgit/src/org/eclipse/jgit/internal/util/CleanupService.java
@@ -0,0 +1,92 @@
+/*
+ * Copyright (C) 2024, Thomas Wolf <twolf@apache.org> and others
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Distribution License v. 1.0 which is available at
+ * https://www.eclipse.org/org/documents/edl-v10.php.
+ *
+ * SPDX-License-Identifier: BSD-3-Clause
+ */
+package org.eclipse.jgit.internal.util;
+
+/**
+ * A class that is registered as an OSGi service via the manifest. If JGit runs
+ * in OSGi, OSGi will instantiate a singleton as soon as the bundle is activated
+ * since this class is an immediate OSGi component with no dependencies. OSGi
+ * will then call its {@link #start()} method. If JGit is not running in OSGi,
+ * {@link #getInstance()} will lazily create an instance.
+ * <p>
+ * An OSGi-created {@link CleanupService} will run the registered cleanup when
+ * the {@code org.eclipse.jgit} bundle is deactivated. A lazily created instance
+ * will register the cleanup as a JVM shutdown hook.
+ * </p>
+ */
+public final class CleanupService {
+
+	private static final Object LOCK = new Object();
+
+	private static CleanupService INSTANCE;
+
+	private final boolean isOsgi;
+
+	private Runnable cleanup;
+
+	/**
+	 * Public component constructor for OSGi DS. Do <em>not</em> call this
+	 * explicitly! (Unfortunately this constructor must be public because of
+	 * OSGi requirements.)
+	 */
+	public CleanupService() {
+		this.isOsgi = true;
+		setInstance(this);
+	}
+
+	private CleanupService(boolean isOsgi) {
+		this.isOsgi = isOsgi;
+	}
+
+	private static void setInstance(CleanupService service) {
+		synchronized (LOCK) {
+			INSTANCE = service;
+		}
+	}
+
+	/**
+	 * Obtains the singleton instance of the {@link CleanupService} that knows
+	 * whether or not it is running on OSGi.
+	 *
+	 * @return the {@link CleanupService} singleton instance
+	 */
+	public static CleanupService getInstance() {
+		synchronized (LOCK) {
+			if (INSTANCE == null) {
+				INSTANCE = new CleanupService(false);
+			}
+			return INSTANCE;
+		}
+	}
+
+	void start() {
+		// Nothing to do
+	}
+
+	void register(Runnable cleanUp) {
+		if (isOsgi) {
+			cleanup = cleanUp;
+		} else {
+			try {
+				Runtime.getRuntime().addShutdownHook(new Thread(cleanUp));
+			} catch (IllegalStateException e) {
+				// Ignore -- the JVM is already shutting down.
+			}
+		}
+	}
+
+	void shutDown() {
+		if (isOsgi && cleanup != null) {
+			Runnable r = cleanup;
+			cleanup = null;
+			r.run();
+		}
+	}
+}
diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/internal/util/ShutdownHook.java b/org.eclipse.jgit/src/org/eclipse/jgit/internal/util/ShutdownHook.java
index f52025fd6be54583db0b2ed7fc8b5ede30bc6615..5ba33dbbff35df16aaebda3a7e6c3c5485b9a4f9 100644
--- a/org.eclipse.jgit/src/org/eclipse/jgit/internal/util/ShutdownHook.java
+++ b/org.eclipse.jgit/src/org/eclipse/jgit/internal/util/ShutdownHook.java
@@ -24,8 +24,12 @@
 import org.slf4j.LoggerFactory;
 
 /**
- * A hook registered as a JVM shutdown hook managing a set of objects needing
- * cleanup during JVM shutdown. See {@link Runtime#addShutdownHook}.
+ * The singleton {@link ShutdownHook} provides a means to register
+ * {@link Listener}s that are run when JGit is uninstalled, either
+ * <ul>
+ * <li>in an OSGi framework when this bundle is deactivated, or</li>
+ * <li>otherwise, when the JVM as a whole shuts down.</li>
+ * </ul>
  */
 @SuppressWarnings("ImmutableEnumChecker")
 public enum ShutdownHook {
@@ -35,11 +39,11 @@ public enum ShutdownHook {
 	INSTANCE;
 
 	/**
-	 * Object that needs to cleanup on JVM shutdown.
+	 * Object that needs to cleanup on shutdown.
 	 */
 	public interface Listener {
 		/**
-		 * Cleanup resources when JVM shuts down, called from JVM shutdown hook.
+		 * Cleanup resources when JGit is shut down.
 		 * <p>
 		 * Implementations should be coded defensively
 		 * <ul>
@@ -65,11 +69,7 @@ public interface Listener {
 	private volatile boolean shutdownInProgress;
 
 	private ShutdownHook() {
-		try {
-			Runtime.getRuntime().addShutdownHook(new Thread(this::cleanup));
-		} catch (IllegalStateException e) {
-			// ignore - the VM is already shutting down
-		}
+		CleanupService.getInstance().register(this::cleanup);
 	}
 
 	private void cleanup() {
@@ -82,9 +82,7 @@ private void cleanup() {
 			}).get(30L, TimeUnit.SECONDS);
 		} catch (RejectedExecutionException | InterruptedException
 				| ExecutionException | TimeoutException e) {
-			// message isn't localized since during shutdown there's no
-			// guarantee which classes are still loaded
-			LOG.error("Cleanup during JVM shutdown failed", e); //$NON-NLS-1$
+			LOG.error(JGitText.get().shutdownCleanupFailed, e);
 		}
 		runner.shutdownNow();
 	}
@@ -104,12 +102,12 @@ private void notify(Listener l) {
 	}
 
 	/**
-	 * Register object that needs cleanup during JVM shutdown if it is not
-	 * already registered. Registration is disabled when JVM shutdown is already
-	 * in progress.
+	 * Register object that needs cleanup during JGit shutdown if it is not
+	 * already registered. Registration is disabled when JGit shutdown is
+	 * already in progress.
 	 *
 	 * @param l
-	 *            the object to call {@link Listener#onShutdown} on when JVM
+	 *            the object to call {@link Listener#onShutdown} on when JGit
 	 *            shuts down
 	 * @return {@code true} if this object has been registered
 	 */
@@ -123,8 +121,8 @@ public boolean register(Listener l) {
 	}
 
 	/**
-	 * Unregister object that no longer needs cleanup during JVM shutdown if it
-	 * is still registered. Unregistration is disabled when JVM shutdown is
+	 * Unregister object that no longer needs cleanup during JGit shutdown if it
+	 * is still registered. Unregistration is disabled when JGit shutdown is
 	 * already in progress.
 	 *
 	 * @param l
@@ -142,9 +140,9 @@ public boolean unregister(Listener l) {
 	}
 
 	/**
-	 * Whether a JVM shutdown is in progress
+	 * Whether a JGit shutdown is in progress
 	 *
-	 * @return {@code true} if a JVM shutdown is in progress
+	 * @return {@code true} if a JGit shutdown is in progress
 	 */
 	public boolean isShutdownInProgress() {
 		return shutdownInProgress;