跪拜 Guibai
← Back to the summary

Java Regex: Precompile Pattern or Pay 1750x in Throughput

ValidX Regex Precompilation Optimization: Pattern Reuse Strategy

Table of Contents


Introduction

Regular expressions are a powerful tool for text processing, but their performance characteristics are often overlooked. Many developers write regex code that is functionally correct yet performs poorly—recompiling the regex on every call becomes a critical performance bottleneck in high-concurrency scenarios.

This article deeply analyzes the compilation mechanism of Java regex and summarizes the best practices for Pattern precompilation and reuse. These principles are strictly followed in the ValidX project and form the basis for writing high-performance validators.


The overall knowledge flow is shown below for quick global understanding:

flowchart TD
    A["Regex performance problem"] --> B["Compilation cost analysis"]
    B --> C["Core principle of precompilation<br/>static final"]
    C --> D["ValidX management practices"]
    D --> E["Correct way to reuse Pattern"]
    E --> F["Precompilation strategy for dynamic regex"]
    F --> G["Common errors and fixes"]
    G --> H["Performance benchmark validation"]
    H --> I["Production environment management checklist"]

    style A fill:#e1f5ff
    style C fill:#fff3e0
    style I fill:#c8e6c9

1. The Compilation Cost of Regular Expressions

1.1 The Internal Process of Pattern.compile()

When you call Pattern.compile(regex), Java internally goes through four stages:

flowchart TD
    A["Pattern.compile()"] --> B["① Lexical Analysis"]
    B --> C["② Syntax Analysis"]
    C --> D["③ State Machine Construction"]
    D --> E["④ Optimization"]
    
    B -.- B1["Parse the regex string into a token sequence<br/>e.g., ^\\d{17}[0-9X] → [^][\\d][{][17]...]"]
    C -.- C1["Build the token sequence into an AST<br/>e.g., \\d{17} → QuantifierNode(DigitNode, 17, 17)]"]
    D -.- D1["Convert AST to NFA<br/>Create a chain of Node objects, each representing a state"]
    E -.- E1["NFA → DFA optimization<br/>Merge equivalent states, eliminate dead paths"]
    
    style A fill:#e1f5ff
    style E fill:#c8e6c9

The entire process takes 0.1 to 1 millisecond (depending on regex complexity).

1.2 Quantitative Analysis of Compilation Overhead

Regex Complexity Compilation Time Matching Time Compilation Percentage
Simple (^\d{6}$) ~0.05ms ~0.001ms 98%
Medium (^1[3-9]\d{9}$) ~0.15ms ~0.002ms 99%
Complex (`^(?:13[0-9] 14[579] ...)\d{8}$`) ~0.5ms

Key Findings:

1.3 Three Common Performance Pitfalls

Pitfall 1: Compiling Inside a Method

// ❌ Bad practice: compiles on every call
public boolean isValid(String value) {
    Pattern pattern = Pattern.compile("^\\d{17}[0-9X]$");  // Compiles every time!
    return pattern.matcher(value).matches();
}

// 1 million calls = 1 million compilations = wasting 50~500 seconds

Pitfall 2: Using String.matches()

// ❌ Bad practice: String.matches() compiles internally on every call
public boolean isValid(String value) {
    return value.matches("^\\d{17}[0-9X]$");  // Delegates to Pattern.compile() under the hood
}

// String.matches() source code:
// public boolean matches(String regex) {
//     return Pattern.matches(regex, this);  // ← compiles each time
// }

Pitfall 3: Compiling Inside a Loop

// ❌ Bad practice: compiling in a loop
for (String idCard : idCardList) {
    Pattern pattern = Pattern.compile("^\\d{17}[0-9X]$");  // Loop N times = compile N times
    if (!pattern.matcher(idCard).matches()) {
        errors.add(idCard);
    }
}

// 1000 ID cards = 1000 compilations = wasting 50~500 milliseconds

The essence of these three pitfalls is the same—turning "compile once" into "compile N times". The following diagram sums up their common root:

