Blogg

Här finns tekniska artiklar, presentationer och nyheter om arkitektur och systemutveckling. Håll dig uppdaterad, följ oss på LinkedIn

Callista medarbetare Magnus Ekstrand

ScopedValue i Java - ett alternativ till ThreadLocal

// Magnus Ekstrand

Java 25 släpptes i september 2025 som en ny LTS-version och en av de funktioner som förtjänar mer uppmärksamhet är Scoped Values (JEP 506) som blivit en färdig och permanent del av Java API:et. Den introducerades som incubator redan i Java 20 och preview i Java 21 men har nu mognat och är klar för att användas i produktion.

Problemet med ThreadLocal

De flesta Java-utvecklare känner igen mönstret: du vill dela kontext — ett request-ID, en användarsession eller ett transaktions-ID — mellan metoder utan att tvingas skicka det som parameter genom hela anropskedjan. Lösningen har sedan Java 1.2 varit ThreadLocal.

Men ThreadLocal har tre stora svagheter:

  • Mutabilitet – kod som kommer åt en ThreadLocal kan ändra dess bindning när som helst med set(). Det gör dataflödet svårt att följa och felsöka.
  • Oklar livslängd – värdet ligger kvar i tråden tills någon explicitly anropar remove(). Glömmer man det riskerar man minnesläckor och att känslig data läcker mellan förfrågningar i trådpooler.
  • Hög minneskostnad vid arv av trådar – när man använder InheritableThreadLocal kopieras hela kartan av en tråds lokala värden till varje ny barntråd. Det blir snabbt en tung belastning när applikationer skapar miljontals virtuella trådar.

Snabbjämförelse: ThreadLocal vs ScopedValue

Egenskap ThreadLocal ScopedValue
Datastruktur Muterbar (set()) Oföränderlig (Läsbar/Read-only)
Livslängd Odefinierad (kräver manuell remove()) Tydligt avgränsad av scopet (run/call)
Minnesåtgång Kopierar data per ny barntråd Låg (delar samma bindning i minnet)
Säkerhet Alla med referens kan ändra värdet Ingen kan ändra värdet i ett pågående scope

Varför virtuella trådar spelar roll

En ThreadLocal lagrar ett eget värde i varje tråd. Det fungerar i traditionella trådpooler, men skalar dåligt när applikationer skapar stora mängder virtuella trådar (Project Loom).

ScopedValue är byggd för det vanligaste fallet: kontexten ska bara skickas i en riktning — från en anropande metod till de metoder den anropar.

I stället för att kopiera hela datastrukturer till nya trådar, delar ScopedValue en oföränderlig trädstruktur av bindningar. När en deluppgift startas pekar den helt enkelt tillbaka på föräldratrådens scope. Ingen datakopiering sker.

Viktig detalj vid arv av trådar: Vanliga trådar som skapas med new Thread() eller ExecutorService ärver inte ScopedValue-bindningar. Arv sker enbart för deluppgifter som startas inom en StructuredTaskScope.

Vad är en ScopedValue?

En ScopedValue låter en metod dela kontext med alla metoder den anropar, längre ned i anropskedjan, utan att skicka med parametrar. Bindningen gäller under en avgränsad körning och blir automatiskt obunden när körningen avslutas.

Själva bindningen kan inte ändras. Objektet som binds kan dock (teoretiskt) vara muterbart, så använd helst objekt som Javas record som inte kan ändras.

Grundmönstret ser ut så här:

public class Example {
    private static final ScopedValue<String> TENANT_ID = ScopedValue.newInstance();

    public static void main(String[] args) throws Exception {
        // För operationer utan returvärde (Runnable)
        ScopedValue.where(TENANT_ID, "tenant-123")
                   .run(Example::handleRequest);

        // För operationer som returnerar ett resultat (Callable)
        String result = ScopedValue.where(TENANT_ID, "tenant-456")
                                   .call(() -> "Behandlat svar för " + TENANT_ID.get());

        System.out.println(result);
    }

    static void handleRequest() {
        System.out.println("Tenant: " + TENANT_ID.get());
    }
}

Det viktiga här är run() och call(). Värdet "tenant-123" kan läsas i blocket och i alla underliggande metodanrop. Så fort run() eller call() returnerar är TENANT_ID obundet igen. Ingen risk för minnesläckor!

Ingen set()-metod — och det är en styrka

En medveten designprincip är att ScopedValue saknar en set()-metod. Det ger vad som kallas Capability-based access control:

  • Eftersom variablen oftast deklareras som private static final, kan endast klassen som äger den binda eller läsa den.
  • Ingen underliggande metod eller tredjepartsbibliotek kan av misstag ändra eller “smutsa ner” kontexten för resten av anropskedjan.

När värdet kan saknas

API:et erbjuder säkra sätt att läsa värdet:

// Standardvärde om inget är bundet
String tenant = TENANT_ID.orElse("anonymous");

// Kasta ett specifikt exception om inget är bundet
String tenant = TENANT_ID.orElseThrow(() -> new IllegalStateException("Saknar användarkontext"));

// Kontrollera om värdet är bundet innan det läses
if (TENANT_ID.isBound()) {
    String tenant = TENANT_ID.get();
}

Notera: I Java 25 accepterar orElse() inte längre null som argument. Men vill man går fortfarande att binda ett faktiskt null-värde explicit med ScopedValue.where(KEY, null).

Nästlade scopes och tillfällig ombindning (Skuggning)

