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:36 UTC

(logging-log4j2) branch feature/context-propagation created (now 21f95c63a5)

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

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


      at 21f95c63a5 Add support for thread context map propagation

This branch includes the following new commits:

     new 21f95c63a5 Add support for thread context map propagation

The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.



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

Posted by pk...@apache.org.
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>