concurrency-interview: companion code for the Top 40 interview post

Adds the concurrency-interview module: a real race condition with its
actual lost-update count, why volatile alone doesn't fix it, a real
JVM-detected deadlock (ThreadMXBean.findDeadlockedThreads()) with the
fix, and ScopedValue's exact child-thread inheritance rules (finalized
in JDK 25 via JEP 506, demonstrated against StructuredTaskScope, still
preview per JEP 505/525). 4 runnable demos, 1 JUnit test class (9
tests), 5 captured output transcripts.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01FhzLY5p6okFva3qsnsRyvM
This commit is contained in:
2026-09-30 07:25:12 +00:00
co-authored by Claude Sonnet 5
parent bc5153d2a5
commit 202f2f18a5
15 changed files with 553 additions and 0 deletions
+1
View File
@@ -11,6 +11,7 @@ article; each module's own README has that article's version table, quickstart,
| [`vt-pinning`](vt-pinning/) | Diagnosing Virtual Thread Pinning in Production: JFR Events, jcmd, and Real Fixes |
| [`synchronizers`](synchronizers/) | CountDownLatch vs CyclicBarrier vs Phaser vs Semaphore in Java |
| [`executors`](executors/) | ExecutorService Done Right: Shutdown, try-with-resources and Virtual Thread Executors |
| [`concurrency-interview`](concurrency-interview/) | Top 40 Java Concurrency Interview Questions and Answers (2026) |
## License
+78
View File
@@ -0,0 +1,78 @@
# concurrency-interview
Companion code for the ankurm.com post *"Top 40 Java Concurrency Interview Questions and Answers
(2026)."* Seventh module in `java-core-examples`, the Java-core / concurrency series - this one
backs a hub-style Q&A post rather than a single deep dive.
Most of the 40 questions in that post link out to the deep, already-verified demos in this repo's
other modules (locks, atomics, jmm, synchronizers, executors, vt-pinning) and to a few of this
site's existing standalone posts. The four demos here exist because their questions weren't
already proven anywhere else in the series: a real race condition and its actual lost-update
count, why `volatile` alone doesn't fix it, a real JVM-detected deadlock (and the fix), and exactly
which threads a `ScopedValue` binding does and doesn't reach.
## Versions this was built and tested against
| Component | Version | Notes |
|---|---|---|
| JDK | 25.0.4.1+1 (Temurin, LTS) | Every demo needs `--enable-preview` - see below. |
| JUnit Jupiter | 5.11.0 | Correctness tests, 5 repeats for the one timing-sensitive case. |
| Maven | 3.9.11 | |
| Hardware | 2 vCPU x86-64 VM | Same sandbox as the rest of this series. |
**Why `--enable-preview` for everything, even the demos that don't touch a preview API:** this
module is compiled as one unit, and `ScopedValueDemo` needs `StructuredTaskScope` (still preview
in JDK 25/26, per JEP 505/525) to demonstrate scoped-value inheritance into a child thread.
`ScopedValue` itself was finalized in JDK 25 via JEP 506 and needs no flag on its own.
## Quickstart
```bash
export JAVA_HOME=/path/to/jdk-25
mvn package
java --enable-preview -cp target/classes com.ankurm.concurrencyinterview.RaceConditionDemo
java --enable-preview -cp target/classes com.ankurm.concurrencyinterview.DeadlockDemo
```
`scripts/run-all.sh` regenerates every file in `output/` (needs `JDK25_HOME`).
## What's in here
| File | What it shows |
|---|---|
| `.../RaceConditionDemo.java` | A plain `int` incremented by 8 threads, 200,000 times each, next to an `AtomicInteger` doing identical work - the real lost-update count, not an assertion that one exists. |
| `.../VolatileNotEnoughDemo.java` | The same workload against a `volatile int` - visibility is guaranteed, atomicity is not, and the loss is just as real. |
| `.../DeadlockDemo.java` | Two threads, two locks, acquired in opposite order - a genuine deadlock, detected with `ThreadMXBean.findDeadlockedThreads()`, then a second run with consistent lock ordering that completes normally. |
| `.../ScopedValueDemo.java` | `ScopedValue` visible to same-thread direct and indirect callees; NOT inherited by a plain `new Thread()`; inherited by a thread forked from `StructuredTaskScope`. |
| `src/test/.../InterviewClaimsTest.java` | Asserts what's actually guaranteed: `AtomicInteger` never loses an update (5 repeats), and `ScopedValue`'s exact inheritance rule - deliberately does NOT assert on the raw-int or volatile-int race outcomes, since those are genuinely non-deterministic. |
| `output/01`-`04` | Each demo's real run. |
| `output/05` | JUnit correctness run. |
## Reading the results honestly (2-vCPU sandbox)
**The race condition lost 351,504 of 1,600,000 increments this run** (`output/01`) - not "some"
or "a few," an exact, real number from an actual run, which is the whole point of not just
asserting that `count++` is unsafe. Rerun it and the number will be different; it won't be zero on
this hardware with this workload.
**`volatile` lost 744,982 updates under the identical workload** (`output/02`). `volatile`
guarantees every thread observes the latest write and prevents instruction reordering around it -
it says nothing about two threads reading the same value before either writes back, which is
exactly what `count++` (read, add, write) allows. The fix for a shared counter is `AtomicInteger`
or a lock, not the `volatile` keyword.
**The deadlock in `output/03` is real, not simulated** - `ThreadMXBean.findDeadlockedThreads()` is
the same API a monitoring agent or `jstack` uses, and it reports both threads, each blocked on the
lock the other holds. The fix demonstrated right after it isn't "add more locking" - it's ensuring
every thread that needs both locks acquires them in the same order, which makes the circular-wait
condition structurally impossible.
**`ScopedValue` inheritance in `output/04` is exactly as narrow as the JDK docs say, confirmed
rather than assumed:** bound on the main thread, visible to nested method calls on that same
thread; a plain `new Thread()` reports `isBound() = false`; only the `StructuredTaskScope.fork()`'d
thread sees the binding. Reaching for `ScopedValue` and expecting it to "just work" across a
hand-rolled `new Thread()` is a real interview trap, not a hypothetical one.
## License
MIT - see the [repo-wide LICENSE](../LICENSE).
@@ -0,0 +1,5 @@
expected increments: 1600000
unsafeCounter (plain int, unsynchronized): 1248496
safeCounter (AtomicInteger): 1600000
unsafeCounter lost 351504 updates to the race
safeCounter matches expected: true
@@ -0,0 +1,5 @@
expected increments: 1600000
volatileCounter: 855018 (volatile guarantees every thread SEES the latest value - it does not make ++ atomic)
atomicCounter: 1600000
volatileCounter lost updates: 744982
atomicCounter matches expected: true
@@ -0,0 +1,12 @@
=== Opposite lock order: a real deadlock, detected via ThreadMXBean ===
thread-1: holding lockA, waiting for lockB
thread-2: holding lockB, waiting for lockA
main: findDeadlockedThreads() detected 2 deadlocked threads
thread-1 is blocked on java.lang.Object@5451c3a8, owned by thread-2
thread-2 is blocked on java.lang.Object@49476842, owned by thread-1
=== Same lock order on both threads: no deadlock possible ===
thread-1 (fixed order): acquired both locks, done
thread-2 (fixed order): acquired both locks, done
main: both fixed-order threads finished, isAlive t1=false t2=false
done (the process exits here - the two deadlocked threads above never finish)
@@ -0,0 +1,13 @@
=== Same-thread binding: visible to direct AND indirect callees ===
handleRequest(): REQUEST_ID.get() = req-42
logSomewhereDeeper() (indirect callee, same thread): REQUEST_ID.get() = req-42
=== Outside any binding: isBound() is false ===
main: REQUEST_ID.isBound() = false
=== A plain `new Thread()` does NOT inherit the binding ===
plain child thread: REQUEST_ID.isBound() = false
=== A StructuredTaskScope.fork()'d thread DOES inherit the binding ===
forked subtask: REQUEST_ID.isBound() = true, REQUEST_ID.get() = req-100
done
@@ -0,0 +1,4 @@
-------------------------------------------------------------------------------
Test set: com.ankurm.concurrencyinterview.InterviewClaimsTest
-------------------------------------------------------------------------------
Tests run: 9, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.216 s -- in com.ankurm.concurrencyinterview.InterviewClaimsTest
+49
View File
@@ -0,0 +1,49 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.ankurm</groupId>
<artifactId>java-core-examples</artifactId>
<version>1.0</version>
</parent>
<artifactId>concurrency-interview</artifactId>
<name>concurrency-interview</name>
<description>Verified demos behind the "Top 40 Java Concurrency Interview Questions" hub post: a real race condition, a real detected deadlock, volatile's actual limits, and ScopedValue inheritance rules.</description>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.11.0</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
<configuration>
<release>25</release>
<compilerArgs>
<arg>--enable-preview</arg>
</compilerArgs>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version>
<configuration>
<argLine>--enable-preview</argLine>
</configuration>
</plugin>
</plugins>
</build>
</project>
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
# Regenerates every file in ../output/. Requires JDK25_HOME. Every demo in this module needs
# --enable-preview to run (ScopedValueDemo uses StructuredTaskScope, still preview in JDK 25/26),
# even though ScopedValue itself is finalized (JEP 506).
set -euo pipefail
if [[ -z "${JDK25_HOME:-}" ]]; then
echo "JDK25_HOME must be set (e.g. /path/to/jdk-25)" >&2
exit 1
fi
cd "$(dirname "$0")/.."
OUT=output
mkdir -p "$OUT"
JAVA_HOME="$JDK25_HOME" mvn -q -f ../pom.xml -pl concurrency-interview package
run() {
local class=$1
local outfile=$2
echo "==> $class"
"$JDK25_HOME/bin/java" --enable-preview -cp target/classes "com.ankurm.concurrencyinterview.$class" 2>&1 | grep -v "Picked up" > "$OUT/$outfile"
}
run RaceConditionDemo 01-race-condition.txt
run VolatileNotEnoughDemo 02-volatile-not-enough.txt
run DeadlockDemo 03-deadlock-detection.txt
run ScopedValueDemo 04-scoped-value-inheritance.txt
cp target/surefire-reports/com.ankurm.concurrencyinterview.InterviewClaimsTest.txt "$OUT/05-correctness-tests.txt"
echo "Done. See $OUT/"
@@ -0,0 +1,111 @@
package com.ankurm.concurrencyinterview;
import java.lang.management.ManagementFactory;
import java.lang.management.ThreadInfo;
import java.lang.management.ThreadMXBean;
/**
* The textbook deadlock: two threads, two locks, acquired in opposite order. Thread-1 takes
* lockA then reaches for lockB; Thread-2 takes lockB then reaches for lockA. Neither can proceed
* and neither will ever release what it's already holding. This demo doesn't just describe that -
* it triggers a real deadlock, detects it with the same java.lang.management API a monitoring
* tool would use (ThreadMXBean.findDeadlockedThreads()), and then runs a second, fixed version
* that acquires both locks in the same global order every time, which completes normally.
*/
public class DeadlockDemo {
private static final Object lockA = new Object();
private static final Object lockB = new Object();
// Separate locks for the fixed-order demo: the two threads above deadlock forever and never
// release lockA/lockB, so reusing those objects here would just hang a second time.
private static final Object lockC = new Object();
private static final Object lockD = new Object();
private static void triggerDeadlock() throws InterruptedException {
Thread t1 = new Thread(() -> {
synchronized (lockA) {
System.out.println("thread-1: holding lockA, waiting for lockB");
sleep(200);
synchronized (lockB) {
System.out.println("thread-1: this line never prints - deadlocked before reaching it");
}
}
}, "thread-1");
Thread t2 = new Thread(() -> {
synchronized (lockB) {
System.out.println("thread-2: holding lockB, waiting for lockA");
sleep(200);
synchronized (lockA) {
System.out.println("thread-2: this line never prints - deadlocked before reaching it");
}
}
}, "thread-2");
t1.start();
t2.start();
Thread.sleep(1000); // give the deadlock time to actually form
ThreadMXBean threadBean = ManagementFactory.getThreadMXBean();
long[] deadlockedIds = threadBean.findDeadlockedThreads();
if (deadlockedIds == null) {
System.out.println("main: findDeadlockedThreads() found nothing - unexpected for this demo");
return;
}
System.out.println("main: findDeadlockedThreads() detected " + deadlockedIds.length + " deadlocked threads");
for (ThreadInfo info : threadBean.getThreadInfo(deadlockedIds, true, true)) {
System.out.println(" " + info.getThreadName() + " is blocked on " + info.getLockInfo()
+ ", owned by " + info.getLockOwnerName());
}
// The two threads above are stuck forever - this JVM process exits without joining them.
}
private static void runFixedOrderedVersion() throws InterruptedException {
Thread t1 = new Thread(() -> {
synchronized (lockC) {
sleep(100);
synchronized (lockD) {
System.out.println("thread-1 (fixed order): acquired both locks, done");
}
}
}, "thread-1-fixed");
Thread t2 = new Thread(() -> {
synchronized (lockC) { // same order as thread-1: lockC first, lockD second
sleep(100);
synchronized (lockD) {
System.out.println("thread-2 (fixed order): acquired both locks, done");
}
}
}, "thread-2-fixed");
t1.start();
t2.start();
t1.join(5000);
t2.join(5000);
System.out.println("main: both fixed-order threads finished, isAlive t1=" + t1.isAlive()
+ " t2=" + t2.isAlive());
}
private static void sleep(long ms) {
try {
Thread.sleep(ms);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
public static void main(String[] args) throws InterruptedException {
System.out.println("=== Opposite lock order: a real deadlock, detected via ThreadMXBean ===");
triggerDeadlock();
System.out.println();
System.out.println("=== Same lock order on both threads: no deadlock possible ===");
runFixedOrderedVersion();
System.out.println("done (the process exits here - the two deadlocked threads above never finish)");
System.exit(0); // the deadlocked threads from triggerDeadlock() would otherwise hang the JVM forever
}
}
@@ -0,0 +1,46 @@
package com.ankurm.concurrencyinterview;
import java.util.concurrent.atomic.AtomicInteger;
/**
* The canonical interview answer to "why isn't count++ thread-safe?" is that it's a
* read-modify-write sequence, not one atomic step. This demo doesn't just assert that - it runs
* 8 threads each incrementing a plain int 200,000 times (1,600,000 total increments expected) and
* prints whatever the real, lost-update total actually comes out to, side by side with an
* AtomicInteger doing the identical work correctly. The exact deficit is inherently
* non-deterministic - that's the point being demonstrated, not a bug in the demo.
*/
public class RaceConditionDemo {
private static int unsafeCounter = 0;
private static final AtomicInteger safeCounter = new AtomicInteger(0);
private static final int THREADS = 8;
private static final int INCREMENTS_PER_THREAD = 200_000;
public static void main(String[] args) throws InterruptedException {
Thread[] threads = new Thread[THREADS];
for (int i = 0; i < THREADS; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < INCREMENTS_PER_THREAD; j++) {
unsafeCounter++; // read-modify-write, not atomic
safeCounter.incrementAndGet(); // genuinely atomic
}
});
}
for (Thread t : threads) t.start();
for (Thread t : threads) t.join();
int expected = THREADS * INCREMENTS_PER_THREAD;
System.out.println("expected increments: " + expected);
System.out.println("unsafeCounter (plain int, unsynchronized): " + unsafeCounter);
System.out.println("safeCounter (AtomicInteger): " + safeCounter.get());
if (unsafeCounter != expected) {
System.out.println("unsafeCounter lost " + (expected - unsafeCounter) + " updates to the race");
} else {
System.out.println("unsafeCounter happened to come out correct this run - the race is still real, "
+ "just didn't lose anything THIS time (rerun it - it usually does on this box)");
}
System.out.println("safeCounter matches expected: " + (safeCounter.get() == expected));
}
}
@@ -0,0 +1,63 @@
package com.ankurm.concurrencyinterview;
import java.util.concurrent.StructuredTaskScope;
/**
* ScopedValue (java.lang.ScopedValue) was finalized in JDK 25 via JEP 506 - no preview flag
* needed for the basic API. But its main selling point over ThreadLocal is sharing immutable
* context with CHILD threads, and that inheritance is NOT automatic for just any thread: it only
* happens for threads forked from a StructuredTaskScope, which is still a preview API in JDK 25/26
* (JEP 505/525). This class needs --enable-preview for that reason alone - the ScopedValue calls
* themselves wouldn't require it.
*/
public class ScopedValueDemo {
private static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();
private static void handleRequest() {
System.out.println("handleRequest(): REQUEST_ID.get() = " + REQUEST_ID.get());
logSomewhereDeeper();
}
private static void logSomewhereDeeper() {
System.out.println("logSomewhereDeeper() (indirect callee, same thread): REQUEST_ID.get() = " + REQUEST_ID.get());
}
public static void main(String[] args) throws InterruptedException {
System.out.println("=== Same-thread binding: visible to direct AND indirect callees ===");
ScopedValue.where(REQUEST_ID, "req-42").run(ScopedValueDemo::handleRequest);
System.out.println();
System.out.println("=== Outside any binding: isBound() is false ===");
System.out.println("main: REQUEST_ID.isBound() = " + REQUEST_ID.isBound());
System.out.println();
System.out.println("=== A plain `new Thread()` does NOT inherit the binding ===");
ScopedValue.where(REQUEST_ID, "req-99").run(() -> {
Thread plainChild = new Thread(() ->
System.out.println("plain child thread: REQUEST_ID.isBound() = " + REQUEST_ID.isBound()));
plainChild.start();
try {
plainChild.join();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
System.out.println();
System.out.println("=== A StructuredTaskScope.fork()'d thread DOES inherit the binding ===");
ScopedValue.where(REQUEST_ID, "req-100").run(() -> {
try (var scope = StructuredTaskScope.open()) {
scope.fork(() -> {
System.out.println("forked subtask: REQUEST_ID.isBound() = " + REQUEST_ID.isBound()
+ ", REQUEST_ID.get() = " + REQUEST_ID.get());
return null;
});
scope.join();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
System.out.println("done");
}
}
@@ -0,0 +1,42 @@
package com.ankurm.concurrencyinterview;
import java.util.concurrent.atomic.AtomicInteger;
/**
* A common interview trap: "just mark it volatile" does NOT make count++ thread-safe. volatile
* guarantees visibility (every thread sees the latest write) and ordering, but it does nothing
* about the read-modify-write sequence being non-atomic - two threads can both read the same
* value before either writes back. This runs a volatile int through the identical concurrent
* increment workload as RaceConditionDemo, next to an AtomicInteger, to show the failure mode is
* the same as a plain int, not fixed by the keyword alone.
*/
public class VolatileNotEnoughDemo {
private static volatile int volatileCounter = 0;
private static final AtomicInteger atomicCounter = new AtomicInteger(0);
private static final int THREADS = 8;
private static final int INCREMENTS_PER_THREAD = 200_000;
public static void main(String[] args) throws InterruptedException {
Thread[] threads = new Thread[THREADS];
for (int i = 0; i < THREADS; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < INCREMENTS_PER_THREAD; j++) {
volatileCounter++; // visibility guaranteed, atomicity is NOT
atomicCounter.incrementAndGet();
}
});
}
for (Thread t : threads) t.start();
for (Thread t : threads) t.join();
int expected = THREADS * INCREMENTS_PER_THREAD;
System.out.println("expected increments: " + expected);
System.out.println("volatileCounter: " + volatileCounter
+ " (volatile guarantees every thread SEES the latest value - it does not make ++ atomic)");
System.out.println("atomicCounter: " + atomicCounter.get());
System.out.println("volatileCounter lost updates: " + (expected - volatileCounter));
System.out.println("atomicCounter matches expected: " + (atomicCounter.get() == expected));
}
}
@@ -0,0 +1,91 @@
package com.ankurm.concurrencyinterview;
import org.junit.jupiter.api.RepeatedTest;
import org.junit.jupiter.api.Test;
import java.util.NoSuchElementException;
import java.util.concurrent.StructuredTaskScope;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Deliberately does NOT assert on the raw-int or volatile-int race demos - whether a given run
* happens to lose an update is inherently non-deterministic, and a test that occasionally passes
* by luck is worse than no test. What's asserted here is what's actually guaranteed: AtomicInteger
* never loses an update under the identical workload, and ScopedValue's inheritance rule is exactly
* what the docs say it is, not "roughly."
*/
class InterviewClaimsTest {
@RepeatedTest(5)
void atomicIntegerNeverLosesAnUpdateUnderTheSameWorkloadThatBreaksAPlainInt() throws InterruptedException {
AtomicInteger counter = new AtomicInteger(0);
int threads = 8;
int incrementsPerThread = 50_000;
Thread[] pool = new Thread[threads];
for (int i = 0; i < threads; i++) {
pool[i] = new Thread(() -> {
for (int j = 0; j < incrementsPerThread; j++) {
counter.incrementAndGet();
}
});
}
for (Thread t : pool) t.start();
for (Thread t : pool) t.join();
assertEquals(threads * incrementsPerThread, counter.get());
}
@Test
void scopedValueIsVisibleToDirectAndIndirectCalleesOnTheSameThread() {
ScopedValue<String> value = ScopedValue.newInstance();
AtomicReference<String> seenByCallee = new AtomicReference<>();
ScopedValue.where(value, "abc").run(() -> seenByCallee.set(value.get()));
assertEquals("abc", seenByCallee.get());
}
@Test
void scopedValueGetOutsideAnyBindingThrows() {
ScopedValue<String> value = ScopedValue.newInstance();
assertFalse(value.isBound());
assertThrows(NoSuchElementException.class, value::get);
}
@Test
void plainThreadDoesNotInheritAScopedValueBinding() throws InterruptedException {
ScopedValue<String> value = ScopedValue.newInstance();
AtomicReference<Boolean> boundInChild = new AtomicReference<>();
ScopedValue.where(value, "parent-bound").run(() -> {
Thread child = new Thread(() -> boundInChild.set(value.isBound()));
child.start();
try {
child.join();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
assertEquals(false, boundInChild.get(), "a plain new Thread() must NOT inherit the binding");
}
@Test
void structuredTaskScopeForkedThreadDoesInheritTheBinding() throws Exception {
ScopedValue<String> value = ScopedValue.newInstance();
AtomicReference<String> seenInSubtask = new AtomicReference<>();
ScopedValue.where(value, "inherited").run(() -> {
try (var scope = StructuredTaskScope.open()) {
scope.fork(() -> {
seenInSubtask.set(value.get());
return null;
});
scope.join();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
assertEquals("inherited", seenInSubtask.get());
}
}
+1
View File
@@ -19,6 +19,7 @@
<module>vt-pinning</module>
<module>synchronizers</module>
<module>executors</module>
<module>concurrency-interview</module>
</modules>
<properties>