> ## Content Index
> Fetch the complete content index at: https://blog.brokk.ai/llms.txt
> Use this file to discover other available public pages before exploring further.

# Stop Allocating Nothing: How We Tripled Java TreeSitter Performance...
- URL: https://blog.brokk.ai/stop-allocating-nothing-how-we-tripled-java-treesitter-performance/
- Published: 2026-04-23T18:37:15.000Z
- Updated: 2026-04-23T18:37:15.000Z
- Author: David Baker Effendi

*(...on our CI/CD performance regression tests)*  
  
**Upgrades in this post are available from version** [**0.23.4**](https://github.com/BrokkAi/brokk/releases/tag/0.23.4?ref=blog.brokk.ai)**.**

Using [TreeSitter](https://tree-sitter.github.io/tree-sitter/?ref=blog.brokk.ai) in a JVM environment is fragmented and often requires manual setup.

The official binding, [jtreesitter](https://tree-sitter.github.io/java-tree-sitter/io/github/treesitter/jtreesitter/package-summary.html?ref=blog.brokk.ai), requires compiling native C code, which is impractical for many users and often a non-starter. This led to the popularity of the [bonede](https://github.com/bonede/tree-sitter-ng?ref=blog.brokk.ai) fork (by Wang Liang), which provided pre-compiled binaries and became the de facto standard. At **Brokk**, we use [our own fork](https://github.com/BrokkAi/tree-sitter-ng?ref=blog.brokk.ai) of the `bonede` lineage that adds deterministic cleanup of native structures, JSpecify null annotations, and an on-demand iterator-based approach using `node.getChildren()`, making the high-level code much cleaner

Recently, while running a performance regression benchmark with a 1,000 max file limit against various open-source repositories on a GitHub CI/CD runner, we noticed our analysis was taking about, on average, **22 seconds**. 

This was slower than expected. Investigation of the JNI layer revealed unnecessary object allocation for null nodes.

## **Problem: Null node handling**

TreeSitter traversal involves frequent AST navigation. Many nodes have optional children—things like a try block that might or might not have a “finally” clause. When requesting a missing child node, the `bonede` binding performs unnecessary work in the JNI layer.

Every time a node was requested, the C code would cross the JNI boundary and execute a function called [ts\_node\_to\_obj](https://github.com/bonede/tree-sitter-ng/blob/4e854f7fb6d9eeebd5acf7480827277cdc661154/tree-sitter/src/main/c/org%5Ftreesitter%5FTSParser.c?ref=blog.brokk.ai#L48). For each node, including `null` nodes, the following steps occur:

1. **Cross the JNI boundary** from Java to C.
2. **Call AllocObject**: This tells the JVM to allocate a new `TSNode` object on the heap.
3. **Call SetIntField and SetLongField six times** to populate the object’s internal pointers and metadata.
4. **Return the object** to Java.
5. **Java-side Check**: The Java code would then immediately call `TSNode::isNull`, realize the node was empty, and throw it away.

`TSNode::isNull` also crosses the JNI boundary.

```java
  public boolean isNull(){
    return TSParser.ts_node_is_null(this); // (JNI bound function)
  }
```

This corresponded to simply calling [the following C code](https://github.com/tree-sitter/tree-sitter/blob/a69367f7399953a966d69a875cbda7e147c221e0/lib/src/node.c?ref=blog.brokk.ai#L497).

```c
bool ts_node_is_null(TSNode self) {
  return self.id == 0;
}
```

These nodes should be treated as immutable, so if a node is not `null`, it won’t become `null` during its lifetime, even after an incremental edit. To illustrate the impact, here is a screenshot of the IntelliJ profiler showing how “hot” this path is when running the regression tests on my local machine:

![](https://storage.ghost.io/c/f5/e4/f5e49182-2f16-4855-8727-fde7524e784f/content/images/2026/04/data-src-image-9cf12594-cf14-4c60-b114-80048ad243ee.png)

****Figure 1**: CPU Time spent checking if `TSNode` is `null` during benchmarks across 3 iterations.

Across large analyses, this produces millions of short-lived objects, increasing GC pressure and forcing frequent cleanup. This creates massive **Garbage Collection (GC) pressure**, forcing the JVM to constantly stop and clean up the "nothing" you just created.

---

## **The Fix: Early Exit in C**

The solution was straightforward. We modified the `ts_node_to_obj` function in our C layer to check if the node was null *before* talking to the JVM's object allocator.

### **The C Change**

In our Brokk fork, we added an early return:

```c
// In our JNI layer
jobject ts_node_to_obj(JNIEnv *env, TSNode ts_node) {   
  if (ts_node_is_null(ts_node)) {
    return NULL; // Avoid JVM allocation entirely
  }
  // ... otherwise, proceed with AllocObject and field population
}
```

By returning a native `NULL`, the JNI layer simply hands a Java `null` back to the caller. No heap allocation, no field setting, and no GC overhead.

### **The Java Cleanup**

This simplifies the Java-side traversal logic by removing redundant null checks:

```java
// Before: Heavy and redundant
for (int i = 0; i < node.getChildCount(); i++) {
  TSNode child = node.getChild(i);
  if (child != null && !child.isNull()) {
    // do work
  }
}

// After: Idiomatic and fast
for (TSNode child: node.getChildren()) {
  // do work
}
```

---

## **Results**

The impact was measurable immediately. We ran our standard performance benchmark, analyzing 1,000 files in a daily regression suite, and the runner completed with an average iteration time of just **under 7 seconds**.

Initial results appeared anomalous. So we ran the same experiment on a developer laptop, before and after the fix. The JVM was set to use a maximum heap size of 256Mb to introduce some GC pressure. We then focused our profiler's method tree on the main file parsing and indexing entry point, `TreeSitterAnalyzer::analyzeFileContent`:

Null checks account for \~25% of total CPU time across the three iterations (**Figure 2**). After the fix, total CPU time drops to \~140 seconds (**Figure 3**).

![](https://storage.ghost.io/c/f5/e4/f5e49182-2f16-4855-8727-fde7524e784f/content/images/2026/04/Screenshot-2026-04-04-at-15.31.14.png)

****Figure 2**: Pre-fix profiler output highlighting time spent in null checks.

![](https://storage.ghost.io/c/f5/e4/f5e49182-2f16-4855-8727-fde7524e784f/content/images/2026/04/Screenshot-2026-04-04-at-15.30.51.png)

****Figure 3**: Post-fix profiler output after eliminating JNI null allocations.

The improvement was approximately 2× locally and \~3× on CI. The parsing and indexing make use of multi-threading, which may close the gap on developer hardware.

---

The improvement comes from reducing JNI overhead rather than optimizing C execution. Eliminating transient allocations reduces GC overhead and shifts execution toward analysis work.

For our clients, this translates to faster code intelligence, a more responsive UI during codebase exploration, and lower resource consumption on their machines.

In JNI-heavy systems, minimizing boundary crossings is critical for performance.