You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@logging.apache.org by pk...@apache.org on 2024/04/04 10:34:37 UTC

(logging-log4j2) 01/01: Add support for thread context map propagation

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

pkarwasz pushed a commit to branch feature/context-propagation
in repository https://gitbox.apache.org/repos/asf/logging-log4j2.git

commit 21f95c63a53b0b4d48a6b45d08b7f103e798630b
Author: Piotr P. Karwasz <pi...@karwasz.org>
AuthorDate: Thu Apr 4 12:05:26 2024 +0200

    Add support for thread context map propagation
    
    Adds a new `ExtendedThreadContext` SPI class to help external libraries
    with an efficient propagation of the `ThreadContextMap`.
    
    Currently integrators can retrieve and set the context map through a
    combination of `ThreadContext#getImmutableContext`, `#clear` and
    `#putAll`. This inevitable leads to the creation of temporary objects if
    the underlying implementation does not use the JDK `Map` class
    internally.
    
    As a replacement we provide two `saveMap` and `restoreMap` methods that
    can be used to access the `ThreadLocal`s directly. Due to the inherent
    unsafety of such operations, these methods are only available from the
    `o.a.l.l.spi` package.
    
    We also reimplement `CloseableThreadContext.Instance` to take advantage
    of this SPI.
---
 log4j-api-test/pom.xml                             |  11 +-
 .../log4j/test/spi/ThreadContextMapSuite.java      | 130 +++++++++++++++++++++
 .../logging/log4j/test/spi/package-info.java       |  26 +++++
 .../logging/log4j/CloseableThreadContextTest.java  |   5 +-
 .../logging/log4j/spi/ThreadContextMapTest.java    |  82 ++++++++-----
 .../logging/log4j/CloseableThreadContext.java      |  42 +++----
 .../internal/map/StringArrayThreadContextMap.java  |  19 ++-
 .../CopyOnWriteSortedArrayThreadContextMap.java    |  17 +++
 .../logging/log4j/spi/DefaultThreadContextMap.java |  17 +++
 .../logging/log4j/spi/ExtendedThreadContext.java   |  63 ++++++++++
 .../GarbageFreeSortedArrayThreadContextMap.java    |  18 +++
 .../logging/log4j/spi/NoOpThreadContextMap.java    |  10 ++
 .../apache/logging/log4j/spi/ThreadContextMap.java |  24 ++++
 .../logging/log4j/perf/nogc/OpenHashStringMap.java |  15 +++
 .../org/apache/logging/slf4j/MDCContextMap.java    |  47 ++++++--
 .../apache/logging/slf4j/MDCContextMapTest.java    |  52 +++++++--
 .../.2.x.x/add_extended_thread_context.xml         |   7 ++
 17 files changed, 496 insertions(+), 89 deletions(-)

diff --git a/log4j-api-test/pom.xml b/log4j-api-test/pom.xml
index 0ef2578dd8..40d4dc2525 100644
--- a/log4j-api-test/pom.xml
+++ b/log4j-api-test/pom.xml
@@ -37,6 +37,7 @@
     <bnd-module-name>org.apache.logging.log4j.test</bnd-module-name>
     <bnd-extra-package-options>
       org.apache.commons.lang3.*;resolution:=optional,
+      org.assertj.*;resolution:=optional,
       <!-- Both JUnit 4 and JUnit 5 are not required -->
       org.junit.*;resolution:=optional,
       org.hamcrest.*;resolution:=optional,
@@ -48,6 +49,7 @@
     <bnd-extra-module-options>
       <!-- Non-transitive static modules -->
       junit;transitive=false,
+      org.assertj.core;transitive=false,
       org.hamcrest;transitive=false,
       org.junit.jupiter.api;transitive=false,
       org.junitpioneer;transitive=false,
@@ -72,6 +74,10 @@
       <groupId>org.apache.logging.log4j</groupId>
       <artifactId>log4j-api</artifactId>
     </dependency>
+    <dependency>
+      <groupId>org.assertj</groupId>
+      <artifactId>assertj-core</artifactId>
+    </dependency>
     <dependency>
       <groupId>org.apache.commons</groupId>
       <artifactId>commons-lang3</artifactId>
@@ -108,11 +114,6 @@
       <groupId>org.codehaus.plexus</groupId>
       <artifactId>plexus-utils</artifactId>
     </dependency>
-    <dependency>
-      <groupId>org.assertj</groupId>
-      <artifactId>assertj-core</artifactId>
-      <scope>test</scope>
-    </dependency>
     <!-- Required for JSON support -->
     <dependency>
       <groupId>com.fasterxml.jackson.core</groupId>