flowchart LR
    A["Call isValid()"] --> B{"Where is Pattern compiled?"}
    B -->|"Inside method body"| C["Pitfall 1<br/>Compiles every call"]
    B -->|"String.matches()"| D["Pitfall 2<br/>Implicit internal compilation"]
    B -->|"Inside loop body"| E["Pitfall 3<br/>Compiles every iteration"]
    C --> F["Compilation count = call count"]
    D --> F
    E --> F
    F --> G["99% time spent compiling<br/>Performance drop >1000x"]

    style G fill:#ffcdd2

2. Core Principle of Precompilation: static final

2.1 Why static

The meaning of the static keyword: belongs to the class, not to instances.

public class MyValidator {
    // ✅ static: compiled once at class load, shared by all instances
    private static final Pattern PATTERN = Pattern.compile("^\\d{17}[0-9X]$");
    
    public boolean isValid(String value) {
        return PATTERN.matcher(value).matches();
    }
}

// Even if you create 1000 MyValidator instances, PATTERN is compiled only once

Timing of static:

2.2 Why final

The meaning of the final keyword: the reference is immutable.

// ✅ final: reference cannot be unintentionally replaced
private static final Pattern PATTERN = Pattern.compile("^\\d{17}[0-9X]$");

// ❌ non-final: may be accidentally modified
private static Pattern PATTERN = Pattern.compile("^\\d{17}[0-9X]$");
PATTERN = Pattern.compile("^\\d{18}$");  // Compiles fine, but logic error!

Purpose of final:

2.3 Why Pattern Is Naturally Thread-Safe

Pattern class design: Immutable Object.

// Key characteristics of the Pattern class:
public final class Pattern implements java.io.Serializable {
    private final String pattern;  // final, immutable
    private final int flags;       // final, immutable
    
    // Once created, the internal state of a Pattern object never changes
    // Multiple threads can access the same Pattern instance without synchronization
}

Guarantees of thread safety:

// ✅ Thread-safe
private static final Pattern PATTERN = Pattern.compile("^\\d{17}[0-9X]$");

// Thread A
boolean resultA = PATTERN.matcher(valueA).matches();

// Thread B (simultaneously)
boolean resultB = PATTERN.matcher(valueB).matches();

// Two threads share the same Pattern, but each creates its own independent Matcher—safe!

Combining static, final, and thread safety gives the complete logical chain of the precompilation principle:

flowchart TD
    A["static<br/>Belongs to class, compiled once at class load"] --> D["static final Pattern"]
    B["final<br/>Reference immutable, prevents accidental replacement"] --> D
    C["Pattern immutable<br/>Naturally thread-safe"] --> D
    D --> E["Globally unique instance"]
    E --> F["Read-only sharing across all threads"]
    F --> G["Zero compilation overhead + lock-free concurrency"]

    style D fill:#fff3e0
    style G fill:#c8e6c9

3. ValidX's Pattern Management Practices

3.1 Scenario for a Single Pattern

// ✅ Actual code from ValidX's ChinesePhoneValidator
public class ChinesePhoneValidator implements ConstraintValidator<ChinesePhone, String> {

    // Single Pattern: precompiled as static final
    private static final Pattern MOBILE_PATTERN = Pattern.compile("^1[3-9]\\d{9}$");

    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        if (value == null || value.isEmpty()) return true;
        String cleanValue = value.replaceAll("[\\s-]", "");
        return cleanValue.length() == 11 && MOBILE_PATTERN.matcher(cleanValue).matches();
    }
}

3.2 Scenario for Multiple Patterns

// ✅ Actual code from ValidX's ExpressNumberValidator
public class ExpressNumberValidator implements ConstraintValidator<ExpressNumber, Object> {

    // 10 Patterns: all precompiled as static final
    private static final Pattern SF_EXPRESS_PATTERN = 
        Pattern.compile("^\\d{12}$");
    private static final Pattern YTO_EXPRESS_PATTERN = 
        Pattern.compile("^(YT\\d{11,13}|\\d{10,13})$", Pattern.CASE_INSENSITIVE);
    private static final Pattern STO_EXPRESS_PATTERN = 
        Pattern.compile("^\\d{12}$");
    private static final Pattern ZTO_EXPRESS_PATTERN = 
        Pattern.compile("^[A-Za-z0-9]{12,}$");
    private static final Pattern YUNDA_EXPRESS_PATTERN = 
        Pattern.compile("^\\d{13}$");
    private static final Pattern EMS_PATTERN = 
        Pattern.compile("^(E\\d{9}CN|[A-Z]{2}\\d{9}CN)$", Pattern.CASE_INSENSITIVE);
    private static final Pattern JD_LOGISTICS_PATTERN = 
        Pattern.compile("^JD\\d{13,15}$", Pattern.CASE_INSENSITIVE);
    private static final Pattern DEPPON_PATTERN = 
        Pattern.compile("^\\d{8,9}$");
    private static final Pattern TTKD_EXPRESS_PATTERN = 
        Pattern.compile("^\\d{12,14}$");
    private static final Pattern BEST_EXPRESS_PATTERN = 
        Pattern.compile("^[A-Za-z0-9]{10,12}$");

