This is an automated email from the ASF dual-hosted git repository.
mawiesne pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/opennlp.git
The following commit(s) were added to refs/heads/main by this push:
new ef0a354cf OPENNLP-1883: Make stemmers thread-safe, add StemmerFactory
and per-thread stem caching (#1163)
ef0a354cf is described below
commit ef0a354cf11ae0ca00dcd7ab8d0d473b48e6fd8e
Author: Kristian Rickert <[email protected]>
AuthorDate: Mon Jul 20 11:15:41 2026 -0400
OPENNLP-1883: Make stemmers thread-safe, add StemmerFactory and per-thread
stem caching (#1163)
* SnowballStemmer now routes each call to a per-thread generated engine via
OwnerOrPerThreadState, the same pattern as the thread-safe *ME components,
so one instance can be shared across threads without touching the
generated Snowball code. StemmerFactory (api) captures stemmer
configuration as an immutable, thread-safe seam for stateful engines;
SharingStemmer adapts a factory into a shareable Stemmer for
implementations that are not thread-safe themselves (e.g. PorterStemmer).
NormalizationProfile.matchingAnalyzer() and TermAnalyzer are now safe to
share across threads when stemming.
* Add multi-threaded stemmer eval and expand concurrency unit tests
* Add SnowballStemmer JMH benchmark: thread-safe vs pre-patch baseline
* Add CachingStemmer: per-thread LRU memoization of word-to-stem mappings
Natural text is Zipf-distributed, so the same words are stemmed
constantly. CachingStemmer wraps a StemmerFactory with a bounded
per-thread LRU (default 1024 entries) following the same
OwnerOrPerThreadState pattern as the *ME components: no cross-thread
sharing, thread-safe regardless of the delegate. On the JMH zipf
workload the cache is a ~34x throughput multiplier; on a cache-hostile
uniform workload over 8x the cache capacity it still breaks even.
Results and a corrected JMH invocation are documented in BENCHMARKS.md.
* Route TermAnalyzer factory-based stemming through CachingStemmer
* Extract a DelegatingStemmer base to share the per-thread wrapper plumbing
* Add a stemmer manual chapter with a mirror-tested usage example via
docbkx/stemmer.xml documenting the Stemmer interface, StemmerFactory,
and the sharing and caching wrappers
---
.../main/java/opennlp/tools/stemmer/Stemmer.java | 25 +-
.../stemmer/{Stemmer.java => StemmerFactory.java} | 13 +-
opennlp-core/opennlp-runtime/BENCHMARKS.md | 84 ++++-
.../tools/stemmer/CachingStemmerBenchmark.java | 199 +++++++++++
.../stemmer/snowball/SnowballStemmerBenchmark.java | 153 +++++++++
.../java/opennlp/tools/stemmer/CachingStemmer.java | 152 +++++++++
.../opennlp/tools/stemmer/DelegatingStemmer.java | 70 ++++
.../java/opennlp/tools/stemmer/PorterStemmer.java | 7 +
.../tools/stemmer/PorterStemmerFactory.java | 17 +-
.../java/opennlp/tools/stemmer/SharingStemmer.java | 67 ++++
.../tools/stemmer/snowball/SnowballStemmer.java | 135 +++++---
.../stemmer/snowball/SnowballStemmerFactory.java | 109 ++++++
.../util/normalizer/NormalizationProfile.java | 23 +-
.../tools/util/normalizer/TermAnalyzer.java | 43 ++-
.../opennlp/tools/stemmer/CachingStemmerTest.java | 139 ++++++++
.../opennlp/tools/stemmer/StemmerFactoryTest.java | 376 +++++++++++++++++++++
.../stemmer/StemmerFactoryUsageExampleTest.java | 62 ++++
.../util/normalizer/NormalizationProfilesTest.java | 23 ++
.../tools/util/normalizer/TermAnalyzerTest.java | 28 +-
opennlp-docs/src/docbkx/opennlp.xml | 1 +
opennlp-docs/src/docbkx/stemmer.xml | 72 ++++
.../tools/eval/MultiThreadedStemmerEval.java | 175 ++++++++++
22 files changed, 1885 insertions(+), 88 deletions(-)
diff --git a/opennlp-api/src/main/java/opennlp/tools/stemmer/Stemmer.java
b/opennlp-api/src/main/java/opennlp/tools/stemmer/Stemmer.java
index 750890eb6..fb6bdcffb 100644
--- a/opennlp-api/src/main/java/opennlp/tools/stemmer/Stemmer.java
+++ b/opennlp-api/src/main/java/opennlp/tools/stemmer/Stemmer.java
@@ -17,10 +17,33 @@
package opennlp.tools.stemmer;
+import java.util.List;
+
/**
- * The stemmer is reducing a word to its stem.
+ * Reduces a word to its root form.
+ *
+ * <p>Thread safety is implementation specific.</p>
*/
public interface Stemmer {
+ /**
+ * Stems {@code word}.
+ *
+ * @param word The input word. Must not be {@code null}.
+ * @return The stemmed form.
+ * @throws IllegalArgumentException Thrown if {@code word} is {@code null}.
+ */
CharSequence stem(CharSequence word);
+
+ /**
+ * {@return all stem forms for {@code word}} Defaults to a single-element
list from
+ * {@link #stem(CharSequence)}. Dictionary-based engines may override this
to return
+ * multiple roots; delegating wrappers must forward it to preserve the full
list.
+ *
+ * @param word The input word. Must not be {@code null}.
+ * @throws IllegalArgumentException Thrown if {@code word} is {@code null}.
+ */
+ default List<CharSequence> stemAll(CharSequence word) {
+ return List.of(stem(word));
+ }
}
diff --git a/opennlp-api/src/main/java/opennlp/tools/stemmer/Stemmer.java
b/opennlp-api/src/main/java/opennlp/tools/stemmer/StemmerFactory.java
similarity index 68%
copy from opennlp-api/src/main/java/opennlp/tools/stemmer/Stemmer.java
copy to opennlp-api/src/main/java/opennlp/tools/stemmer/StemmerFactory.java
index 750890eb6..60c80804e 100644
--- a/opennlp-api/src/main/java/opennlp/tools/stemmer/Stemmer.java
+++ b/opennlp-api/src/main/java/opennlp/tools/stemmer/StemmerFactory.java
@@ -4,7 +4,7 @@
* 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
+ * the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
@@ -18,9 +18,14 @@
package opennlp.tools.stemmer;
/**
- * The stemmer is reducing a word to its stem.
+ * A factory that captures a stemmer configuration and mints configured {@link
Stemmer}
+ * instances on demand. Unlike {@code BaseToolFactory}, this type is not
loaded from a model
+ * manifest.
*/
-public interface Stemmer {
+public interface StemmerFactory {
- CharSequence stem(CharSequence word);
+ /**
+ * {@return a new {@link Stemmer}}
+ */
+ Stemmer newStemmer();
}
diff --git a/opennlp-core/opennlp-runtime/BENCHMARKS.md
b/opennlp-core/opennlp-runtime/BENCHMARKS.md
index 68c3ac185..21cb71a66 100644
--- a/opennlp-core/opennlp-runtime/BENCHMARKS.md
+++ b/opennlp-core/opennlp-runtime/BENCHMARKS.md
@@ -13,6 +13,8 @@ variance reporting.
| `TokenizerMEBenchmark` | TokenizerME | 3 approaches |
| `SentenceDetectorMEBenchmark` | SentenceDetectorME | 3 approaches |
| `POSTaggerMEBenchmark` | POSTaggerME | 3 approaches x 2 cache configs |
+| `SnowballStemmerBenchmark` | SnowballStemmer | 3 approaches (incl.
plain-field baseline) |
+| `CachingStemmerBenchmark` | CachingStemmer | cached vs uncached x 2
workloads |
### Approaches measured
@@ -30,32 +32,88 @@ mvn test-compile -Pjmh \
-pl opennlp-core/opennlp-runtime -am \
-Dforbiddenapis.skip=true -Dcheckstyle.skip=true
+# Materialize the test classpath once (JMH's forked JVMs inherit
+# java.class.path, which mvn exec:java does not populate, running
+# through exec:java fails with ClassNotFoundException: ForkedMain)
+mvn dependency:build-classpath -pl opennlp-core/opennlp-runtime \
+ -Pjmh -DincludeScope=test -Dmdep.outputFile=/tmp/cp.txt
+
+CP="opennlp-core/opennlp-runtime/target/classes:opennlp-core/opennlp-runtime/target/test-classes:$(cat
/tmp/cp.txt)"
+
# Run all ME benchmarks
-mvn exec:java -pl opennlp-core/opennlp-runtime \
- -Pjmh -Dexec.classpathScope=test \
- -Dexec.mainClass=org.openjdk.jmh.Main \
- -Dexec.args="opennlp.tools.*.ME*"
+java -cp "$CP" org.openjdk.jmh.Main 'opennlp.tools.*.ME*'
# Run POSTagger only (includes cacheSize param)
-mvn exec:java -pl opennlp-core/opennlp-runtime \
- -Pjmh -Dexec.classpathScope=test \
- -Dexec.mainClass=org.openjdk.jmh.Main \
- -Dexec.args="POSTaggerMEBenchmark"
+java -cp "$CP" org.openjdk.jmh.Main POSTaggerMEBenchmark
```
-### Regression testing (stock vs patched)
+### Baseline comparison
-Run the `newInstancePerCall` benchmark on both stock and patched
-builds. The throughput numbers should be within JMH's error margin.
+Run the `newInstancePerCall` benchmark on both `main` and this
+branch. The throughput numbers should be within JMH's error margin.
```bash
-# On stock (upstream/main):
+# On main:
# ... build and run as above, save output
-# On patched (feature/thread-safe-me):
+# On this branch:
# ... build and run as above, compare
```
+### SnowballStemmer results (Linux, JDK 25, 32 cores, 2 forks x 10 iterations)
+
+`SnowballStemmerBenchmark` compares the thread-safe `SnowballStemmer`
+(engine behind `OwnerOrPerThreadState`) against a baseline replica
+that keeps the engine in a plain field (not shareable).
+One op = stemming 16 English words.
+
+| Strategy | 1 thread | 8 threads | 32 threads |
+|----------|---------:|----------:|-----------:|
+| `sharedInstance` (one shared stemmer) | 560k ± 3k ops/s | 1.55M ± 0.17M |
3.16M ± 0.34M |
+| `instancePerThread` (stemmer per thread) | 509k ± 26k ops/s | 1.60M ± 0.17M
| 2.94M ± 0.11M |
+| `legacyInstancePerThread` (plain-field baseline, stemmer per thread) | 544k
± 19k ops/s | 1.46M ± 0.16M | 4.77M ± 0.39M |
+
+At 1 and 8 threads the three strategies are within (or nearly within)
+each other's error bars: the `OwnerOrPerThreadState` lookup is not
+measurable against the cost of stemming itself. Only at full
+saturation (32 threads, hyperthreaded) does the legacy plain-field
+baseline pull ahead (~1.5x): with every hardware thread busy, the
+per-call owner check plus `ThreadLocal` lookup is no longer hidden by
+memory-level parallelism. Real pipelines stem as one stage among many,
+so the saturated-microbenchmark gap is an upper bound, and the legacy
+strategy was not shareable across threads in the first place.
+
+### CachingStemmer results (same environment)
+
+`CachingStemmerBenchmark` compares a `CachingStemmer` (per-thread LRU,
+default 1024 entries, wrapping the English Snowball stemmer) against
+the uncached shared stemmer. One op = 16 tokens from a 64k-token
+stream. The `zipf` workload samples a 512-word vocabulary with 1/rank
+weights (real-text repetition; the cache holds the whole vocabulary);
+`diverse` samples an 8192-word vocabulary uniformly (8x cache
+capacity: mostly misses plus constant eviction).
+
+| Workload | Strategy | 8 threads | 32 threads |
+|----------|----------|----------:|-----------:|
+| `zipf` | `cachedShared` | 48.5M ± 0.7M ops/s | 95.4M ± 0.9M |
+| `zipf` | `uncachedShared` | 1.43M ± 0.13M | 2.75M ± 0.34M |
+| `diverse` | `cachedShared` | 1.81M ± 0.51M | 3.45M ± 0.18M |
+| `diverse` | `uncachedShared` | 1.08M ± 0.09M | 3.13M ± 0.12M |
+
+On the Zipf workload the cache is a ~34x throughput multiplier (raw
+stemming becomes a hash lookup for the dominant vocabulary). On the
+cache-hostile workload it still does not lose: the ~12% residual hit
+rate pays for the eviction overhead. The cache more than recovers the
+`OwnerOrPerThreadState` lookup cost observed in
+`SnowballStemmerBenchmark` at full saturation.
+
+The cache is keyed to the physical thread, and these runs use a fixed
+platform-thread pool whose threads live for the whole measurement. On
+a virtual-thread-per-task executor every task starts with an empty
+cache, so the multiplier only applies to repeats within one task;
+workloads that stem a handful of words per task should expect
+uncached-level throughput there.
+
### POSTagger cache impact
The `POSTaggerMEBenchmark` uses `@Param({"0", "3"})` for cache
diff --git
a/opennlp-core/opennlp-runtime/src/jmh/java/opennlp/tools/stemmer/CachingStemmerBenchmark.java
b/opennlp-core/opennlp-runtime/src/jmh/java/opennlp/tools/stemmer/CachingStemmerBenchmark.java
new file mode 100644
index 000000000..ae0050b2b
--- /dev/null
+++
b/opennlp-core/opennlp-runtime/src/jmh/java/opennlp/tools/stemmer/CachingStemmerBenchmark.java
@@ -0,0 +1,199 @@
+/*
+ * 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 opennlp.tools.stemmer;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Random;
+import java.util.concurrent.TimeUnit;
+
+import org.openjdk.jmh.annotations.Benchmark;
+import org.openjdk.jmh.annotations.BenchmarkMode;
+import org.openjdk.jmh.annotations.Fork;
+import org.openjdk.jmh.annotations.Level;
+import org.openjdk.jmh.annotations.Measurement;
+import org.openjdk.jmh.annotations.Mode;
+import org.openjdk.jmh.annotations.OutputTimeUnit;
+import org.openjdk.jmh.annotations.Param;
+import org.openjdk.jmh.annotations.Scope;
+import org.openjdk.jmh.annotations.Setup;
+import org.openjdk.jmh.annotations.State;
+import org.openjdk.jmh.annotations.Threads;
+import org.openjdk.jmh.annotations.Warmup;
+import org.openjdk.jmh.infra.Blackhole;
+import org.openjdk.jmh.runner.Runner;
+import org.openjdk.jmh.runner.options.Options;
+import org.openjdk.jmh.runner.options.OptionsBuilder;
+
+import opennlp.tools.stemmer.snowball.SnowballStemmer;
+import opennlp.tools.stemmer.snowball.SnowballStemmerFactory;
+
+/**
+ * JMH benchmark for {@link CachingStemmer} against an uncached shared {@link
SnowballStemmer}.
+ *
+ * <p>Two workloads drive both strategies:</p>
+ * <ul>
+ * <li>{@code zipf}: a 64k-token stream sampled with 1/rank weights from a
512-word
+ * vocabulary. This models real text, where a small vocabulary
dominates; the default
+ * 1024-entry cache holds the whole vocabulary.</li>
+ * <li>{@code diverse}: a 64k-token stream sampled uniformly from an
8192-word vocabulary,
+ * 8x the cache capacity. This is the cache-hostile case: mostly misses
plus constant
+ * eviction, so it bounds the overhead the cache can add.</li>
+ * </ul>
+ *
+ * <p>One op stems 16 consecutive tokens from the stream; each benchmark
thread walks the stream
+ * from its own cursor.</p>
+ */
+@BenchmarkMode(Mode.Throughput)
+@OutputTimeUnit(TimeUnit.SECONDS)
+@Warmup(iterations = 5, time = 2)
+@Measurement(iterations = 10, time = 2)
+@Fork(2)
+public class CachingStemmerBenchmark {
+
+ private static final int STREAM_LENGTH = 65536;
+ private static final int WORDS_PER_OP = 16;
+
+ private static final String[] ROOTS = {
+ "run", "walk", "talk", "develop", "nation", "connect", "form", "create",
+ "act", "direct", "govern", "manage", "operate", "organize", "present",
"relate",
+ "report", "state", "structure", "test", "train", "transform",
"translate", "value",
+ "view", "wonder", "yield", "zone", "note", "mark", "place", "point"
+ };
+ private static final String[] PREFIXES = {
+ "", "re", "un", "over", "under", "out", "pre", "post",
+ "non", "anti", "de", "dis", "mis", "sub", "super", "inter"
+ };
+ private static final String[] SUFFIXES = {
+ "", "s", "ed", "ing", "er", "ers", "ation", "ations",
+ "ly", "ness", "ment", "ments", "ize", "ized", "izing", "al"
+ };
+
+ @State(Scope.Benchmark)
+ public static class WorkloadState {
+
+ @Param({"zipf", "diverse"})
+ String workload;
+
+ String[] stream;
+
+ @Setup(Level.Trial)
+ public void build() {
+ Random random = new Random(42);
+ List<String> vocabulary = new ArrayList<>();
+ if ("zipf".equals(workload)) {
+ // 32 roots x 16 suffixes = 512 unique words, sampled with 1/rank
weights.
+ for (String root : ROOTS) {
+ for (String suffix : SUFFIXES) {
+ vocabulary.add(root + suffix);
+ }
+ }
+ double[] cumulative = new double[vocabulary.size()];
+ double sum = 0;
+ for (int rank = 0; rank < vocabulary.size(); rank++) {
+ sum += 1.0 / (rank + 1);
+ cumulative[rank] = sum;
+ }
+ stream = new String[STREAM_LENGTH];
+ for (int i = 0; i < STREAM_LENGTH; i++) {
+ double r = random.nextDouble() * sum;
+ int idx = 0;
+ while (cumulative[idx] < r) {
+ idx++;
+ }
+ stream[i] = vocabulary.get(idx);
+ }
+ } else {
+ // 16 prefixes x 32 roots x 16 suffixes = 8192 unique words, sampled
uniformly.
+ for (String prefix : PREFIXES) {
+ for (String root : ROOTS) {
+ for (String suffix : SUFFIXES) {
+ vocabulary.add(prefix + root + suffix);
+ }
+ }
+ }
+ stream = new String[STREAM_LENGTH];
+ for (int i = 0; i < STREAM_LENGTH; i++) {
+ stream[i] = vocabulary.get(random.nextInt(vocabulary.size()));
+ }
+ }
+ }
+ }
+
+ @State(Scope.Benchmark)
+ public static class UncachedState {
+ Stemmer stemmer;
+
+ @Setup(Level.Trial)
+ public void create() {
+ stemmer = new SnowballStemmer(SnowballStemmer.ALGORITHM.ENGLISH);
+ }
+ }
+
+ @State(Scope.Benchmark)
+ public static class CachedState {
+ Stemmer stemmer;
+
+ @Setup(Level.Trial)
+ public void create() {
+ stemmer = new CachingStemmer(
+ new SnowballStemmerFactory(SnowballStemmer.ALGORITHM.ENGLISH));
+ }
+ }
+
+ @State(Scope.Thread)
+ public static class Cursor {
+ int position;
+
+ @Setup(Level.Trial)
+ public void randomize() {
+ position = new Random().nextInt(STREAM_LENGTH);
+ }
+ }
+
+ @Benchmark
+ @Threads(Threads.MAX)
+ public void uncachedShared(WorkloadState w, UncachedState st, Cursor cursor,
Blackhole bh) {
+ for (int i = 0; i < WORDS_PER_OP; i++) {
+ bh.consume(st.stemmer.stem(w.stream[cursor.position++ & (STREAM_LENGTH -
1)]));
+ }
+ }
+
+ @Benchmark
+ @Threads(Threads.MAX)
+ public void cachedShared(WorkloadState w, CachedState st, Cursor cursor,
Blackhole bh) {
+ for (int i = 0; i < WORDS_PER_OP; i++) {
+ bh.consume(st.stemmer.stem(w.stream[cursor.position++ & (STREAM_LENGTH -
1)]));
+ }
+ }
+
+ /**
+ * Quick local iteration only: {@code forks(0)} disables JVM fork isolation
+ * (unlike {@code mvn} with the {@code jmh} profile).
+ * Use the Maven-invoked configuration for publishable numbers.
+ */
+ public static void main(String[] args) throws Exception {
+ Options opt = new OptionsBuilder()
+ .include(CachingStemmerBenchmark.class.getSimpleName())
+ .forks(0)
+ .warmupIterations(3)
+ .measurementIterations(5)
+ .build();
+ new Runner(opt).run();
+ }
+}
diff --git
a/opennlp-core/opennlp-runtime/src/jmh/java/opennlp/tools/stemmer/snowball/SnowballStemmerBenchmark.java
b/opennlp-core/opennlp-runtime/src/jmh/java/opennlp/tools/stemmer/snowball/SnowballStemmerBenchmark.java
new file mode 100644
index 000000000..62eb09031
--- /dev/null
+++
b/opennlp-core/opennlp-runtime/src/jmh/java/opennlp/tools/stemmer/snowball/SnowballStemmerBenchmark.java
@@ -0,0 +1,153 @@
+/*
+ * 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 opennlp.tools.stemmer.snowball;
+
+import java.util.concurrent.TimeUnit;
+
+import org.openjdk.jmh.annotations.Benchmark;
+import org.openjdk.jmh.annotations.BenchmarkMode;
+import org.openjdk.jmh.annotations.Fork;
+import org.openjdk.jmh.annotations.Level;
+import org.openjdk.jmh.annotations.Measurement;
+import org.openjdk.jmh.annotations.Mode;
+import org.openjdk.jmh.annotations.OutputTimeUnit;
+import org.openjdk.jmh.annotations.Scope;
+import org.openjdk.jmh.annotations.Setup;
+import org.openjdk.jmh.annotations.State;
+import org.openjdk.jmh.annotations.Threads;
+import org.openjdk.jmh.annotations.Warmup;
+import org.openjdk.jmh.infra.Blackhole;
+import org.openjdk.jmh.runner.Runner;
+import org.openjdk.jmh.runner.options.Options;
+import org.openjdk.jmh.runner.options.OptionsBuilder;
+
+import opennlp.tools.stemmer.Stemmer;
+
+/**
+ * JMH benchmark for the thread-safe {@link SnowballStemmer} versus a baseline
replica of the
+ * previous implementation that held its generated engine in a plain field.
+ *
+ * <p>Three strategies are measured, all at {@link Threads#MAX}:</p>
+ * <ul>
+ * <li>{@code sharedInstance}: one thread-safe {@link SnowballStemmer}
shared by every thread
+ * (one thread runs on the owner fast path, the rest on {@link
ThreadLocal} state)</li>
+ * <li>{@code instancePerThread}: one thread-safe {@link SnowballStemmer}
per thread (every
+ * thread is the owner of its own instance, so this isolates the
owner-fast-path cost)</li>
+ * <li>{@code legacyInstancePerThread}: the old non-thread-safe
implementation, one per thread
+ * (the baseline; sharing it across threads would be a correctness
bug)</li>
+ * </ul>
+ */
+@BenchmarkMode(Mode.Throughput)
+@OutputTimeUnit(TimeUnit.SECONDS)
+@Warmup(iterations = 5, time = 2)
+@Measurement(iterations = 10, time = 2)
+@Fork(2)
+public class SnowballStemmerBenchmark {
+
+ private static final String[] INPUT = {
+ "running", "accompanying", "malediction", "softeners", "declining",
+ "conspiracies", "monotonically", "annotations", "internationalization",
+ "denormalization", "photographers", "responsibilities",
"acknowledgement",
+ "this", "cat", "querying"
+ };
+
+ /**
+ * Baseline replica of the previous {@code SnowballStemmer} implementation:
the generated engine
+ * lives in a plain field, so an instance must not be shared across threads.
+ */
+ static final class LegacySnowballStemmer implements Stemmer {
+
+ private final AbstractSnowballStemmer stemmer = new englishStemmer();
+
+ @Override
+ public CharSequence stem(CharSequence word) {
+ stemmer.setCurrent(word.toString());
+ stemmer.stem();
+ return stemmer.getCurrent();
+ }
+ }
+
+ @State(Scope.Benchmark)
+ public static class SharedState {
+ Stemmer stemmer;
+
+ @Setup(Level.Trial)
+ public void create() {
+ stemmer = new SnowballStemmer(SnowballStemmer.ALGORITHM.ENGLISH);
+ }
+ }
+
+ @State(Scope.Thread)
+ public static class PerThreadState {
+ Stemmer stemmer;
+
+ @Setup(Level.Trial)
+ public void create() {
+ stemmer = new SnowballStemmer(SnowballStemmer.ALGORITHM.ENGLISH);
+ }
+ }
+
+ @State(Scope.Thread)
+ public static class LegacyPerThreadState {
+ Stemmer stemmer;
+
+ @Setup(Level.Trial)
+ public void create() {
+ stemmer = new LegacySnowballStemmer();
+ }
+ }
+
+ @Benchmark
+ @Threads(Threads.MAX)
+ public void sharedInstance(SharedState st, Blackhole bh) {
+ for (String s : INPUT) {
+ bh.consume(st.stemmer.stem(s));
+ }
+ }
+
+ @Benchmark
+ @Threads(Threads.MAX)
+ public void instancePerThread(PerThreadState pt, Blackhole bh) {
+ for (String s : INPUT) {
+ bh.consume(pt.stemmer.stem(s));
+ }
+ }
+
+ @Benchmark
+ @Threads(Threads.MAX)
+ public void legacyInstancePerThread(LegacyPerThreadState pt, Blackhole bh) {
+ for (String s : INPUT) {
+ bh.consume(pt.stemmer.stem(s));
+ }
+ }
+
+ /**
+ * Quick local iteration only: {@code forks(0)} disables JVM fork isolation
+ * (unlike {@code mvn} with the {@code jmh} profile).
+ * Use the Maven-invoked configuration for publishable numbers.
+ */
+ public static void main(String[] args) throws Exception {
+ Options opt = new OptionsBuilder()
+ .include(SnowballStemmerBenchmark.class.getSimpleName())
+ .forks(0)
+ .warmupIterations(3)
+ .measurementIterations(5)
+ .build();
+ new Runner(opt).run();
+ }
+}
diff --git
a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/stemmer/CachingStemmer.java
b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/stemmer/CachingStemmer.java
new file mode 100644
index 000000000..b4ec524a0
--- /dev/null
+++
b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/stemmer/CachingStemmer.java
@@ -0,0 +1,152 @@
+/*
+ * 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 opennlp.tools.stemmer;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Supplier;
+
+import opennlp.tools.commons.ThreadSafe;
+
+/**
+ * A {@link Stemmer} that memoizes word-to-stem mappings in a bounded
per-thread LRU cache.
+ *
+ * <p>Each thread gets its own delegate stemmer (minted from the supplied
{@link StemmerFactory})
+ * and its own cache, so instances are safe to share regardless of whether the
factory's stemmers
+ * are, and memory is bounded to {@code capacity} entries per thread that
stems. Caching pays off
+ * on threads reused across many words, such as a fixed platform-thread pool;
on a
+ * virtual-thread-per-task executor every task starts with an empty cache, so
repeats are served
+ * only within one task. {@link #stemAll(CharSequence)} is forwarded to the
delegate uncached.</p>
+ *
+ * <p>Long-running environments such as application containers should call
+ * {@link #clearThreadLocalState()} when a pooled thread no longer uses this
stemmer.</p>
+ */
+@ThreadSafe
+public final class CachingStemmer extends
DelegatingStemmer<CachingStemmer.ThreadState> {
+
+ /**
+ * Covers the high-frequency vocabulary of most corpora while keeping the
per-thread footprint
+ * small.
+ */
+ public static final int DEFAULT_CAPACITY = 1024;
+
+ /**
+ * Creates a caching stemmer with the {@linkplain #DEFAULT_CAPACITY default
capacity}.
+ *
+ * @param factory The factory that mints one delegate per thread. Must not
be {@code null}.
+ * @throws IllegalArgumentException if {@code factory} is {@code null}.
+ */
+ public CachingStemmer(StemmerFactory factory) {
+ this(factory, DEFAULT_CAPACITY);
+ }
+
+ /**
+ * Creates a caching stemmer.
+ *
+ * @param factory The factory that mints one delegate per thread. Must not
be {@code null}.
+ * @param capacity The maximum number of word-to-stem entries kept per
thread; must be positive.
+ * @throws IllegalArgumentException if {@code factory} is {@code null} or
{@code capacity} is
+ * not positive.
+ */
+ public CachingStemmer(StemmerFactory factory, int capacity) {
+ super(threadStateSupplier(factory, capacity), threadState ->
threadState.cache.clear());
+ }
+
+ /**
+ * Validates the constructor arguments eagerly and returns a supplier that
mints one delegate and
+ * its per-thread cache, so invalid arguments fail at construction rather
than on first use.
+ *
+ * @param factory The factory that mints one delegate per thread. Must not
be {@code null}.
+ * @param capacity The maximum number of word-to-stem entries kept per
thread; must be positive.
+ * @return a supplier of fresh per-thread state.
+ * @throws IllegalArgumentException if {@code factory} is {@code null} or
{@code capacity} is
+ * not positive.
+ */
+ private static Supplier<ThreadState> threadStateSupplier(StemmerFactory
factory, int capacity) {
+ requireFactory(factory);
+ if (capacity <= 0) {
+ throw new IllegalArgumentException("capacity must be positive, got " +
capacity);
+ }
+ return () -> new ThreadState(factory.newStemmer(), capacity);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public CharSequence stem(CharSequence word) {
+ if (word == null) {
+ throw new IllegalArgumentException("word must not be null");
+ }
+ final ThreadState ts = state.get();
+ final String key = word.toString();
+ final String cached = ts.cache.get(key);
+ if (cached != null) {
+ return cached;
+ }
+ final String stemmed = ts.delegate.stem(key).toString();
+ ts.cache.put(key, stemmed);
+ return stemmed;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public List<CharSequence> stemAll(CharSequence word) {
+ if (word == null) {
+ throw new IllegalArgumentException("word must not be null");
+ }
+ return state.get().delegate.stemAll(word);
+ }
+
+ /**
+ * Empties the calling thread's cache while keeping its delegate, forcing
every subsequent
+ * word through a fresh stemming pass. Only the calling thread's cache is
affected. A thread
+ * that has not stemmed yet has its state initialized by this call, so
invoke it on the
+ * thread whose cache should be emptied, not from an unrelated maintenance
thread.
+ */
+ public void clearCache() {
+ state.get().cache.clear();
+ }
+
+ static final class ThreadState {
+
+ private final Stemmer delegate;
+ private final LinkedHashMap<String, String> cache;
+
+ /**
+ * Creates the per-thread delegate and its access-ordered LRU cache.
+ *
+ * @param delegate The stemmer minted for this thread.
+ * @param capacity The maximum number of entries retained before eviction.
+ */
+ private ThreadState(Stemmer delegate, int capacity) {
+ this.delegate = delegate;
+ // Access-ordered for LRU eviction.
+ this.cache = new LinkedHashMap<>((int) Math.min(capacity / 0.75d + 1.0d,
4096.0d),
+ 0.75f, true) {
+ @Override
+ protected boolean removeEldestEntry(Map.Entry<String, String> eldest) {
+ return size() > capacity;
+ }
+ };
+ }
+ }
+}
diff --git
a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/stemmer/DelegatingStemmer.java
b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/stemmer/DelegatingStemmer.java
new file mode 100644
index 000000000..bbec1a818
--- /dev/null
+++
b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/stemmer/DelegatingStemmer.java
@@ -0,0 +1,70 @@
+/*
+ * 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 opennlp.tools.stemmer;
+
+import java.util.function.Consumer;
+import java.util.function.Supplier;
+
+import opennlp.tools.util.OwnerOrPerThreadState;
+
+/**
+ * Shared plumbing for {@link Stemmer} wrappers that route each call to a
per-thread payload minted
+ * from a {@link StemmerFactory}, following the {@link OwnerOrPerThreadState}
pattern of the
+ * thread-safe {@code *ME} components. Subclasses differ only in the payload
they carry per thread
+ * and in what {@link #stem(CharSequence)} does with it.
+ *
+ * @param <P> the per-thread payload: a bare delegate stemmer, or a delegate
paired with per-thread
+ * working state such as a cache.
+ */
+abstract class DelegatingStemmer<P> implements Stemmer {
+
+ /** The per-thread payload holder. */
+ protected final OwnerOrPerThreadState<P> state;
+
+ /**
+ * @param init Mints the per-thread payload on first use by the owner and
by each extra thread.
+ * @param cleanup Releases a per-thread payload when its thread clears state.
+ */
+ DelegatingStemmer(Supplier<P> init, Consumer<P> cleanup) {
+ this.state = new OwnerOrPerThreadState<>(init, cleanup);
+ }
+
+ /**
+ * Validates a factory argument before it is captured by a subclass
supplier, so a {@code null}
+ * factory fails at construction rather than on first use.
+ *
+ * @param factory The factory to validate. Must not be {@code null}.
+ * @return {@code factory}.
+ * @throws IllegalArgumentException if {@code factory} is {@code null}.
+ */
+ static StemmerFactory requireFactory(StemmerFactory factory) {
+ if (factory == null) {
+ throw new IllegalArgumentException("factory must not be null");
+ }
+ return factory;
+ }
+
+ /**
+ * Removes this thread's payload to prevent classloader leaks in container
environments. Call
+ * when the thread is returned to a pool or the stemmer is no longer needed,
mirroring
+ * {@code clearThreadLocalState()} on the thread-safe {@code *ME} components.
+ */
+ public void clearThreadLocalState() {
+ state.clearForCurrentThread();
+ }
+}
diff --git
a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/stemmer/PorterStemmer.java
b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/stemmer/PorterStemmer.java
index 623a2d3d2..aac73519f 100644
---
a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/stemmer/PorterStemmer.java
+++
b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/stemmer/PorterStemmer.java
@@ -597,8 +597,15 @@ public class PorterStemmer implements Stemmer {
/**
* Stem a word provided as a CharSequence.
* Returns the result as a CharSequence.
+ *
+ * @param word The word to stem. Must not be {@code null}.
+ * @return the stemmed word.
+ * @throws IllegalArgumentException if {@code word} is {@code null}.
*/
public CharSequence stem(CharSequence word) {
+ if (word == null) {
+ throw new IllegalArgumentException("word must not be null");
+ }
return stem(word.toString());
}
diff --git a/opennlp-api/src/main/java/opennlp/tools/stemmer/Stemmer.java
b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/stemmer/PorterStemmerFactory.java
similarity index 69%
copy from opennlp-api/src/main/java/opennlp/tools/stemmer/Stemmer.java
copy to
opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/stemmer/PorterStemmerFactory.java
index 750890eb6..68285109a 100644
--- a/opennlp-api/src/main/java/opennlp/tools/stemmer/Stemmer.java
+++
b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/stemmer/PorterStemmerFactory.java
@@ -4,7 +4,7 @@
* 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
+ * the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
@@ -17,10 +17,19 @@
package opennlp.tools.stemmer;
+import opennlp.tools.commons.ThreadSafe;
+
/**
- * The stemmer is reducing a word to its stem.
+ * A thread-safe factory for {@link PorterStemmer} instances.
*/
-public interface Stemmer {
+@ThreadSafe
+public class PorterStemmerFactory implements StemmerFactory {
- CharSequence stem(CharSequence word);
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public Stemmer newStemmer() {
+ return new PorterStemmer();
+ }
}
diff --git
a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/stemmer/SharingStemmer.java
b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/stemmer/SharingStemmer.java
new file mode 100644
index 000000000..59e8d1508
--- /dev/null
+++
b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/stemmer/SharingStemmer.java
@@ -0,0 +1,67 @@
+/*
+ * 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 opennlp.tools.stemmer;
+
+import java.util.List;
+
+import opennlp.tools.commons.ThreadSafe;
+import opennlp.tools.util.OwnerOrPerThreadState;
+
+/**
+ * A {@link Stemmer} that is safe to share across threads by routing each call
to a per-thread
+ * delegate minted from a {@link StemmerFactory}. Use it to share a stemmer
whose implementation is
+ * <em>not</em> itself thread-safe, such as {@link PorterStemmer}; thread-safe
implementations like
+ * {@code SnowballStemmer} do not need this wrapper.
+ *
+ * <p>The first thread uses a single owner delegate without touching {@link
ThreadLocal} until a
+ * second thread appears, matching the {@link OwnerOrPerThreadState} pattern
used by the
+ * thread-safe {@code *ME} components.</p>
+ */
+@ThreadSafe
+public final class SharingStemmer extends DelegatingStemmer<Stemmer> {
+
+ /**
+ * @param factory The factory that mints per-thread delegates. Must not be
{@code null}.
+ * @throws IllegalArgumentException if {@code factory} is {@code null}.
+ */
+ public SharingStemmer(StemmerFactory factory) {
+ super(requireFactory(factory)::newStemmer, stemmer -> { });
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public CharSequence stem(CharSequence word) {
+ if (word == null) {
+ throw new IllegalArgumentException("word must not be null");
+ }
+ return state.get().stem(word);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public List<CharSequence> stemAll(CharSequence word) {
+ if (word == null) {
+ throw new IllegalArgumentException("word must not be null");
+ }
+ return state.get().stemAll(word);
+ }
+}
diff --git
a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/stemmer/snowball/SnowballStemmer.java
b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/stemmer/snowball/SnowballStemmer.java
index 7b8b1636a..bc634f1c4 100644
---
a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/stemmer/snowball/SnowballStemmer.java
+++
b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/stemmer/snowball/SnowballStemmer.java
@@ -17,76 +17,99 @@
package opennlp.tools.stemmer.snowball;
+import java.util.function.Supplier;
+
+import opennlp.tools.commons.ThreadSafe;
+import opennlp.tools.stemmer.SharingStemmer;
import opennlp.tools.stemmer.Stemmer;
+/**
+ * A {@link Stemmer} backed by the generated Snowball engines.
+ *
+ * <p>The generated engines hold mutable per-call buffers, so this class
routes each call to a
+ * per-thread engine, the same pattern the thread-safe {@code *ME} components
use. A single
+ * {@code SnowballStemmer} instance is therefore safe to share across
threads.</p>
+ *
+ * <p>As with the {@code *ME} components, long-running environments such as
application
+ * containers should call {@link #clearThreadLocalState()} when a pooled
thread no longer uses
+ * this stemmer; otherwise the thread retains its engine until the instance is
unreachable,
+ * which can pin the defining classloader on redeploys.</p>
+ */
+@ThreadSafe
public class SnowballStemmer implements Stemmer {
- private final AbstractSnowballStemmer stemmer;
- private final int repeat;
+ private final SharingStemmer sharing;
+ /**
+ * Creates a stemmer for the given algorithm and repeat count.
+ *
+ * @param algorithm The Snowball algorithm. Must not be {@code null}.
+ * @param repeat How many times to apply the stemmer per word; must be
positive.
+ * @throws IllegalArgumentException Thrown if {@code algorithm} is {@code
null} or
+ * {@code repeat} is not positive.
+ */
public SnowballStemmer(ALGORITHM algorithm, int repeat) {
- this.repeat = repeat;
-
- if (ALGORITHM.ARABIC.equals(algorithm)) {
- stemmer = new arabicStemmer();
- } else if (ALGORITHM.DANISH.equals(algorithm)) {
- stemmer = new danishStemmer();
- } else if (ALGORITHM.DUTCH.equals(algorithm)) {
- stemmer = new dutchStemmer();
- } else if (ALGORITHM.CATALAN.equals(algorithm)) {
- stemmer = new catalanStemmer();
- } else if (ALGORITHM.ENGLISH.equals(algorithm)) {
- stemmer = new englishStemmer();
- } else if (ALGORITHM.FINNISH.equals(algorithm)) {
- stemmer = new finnishStemmer();
- } else if (ALGORITHM.FRENCH.equals(algorithm)) {
- stemmer = new frenchStemmer();
- } else if (ALGORITHM.GERMAN.equals(algorithm)) {
- stemmer = new germanStemmer();
- } else if (ALGORITHM.GREEK.equals(algorithm)) {
- stemmer = new greekStemmer();
- } else if (ALGORITHM.HUNGARIAN.equals(algorithm)) {
- stemmer = new hungarianStemmer();
- } else if (ALGORITHM.INDONESIAN.equals(algorithm)) {
- stemmer = new indonesianStemmer();
- } else if (ALGORITHM.IRISH.equals(algorithm)) {
- stemmer = new irishStemmer();
- } else if (ALGORITHM.ITALIAN.equals(algorithm)) {
- stemmer = new italianStemmer();
- } else if (ALGORITHM.NORWEGIAN.equals(algorithm)) {
- stemmer = new norwegianStemmer();
- } else if (ALGORITHM.PORTER.equals(algorithm)) {
- stemmer = new porterStemmer();
- } else if (ALGORITHM.PORTUGUESE.equals(algorithm)) {
- stemmer = new portugueseStemmer();
- } else if (ALGORITHM.ROMANIAN.equals(algorithm)) {
- stemmer = new romanianStemmer();
- } else if (ALGORITHM.RUSSIAN.equals(algorithm)) {
- stemmer = new russianStemmer();
- } else if (ALGORITHM.SPANISH.equals(algorithm)) {
- stemmer = new spanishStemmer();
- } else if (ALGORITHM.SWEDISH.equals(algorithm)) {
- stemmer = new swedishStemmer();
- } else if (ALGORITHM.TURKISH.equals(algorithm)) {
- stemmer = new turkishStemmer();
- } else {
- throw new IllegalStateException("Unexpected stemmer algorithm: " +
algorithm);
- }
+ this.sharing = new SharingStemmer(new SnowballStemmerFactory(algorithm,
repeat));
}
+ /**
+ * Creates a stemmer for the given algorithm with {@code repeat = 1}.
+ *
+ * @param algorithm The Snowball algorithm. Must not be {@code null}.
+ * @throws IllegalArgumentException Thrown if {@code algorithm} is {@code
null}.
+ */
public SnowballStemmer(ALGORITHM algorithm) {
this(algorithm, 1);
}
- public CharSequence stem(CharSequence word) {
-
- stemmer.setCurrent(word.toString());
+ /**
+ * Returns a supplier of a fresh Snowball engine for the given algorithm,
shared with
+ * {@link SnowballStemmerFactory} whose products embed one engine directly.
+ *
+ * @param algorithm The Snowball algorithm.
+ * @return a supplier that mints a new engine on each call.
+ */
+ static Supplier<AbstractSnowballStemmer> engineFor(ALGORITHM algorithm) {
+ return switch (algorithm) {
+ case ARABIC -> arabicStemmer::new;
+ case DANISH -> danishStemmer::new;
+ case DUTCH -> dutchStemmer::new;
+ case CATALAN -> catalanStemmer::new;
+ case ENGLISH -> englishStemmer::new;
+ case FINNISH -> finnishStemmer::new;
+ case FRENCH -> frenchStemmer::new;
+ case GERMAN -> germanStemmer::new;
+ case GREEK -> greekStemmer::new;
+ case HUNGARIAN -> hungarianStemmer::new;
+ case INDONESIAN -> indonesianStemmer::new;
+ case IRISH -> irishStemmer::new;
+ case ITALIAN -> italianStemmer::new;
+ case NORWEGIAN -> norwegianStemmer::new;
+ case PORTER -> porterStemmer::new;
+ case PORTUGUESE -> portugueseStemmer::new;
+ case ROMANIAN -> romanianStemmer::new;
+ case RUSSIAN -> russianStemmer::new;
+ case SPANISH -> spanishStemmer::new;
+ case SWEDISH -> swedishStemmer::new;
+ case TURKISH -> turkishStemmer::new;
+ };
+ }
- for (int i = 0; i < repeat; i++) {
- stemmer.stem();
- }
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public CharSequence stem(CharSequence word) {
+ return sharing.stem(word);
+ }
- return stemmer.getCurrent();
+ /**
+ * Removes this thread's engine to prevent classloader leaks in container
environments. Call
+ * when the thread is returned to a pool or the stemmer is no longer needed,
mirroring
+ * {@code clearThreadLocalState()} on the thread-safe {@code *ME} components.
+ */
+ public void clearThreadLocalState() {
+ sharing.clearThreadLocalState();
}
public enum ALGORITHM {
diff --git
a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/stemmer/snowball/SnowballStemmerFactory.java
b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/stemmer/snowball/SnowballStemmerFactory.java
new file mode 100644
index 000000000..69a674825
--- /dev/null
+++
b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/stemmer/snowball/SnowballStemmerFactory.java
@@ -0,0 +1,109 @@
+/*
+ * 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 opennlp.tools.stemmer.snowball;
+
+import opennlp.tools.commons.ThreadSafe;
+import opennlp.tools.stemmer.Stemmer;
+import opennlp.tools.stemmer.StemmerFactory;
+
+/**
+ * A thread-safe factory that captures a Snowball stemmer configuration for
APIs that accept a
+ * {@link StemmerFactory}.
+ *
+ * <p>Use this factory for the classic one-stemmer-per-thread pattern: {@link
#newStemmer()}
+ * returns a plain, thread-confined stemmer without the per-call thread
routing of the
+ * shareable {@link SnowballStemmer}.</p>
+ */
+@ThreadSafe
+public class SnowballStemmerFactory implements StemmerFactory {
+
+ private final SnowballStemmer.ALGORITHM algorithm;
+ private final int repeat;
+
+ /**
+ * Creates a factory with {@code repeat = 1}.
+ *
+ * @param algorithm The Snowball algorithm. Must not be {@code null}.
+ * @throws IllegalArgumentException if {@code algorithm} is {@code null}.
+ */
+ public SnowballStemmerFactory(SnowballStemmer.ALGORITHM algorithm) {
+ this(algorithm, 1);
+ }
+
+ /**
+ * Creates a factory over the given algorithm and repeat count.
+ *
+ * @param algorithm The Snowball algorithm. Must not be {@code null}.
+ * @param repeat How many times to apply the stemmer per word; must be
positive.
+ * @throws IllegalArgumentException if {@code algorithm} is {@code null} or
{@code repeat}
+ * is not positive.
+ */
+ public SnowballStemmerFactory(SnowballStemmer.ALGORITHM algorithm, int
repeat) {
+ if (algorithm == null) {
+ throw new IllegalArgumentException("algorithm must not be null");
+ }
+ if (repeat <= 0) {
+ throw new IllegalArgumentException("repeat must be positive, got " +
repeat);
+ }
+ this.algorithm = algorithm;
+ this.repeat = repeat;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public Stemmer newStemmer() {
+ return new ConfinedStemmer(SnowballStemmer.engineFor(algorithm).get(),
repeat);
+ }
+
+ /** A plain, thread-confined stemmer that applies one Snowball engine
directly; not thread-safe. */
+ private static final class ConfinedStemmer implements Stemmer {
+
+ private final AbstractSnowballStemmer engine;
+ private final int repeat;
+
+ /**
+ * Creates a thread-confined stemmer over the given engine.
+ *
+ * @param engine The Snowball engine to apply.
+ * @param repeat How many times to apply the engine per word.
+ */
+ private ConfinedStemmer(AbstractSnowballStemmer engine, int repeat) {
+ this.engine = engine;
+ this.repeat = repeat;
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * @throws IllegalArgumentException if {@code word} is {@code null}.
+ */
+ @Override
+ public CharSequence stem(CharSequence word) {
+ if (word == null) {
+ throw new IllegalArgumentException("word must not be null");
+ }
+ engine.setCurrent(word.toString());
+ for (int i = 0; i < repeat; i++) {
+ engine.stem();
+ }
+ return engine.getCurrent();
+ }
+ }
+}
diff --git
a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/NormalizationProfile.java
b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/NormalizationProfile.java
index e7a47477e..57283d34d 100644
---
a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/NormalizationProfile.java
+++
b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/NormalizationProfile.java
@@ -17,7 +17,9 @@
package opennlp.tools.util.normalizer;
import opennlp.tools.stemmer.Stemmer;
+import opennlp.tools.stemmer.StemmerFactory;
import opennlp.tools.stemmer.snowball.SnowballStemmer;
+import opennlp.tools.stemmer.snowball.SnowballStemmerFactory;
/**
* Per-language normalization settings, mirroring how OpenNLP already selects
a Snowball stemmer by
@@ -61,8 +63,17 @@ public record NormalizationProfile(String language,
SnowballStemmer.ALGORITHM st
}
/**
- * {@return a new {@link Stemmer} for this language} A fresh instance is
returned on each call
- * because the Snowball stemmers are stateful and not thread-safe.
+ * {@return a thread-safe factory for this language's Snowball stemmer} The
factory may be
+ * shared; each {@linkplain StemmerFactory#newStemmer() minted stemmer} is
confined to the
+ * calling thread.
+ */
+ public StemmerFactory stemmerFactory() {
+ return new SnowballStemmerFactory(stemmerAlgorithm);
+ }
+
+ /**
+ * {@return a new {@link Stemmer} for this language} The returned {@link
SnowballStemmer} is
+ * thread-safe and may be shared across threads.
*/
public Stemmer newStemmer() {
return new SnowballStemmer(stemmerAlgorithm);
@@ -70,8 +81,10 @@ public record NormalizationProfile(String language,
SnowballStemmer.ALGORITHM st
/**
* Returns a matching analyzer for this language: NFC, case folding, the
language's
- * {@linkplain #accentFold() diacritic fold} when it has one, then stemming.
Each call builds an
- * independent analyzer with its own stemmer, so use one per thread when
stemming.
+ * {@linkplain #accentFold() diacritic fold} when it has one, then stemming.
The returned
+ * analyzer is thread-safe, and repeated words resolve from a bounded
per-thread stem cache
+ * instead of being re-stemmed. The cache is keyed to the thread, so build
the analyzer once
+ * and reuse it from threads that live across many calls, such as a fixed
platform-thread pool.
*
* @return the analyzer.
*/
@@ -80,6 +93,6 @@ public record NormalizationProfile(String language,
SnowballStemmer.ALGORITHM st
if (accentFold != null) {
builder.transform(Dimension.ACCENT_FOLD, accentFold);
}
- return builder.stem(newStemmer()).build();
+ return builder.stem(stemmerFactory()).build();
}
}
diff --git
a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/TermAnalyzer.java
b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/TermAnalyzer.java
index eda2a032e..00e8d1851 100644
---
a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/TermAnalyzer.java
+++
b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/TermAnalyzer.java
@@ -25,7 +25,9 @@ import java.util.Locale;
import java.util.Set;
import opennlp.tools.lemmatizer.Lemmatizer;
+import opennlp.tools.stemmer.CachingStemmer;
import opennlp.tools.stemmer.Stemmer;
+import opennlp.tools.stemmer.StemmerFactory;
import opennlp.tools.tokenize.uax29.WordTokenizer;
import opennlp.tools.util.Span;
@@ -41,10 +43,12 @@ import opennlp.tools.util.Span;
* {@link Dimension#LEMMA} are enabled by supplying a {@link Stemmer} or
{@link Lemmatizer}.</p>
*
* <p>An instance is immutable and is thread-safe when its configured
transforms are. The built-in
- * character normalizers are stateless, but the Snowball stemmers are not, so
an analyzer configured
- * with a {@link Stemmer} (for example through {@code
NormalizationProfile.matchingAnalyzer()}) should
- * not be shared across threads when {@link Dimension#STEM} is used. Build one
with
- * {@link #builder()}.</p>
+ * character normalizers are stateless and the Snowball stemmers are
thread-safe. When
+ * {@link Dimension#STEM} is enabled through {@link
Builder#stem(StemmerFactory)}, stemming is
+ * routed through a {@link CachingStemmer}: the analyzer is safe to share
across threads even when
+ * the factory mints stateful stemmers, and repeated words resolve from a
bounded per-thread cache
+ * instead of being re-stemmed. A raw {@link Stemmer} passed to {@link
Builder#stem(Stemmer)} is
+ * only safe when that stemmer is itself thread-safe or the analyzer is
confined to one thread.</p>
*/
public final class TermAnalyzer {
@@ -448,6 +452,37 @@ public final class TermAnalyzer {
return this;
}
+ /**
+ * Enables {@link Dimension#STEM} through a {@link StemmerFactory}. The
analyzer receives a
+ * {@link CachingStemmer} with the {@linkplain
CachingStemmer#DEFAULT_CAPACITY default cache
+ * capacity}: it can be shared across threads, and repeated words resolve
from a bounded
+ * per-thread cache instead of being re-stemmed. The cache is keyed to the
thread, so the
+ * memoization pays off on threads reused across many tokens (a fixed
platform-thread pool);
+ * on a virtual-thread-per-task executor every task starts with an empty
cache.
+ *
+ * @param factory The stemmer factory. Must not be {@code null}.
+ * @return this builder
+ * @throws IllegalArgumentException if {@code factory} is {@code null}.
+ */
+ public Builder stem(StemmerFactory factory) {
+ return stem(factory, CachingStemmer.DEFAULT_CAPACITY);
+ }
+
+ /**
+ * Enables {@link Dimension#STEM} through a {@link StemmerFactory} with an
explicit stem cache
+ * capacity, otherwise identical to {@link #stem(StemmerFactory)}.
+ *
+ * @param factory The stemmer factory. Must not be {@code null}.
+ * @param cacheSize The maximum number of word-to-stem entries kept per
thread; must be
+ * positive.
+ * @return this builder
+ * @throws IllegalArgumentException if {@code factory} is {@code null} or
{@code cacheSize} is
+ * not positive.
+ */
+ public Builder stem(StemmerFactory factory, int cacheSize) {
+ return stem(new CachingStemmer(factory, cacheSize));
+ }
+
/**
* Enables {@link Dimension#LEMMA} through the given lemmatizer.
*
diff --git
a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/stemmer/CachingStemmerTest.java
b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/stemmer/CachingStemmerTest.java
new file mode 100644
index 000000000..6890dbca8
--- /dev/null
+++
b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/stemmer/CachingStemmerTest.java
@@ -0,0 +1,139 @@
+/*
+ * 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 opennlp.tools.stemmer;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import opennlp.tools.stemmer.snowball.SnowballStemmer;
+import opennlp.tools.stemmer.snowball.SnowballStemmerFactory;
+
+class CachingStemmerTest {
+
+ private static final SnowballStemmerFactory ENGLISH =
+ new SnowballStemmerFactory(SnowballStemmer.ALGORITHM.ENGLISH);
+
+ @Test
+ void cachedResultsMatchUncachedResults() {
+ Stemmer reference = ENGLISH.newStemmer();
+ CachingStemmer cached = new CachingStemmer(ENGLISH);
+
+ List<String> words = List.of("running", "declining", "conspiracies",
"this",
+ "running", "declining", "ab'yle", "running");
+ for (String word : words) {
+ Assertions.assertEquals(reference.stem(word).toString(),
cached.stem(word).toString(),
+ "mismatch for '" + word + "'");
+ }
+ }
+
+ @Test
+ void repeatedWordsHitTheCache() {
+ AtomicInteger delegateCalls = new AtomicInteger();
+ StemmerFactory countingFactory = () -> {
+ Stemmer delegate = ENGLISH.newStemmer();
+ return word -> {
+ delegateCalls.incrementAndGet();
+ return delegate.stem(word);
+ };
+ };
+
+ CachingStemmer cached = new CachingStemmer(countingFactory);
+ for (int i = 0; i < 100; i++) {
+ Assertions.assertEquals("run", cached.stem("running").toString());
+ }
+ Assertions.assertEquals(1, delegateCalls.get());
+ }
+
+ @Test
+ void evictionKeepsResultsCorrect() {
+ AtomicInteger delegateCalls = new AtomicInteger();
+ StemmerFactory countingFactory = () -> {
+ Stemmer delegate = ENGLISH.newStemmer();
+ return word -> {
+ delegateCalls.incrementAndGet();
+ return delegate.stem(word);
+ };
+ };
+
+ // Capacity 2 with a 3-word cycle: every access evicts the word needed two
steps later.
+ CachingStemmer cached = new CachingStemmer(countingFactory, 2);
+ Stemmer reference = ENGLISH.newStemmer();
+ List<String> words = List.of("running", "declining", "conspiracies");
+ for (int round = 0; round < 5; round++) {
+ for (String word : words) {
+ Assertions.assertEquals(reference.stem(word).toString(),
cached.stem(word).toString());
+ }
+ }
+ Assertions.assertTrue(delegateCalls.get() > 3,
+ "expected evictions to force repeated delegate calls, got " +
delegateCalls.get());
+ }
+
+ @Test
+ void stemAllBypassesTheCache() {
+ CachingStemmer cached = new CachingStemmer(ENGLISH);
+ List<CharSequence> forms = cached.stemAll("running");
+ Assertions.assertEquals(1, forms.size());
+ Assertions.assertEquals("run", forms.getFirst().toString());
+ }
+
+ @Test
+ void isSafeUnderConcurrentUse() throws Exception {
+ Stemmer reference = ENGLISH.newStemmer();
+ List<String> words = List.of("running", "declining", "conspiracies",
"annotations",
+ "photographers", "responsibilities", "this", "querying");
+ List<String> expected = new ArrayList<>(words.size());
+ for (String word : words) {
+ expected.add(reference.stem(word).toString());
+ }
+
+ // Capacity below the vocabulary size, so threads continuously evict
within their own cache.
+ CachingStemmer cached = new CachingStemmer(ENGLISH, 4);
+ try (ExecutorService pool = Executors.newFixedThreadPool(8)) {
+ Future<?>[] tasks = new Future<?>[32];
+ for (int i = 0; i < tasks.length; i++) {
+ final int offset = i;
+ tasks[i] = pool.submit(() -> {
+ for (int n = 0; n < 200; n++) {
+ for (int w = 0; w < words.size(); w++) {
+ int idx = (w + offset) % words.size();
+ Assertions.assertEquals(expected.get(idx),
cached.stem(words.get(idx)).toString());
+ }
+ }
+ });
+ }
+ for (Future<?> task : tasks) {
+ task.get(30, TimeUnit.SECONDS);
+ }
+ }
+ }
+
+ @Test
+ void rejectsInvalidArguments() {
+ Assertions.assertThrows(IllegalArgumentException.class, () -> new
CachingStemmer(null));
+ Assertions.assertThrows(IllegalArgumentException.class, () -> new
CachingStemmer(ENGLISH, 0));
+ Assertions.assertThrows(IllegalArgumentException.class, () -> new
CachingStemmer(ENGLISH, -1));
+ }
+}
diff --git
a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/stemmer/StemmerFactoryTest.java
b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/stemmer/StemmerFactoryTest.java
new file mode 100644
index 000000000..9a19d0110
--- /dev/null
+++
b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/stemmer/StemmerFactoryTest.java
@@ -0,0 +1,376 @@
+/*
+ * 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 opennlp.tools.stemmer;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
+
+import opennlp.tools.stemmer.snowball.SnowballStemmer;
+import opennlp.tools.stemmer.snowball.SnowballStemmerFactory;
+
+class StemmerFactoryTest {
+
+ @Test
+ void snowballFactoryMatchesDirectStemmer() {
+ StemmerFactory factory = new
SnowballStemmerFactory(SnowballStemmer.ALGORITHM.ENGLISH);
+ Stemmer direct = new SnowballStemmer(SnowballStemmer.ALGORITHM.ENGLISH);
+ Assertions.assertEquals(direct.stem("running"),
factory.newStemmer().stem("running"));
+ }
+
+ @Test
+ void porterFactoryMatchesDirectStemmer() {
+ StemmerFactory factory = new PorterStemmerFactory();
+ Stemmer direct = new PorterStemmer();
+ Assertions.assertEquals(direct.stem("running"),
factory.newStemmer().stem("running"));
+ }
+
+ @Test
+ void stemAllDefaultReturnsSingleForm() {
+ StemmerFactory factory = new
SnowballStemmerFactory(SnowballStemmer.ALGORITHM.ENGLISH);
+ List<CharSequence> forms = factory.newStemmer().stemAll("running");
+ Assertions.assertEquals(1, forms.size());
+ Assertions.assertEquals("run", forms.getFirst());
+ }
+
+ @Test
+ void factoryRejectsInvalidRepeat() {
+ Assertions.assertThrows(IllegalArgumentException.class,
+ () -> new SnowballStemmerFactory(SnowballStemmer.ALGORITHM.ENGLISH,
0));
+ }
+
+ @Test
+ void sharingStemmerMatchesSingleThreadReference() throws Exception {
+ StemmerFactory factory = new
SnowballStemmerFactory(SnowballStemmer.ALGORITHM.ENGLISH);
+ Stemmer reference = factory.newStemmer();
+ SharingStemmer shared = new SharingStemmer(factory);
+
+ Assertions.assertEquals(reference.stem("running"), shared.stem("running"));
+ Assertions.assertEquals(reference.stem("this"), shared.stem("this"));
+ }
+
+ @Test
+ void sharingStemmerIsSafeUnderConcurrentUse() throws Exception {
+ StemmerFactory factory = new
SnowballStemmerFactory(SnowballStemmer.ALGORITHM.ENGLISH);
+ SharingStemmer shared = new SharingStemmer(factory);
+ CharSequence expectedRunning = factory.newStemmer().stem("running");
+ CharSequence expectedDeclining = factory.newStemmer().stem("declining");
+
+ try (ExecutorService pool = Executors.newFixedThreadPool(8)) {
+ Future<?>[] tasks = new Future<?>[64];
+ for (int i = 0; i < tasks.length; i++) {
+ tasks[i] = pool.submit(() -> {
+ for (int n = 0; n < 200; n++) {
+ Assertions.assertEquals(expectedRunning, shared.stem("running"));
+ Assertions.assertEquals(expectedDeclining,
shared.stem("declining"));
+ }
+ });
+ }
+ for (Future<?> task : tasks) {
+ task.get(30, TimeUnit.SECONDS);
+ }
+ }
+ }
+
+ @Test
+ void snowballStemmerIsSafeUnderConcurrentUse() throws Exception {
+ Stemmer shared = new SnowballStemmer(SnowballStemmer.ALGORITHM.ENGLISH);
+ CharSequence expectedRunning = shared.stem("running");
+ CharSequence expectedDeclining = shared.stem("declining");
+
+ try (ExecutorService pool = Executors.newFixedThreadPool(8)) {
+ Future<?>[] tasks = new Future<?>[64];
+ for (int i = 0; i < tasks.length; i++) {
+ tasks[i] = pool.submit(() -> {
+ for (int n = 0; n < 200; n++) {
+ Assertions.assertEquals(expectedRunning, shared.stem("running"));
+ Assertions.assertEquals(expectedDeclining,
shared.stem("declining"));
+ }
+ });
+ }
+ for (Future<?> task : tasks) {
+ task.get(30, TimeUnit.SECONDS);
+ }
+ }
+ }
+
+ @Test
+ void sharingStemmerRejectsNullFactory() {
+ Assertions.assertThrows(IllegalArgumentException.class, () -> new
SharingStemmer(null));
+ }
+
+ @ParameterizedTest
+ @EnumSource(SnowballStemmer.ALGORITHM.class)
+ void everyAlgorithmIsSafeUnderConcurrentUse(SnowballStemmer.ALGORITHM
algorithm)
+ throws Exception {
+ // Words across scripts; stemming any input is deterministic, so a
mismatch under
+ // concurrency signals corrupted shared state, regardless of the input
language.
+ List<String> words = List.of("running", "declining", "ab'yle", "prévoyant",
+ "επιστροφή", "яблоками", "استفتياكما", "skrøbeligheder");
+ Stemmer reference = new SnowballStemmer(algorithm);
+ List<String> expected = new ArrayList<>(words.size());
+ for (String word : words) {
+ expected.add(reference.stem(word).toString());
+ }
+
+ Stemmer shared = new SnowballStemmer(algorithm);
+ try (ExecutorService pool = Executors.newFixedThreadPool(4)) {
+ Future<?>[] tasks = new Future<?>[16];
+ for (int i = 0; i < tasks.length; i++) {
+ final int offset = i;
+ tasks[i] = pool.submit(() -> {
+ for (int n = 0; n < 50; n++) {
+ for (int w = 0; w < words.size(); w++) {
+ int idx = (w + offset) % words.size();
+ Assertions.assertEquals(expected.get(idx),
shared.stem(words.get(idx)).toString());
+ }
+ }
+ });
+ }
+ for (Future<?> task : tasks) {
+ task.get(30, TimeUnit.SECONDS);
+ }
+ }
+ }
+
+ @Test
+ void ownerThreadCanStemConcurrentlyWithOtherThreads() throws Exception {
+ // The calling thread claims the owner fast path first; other threads must
transition the
+ // stemmer to per-thread state while the owner keeps stemming.
+ Stemmer shared = new SnowballStemmer(SnowballStemmer.ALGORITHM.ENGLISH);
+ CharSequence expectedRunning = shared.stem("running");
+ CharSequence expectedDeclining = shared.stem("declining");
+
+ CountDownLatch started = new CountDownLatch(4);
+ try (ExecutorService pool = Executors.newFixedThreadPool(4)) {
+ Future<?>[] tasks = new Future<?>[4];
+ for (int i = 0; i < tasks.length; i++) {
+ tasks[i] = pool.submit(() -> {
+ started.countDown();
+ for (int n = 0; n < 500; n++) {
+ Assertions.assertEquals(expectedRunning, shared.stem("running"));
+ Assertions.assertEquals(expectedDeclining,
shared.stem("declining"));
+ }
+ });
+ }
+ Assertions.assertTrue(started.await(10, TimeUnit.SECONDS));
+ // Owner thread stems concurrently with the pool threads.
+ for (int n = 0; n < 500; n++) {
+ Assertions.assertEquals(expectedRunning, shared.stem("running"));
+ Assertions.assertEquals(expectedDeclining, shared.stem("declining"));
+ }
+ for (Future<?> task : tasks) {
+ task.get(30, TimeUnit.SECONDS);
+ }
+ }
+ }
+
+ @Test
+ void snowballStemmerIsSafeOnVirtualThreads() throws Exception {
+ // Every task runs on a fresh virtual thread, exercising per-thread state
creation on each
+ // access rather than reuse from a fixed pool.
+ Stemmer shared = new SnowballStemmer(SnowballStemmer.ALGORITHM.ENGLISH);
+ CharSequence expected = shared.stem("running");
+
+ try (ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor()) {
+ List<Future<?>> tasks = new ArrayList<>(256);
+ for (int i = 0; i < 256; i++) {
+ tasks.add(pool.submit(() -> {
+ for (int n = 0; n < 20; n++) {
+ Assertions.assertEquals(expected, shared.stem("running"));
+ }
+ }));
+ }
+ for (Future<?> task : tasks) {
+ task.get(30, TimeUnit.SECONDS);
+ }
+ }
+ }
+
+ @Test
+ void sharingPorterStemmerIsSafeUnderConcurrentUse() throws Exception {
+ // PorterStemmer is the remaining stateful engine; SharingStemmer must
isolate it per thread.
+ Stemmer reference = new PorterStemmer();
+ CharSequence expectedRunning = reference.stem("running");
+ CharSequence expectedConspiracies = reference.stem("conspiracies");
+ Stemmer shared = new SharingStemmer(new PorterStemmerFactory());
+
+ try (ExecutorService pool = Executors.newFixedThreadPool(8)) {
+ Future<?>[] tasks = new Future<?>[64];
+ for (int i = 0; i < tasks.length; i++) {
+ tasks[i] = pool.submit(() -> {
+ for (int n = 0; n < 200; n++) {
+ Assertions.assertEquals(expectedRunning, shared.stem("running"));
+ Assertions.assertEquals(expectedConspiracies,
shared.stem("conspiracies"));
+ }
+ });
+ }
+ for (Future<?> task : tasks) {
+ task.get(30, TimeUnit.SECONDS);
+ }
+ }
+ }
+ @Test
+ void factoryPassesRepeatThrough() {
+ // "internationalization" stems differently at repeat 1 and 2, witnessing
the pass-through.
+ final Stemmer once = new
SnowballStemmerFactory(SnowballStemmer.ALGORITHM.ENGLISH, 1).newStemmer();
+ final Stemmer twice = new
SnowballStemmerFactory(SnowballStemmer.ALGORITHM.ENGLISH, 2).newStemmer();
+ Assertions.assertEquals("internation",
once.stem("internationalization").toString());
+ Assertions.assertEquals("intern",
twice.stem("internationalization").toString());
+ Assertions.assertEquals(
+ new SnowballStemmer(SnowballStemmer.ALGORITHM.ENGLISH,
2).stem("internationalization").toString(),
+ twice.stem("internationalization").toString());
+ }
+
+ @Test
+ void factoryRejectsNullAlgorithmWithIllegalArgument() {
+ Assertions.assertThrows(IllegalArgumentException.class,
+ () -> new SnowballStemmerFactory(null, 1));
+ }
+
+ @Test
+ void snowballConstructorValidatesLikeTheFactory() {
+ Assertions.assertThrows(IllegalArgumentException.class,
+ () -> new SnowballStemmer(null, 1));
+ Assertions.assertThrows(IllegalArgumentException.class,
+ () -> new SnowballStemmer(SnowballStemmer.ALGORITHM.ENGLISH, 0));
+ }
+
+ @Test
+ void sharingStemmerForwardsStemAllToMultiOutputDelegate() {
+ // A multi-output delegate must keep its full result list through the
wrapper.
+ final StemmerFactory multiOutput = () -> new Stemmer() {
+ @Override
+ public CharSequence stem(CharSequence word) {
+ return word.toString() + "-a";
+ }
+
+ @Override
+ public List<CharSequence> stemAll(CharSequence word) {
+ return List.of(word.toString() + "-a", word.toString() + "-b");
+ }
+ };
+ final SharingStemmer sharing = new SharingStemmer(multiOutput);
+ Assertions.assertEquals(2, sharing.stemAll("run").size());
+ Assertions.assertEquals("run-b", sharing.stemAll("run").get(1).toString());
+ }
+
+ @Test
+ void clearThreadLocalStateEmptiesTheOwnerCache() {
+ // On the owning thread the state object is retained and reset: the cache
must be empty
+ // afterwards, observable as a fresh delegate call for a previously cached
word.
+ final List<String> delegateCalls = new ArrayList<>();
+ final StemmerFactory counting = () -> word -> {
+ delegateCalls.add(word.toString());
+ return word.toString() + "-s";
+ };
+ final CachingStemmer caching = new CachingStemmer(counting);
+ caching.stem("dog");
+ caching.stem("dog");
+ Assertions.assertEquals(1, delegateCalls.size(), "second lookup is served
from the cache");
+ caching.clearThreadLocalState();
+ Assertions.assertEquals("dog-s", caching.stem("dog").toString());
+ Assertions.assertEquals(2, delegateCalls.size(), "the clear emptied the
cache");
+ }
+
+ @Test
+ void clearThreadLocalStateReleasesAWorkerThreadsDelegate() throws Exception {
+ // On a non-owner thread the per-thread delegate is removed and rebuilt on
next use.
+ final List<Integer> built = new ArrayList<>();
+ final StemmerFactory counting = () -> {
+ synchronized (built) {
+ built.add(built.size());
+ }
+ return word -> word.toString() + "-s";
+ };
+ final SharingStemmer sharing = new SharingStemmer(counting);
+ sharing.stem("owner");
+ final int builtForOwner = built.size();
+ final Thread worker = new Thread(() -> {
+ sharing.stem("dog");
+ sharing.stem("cat");
+ final int beforeClear = built.size();
+ sharing.clearThreadLocalState();
+ sharing.stem("fish");
+ Assertions.assertEquals(beforeClear + 1, built.size(),
+ "a fresh delegate is built after the clear");
+ });
+ worker.start();
+ worker.join(30_000);
+ Assertions.assertTrue(built.size() > builtForOwner, "the worker built its
own delegate");
+
+ final SnowballStemmer snowball = new
SnowballStemmer(SnowballStemmer.ALGORITHM.ENGLISH);
+ Assertions.assertEquals("run", snowball.stem("running").toString());
+ snowball.clearThreadLocalState();
+ Assertions.assertEquals("run", snowball.stem("running").toString());
+ }
+
+ @Test
+ void nullWordThrowsIllegalArgumentAcrossTheStemmerApi() {
+ final SnowballStemmerFactory factory =
+ new SnowballStemmerFactory(SnowballStemmer.ALGORITHM.ENGLISH);
+ Assertions.assertThrows(IllegalArgumentException.class,
+ () -> new
SnowballStemmer(SnowballStemmer.ALGORITHM.ENGLISH).stem(null));
+ Assertions.assertThrows(IllegalArgumentException.class,
+ () -> factory.newStemmer().stem(null));
+ Assertions.assertThrows(IllegalArgumentException.class,
+ () -> new CachingStemmer(factory).stem(null));
+ Assertions.assertThrows(IllegalArgumentException.class,
+ () -> new SharingStemmer(factory).stem(null));
+ Assertions.assertThrows(IllegalArgumentException.class,
+ () -> new PorterStemmer().stem((CharSequence) null));
+ }
+
+ @Test
+ void hugeCapacityConstructsAndStems() {
+ // Pins the overflow-safe table sizing: capacities near Integer.MAX_VALUE
are documented
+ // as legal ("must be positive") and must not wrap the LinkedHashMap
initial capacity.
+ final CachingStemmer cached = new CachingStemmer(
+ new SnowballStemmerFactory(SnowballStemmer.ALGORITHM.ENGLISH),
Integer.MAX_VALUE);
+ Assertions.assertEquals("run", cached.stem("running").toString());
+ }
+
+ @Test
+ void clearCacheForcesRestemmingOnTheCallingThread() {
+ final AtomicInteger delegateCalls = new AtomicInteger();
+ final StemmerFactory counting = () -> word -> {
+ delegateCalls.incrementAndGet();
+ return word.toString();
+ };
+ final CachingStemmer cached = new CachingStemmer(counting);
+
+ cached.stem("running");
+ cached.stem("running");
+ Assertions.assertEquals(1, delegateCalls.get(), "the repeat is served from
the cache");
+
+ cached.clearCache();
+ cached.stem("running");
+ Assertions.assertEquals(2, delegateCalls.get(),
+ "after clearCache() the word goes through the delegate again");
+ }
+}
diff --git
a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/stemmer/StemmerFactoryUsageExampleTest.java
b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/stemmer/StemmerFactoryUsageExampleTest.java
new file mode 100644
index 000000000..c0b97941d
--- /dev/null
+++
b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/stemmer/StemmerFactoryUsageExampleTest.java
@@ -0,0 +1,62 @@
+/*
+ * 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 opennlp.tools.stemmer;
+
+import java.util.List;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import opennlp.tools.stemmer.snowball.SnowballStemmer;
+import opennlp.tools.stemmer.snowball.SnowballStemmerFactory;
+
+/**
+ * Runs the manual's stemmer factory examples (docbkx {@code stemmer.xml})
verbatim: every
+ * value the chapter states is asserted here, so a change breaking this test
breaks the
+ * manual.
+ */
+public class StemmerFactoryUsageExampleTest {
+
+ /**
+ * Factory to {@code newStemmer()} to a single stem and the default {@code
stemAll} list.
+ */
+ @Test
+ void testSnowballFactoryStemsOneWord() {
+ final StemmerFactory factory =
+ new SnowballStemmerFactory(SnowballStemmer.ALGORITHM.ENGLISH);
+ final Stemmer stemmer = factory.newStemmer();
+
+ Assertions.assertEquals("run", stemmer.stem("running").toString());
+ Assertions.assertEquals(List.of("run"),
+
stemmer.stemAll("running").stream().map(CharSequence::toString).toList());
+ }
+
+ /**
+ * {@link SharingStemmer} and {@link CachingStemmer} produce the same stem
as a fresh
+ * factory stemmer for the same word.
+ */
+ @Test
+ void testSharingAndCachingStemmersMatchFactoryStem() {
+ final StemmerFactory factory =
+ new SnowballStemmerFactory(SnowballStemmer.ALGORITHM.ENGLISH);
+ final CharSequence expected = factory.newStemmer().stem("running");
+
+ Assertions.assertEquals(expected, new
SharingStemmer(factory).stem("running"));
+ Assertions.assertEquals(expected, new
CachingStemmer(factory).stem("running"));
+ }
+}
diff --git
a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/normalizer/NormalizationProfilesTest.java
b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/normalizer/NormalizationProfilesTest.java
index ba39a5cf1..149139847 100644
---
a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/normalizer/NormalizationProfilesTest.java
+++
b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/normalizer/NormalizationProfilesTest.java
@@ -19,6 +19,9 @@ package opennlp.tools.util.normalizer;
import java.util.EnumSet;
import java.util.List;
import java.util.Set;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
import java.util.stream.Collectors;
import org.junit.jupiter.api.Test;
@@ -201,4 +204,24 @@ public class NormalizationProfilesTest {
assertThrows(IllegalArgumentException.class, () ->
NormalizationProfiles.detect(null, detector));
assertThrows(IllegalArgumentException.class, () ->
NormalizationProfiles.detect("text", null));
}
+
+ @Test
+ void testMatchingAnalyzerIsThreadSafeForStemming() throws Exception {
+ final TermAnalyzer analyzer =
NormalizationProfiles.forLanguage("eng").orElseThrow().matchingAnalyzer();
+ final String expected =
analyzer.analyze("running").getFirst().at(Dimension.STEM);
+
+ try (ExecutorService pool = Executors.newFixedThreadPool(8)) {
+ final Future<?>[] tasks = new Future<?>[32];
+ for (int i = 0; i < tasks.length; i++) {
+ tasks[i] = pool.submit(() -> {
+ for (int n = 0; n < 50; n++) {
+ assertEquals(expected,
analyzer.analyze("running").getFirst().at(Dimension.STEM));
+ }
+ });
+ }
+ for (final Future<?> task : tasks) {
+ task.get(30, java.util.concurrent.TimeUnit.SECONDS);
+ }
+ }
+ }
}
diff --git
a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/normalizer/TermAnalyzerTest.java
b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/normalizer/TermAnalyzerTest.java
index db9018e99..39232e16e 100644
---
a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/normalizer/TermAnalyzerTest.java
+++
b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/normalizer/TermAnalyzerTest.java
@@ -30,6 +30,10 @@ import org.junit.jupiter.api.Test;
import opennlp.tools.lemmatizer.Lemmatizer;
import opennlp.tools.stemmer.PorterStemmer;
+import opennlp.tools.stemmer.Stemmer;
+import opennlp.tools.stemmer.StemmerFactory;
+import opennlp.tools.stemmer.snowball.SnowballStemmer;
+import opennlp.tools.stemmer.snowball.SnowballStemmerFactory;
import opennlp.tools.util.Span;
import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -318,11 +322,33 @@ public class TermAnalyzerTest {
.transform(null, CaseFoldCharSequenceNormalizer.getInstance()));
assertThrows(IllegalArgumentException.class,
() -> TermAnalyzer.builder().transform(Dimension.CASE_FOLD, null));
- assertThrows(IllegalArgumentException.class, () ->
TermAnalyzer.builder().stem(null));
+ assertThrows(IllegalArgumentException.class,
+ () -> TermAnalyzer.builder().stem((Stemmer) null));
+ assertThrows(IllegalArgumentException.class,
+ () -> TermAnalyzer.builder().stem((StemmerFactory) null));
+ assertThrows(IllegalArgumentException.class,
+ () -> TermAnalyzer.builder().stem(new SnowballStemmerFactory(
+ SnowballStemmer.ALGORITHM.ENGLISH), 0));
assertThrows(IllegalArgumentException.class, () ->
TermAnalyzer.builder().lemmatize(null));
assertThrows(IllegalArgumentException.class, () ->
TermAnalyzer.builder().tokenizer(null));
}
+ @Test
+ void testStemFactoryCachedResultsMatchDirectStemmer() {
+ final TermAnalyzer cached = TermAnalyzer.builder().caseFold()
+ .stem(new
SnowballStemmerFactory(SnowballStemmer.ALGORITHM.ENGLISH)).build();
+ final TermAnalyzer direct = TermAnalyzer.builder().caseFold()
+ .stem(new SnowballStemmer(SnowballStemmer.ALGORITHM.ENGLISH)).build();
+
+ final String text = "Running runners kept running while the RUNNING
continued";
+ final List<Term> cachedTerms = cached.analyze(text);
+ final List<Term> directTerms = direct.analyze(text);
+ assertEquals(directTerms.size(), cachedTerms.size());
+ for (int i = 0; i < directTerms.size(); i++) {
+ assertEquals(directTerms.get(i).at(Dimension.STEM),
cachedTerms.get(i).at(Dimension.STEM));
+ }
+ }
+
@Test
void testMaxTokenLengthRejectsNonPositiveValues() {
assertThrows(IllegalArgumentException.class, () ->
TermAnalyzer.builder().maxTokenLength(0));
diff --git a/opennlp-docs/src/docbkx/opennlp.xml
b/opennlp-docs/src/docbkx/opennlp.xml
index 263c5f96c..36641c2c8 100644
--- a/opennlp-docs/src/docbkx/opennlp.xml
+++ b/opennlp-docs/src/docbkx/opennlp.xml
@@ -108,6 +108,7 @@ under the License.
<xi:include xmlns:xi="http://www.w3.org/2001/XInclude"
href="./sentiment.xml" />
<xi:include xmlns:xi="http://www.w3.org/2001/XInclude"
href="./postagger.xml" />
<xi:include xmlns:xi="http://www.w3.org/2001/XInclude"
href="./lemmatizer.xml" />
+ <xi:include xmlns:xi="http://www.w3.org/2001/XInclude"
href="./stemmer.xml" />
<xi:include xmlns:xi="http://www.w3.org/2001/XInclude"
href="./spellcheck.xml" />
<xi:include xmlns:xi="http://www.w3.org/2001/XInclude"
href="./chunker.xml" />
<xi:include xmlns:xi="http://www.w3.org/2001/XInclude"
href="./parser.xml" />
diff --git a/opennlp-docs/src/docbkx/stemmer.xml
b/opennlp-docs/src/docbkx/stemmer.xml
new file mode 100644
index 000000000..248b310c1
--- /dev/null
+++ b/opennlp-docs/src/docbkx/stemmer.xml
@@ -0,0 +1,72 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE chapter PUBLIC "-//OASIS//DTD DocBook XML 5.0//EN"
+"http://docbook.org/xml/5.0/dtd/docbook.dtd"[
+]>
+<!-- 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. -->
+
+<chapter xml:id="tools.stemmer">
+
+ <title>Stemmer</title>
+
+ <section xml:id="tools.stemmer.introduction">
+ <title>Introduction</title>
+ <para>
+ A stemmer reduces a word form to a shorter root.
OpenNLP ships rule-based
+ engines such as Snowball and Porter through the
+ <code>opennlp.tools.stemmer.Stemmer</code> interface.
Stemming is
+ language-specific and lossy: it is useful for search
and feature reduction,
+ not as a substitute for lemmatization when a dictionary
form is required.
+ </para>
+ </section>
+
+ <section xml:id="tools.stemmer.factory">
+ <title>StemmerFactory</title>
+ <para>
+ <code>StemmerFactory</code> captures a stemmer
configuration once and mints
+ configured <code>Stemmer</code> instances on demand. It
is a plain supplier
+ of stemmers, not a <code>BaseToolFactory</code> loaded
from a model
+ manifest. The English Snowball factory needs no
external data:
+ <code>StemmerFactoryUsageExampleTest</code> asserts the
behavior shown
+ here.
+ <programlisting language="java"><![CDATA[
+StemmerFactory factory = new
SnowballStemmerFactory(SnowballStemmer.ALGORITHM.ENGLISH);
+Stemmer stemmer = factory.newStemmer();
+
+stemmer.stem("running"); // "run"
+stemmer.stemAll("running"); // ["run"]]]>
+ </programlisting>
+ </para>
+ </section>
+
+ <section xml:id="tools.stemmer.sharing">
+ <title>Sharing and caching</title>
+ <para>
+ Some stemmer implementations keep mutable engine state
and must not be
+ called from several threads at once.
<code>SharingStemmer</code> wraps a
+ <code>StemmerFactory</code> and routes each call to a
per-thread delegate,
+ so one instance can be shared safely.
<code>CachingStemmer</code> adds a
+ bounded per-thread LRU cache of word-to-stem mappings
on top of the same
+ factory pattern. Both wrappers return the same stem as
a fresh factory
+ stemmer for the same word:
+ <programlisting language="java"><![CDATA[
+StemmerFactory factory = new
SnowballStemmerFactory(SnowballStemmer.ALGORITHM.ENGLISH);
+
+new SharingStemmer(factory).stem("running"); // "run"
+new CachingStemmer(factory).stem("running"); // "run"]]>
+ </programlisting>
+ Thread-safe stemmers such as the shareable
<code>SnowballStemmer</code>
+ class do not need <code>SharingStemmer</code>.
Long-running containers
+ should call <code>clearThreadLocalState()</code> when a
pooled thread no
+ longer uses a sharing or caching stemmer.
+ </para>
+ </section>
+</chapter>
diff --git
a/opennlp-eval-tests/src/test/java/opennlp/tools/eval/MultiThreadedStemmerEval.java
b/opennlp-eval-tests/src/test/java/opennlp/tools/eval/MultiThreadedStemmerEval.java
new file mode 100644
index 000000000..aaac3c4c2
--- /dev/null
+++
b/opennlp-eval-tests/src/test/java/opennlp/tools/eval/MultiThreadedStemmerEval.java
@@ -0,0 +1,175 @@
+/*
+ * 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 opennlp.tools.eval;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Random;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import opennlp.tools.commons.ThreadSafe;
+import opennlp.tools.stemmer.PorterStemmerFactory;
+import opennlp.tools.stemmer.SharingStemmer;
+import opennlp.tools.stemmer.Stemmer;
+import opennlp.tools.stemmer.snowball.SnowballStemmer;
+import opennlp.tools.stemmer.snowball.SnowballStemmer.ALGORITHM;
+import opennlp.tools.util.normalizer.Dimension;
+import opennlp.tools.util.normalizer.NormalizationProfiles;
+import opennlp.tools.util.normalizer.Term;
+import opennlp.tools.util.normalizer.TermAnalyzer;
+
+/**
+ * Tests that the thread-safe stemmer implementations really are thread-safe
by hammering shared
+ * instances from many threads and comparing every result against a
single-threaded reference.
+ *
+ * @see ThreadSafe
+ * @see MultiThreadedToolsEval
+ */
+public class MultiThreadedStemmerEval extends AbstractEvalTest {
+
+ private static final int NUM_THREADS = 8;
+ private static final int RUNS_PER_THREAD = 500;
+
+ /**
+ * Words across scripts and languages, including apostrophes, diacritics,
Greek, Cyrillic and
+ * Arabic. Stemming is deterministic for any input, so every algorithm is
run over the whole
+ * list; a mismatch signals cross-thread corruption of the stemmer's
internal buffers.
+ */
+ private static final List<String> WORDS = List.of(
+ "running", "accompanying", "malediction", "softeners", "declining",
+ "importantíssimes", "besar", "accidentalment",
+ "abbattimento", "aborrecimentos", "importantísimas",
+ "esiintymispaikasta", "aabenbaringen", "skrøbeligheder",
"aftonringningen",
+ "buchbindergesellen", "mitverursacht", "prévoyant", "examinateurs",
+ "ab'yle", "kaçmamaktadır", "sarayı'nı",
+ "abbahagynám", "konstrukciójából", "vliegtuigtransport",
+ "absurdităţilor", "saracilor", "peledakan", "bhfeidhm",
+ "επιστροφή", "στρατιωτών", "красивая", "яблоками",
+ "استفتياكما", "استنتاجاتهما");
+
+ @Test
+ public void runSharedSnowballStemmersMultiThreaded() throws Exception {
+ for (ALGORITHM algorithm : ALGORITHM.values()) {
+ List<String> expected = referenceStems(new SnowballStemmer(algorithm));
+ hammer(new SnowballStemmer(algorithm), expected, "Snowball " +
algorithm);
+ }
+ }
+
+ @Test
+ public void runSharedPorterStemmerMultiThreaded() throws Exception {
+ SharingStemmer shared = new SharingStemmer(new PorterStemmerFactory());
+ List<String> expected = referenceStems(new
PorterStemmerFactory().newStemmer());
+ hammer(shared, expected, "SharingStemmer(Porter)");
+ }
+
+ @Test
+ public void runMatchingAnalyzersMultiThreaded() throws Exception {
+ for (String language : NormalizationProfiles.supportedLanguages()) {
+ TermAnalyzer analyzer =
+
NormalizationProfiles.forLanguage(language).orElseThrow().matchingAnalyzer();
+
+ List<String> expected = new ArrayList<>(WORDS.size());
+ for (String word : WORDS) {
+ expected.add(stemDimension(analyzer, word));
+ }
+
+ runInThreads(threadIndex -> {
+ List<Integer> order = shuffledIndexes(threadIndex);
+ for (int run = 0; run < RUNS_PER_THREAD / 10; run++) {
+ for (int i : order) {
+ Assertions.assertEquals(expected.get(i), stemDimension(analyzer,
WORDS.get(i)),
+ () -> "matchingAnalyzer(" + language + ") corrupted under
concurrency");
+ }
+ }
+ });
+ }
+ }
+
+ private static String stemDimension(TermAnalyzer analyzer, String word) {
+ List<Term> terms = analyzer.analyze(word);
+ if (terms.isEmpty()) {
+ return "";
+ }
+ StringBuilder sb = new StringBuilder();
+ for (Term term : terms) {
+ sb.append(term.at(Dimension.STEM)).append(' ');
+ }
+ return sb.toString();
+ }
+
+ private static List<String> referenceStems(Stemmer reference) {
+ List<String> expected = new ArrayList<>(WORDS.size());
+ for (String word : WORDS) {
+ expected.add(reference.stem(word).toString());
+ }
+ return expected;
+ }
+
+ private static void hammer(Stemmer shared, List<String> expected, String
label) throws Exception {
+ runInThreads(threadIndex -> {
+ List<Integer> order = shuffledIndexes(threadIndex);
+ for (int run = 0; run < RUNS_PER_THREAD; run++) {
+ for (int i : order) {
+ Assertions.assertEquals(expected.get(i),
shared.stem(WORDS.get(i)).toString(),
+ () -> label + " corrupted under concurrency for input '" +
WORDS.get(i) + "'");
+ }
+ }
+ });
+ }
+
+ /**
+ * Each thread visits the word list in its own order so that different
threads are (almost)
+ * always inside different words at the same time, maximizing the chance of
exposing shared
+ * mutable state.
+ */
+ private static List<Integer> shuffledIndexes(int seed) {
+ List<Integer> order = new ArrayList<>(WORDS.size());
+ for (int i = 0; i < WORDS.size(); i++) {
+ order.add(i);
+ }
+ Collections.shuffle(order, new Random(seed));
+ return order;
+ }
+
+ private interface ThreadBody {
+ void run(int threadIndex) throws Exception;
+ }
+
+ private static void runInThreads(ThreadBody body) throws Exception {
+ try (ExecutorService pool = Executors.newFixedThreadPool(NUM_THREADS)) {
+ List<Future<?>> tasks = new ArrayList<>(NUM_THREADS);
+ for (int t = 0; t < NUM_THREADS; t++) {
+ final int threadIndex = t;
+ tasks.add(pool.submit(() -> {
+ body.run(threadIndex);
+ return null;
+ }));
+ }
+ for (Future<?> task : tasks) {
+ task.get(120, TimeUnit.SECONDS);
+ }
+ }
+ }
+}