En användbar egenskap är att ScopedValue.where() kan nästlas. Ett inre block kan tillfälligt binda om samma ScopedValue till ett nytt värde.

Detta kallas skuggning (shadowing) — det fungerar precis som lokala variabler i kodblock: det inre blocket ser det nya värdet, men så fort blocket lämnas återställs det yttre värdet automatiskt, även om ett exception skulle kastas.

Exempel på rapportgenerering:

private static final ScopedValue<String> RAPPORTNIVA = ScopedValue.newInstance();

public void genereraRapport() {
    ScopedValue.where(RAPPORTNIVA, "detaljerad").run(() -> {
        skrivRapportHeader(); // Värdet är "detaljerad"

        ScopedValue.where(RAPPORTNIVA, "sammanfattad").run(() -> {
            skrivDelsummering(); // Värdet är "sammanfattad"
        });

        skrivRapportFooter(); // Värdet är "detaljerad" igen!
    });
}

Motsvarande mönster med ThreadLocal kräver manuell hantering:

private static final ThreadLocal<String> RAPPORTNIVA_TL = new ThreadLocal<>();

String tidigare = RAPPORTNIVA_TL.get();
RAPPORTNIVA_TL.set("sammanfattad");
try {
    skrivDelsummering();
} finally {
    if (tidigare == null) {
        RAPPORTNIVA_TL.remove();
    } else {
        RAPPORTNIVA_TL.set(tidigare);
    }
}

Glömmer du finally-blocket i ThreadLocal-varianten kommer inte det gamla värdet att återställas. Med ScopedValue är återställningen garanterad.

Avancerat: tillsammans med strukturerad concurrency

ScopedValue är en permanent del av Java 25. Strukturerad concurrency (StructuredTaskScope) är däremot fortfarande i preview i Java 25 (JEP 505). För att använda StructuredTaskScope behövs därför flaggan --enable-preview.

De två funktionerna kompletterar varandra väl: delad kontext och parallella deluppgifter får en tydlig och avgränsad livslängd.

Här är ett exempel på en webbtjänst som hämtar användare och ordrar parallellt. Bägge deluppgifterna behöver kontext för loggning:

import java.util.List;
import java.util.concurrent.StructuredTaskScope;

record RequestContext(String requestId, String tenantId) {}
record User(String userId) {}
record Order(String orderId) {}
record OrderSummary(User user, List<Order> orders) {}

public class Service {
    private static final ScopedValue<RequestContext> REQUEST_CTX = ScopedValue.newInstance();

    public OrderSummary handleRequest(String userId, String requestId, String tenantId) throws Exception {
        var ctx = new RequestContext(requestId, tenantId);

        // 1. Bind kontexten
        return ScopedValue.where(REQUEST_CTX, ctx).call(() -> {
            try (var scope = StructuredTaskScope.open()) {
                // 2. Starta deluppgifter i egna virtuella trådar.
                // REQUEST_CTX ärvs automatiskt till båda uppgifterna!
                var userTask = scope.fork(() -> fetchUser(userId));
                var ordersTask = scope.fork(() -> fetchRecentOrders(userId));

                scope.join(); // Vänta tills båda är klara

                return new OrderSummary(userTask.get(), ordersTask.get());
            }
        });
    }

    private User fetchUser(String userId) {
        // userId skickas som parameter (affärsdata)
        // REQUEST_CTX hämtas automatiskt i log()-metoden!
        log("Hämtar användare " + userId);
        return new User(userId);
    }

    private List<Order> fetchRecentOrders(String userId) {
        log("Hämtar ordrar för " + userId);
        return List.of(new Order("ord-1"));
    }

    private void log(String message) {
        // Ingen RequestContext skickades som parameter hit,
        // men den finns tillgänglig i scopet!
        RequestContext ctx = REQUEST_CTX.get();
        System.out.printf("[%s][%s] %s%n", ctx.requestId(), ctx.tenantId(), message);
    }
}

Kompilering och exekvering i Java 25 (eftersom StructuredTaskScope är preview):

javac --release 25 --enable-preview Service.java
java --enable-preview Service

(Obs: Om du enbart använder ScopedValue utan StructuredTaskScope behöver du inte --enable-preview i Java 25!)

När ska man använda ScopedValue — och inte?

Använd ScopedValue när:

  • Datan utgör omgivande kontext (t.ex. request-ID, tenant-ID eller säkerhetstoken).
  • Värdet ska vara skrivskyddat och oföränderligt.
  • Livslängden är tydligt avgränsad i koden.
  • En eller flera underliggande metoder i sin tur behöver använda datan.

Undvik ScopedValue när:

  • Värdet är en central del av metodens indata (affärslogik) — då bör det istället skickas som en vanlig parameter.
  • Datan behöver kunna ändras (muteras) under körningens gång.
  • Det är oklart inom vilket scope värdet ska gälla.

Sammanfattning

Scoped Values i Java 25 löser ett klassiskt problem med trådlokal data på ett säkrare, renare och betydligt mer minneseffektivt sätt än ThreadLocal.

Genom att kombinera oföränderlig kontext med tydliga exekverings-scopes minskar risken för minnesläckor och dataläckage till noll. Tillsammans med virtuella trådar och strukturerad concurrency bildar det en modern grund för samordnad programmering i Java.

Exempelkod hittas på GitHub

Tack för att du läser Callistas blogg.
Hjälp oss att nå ut med information genom att dela nyheter och artiklar i ditt nätverk.

Kommentarer