    // Select the corresponding Pattern based on the express company
    public boolean isValid(String value, ExpressType type) {
        switch (type) {
            case SF: return SF_EXPRESS_PATTERN.matcher(value).matches();
            case YTO: return YTO_EXPRESS_PATTERN.matcher(value).matches();
            // ...
        }
    }
}

All 10 Patterns are compiled once at class load time, with zero compilation overhead at runtime.

3.3 Pattern Naming Conventions

ValidX follows a unified naming convention:

// ✅ Naming convention: all uppercase + _PATTERN suffix
private static final Pattern MOBILE_PATTERN = ...;
private static final Pattern EMAIL_PATTERN = ...;
private static final Pattern FIFTEEN_ID_PATTERN = ...;
private static final Pattern EIGHTEEN_ID_PATTERN = ...;

// If there are multiple similar Patterns, use a prefix to distinguish
private static final Pattern SF_EXPRESS_PATTERN = ...;
private static final Pattern YTO_EXPRESS_PATTERN = ...;

Naming principles:


4. The Correct Way to Reuse Pattern

4.1 Creation and Reuse of Matcher

// ✅ Correct practice: reuse Pattern, create Matcher each time
private static final Pattern PATTERN = Pattern.compile("^\\d{17}[0-9X]$");

public boolean isValid(String value) {
    // Pattern reuse—zero compilation overhead
    // Matcher creation each time—pure memory operation, nanosecond-level
    return PATTERN.matcher(value).matches();
}

Why create a Matcher each time?

4.2 Why Matcher Cannot Be Shared

// ❌ Wrong example: attempting to share a Matcher
private static final Pattern PATTERN = Pattern.compile("^\\d{17}[0-9X]$");
private static final Matcher SHARED_MATCHER = PATTERN.matcher("");  // initial empty value

public boolean isValid(String value) {
    // ❌ Data race in multi-threaded environment!
    SHARED_MATCHER.reset(value);
    return SHARED_MATCHER.matches();
}

// Thread A: reset("11010519491231002X")
// Thread B: reset("11010519491231003Y") (simultaneously)
// Result: Matcher state is corrupted, returns wrong results

Matcher design:

4.3 High-Performance Matching Template

// ✅ Recommended template: static final Pattern + create Matcher each time
public class HighPerformanceValidator 
    implements ConstraintValidator<MyAnnotation, String> {

    // ① Precompile Pattern (once at class load)
    private static final Pattern PATTERN = Pattern.compile("^[A-Z]{2}\\d{6}$");

    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        if (value == null || value.isEmpty()) return true;
        
        // ② Fail fast: length check (O(1))
        if (value.length() != 8) return false;
        
        // ③ Create Matcher and match (pure memory operation)
        return PATTERN.matcher(value).matches();
    }
}

The lifecycle difference between Pattern and Matcher is the core of the reuse strategy. A sequence diagram shows it more clearly:

sequenceDiagram
    participant T1 as Thread A
    participant T2 as Thread B
    participant P as static final Pattern
    participant M1 as Matcher A
    participant M2 as Matcher B

    Note over P: Compiled once at class load, read-only afterwards
    T1->>P: matcher(valueA)
    P-->>T1: Returns new Matcher A
    T2->>P: matcher(valueB)
    P-->>T2: Returns new Matcher B
    T1->>M1: matches()
    T2->>M2: matches()
    Note over M1,M2: Each holds its own independent match state, no interference

5. Precompilation Strategy for Dynamic Regular Expressions

5.1 Regular Expressions in Annotation Parameters

Some validators need to accept regex from annotation parameters:

// Custom annotation
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
public @interface CustomPattern {
    String pattern();  // regex as annotation parameter
    String message() default "Format error";
}

// Usage
public class UserDTO {
    @CustomPattern(pattern = "^[A-Z]{2}\\d{6}$", message = "Code format error")
    private String code;
}

5.2 Precompiling in initialize()

// ✅ Correct approach: precompile in initialize()
public class CustomPatternValidator 
    implements ConstraintValidator<CustomPattern, String> {

    private Pattern compiledPattern;  // instance field, not static

    @Override
    public void initialize(CustomPattern annotation) {
        // initialize() is called only once—compile here
        this.compiledPattern = Pattern.compile(annotation.pattern());
    }

    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        if (value == null || value.isEmpty()) return true;
        // Reuse the compiled Pattern
        return compiledPattern.matcher(value).matches();
    }
}

Why compile in initialize()?

5.3 Caching Compiled Results

If the same regex is used by multiple annotations, a global cache can help:

// ✅ Advanced approach: global Pattern cache
public class PatternCache {
    private static final Map<String, Pattern> CACHE = new ConcurrentHashMap<>();

    public static Pattern getOrCompile(String regex) {
        return CACHE.computeIfAbsent(regex, Pattern::compile);
    }
}

// Usage
public class CustomPatternValidator 
    implements ConstraintValidator<CustomPattern, String> {

    private Pattern compiledPattern;

    @Override
    public void initialize(CustomPattern annotation) {
        // Get from cache to avoid repeated compilation
        this.compiledPattern = PatternCache.getOrCompile(annotation.pattern());
    }

    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        return compiledPattern.matcher(value).matches();
    }
}

Advantages of caching:


Three handling strategies for dynamic regex, selected by the regex source:

flowchart TD
    A["Regex source"] --> B{"Known at compile time?"}
    B -->|"Yes"| C["static final Pattern<br/>Compiled at class load"]
    B -->|"No, from annotation parameter"| D{"Same regex used multiple times?"}
    D -->|"No"| E["Precompile in initialize()<br/>Store in instance field"]
    D -->|"Yes"| F["ConcurrentHashMap cache<br/>computeIfAbsent for reuse"]
    C --> G["Zero compilation overhead at runtime"]
    E --> G
    F --> G

    style G fill:#c8e6c9

6. Common Errors and Fixes

6.1 Error 1: Compiling Inside a Method

// ❌ Error code
public boolean isValid(String value) {
    Pattern pattern = Pattern.compile("^\\d{17}[0-9X]$");  // compiles every call
    return pattern.matcher(value).matches();
}

// ✅ Fix: extract to static final
private static final Pattern PATTERN = Pattern.compile("^\\d{17}[0-9X]$");

public boolean isValid(String value) {
    return PATTERN.matcher(value).matches();
}

6.2 Error 2: Using String.matches()

// ❌ Error code
public boolean isValid(String value) {
    return value.matches("^\\d{17}[0-9X]$");  // compiles internally every time
}

// ✅ Fix: use precompiled Pattern
private static final Pattern PATTERN = Pattern.compile("^\\d{17}[0-9X]$");

public boolean isValid(String value) {
    return PATTERN.matcher(value).matches();
}

6.3 Error 3: Caching Mutable Objects

// ❌ Error code: caching a mutable Matcher
private static final Matcher SHARED_MATCHER = 
    Pattern.compile("^\\d{17}[0-9X]$").matcher("");

public boolean isValid(String value) {
    SHARED_MATCHER.reset(value);  // modifies shared state—not thread-safe!
    return SHARED_MATCHER.matches();
}

// ✅ Fix: cache Pattern, create Matcher each time
private static final Pattern PATTERN = Pattern.compile("^\\d{17}[0-9X]$");

public boolean isValid(String value) {
    return PATTERN.matcher(value).matches();  // creates new Matcher each time
}

6.4 Error 4: Ignoring Pattern Flags

// ❌ Error code: ignoring case-insensitive flag
private static final Pattern PATTERN = Pattern.compile("^[a-z]+$");
// Cannot match "ABC"

// ✅ Fix: add flags
private static final Pattern PATTERN = 
    Pattern.compile("^[a-z]+$", Pattern.CASE_INSENSITIVE);
// Can match "ABC", "abc", "AbC"

