Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
178386f
OPENNLP-1816: Make ME classes thread-safe by eliminating shared mutab…
krickert Mar 30, 2026
cb6e6f4
OPENNLP-1816: Ensure thread safety for all stateful feature generator…
krickert Apr 2, 2026
f65b567
OPENNLP-1816: Fix ThreadLocal implementation for ME classes and BeamS…
krickert Apr 2, 2026
b80d3a1
OPENNLP-1816: Finalize thread-safety fixes and documentation
krickert Apr 2, 2026
9b9eea9
OPENNLP-1816: Add LemmatizerME null guard, rename cache property, add…
krickert Apr 2, 2026
90123d6
OPENNLP-1816: BeamSearch review follow-up (Javadoc, inheritDoc, cache…
krickert Apr 9, 2026
6673a0a
OPENNLP-1816: Assign bestSequence after null check in ME classes
krickert Apr 9, 2026
794fa23
OPENNLP-1816: Format JMH benchmarks for 110-column style
krickert Apr 9, 2026
b193ca1
OPENNLP-1816: Widen ThreadSafetyBenchmarkTest constant line
krickert Apr 9, 2026
7c1772c
OPENNLP-1816: Refactor ConfigurablePOSContextGenerator cache paths
krickert Apr 9, 2026
3a43f71
OPENNLP-1816: CachedFeatureGenerator else branch and deprecated stats
krickert Apr 9, 2026
4334043
OPENNLP-1816: ThreadSafe and field notes on feature generators
krickert Apr 9, 2026
3b722eb
OPENNLP-1816: Reject POSTaggerME contextCacheSize below -1
krickert Apr 9, 2026
c1c00ae
OPENNLP-1816: Tighten POSTaggerME constructor formatting
krickert Apr 9, 2026
6de4b0d
OPENNLP-1816: Javadoc for CachedFeatureGeneratorTest methods
krickert Apr 10, 2026
6911a60
OPENNLP-1816: ThreadSafetyBenchmarkTest 110-col sweep and Javadoc
krickert Apr 10, 2026
1c6d933
OPENNLP-1816: Run thread-safety benchmark as Failsafe IT
krickert Apr 10, 2026
13177da
OPENNLP-1816: ME last-result state and Javadoc line length
krickert Apr 10, 2026
7e44232
OPENNLP-1816: Drop strong Thread reference in LastResultOwnerOrThread…
krickert Apr 17, 2026
26ef515
OPENNLP-1816: BeamSearch tempScores reuse and thread-safety Javadoc p…
krickert Apr 17, 2026
cb4d7f2
OPENNLP-1816: POSTaggerNameFeatureGenerator per-thread sentence cache
krickert Apr 17, 2026
c6274c4
OPENNLP-1816: DictionaryFeatureGenerator safe-publication and @Thread…
krickert Apr 17, 2026
19a2ac6
OPENNLP-1816: NameFinderME drop nested ThreadLocal, share AFG instance
krickert Apr 17, 2026
42f499b
OPENNLP-1816: Update docs for thread-safe ME classes in 3.0.0
krickert Apr 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,10 @@ and import only the components you need, which will result in a smaller dependen
Only `opennlp-runtime` needs to be added as a dependency, and you can add additional modules (e.g. `opennlp-ml-maxent`, `opennlp-model-resolver`, etc.) as required by your project.
For users of the traditional CLI toolkit, nothing changes with the 3.x release line. CLI usage remains stable as described in the [project's dev manual](https://opennlp.apache.org/docs/).

### Thread safety

Starting with 3.0.0, the core `*ME` classes (`POSTaggerME`, `TokenizerME`, `SentenceDetectorME`, `ChunkerME`, `LemmatizerME`, `NameFinderME`) are thread safe and a single instance can be shared across threads. This eliminates the need to pool or recreate ME instances per thread. The legacy `ThreadSafe*ME` wrappers from 2.x still work but are now deprecated; existing code does not need to change to upgrade.

### Head's up

The Apache OpenNLP team is planning to change the package namespace from `opennlp` to `org.apache.opennlp` in a future release (potentially 4.x).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@
import opennlp.tools.cmdline.params.EncodingParameter;
import opennlp.tools.doccat.DocumentSample;
import opennlp.tools.tokenize.SimpleTokenizer;
import opennlp.tools.tokenize.ThreadSafeTokenizerME;
import opennlp.tools.tokenize.Tokenizer;
import opennlp.tools.tokenize.TokenizerME;
import opennlp.tools.tokenize.TokenizerModel;
import opennlp.tools.tokenize.WhitespaceTokenizer;
import opennlp.tools.util.ObjectStream;
Expand Down Expand Up @@ -74,7 +74,7 @@ public ObjectStream<DocumentSample> create(String[] args) {
Tokenizer tokenizer = WhitespaceTokenizer.INSTANCE;
if (params.getTokenizerModel() != null) {
try {
tokenizer = new ThreadSafeTokenizerME(new TokenizerModel(params.getTokenizerModel()));
tokenizer = new TokenizerME(new TokenizerModel(params.getTokenizerModel()));
} catch (IOException e) {
throw new TerminateToolException(-1, "Failed to load tokenizer model!", e);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@
import opennlp.tools.formats.DirectorySampleStream;
import opennlp.tools.formats.convert.FileToStringSampleStream;
import opennlp.tools.namefind.NameSample;
import opennlp.tools.tokenize.ThreadSafeTokenizerME;
import opennlp.tools.tokenize.Tokenizer;
import opennlp.tools.tokenize.TokenizerME;
import opennlp.tools.tokenize.TokenizerModel;
import opennlp.tools.util.ObjectStream;
import opennlp.tools.util.StringUtil;
Expand Down Expand Up @@ -70,7 +70,7 @@ public ObjectStream<NameSample> create(String[] args) {
}
try {
TokenizerModel tokenizerModel = new TokenizerModel(params.getTokenizerModel());
Tokenizer tokenizer = new ThreadSafeTokenizerME(tokenizerModel);
Tokenizer tokenizer = new TokenizerME(tokenizerModel);

ObjectStream<String> mucDocStream = new FileToStringSampleStream(
new DirectorySampleStream(params.getData(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import java.util.PriorityQueue;
import java.util.Queue;

import opennlp.tools.commons.ThreadSafe;
import opennlp.tools.ml.model.MaxentModel;
import opennlp.tools.ml.model.SequenceClassificationModel;
import opennlp.tools.util.BeamSearchContextGenerator;
Expand All @@ -34,23 +35,45 @@
* <p>
* This is based on the description in Ratnaparkhi (1998),
* PhD diss, Univ. of Pennsylvania.
* <p>
* This implementation is thread-safe. The contexts cache and probability buffer
* are maintained per-thread via {@link ThreadLocal}.
* <p>
* <b>Note:</b> In container environments with classloader isolation (e.g. Jakarta EE),
* {@link ThreadLocal} state may pin the classloader. Ensure instances do not outlive
* the application's lifecycle, or call {@link ThreadLocal#remove()} on pooled threads.
*
* @see Sequence
* @see SequenceValidator
* @see BeamSearchContextGenerator
*/
public class BeamSearch implements SequenceClassificationModel {
@ThreadSafe
public class BeamSearch implements SequenceClassificationModel, AutoCloseable {

public static final String BEAM_SIZE_PARAMETER = "BeamSize";

private static final Object[] EMPTY_ADDITIONAL_CONTEXT = new Object[0];

protected final int size;
protected final MaxentModel model;
private final int size;
private final MaxentModel model;

private static final int ZERO_LOG = -100000;

private final int cacheSize;

private final double[] probs;
private Cache<String[], double[]> contextsCache;
private static final int zeroLog = -100000;
private final ThreadLocal<CacheState> threadState;

private static final class CacheState {
private final double[] probs;
private final double[] tempScores;
private final Cache<String[], double[]> cache;

CacheState(int numOutcomes, int cacheSize) {
this.probs = new double[numOutcomes];
this.tempScores = new double[numOutcomes];
this.cache = cacheSize > 0 ? new Cache<>(cacheSize) : null;
}
}

/**
* Initializes a {@link BeamSearch} instance.
Expand All @@ -63,92 +86,90 @@ public BeamSearch(int size, MaxentModel model) {
}

/**
* Initializes a {@link BeamSearch} instance.
* Initializes a {@link BeamSearch} instance with an optional per-thread contexts cache.
*
* @param size The size of the beam (k).
* @param model The {@link MaxentModel} for assigning probabilities to the sequence outcomes.
* @param cacheSize The capacity of the {@link Cache} to use.
* @param cacheSize The capacity of the per-thread contexts cache. Use {@code 0} to disable
* only that cache; per-thread score buffers are still allocated so evaluation stays
* thread-safe (see {@link CacheState}).
*/
public BeamSearch(int size, MaxentModel model, int cacheSize) {

this.size = size;
this.model = model;

if (cacheSize > 0) {
contextsCache = new Cache<>(cacheSize);
}

this.probs = new double[model.getNumOutcomes()];
this.cacheSize = cacheSize;
this.threadState = ThreadLocal.withInitial(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be:

this.threadState = cacheSize > 0 ? ThreadLocal.withInitial(
        () -> new CacheState(model.getNumOutcomes(), cacheSize))
        : null;

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I considered that shape, but left threadState as a ThreadLocal even when cacheSize == 0 on purpose: turning off the optional contexts cache is not the same as having no per-thread state — BeamSearch still needs isolated buffers for eval / beam work when multiple threads hit the same instance.

Dropping to null would just force a different place to stash that state (or reintroduce races). Happy to add a one-line comment next to the field if that helps future readers.

() -> new CacheState(model.getNumOutcomes(), cacheSize));
}

/**
* Computes the best sequence of outcomes based on the {@link MaxentModel}.
*
* @param numSequences The number of sequences.
* @param sequence The input {@link T} sequence.
* @param additionalContext An {@link Object[]} of additional context.
* This is passed to the context generator blindly with the
* assumption that the context are appropriate.
* @param minSequenceScore The minimum sequence score to use.
* @param cg The {@link BeamSearchContextGenerator context generator} to use.
* @param validator The {@link SequenceValidator} to validate sequences.
*
* @return The top ranked {@link Sequence} of outcomes or {@code null}
* if no sequence could be found.
* {@inheritDoc}
*/
@Override
public <T> Sequence[] bestSequences(int numSequences, T[] sequence,
Object[] additionalContext, double minSequenceScore,
BeamSearchContextGenerator<T> cg, SequenceValidator<T> validator) {
public <T> Sequence[] bestSequences(final int numSequences, final T[] sequence,
Comment thread
mawiesne marked this conversation as resolved.
final Object[] additionalContext, final double minSequenceScore,
final BeamSearchContextGenerator<T> cg, final SequenceValidator<T> validator) {

final CacheState state = threadState.get();

Queue<Sequence> prev = new PriorityQueue<>(size);
Queue<Sequence> next = new PriorityQueue<>(size);
Queue<Sequence> tmp;
prev.add(new Sequence());

if (additionalContext == null) {
additionalContext = EMPTY_ADDITIONAL_CONTEXT;
Object[] context = additionalContext;
if (context == null) {
context = EMPTY_ADDITIONAL_CONTEXT;
}

for (int i = 0; i < sequence.length; i++) {
int sz = StrictMath.min(size, prev.size());
final int sz = StrictMath.min(size, prev.size());

for (int sc = 0; prev.size() > 0 && sc < sz; sc++) {
Sequence top = prev.remove();
List<String> tmpOutcomes = top.getOutcomes();
String[] outcomes = tmpOutcomes.toArray(new String[0]);
String[] contexts = cg.getContext(i, sequence, outcomes, additionalContext);
double[] scores;
if (contextsCache != null) {
scores = contextsCache.computeIfAbsent(contexts, c -> model.eval(c, probs));
final Sequence top = prev.remove();
final List<String> tmpOutcomes = top.getOutcomes();
final String[] outcomes = tmpOutcomes.toArray(new String[0]);
final String[] contexts = cg.getContext(i, sequence, outcomes, context);
final double[] scores;
if (state.cache != null) {
scores = state.cache.computeIfAbsent(contexts, c -> {
// eval() writes into state.probs; cache values must be immutable copies for reuse.
double[] res = model.eval(c, state.probs);
double[] copy = new double[res.length];
Comment thread
mawiesne marked this conversation as resolved.
System.arraycopy(res, 0, copy, 0, res.length);
return copy;
});
} else {
scores = model.eval(contexts, probs);
scores = model.eval(contexts, state.probs);
}

double[] temp_scores = new double[scores.length];
System.arraycopy(scores, 0, temp_scores, 0, scores.length);
// tempScores is a per-thread scratch buffer of length numOutcomes; we sort a copy here so
// we never mutate `scores` (which may be a cached entry or alias state.probs).
final double[] tempScores = state.tempScores;
System.arraycopy(scores, 0, tempScores, 0, scores.length);

Arrays.sort(temp_scores);
Arrays.sort(tempScores);

double min = temp_scores[StrictMath.max(0,scores.length - size)];
final double min = tempScores[StrictMath.max(0, scores.length - size)];

for (int p = 0; p < scores.length; p++) {
if (scores[p] >= min) {
String out = model.getOutcome(p);
final String out = model.getOutcome(p);
if (validator.validSequence(i, sequence, outcomes, out)) {
Sequence ns = new Sequence(top, out, scores[p]);
final Sequence ns = new Sequence(top, out, scores[p]);
if (ns.getScore() > minSequenceScore) {
next.add(ns);
}
}
}
}

if (next.size() == 0) { //if no advanced sequences, advance all valid
if (next.isEmpty()) { // if no advanced sequences, advance all valid
for (int p = 0; p < scores.length; p++) {
String out = model.getOutcome(p);
final String out = model.getOutcome(p);
if (validator.validSequence(i, sequence, outcomes, out)) {
Sequence ns = new Sequence(top, out, scores[p]);
final Sequence ns = new Sequence(top, out, scores[p]);
if (ns.getScore() > minSequenceScore) {
next.add(ns);
}
Expand All @@ -164,8 +185,8 @@ public <T> Sequence[] bestSequences(int numSequences, T[] sequence,
next = tmp;
}

int numSeq = StrictMath.min(numSequences, prev.size());
Sequence[] topSequences = new Sequence[numSeq];
final int numSeq = StrictMath.min(numSequences, prev.size());
final Sequence[] topSequences = new Sequence[numSeq];

for (int seqIndex = 0; seqIndex < numSeq; seqIndex++) {
topSequences[seqIndex] = prev.remove();
Expand All @@ -175,47 +196,28 @@ public <T> Sequence[] bestSequences(int numSequences, T[] sequence,
}

/**
* Computes the best sequence of outcomes based on the {@link MaxentModel}.
*
* @param numSequences The number of sequences.
* @param sequence The input {@link T} sequence.
* @param additionalContext An {@link Object[]} of additional context.
* This is passed to the context generator blindly with the
* assumption that the context are appropriate.
* @param cg The {@link BeamSearchContextGenerator context generator} to use.
* @param validator The {@link SequenceValidator} to validate sequences.
*
* @return The top ranked {@link Sequence} of outcomes or {@code null}
* if no sequence could be found.
* {@inheritDoc}
*/
@Override
public <T> Sequence[] bestSequences(int numSequences, T[] sequence,
Object[] additionalContext, BeamSearchContextGenerator<T> cg, SequenceValidator<T> validator) {
return bestSequences(numSequences, sequence, additionalContext, zeroLog, cg, validator);
public <T> Sequence[] bestSequences(final int numSequences, final T[] sequence,
final Object[] additionalContext, final BeamSearchContextGenerator<T> cg,
final SequenceValidator<T> validator) {
return bestSequences(numSequences, sequence, additionalContext, ZERO_LOG, cg, validator);
}

/**
* Computes the best sequence of outcomes based on the {@link MaxentModel}.
*
* @param sequence The input {@link T} sequence.
* @param additionalContext An {@link Object[]} of additional context.
* This is passed to the context generator blindly with the
* assumption that the context are appropriate.
* @param cg The {@link BeamSearchContextGenerator context generator} to use.
* @param validator The {@link SequenceValidator} to validate sequences.
*
* @return The top ranked {@link Sequence} of outcomes or {@code null}
* if no sequence could be found.
* {@inheritDoc}
*/
@Override
public <T> Sequence bestSequence(T[] sequence, Object[] additionalContext,
BeamSearchContextGenerator<T> cg, SequenceValidator<T> validator) {
Sequence[] sequences = bestSequences(1, sequence, additionalContext, cg, validator);
public <T> Sequence bestSequence(final T[] sequence, final Object[] additionalContext,
final BeamSearchContextGenerator<T> cg, final SequenceValidator<T> validator) {
final Sequence[] sequences = bestSequences(1, sequence, additionalContext, cg, validator);

if (sequences.length > 0)
if (sequences.length > 0) {
return sequences[0];
else
} else {
return null;
}
}

@Override
Expand All @@ -227,4 +229,25 @@ public String[] getOutcomes() {
}
return outcomes;
}

/**
* Clears {@link ThreadLocal} state for the <b>current</b> thread only. This is intentionally not a
* "shut down the {@code BeamSearch} instance" operation: a single {@code BeamSearch} is typically
* shared across many pool threads, and each one owns an independent {@link CacheState} entry.
*
* <p>Typical usage patterns:</p>
* <ul>
* <li><b>Worker thread returning to a pool:</b> call {@code close()} (or wrap a single decode call in
* try-with-resources) on each pool thread that has touched the instance.</li>
* <li><b>Application shutdown / classloader unload:</b> {@code close()} on a single thread is
* <i>not</i> sufficient to release every per-thread slot — those die with their owning threads, or
* must be cleared on each thread before the application classloader is released.</li>
* </ul>
*
* <p>Same lifecycle contract as {@code clearThreadLocalState()} on the seven ME classes.</p>
*/
@Override
public void close() {
threadState.remove();
}
}
Loading