diff --git a/log4j-api-test/src/main/java/org/apache/logging/log4j/test/spi/ThreadContextMapSuite.java b/log4j-api-test/src/main/java/org/apache/logging/log4j/test/spi/ThreadContextMapSuite.java
new file mode 100644
index 0000000000..9c3bf4aa3f
--- /dev/null
+++ b/log4j-api-test/src/main/java/org/apache/logging/log4j/test/spi/ThreadContextMapSuite.java
@@ -0,0 +1,130 @@
+/*
+ * 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.logging.log4j.test.spi;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.time.Duration;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import org.apache.logging.log4j.spi.ThreadContextMap;
+
+/**
+ * A simple suite of tests for {@link ThreadContextMap} implementations.
+ */
+public abstract class ThreadContextMapSuite {
+    private static final String KEY = "key";
+
+    /**
+     * Checks if new threads either inherit or do not inherit the context data of the current thread.
+     *
+     * @param threadContext A {@link ThreadContextMap implementation}.
+     * @param key           The key to use.
+     * @param expectedValue The expected value on a new thread.
+     */
+    protected static void assertThreadContextValueOnANewThread(
+            final ThreadContextMap threadContext, final String key, final String expectedValue) {
+        final ExecutorService executorService = Executors.newSingleThreadExecutor();
+        try {
+            assertThat(executorService.submit(() -> threadContext.get(key)))
+                    .succeedsWithin(Duration.ofSeconds(1))
+                    .isEqualTo(expectedValue);
+        } finally {
+            executorService.shutdown();
+        }
+    }
+
+    /**
+     * Ensures that {@code save/restore} works correctly on the current thread.
+     */
+    protected static void assertContextDataCanBeSavedAndRestored(final ThreadContextMap threadContext) {
+        final String externalValue = "externalValue";
+        final String internalValue = "internalValue";
+        final RuntimeException throwable = new RuntimeException();
+        threadContext.put(KEY, externalValue);
+        final Object saved = threadContext.save();
+        try {
+            threadContext.put(KEY, internalValue);
+            assertThat(threadContext.get(KEY)).isEqualTo(internalValue);
+            throw throwable;
+        } catch (final RuntimeException e) {
+            assertThat(e).isSameAs(throwable);
+            assertThat(threadContext.get(KEY)).isEqualTo(internalValue);
+        } finally {
+            threadContext.restore(saved);
+        }
+        assertThat(threadContext.get(KEY)).isEqualTo(externalValue);
+    }
+
+    /**
+     * Ensures that the context data obtained through {@link ThreadContextMap#save} can be safely transferred to another
+     * thread.
+     *
+     * @param threadContext The {@link ThreadContextMap} to test.
+     */
+    protected static void assertContextDataCanBeTransferred(final ThreadContextMap threadContext) {
+        final String mainThreadValue = "mainThreadValue";
+        final String newThreadValue = "newThreadValue";
+        final RuntimeException throwable = new RuntimeException();
+        threadContext.put(KEY, mainThreadValue);
+        final Object mainThreadSaved = threadContext.save();
+        threadContext.remove(KEY);
+        // Move to new thread
+        final ExecutorService executorService = Executors.newSingleThreadExecutor();
+        try {
+            assertThat(executorService.submit(() -> {
+                        threadContext.put(KEY, newThreadValue);
+                        final Object newThreadSaved = threadContext.restore(mainThreadSaved);
+                        try {
+                            assertThat(threadContext.get(KEY)).isEqualTo(mainThreadValue);
+                            throw throwable;
+                        } catch (final RuntimeException e) {
+                            assertThat(e).isSameAs(throwable);
+                            assertThat(threadContext.get(KEY)).isEqualTo(mainThreadValue);
+                        } finally {
+                            threadContext.restore(newThreadSaved);
+                        }
+                        assertThat(threadContext.get(KEY)).isEqualTo(newThreadValue);
+                    }))
+                    .succeedsWithin(Duration.ofSeconds(1));
+        } finally {
+            executorService.shutdown();
+        }
+    }
+
+    /**
+     * Ensures the {@code restore(null)} can be used to clear the context data.
+     *
+     * @param threadContext The {@link ThreadContextMap} to test.
+     */
+    protected static void assertNullValueClearsTheContextData(final ThreadContextMap threadContext) {
+        final String value = "restoreAcceptsNull";
+        final RuntimeException throwable = new RuntimeException();
+        threadContext.put(KEY, value);
+        final Object saved = threadContext.restore(null);
+        try {
+            assertThat(threadContext.getImmutableMapOrNull()).isNullOrEmpty();
+            throw throwable;
+        } catch (final RuntimeException e) {
+            assertThat(e).isSameAs(throwable);
+            assertThat(threadContext.getImmutableMapOrNull()).isNullOrEmpty();
+        } finally {
+            threadContext.restore(saved);
+        }
+        assertThat(threadContext.get(KEY)).isEqualTo(value);
+    }
+}
diff --git a/log4j-api-test/src/main/java/org/apache/logging/log4j/test/spi/package-info.java b/log4j-api-test/src/main/java/org/apache/logging/log4j/test/spi/package-info.java
new file mode 100644
index 0000000000..02cf0ffd78
--- /dev/null
+++ b/log4j-api-test/src/main/java/org/apache/logging/log4j/test/spi/package-info.java
@@ -0,0 +1,26 @@
+/*
+ * 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.
+ */
+/**
+ * Contains helper classes to blackbox test implementations of SPI interfaces.
+ * @since 2.24.0
+ */
+@Export
+@Version("2.24.0")
+package org.apache.logging.log4j.test.spi;
+
+import org.osgi.annotation.bundle.Export;
+import org.osgi.annotation.versioning.Version;
diff --git a/log4j-api-test/src/test/java/org/apache/logging/log4j/CloseableThreadContextTest.java b/log4j-api-test/src/test/java/org/apache/logging/log4j/CloseableThreadContextTest.java
index 56e0086a49..153ffa9571 100644
--- a/log4j-api-test/src/test/java/org/apache/logging/log4j/CloseableThreadContextTest.java
+++ b/log4j-api-test/src/test/java/org/apache/logging/log4j/CloseableThreadContextTest.java
@@ -23,19 +23,18 @@ import static org.junit.jupiter.api.Assertions.assertNotNull;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import org.apache.logging.log4j.test.junit.Log4jStaticResources;
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
-import org.junit.jupiter.api.parallel.ResourceAccessMode;
 import org.junit.jupiter.api.parallel.ResourceLock;