// Or use inline flags in the regex
private static final Pattern PATTERN = 
    Pattern.compile("(?i)^[a-z]+$");  // (?i) means ignore case

7. Performance Benchmark: Precompiled vs. On-the-fly Compilation

7.1 Test Method

@BenchmarkMode(Mode.Throughput)
@OutputTimeUnit(TimeUnit.SECONDS)
@State(Scope.Thread)
@Fork(1)
@Warmup(iterations = 3, time = 1)
@Measurement(iterations = 5, time = 1)
public class PatternBenchmark {

    private static final String TEST_VALUE = "11010519491231002X";
    
    // Strategy A: precompiled Pattern
    private static final Pattern PRECOMPILED = 
        Pattern.compile("^[0-9]{17}[0-9X]$");
    
    @Benchmark
    public boolean testPrecompiled() {
        return PRECOMPILED.matcher(TEST_VALUE).matches();
    }
    
    // Strategy B: compile every time
    @Benchmark
    public boolean testCompileEveryTime() {
        Pattern pattern = Pattern.compile("^[0-9]{17}[0-9X]$");
        return pattern.matcher(TEST_VALUE).matches();
    }
    
    // Strategy C: String.matches()
    @Benchmark
    public boolean testStringMatches() {
        return TEST_VALUE.matches("^[0-9]{17}[0-9X]$");
    }
}

7.2 Test Results

Strategy Throughput (ops/s) Single-call Time Relative Performance
Precompiled Pattern ~3,500,000 ~0.29μs 1x (baseline)
Compile every time ~2,000 ~500μs 1750x slower
String.matches() ~2,000 ~500μs 1750x slower

7.3 Analysis of Performance Gap

flowchart BT
    subgraph Precompiled
        A1[Compilation overhead: 0<br/>Already done at class load] --> A2[Matching overhead: ~0.29μs<br/>Pure memory operation]
        A2 --> A3[Total overhead: ~0.29μs]
    end
    
    subgraph Compile every time
        B1[Compilation overhead: ~500μs<br/>lexical + syntax + state machine + optimization] --> B2[Matching overhead: ~0.29μs]
        B2 --> B3[Total overhead: ~500.29μs]
    end
    
    A3 -.->|Performance gap: 1725x| C[Comparison in million-QPS scenario]
    B3 -.-> C
    
    C --> D[Precompiled: 0.29 seconds CPU time]
    C --> E[Compile every time: 500 seconds CPU time<br/>Needs 500 CPU cores!]
    
    style A3 fill:#c8e6c9
    style B3 fill:#ffcdd2
    style D fill:#c8e6c9
    style E fill:#ffcdd2

8. Production Checklist for Pattern Management

Coding Standards

Thread Safety

Performance Optimization

Code Review


9. Summary

Review of Core Principles

Principle Description Example
Compile-time caching Compile at class load, zero runtime overhead static final Pattern
Object reuse Reuse Pattern, create new Matcher PATTERN.matcher(value)
Thread safety Pattern immutable, Matcher thread-local Safe sharing across threads
Fail fast Cheap checks before regex matching length check → regex match

Performance Optimization Pyramid

flowchart BT
    subgraph Base["🏗️ Most basic and important"]
        A1[Avoid compilation overhead]
        A2[Avoid repeated compilation]
    end
    
    subgraph Core["⚙️ Three core strategies"]
        B1[Precompile<br/>static final]
        B2[Reuse<br/>Pattern reuse, Matcher new]
        B3[Thread safety<br/>Pattern immutable, thread-local]
    end
    
    subgraph Goal["🎯 Optimization goal"]
        C[Million-level QPS]
    end
    
    Base --> Core
    Core --> Goal
    
    style Goal fill:#fff3e0
    style Core fill:#e3f2fd
    style Base fill:#e8f5e9

Key Insight

The compilation cost of a regex is more than 1000 times the matching cost.

The essence of precompilation: reduce "every time" overhead to "once" (at class load).

Following these principles can improve the performance of your regex validators by three orders of magnitude in high-concurrency scenarios.


Project Links

Comments

Top 1 from juejin.cn, machine-translated. The original thread is authoritative.

余小二126

https://mp.weixin.qq.com/s/igFXsEGjY02akEuv8V11hQ