第20章 - 性能优化与最佳实践

20.1 性能问题的来源与度量

20.1.1 五类开销

JTS 应用的性能问题,几乎都落在下面五类里。定位问题时先分类,比盲目“优化代码”有效得多:

类别 典型表现 常见原因 优化方向
IO 开销 CPU 不高但慢 WKT 文本解析、数据库往返、文件反复读 换 WKB/二进制、批量读写
算法复杂度 数据量翻倍耗时翻四倍 逐对求交、逐对 union、无索引的包含判断 建索引、批量算法
对象分配 GC 频繁、CPU 抖动 循环里 getCoordinates()、反复建 GeometryFactory 复用、少拷贝
无效输入 偶发极慢或抛异常 自相交、近重合边触发大量 noding 入口校验/修复
并发争用 核多但吞吐不涨 共享可变几何、锁粒度大 线程安全边界划分

优化第一定律:先测量,再动手。 没有 profile 的“我觉得这里慢”经常是错的——JTS 的热点往往在 Coordinate 数组分配、Envelope 计算、noding 的相交检测上,而不是调用方以为的“几何对象创建”。

20.1.2 度量流程

   确定基线(固定数据集、固定 JVM、固定 JTS 版本)
        │
        ▼
   端到端计时(找出慢的业务步骤)        ← 先粗后细
        │
        ▼
   采样剖析(JFR / async-profiler)      ← 看热点方法栈
        │
        ▼
   微基准(JMH)验证单个优化             ← 排除噪声
        │
        ▼
   回归对比(吞吐 + P99 + 内存 + GC)
        │
        ▼
   灰度上线,监控真实指标

三个常见误区:

  1. 用 System.currentTimeMillis() 在业务代码里打点,同一毫秒内多次调用测不出差异;
  2. 不预热就测,JIT 还没编译完,结论反着来;
  3. 只测平均值,不看 P99 与 GC——线上事故往往发生在长尾。

20.1.3 复杂度直觉

操作 典型复杂度 备注
Geometry.getArea() / getLength() O(n) 每次重新遍历坐标
Geometry.getEnvelopeInternal() O(1)(缓存) 首次计算 O(n),之后返回缓存引用
Envelope.contains(Coordinate) O(1) 便宜,适合预筛
Geometry.intersects(Geometry) 最坏 O(n·m) 无索引时逐段比较
PreparedGeometry 谓词 约 O(log n + k) 预建线段索引与点定位
STRtree 查询 O(log n + k) 需先 build
OverlayNG(面-面) 约 O((n+m) log(n+m)) 起步 noding 是主要成本
Geometry.getCoordinates() O(n) + 分配 高频调用要缓存

“O(n log n) 起步”的意思是:叠加分析除了 noding,还要构环、标注、输出,常数项很大。减少顶点数往往比换算法更有效。

20.2 PreparedGeometry:重复谓词的加速器

20.2.1 原理

同一个面与成千上万个点/线做 intersects / contains 判断时,普通 API 每次都要重新遍历面的所有线段。PreparedGeometry 把面“预处理”成带索引的结构:

  • PreparedPolygon 懒加载两类索引:线段集合的 FastSegmentSetIntersectionFinder(用于 intersects)与 IndexedPointInAreaLocator(用于点定位);
  • 矩形特判:若几何是矩形,直接走 RectangleIntersects / RectangleContains 的快速路径;
  • 包络预筛:先用包络排除明显不相交的输入。
   普通 intersects:            Prepared intersects:
   每次遍历所有线段            首次构建线段索引(懒加载)
   O(n) per call               之后每次约 O(log n + k)
   ────────────────            ────────────────────
   1 万次调用 → 1 万次遍历      1 万次调用 → 1 次构建 + 1 万次查询

20.2.2 API 与方法

PreparedGeometryFactory.prepare(Geometry) 是入口,返回 PreparedGeometry 接口。接口方法与语义如下:

方法 等价 Geometry 方法 说明
intersects(g) intersects 支持 GeometryCollection 输入
contains(g) contains 完整包含语义
containsProperly(g) 无直接等价 测试几何完全在内部,不碰边界
covers(g) covers 覆盖(允许边界接触)
coveredBy(g) coveredBy 反向覆盖
crosses(g) crosses 跨立
disjoint(g) disjoint 分离
overlaps(g) overlaps 部分重叠
touches(g) touches 接触
within(g) within 被包含
getGeometry() — 取回原几何

containsProperly 是 PreparedGeometry 独有语义:测试几何的每个点都在目标内部,完全不碰边界。它比 contains 便宜,非常适合“大面里筛小面”的场景——若小面 properly contained,则它与大面的交集就是它自己,可以直接跳过昂贵的求交。

20.2.3 用法与效果

import java.util.ArrayList;
import java.util.List;
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.geom.Point;
import org.locationtech.jts.geom.Polygon;
import org.locationtech.jts.geom.prep.PreparedGeometry;
import org.locationtech.jts.geom.prep.PreparedGeometryFactory;
import org.locationtech.jts.io.WKTReader;

public class PreparedDemo {
    public static void main(String[] args) throws Exception {
        WKTReader r = new WKTReader();
        Polygon city = (Polygon) r.read(
            "POLYGON ((0 0, 100 0, 100 100, 0 100, 0 0))");

        GeometryFactory gf = new GeometryFactory();
        List<Point> points = new ArrayList<Point>();
        for (int i = 0; i < 100000; i++) {
            double x = (i * 37 % 200) - 50;   // 固定伪随机,结果可复现
            double y = (i * 91 % 200) - 50;
            points.add(gf.createPoint(new Coordinate(x, y)));
        }

        // 1) 普通方式
        long t0 = System.nanoTime();
        int count1 = 0;
        for (Point p : points) {
            if (city.intersects(p)) count1++;
        }
        long t1 = System.nanoTime();

        // 2) Prepared 方式
        PreparedGeometry prep = PreparedGeometryFactory.prepare(city);
        long t2 = System.nanoTime();
        int count2 = 0;
        for (Point p : points) {
            if (prep.intersects(p)) count2++;
        }
        long t3 = System.nanoTime();

        System.out.println("结果一致 = " + (count1 == count2));
        System.out.println("普通耗时 = " + (t1 - t0) / 1_000_000 + " ms");
        System.out.println("Prepared 耗时 = " + (t3 - t2) / 1_000_000 + " ms");
        // 典型量级:普通 100~300 ms,Prepared 5~20 ms(取决于多边形顶点数)
    }
}

