rmatch is a Java library for matching many regular expressions against large
text buffers with one pass-oriented matching pipeline. It is aimed at workloads
where many patterns are registered once and then reused against large corpora,
for example log scanning, rule matching, and other high-volume multi-pattern
search tasks.
2.0.0-RC1 is available from Maven Central for serious evaluation before the
stable 2.0 release. The engine and proposed public contract are ready for use;
feedback from real workloads will help expose any remaining friction.
Found a bug, an API problem, confusing syntax, or a result you cannot reproduce? Please open an issue in the rmatch GitHub issue tracker. Include the smallest pattern set and input that demonstrate the problem, plus your Java and rmatch versions. Questions and critical feedback are welcome too.
The benchmark question for rmatch is not "can it beat one regex on one short string?" It is: when many patterns must be applied to the same corpus, how does a one-pass multi-pattern engine compare with engines that normally run one compiled pattern at a time?
The public comparison model is:
java.util.regexnaive loop: compile the same pattern set, then run each compiled Java regex over the corpus.- RE2J loop: compile the same pattern set with RE2J, then run each compiled RE2J regex over the corpus.
- rmatch: register the same pattern set in one matcher and scan the corpus once.
Current measurements use byte-identical inputs and require match counts to agree before a timing is retained. The charts below come from Docker runs on a 16-core / 32-thread AMD Ryzen 9 9950X3D using deterministic 8 MiB and 50 MiB fixtures with 1,000, 2,500, 5,000, 7,500, and 10,000 patterns. Every JVM data point uses the highest-throughput thread count found for that engine and scenario in its calibration sweep. The exact thread counts are recorded with the receipts in the benchmark project. Hyperscan is the explicitly labeled one-thread native reference, included to show the much higher native-performance ceiling rather than as an execution-model peer.
This is deliberately a "push each engine" comparison, not an equal-CPU-budget comparison. At 1,000 patterns tuned RE2/J leads the JVM lanes. As the pattern set grows, rmatch overtakes RE2/J between 5,000 and 7,500 patterns, depending on fixture size and expression family. The logarithmic scale keeps Hyperscan and the JVM results legible in the same chart.
These are exploratory measurements from a benchmark project that is only just getting started, not a systematic testing campaign or a final verdict. The workloads are generated, the scenario set is still small, and wider syntax, corpora, machines, match densities, and resource controls remain to be tested. The harness, calibration sweeps, methodology, exact thread counts, and all 80 retained receipts live in the rmatch performance measurements project. Watch this space.
Use rmatch when the workload looks like this:
- You have hundreds, thousands, or tens of thousands of patterns.
- You apply the same pattern set to large buffers or many large documents.
- You care more about aggregate scanning throughput than PCRE-compatible syntax breadth.
- You can work within a regular-language subset and do not need captures, backreferences, or lookaround.
When contributing to rmatch development, performance regression testing is part
of the work: almost all plausible matcher improvements are not improvements
until measured against the same inputs with the same correctness expectations.
The branch-vs-main protocol lives in
docs/performance-regression-testing.md.
The examples below use the public API in 2.0.0-RC1.
Add rmatch to an existing Maven project:
<dependency>
<groupId>no.rmz</groupId>
<artifactId>rmatch</artifactId>
<version>2.0.0-RC1</version>
</dependency>2.0.0-RC1 is compiled with --release 21, so consumers should use Java 21 or
newer. Maven Central also publishes source and Javadoc artifacts for IDEs and
API browsers.
For Gradle:
dependencies {
implementation("no.rmz:rmatch:2.0.0-RC1")
}For a scratch project, create this structure:
rmatch-demo/
pom.xml
src/main/java/Example.java
For a complete scratch project, use this full pom.xml:
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>demo</groupId>
<artifactId>rmatch-demo</artifactId>
<version>1.0.0</version>
<properties>
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>no.rmz</groupId>
<artifactId>rmatch</artifactId>
<version>2.0.0-RC1</version>
</dependency>
</dependencies>
</project>Put this in src/main/java/Example.java. It registers two patterns once, scans
one buffer, and prints both matches.
import no.rmz.rmatch.Matcher;
import no.rmz.rmatch.RMatch;
public class Example {
public static void main(String[] args) throws Exception {
try (Matcher matcher = RMatch.newMatcher()) {
matcher.add("ERROR|WARN", (buffer, start, end) -> {
System.out.println("log-level match: " + buffer.getString(start, end));
});
matcher.add("user:[a-z]+", (buffer, start, end) -> {
System.out.println("user token match: " + buffer.getString(start, end));
});
matcher.match(RMatch.stringBuffer("INFO user:alice WARN disk nearly full"));
}
}
}Run it:
mvn -q compile exec:java -Dexec.mainClass=ExampleExpected output contains both lines. Callback order is deliberately unspecified, even for a single-engine matcher; the production matcher may also invoke actions concurrently:
user token match: user:alice
log-level match: WARN
RMatch.newMatcher() creates the recommended production matcher. Matchers are
AutoCloseable; closing releases the worker threads a partitioned matcher
owns, so try-with-resources is the natural usage pattern. The callback
receives the matched buffer and half-open [start, end) offsets — the same
convention as String.substring, so the matched text is exactly
buffer.getString(start, end) and its length is end - start. rmatch
reports the longest match for each start position; overlapping matches from
different start positions may therefore be reported.
A matcher has a build-then-use lifecycle. Register every pattern before the
first call to match(); that first scan permanently freezes registration.
Create a replacement matcher when the rule set changes. Registering the same
Action object more than once for one pattern is idempotent. Two distinct
action objects remain distinct even if their equals() methods consider them
equal, and both are invoked for each reported match. If result order matters,
collect the callbacks and sort them explicitly.
The automatic matcher uses a hardware-based heuristic intended as a sensible starting point, not as the best setting for every workload. Applications that know their deployment can select the parallelism explicitly:
try (Matcher matcher = RMatch.newMatcher(384)) {
// Register patterns and scan buffers as usual.
}The argument is the number of pattern partitions and the maximum number of
concurrent workers. Supported values range from 1 through 1024; 1 uses
the single-engine matcher without a worker pool. The deliberately high ceiling
leaves room for current high-end workstations and servers, including machines
with hundreds of hardware threads. Each partition scans the same buffer with
its share of the patterns, so more workers also consume more memory bandwidth
and can make smaller workloads slower. Measure with representative patterns
and corpora rather than assuming that the largest available value is best.
Matcher.add throws RegexpParserException when a pattern is outside the
supported syntax subset, so unsupported constructs fail loudly at
registration time, never silently at match time.
The callback does not carry a pattern identifier; the action itself is the identity. Register one action per pattern and let the closure capture whatever identity you need:
for (Rule rule : rules) {
matcher.add(rule.pattern(), (buffer, start, end) ->
hits.add(new Hit(rule.id(), start, end)));
}Actions may run concurrently on a partitioned matcher, so collect into a
thread-safe structure (LongAdder, a concurrent collection, or a
synchronized block). No callback order is guaranteed.
For one small pattern against one small string, java.util.regex is usually the
simpler tool. rmatch is for many-pattern workloads where avoiding a separate
regex search for every pattern matters.
The public input helpers are named RMatch.stringBuffer(...). That is an
intentional choice, not an accident of the example code: these helpers read the
whole input into a String and match over that materialized text.
rmatch buffers are pure content: a Buffer answers hasCharAt(pos),
charAt(pos), and getString(start, stop), and holds no iteration state.
Engines keep their own cursors, which is how the partitioned matcher scans one
buffer from several threads — implementations must tolerate concurrent
readers, which immutable content does trivially. Match callbacks receive start
and end offsets, and user code commonly asks the buffer for the matched text
by offset, so content must be replayable within whatever retention window the
implementation documents.
If your input starts as a file, Reader, or InputStream, the convenience
overloads can read and decode it for you:
Buffer fromString = RMatch.stringBuffer("plain text");
Buffer fromPath = RMatch.stringBuffer(path, StandardCharsets.UTF_8);
Buffer fromReader = RMatch.stringBuffer(reader);
Buffer fromStream = RMatch.stringBuffer(inputStream, StandardCharsets.UTF_8);All of these forms still materialize the whole input as a String. The
Buffer contract deliberately has no total-length method: end of input is the
positional question hasCharAt(pos), which a bounded-window implementation
over a long or streaming input can answer by fetching more data, restricting
getString to its documented lookback window. rmatch does not ship such an
adapter yet, but the interface is shaped so that it stays possible.
The escape hatch is deliberately simple: Buffer is public, and
Matcher.match(Buffer) accepts any implementation. If you want to experiment
with a file-backed, windowed, mmap-backed, or otherwise specialized input
adapter, implement Buffer and pass it directly to the matcher. The contract
is three methods: character lookup by position, substring lookup by half-open
range, and a positional end-of-input probe — plus tolerance for concurrent
readers.
The parser intentionally supports a reduced regular-expression language. This
is not a drop-in replacement for java.util.regex or PCRE.
- Literal text:
abc - Concatenation:
ab(implicit) - Alternation:
a|b - Quantifiers on the previous atom:
?,*,+ - Counted quantifiers:
{m},{m,n},{m,}(expansion capped at 1000) - Grouping:
( ... )and(?: ... )(equivalent; there are no captures) - Any single character:
.(matches every character, including newline) - Character classes:
[abc], ranges[a-z], negated classes[^abc] - Shorthand classes:
\d,\D,\w,\W,\s,\S - Escapes:
\\,\.,\*,\+,\?,\[,\(,\n,\t,\r,\f - Line anchors:
^and$in line-oriented mode.^matches at buffer start and after\n;$matches at EOF and before\n. - Word boundaries:
\band\Busing ASCII word semantics aligned with\w: letters, digits, and underscore are word characters. - Pattern-prefix flags:
(?i)for case-insensitive matching;(?s)is accepted because.is already DOTALL. Programmatic registration can usematcher.add(pattern, Set.of(PatternFlag.CASE_INSENSITIVE), action)instead of splicing prefixes into pattern strings
These constructs are not part of the supported surface:
- Pure zero-width patterns such as
^$are not yet part of the public support contract; match reporting currently assumes consumed spans. - Lookaround:
(?=...),(?!...),(?<=...),(?<!...) - Backreferences and capture-group features
- Scoped inline flags such as
a(?i)b MULTILINEmode and a non-DOTALL.toggle
Backreferences are intentionally out of scope because they are non-regular. The normative 2.0 syntax and match-reporting contract, including longest-match and overlap behavior, is in docs/regex-syntax-and-semantics.md.
- CHANGELOG.md records the
1.9.xpreparation line and the published2.0.0-RC1release candidate. - docs/release-notes-2.0.0-RC1.md contains the user-facing release notes for the first 2.0 candidate.
- docs/regex-syntax-and-semantics.md defines the proposed stable syntax and matching behavior.
- docs/release.md documents the Maven Central release lane.
- docs/maven-central-release-checklist.md tracks the current release checklist.
- docs/regex-syntax-roadmap.md tracks possible work beyond the stable 2.0 surface.
- rmatch/ contains the public library artifact
no.rmz:rmatch. - rmatch-tester/ contains local performance and experiment tooling; it is not part of the Maven Central release lane.
- rmatch-perftest contains the benchmark platform and multi-engine comparison harness.
- rmatch-meta contains longer-form benchmark writeups, plans, and analysis papers.
Use rmatch-perftest for branch-vs-main performance checks:
- In
rmatchonmain, run correctness checks and install locally:./mvnw -q test && ./mvnw -q -DskipTests install - In
rmatch-perftest, run the baseline benchmark config and keep the result directory. - Switch to the candidate branch in
rmatch, rerun the same checks, and install locally again. - In
rmatch-perftest, rerun the exact same benchmark config, reusing the same pattern and corpus files. - Compare baseline vs candidate from the generated reports, raw JSON, or database artifacts. Treat the comparison as valid only when inputs and match counts agree.
Keeping the benchmark campaign machinery in rmatch-perftest keeps the Maven
library artifact small and focused.