diff --git a/monticore-runtime/src/main/java/de/monticore/utils/MCThread2Instance.java b/monticore-runtime/src/main/java/de/monticore/utils/MCThread2Instance.java
new file mode 100644
index 0000000000..f62c0eaf1d
--- /dev/null
+++ b/monticore-runtime/src/main/java/de/monticore/utils/MCThread2Instance.java
@@ -0,0 +1,75 @@
+// (c) https://github.com/MontiCore/monticore
+package de.monticore.utils;
+
+import de.se_rwth.commons.logging.Log;
+
+import java.util.function.Supplier;
+
+/**
+ * A Map which maps every thread to a different instance.
+ *
+ * This class is to be used for static delegates,
+ * to allow usage of MontiCore using multiple threads.
+ *
+ * Accessing of the instance of another thread is deliberately not possible.
+ */
+public class MCThread2Instance {
+
+ protected static final String LOG_NAME = MCThread2Instance.class.getName();
+
+ // As of writing, this cannot do anything that ThreadLocal cannot do
+ // without a simple wrapper;
+ // Thus, currently this simply wraps ThreadLocal.
+ protected ThreadLocal threadLocal;
+
+ public MCThread2Instance() {
+ this.threadLocal = new ThreadLocal();
+ }
+
+ public MCThread2Instance(T initialValue) {
+ this(() -> initialValue);
+ }
+
+ public MCThread2Instance(Supplier initialValueSupplier) {
+ this.threadLocal = ThreadLocal.withInitial(initialValueSupplier);
+ }
+
+ /**
+ * @param newInstance the new instance specific to this thread.
+ */
+ public void set(T newInstance) {
+ Log.errorIfNull(newInstance);
+ getThreadLocal().set(newInstance);
+ }
+
+ /**
+ * @return the instance specific to this thread.
+ */
+ public T get() {
+ T instance = _internal_get();
+ if (instance == null) {
+ Log.error(
+ "0x72100 internal error: "
+ + "Tried to get the thread-specific instance out of "
+ + MCThread2Instance.class.getName()
+ + ", but no thread-specific instance has been set yet."
+ + System.lineSeparator() + "Thread: " + Thread.currentThread()
+ );
+ }
+ return instance;
+ }
+
+ // internal
+
+ /**
+ * @return thread local instance (can be null)
+ */
+ protected T _internal_get() {
+ return getThreadLocal().get();
+ }
+
+ protected ThreadLocal getThreadLocal() {
+ return this.threadLocal;
+ }
+
+}
diff --git a/monticore-runtime/src/test/java/de/monticore/utils/MCThread2InstanceTest.java b/monticore-runtime/src/test/java/de/monticore/utils/MCThread2InstanceTest.java
new file mode 100644
index 0000000000..682769fe5a
--- /dev/null
+++ b/monticore-runtime/src/test/java/de/monticore/utils/MCThread2InstanceTest.java
@@ -0,0 +1,109 @@
+// (c) https://github.com/MontiCore/monticore
+package de.monticore.utils;
+
+import de.se_rwth.commons.logging.Finding;
+import de.se_rwth.commons.logging.Log;
+import de.se_rwth.commons.logging.LogStub;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.stream.Collectors;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+
+class MCThread2InstanceTest {
+
+ @BeforeEach
+ public void setupLog() {
+ LogStub.init();
+ Log.enableFailQuick(false);
+ }
+
+ @Test
+ public void testDefaultConstructor() {
+ MCThread2Instance mcThreadLocal = new MCThread2Instance<>();
+ assertNull(mcThreadLocal.get());
+ assertEquals(1, Log.getErrorCount());
+ assertTrue(Log.getFindings().get(0).getMsg().startsWith("0x72100"));
+ runInThread(() -> assertNull(mcThreadLocal.get()));
+ // deliberately not checking Log again
+ }
+
+ @Test
+ public void testConstructorWithInitialValue() {
+ Box box = new Box(1);
+ MCThread2Instance mcThreadLocal = new MCThread2Instance<>(box);
+ assertEquals(1, mcThreadLocal.get().value);
+ runInThread(() -> assertEquals(1, mcThreadLocal.get().value));
+ mcThreadLocal.get().value = 2;
+ assertEquals(2, mcThreadLocal.get().value);
+ runInThread(() -> assertEquals(2, mcThreadLocal.get().value));
+ assertNoFindings();
+ }
+
+ @Test
+ public void testConstructorWithSupplier() {
+ AtomicInteger i = new AtomicInteger(0);
+ MCThread2Instance mcThreadLocal = new MCThread2Instance<>(
+ () -> new Box(i.getAndIncrement())
+ );
+ assertEquals(0, mcThreadLocal.get().value);
+ runInThread(() -> assertEquals(1, mcThreadLocal.get().value));
+ runInThread(() -> assertEquals(2, mcThreadLocal.get().value));
+ }
+
+ @Test
+ public void testSet() {
+ MCThread2Instance mcThreadLocal = new MCThread2Instance<>();
+ Box box = new Box(4);
+ mcThreadLocal.set(box);
+ assertEquals(4, mcThreadLocal.get().value);
+ runInThread(() -> {
+ mcThreadLocal.set(new Box(5));
+ });
+ runInThread(() -> {
+ assertNull(mcThreadLocal.get());
+ assertEquals(1, Log.getErrorCount());
+ });
+ assertEquals(4, mcThreadLocal.get().value);
+ }
+
+ // internals
+
+ protected void runInThread(Runnable task) {
+ Thread thread = new Thread(task);
+ thread.start();
+ try {
+ thread.join();
+ }
+ catch (InterruptedException e) {
+ fail(e);
+ }
+ }
+
+ protected static void assertNoFindings() {
+ assertTrue(Log.getFindings().isEmpty(),
+ "Expected no Log findings, but got:"
+ + System.lineSeparator() + getAllFindingsAsString()
+ );
+ }
+
+ protected static String getAllFindingsAsString() {
+ return Log.getFindings().stream()
+ .map(Finding::buildMsg)
+ .collect(Collectors.joining(System.lineSeparator()));
+ }
+
+ protected static class Box {
+ public int value;
+
+ public Box(int value) {
+ this.value = value;
+ }
+ }
+
+}