上面的计时只用于示意数量级,正式对比请用 20.8 节的 JMH 写法。要点是:PreparedGeometry 的收益随“调用次数 / 顶点数”增长;只调用一两次时,构建索引反而是负担。

20.2.4 复用、线程安全与生命周期

Javadoc 明确:PreparedGeometry 的实现是线程安全的,可以在多线程间共享(这正是 prepared 状态的价值所在)。但要注意:

  • PreparedGeometryFactory 本身无状态,可复用;
  • 被准备的 Geometry 必须不变:一旦修改了原几何(setSRID、normalize()、apply 修改坐标),索引就与几何不一致;
  • PreparedGeometry 内部索引是懒加载的,第一次调用才构建,因此“构建成本”发生在首次查询而非 prepare 时;
  • 1.20 修复了 PreparedGeometry 对 EMPTY 元素的处理(版本历史 #904),老版本需注意空元素。

20.3 空间索引:STRtree、HPRtree 与查询优化

20.3.1 STRtree 的半静态语义

STRtree 是按 Sort-Tile-Recursive 算法打包的 R 树,特点是“插入后构建,构建后只读”:

  • 插入:insert(Envelope, Object);插入不是线程安全的,多线程插入必须外部同步;
  • 构建:第一次查询时自动构建(也可显式 build());构建后不能再插入,源码里是 Assert.isTrue(!built, ...);
  • 查询:query(Envelope)、query(Envelope, ItemVisitor) 是线程安全的;
  • 删除:remove(Envelope, Object) 允许,但会破坏打包结构,频繁删除应重建。
   典型生命周期:
   new STRtree() → insert × N → 第一次 query 触发 build → 只读查询
                                        │
                                        └─ 再 insert → Assert 失败(禁用)

20.3.2 批量空间连接

最常见的用法不是“树查询”,而是空间连接:给 A 集合建索引,遍历 B 集合查询候选,再精确判断。

import java.util.ArrayList;
import java.util.List;
import org.locationtech.jts.geom.Envelope;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.index.strtree.STRtree;
import org.locationtech.jts.io.WKTReader;

public class StrTreeJoinDemo {
    public static void main(String[] args) throws Exception {
        WKTReader r = new WKTReader();
        Geometry parcels = r.read(
            "MULTIPOLYGON (((0 0, 10 0, 10 10, 0 10, 0 0)),"
          + " ((10 0, 20 0, 20 10, 10 10, 10 0)))");
        Geometry[] quakes = new Geometry[] {
            r.read("POINT (5 5)"),
            r.read("POINT (15 5)"),
            r.read("POINT (30 30)")
        };

        // 1) 给面建索引(按部件逐个插入,便于交并时定位)
        STRtree index = new STRtree();
        for (int i = 0; i < parcels.getNumGeometries(); i++) {
            Geometry poly = parcels.getGeometryN(i);
            index.insert(poly.getEnvelopeInternal(), poly);
        }
        index.build();

        // 2) 遍历点,先包络查询,再精确判断
        for (Geometry q : quakes) {
            Envelope env = q.getEnvelopeInternal();
            List candidates = index.query(env);
            boolean hit = false;
            for (Object o : candidates) {
                Geometry poly = (Geometry) o;
                if (poly.intersects(q)) { hit = true; break; }
            }
            System.out.println(q + " 落在面内 = " + hit);
        }
    }
}

要点:

  • 索引项存“部件”而不是整个 MultiPolygon,查询结果更精确;
  • getEnvelopeInternal() 返回内部缓存引用(不拷贝),查询时直接传,避免每次 getEnvelope() 复制;
  • 精确判断仍要 intersects,索引只负责减少候选。

20.3.3 HPRtree:静态批量索引

HPRtree(Hilbert-Packed R-tree)是 1.16 引入的静态索引,用 Hilbert 曲线排序后按块打包,内存更省、构建更快。Javadoc 的结论是:HPRtree 比 STRtree 略快,且对象分配更少,但不保证对 JTS 操作带来显著提升。

使用方式与 STRtree 类似,但必须显式 build()(或首次查询时自动构建):

import java.util.List;
import org.locationtech.jts.geom.Envelope;
import org.locationtech.jts.index.hprtree.HPRtree;

public class HprTreeDemo {
    public static void main(String[] args) {
        HPRtree tree = new HPRtree();
        for (int i = 0; i < 1000; i++) {
            double x = (i * 31 % 1000);
            double y = (i * 17 % 1000);
            tree.insert(new Envelope(x, x + 1, y, y + 1), "item-" + i);
        }
        tree.build();
        System.out.println("size = " + tree.size());

        List hits = tree.query(new Envelope(0, 100, 0, 100));
        System.out.println("命中 = " + hits.size());
    }
}

20.3.4 索引选择

索引 构建方式 动态插入 查询线程安全 适用场景
STRtree 首次查询自动构建 构建前可插 是 通用、半静态数据
HPRtree 显式 build / 首次查询 构建前可插 是 静态大数据、批量查询
Quadtree 增量插入 支持(1.19 起线程安全) 需查版本 动态更新、均匀分布
IntervalRTree 一维区间 构建后只读 — 线段/区间索引
KdTree 增量插入 支持 — 最近邻、点集

经验法则:数据量 > 数千且查询多于一次,就值得建索引;数据量小或只查一次,线性扫描更快。

20.4 批量 overlay 与 union 策略

20.4.1 逐对 union 的平方级陷阱

把 N 个面合并成一张图,最直觉的写法是循环:

Geometry result = geoms.get(0);
for (int i = 1; i < geoms.size(); i++) {
    result = result.union(geoms.get(i));   // 每次都与“越来越大”的结果求并
}

这段代码的问题是:第 i 次 union 的输入规模是“已合并结果 + 新几何”,越往后越慢,整体接近 O(N²),而且中间结果反复拷贝。N=1000 时可能比正确写法慢几十倍。

20.4.2 正确姿势

场景 推荐 API 说明
任意几何集合的一元并 UnaryUnionOp.union(Collection) 内部按维度分组、用索引加速
多边形集合的级联合并 CascadedPolygonUnion.union(Collection) 自底向上两两合并,避免超大中间结果
需要稳健性 OverlayNGRobust.union(Collection) 一元并 + OverlayNG 回退
合法覆盖率合并 CoverageUnion.union(Geometry[]) 利用公共边精确匹配,最快
两个几何求并 Geometry.union 或 OverlayNG 只有两个时无需批量算法
import java.util.ArrayList;
import java.util.List;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.io.WKTReader;
import org.locationtech.jts.operation.overlayng.OverlayNGRobust;
import org.locationtech.jts.operation.union.CascadedPolygonUnion;
import org.locationtech.jts.operation.union.UnaryUnionOp;

public class BatchUnionDemo {
    public static void main(String[] args) throws Exception {
        WKTReader r = new WKTReader();
        List<Geometry> polys = new ArrayList<Geometry>();
        for (int i = 0; i < 200; i++) {
            double x = (i % 20) * 10.0;
            double y = (i / 20) * 10.0;
            polys.add(r.read("POLYGON ((" + x + " " + y + ", "
                + (x + 10) + " " + y + ", " + (x + 10) + " " + (y + 10) + ", "
                + x + " " + (y + 10) + ", " + x + " " + y + "))"));
        }

        // 1) 一元并(通用)
        Geometry u1 = UnaryUnionOp.union(polys);
        // 2) 级联多边形并(纯面集合更快)
        Geometry u2 = CascadedPolygonUnion.union(polys);
        // 3) 稳健一元并
        Geometry u3 = OverlayNGRobust.union(polys);

        System.out.println(u1.getArea());
        System.out.println(u2.getArea());
        System.out.println(u3.getArea());
        // 相邻面共享边,三者面积一致;差别在耗时
    }
}

20.4.3 先用索引减少参与运算的几何

如果只是“把互相接触的一堆面合并成若干块”,不需要对全部要素做一元并。更好的策略是:

  1. 用 STRtree 找每个面的相邻面(包络相交);
  2. 用并查集把相邻面分组;
  3. 每组内部用 CascadedPolygonUnion 合并。

这样把一次大 union 拆成若干小 union,既降低了 noding 规模,也便于并行。

   N 个面
     │
     ▼
   STRtree 包络查询 → 邻接图
     │
     ▼
   并查集分组
     │
     ├── 连通块 1 → union → 结果 1
     ├── 连通块 2 → union → 结果 2
     └── ...
     │
     ▼
   MultiPolygon 汇总(块之间不相交,无需再 union)

20.5 前置处理:校验、修复与简化

20.5.1 无效输入的隐性成本

无效几何对性能的伤害不是线性的:一次自相交可能让 noding 进入大量重复求交,甚至触发 OverlayNGRobust 的多级回退(浮点 → 5 轮吸附 → snap-rounding),单次耗时可能是正常情况的百倍。更糟的是,它可能“有时成功有时失败”,让性能问题显得随机。

入口处花 1 毫秒做校验,胜过运行时花 1 秒重试。 校验与修复见第 17 章,这里只强调顺序:

   解析 → 坐标有限性检查 → IsValidOp → GeometryFixer(按需)
        → 精度约简(按需,统一网格)→ 入库/进入计算池

20.5.2 先简化后运算

顶点数是叠加/缓冲成本的主因。如果业务允许较小误差,先简化能带来数量级收益:

  • DouglasPeuckerSimplifier:距离容差,快,但可能产生自交/丢拓扑;
  • TopologyPreservingSimplifier:保持拓扑,稍慢;
  • CoverageSimplifier:整组覆盖率保持,适合面群(见第 17 章);
  • GeometryPrecisionReducer:降精度、去重复点,顺便减少顶点。
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.PrecisionModel;
import org.locationtech.jts.io.WKTReader;
import org.locationtech.jts.precision.GeometryPrecisionReducer;
import org.locationtech.jts.simplify.DouglasPeuckerSimplifier;
import org.locationtech.jts.simplify.TopologyPreservingSimplifier;

public class PreprocessDemo {
    public static void main(String[] args) throws Exception {
        WKTReader r = new WKTReader();
        Geometry g = r.read(
            "POLYGON ((0 0, 5 0.001, 10 0, 10 5, 9.999 10, 5 10.001,"
          + " 0 10, 0.001 5, 0 0))");

        System.out.println("原始顶点 = " + g.getNumPoints());   // 9

        Geometry dp = DouglasPeuckerSimplifier.simplify(g, 0.01);
        System.out.println("DP 简化后 = " + dp.getNumPoints());

        Geometry tp = TopologyPreservingSimplifier.simplify(g, 0.01);
        System.out.println("拓扑保持后 = " + tp.getNumPoints());

        Geometry pr = GeometryPrecisionReducer.reduce(g, new PrecisionModel(-0.5));
        System.out.println("精度约简后 = " + pr.getNumPoints());
    }
}

20.5.3 前置处理的收益边界

不是所有场景都适合前置简化:

  • 如果下游需要精确面积,简化会引入系统偏差;
  • 如果数据要参与覆盖率校验,逐要素简化会破坏公共边匹配,必须用 CoverageSimplifier;
  • 如果数据只是过滤/筛选,可以先不简化,等真正需要几何运算时再处理。

优化顺序建议:先修正确性,再减数据量,最后调算法参数。反过来做,往往白忙。

20.6 对象分配与内存

20.6.1 JTS 的对象模型

一次几何运算会创建大量短命对象:Coordinate、CoordinateSequence、Envelope、LineSegment、Edge、SegmentString……在 100 万顶点的数据上,这些对象的分配与回收往往比算法本身更耗时。

对象 典型构成 估算内存(64 位 JVM,含对象头) 备注
Coordinate 3 个 double(x, y, z) 约 40 字节 默认带 z,即使不用
CoordinateXY 2 个 double 约 32 字节 2D 数据可省 20%
CoordinateArraySequence Coordinate 引用数组 8 字节/点 + 对象 默认实现,灵活但对象多
PackedCoordinateSequence(double 2D) double 数组 16 字节/点 紧凑,无逐个对象
PackedCoordinateSequence(float 2D) float 数组 8 字节/点 省内存,损精度
Envelope 4 个 double 约 48 字节 高频创建要复用

默认工厂使用的是 CoordinateArraySequenceFactory(源码 GeometryFactory.getDefaultCoordinateSequenceFactory() 返回 CoordinateArraySequenceFactory.instance())。如果你看到“JTS 默认用 Packed 序列”的说法,那是不准确的:要紧凑存储必须显式指定 PackedCoordinateSequenceFactory。

20.6.2 高频反例:循环里调用 getCoordinates()

// 反例:每次 getCoordinates() 都新建数组与坐标对象
double sumX = 0;
for (int k = 0; k < lines.size(); k++) {
    Geometry g = lines.get(k);
    Coordinate[] cs = g.getCoordinates();     // 每次分配
    for (int i = 0; i < cs.length; i++) {
        sumX += cs[i].x;
    }
}
// 正例:直接遍历 CoordinateSequence,避免中间数组
import org.locationtech.jts.geom.CoordinateSequence;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.LineString;

double sumX2 = 0;
for (int k = 0; k < lines.size(); k++) {
    Geometry g = lines.get(k);
    if (!(g instanceof LineString)) continue;
    CoordinateSequence seq = ((LineString) g).getCoordinateSequence();
    for (int i = 0; i < seq.size(); i++) {
        sumX2 += seq.getX(i);                 // 无额外分配
    }
}

注意 seq.getX(i) 是否高效取决于序列实现:CoordinateArraySequence 每次会读 Coordinate 对象,Packed 序列直接读数组。无论哪种,都比先拷贝一整个数组便宜。

20.6.3 复用 Envelope 与工厂

  • GeometryFactory:Javadoc 明确“线程安全”,应按 (SRID, PrecisionModel, CoordinateSequenceFactory) 组合缓存复用,不要在每个要素里 new GeometryFactory();
  • Envelope:查询循环里复用同一个 Envelope,用 init / expandToInclude 重置,减少短命对象;
  • WKTReader / WKBReader:解析器本身可复用(无状态或轻状态),但注意 WKTReader 的工厂配置在构造时固定。
import org.locationtech.jts.geom.Envelope;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.index.strtree.STRtree;

public class EnvelopeReuseDemo {
    public static void main(String[] args) {
        STRtree tree = new STRtree();
        // ... 假设已插入大量要素

        // 反例:每次查询 new Envelope
        // for (Geometry q : queries) {
        //     List hits = tree.query(new Envelope(q.getEnvelopeInternal()));
        // }

        // 正例:复用 Envelope
        Envelope env = new Envelope();
        Geometry[] queries = new Geometry[0];
        for (int i = 0; i < queries.length; i++) {
            env.init(queries[i].getEnvelopeInternal());
            tree.query(env);
        }
    }
}

另外要记住:getEnvelopeInternal() 返回内部缓存引用,只读;getEnvelope() 返回副本。查询时用前者省一次拷贝,但绝不能修改它。

20.6.4 大结果集与内存峰值

  • 不要把百万要素一次性读进 List<Geometry>,用分批(每批 1 万)处理;
  • 中间结果及时释放引用,避免在同一作用域持有多个大几何;
  • 输出时用流式 WKBWriter/WKTWriter(write(Geometry, Writer)),不要先拼成巨型字符串;
  • 监控 GC 分配速率,而不是只看堆占用:高分配速率往往意味着大量短命对象。

20.7 线程安全边界

20.7.1 安全矩阵

对象/操作 多线程读 多线程写 说明
GeometryFactory 安全 — 不可变,Javadoc 明确线程安全
Geometry(只读方法) 安全 — 不修改内部状态
Geometry(setSRID/userData/normalize/apply 修改) 不安全 不安全 会改变对象状态
PreparedGeometry 安全 — Javadoc 明确线程安全
STRtree query 安全 — 查询无状态
STRtree insert / build — 不安全 插入需外部同步
HPRtree build — 不安全 构建期单线程
OverlayNG 实例 不安全 不安全 有内部状态,一次一个线程
Noder / LineIntersector / Simplifier 实例 不安全 不安全 有可变字段
GeometryFactory 创建的新几何 — — 结果对象归本线程

20.7.2 最容易踩的坑

坑一:并发 normalize。 有人为了“结果稳定”在多线程里对共享几何调用 normalize()——该方法会就地重排坐标与环方向,多线程同时调用会把几何改坏。

坑二:边读边 setSRID。 一个线程在解析后 setSRID,另一个线程已经在用它建索引,索引与 SRID 无关但几何字段被改,存在可见性问题。

坑三:复用 Simplifier / Intersector 实例。 例如把同一个 RobustLineIntersector 放进多个线程共享,内部 intPt 数组会被覆盖,结果随机错乱。正确做法是每个线程一个实例(ThreadLocal 或方法内 new)。

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.Envelope;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.geom.prep.PreparedGeometry;
import org.locationtech.jts.geom.prep.PreparedGeometryFactory;

public class ParallelPrepareDemo {
    public static void main(String[] args) throws Exception {
        GeometryFactory gf = new GeometryFactory();
        final Geometry bigPolygon =
            gf.toGeometry(new Envelope(0, 100, 0, 100));
        final PreparedGeometry prep =
            PreparedGeometryFactory.prepare(bigPolygon); // 线程安全,可共享
        final List<Geometry> batch = new ArrayList<Geometry>();
        for (int i = 0; i < 10000; i++) {
            batch.add(gf.createPoint(
                new Coordinate((i * 37 % 200) - 50, (i * 91 % 150) - 25)));
        }

        ExecutorService pool = Executors.newFixedThreadPool(4);
        List<Future<Integer>> futures = new ArrayList<Future<Integer>>();
        for (int t = 0; t < 4; t++) {
            final int from = t * 2500;
            final int to = from + 2500;
            futures.add(pool.submit(new Callable<Integer>() {
                public Integer call() {
                    int count = 0;
                    for (int i = from; i < to; i++) {
                        if (prep.intersects(batch.get(i))) count++;
                    }
                    return count;
                }
            }));
        }
        int total = 0;
        for (Future<Integer> f : futures) total += f.get();
        pool.shutdown();
        System.out.println("命中总数 = " + total);
    }
}

20.7.3 并行模式

   ┌──────────────────────────────────────────────────────┐
   │ 共享只读: GeometryFactory、PreparedGeometry、STRtree │
   ├──────────────────────────────────────────────────────┤
   │ 线程私有: OverlayNG、Noder、LineIntersector、        │
   │           结果几何、临时 CoordinateSequence            │
   ├──────────────────────────────────────────────────────┤
   │ 任务切分: 按图幅/行政区/批次切分,避免锁              │
   └──────────────────────────────────────────────────────┘

并行前先问三个问题:

  1. 任务粒度够大吗?(每次任务至少几十毫秒,否则线程调度成本占比过高)
  2. 共享对象是否真的只读?
  3. 内存峰值能否承受“核数 × 单任务内存”?

20.8 JMH 基准与热点定位

20.8.1 为什么不能用 nanoTime 做正式对比

业务代码里的 System.nanoTime() 计时受这些因素干扰:

  • JIT 预热:前几千次调用是解释执行/C1 编译,慢一个数量级;
  • 死代码消除:计算结果没被使用,JIT 可能直接删掉;
  • 常量折叠:输入是常量时,整个计算可能被优化掉;
  • GC 抖动:一次 Full GC 就能让平均值翻倍;
  • CPU 频率与调度:同一台机器不同时刻差异可达 20%。

JMH 通过预热、多次 fork、黑盒消费、统计分布来消除这些干扰,是 JVM 微基准的事实标准。

20.8.2 JMH 工程配置

Maven 依赖(JMH 1.37):

<dependencies>
  <dependency>
    <groupId>org.openjdk.jmh</groupId>
    <artifactId>jmh-core</artifactId>
    <version>1.37</version>
  </dependency>
  <dependency>
    <groupId>org.openjdk.jmh</groupId>
    <artifactId>jmh-generator-annprocess</artifactId>
    <version>1.37</version>
    <scope>provided</scope>
  </dependency>
</dependencies>

打包可执行基准 jar 需要 shade 插件并把主类设为 org.openjdk.jmh.Main,然后用 java -jar target/benchmarks.jar 运行。

20.8.3 基准类示例

import java.util.concurrent.TimeUnit;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.prep.PreparedGeometry;
import org.locationtech.jts.geom.prep.PreparedGeometryFactory;
import org.locationtech.jts.io.WKTReader;
import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.BenchmarkMode;
import org.openjdk.jmh.annotations.Fork;
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.Warmup;
import org.openjdk.jmh.infra.Blackhole;

@State(Scope.Thread)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
@Warmup(iterations = 5, time = 1)
@Measurement(iterations = 5, time = 1)
@Fork(2)
public class PredicateBenchmark {
    private Geometry polygon;
    private PreparedGeometry prepared;
    private Geometry probe;

    @Setup
    public void setup() throws Exception {
        WKTReader r = new WKTReader();
        polygon = r.read("POLYGON ((0 0, 100 0, 100 100, 0 100, 0 0))");
        prepared = PreparedGeometryFactory.prepare(polygon);
        probe = r.read("LINESTRING (10 10, 90 90)");
    }

    @Benchmark
    public void plainIntersects(Blackhole bh) {
        bh.consume(polygon.intersects(probe));
    }

    @Benchmark
    public void preparedIntersects(Blackhole bh) {
        bh.consume(prepared.intersects(probe));
    }
}

结果解读要点:

  • Score 是每次操作的平均耗时(AverageTime),越低越好;
  • 看 Error 与置信区间,差异小于误差范围的结论不可信;
  • 同时看 gc.alloc.rate(JMH 的 GC profiler)判断分配压力;
  • 对照组要公平:同一个 probe、同一份数据、同样的预热。

20.8.4 热点定位工具

工具 用法 适合发现
JFR -XX:StartFlightRecording=duration=60s,filename=app.jfr CPU 热点、分配、锁、GC
async-profiler -agentpath:libasyncProfiler.so=start,event=cpu,file=prof.html 低开销 CPU/分配火焰图
JConsole / VisualVM 图形化 内存、线程、GC
GC 日志 -Xlog:gc*:file=gc.log 停顿、晋升、分配速率
JIT 日志 -XX:+PrintCompilation 编译行为异常

读 flame graph 的原则:先看宽(占比大的栈),再看JTS 边界内的 self time。如果大部分时间在 Coordinate.copy、Arrays.copyOf、Envelope.expandToInclude,说明分配/拷贝是主因;如果在 MCIndexNoder、SegmentIntersector 里,说明 noding 规模是主因。

20.8.5 基准反例

反例 问题
只测一次、不预热 测到的是解释执行时间
不用 Blackhole 消费结果 JIT 删掉计算
输入是常量 常量折叠
每个基准方法里都 new 数据 测到的是构造时间
不同基准共享可变状态 线程/缓存污染
只报告平均值 长尾问题被掩盖

20.9 OverlayNG 调优

20.9.1 先选对精度策略

第 18 章讲过三档策略。从性能角度:

  • 浮点 OverlayNG 最快,但可能抛异常;
  • OverlayNGRobust 在成功路径与浮点几乎同速,失败才回退;
  • 固定精度 snap-rounding 比浮点略慢(要维护 hot pixel 索引),但避免了回退。

经验:批处理用固定精度(结果可复现、不触发回退),交互式用浮点 + 捕获异常。

20.9.2 减少输入规模

叠加分析的耗时可粗略分解:

  总时间 ≈ noding(线段求交) + 构环/标注 + 输出构造
              ↑ 主导项,随线段数与交点数增长

因此最有效的优化是减少参与 noding 的线段:

  1. 包络预筛:包络不相交直接返回空/原几何,根本不进 overlay;
  2. 前置精度约简:去掉重复点、把顶点钉到网格,减少近重合边;
  3. 前置简化:在误差允许时降低顶点数;
  4. 空间分区:把一个巨型 overlay 拆成多个小区域 overlay,再拼接(见 20.10)。
import org.locationtech.jts.geom.Envelope;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.PrecisionModel;
import org.locationtech.jts.io.WKTReader;
import org.locationtech.jts.operation.overlayng.OverlayNG;
import org.locationtech.jts.operation.overlayng.OverlayNGRobust;
import org.locationtech.jts.precision.GeometryPrecisionReducer;

public class OverlayTuningDemo {
    public static void main(String[] args) throws Exception {
        WKTReader r = new WKTReader();
        Geometry a = r.read("POLYGON ((0 0, 100 0, 100 100, 0 100, 0 0))");
        Geometry b = r.read("POLYGON ((50 50, 150 50, 150 150, 50 150, 50 50))");

        // 0) 包络预筛:不相交就不算
        Envelope ea = a.getEnvelopeInternal();
        Envelope eb = b.getEnvelopeInternal();
        if (!ea.intersects(eb)) {
            System.out.println("包络不相交,跳过 overlay");
            return;
        }

        // 1) 前置精度约简(可选)
        PrecisionModel pm = new PrecisionModel(-0.001);
        Geometry a2 = GeometryPrecisionReducer.reduce(a, pm);
        Geometry b2 = GeometryPrecisionReducer.reduce(b, pm);

        // 2) 三种策略(正式对比请用 JMH)
        long t0 = System.nanoTime();
        Geometry g1 = OverlayNG.overlay(a2, b2, OverlayNG.INTERSECTION);
        long t1 = System.nanoTime();

        Geometry g2 = OverlayNG.overlay(a2, b2, OverlayNG.INTERSECTION, pm);
        long t2 = System.nanoTime();

        Geometry g3 = OverlayNGRobust.overlay(a2, b2, OverlayNG.INTERSECTION);
        long t3 = System.nanoTime();

        System.out.println("浮点 = " + (t1 - t0) / 1000 + " 微秒,面积=" + g1.getArea());
        System.out.println("固定 = " + (t2 - t1) / 1000 + " 微秒,面积=" + g2.getArea());
        System.out.println("稳健 = " + (t3 - t2) / 1000 + " 微秒,面积=" + g3.getArea());
    }
}

20.9.3 调试开关不要留在生产

setOutputEdges、setOutputNodedEdges、setOutputResultEdges 会让 OverlayNG 保留并输出中间结构,内存和耗时都显著增加;setOptimized(false) 会关闭包络裁剪等优化。这些开关只用于问题复现,生产代码里必须关闭。

20.9.4 判断“该不该用 overlay”

需求 更便宜的替代
只要判断是否相交/包含 PreparedGeometry 谓词
只要按范围过滤 Envelope 判断 + STRtree
把覆盖率合并成一张图 CoverageUnion
合并互相接触的面群 STRtree 分组 + CascadedPolygonUnion
求两个几何的精确交集 OverlayNG / OverlayNGRobust(必须用)
缓冲区后求并 直接 buffer(内部处理并)

能不算 overlay 就不算,是 JTS 性能优化里回报最高的一条。

20.10 分块与并行处理

20.10.1 为什么要分块

单机处理省级、国家级数据时,一次性把所有几何放进内存做 overlay 有三个问题:

  1. 内存峰值:中间 noding 结构可能与输入同量级甚至更大;
  2. 无法并行:一个巨型 overlay 只能单线程跑;
  3. 失败代价高:一次异常要全部重算。

分块处理把大任务拆成互相独立的小任务,同时解决这三点。

20.10.2 分块方式与边界处理

分块方式 优点 注意
规则网格(瓦片) 实现简单、负载均衡 要素可能横跨多块
行政区/图幅 符合业务、便于质检 大小不均,需二次均衡
按要素数量批次 内存可控 空间局部性差,跨批要素丢失关系
按空间索引分桶(Hilbert) 局部性好 需要预处理排序

无论哪种方式,分块边界都必须外扩 buffer,否则跨块要素在块内看起来“被切断”,计算结果不完整:

   瓦片 A(输出范围)        瓦片 A 的处理范围(外扩 buffer)
   ┌──────────────┐          ┌──────────────────────────┐
   │              │          │  ┌──────────────┐        │
   │              │          │  │   输出区      │        │
   │              │          │  └──────────────┘        │
   └──────────────┘          └──────────────────────────┘
   只裁不扩 → 跨界要素丢失    先扩后裁 → 结果完整

20.10.3 并行处理骨架

import java.util.ArrayList;
import java.util.List;
import java.util.stream.IntStream;
import org.locationtech.jts.geom.Envelope;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.index.strtree.STRtree;

public class ParallelTileDemo {
    /** 单个瓦片任务:查询 → 相交 → 裁回输出范围 */
    static Geometry processTile(Envelope outEnv, double buffer,
                                STRtree index, GeometryFactory gf) {
        Envelope queryEnv = new Envelope(outEnv);
        queryEnv.expandBy(buffer);

        List candidates = index.query(queryEnv);       // 线程安全
        List<Geometry> clipped = new ArrayList<Geometry>();
        for (Object o : candidates) {
            Geometry g = (Geometry) o;
            Geometry hit = g.intersection(gf.toGeometry(queryEnv));
            if (!hit.isEmpty()) clipped.add(hit);
        }
        if (clipped.isEmpty()) return gf.createGeometryCollection();

        Geometry merged = clipped.get(0);
        for (int i = 1; i < clipped.size(); i++) {
            merged = merged.union(clipped.get(i));      // 小规模,局部并
        }
        // 裁回瓦片输出范围
        return merged.intersection(gf.toGeometry(outEnv));
    }

    public static void main(String[] args) {
        GeometryFactory gf = new GeometryFactory();
        STRtree index = new STRtree();
        // ... 向 index 插入数据并 build()

        List<Envelope> tiles = new ArrayList<Envelope>();
        for (int i = 0; i < 100; i++) {
            double x = (i % 10) * 1000;
            double y = (i / 10) * 1000;
            tiles.add(new Envelope(x, x + 1000, y, y + 1000));
        }

        List<Geometry> results = new ArrayList<Geometry>();
        IntStream.range(0, tiles.size()).parallel()
            .mapToObj(i -> processTile(tiles.get(i), 50, index, gf))
            .forEach(results::add);

        System.out.println("瓦片结果数 = " + results.size());
    }
}

这段代码有三个必须注意的点:

  1. results::add 在并行流里不是线程安全的。示例为了简洁这样写是错误的,正确做法是用 collect 收集,或每个线程一个局部 List 再合并;
  2. STRtree 的查询是线程安全的,但必须在并行之前 build 完成;
  3. 每个瓦片内部仍然做了多次 union,如果要素很多,应该换成 CascadedPolygonUnion 或 CoverageUnion(合法覆盖率时)。

修正后的收集方式:

List<Geometry> results = IntStream.range(0, tiles.size()).parallel()
    .mapToObj(i -> processTile(tiles.get(i), 50, index, gf))
    .collect(java.util.stream.Collectors.toList());

20.10.4 结果拼接

分块结果的拼接策略取决于业务:

  • 计数/统计:直接汇总,注意跨块要素被重复计数——计数前按要素 ID 去重;
  • 面合并:块间边界会有重复覆盖,用一元并(UnaryUnionOp)或先确保覆盖率合法再用 CoverageUnion;
  • 线/点:需要去重(相同坐标的端点重复);
  • 输出图层:每块单独输出时,相邻块边界处会有接缝,渲染上通常可接受,拓扑上不连续。

20.10.5 并行的边界条件

  • 任务粒度:单任务 < 10 ms 时,线程调度与结果合并成本占比过高;
  • 内存:并行度 × 单任务峰值内存必须小于堆上限,否则频繁 Full GC 反而更慢;
  • 异常传播:并行流里的异常会被包装成 CompletionException,要解包记录;
  • 不要共享可变几何:见 20.7 的安全矩阵;
  • 确定性:并行结果顺序不稳定,输出前排序(按 ID 或坐标)保证可复现。

20.11 缓存策略

20.11.1 哪些东西值得缓存

缓存对象 收益 失效条件
PreparedGeometry 高频谓词调用提速数倍到数十倍 原几何被修改
STRtree / HPRtree 避免重复构建 数据变更
WKT 解析结果 避免重复解析 数据变更
投影转换结果 避免重复转换 源 CRS/目标 CRS 变化
IsValidOp 结论 避免重复校验 几何变更
Envelope JTS 已在 Geometry 内部缓存 几何变更
空间查询结果 热点区域查询加速 数据或查询条件变化

20.11.2 缓存的两个坑

坑一:缓存了可变对象。 把 Geometry 缓存后,别处调用 normalize() 或 setSRID 会改变它,缓存与索引全部失效。缓存前应 copy(),或约定“缓存对象只读”。

坑二:键的设计。 用几何对象的 hashCode 做键基本无意义(JTS 的 Geometry 没有按坐标重写 hashCode);应该用业务主键 + 数据版本号。

20.11.3 一个线程安全的 LRU 缓存

import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.prep.PreparedGeometry;
import org.locationtech.jts.geom.prep.PreparedGeometryFactory;

public class PreparedCache {
    private static final int MAX_ENTRIES = 1024;

    private final Map<String, PreparedGeometry> cache =
        Collections.synchronizedMap(new LinkedHashMap<String, PreparedGeometry>(
            MAX_ENTRIES, 0.75f, true) {
            @Override
            protected boolean removeEldestEntry(
                    Map.Entry<String, PreparedGeometry> eldest) {
                return size() > MAX_ENTRIES;
            }
        });

    public PreparedGeometry get(String key, Geometry geom) {
        PreparedGeometry cached = cache.get(key);
        if (cached != null) return cached;
        PreparedGeometry prepared = PreparedGeometryFactory.prepare(geom.copy());
        cache.put(key, prepared);
        return prepared;
    }
}

说明:

  • synchronizedMap 保证单次 get/put 原子,但“检查-构建-放入”整体不是原子的,高并发下可能重复构建同一个条目(可接受,用 computeIfAbsent 可改善);
  • 缓存里存的是 geom.copy() 的 prepared 版本,避免外部修改影响;
  • LinkedHashMap 的 accessOrder=true 实现 LRU;
  • 分布式场景不要把 PreparedGeometry 序列化传输(内部索引不可序列化/无意义),应各节点本地构建。

20.11.4 缓存分层

  L1:JVM 内(PreparedGeometry、STRtree、解析结果)
  L2:进程外(Redis/Memcached,存 WKB + 元数据)
  L3:数据库(物化视图、空间索引)

L1 追求纳秒~微秒级,适合热点几何;L2 追求跨进程共享,适合跨请求结果;L3 适合全量数据。不要用 L2 缓存几何对象本身(序列化开销 + 精度问题),缓存 WKB 或查询结果 ID。

20.12 性能优化检查清单(含反例)

20.12.1 检查清单

序号 检查项 通过标准
1 是否有基线数据与基线耗时 有固定数据集与可复现脚本
2 是否定位到热点 有 profile 证据,不是猜测
3 重复谓词是否用 PreparedGeometry 循环内谓词调用已准备
4 批量空间查询是否建索引 STRtree/HPRtree,构建一次复用
5 是否避免逐对 union 用 UnaryUnionOp/CascadedPolygonUnion
6 覆盖率合并是否用 CoverageUnion 先校验覆盖率,再合并
7 是否在入口修复无效几何 校验/修复前置,避免回退风暴
8 是否减少顶点数 允许误差时简化/精度约简
9 是否避免循环内 getCoordinates 用 CoordinateSequence 遍历
10 是否复用 GeometryFactory 按 SRID/PM 缓存工厂
11 是否避免不必要拷贝 用 getEnvelopeInternal、stream 写出
12 线程安全边界是否清晰 只读共享、状态私有、索引先构建
13 是否有并行度与内存上限 并行度 × 单任务内存 < 堆
14 缓存是否可失效 有版本号/TTL/主动清空
15 是否有回归基准 每次升级跑 JMH 与端到端
16 输出是否流式 大结果不拼巨型字符串
17 日志是否精简 热路径不打 WKT
18 是否监控 GC 与异常率 有分配速率、TopologyException 率指标

20.12.2 反例集

反例 代价 正确做法
for (g : list) result = result.union(g) O(N²) UnaryUnionOp / CascadedPolygonUnion
循环里 g.getCoordinates() 大量数组分配 CoordinateSequence 遍历
每次查询 new STRtree 重复构建 构建一次,只读复用
每次谓词前 isValid() 校验成本翻倍 入口校验一次并缓存
用 WKT 做中间结果 解析/序列化双慢 传对象或 WKB
在循环里 new GeometryFactory() 无谓对象 + 破坏 PM 一致性 复用工厂
百万要素一次读入内存 OOM/长 GC 分批流式
用并行流处理几百个小任务 调度开销大于收益 串行或合并任务
热路径打 System.out.println(wkt) IO + 字符串拼接 关闭或降级日志
对无效几何反复重试 overlay 回退链放大耗时 先修复再算

20.12.3 优化决策树

   性能不达标
        │
        ▼
   有 profile 吗? ── 否 ──► 先做 JFR/async-profiler 采样
        │ 是
        ▼
   热点在 IO/解析? ── 是 ──► 换 WKB、批量读写、流式处理
        │ 否
        ▼
   热点在谓词/查询? ── 是 ──► PreparedGeometry + 空间索引
        │ 否
        ▼
   热点在 overlay/noding? ── 是 ──► 减少顶点/前置修复/分块/换批量 union
        │ 否
        ▼
   热点在 GC/分配? ── 是 ──► 减少 getCoordinates/copy、复用 Envelope/工厂
        │ 否
        ▼
   热点在锁/线程? ── 是 ──► 缩小临界区、只读共享、任务私有状态
        │ 否
        ▼
   检查数据本身(无效几何、超大要素、异常坐标)

20.13 常见问题与避坑

问:PreparedGeometry 为什么没有更快? 检查三点:调用次数是否足够多(少于几百次时构建成本占比高);几何是否是小面(顶点少时收益有限);是否每次都在重新 prepare。

问:STRtree 插入报 “Cannot insert items into an STR packed R-tree after it has been built”? 树在第一次 query 时已自动 build,之后不能再插入。要么先插完再查,要么换 Quadtree(支持增量)。

问:并行之后结果不稳定,甚至偶发错误? 大概率共享了可变对象:复用同一个 OverlayNG/Noder/LineIntersector 实例,或对共享 Geometry 调用了 normalize/setSRID。按 20.7 的矩阵检查。

问:为什么简单的 intersects 也慢? 可能是面有几十万顶点且被反复调用(用 PreparedGeometry),也可能是包络很大导致候选过多(加索引或分块)。

问:union 大集合时内存暴涨怎么办? 改用 CascadedPolygonUnion(自底向上合并,中间结果小);或先用 STRtree 分组,再逐组合并;或分块 + CoverageUnion。

问:简化之后结果面积变化很大? Douglas-Peucker 不保拓扑也不保面积,容差过大就会明显改变形状。面积敏感场景用 TopologyPreservingSimplifier 或 CoverageSimplifier,并把容差控制在业务精度内。

问:JMH 结果和线上表现差距很大? 基准通常是单线程、热数据、无 IO;线上有 GC、锁、网络、数据库。JMH 只用于比较算法/实现的相对优劣,端到端压测不可替代。

问:缓存 PreparedGeometry 会不会内存泄漏? 会,如果没有上限和失效策略。用有界 LRU + 业务版本号,几何变更时主动清空对应条目。

20.14 本章小结

  1. 性能优化从测量开始:端到端计时 → 采样剖析 → JMH 微基准 → 回归对比,拒绝凭感觉优化。
  2. JTS 的五类开销是 IO、算法复杂度、对象分配、无效输入、并发争用;不同类别对应不同手段。
  3. PreparedGeometry 把重复谓词从 O(n) 降到近似 O(log n),线程安全、懒加载,适合“一个面 vs 海量对象”;但构建有成本,少次调用不划算。
  4. STRtree 是半静态索引:插入需在构建前、查询线程安全、构建后不可插入;HPRtree 更适合静态大数据;数据量大且多次查询才值得建索引。
  5. 批量 union 不要逐对循环:用 UnaryUnionOp、CascadedPolygonUnion、OverlayNGRobust.union;合法覆盖率用 CoverageUnion。
  6. 前置校验/修复/简化往往比调算法参数更有效;无效几何会触发 overlay 的多级回退,代价是数量级的。
  7. 减少对象分配:用 CoordinateSequence 遍历代替 getCoordinates(),复用 Envelope 与 GeometryFactory,流式写出大结果。
  8. 线程安全边界:GeometryFactory 与 PreparedGeometry 可共享;Geometry 只读安全、修改不安全;索引查询安全、插入不安全;OverlayNG/Noder/LineIntersector 等实例必须线程私有。
  9. JMH 是 JVM 微基准标准;JFR 与 async-profiler 用于定位热点;两者结合才能既知道“哪里慢”又知道“优化多少”。
  10. OverlayNG 调优的核心是减少参与 noding 的线段:包络预筛、前置精度约简/简化、分块;调试开关不要留在生产。
  11. 分块并行能同时解决内存与耗时,但要注意边界 buffer、结果拼接去重、并行流的收集安全与内存上限。
  12. 缓存要有界、可失效、只读;PreparedGeometry 等内部索引不适合跨进程传输。

20.15 相关资源

  • 系列目录:JTS 教程目录
  • 空间索引体系:第 13 章(STRtree/HPRtree/Quadtree 原理与 API)
  • 叠加分析:第 09 章(OverlayNG 架构与操作成本)
  • 线网处理与 Noding:第 14 章(noding 是 overlay 的主要成本)
  • 几何化简:第 12 章(简化容差与拓扑保持)
  • 有效性与 Coverage 质检:第 17 章(前置修复与覆盖率算法)
  • 精度模型与数值鲁棒性:第 18 章(精度策略对性能的影响)
  • 坐标系与工程陷阱:第 19 章(单位错误导致的“假性能问题”)
  • 综合实战:第 24 章(性能清单在完整项目中的落地)
  • JTS 官方 Javadoc:https://locationtech.github.io/jts/javadoc/
  • JMH 官方文档:https://openjdk.org/projects/code-tools/jmh/
  • async-profiler:https://github.com/async-profiler/async-profiler