-import org.junit.jupiter.api.parallel.Resources;
 
 /**
  * Tests {@link CloseableThreadContext}.
  *
  * @since 2.6
  */
-@ResourceLock(value = Resources.SYSTEM_PROPERTIES, mode = ResourceAccessMode.READ)
+@ResourceLock(Log4jStaticResources.THREAD_CONTEXT)
 public class CloseableThreadContextTest {
 
     private final String key = "key";
diff --git a/log4j-api-test/src/test/java/org/apache/logging/log4j/spi/ThreadContextMapTest.java b/log4j-api-test/src/test/java/org/apache/logging/log4j/spi/ThreadContextMapTest.java
index f394e71bea..30f52c4906 100644
--- a/log4j-api-test/src/test/java/org/apache/logging/log4j/spi/ThreadContextMapTest.java
+++ b/log4j-api-test/src/test/java/org/apache/logging/log4j/spi/ThreadContextMapTest.java
@@ -16,61 +16,85 @@
  */
 package org.apache.logging.log4j.spi;
 
-import static org.assertj.core.api.Assertions.assertThat;
-
-import java.time.Duration;
 import java.util.Properties;
-import java.util.concurrent.ExecutorService;
-import java.util.concurrent.Executors;
 import java.util.stream.Stream;
+import org.apache.logging.log4j.internal.map.StringArrayThreadContextMap;
+import org.apache.logging.log4j.test.spi.ThreadContextMapSuite;
+import org.apache.logging.log4j.util.Lazy;
 import org.apache.logging.log4j.util.PropertiesUtil;
+import org.junit.jupiter.api.parallel.Execution;
+import org.junit.jupiter.api.parallel.ExecutionMode;
 import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.MethodSource;
 
-class ThreadContextMapTest {
+@Execution(ExecutionMode.CONCURRENT)
+class ThreadContextMapTest extends ThreadContextMapSuite {
 
     private static final String KEY = "key";
+    private static final Lazy<PropertiesUtil> defaultMapProperties = Lazy.pure(() -> createMapProperties(false));
+    private static final Lazy<PropertiesUtil> inheritableMapProperties = Lazy.pure(() -> createMapProperties(true));
+
+    private static PropertiesUtil createMapProperties(final boolean inheritable) {
+        final Properties props = new Properties();
+        // By specifying all the possible properties, the resulting thread context maps do not depend on other
+        // property sources like Java system properties.
+        props.setProperty("log4j2.threadContextInitialCapacity", "16");
+        props.setProperty("log4j2.isThreadContextMapInheritable", inheritable ? "true" : "false");
+        return new PropertiesUtil(props);
+    }
 
     static Stream<ThreadContextMap> defaultMaps() {
         return Stream.of(
                 new DefaultThreadContextMap(),
                 new CopyOnWriteSortedArrayThreadContextMap(),
-                new GarbageFreeSortedArrayThreadContextMap());
+                new GarbageFreeSortedArrayThreadContextMap(),
+                new StringArrayThreadContextMap());
+    }
+
+    static Stream<ThreadContextMap> localMaps() {
+        return Stream.of(
+                new DefaultThreadContextMap(true, defaultMapProperties.get()),
+                new CopyOnWriteSortedArrayThreadContextMap(defaultMapProperties.get()),
+                new GarbageFreeSortedArrayThreadContextMap(defaultMapProperties.get()),
+                new StringArrayThreadContextMap());
     }
 
     static Stream<ThreadContextMap> inheritableMaps() {
-        final Properties props = new Properties();
-        props.setProperty("log4j2.isThreadContextMapInheritable", "true");
-        final PropertiesUtil util = new PropertiesUtil(props);
         return Stream.of(
-                new DefaultThreadContextMap(true, util),
-                new CopyOnWriteSortedArrayThreadContextMap(util),
-                new GarbageFreeSortedArrayThreadContextMap(util));
+                new DefaultThreadContextMap(true, inheritableMapProperties.get()),
+                new CopyOnWriteSortedArrayThreadContextMap(inheritableMapProperties.get()),
+                new GarbageFreeSortedArrayThreadContextMap(inheritableMapProperties.get()));
     }
 
     @ParameterizedTest
     @MethodSource("defaultMaps")
-    void threadLocalNotInheritableByDefault(final ThreadContextMap contextMap) {
-        contextMap.put(KEY, "threadLocalNotInheritableByDefault");
-        verifyThreadContextValueFromANewThread(contextMap, null);
+    void threadLocalNotInheritableByDefault(final ThreadContextMap threadContext) {
+        threadContext.put(KEY, "threadLocalNotInheritableByDefault");
+        assertThreadContextValueOnANewThread(threadContext, KEY, null);
     }
 
     @ParameterizedTest
     @MethodSource("inheritableMaps")
-    void threadLocalInheritableIfConfigured(final ThreadContextMap contextMap) {
-        contextMap.put(KEY, "threadLocalInheritableIfConfigured");
-        verifyThreadContextValueFromANewThread(contextMap, "threadLocalInheritableIfConfigured");
+    void threadLocalInheritableIfConfigured(final ThreadContextMap threadContext) {
+        threadContext.put(KEY, "threadLocalInheritableIfConfigured");
+        assertThreadContextValueOnANewThread(threadContext, KEY, "threadLocalInheritableIfConfigured");
     }
 
-    private static void verifyThreadContextValueFromANewThread(
-            final ThreadContextMap contextMap, final String expected) {
-        final ExecutorService executorService = Executors.newSingleThreadExecutor();
-        try {
-            assertThat(executorService.submit(() -> contextMap.get(KEY)))
-                    .succeedsWithin(Duration.ofSeconds(1))
-                    .isEqualTo(expected);
-        } finally {
-            executorService.shutdown();
-        }
+    @ParameterizedTest
+    @MethodSource("localMaps")
+    void saveAndRestoreMap(final ThreadContextMap threadContext) {
+        assertContextDataCanBeSavedAndRestored(threadContext);
+    }
+
+    @ParameterizedTest
+    @MethodSource("localMaps")
+    void saveAndRestoreMapOnAnotherThread(final ThreadContextMap threadContext) {
+        assertContextDataCanBeTransferred(threadContext);
+    }
+
+    @ParameterizedTest
+    @MethodSource("localMaps")
+    void restoreAcceptsNull(final ThreadContextMap threadContext) {
+        assertNullValueClearsTheContextData(threadContext);
     }
 }
diff --git a/log4j-api/src/main/java/org/apache/logging/log4j/CloseableThreadContext.java b/log4j-api/src/main/java/org/apache/logging/log4j/CloseableThreadContext.java
index c585edf546..d5e23c9bc6 100644
--- a/log4j-api/src/main/java/org/apache/logging/log4j/CloseableThreadContext.java
+++ b/log4j-api/src/main/java/org/apache/logging/log4j/CloseableThreadContext.java
@@ -16,10 +16,10 @@
  */
 package org.apache.logging.log4j;
 
-import java.util.ArrayList;
-import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import org.apache.logging.log4j.spi.ExtendedThreadContext;
+import org.apache.logging.log4j.util.Lazy;
 
 /**
  * Adds entries to the {@link ThreadContext stack or map} and them removes them when the object is closed, e.g. as part
@@ -102,7 +102,8 @@ public class CloseableThreadContext {
     public static class Instance implements AutoCloseable {
 
         private int pushCount = 0;
-        private final Map<String, String> originalValues = new HashMap<>();
+        // Instantiated lazily to provide reusability
+        private final Lazy<Object> originalMap = Lazy.lazy(ExtendedThreadContext::saveMap);
 
         private Instance() {}
 
@@ -114,6 +115,7 @@ public class CloseableThreadContext {
          * @return the instance that will back out the changes when closed.
          */
         public Instance push(final String message) {
+            originalMap.get();
             ThreadContext.push(message);
             pushCount++;
             return this;
@@ -128,6 +130,7 @@ public class CloseableThreadContext {
          * @return the instance that will back out the changes when closed.
          */
         public Instance push(final String message, final Object[] args) {
+            originalMap.get();
             ThreadContext.push(message, args);
             pushCount++;
             return this;
@@ -143,10 +146,7 @@ public class CloseableThreadContext {
          * @return a new instance that will back out the changes when closed.
          */
         public Instance put(final String key, final String value) {
-            // If there are no existing values, a null will be stored as an old value
-            if (!originalValues.containsKey(key)) {
-                originalValues.put(key, ThreadContext.get(key));
-            }
+            originalMap.get();
             ThreadContext.put(key, value);
             return this;
         }
@@ -161,13 +161,8 @@ public class CloseableThreadContext {
          * @since 2.8
          */
         public Instance putAll(final Map<String, String> values) {
-            final Map<String, String> currentValues = ThreadContext.getContext();
+            originalMap.get();
             ThreadContext.putAll(values);
-            for (final String key : values.keySet()) {
-                if (!originalValues.containsKey(key)) {
-                    originalValues.put(key, currentValues.get(key));
-                }
-            }
             return this;
         }
 
@@ -180,6 +175,7 @@ public class CloseableThreadContext {
          * @since 2.8
          */
         public Instance pushAll(final List<String> messages) {
+            originalMap.get();
             for (final String message : messages) {
                 push(message);
             }
@@ -203,22 +199,10 @@ public class CloseableThreadContext {
         }
 
         private void closeMap() {
-            final Map<String, String> valuesToReplace = new HashMap<>(originalValues.size());
-            final List<String> keysToRemove = new ArrayList<>(originalValues.size());
-            for (final Map.Entry<String, String> entry : originalValues.entrySet()) {
-                final String key = entry.getKey();
-                final String originalValue = entry.getValue();
-                if (null == originalValue) {
-                    keysToRemove.add(key);
-                } else {
-                    valuesToReplace.put(key, originalValue);
-                }
-            }
-            if (!valuesToReplace.isEmpty()) {
-                ThreadContext.putAll(valuesToReplace);
-            }
-            if (!keysToRemove.isEmpty()) {
-                ThreadContext.removeAll(keysToRemove);
+            if (originalMap.isInitialized()) {
+                ExtendedThreadContext.restoreMap(originalMap.get());
+                // Prepare to be reused
+                originalMap.set(null);
             }
         }
 
diff --git a/log4j-api/src/main/java/org/apache/logging/log4j/internal/map/StringArrayThreadContextMap.java b/log4j-api/src/main/java/org/apache/logging/log4j/internal/map/StringArrayThreadContextMap.java
index 608ca77ffb..b5c79caab4 100644
--- a/log4j-api/src/main/java/org/apache/logging/log4j/internal/map/StringArrayThreadContextMap.java
+++ b/log4j-api/src/main/java/org/apache/logging/log4j/internal/map/StringArrayThreadContextMap.java
@@ -43,7 +43,7 @@ public class StringArrayThreadContextMap implements ThreadContextMap, ReadOnlySt
      */
     public static final String INHERITABLE_MAP = "isThreadContextMapInheritable";
 
-    private ThreadLocal<Object[]> threadLocalMapState;
+    private final ThreadLocal<Object[]> threadLocalMapState;
 
     public StringArrayThreadContextMap() {
         threadLocalMapState = new ThreadLocal<>();
@@ -160,6 +160,23 @@ public class StringArrayThreadContextMap implements ThreadContextMap, ReadOnlySt
         return UnmodifiableArrayBackedMap.getInstance(state).size();
     }
 
+    @Override
+    public Object save() {
+        return threadLocalMapState.get();
+    }
+
+    @Override
+    @SuppressWarnings("unchecked")
+    public Object restore(final Object contextMap) {
+        final Object current = save();
+        if (contextMap == null) {
+            clear();
+        } else {
+            threadLocalMapState.set((Object[]) contextMap);
+        }
+        return current;
+    }
+
     @Override
     public String toString() {
         final Object[] state = threadLocalMapState.get();
diff --git a/log4j-api/src/main/java/org/apache/logging/log4j/spi/CopyOnWriteSortedArrayThreadContextMap.java b/log4j-api/src/main/java/org/apache/logging/log4j/spi/CopyOnWriteSortedArrayThreadContextMap.java
index ca9f501c21..98b2a35cca 100644
--- a/log4j-api/src/main/java/org/apache/logging/log4j/spi/CopyOnWriteSortedArrayThreadContextMap.java
+++ b/log4j-api/src/main/java/org/apache/logging/log4j/spi/CopyOnWriteSortedArrayThreadContextMap.java
@@ -221,6 +221,23 @@ class CopyOnWriteSortedArrayThreadContextMap implements ReadOnlyThreadContextMap
         return map == null || map.isEmpty();
     }
 
+    @Override
+    public Object save() {
+        return localMap.get();
+    }
+
+    @Override
+    @SuppressWarnings("unchecked")
+    public Object restore(final Object contextMap) {
+        final Object current = save();
+        if (contextMap == null) {
+            clear();
+        } else {
+            localMap.set((StringMap) contextMap);
+        }
+        return current;
+    }
+
     @Override
     public String toString() {
         final StringMap map = localMap.get();
diff --git a/log4j-api/src/main/java/org/apache/logging/log4j/spi/DefaultThreadContextMap.java b/log4j-api/src/main/java/org/apache/logging/log4j/spi/DefaultThreadContextMap.java
index a07992d371..16b0d4266e 100644
--- a/log4j-api/src/main/java/org/apache/logging/log4j/spi/DefaultThreadContextMap.java
+++ b/log4j-api/src/main/java/org/apache/logging/log4j/spi/DefaultThreadContextMap.java
@@ -231,4 +231,21 @@ public class DefaultThreadContextMap implements ThreadContextMap, ReadOnlyString
         final Map<String, String> otherMap = other.getImmutableMapOrNull();
         return Objects.equals(map, otherMap);
     }
+
+    @Override
+    public Object save() {
+        return localMap.get();
+    }
+
+    @Override
+    @SuppressWarnings("unchecked")
+    public Object restore(final Object contextMap) {
+        final Object current = save();
+        if (contextMap == null) {
+            clear();
+        } else {
+            localMap.set((Map<String, String>) contextMap);
+        }
+        return current;
+    }
 }
diff --git a/log4j-api/src/main/java/org/apache/logging/log4j/spi/ExtendedThreadContext.java b/log4j-api/src/main/java/org/apache/logging/log4j/spi/ExtendedThreadContext.java
new file mode 100644
index 0000000000..6af0c06b49
--- /dev/null
+++ b/log4j-api/src/main/java/org/apache/logging/log4j/spi/ExtendedThreadContext.java
@@ -0,0 +1,63 @@
+/*
+ * 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.logging.log4j.spi;
+
+import org.apache.logging.log4j.util.ProviderUtil;
+
+/**
+ * Provides advanced access method to the {@link ThreadContextMap}.
+ * @since 2.24.0
+ */
+public final class ExtendedThreadContext {
+
+    private ExtendedThreadContext() {}
+
+    /**
+     * Saves the context data of the current thread
+     * <p>
+     *     Thread-safety note: The returned object can safely be passed off to another thread: future changes in the
+     *     underlying context data will not be reflected in the returned object.
+     * </p>
+     * @return An opaque representation of the context data.
+     * @see #restoreMap
+     */
+    public static Object saveMap() {
+        return getThreadContextMap().save();
+    }
+
+    /**
+     * Restores the context data of the current thread from a saved version
+     * @param contextMap An opaque representation of the context data obtained through a previous call to
+     *                   {@link #saveMap} or {@code restoreMap}.
+     * @return An opaque representation of the current thread's context data.
+     * @see #saveMap
+     */
+    public static Object restoreMap(final Object contextMap) {
+        return getThreadContextMap().restore(contextMap);
+    }
+
+    /**
+     * Clears the context data of the current thread
+     */
+    public static void clearMap() {
+        getThreadContextMap().clear();
+    }
+
+    private static ThreadContextMap getThreadContextMap() {
+        return ProviderUtil.getProvider().getThreadContextMapInstance();
+    }
+}
diff --git a/log4j-api/src/main/java/org/apache/logging/log4j/spi/GarbageFreeSortedArrayThreadContextMap.java b/log4j-api/src/main/java/org/apache/logging/log4j/spi/GarbageFreeSortedArrayThreadContextMap.java
index 1622a0c455..07c1dc10e6 100644
--- a/log4j-api/src/main/java/org/apache/logging/log4j/spi/GarbageFreeSortedArrayThreadContextMap.java
+++ b/log4j-api/src/main/java/org/apache/logging/log4j/spi/GarbageFreeSortedArrayThreadContextMap.java
@@ -210,6 +210,24 @@ class GarbageFreeSortedArrayThreadContextMap implements ReadOnlyThreadContextMap
         return map == null || map.isEmpty();
     }
 
+    @Override
+    public Object save() {
+        final StringMap map = localMap.get();
+        return map != null && !map.isEmpty() ? createStringMap(map) : null;
+    }
+
+    @Override
+    @SuppressWarnings("unchecked")
+    public Object restore(final Object contextMap) {
+        final Object current = save();
+        if (contextMap == null) {
+            clear();
+        } else {
+            localMap.set((StringMap) contextMap);
+        }
+        return current;
+    }
+
     @Override
     public String toString() {
         final StringMap map = localMap.get();
diff --git a/log4j-api/src/main/java/org/apache/logging/log4j/spi/NoOpThreadContextMap.java b/log4j-api/src/main/java/org/apache/logging/log4j/spi/NoOpThreadContextMap.java
index e79626f25a..96964720d2 100644
--- a/log4j-api/src/main/java/org/apache/logging/log4j/spi/NoOpThreadContextMap.java
+++ b/log4j-api/src/main/java/org/apache/logging/log4j/spi/NoOpThreadContextMap.java
@@ -62,4 +62,14 @@ public class NoOpThreadContextMap implements ThreadContextMap {
 
     @Override
     public void remove(final String key) {}
+
+    @Override
+    public Object save() {
+        return null;
+    }
+
+    @Override
+    public Object restore(final Object contextMap) {
+        return null;
+    }
 }
diff --git a/log4j-api/src/main/java/org/apache/logging/log4j/spi/ThreadContextMap.java b/log4j-api/src/main/java/org/apache/logging/log4j/spi/ThreadContextMap.java
index f0af90ddbb..2d0eba1ea8 100644
--- a/log4j-api/src/main/java/org/apache/logging/log4j/spi/ThreadContextMap.java
+++ b/log4j-api/src/main/java/org/apache/logging/log4j/spi/ThreadContextMap.java
@@ -18,6 +18,7 @@ package org.apache.logging.log4j.spi;
 
 import java.util.Map;
 import org.apache.logging.log4j.ThreadContext;
+import org.osgi.annotation.versioning.ProviderType;
 
 /**
  * Service provider interface to implement custom MDC behavior for {@link org.apache.logging.log4j.ThreadContext}.
@@ -26,6 +27,7 @@ import org.apache.logging.log4j.ThreadContext;
  * are accessible to applications via the {@link ThreadContext#getThreadContextMap()} method.
  * </p>
  */
+@ProviderType
 public interface ThreadContextMap {
 
     /**
@@ -33,6 +35,28 @@ public interface ThreadContextMap {
      */
     void clear();
 
+    /**
+     * Saves the context data of the current thread
+     * <p>
+     *     Thread-safety note: The returned object can safely be passed off to another thread: future changes in the
+     *     underlying context data will not be reflected in the returned object.
+     * </p>
+     * @return An opaque representation of the context data.
+     * @since 2.24.0
+     * @see #restore
+     */
+    Object save();
+
+    /**
+     * Restores the context data of the current thread from a saved version
+     * @param contextMap An opaque representation of the context data obtained through a previous call to
+     *                   {@link #save} or {@code restore}.
+     * @return An opaque representation of the current context data.
+     * @since 2.24.0
+     * @see #save
+     */
+    Object restore(Object contextMap);
+
     /**
      * Determines if the key is in the context.
      * @param key The key to locate.
diff --git a/log4j-perf-test/src/main/java/org/apache/logging/log4j/perf/nogc/OpenHashStringMap.java b/log4j-perf-test/src/main/java/org/apache/logging/log4j/perf/nogc/OpenHashStringMap.java
index 2e26a3b152..0c69e7eef8 100644
--- a/log4j-perf-test/src/main/java/org/apache/logging/log4j/perf/nogc/OpenHashStringMap.java
+++ b/log4j-perf-test/src/main/java/org/apache/logging/log4j/perf/nogc/OpenHashStringMap.java
@@ -798,6 +798,21 @@ public class OpenHashStringMap<K, V> implements StringMap, ThreadContextMap {
         return sb.toString();
     }
 
+    @Override
+    public Object save() {
+        return getImmutableMapOrNull();
+    }
+
+    @Override
+    public Object restore(final Object contextMap) {
+        final Object current = save();
+        clear();
+        if (contextMap instanceof Map) {
+            putAll((Map<? extends K, ? extends V>) contextMap);
+        }
+        return current;
+    }
+
     private static final class HashCommon {
         private HashCommon() {}
 
diff --git a/log4j-to-slf4j/src/main/java/org/apache/logging/slf4j/MDCContextMap.java b/log4j-to-slf4j/src/main/java/org/apache/logging/slf4j/MDCContextMap.java
index 4f2ef190bb..b136ead6df 100644
--- a/log4j-to-slf4j/src/main/java/org/apache/logging/slf4j/MDCContextMap.java
+++ b/log4j-to-slf4j/src/main/java/org/apache/logging/slf4j/MDCContextMap.java
@@ -23,6 +23,7 @@ import org.apache.logging.log4j.spi.CleanableThreadContextMap;
 import org.apache.logging.log4j.util.SortedArrayStringMap;
 import org.apache.logging.log4j.util.StringMap;
 import org.slf4j.MDC;
+import org.slf4j.spi.MDCAdapter;
 
 /**
  * Bind the ThreadContextMap to the SLF4J MDC.
@@ -35,60 +36,86 @@ public class MDCContextMap implements CleanableThreadContextMap {
         EMPTY_CONTEXT_DATA.freeze();
     }
 
+    private final MDCAdapter mdc;
+
+    public MDCContextMap() {
+        this(MDC.getMDCAdapter());
+    }
+
+    MDCContextMap(final MDCAdapter mdc) {
+        this.mdc = mdc;
+    }
+
     @Override
     public void put(final String key, final String value) {
-        MDC.put(key, value);
+        mdc.put(key, value);
     }
 
     @Override
     public void putAll(final Map<String, String> m) {
         for (final Entry<String, String> entry : m.entrySet()) {
-            MDC.put(entry.getKey(), entry.getValue());
+            mdc.put(entry.getKey(), entry.getValue());
         }
     }
 
     @Override
     public String get(final String key) {
-        return MDC.get(key);
+        return mdc.get(key);
     }
 
     @Override
     public void remove(final String key) {
-        MDC.remove(key);
+        mdc.remove(key);
     }
 
     @Override
     public void removeAll(final Iterable<String> keys) {
         for (final String key : keys) {
-            MDC.remove(key);
+            mdc.remove(key);
         }
     }
 
     @Override
     public void clear() {
-        MDC.clear();
+        mdc.clear();
+    }
+
+    @Override
+    public Object save() {
+        return mdc.getCopyOfContextMap();
+    }
+
+    @Override
+    public Object restore(final Object contextMap) {
+        final Object current = save();
+        if (contextMap == null) {
+            mdc.clear();
+        } else {
+            mdc.setContextMap((Map<String, String>) contextMap);
+        }
+        return current;
     }
 
     @Override
     public boolean containsKey(final String key) {
-        final Map<String, String> map = MDC.getCopyOfContextMap();
+        final Map<String, String> map = mdc.getCopyOfContextMap();
         return map != null && map.containsKey(key);
     }
 
     @Override
     public Map<String, String> getCopy() {
-        final Map<String, String> contextMap = MDC.getCopyOfContextMap();
+        final Map<String, String> contextMap = mdc.getCopyOfContextMap();
         return contextMap != null ? contextMap : new HashMap<>();
     }
 
     @Override
     public Map<String, String> getImmutableMapOrNull() {
-        return MDC.getCopyOfContextMap();
+        return mdc.getCopyOfContextMap();
     }
 
     @Override
     public boolean isEmpty() {
-        final Map<String, String> map = MDC.getCopyOfContextMap();
+        final Map<String, String> map = mdc.getCopyOfContextMap();
         return map == null || map.isEmpty();
     }
 
diff --git a/log4j-to-slf4j/src/test/java/org/apache/logging/slf4j/MDCContextMapTest.java b/log4j-to-slf4j/src/test/java/org/apache/logging/slf4j/MDCContextMapTest.java
index 6673de4597..b35be1013e 100644
--- a/log4j-to-slf4j/src/test/java/org/apache/logging/slf4j/MDCContextMapTest.java
+++ b/log4j-to-slf4j/src/test/java/org/apache/logging/slf4j/MDCContextMapTest.java
@@ -24,27 +24,55 @@ import static org.mockito.Mockito.verifyNoMoreInteractions;
 import static org.mockito.Mockito.when;
 
 import org.apache.logging.log4j.spi.ThreadContextMap;
+import org.apache.logging.log4j.test.spi.ThreadContextMapSuite;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.parallel.Execution;
+import org.junit.jupiter.api.parallel.ExecutionMode;
 import org.junitpioneer.jupiter.Issue;
-import org.slf4j.MDCTestHelper;
+import org.slf4j.helpers.BasicMDCAdapter;
 import org.slf4j.spi.MDCAdapter;
 
-class MDCContextMapTest {
+@Execution(ExecutionMode.CONCURRENT)
+class MDCContextMapTest extends ThreadContextMapSuite {
+
+    private static final String KEY = "key";
 
     @Test
     @Issue("https://github.com/apache/logging-log4j2/issues/1426")
     void nonNullGetCopy() {
-        final ThreadContextMap contextMap = new MDCContextMap();
         final MDCAdapter mockAdapter = mock(MDCAdapter.class);
+        final ThreadContextMap contextMap = new MDCContextMap(mockAdapter);
         when(mockAdapter.getCopyOfContextMap()).thenReturn(null);
-        final MDCAdapter adapter = MDCTestHelper.replaceMDCAdapter(mockAdapter);
-        try {
-            assertThat(contextMap.getImmutableMapOrNull()).isNull();
-            assertThat(contextMap.getCopy()).isNotNull();
-            verify(mockAdapter, times(2)).getCopyOfContextMap();
-            verifyNoMoreInteractions(mockAdapter);
-        } finally {
-            MDCTestHelper.replaceMDCAdapter(adapter);
-        }
+
+        assertThat(contextMap.getImmutableMapOrNull()).isNullOrEmpty();
+        assertThat(contextMap.getCopy()).isNotNull();
+        verify(mockAdapter, times(2)).getCopyOfContextMap();
+        verifyNoMoreInteractions(mockAdapter);
+    }
+
+    private ThreadContextMap createThreadContextMap() {
+        return new MDCContextMap(new BasicMDCAdapter());
+    }
+
+    @Test
+    void threadLocalInheritable() {
+        final ThreadContextMap threadContext = createThreadContextMap();
+        threadContext.put(KEY, "threadLocalInheritable");
+        assertThreadContextValueOnANewThread(threadContext, KEY, "threadLocalInheritable");
+    }
+
+    @Test
+    void saveAndRestoreMap() {
+        assertContextDataCanBeSavedAndRestored(createThreadContextMap());
+    }
+
+    @Test
+    void saveAndRestoreMapOnAnotherThread() {
+        assertContextDataCanBeTransferred(createThreadContextMap());
+    }
+
+    @Test
+    void restoreAcceptsNull() {
+        assertNullValueClearsTheContextData(createThreadContextMap());
     }
 }
diff --git a/src/changelog/.2.x.x/add_extended_thread_context.xml b/src/changelog/.2.x.x/add_extended_thread_context.xml
new file mode 100644
index 0000000000..e2a1ab575e
--- /dev/null
+++ b/src/changelog/.2.x.x/add_extended_thread_context.xml
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<entry xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+       xmlns="http://logging.apache.org/log4j/changelog"
+       xsi:schemaLocation="http://logging.apache.org/log4j/changelog https://logging.apache.org/log4j/changelog-0.1.3.xsd"
+       type="added">
+  <description format="asciidoc">Add `ExtendedThreadContext` SPI class to help the propagation of the thread context map.</description>
+</entry>