From c471ec78cd3abd2a41cf98538be4a1050e4c2d0c Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Sun, 8 Nov 2026 18:37:28 -0300 Subject: [PATCH 01/42] test: benchmark Java threads calling into JS concurrently One, two and four Java threads call a JS implementation at once with no arguments, three existing objects, or three new objects per call. The benchmark runs asynchronously so the JS thread can release the isolate. --- .../app/tests/benchmarks/jniBenchmarks.js | 68 ++++++++++++++++- .../java/com/tns/tests/ContentionBench.java | 73 +++++++++++++++++++ 2 files changed, 140 insertions(+), 1 deletion(-) create mode 100644 test-app/app/src/main/java/com/tns/tests/ContentionBench.java diff --git a/test-app/app/src/main/assets/app/tests/benchmarks/jniBenchmarks.js b/test-app/app/src/main/assets/app/tests/benchmarks/jniBenchmarks.js index 0e6c54fe8..8a0cd3fd1 100644 --- a/test-app/app/src/main/assets/app/tests/benchmarks/jniBenchmarks.js +++ b/test-app/app/src/main/assets/app/tests/benchmarks/jniBenchmarks.js @@ -88,6 +88,43 @@ function measureLifecycle(name, count, create) { } + +// Aggregate ns per call while `threads` Java threads call into JS at once. Runs +// asynchronously: the JS thread must go idle and release the isolate for the +// callers to get in, so it polls for the result. +function measureContention(name, sink, threads, calls, mode, done) { + if (FILTER && name.indexOf(FILTER) < 0) { + done(null); + return; + } + var Bench = com.tns.tests.ContentionBench; + calls *= SCALE; + var samples = []; + var round = -1; + function startRound() { + Bench.start(sink, threads, calls, mode); + setTimeout(poll, 5); + } + function poll() { + var nanos = Number(Bench.result()); + if (nanos < 0) { + setTimeout(poll, 5); + return; + } + if (round >= 0) { + samples.push(nanos / (threads * calls)); + } + round++; + if (round < ROUNDS) { + gc(); + setTimeout(startRound, 20); + } else { + done(report(name, samples)); + } + } + startRound(); +} + function buildBenchmarks() { var bench = new JniBench(); var other = bench.getOther(); @@ -326,6 +363,23 @@ function buildBenchmarks() { ]; } +function buildContentionBenchmarks() { + var Bench = com.tns.tests.ContentionBench; + var sink = new Bench.Sink({ + call0: function () { }, + call3: function (a, b, c) { } + }); + var list = []; + [1, 2, 4].forEach(function (threads) { + [["no args", Bench.NO_ARGS], ["3 existing objects", Bench.EXISTING_OBJECTS], ["3 new objects", Bench.NEW_OBJECTS]].forEach(function (mode) { + list.push(function (done) { + measureContention("contention " + threads + " thread(s), " + mode[0], sink, threads, 20000, mode[1], done); + }); + }); + }); + return list; +} + exports.run = function () { var benchmarks = buildBenchmarks(); var results = []; @@ -333,7 +387,7 @@ exports.run = function () { // Yield to the looper between benchmarks so GC and queued work run outside timed rounds. function next() { if (index >= benchmarks.length) { - console.log("JNIBENCH_DONE " + JSON.stringify(results)); + runContention(); return; } gc(); @@ -341,5 +395,17 @@ exports.run = function () { if (result) results.push(result); setTimeout(next, 50); } + var contention = buildContentionBenchmarks(); + var contentionIndex = 0; + function runContention() { + if (contentionIndex >= contention.length) { + console.log("JNIBENCH_DONE " + JSON.stringify(results)); + return; + } + contention[contentionIndex++](function (result) { + if (result) results.push(result); + setTimeout(runContention, 50); + }); + } setTimeout(next, 500); }; diff --git a/test-app/app/src/main/java/com/tns/tests/ContentionBench.java b/test-app/app/src/main/java/com/tns/tests/ContentionBench.java new file mode 100644 index 000000000..6443c6aa8 --- /dev/null +++ b/test-app/app/src/main/java/com/tns/tests/ContentionBench.java @@ -0,0 +1,73 @@ +package com.tns.tests; + +import java.util.concurrent.CountDownLatch; + +/** + * Several Java threads calling one JS implementation at once, the multithreaded-JS + * case. start() returns immediately so the JS thread can go idle and release the + * isolate; the caller polls result() for the elapsed nanoseconds. + */ +public class ContentionBench { + public interface Sink { + void call0(); + + void call3(Object a, Object b, Object c); + } + + public static final int NO_ARGS = 0; + public static final int EXISTING_OBJECTS = 1; + public static final int NEW_OBJECTS = 2; + + private static volatile long resultNanos = -1; + + public static long result() { + return resultNanos; + } + + public static void start(final Sink sink, final int threads, final int callsPerThread, final int mode) { + resultNanos = -1; + final Object[] shared = {new Object(), new Object(), new Object()}; + new Thread(new Runnable() { + @Override + public void run() { + final CountDownLatch ready = new CountDownLatch(threads); + final CountDownLatch go = new CountDownLatch(1); + Thread[] workers = new Thread[threads]; + for (int t = 0; t < threads; t++) { + workers[t] = new Thread(new Runnable() { + @Override + public void run() { + ready.countDown(); + try { + go.await(); + } catch (InterruptedException e) { + return; + } + for (int i = 0; i < callsPerThread; i++) { + if (mode == NO_ARGS) { + sink.call0(); + } else if (mode == EXISTING_OBJECTS) { + sink.call3(shared[0], shared[1], shared[2]); + } else { + sink.call3(new Object(), new Object(), new Object()); + } + } + } + }); + workers[t].start(); + } + try { + ready.await(); + long start = System.nanoTime(); + go.countDown(); + for (Thread worker : workers) { + worker.join(); + } + resultNanos = System.nanoTime() - start; + } catch (InterruptedException e) { + resultNanos = 0; + } + } + }).start(); + } +} From 8694f158ffcf5ff6cd095180819c3996470c56b1 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Sun, 8 Nov 2026 18:53:13 -0300 Subject: [PATCH 02/42] perf: keep Java object ids in one table touched only under the isolate lock com.tns.Runtime kept four maps per runtime (id -> object and object -> id, each strong and weak), wrapped in synchronizedMap/ConcurrentHashMap when multithreaded JS is enabled. Every JS wrapper creation, object return and finalization paid for those locks, a second WeakReference per object, and NativeScriptHashMap entry allocations. They are replaced by one ObjectTable: open addressing over the object id and over System.identityHashCode, no per-entry allocation, no locks. Its rule is that only the thread holding the runtime's isolate lock touches it, so the accesses that used to happen in Java before the lock now happen natively after taking it: the receiver of a multithreaded Java -> JS call and every object argument are resolved (and new objects registered) in callJSMethodNative, and createJSInstance registers inside createJSInstanceNative. With multithreaded JS off the receiver is still resolved on the runtime's own thread, as before. Object ids stay monotonic, so native caches keyed by them never see a reused id. NativeScriptHashMap, NativeScriptWeakHashMap and NativeScriptAbstractMap have no other users and are removed. --- .../runtime/src/main/cpp/ArgConverter.cpp | 8 +- .../runtime/src/main/cpp/ObjectManager.cpp | 22 + test-app/runtime/src/main/cpp/ObjectManager.h | 9 + test-app/runtime/src/main/cpp/Runtime.cpp | 17 +- test-app/runtime/src/main/cpp/Runtime.h | 2 +- .../runtime/src/main/cpp/com_tns_Runtime.cpp | 4 +- .../java/com/tns/NativeScriptAbstractMap.java | 515 -------- .../java/com/tns/NativeScriptHashMap.java | 1056 ----------------- .../java/com/tns/NativeScriptWeakHashMap.java | 725 ----------- .../src/main/java/com/tns/ObjectTable.java | 247 ++++ .../src/main/java/com/tns/Runtime.java | 112 +- .../test/java/com/tns/ObjectTableTest.java | 121 ++ .../src/test/java/com/tns/RuntimeTest.java | 71 +- 13 files changed, 461 insertions(+), 2448 deletions(-) delete mode 100644 test-app/runtime/src/main/java/com/tns/NativeScriptAbstractMap.java delete mode 100644 test-app/runtime/src/main/java/com/tns/NativeScriptHashMap.java delete mode 100644 test-app/runtime/src/main/java/com/tns/NativeScriptWeakHashMap.java create mode 100644 test-app/runtime/src/main/java/com/tns/ObjectTable.java create mode 100644 test-app/runtime/src/test/java/com/tns/ObjectTableTest.java diff --git a/test-app/runtime/src/main/cpp/ArgConverter.cpp b/test-app/runtime/src/main/cpp/ArgConverter.cpp index 2ad647036..bc3667efe 100644 --- a/test-app/runtime/src/main/cpp/ArgConverter.cpp +++ b/test-app/runtime/src/main/cpp/ArgConverter.cpp @@ -155,14 +155,12 @@ void ArgConverter::ConvertJavaArgsToJsArgs(Isolate* isolate, JNIEnv* env, const break; } case Type::JsObject: { - auto javaObjectID = static_cast(bits); + JniLocalRef arg(env->GetObjectArrayElement(args.refs, i)); + auto javaObjectID = objectManager->GetOrCreateObjectId(arg); jsArg = objectManager->GetJsObjectByJavaObject(javaObjectID); if (jsArg.IsEmpty()) { - JniLocalRef argJavaClassPath(env->GetObjectArrayElement(args.refs, i)); - string argClassName = jstringToString(ObjectToString(argJavaClassPath)); - argClassName = Util::ConvertFromCanonicalToJniName(argClassName); - jsArg = objectManager->CreateJSWrapper(javaObjectID, argClassName); + jsArg = objectManager->CreateJSWrapper(javaObjectID, "", arg); } break; } diff --git a/test-app/runtime/src/main/cpp/ObjectManager.cpp b/test-app/runtime/src/main/cpp/ObjectManager.cpp index b3411d7eb..f2702fa7b 100644 --- a/test-app/runtime/src/main/cpp/ObjectManager.cpp +++ b/test-app/runtime/src/main/cpp/ObjectManager.cpp @@ -53,6 +53,14 @@ void ObjectManager::InitializeJNI() { runtimeClass, "getOrCreateJavaObjectID", "(Ljava/lang/Object;)I"); NS_CHECK(GET_OR_CREATE_JAVA_OBJECT_ID_METHOD_ID != nullptr); + FIND_JAVA_OBJECT_ID_METHOD_ID = env.GetMethodID( + runtimeClass, "findJavaObjectID", "(Ljava/lang/Object;)I"); + NS_CHECK(FIND_JAVA_OBJECT_ID_METHOD_ID != nullptr); + + MAKE_INSTANCE_STRONG_METHOD_ID = env.GetMethodID( + runtimeClass, "makeInstanceStrong", "(Ljava/lang/Object;I)V"); + NS_CHECK(MAKE_INSTANCE_STRONG_METHOD_ID != nullptr); + MAKE_INSTANCE_WEAK_AND_CHECK_IF_ALIVE_METHOD_ID = env.GetMethodID(runtimeClass, "makeInstanceWeakAndCheckIfAlive", "(I)Z"); NS_CHECK(MAKE_INSTANCE_WEAK_AND_CHECK_IF_ALIVE_METHOD_ID != nullptr); @@ -248,6 +256,18 @@ int ObjectManager::GetOrCreateObjectId(jobject object) { return javaObjectID; } +int ObjectManager::FindObjectId(jobject object) { + JEnv env; + return env.CallIntMethod(m_javaRuntimeObject, FIND_JAVA_OBJECT_ID_METHOD_ID, + object); +} + +void ObjectManager::RegisterStrong(jobject object, int javaObjectID) { + JEnv env; + env.CallVoidMethod(m_javaRuntimeObject, MAKE_INSTANCE_STRONG_METHOD_ID, + object, javaObjectID); +} + Local ObjectManager::GetJsObjectByJavaObject(int javaObjectID) { auto isolate = m_isolate; EscapableHandleScope handleScope(isolate); @@ -577,6 +597,8 @@ jclass ObjectManager::JAVA_LANG_CLASS = nullptr; jmethodID ObjectManager::GET_NAME_METHOD_ID = nullptr; jmethodID ObjectManager::GET_JAVAOBJECT_BY_ID_METHOD_ID = nullptr; jmethodID ObjectManager::GET_OR_CREATE_JAVA_OBJECT_ID_METHOD_ID = nullptr; +jmethodID ObjectManager::FIND_JAVA_OBJECT_ID_METHOD_ID = nullptr; +jmethodID ObjectManager::MAKE_INSTANCE_STRONG_METHOD_ID = nullptr; jmethodID ObjectManager::MAKE_INSTANCE_WEAK_AND_CHECK_IF_ALIVE_METHOD_ID = nullptr; jmethodID ObjectManager::RELEASE_NATIVE_INSTANCE_METHOD_ID = nullptr; diff --git a/test-app/runtime/src/main/cpp/ObjectManager.h b/test-app/runtime/src/main/cpp/ObjectManager.h index e2a0e6b94..9bc06e966 100644 --- a/test-app/runtime/src/main/cpp/ObjectManager.h +++ b/test-app/runtime/src/main/cpp/ObjectManager.h @@ -44,6 +44,11 @@ class ObjectManager { void SetJavaClass(const v8::Local& instance, jclass clazz); int GetOrCreateObjectId(jobject object); + // The id object is registered under, or -1. + int FindObjectId(jobject object); + + void RegisterStrong(jobject object, int javaObjectID); + v8::Local GetJsObjectByJavaObject(int javaObjectID); v8::Local CreateJSWrapper(jint javaObjectID, @@ -179,6 +184,10 @@ class ObjectManager { static jmethodID GET_OR_CREATE_JAVA_OBJECT_ID_METHOD_ID; + static jmethodID FIND_JAVA_OBJECT_ID_METHOD_ID; + + static jmethodID MAKE_INSTANCE_STRONG_METHOD_ID; + static jmethodID MAKE_INSTANCE_WEAK_AND_CHECK_IF_ALIVE_METHOD_ID; static jmethodID RELEASE_NATIVE_INSTANCE_METHOD_ID; diff --git a/test-app/runtime/src/main/cpp/Runtime.cpp b/test-app/runtime/src/main/cpp/Runtime.cpp index dd45ea3d4..171288290 100644 --- a/test-app/runtime/src/main/cpp/Runtime.cpp +++ b/test-app/runtime/src/main/cpp/Runtime.cpp @@ -606,7 +606,8 @@ static bool TryGetPrimitiveReturnBits(Isolate* isolate, } jobject Runtime::CallJSMethodNative(JNIEnv* _env, jobject obj, - jint javaObjectID, jstring methodName, + jobject javaObject, jint javaObjectID, + jstring methodName, jint retType, jboolean isConstructor, const JavaCallArgs& args, jlongArray primitiveResult) { @@ -618,6 +619,14 @@ jobject Runtime::CallJSMethodNative(JNIEnv* _env, jobject obj, DEBUG_WRITE("CallJSMethodNative called javaObjectID=%d", javaObjectID); + if (javaObjectID < 0) { + javaObjectID = m_objectManager->FindObjectId(javaObject); + if (javaObjectID < 0) { + throw NativeScriptException("Cannot find object id for an instance of " + + m_objectManager->GetClassName(javaObject)); + } + } + auto jsObject = m_objectManager->GetJsObjectByJavaObject(javaObjectID); if (jsObject.IsEmpty()) { stringstream ss; @@ -659,9 +668,9 @@ jobject Runtime::CallJSMethodNative(JNIEnv* _env, jobject obj, } int classReturnType = retType; - jobject javaObject = + jobject javaResult = ConvertJsValueToJavaObject(env, jsResult, classReturnType); - return javaObject; + return javaResult; } void Runtime::CreateJSInstanceNative(JNIEnv* _env, jobject obj, @@ -675,6 +684,8 @@ void Runtime::CreateJSInstanceNative(JNIEnv* _env, jobject obj, JEnv env(_env); + m_objectManager->RegisterStrong(javaObject, javaObjectID); + string existingClassName = ArgConverter::jstringToString(className); string jniName = Util::ConvertFromCanonicalToJniName(existingClassName); Local jsInstance; diff --git a/test-app/runtime/src/main/cpp/Runtime.h b/test-app/runtime/src/main/cpp/Runtime.h index bc75dc697..3b02b715e 100644 --- a/test-app/runtime/src/main/cpp/Runtime.h +++ b/test-app/runtime/src/main/cpp/Runtime.h @@ -172,7 +172,7 @@ class Runtime { void RunModule(const char *moduleName); void RunWorker(const std::string& filePath); jobject RunScript(JNIEnv* _env, jobject obj, jstring scriptFile); - jobject CallJSMethodNative(JNIEnv* _env, jobject obj, jint javaObjectID, jstring methodName, jint retType, jboolean isConstructor, const JavaCallArgs& args, jlongArray primitiveResult); + jobject CallJSMethodNative(JNIEnv* _env, jobject obj, jobject javaObject, jint javaObjectID, jstring methodName, jint retType, jboolean isConstructor, const JavaCallArgs& args, jlongArray primitiveResult); void CreateJSInstanceNative(JNIEnv* _env, jobject obj, jobject javaObject, jint javaObjectID, jstring className); jint GenerateNewObjectId(JNIEnv* env, jobject obj); void AdjustAmountOfExternalAllocatedMemory(); diff --git a/test-app/runtime/src/main/cpp/com_tns_Runtime.cpp b/test-app/runtime/src/main/cpp/com_tns_Runtime.cpp index 82ac48fcf..8e044f6e1 100644 --- a/test-app/runtime/src/main/cpp/com_tns_Runtime.cpp +++ b/test-app/runtime/src/main/cpp/com_tns_Runtime.cpp @@ -194,7 +194,7 @@ extern "C" JNIEXPORT jobject Java_com_tns_Runtime_runScript(JNIEnv* _env, jobjec return result; } -extern "C" JNIEXPORT jobject Java_com_tns_Runtime_callJSMethodNative(JNIEnv* _env, jobject obj, jint runtimeId, jint javaObjectID, jstring methodName, jint retType, jboolean isConstructor, jintArray argTypes, jlongArray argValues, jobjectArray argRefs, jlongArray primitiveResult) { +extern "C" JNIEXPORT jobject Java_com_tns_Runtime_callJSMethodNative(JNIEnv* _env, jobject obj, jint runtimeId, jobject javaObject, jint javaObjectID, jstring methodName, jint retType, jboolean isConstructor, jintArray argTypes, jlongArray argValues, jobjectArray argRefs, jlongArray primitiveResult) { JEnv::EntryScope envScope(_env); jobject result = nullptr; @@ -211,7 +211,7 @@ extern "C" JNIEXPORT jobject Java_com_tns_Runtime_callJSMethodNative(JNIEnv* _en v8::Context::Scope context_scope(context); try { - result = runtime->CallJSMethodNative(_env, obj, javaObjectID, methodName, retType, isConstructor, JavaCallArgs{argTypes, argValues, argRefs}, primitiveResult); + result = runtime->CallJSMethodNative(_env, obj, javaObject, javaObjectID, methodName, retType, isConstructor, JavaCallArgs{argTypes, argValues, argRefs}, primitiveResult); } catch (NativeScriptException& e) { e.ReThrowToJava(); } catch (std::exception e) { diff --git a/test-app/runtime/src/main/java/com/tns/NativeScriptAbstractMap.java b/test-app/runtime/src/main/java/com/tns/NativeScriptAbstractMap.java deleted file mode 100644 index 3de04826f..000000000 --- a/test-app/runtime/src/main/java/com/tns/NativeScriptAbstractMap.java +++ /dev/null @@ -1,515 +0,0 @@ -package com.tns; - -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import java.io.Serializable; -import java.util.AbstractCollection; -import java.util.AbstractSet; -import java.util.Collection; -import java.util.Iterator; -import java.util.Map; -import java.util.Set; - -/** - * A base class for {@code Map} implementations. - * - *

- * Subclasses that permit new mappings to be added must override {@link #put}. - * - *

- * The default implementations of many methods are inefficient for large maps. For example in the default implementation, each call to {@link #get} performs a linear iteration of the entry set. Subclasses should override such methods to improve their performance. - * - * @since 1.2 - */ -public abstract class NativeScriptAbstractMap implements Map { - // Lazily-initialized key set (for implementing {@link #keySet}). - Set keySet; - - // Lazily-initialized values collection (for implementing {@link #values}). - Collection valuesCollection; - - /** - * An immutable key-value mapping. Despite the name, this class is non-final - * and its subclasses may be mutable. - * - * @since 1.6 - */ - public static class SimpleImmutableEntry implements Map.Entry, Serializable { - private static final long serialVersionUID = 7138329143949025153L; - - private final K key; - private final V value; - - public SimpleImmutableEntry(K theKey, V theValue) { - key = theKey; - value = theValue; - } - - /** - * Constructs an instance with the key and value of {@code copyFrom}. - */ - public SimpleImmutableEntry(Map.Entry copyFrom) { - key = copyFrom.getKey(); - value = copyFrom.getValue(); - } - - public K getKey() { - return key; - } - - public V getValue() { - return value; - } - - /** - * This base implementation throws {@code UnsupportedOperationException} always. - */ - public V setValue(V object) { - throw new UnsupportedOperationException(); - } - - @Override - public boolean equals(Object object) { - if (this == object) { - return true; - } - if (object instanceof Map.Entry) { - Map.Entry entry = (Map.Entry) object; - return (key == null ? entry.getKey() == null : key.equals(entry.getKey())) && (value == null ? entry.getValue() == null : value.equals(entry.getValue())); - } - return false; - } - - @Override - public int hashCode() { - return (key == null ? 0 : key.hashCode()) ^ (value == null ? 0 : value.hashCode()); - } - - @Override - public String toString() { - return key + "=" + value; - } - } - - /** - * A key-value mapping with mutable values. - * - * @since 1.6 - */ - public static class SimpleEntry implements Map.Entry, Serializable { - private static final long serialVersionUID = -8499721149061103585L; - - private final K key; - private V value; - - public SimpleEntry(K theKey, V theValue) { - key = theKey; - value = theValue; - } - - /** - * Constructs an instance with the key and value of {@code copyFrom}. - */ - public SimpleEntry(Map.Entry copyFrom) { - key = copyFrom.getKey(); - value = copyFrom.getValue(); - } - - public K getKey() { - return key; - } - - public V getValue() { - return value; - } - - public V setValue(V object) { - V result = value; - value = object; - return result; - } - - @Override - public boolean equals(Object object) { - if (this == object) { - return true; - } - if (object instanceof Map.Entry) { - Map.Entry entry = (Map.Entry) object; - return (key == null ? entry.getKey() == null : key.equals(entry.getKey())) && (value == null ? entry.getValue() == null : value.equals(entry.getValue())); - } - return false; - } - - @Override - public int hashCode() { - return (key == null ? 0 : key.hashCode()) ^ (value == null ? 0 : value.hashCode()); - } - - @Override - public String toString() { - return key + "=" + value; - } - } - - protected NativeScriptAbstractMap() { - } - - /** - * {@inheritDoc} - * - *

- * This implementation calls {@code entrySet().clear()}. - */ - public void clear() { - entrySet().clear(); - } - - /** - * {@inheritDoc} - * - *

- * This implementation iterates its key set, looking for a key that {@code key} equals. - */ - public boolean containsKey(Object key) { - Iterator> it = entrySet().iterator(); - if (key != null) { - while (it.hasNext()) { - if (key.equals(it.next().getKey())) { - return true; - } - } - } else { - while (it.hasNext()) { - if (it.next().getKey() == null) { - return true; - } - } - } - return false; - } - - /** - * {@inheritDoc} - * - *

- * This implementation iterates its entry set, looking for an entry with a value that {@code value} equals. - */ - public boolean containsValue(Object value) { - Iterator> it = entrySet().iterator(); - if (value != null) { - while (it.hasNext()) { - if (value.equals(it.next().getValue())) { - return true; - } - } - } else { - while (it.hasNext()) { - if (it.next().getValue() == null) { - return true; - } - } - } - return false; - } - - public abstract Set> entrySet(); - - /** - * {@inheritDoc} - * - *

- * This implementation first checks the structure of {@code object}. If it is not a map or of a different size, this returns false. Otherwise it iterates its own entry set, looking up each entry's key in {@code object} . If any value does not equal the other map's value for the same key, this returns false. Otherwise it returns true. - */ - @Override - public boolean equals(Object object) { - if (this == object) { - return true; - } - if (object instanceof Map) { - Map map = (Map) object; - if (size() != map.size()) { - return false; - } - - try { - for (Entry entry : entrySet()) { - K key = entry.getKey(); - V mine = entry.getValue(); - Object theirs = map.get(key); - if (mine == null) { - if (theirs != null || !map.containsKey(key)) { - return false; - } - } else if (!mine.equals(theirs)) { - return false; - } - } - } catch (NullPointerException ignored) { - return false; - } catch (ClassCastException ignored) { - return false; - } - return true; - } - return false; - } - - /** - * {@inheritDoc} - * - *

- * This implementation iterates its entry set, looking for an entry with a key that {@code key} equals. - */ - public V get(Object key) { - Iterator> it = entrySet().iterator(); - if (key != null) { - while (it.hasNext()) { - Map.Entry entry = it.next(); - if (key.equals(entry.getKey())) { - return entry.getValue(); - } - } - } else { - while (it.hasNext()) { - Map.Entry entry = it.next(); - if (entry.getKey() == null) { - return entry.getValue(); - } - } - } - return null; - } - - /** - * {@inheritDoc} - * - *

- * This implementation iterates its entry set, summing the hashcodes of its entries. - */ - @Override - public int hashCode() { - int result = 0; - Iterator> it = entrySet().iterator(); - while (it.hasNext()) { - result += it.next().hashCode(); - } - return result; - } - - /** - * {@inheritDoc} - * - *

- * This implementation compares {@code size()} to 0. - */ - public boolean isEmpty() { - return size() == 0; - } - - /** - * {@inheritDoc} - * - *

- * This implementation returns a view that calls through this to map. Its iterator transforms this map's entry set iterator to return keys. - */ - public Set keySet() { - if (keySet == null) { - keySet = new AbstractSet() { - @Override - public boolean contains(Object object) { - return containsKey(object); - } - - @Override - public int size() { - return NativeScriptAbstractMap.this.size(); - } - - @Override - public Iterator iterator() { - return new Iterator() { - Iterator> setIterator = entrySet().iterator(); - - public boolean hasNext() { - return setIterator.hasNext(); - } - - public K next() { - return setIterator.next().getKey(); - } - - public void remove() { - setIterator.remove(); - } - }; - } - }; - } - return keySet; - } - - /** - * {@inheritDoc} - * - *

- * This base implementation throws {@code UnsupportedOperationException}. - */ - public V put(K key, V value) { - throw new UnsupportedOperationException(); - } - - /** - * {@inheritDoc} - * - *

- * This implementation iterates through {@code map}'s entry set, calling {@code put()} for each. - */ - public void putAll(Map map) { - for (Map.Entry entry : map.entrySet()) { - put(entry.getKey(), entry.getValue()); - } - } - - /** - * {@inheritDoc} - * - *

- * This implementation iterates its entry set, removing the entry with a key that {@code key} equals. - */ - public V remove(Object key) { - Iterator> it = entrySet().iterator(); - if (key != null) { - while (it.hasNext()) { - Map.Entry entry = it.next(); - if (key.equals(entry.getKey())) { - it.remove(); - return entry.getValue(); - } - } - } else { - while (it.hasNext()) { - Map.Entry entry = it.next(); - if (entry.getKey() == null) { - it.remove(); - return entry.getValue(); - } - } - } - return null; - } - - /** - * {@inheritDoc} - * - *

- * This implementation returns its entry set's size. - */ - public int size() { - return entrySet().size(); - } - - /** - * {@inheritDoc} - * - *

- * This implementation composes a string by iterating its entry set. If this map contains itself as a key or a value, the string "(this Map)" will appear in its place. - */ - @Override - public String toString() { - if (isEmpty()) { - return "{}"; - } - - StringBuilder buffer = new StringBuilder(size() * 28); - buffer.append('{'); - Iterator> it = entrySet().iterator(); - while (it.hasNext()) { - Map.Entry entry = it.next(); - Object key = entry.getKey(); - if (key != this) { - buffer.append(key); - } else { - buffer.append("(this Map)"); - } - buffer.append('='); - Object value = entry.getValue(); - if (value != this) { - buffer.append(value); - } else { - buffer.append("(this Map)"); - } - if (it.hasNext()) { - buffer.append(", "); - } - } - buffer.append('}'); - return buffer.toString(); - } - - /** - * {@inheritDoc} - * - *

- * This implementation returns a view that calls through this to map. Its iterator transforms this map's entry set iterator to return values. - */ - public Collection values() { - if (valuesCollection == null) { - valuesCollection = new AbstractCollection() { - @Override - public int size() { - return NativeScriptAbstractMap.this.size(); - } - - @Override - public boolean contains(Object object) { - return containsValue(object); - } - - @Override - public Iterator iterator() { - return new Iterator() { - Iterator> setIterator = entrySet().iterator(); - - public boolean hasNext() { - return setIterator.hasNext(); - } - - public V next() { - return setIterator.next().getValue(); - } - - public void remove() { - setIterator.remove(); - } - }; - } - }; - } - return valuesCollection; - } - - @SuppressWarnings("unchecked") - @Override - protected Object clone() throws CloneNotSupportedException { - NativeScriptAbstractMap result = (NativeScriptAbstractMap) super.clone(); - result.keySet = null; - result.valuesCollection = null; - return result; - } -} diff --git a/test-app/runtime/src/main/java/com/tns/NativeScriptHashMap.java b/test-app/runtime/src/main/java/com/tns/NativeScriptHashMap.java deleted file mode 100644 index 488eda931..000000000 --- a/test-app/runtime/src/main/java/com/tns/NativeScriptHashMap.java +++ /dev/null @@ -1,1056 +0,0 @@ -package com.tns; - -import java.util.AbstractCollection; -import java.util.AbstractMap; -import java.util.AbstractSet; -import java.util.Arrays; -import java.util.Collection; -import java.util.ConcurrentModificationException; -import java.util.Iterator; -import java.util.Map; -import java.util.NoSuchElementException; -import java.util.Set; - -import java.io.IOException; -import java.io.InvalidObjectException; -import java.io.ObjectInputStream; -import java.io.ObjectOutputStream; -import java.io.ObjectStreamField; -import java.io.Serializable; - -//import libcore.util.Objects; - -/** - * HashMap is an implementation of {@link Map}. All optional operations are - * supported. - * - *

- * All elements are permitted as keys or values, including null. - * - *

- * Note that the iteration order for HashMap is non-deterministic. If you want deterministic iteration, use {@link LinkedHashMap}. - * - *

- * Note: the implementation of {@code HashMap} is not synchronized. If one thread of several threads accessing an instance modifies the map structurally, access to the map needs to be synchronized. A structural modification is an operation that adds or removes an entry. Changes in the value of an entry are not structural changes. - * - *

- * The {@code Iterator} created by calling the {@code iterator} method may throw a {@code ConcurrentModificationException} if the map is structurally changed while an iterator is used to iterate over the elements. Only the {@code remove} method that is provided by the iterator allows for removal of elements during iteration. It is not possible to guarantee that this mechanism works in all cases of unsynchronized concurrent modification. It should only be used for debugging purposes. - * - * @param the type of keys maintained by this map - * @param the type of mapped values - */ - -public class NativeScriptHashMap extends AbstractMap implements Cloneable, Serializable { - /** - * Min capacity (other than zero) for a HashMap. Must be a power of two - * greater than 1 (and less than 1 << 30). - */ - private static final int MINIMUM_CAPACITY = 4; - - /** - * Max capacity for a HashMap. Must be a power of two >= MINIMUM_CAPACITY. - */ - private static final int MAXIMUM_CAPACITY = 1 << 30; - - /** - * An empty table shared by all zero-capacity maps (typically from default - * constructor). It is never written to, and replaced on first put. Its size - * is set to half the minimum, so that the first resize will create a - * minimum-sized table. - */ - @SuppressWarnings("rawtypes") - private static final Entry[] EMPTY_TABLE = new HashMapEntry[MINIMUM_CAPACITY >>> 1]; - - /** - * The default load factor. Note that this implementation ignores the load - * factor, but cannot do away with it entirely because it's mentioned in the - * API. - * - *

- * Note that this constant has no impact on the behavior of the program, but it is emitted as part of the serialized form. The load factor of .75 is hardwired into the program, which uses cheap shifts in place of expensive division. - */ - static final float DEFAULT_LOAD_FACTOR = .75F; - - /** - * The hash table. If this hash map contains a mapping for null, it is not - * represented this hash table. - */ - transient HashMapEntry[] table; - - /** - * The entry representing the null key, or null if there's no such mapping. - */ - transient HashMapEntry entryForNullKey; - - /** - * The number of mappings in this hash map. - */ - transient int size; - - /** - * Incremented by "structural modifications" to allow (best effort) - * detection of concurrent modification. - */ - transient int modCount; - - /** - * The table is rehashed when its size exceeds this threshold. The value of - * this field is generally .75 * capacity, except when the capacity is zero, - * as described in the EMPTY_TABLE declaration above. - */ - private transient int threshold; - - // Views - lazily initialized - private transient Set keySet; - private transient Set> entrySet; - private transient Collection values; - - /** - * Constructs a new empty {@code HashMap} instance. - */ - @SuppressWarnings("unchecked") - public NativeScriptHashMap() { - table = (HashMapEntry[]) EMPTY_TABLE; - threshold = -1; // Forces first put invocation to replace EMPTY_TABLE - } - - /** - * Constructs a new {@code HashMap} instance with the specified capacity. - * - * @param capacity the initial capacity of this hash map. - * @throws IllegalArgumentException when the capacity is less than zero. - */ - public NativeScriptHashMap(int capacity) { - if (capacity < 0) { - throw new IllegalArgumentException("Capacity: " + capacity); - } - - if (capacity == 0) { - @SuppressWarnings("unchecked") - HashMapEntry[] tab = (HashMapEntry[]) EMPTY_TABLE; - table = tab; - threshold = -1; // Forces first put() to replace EMPTY_TABLE - return; - } - - if (capacity < MINIMUM_CAPACITY) { - capacity = MINIMUM_CAPACITY; - } else if (capacity > MAXIMUM_CAPACITY) { - capacity = MAXIMUM_CAPACITY; - } else { - capacity = roundUpToPowerOfTwo(capacity); - } - makeTable(capacity); - } - - /** - * Constructs a new {@code HashMap} instance with the specified capacity and - * load factor. - * - * @param capacity the initial capacity of this hash map. - * @param loadFactor the initial load factor. - * @throws IllegalArgumentException when the capacity is less than zero or the load factor is - * less or equal to zero or NaN. - */ - public NativeScriptHashMap(int capacity, float loadFactor) { - this(capacity); - - if (loadFactor <= 0 || Float.isNaN(loadFactor)) { - throw new IllegalArgumentException("Load factor: " + loadFactor); - } - - /* - * Note that this implementation ignores loadFactor; it always uses a - * load factor of 3/4. This simplifies the code and generally improves - * performance. - */ - } - - /** - * Constructs a new {@code HashMap} instance containing the mappings from - * the specified map. - * - * @param map the mappings to add. - */ - public NativeScriptHashMap(Map map) { - this(capacityForInitSize(map.size())); - constructorPutAll(map); - } - - /** - * Inserts all of the elements of map into this HashMap in a manner suitable - * for use by constructors and pseudo-constructors (i.e., clone, - * readObject). Also used by LinkedHashMap. - */ - final void constructorPutAll(Map map) { - if (table == EMPTY_TABLE) { - doubleCapacity(); // Don't do unchecked puts to a shared table. - } - for (Entry e : map.entrySet()) { - constructorPut(e.getKey(), e.getValue()); - } - } - - /** - * Returns an appropriate capacity for the specified initial size. Does not - * round the result up to a power of two; the caller must do this! The - * returned value will be between 0 and MAXIMUM_CAPACITY (inclusive). - */ - static int capacityForInitSize(int size) { - int result = (size >> 1) + size; // Multiply by 3/2 to allow for growth - - // boolean expr is equivalent to result >= 0 && result result; - try { - result = (NativeScriptHashMap) super.clone(); - } catch (CloneNotSupportedException e) { - throw new AssertionError(e); - } - - // Restore clone to empty state, retaining our capacity and threshold - result.makeTable(table.length); - result.entryForNullKey = null; - result.size = 0; - result.keySet = null; - result.entrySet = null; - result.values = null; - - result.init(); // Give subclass a chance to initialize itself - result.constructorPutAll(this); // Calls method overridden in subclass!! - return result; - } - - /** - * This method is called from the pseudo-constructors (clone and readObject) - * prior to invoking constructorPut/constructorPutAll, which invoke the - * overridden constructorNewEntry method. Normally it is a VERY bad idea to - * invoke an overridden method from a pseudo-constructor (Effective Java - * Item 17). In this case it is unavoidable, and the init method provides a - * workaround. - */ - void init() { - } - - /** - * Returns whether this map is empty. - * - * @return {@code true} if this map has no elements, {@code false} otherwise. - * @see #size() - */ - @Override - public boolean isEmpty() { - return size == 0; - } - - /** - * Returns the number of elements in this map. - * - * @return the number of elements in this map. - */ - @Override - public int size() { - return size; - } - - /** - * Returns the value of the mapping with the specified key. - * - * @param key the key. - * @return the value of the mapping with the specified key, or {@code null} if no mapping for the specified key is found. - */ - public V get(Object key) { - if (key == null) { - HashMapEntry e = entryForNullKey; - return e == null ? null : e.value; - } - - int hash = secondaryHashForObject(key); - HashMapEntry[] tab = table; - for (HashMapEntry e = tab[hash & (tab.length - 1)]; e != null; e = e.next) { - K eKey = e.key; - if (eKey == key) { - return e.value; - } - } - return null; - } - - /** - * Returns whether this map contains the specified key. - * - * @param key the key to search for. - * @return {@code true} if this map contains the specified key, {@code false} otherwise. - */ - @Override - public boolean containsKey(Object key) { - if (key == null) { - return entryForNullKey != null; - } - - int hash = secondaryHashForObject(key); - HashMapEntry[] tab = table; - for (HashMapEntry e = tab[hash & (tab.length - 1)]; e != null; e = e.next) { - K eKey = e.key; - if (eKey == key) { - return true; - } - } - return false; - } - - /** - * Returns whether this map contains the specified value. - * - * @param value the value to search for. - * @return {@code true} if this map contains the specified value, {@code false} otherwise. - */ - @Override - public boolean containsValue(Object value) { - HashMapEntry[] tab = table; - int len = tab.length; - if (value == null) { - for (int i = 0; i < len; i++) { - for (HashMapEntry e = tab[i]; e != null; e = e.next) { - if (e.value == null) { - return true; - } - } - } - return entryForNullKey != null && entryForNullKey.value == null; - } - - // value is non-null - for (int i = 0; i < len; i++) { - for (HashMapEntry e = tab[i]; e != null; e = e.next) { - if (value == e.value) { - return true; - } - } - } - return entryForNullKey != null && value.equals(entryForNullKey.value); - } - - /** - * Maps the specified key to the specified value. - * - * @param key the key. - * @param value the value. - * @return the value of any previous mapping with the specified key or {@code null} if there was no such mapping. - */ - @Override - public V put(K key, V value) { - if (key == null) { - return putValueForNullKey(value); - } - - int hash = secondaryHashForObject(key); - HashMapEntry[] tab = table; - int index = hash & (tab.length - 1); - for (HashMapEntry e = tab[index]; e != null; e = e.next) { - K eKey = e.key; - if (eKey == key) { - preModify(e); - V oldValue = e.value; - e.value = value; - return oldValue; - } - } - - // No entry for (non-null) key is present; create one - modCount++; - if (size++ > threshold) { - tab = doubleCapacity(); - index = hash & (tab.length - 1); - } - addNewEntry(key, value, hash, index); - return null; - } - - private V putValueForNullKey(V value) { - HashMapEntry entry = entryForNullKey; - if (entry == null) { - addNewEntryForNullKey(value); - size++; - modCount++; - return null; - } else { - preModify(entry); - V oldValue = entry.value; - entry.value = value; - return oldValue; - } - } - - /** - * Give LinkedHashMap a chance to take action when we modify an existing - * entry. - * - * @param e the entry we're about to modify. - */ - void preModify(HashMapEntry e) { - } - - /** - * This method is just like put, except that it doesn't do things that are - * inappropriate or unnecessary for constructors and pseudo-constructors - * (i.e., clone, readObject). In particular, this method does not check to - * ensure that capacity is sufficient, and does not increment modCount. - */ - private void constructorPut(K key, V value) { - if (key == null) { - HashMapEntry entry = entryForNullKey; - if (entry == null) { - entryForNullKey = constructorNewEntry(null, value, 0, null); - size++; - } else { - entry.value = value; - } - return; - } - - int hash = secondaryHashForObject(key); - HashMapEntry[] tab = table; - int index = hash & (tab.length - 1); - HashMapEntry first = tab[index]; - for (HashMapEntry e = first; e != null; e = e.next) { - if (key == e.key) { - e.value = value; - return; - } - } - - // No entry for (non-null) key is present; create one - tab[index] = constructorNewEntry(key, value, hash, first); - size++; - } - - /** - * Creates a new entry for the given key, value, hash, and index and inserts - * it into the hash table. This method is called by put (and indirectly, - * putAll), and overridden by LinkedHashMap. The hash must incorporate the - * secondary hash function. - */ - void addNewEntry(K key, V value, int hash, int index) { - table[index] = new HashMapEntry(key, value, hash, table[index]); - } - - /** - * Creates a new entry for the null key, and the given value and inserts it - * into the hash table. This method is called by put (and indirectly, - * putAll), and overridden by LinkedHashMap. - */ - void addNewEntryForNullKey(V value) { - entryForNullKey = new HashMapEntry(null, value, 0, null); - } - - /** - * Like newEntry, but does not perform any activity that would be - * unnecessary or inappropriate for constructors. In this class, the two - * methods behave identically; in LinkedHashMap, they differ. - */ - HashMapEntry constructorNewEntry(K key, V value, int hash, HashMapEntry first) { - return new HashMapEntry(key, value, hash, first); - } - - /** - * Copies all the mappings in the specified map to this map. These mappings - * will replace all mappings that this map had for any of the keys currently - * in the given map. - * - * @param map the map to copy mappings from. - */ - @Override - public void putAll(Map map) { - ensureCapacity(map.size()); - super.putAll(map); - } - - /** - * Ensures that the hash table has sufficient capacity to store the - * specified number of mappings, with room to grow. If not, it increases the - * capacity as appropriate. Like doubleCapacity, this method moves existing - * entries to new buckets as appropriate. Unlike doubleCapacity, this method - * can grow the table by factors of 2^n for n > 1. Hopefully, a single call - * to this method will be faster than multiple calls to doubleCapacity. - * - *

- * This method is called only by putAll. - */ - private void ensureCapacity(int numMappings) { - int newCapacity = roundUpToPowerOfTwo(capacityForInitSize(numMappings)); - HashMapEntry[] oldTable = table; - int oldCapacity = oldTable.length; - if (newCapacity <= oldCapacity) { - return; - } - if (newCapacity == oldCapacity * 2) { - doubleCapacity(); - return; - } - - // We're growing by at least 4x, rehash in the obvious way - HashMapEntry[] newTable = makeTable(newCapacity); - if (size != 0) { - int newMask = newCapacity - 1; - for (int i = 0; i < oldCapacity; i++) { - for (HashMapEntry e = oldTable[i]; e != null; ) { - HashMapEntry oldNext = e.next; - int newIndex = e.hash & newMask; - HashMapEntry newNext = newTable[newIndex]; - newTable[newIndex] = e; - e.next = newNext; - e = oldNext; - } - } - } - } - - /** - * Allocate a table of the given capacity and set the threshold accordingly. - * - * @param newCapacity must be a power of two - */ - private HashMapEntry[] makeTable(int newCapacity) { - @SuppressWarnings({"unchecked", "rawtypes"}) - HashMapEntry[] newTable = (HashMapEntry[]) new HashMapEntry[newCapacity]; - table = newTable; - threshold = (newCapacity >> 1) + (newCapacity >> 2); // 3/4 capacity - return newTable; - } - - /** - * Doubles the capacity of the hash table. Existing entries are placed in - * the correct bucket on the enlarged table. If the current capacity is, - * MAXIMUM_CAPACITY, this method is a no-op. Returns the table, which will - * be new unless we were already at MAXIMUM_CAPACITY. - */ - private HashMapEntry[] doubleCapacity() { - HashMapEntry[] oldTable = table; - int oldCapacity = oldTable.length; - if (oldCapacity == MAXIMUM_CAPACITY) { - return oldTable; - } - int newCapacity = oldCapacity * 2; - HashMapEntry[] newTable = makeTable(newCapacity); - if (size == 0) { - return newTable; - } - - for (int j = 0; j < oldCapacity; j++) { - /* - * Rehash the bucket using the minimum number of field writes. This - * is the most subtle and delicate code in the class. - */ - HashMapEntry e = oldTable[j]; - if (e == null) { - continue; - } - int highBit = e.hash & oldCapacity; - HashMapEntry broken = null; - newTable[j | highBit] = e; - for (HashMapEntry n = e.next; n != null; e = n, n = n.next) { - int nextHighBit = n.hash & oldCapacity; - if (nextHighBit != highBit) { - if (broken == null) { - newTable[j | nextHighBit] = n; - } else { - broken.next = n; - } - broken = e; - highBit = nextHighBit; - } - } - if (broken != null) { - broken.next = null; - } - } - return newTable; - } - - /** - * Removes the mapping with the specified key from this map. - * - * @param key the key of the mapping to remove. - * @return the value of the removed mapping or {@code null} if no mapping - * for the specified key was found. - */ - @Override - public V remove(Object key) { - if (key == null) { - return removeNullKey(); - } - int hash = secondaryHashForObject(key); - HashMapEntry[] tab = table; - int index = hash & (tab.length - 1); - for (HashMapEntry e = tab[index], prev = null; e != null; prev = e, e = e.next) { - K eKey = e.key; - if (eKey == key) { - if (prev == null) { - tab[index] = e.next; - } else { - prev.next = e.next; - } - modCount++; - size--; - postRemove(e); - return e.value; - } - } - return null; - } - - private V removeNullKey() { - HashMapEntry e = entryForNullKey; - if (e == null) { - return null; - } - entryForNullKey = null; - modCount++; - size--; - postRemove(e); - return e.value; - } - - /** - * Subclass overrides this method to unlink entry. - */ - void postRemove(HashMapEntry e) { - } - - /** - * Removes all mappings from this hash map, leaving it empty. - * - * @see #isEmpty - * @see #size - */ - @Override - public void clear() { - if (size != 0) { - Arrays.fill(table, null); - entryForNullKey = null; - modCount++; - size = 0; - } - } - - /** - * Returns a set of the keys contained in this map. The set is backed by - * this map so changes to one are reflected by the other. The set does not - * support adding. - * - * @return a set of the keys. - */ - @Override - public Set keySet() { - Set ks = keySet; - return (ks != null) ? ks : (keySet = new KeySet()); - } - - /** - * Returns a collection of the values contained in this map. The collection - * is backed by this map so changes to one are reflected by the other. The - * collection supports remove, removeAll, retainAll and clear operations, - * and it does not support add or addAll operations. - *

- * This method returns a collection which is the subclass of AbstractCollection. The iterator method of this subclass returns a "wrapper object" over the iterator of map's entrySet(). The {@code size} method wraps the map's size method and the {@code contains} method wraps the map's containsValue method. - *

- *

- * The collection is created when this method is called for the first time and returned in response to all subsequent calls. This method may return different collections when multiple concurrent calls occur, since no synchronization is performed. - *

- * - * @return a collection of the values contained in this map. - */ - @Override - public Collection values() { - Collection vs = values; - return (vs != null) ? vs : (values = new Values()); - } - - /** - * Returns a set containing all of the mappings in this map. Each mapping is - * an instance of {@link Map.Entry}. As the set is backed by this map, - * changes in one will be reflected in the other. - * - * @return a set of the mappings. - */ - public Set> entrySet() { - Set> es = entrySet; - return (es != null) ? es : (entrySet = new EntrySet()); - } - - static class HashMapEntry implements Entry { - final K key; - V value; - final int hash; - HashMapEntry next; - - HashMapEntry(K key, V value, int hash, HashMapEntry next) { - this.key = key; - this.value = value; - this.hash = hash; - this.next = next; - } - - public final K getKey() { - return key; - } - - public final V getValue() { - return value; - } - - public final V setValue(V value) { - V oldValue = this.value; - this.value = value; - return oldValue; - } - - @Override - public final boolean equals(Object o) { - if (!(o instanceof Entry)) { - return false; - } - Entry e = (Entry) o; - return key == e.getKey() && value == e.getValue(); - } - - @Override - public final int hashCode() { - return hash ^ (value == null ? 0 : value.hashCode()); - } - - @Override - public final String toString() { - return key + "=" + value; - } - } - - private abstract class HashIterator { - int nextIndex; - HashMapEntry nextEntry = entryForNullKey; - HashMapEntry lastEntryReturned; - int expectedModCount = modCount; - - HashIterator() { - if (nextEntry == null) { - HashMapEntry[] tab = table; - HashMapEntry next = null; - while (next == null && nextIndex < tab.length) { - next = tab[nextIndex++]; - } - nextEntry = next; - } - } - - public boolean hasNext() { - return nextEntry != null; - } - - HashMapEntry nextEntry() { - if (modCount != expectedModCount) { - throw new ConcurrentModificationException(); - } - if (nextEntry == null) { - throw new NoSuchElementException(); - } - - HashMapEntry entryToReturn = nextEntry; - HashMapEntry[] tab = table; - HashMapEntry next = entryToReturn.next; - while (next == null && nextIndex < tab.length) { - next = tab[nextIndex++]; - } - nextEntry = next; - return lastEntryReturned = entryToReturn; - } - - public void remove() { - if (lastEntryReturned == null) { - throw new IllegalStateException(); - } - if (modCount != expectedModCount) { - throw new ConcurrentModificationException(); - } - NativeScriptHashMap.this.remove(lastEntryReturned.key); - lastEntryReturned = null; - expectedModCount = modCount; - } - } - - private final class KeyIterator extends HashIterator implements Iterator { - public K next() { - return nextEntry().key; - } - } - - private final class ValueIterator extends HashIterator implements Iterator { - public V next() { - return nextEntry().value; - } - } - - private final class EntryIterator extends HashIterator implements Iterator> { - public Entry next() { - return nextEntry(); - } - } - - /** - * Returns true if this map contains the specified mapping. - */ - private boolean containsMapping(Object key, Object value) { - if (key == null) { - HashMapEntry e = entryForNullKey; - return e != null && Objects.equal(value, e.value); - } - - int hash = secondaryHashForObject(key); - HashMapEntry[] tab = table; - int index = hash & (tab.length - 1); - for (HashMapEntry e = tab[index]; e != null; e = e.next) { - if (key == e.key) { - return Objects.equal(value, e.value); - } - } - return false; // No entry for key - } - - /** - * Removes the mapping from key to value and returns true if this mapping - * exists; otherwise, returns does nothing and returns false. - */ - private boolean removeMapping(Object key, Object value) { - if (key == null) { - HashMapEntry e = entryForNullKey; - if (e == null || !Objects.equal(value, e.value)) { - return false; - } - entryForNullKey = null; - modCount++; - size--; - postRemove(e); - return true; - } - - int hash = secondaryHashForObject(key); - HashMapEntry[] tab = table; - int index = hash & (tab.length - 1); - for (HashMapEntry e = tab[index], prev = null; e != null; prev = e, e = e.next) { - if (key == e.key) { - if (!Objects.equal(value, e.value)) { - return false; // Map has wrong value for key - } - if (prev == null) { - tab[index] = e.next; - } else { - prev.next = e.next; - } - modCount++; - size--; - postRemove(e); - return true; - } - } - return false; // No entry for key - } - - // Subclass (LinkedHashMap) overrides these for correct iteration order - Iterator newKeyIterator() { - return new KeyIterator(); - } - - Iterator newValueIterator() { - return new ValueIterator(); - } - - Iterator> newEntryIterator() { - return new EntryIterator(); - } - - private final class KeySet extends AbstractSet { - public Iterator iterator() { - return newKeyIterator(); - } - - public int size() { - return size; - } - - public boolean isEmpty() { - return size == 0; - } - - public boolean contains(Object o) { - return containsKey(o); - } - - public boolean remove(Object o) { - int oldSize = size; - NativeScriptHashMap.this.remove(o); - return size != oldSize; - } - - public void clear() { - NativeScriptHashMap.this.clear(); - } - } - - private final class Values extends AbstractCollection { - public Iterator iterator() { - return newValueIterator(); - } - - public int size() { - return size; - } - - public boolean isEmpty() { - return size == 0; - } - - public boolean contains(Object o) { - return containsValue(o); - } - - public void clear() { - NativeScriptHashMap.this.clear(); - } - } - - private final class EntrySet extends AbstractSet> { - public Iterator> iterator() { - return newEntryIterator(); - } - - public boolean contains(Object o) { - if (!(o instanceof Entry)) { - return false; - } - Entry e = (Entry) o; - return containsMapping(e.getKey(), e.getValue()); - } - - public boolean remove(Object o) { - if (!(o instanceof Entry)) { - return false; - } - Entry e = (Entry) o; - return removeMapping(e.getKey(), e.getValue()); - } - - public int size() { - return size; - } - - public boolean isEmpty() { - return size == 0; - } - - public void clear() { - NativeScriptHashMap.this.clear(); - } - } - - private static final long serialVersionUID = 362498820763181265L; - - private static final ObjectStreamField[] serialPersistentFields = - {new ObjectStreamField("loadFactor", float.class)}; - - private void writeObject(ObjectOutputStream stream) throws IOException { - // Emulate loadFactor field for other implementations to read - ObjectOutputStream.PutField fields = stream.putFields(); - fields.put("loadFactor", DEFAULT_LOAD_FACTOR); - stream.writeFields(); - - stream.writeInt(table.length); // Capacity - stream.writeInt(size); - for (Entry e : entrySet()) { - stream.writeObject(e.getKey()); - stream.writeObject(e.getValue()); - } - } - - private void readObject(ObjectInputStream stream) throws IOException, ClassNotFoundException { - stream.defaultReadObject(); - int capacity = stream.readInt(); - if (capacity < 0) { - throw new InvalidObjectException("Capacity: " + capacity); - } - if (capacity < MINIMUM_CAPACITY) { - capacity = MINIMUM_CAPACITY; - } else if (capacity > MAXIMUM_CAPACITY) { - capacity = MAXIMUM_CAPACITY; - } else { - capacity = roundUpToPowerOfTwo(capacity); - } - makeTable(capacity); - - int size = stream.readInt(); - if (size < 0) { - throw new InvalidObjectException("Size: " + size); - } - - init(); // Give subclass (LinkedHashMap) a chance to initialize itself - for (int i = 0; i < size; i++) { - @SuppressWarnings("unchecked") - K key = (K) stream.readObject(); - @SuppressWarnings("unchecked") - V val = (V) stream.readObject(); - constructorPut(key, val); - } - } - - // ~~~~~~~~~~~~~~ - - private static int roundUpToPowerOfTwo(int i) { - i--; // If input is a power of two, shift its high-order bit right. - - // "Smear" the high-order bit all the way to the right. - i |= i >>> 1; - i |= i >>> 2; - i |= i >>> 4; - i |= i >>> 8; - i |= i >>> 16; - - return i + 1; - } - - private static int secondaryHashForObject(Object key) { - int intHash; - - if (key instanceof NativeScriptHashCodeProvider) { - NativeScriptHashCodeProvider provider = (NativeScriptHashCodeProvider) key; - intHash = provider.hashCode__super(); - } else { - intHash = System.identityHashCode(key); - } - - return secondaryHashForInt(intHash); - } - - private static int secondaryHashForInt(int h) { - // Spread bits to regularize both segment and index locations, - // using variant of single-word Wang/Jenkins hash. - h += (h << 15) ^ 0xffffcd7d; - h ^= (h >>> 10); - h += (h << 3); - h ^= (h >>> 6); - h += (h << 2) + (h << 14); - return h ^ (h >>> 16); - } -} \ No newline at end of file diff --git a/test-app/runtime/src/main/java/com/tns/NativeScriptWeakHashMap.java b/test-app/runtime/src/main/java/com/tns/NativeScriptWeakHashMap.java deleted file mode 100644 index b66d00fec..000000000 --- a/test-app/runtime/src/main/java/com/tns/NativeScriptWeakHashMap.java +++ /dev/null @@ -1,725 +0,0 @@ -package com.tns; - -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import java.lang.ref.ReferenceQueue; -import java.lang.ref.WeakReference; -import java.util.AbstractCollection; -import java.util.AbstractSet; -import java.util.Arrays; -import java.util.Collection; -import java.util.ConcurrentModificationException; -import java.util.Iterator; -import java.util.Map; -import java.util.NoSuchElementException; -import java.util.Set; - -/** - * WeakHashMap is an implementation of Map with keys which are WeakReferences. A - * key/value mapping is removed when the key is no longer referenced. All - * optional operations (adding and removing) are supported. Keys and values can - * be any objects. Note that the garbage collector acts similar to a second - * thread on this collection, possibly removing keys. - * - * @since 1.2 - * @see HashMap - * @see WeakReference - */ -public class NativeScriptWeakHashMap extends NativeScriptAbstractMap implements Map { - - private static final int DEFAULT_SIZE = 16; - - private final ReferenceQueue referenceQueue; - - int elementCount; - - Entry[] elementData; - - private final int loadFactor; - - private int threshold; - - volatile int modCount; - - // Simple utility method to isolate unchecked cast for array creation - @SuppressWarnings({"unchecked", "rawtypes"}) - private static Entry[] newEntryArray(int size) { - return new Entry[size]; - } - - private static final class Entry extends WeakReference implements Map.Entry { - final int hash; - - boolean isNull; - - V value; - - Entry next; - - interface Type { - RET get(Map.Entry entry); - } - - Entry(K key, V object, ReferenceQueue queue) { - super(key, queue); - isNull = key == null; - hash = isNull ? 0 : secondaryHashForObject(key); - value = object; - } - - public K getKey() { - return super.get(); - } - - public V getValue() { - return value; - } - - public V setValue(V object) { - V result = value; - value = object; - return result; - } - - @Override - public boolean equals(Object other) { - if (!(other instanceof Map.Entry)) { - return false; - } - Map.Entry entry = (Map.Entry) other; - Object key = super.get(); - return (key == null ? key == entry.getKey() : key == entry.getKey()) && (value == null ? value == entry.getValue() : value == entry.getValue()); - } - - @Override - public int hashCode() { - return hash + (value == null ? 0 : value.hashCode()); - } - - @Override - public String toString() { - return super.get() + "=" + value; - } - } - - class HashIterator implements Iterator { - private int position = 0, expectedModCount; - - private Entry currentEntry, nextEntry; - - private K nextKey; - - final Entry.Type type; - - HashIterator(Entry.Type type) { - this.type = type; - expectedModCount = modCount; - } - - public boolean hasNext() { - if (nextEntry != null && (nextKey != null || nextEntry.isNull)) { - return true; - } - while (true) { - if (nextEntry == null) { - while (position < elementData.length) { - if ((nextEntry = elementData[position++]) != null) { - break; - } - } - if (nextEntry == null) { - return false; - } - } - // ensure key of next entry is not gc'ed - nextKey = nextEntry.get(); - if (nextKey != null || nextEntry.isNull) { - return true; - } - nextEntry = nextEntry.next; - } - } - - public RET next() { - if (expectedModCount == modCount) { - if (hasNext()) { - currentEntry = nextEntry; - nextEntry = currentEntry.next; - RET result = type.get(currentEntry); - // free the key - nextKey = null; - return result; - } - throw new NoSuchElementException(); - } - throw new ConcurrentModificationException(); - } - - public void remove() { - if (expectedModCount == modCount) { - if (currentEntry != null) { - removeEntry(currentEntry); - currentEntry = null; - expectedModCount++; - // cannot poll() as that would change the expectedModCount - } else { - throw new IllegalStateException(); - } - } else { - throw new ConcurrentModificationException(); - } - } - } - - /** - * Constructs a new empty {@code WeakHashMap} instance. - */ - public NativeScriptWeakHashMap() { - this(DEFAULT_SIZE); - } - - /** - * Constructs a new {@code WeakHashMap} instance with the specified - * capacity. - * - * @param capacity - * the initial capacity of this map. - * @throws IllegalArgumentException - * if the capacity is less than zero. - */ - public NativeScriptWeakHashMap(int capacity) { - if (capacity < 0) { - throw new IllegalArgumentException("capacity < 0: " + capacity); - } - elementCount = 0; - elementData = newEntryArray(capacity == 0 ? 1 : capacity); - loadFactor = 7500; // Default load factor of 0.75 - computeMaxSize(); - referenceQueue = new ReferenceQueue(); - } - - /** - * Constructs a new {@code WeakHashMap} instance with the specified capacity - * and load factor. - * - * @param capacity - * the initial capacity of this map. - * @param loadFactor - * the initial load factor. - * @throws IllegalArgumentException - * if the capacity is less than zero or the load factor is less - * or equal to zero. - */ - public NativeScriptWeakHashMap(int capacity, float loadFactor) { - if (capacity < 0) { - throw new IllegalArgumentException("capacity < 0: " + capacity); - } - if (loadFactor <= 0) { - throw new IllegalArgumentException("loadFactor <= 0: " + loadFactor); - } - elementCount = 0; - elementData = newEntryArray(capacity == 0 ? 1 : capacity); - this.loadFactor = (int) (loadFactor * 10000); - computeMaxSize(); - referenceQueue = new ReferenceQueue(); - } - - /** - * Constructs a new {@code WeakHashMap} instance containing the mappings - * from the specified map. - * - * @param map - * the mappings to add. - */ - public NativeScriptWeakHashMap(Map map) { - this(map.size() < 6 ? 11 : map.size() * 2); - putAllImpl(map); - } - - /** - * Removes all mappings from this map, leaving it empty. - * - * @see #isEmpty() - * @see #size() - */ - @Override - public void clear() { - if (elementCount > 0) { - elementCount = 0; - Arrays.fill(elementData, null); - modCount++; - while (referenceQueue.poll() != null) { - // do nothing - } - } - } - - private void computeMaxSize() { - threshold = (int) ((long) elementData.length * loadFactor / 10000); - } - - /** - * Returns whether this map contains the specified key. - * - * @param key - * the key to search for. - * @return {@code true} if this map contains the specified key, {@code false} otherwise. - */ - @Override - public boolean containsKey(Object key) { - return getEntry(key) != null; - } - - /** - * Returns a set containing all of the mappings in this map. Each mapping is - * an instance of {@link Map.Entry}. As the set is backed by this map, - * changes in one will be reflected in the other. It does not support adding - * operations. - * - * @return a set of the mappings. - */ - @Override - public Set> entrySet() { - poll(); - return new AbstractSet>() { - @Override - public int size() { - return NativeScriptWeakHashMap.this.size(); - } - - @Override - public void clear() { - NativeScriptWeakHashMap.this.clear(); - } - - @Override - public boolean remove(Object object) { - if (contains(object)) { - NativeScriptWeakHashMap.this.remove(((Map.Entry) object).getKey()); - return true; - } - return false; - } - - @Override - public boolean contains(Object object) { - if (object instanceof Map.Entry) { - Entry entry = getEntry(((Map.Entry) object).getKey()); - if (entry != null) { - Object key = entry.get(); - if (key != null || entry.isNull) { - return object.equals(entry); - } - } - } - return false; - } - - @Override - public Iterator> iterator() { - return new HashIterator>(new Entry.Type, K, V>() { - public Map.Entry get(Map.Entry entry) { - return entry; - } - }); - } - }; - } - - /** - * Returns a set of the keys contained in this map. The set is backed by - * this map so changes to one are reflected by the other. The set does not - * support adding. - * - * @return a set of the keys. - */ - @Override - public Set keySet() { - poll(); - if (keySet == null) { - keySet = new AbstractSet() { - @Override - public boolean contains(Object object) { - return containsKey(object); - } - - @Override - public int size() { - return NativeScriptWeakHashMap.this.size(); - } - - @Override - public void clear() { - NativeScriptWeakHashMap.this.clear(); - } - - @Override - public boolean remove(Object key) { - if (containsKey(key)) { - NativeScriptWeakHashMap.this.remove(key); - return true; - } - return false; - } - - @Override - public Iterator iterator() { - return new HashIterator(new Entry.Type() { - public K get(Map.Entry entry) { - return entry.getKey(); - } - }); - } - }; - } - return keySet; - } - - /** - * Returns a collection of the values contained in this map. The collection - * is backed by this map so changes to one are reflected by the other. The - * collection supports remove, removeAll, retainAll and clear operations, - * and it does not support add or addAll operations. - *

- * This method returns a collection which is the subclass of AbstractCollection. The iterator method of this subclass returns a "wrapper object" over the iterator of map's entrySet(). The size method wraps the map's size method and the contains method wraps the map's containsValue method. - *

- * The collection is created when this method is called at first time and returned in response to all subsequent calls. This method may return different Collection when multiple calls to this method, since it has no synchronization performed. - * - * @return a collection of the values contained in this map. - */ - @Override - public Collection values() { - poll(); - if (valuesCollection == null) { - valuesCollection = new AbstractCollection() { - @Override - public int size() { - return NativeScriptWeakHashMap.this.size(); - } - - @Override - public void clear() { - NativeScriptWeakHashMap.this.clear(); - } - - @Override - public boolean contains(Object object) { - return containsValue(object); - } - - @Override - public Iterator iterator() { - return new HashIterator(new Entry.Type() { - public V get(Map.Entry entry) { - return entry.getValue(); - } - }); - } - }; - } - return valuesCollection; - } - - /** - * Returns the value of the mapping with the specified key. - * - * @param key - * the key. - * @return the value of the mapping with the specified key, or {@code null} if no mapping for the specified key is found. - */ - @Override - public V get(Object key) { - poll(); - if (key != null) { - int index = (secondaryHashForObject(key) & 0x7FFFFFFF) % elementData.length; - Entry entry = elementData[index]; - while (entry != null) { - if (key == entry.get()) { - return entry.value; - } - entry = entry.next; - } - return null; - } - Entry entry = elementData[0]; - while (entry != null) { - if (entry.isNull) { - return entry.value; - } - entry = entry.next; - } - return null; - } - - Entry getEntry(Object key) { - poll(); - if (key != null) { - int index = (secondaryHashForObject(key) & 0x7FFFFFFF) % elementData.length; - Entry entry = elementData[index]; - while (entry != null) { - if (key == entry.get()) { - return entry; - } - entry = entry.next; - } - return null; - } - Entry entry = elementData[0]; - while (entry != null) { - if (entry.isNull) { - return entry; - } - entry = entry.next; - } - return null; - } - - /** - * Returns whether this map contains the specified value. - * - * @param value - * the value to search for. - * @return {@code true} if this map contains the specified value, {@code false} otherwise. - */ - @Override - public boolean containsValue(Object value) { - poll(); - if (value != null) { - for (int i = elementData.length; --i >= 0;) { - Entry entry = elementData[i]; - while (entry != null) { - K key = entry.get(); - if ((key != null || entry.isNull) && value == entry.value) { - return true; - } - entry = entry.next; - } - } - } else { - for (int i = elementData.length; --i >= 0;) { - Entry entry = elementData[i]; - while (entry != null) { - K key = entry.get(); - if ((key != null || entry.isNull) && entry.value == null) { - return true; - } - entry = entry.next; - } - } - } - return false; - } - - /** - * Returns the number of elements in this map. - * - * @return the number of elements in this map. - */ - @Override - public boolean isEmpty() { - return size() == 0; - } - - @SuppressWarnings("unchecked") - void poll() { - Entry toRemove; - while ((toRemove = (Entry) referenceQueue.poll()) != null) { - removeEntry(toRemove); - } - } - - void removeEntry(Entry toRemove) { - Entry entry, last = null; - int index = (toRemove.hash & 0x7FFFFFFF) % elementData.length; - entry = elementData[index]; - // Ignore queued entries which cannot be found, the user could - // have removed them before they were queued, i.e. using clear() - while (entry != null) { - if (toRemove == entry) { - modCount++; - if (last == null) { - elementData[index] = entry.next; - } else { - last.next = entry.next; - } - elementCount--; - break; - } - last = entry; - entry = entry.next; - } - } - - /** - * Maps the specified key to the specified value. - * - * @param key - * the key. - * @param value - * the value. - * @return the value of any previous mapping with the specified key or {@code null} if there was no mapping. - */ - @Override - public V put(K key, V value) { - poll(); - int index = 0; - Entry entry; - if (key != null) { - index = (secondaryHashForObject(key) & 0x7FFFFFFF) % elementData.length; - entry = elementData[index]; - while (entry != null && key != entry.get()) { - entry = entry.next; - } - } else { - entry = elementData[0]; - while (entry != null && !entry.isNull) { - entry = entry.next; - } - } - if (entry == null) { - modCount++; - if (++elementCount > threshold) { - rehash(); - index = key == null ? 0 : (secondaryHashForObject(key) & 0x7FFFFFFF) % elementData.length; - } - entry = new Entry(key, value, referenceQueue); - entry.next = elementData[index]; - elementData[index] = entry; - return null; - } - V result = entry.value; - entry.value = value; - return result; - } - - private void rehash() { - int length = elementData.length * 2; - if (length == 0) { - length = 1; - } - Entry[] newData = newEntryArray(length); - for (int i = 0; i < elementData.length; i++) { - Entry entry = elementData[i]; - while (entry != null) { - int index = entry.isNull ? 0 : (entry.hash & 0x7FFFFFFF) % length; - Entry next = entry.next; - entry.next = newData[index]; - newData[index] = entry; - entry = next; - } - } - elementData = newData; - computeMaxSize(); - } - - /** - * Copies all the mappings in the given map to this map. These mappings will - * replace all mappings that this map had for any of the keys currently in - * the given map. - * - * @param map - * the map to copy mappings from. - * @throws NullPointerException - * if {@code map} is {@code null}. - */ - @Override - public void putAll(Map map) { - putAllImpl(map); - } - - /** - * Removes the mapping with the specified key from this map. - * - * @param key - * the key of the mapping to remove. - * @return the value of the removed mapping or {@code null} if no mapping - * for the specified key was found. - */ - @Override - public V remove(Object key) { - poll(); - int index = 0; - Entry entry, last = null; - if (key != null) { - index = (secondaryHashForObject(key) & 0x7FFFFFFF) % elementData.length; - entry = elementData[index]; - while (entry != null && key != entry.get()) { - last = entry; - entry = entry.next; - } - } else { - entry = elementData[0]; - while (entry != null && !entry.isNull) { - last = entry; - entry = entry.next; - } - } - if (entry != null) { - modCount++; - if (last == null) { - elementData[index] = entry.next; - } else { - last.next = entry.next; - } - elementCount--; - return entry.value; - } - return null; - } - - /** - * Returns the number of elements in this map. - * - * @return the number of elements in this map. - */ - @Override - public int size() { - poll(); - return elementCount; - } - - private void putAllImpl(Map map) { - if (map.entrySet() != null) { - super.putAll(map); - } - } - - private static int secondaryHashForObject(Object key) { - int intHash; - - if (key instanceof NativeScriptHashCodeProvider) { - NativeScriptHashCodeProvider provider = (NativeScriptHashCodeProvider) key; - intHash = provider.hashCode__super(); - } else { - intHash = System.identityHashCode(key); - } - - return secondaryHashForInt(intHash); - } - - private static int secondaryHashForInt(int h) { - // Spread bits to regularize both segment and index locations, - // using variant of single-word Wang/Jenkins hash. - h += (h << 15) ^ 0xffffcd7d; - h ^= (h >>> 10); - h += (h << 3); - h ^= (h >>> 6); - h += (h << 2) + (h << 14); - return h ^ (h >>> 16); - } -} diff --git a/test-app/runtime/src/main/java/com/tns/ObjectTable.java b/test-app/runtime/src/main/java/com/tns/ObjectTable.java new file mode 100644 index 000000000..375e51860 --- /dev/null +++ b/test-app/runtime/src/main/java/com/tns/ObjectTable.java @@ -0,0 +1,247 @@ +package com.tns; + +import java.lang.ref.WeakReference; + +/** + * A runtime's registry of the Java objects its JS wrappers refer to, by object id. An entry + * holds its object strongly while the JS wrapper is reachable and through a WeakReference + * after the wrapper was collected (until the object is collected too). + * + * Not synchronized: every access must come from the thread that holds the runtime's isolate + * lock. Neither table allocates per entry. + */ +final class ObjectTable { + static final int NO_ID = -1; + + // id -> entry, open addressing on the id. Keys are id + 1 so 0 marks an empty slot. + private int[] idKeys = new int[1024]; + private Object[] values = new Object[1024]; + private int[] identityHashes = new int[1024]; + private int idCount = 0; + + // identity hash -> id + 1, open addressing; values[] decides which object an id holds. + private int[] identityIndex = new int[1024]; + private int identityCount = 0; + + /** Registers obj under id, held strongly, replacing whatever id held before. */ + void putStrong(int id, Object obj) { + int existing = findIdSlot(id); + if (existing >= 0) { + Object value = values[existing]; + if ((value instanceof WeakRef ? ((WeakRef) value).get() : value) == obj) { + values[existing] = obj; + return; + } + remove(id); + } + + int hash = System.identityHashCode(obj); + if ((idCount + 1) * 2 > idKeys.length) { + growIds(); + } + insertId(id, obj, hash); + idCount++; + + if ((identityCount + 1) * 2 > identityIndex.length) { + growIdentity(); + } + insertIdentity(hash, id); + identityCount++; + } + + /** The id obj is registered under, or NO_ID. */ + int findId(Object obj) { + int mask = identityIndex.length - 1; + int i = mix(System.identityHashCode(obj)) & mask; + while (true) { + int entry = identityIndex[i]; + if (entry == 0) { + return NO_ID; + } + int id = entry - 1; + if (get(id) == obj) { + return id; + } + i = (i + 1) & mask; + } + } + + /** The object registered under id; null if there is none or it was collected. */ + Object get(int id) { + int slot = findIdSlot(id); + if (slot < 0) { + return null; + } + Object value = values[slot]; + return value instanceof WeakRef ? ((WeakRef) value).get() : value; + } + + boolean contains(int id) { + return findIdSlot(id) >= 0; + } + + /** + * The JS wrapper for id was collected: hold the object weakly from now on. Returns whether + * the object is still alive; an entry whose object was already collected is removed. + */ + boolean weakenAndCheckAlive(int id) { + int slot = findIdSlot(id); + if (slot < 0) { + return false; + } + Object value = values[slot]; + if (value instanceof WeakRef) { + if (((WeakRef) value).get() != null) { + return true; + } + remove(id); + return false; + } + values[slot] = new WeakRef(value); + return true; + } + + void remove(int id) { + int slot = findIdSlot(id); + if (slot < 0) { + return; + } + int hash = identityHashes[slot]; + removeIdSlot(slot); + idCount--; + removeIdentity(hash, id); + identityCount--; + } + + int size() { + return idCount; + } + + // Distinguishes the table's own weak entries from a registered object that happens to be + // a WeakReference. + private static final class WeakRef extends WeakReference { + WeakRef(Object referent) { + super(referent); + } + } + + private int findIdSlot(int id) { + int mask = idKeys.length - 1; + int i = mix(id) & mask; + int key = id + 1; + while (true) { + int k = idKeys[i]; + if (k == key) { + return i; + } + if (k == 0) { + return -1; + } + i = (i + 1) & mask; + } + } + + private void insertId(int id, Object value, int hash) { + int mask = idKeys.length - 1; + int i = mix(id) & mask; + while (idKeys[i] != 0) { + i = (i + 1) & mask; + } + idKeys[i] = id + 1; + values[i] = value; + identityHashes[i] = hash; + } + + // Backward-shift deletion keeps linear probing tombstone-free. + private void removeIdSlot(int slot) { + int mask = idKeys.length - 1; + int hole = slot; + int j = slot; + while (true) { + j = (j + 1) & mask; + int key = idKeys[j]; + if (key == 0) { + break; + } + int home = mix(key - 1) & mask; + if (!isBetween(hole, home, j)) { + idKeys[hole] = key; + values[hole] = values[j]; + identityHashes[hole] = identityHashes[j]; + hole = j; + } + } + idKeys[hole] = 0; + values[hole] = null; + identityHashes[hole] = 0; + } + + private void insertIdentity(int hash, int id) { + int mask = identityIndex.length - 1; + int i = mix(hash) & mask; + while (identityIndex[i] != 0) { + i = (i + 1) & mask; + } + identityIndex[i] = id + 1; + } + + private void removeIdentity(int hash, int id) { + int mask = identityIndex.length - 1; + int i = mix(hash) & mask; + while (identityIndex[i] != id + 1) { + i = (i + 1) & mask; + } + int hole = i; + int j = i; + while (true) { + j = (j + 1) & mask; + int entry = identityIndex[j]; + if (entry == 0) { + break; + } + int home = mix(identityHashes[findIdSlot(entry - 1)]) & mask; + if (!isBetween(hole, home, j)) { + identityIndex[hole] = entry; + hole = j; + } + } + identityIndex[hole] = 0; + } + + // Whether home lies cyclically in (hole, j], i.e. the entry at j must stay put. + private static boolean isBetween(int hole, int home, int j) { + return hole <= j ? (hole < home && home <= j) : (hole < home || home <= j); + } + + private void growIds() { + int[] oldKeys = idKeys; + Object[] oldValues = values; + int[] oldHashes = identityHashes; + int capacity = oldKeys.length * 2; + idKeys = new int[capacity]; + values = new Object[capacity]; + identityHashes = new int[capacity]; + for (int i = 0; i < oldKeys.length; i++) { + if (oldKeys[i] != 0) { + insertId(oldKeys[i] - 1, oldValues[i], oldHashes[i]); + } + } + } + + private void growIdentity() { + int[] old = identityIndex; + identityIndex = new int[old.length * 2]; + for (int entry : old) { + if (entry != 0) { + insertIdentity(identityHashes[findIdSlot(entry - 1)], entry - 1); + } + } + } + + private static int mix(int h) { + h ^= (h >>> 16); + h *= 0x85ebca6b; + h ^= (h >>> 13); + return h; + } +} diff --git a/test-app/runtime/src/main/java/com/tns/Runtime.java b/test-app/runtime/src/main/java/com/tns/Runtime.java index 3aa1709e4..f4ea292c5 100644 --- a/test-app/runtime/src/main/java/com/tns/Runtime.java +++ b/test-app/runtime/src/main/java/com/tns/Runtime.java @@ -11,7 +11,6 @@ import java.io.File; import java.io.IOException; -import java.lang.ref.WeakReference; import java.lang.reflect.Array; import java.lang.reflect.Constructor; import java.lang.reflect.Field; @@ -43,7 +42,7 @@ private native void initNativeScript(int runtimeId, String filesPath, String nat private native Object runScript(int runtimeId, String filePath) throws NativeScriptException; - private native Object callJSMethodNative(int runtimeId, int javaObjectID, String methodName, int retType, boolean isConstructor, int[] argTypes, long[] argValues, Object[] argRefs, long[] primitiveResult) throws NativeScriptException; + private native Object callJSMethodNative(int runtimeId, Object javaObject, int javaObjectID, String methodName, int retType, boolean isConstructor, int[] argTypes, long[] argValues, Object[] argRefs, long[] primitiveResult) throws NativeScriptException; private native void createJSInstanceNative(int runtimeId, Object javaObject, int javaObjectID, String canonicalName); @@ -176,13 +175,9 @@ public void run() { "Primitive types need to be manually wrapped in their respective Object wrappers.\n" + "If you are creating an instance of an inner class, make sure to always provide reference to the outer `this` as the first argument."; - private Map strongInstances = new HashMap<>(); - - private Map> weakInstances = new HashMap<>(); - - private Map strongJavaObjectToID = new NativeScriptHashMap(); - - private Map weakJavaObjectToID = new NativeScriptWeakHashMap(); + // Unsynchronized: only touched by the thread holding this runtime's isolate lock (the + // native side resolves and registers objects after taking it). + private final ObjectTable objectTable; private Map, JavaScriptImplementation> loadedJavaScriptExtends = new HashMap, JavaScriptImplementation>(); @@ -251,20 +246,18 @@ public int compare(Method lhs, Method rhs) { private static final ClassStorageService classStorageService = new ClassStorageServiceImpl(ClassCacheImpl.INSTANCE, ClassLoadersCollectionImpl.INSTANCE); - public Runtime(ClassResolver classResolver, GcListener gcListener, StaticConfiguration config, DynamicConfiguration dynamicConfig, int runtimeId, int workerId, HashMap strongInstances, HashMap> weakInstances, NativeScriptHashMap strongJavaObjectToId, NativeScriptWeakHashMap weakJavaObjectToId) { + public Runtime(ClassResolver classResolver, GcListener gcListener, StaticConfiguration config, DynamicConfiguration dynamicConfig, int runtimeId, int workerId, ObjectTable objectTable) { this.classResolver = classResolver; this.gcListener = gcListener; this.config = config; this.dynamicConfig = dynamicConfig; this.runtimeId = runtimeId; this.workerId = workerId; - this.strongInstances = strongInstances; - this.weakInstances = weakInstances; - this.strongJavaObjectToID = strongJavaObjectToId; - this.weakJavaObjectToID = weakJavaObjectToId; + this.objectTable = objectTable; } public Runtime(StaticConfiguration config, DynamicConfiguration dynamicConfiguration) { + this.objectTable = new ObjectTable(); synchronized (Runtime.currentRuntime) { ManualInstrumentation.Frame frame = ManualInstrumentation.start("new Runtime"); try { @@ -280,13 +273,9 @@ public Runtime(StaticConfiguration config, DynamicConfiguration dynamicConfigura this.workerId = dynamicConfiguration.workerId; // if multithreadedJS, make all maps concurrent or synchronized: if (config.appConfig.getEnableMultithreadedJavascript()) { - this.strongInstances = new ConcurrentHashMap<>(); - this.weakInstances = new ConcurrentHashMap<>(); // TODO: can't use a ConcurrentHashMap for loadedJavaScriptExtends because it loads null objects, which aren't supported // either leave it like this or create a separate set for null caches this.loadedJavaScriptExtends = Collections.synchronizedMap(new HashMap<>()); - this.strongJavaObjectToID = Collections.synchronizedMap(new NativeScriptHashMap<>()); - this.weakJavaObjectToID = Collections.synchronizedMap(new NativeScriptWeakHashMap<>()); } classResolver = new ClassResolver(classStorageService); @@ -555,17 +544,7 @@ public void ResetDateTimeConfigurationCache() { } public void releaseNativeCounterpart(int nativeObjectId) { - Object strongRef = strongInstances.get(nativeObjectId); - if (strongRef != null) { - strongInstances.remove(nativeObjectId); - strongJavaObjectToID.remove(strongRef); - } - - WeakReference weakRef = weakInstances.get(nativeObjectId); - if (weakRef != null) { - weakInstances.remove(nativeObjectId); - weakJavaObjectToID.remove(weakRef); - } + objectTable.remove(nativeObjectId); } /* @@ -884,10 +863,9 @@ public static void initInstance(Object instance) { } private void createJSInstance(Object instance) { + // Registered by createJSInstanceNative, which holds the isolate lock. int javaObjectID = generateNewObjectId(getRuntimeId()); - makeInstanceStrong(instance, javaObjectID); - Class clazz = instance.getClass(); if (!loadedJavaScriptExtends.containsKey(clazz)) { @@ -1032,8 +1010,7 @@ private void makeInstanceStrong(Object instance, int objectId) { } int key = objectId; - strongInstances.put(key, instance); - strongJavaObjectToID.put(instance, key); + objectTable.putStrong(key, instance); Class clazz = instance.getClass(); // Instances of one class are usually registered back to back, and storeClass takes @@ -1053,31 +1030,7 @@ private boolean makeInstanceWeakAndCheckIfAlive(int javaObjectID) { if (logger.isEnabled()) { logger.write("makeInstanceWeakAndCheckIfAlive instance " + javaObjectID); } - Object instance = strongInstances.get(javaObjectID); - if (instance == null) { - WeakReference ref = weakInstances.get(javaObjectID); - if (ref == null) { - return false; - } else { - instance = ref.get(); - if (instance == null) { - // The Java was moved from strong to weak, and then the Java instance was collected. - weakInstances.remove(javaObjectID); - weakJavaObjectToID.remove(ref); - return false; - } else { - return true; - } - } - } else { - strongInstances.remove(javaObjectID); - strongJavaObjectToID.remove(instance); - - weakJavaObjectToID.put(instance, javaObjectID); - weakInstances.put(javaObjectID, new WeakReference(instance)); - - return true; - } + return objectTable.weakenAndCheckAlive(javaObjectID); } @RuntimeCallable @@ -1086,37 +1039,24 @@ private Object getJavaObjectByID(int javaObjectID) throws Exception { logger.write("Platform.getJavaObjectByID:" + javaObjectID); } - Object instance = strongInstances.get(javaObjectID); - + Object instance = objectTable.get(javaObjectID); if (instance == null) { - instance = keyNotFoundObject; + throw new NativeScriptException(objectTable.contains(javaObjectID) + ? "Attempt to use cleared object reference id=" + javaObjectID + : "No weak reference found. Attempt to use cleared object reference id=" + javaObjectID); } - if (instance == keyNotFoundObject) { - WeakReference wr = weakInstances.get(javaObjectID); - if (wr == null) { - throw new NativeScriptException("No weak reference found. Attempt to use cleared object reference id=" + javaObjectID); - } - - instance = wr.get(); - if (instance == null) { - throw new NativeScriptException("Attempt to use cleared object reference id=" + javaObjectID); - } - } - - // Log.d(DEFAULT_LOG_TAG, - // "Platform.getJavaObjectByID found strong object with id:" + - // javaObjectID); return instance; } private Integer getJavaObjectID(Object obj) { - Integer id = strongJavaObjectToID.get(obj); - if (id == null) { - id = weakJavaObjectToID.get(obj); - } + int id = objectTable.findId(obj); + return id == ObjectTable.NO_ID ? null : id; + } - return id; + @RuntimeCallable + private int findJavaObjectID(Object obj) { + return objectTable.findId(obj); } @RuntimeCallable @@ -1297,8 +1237,8 @@ private PackagedArgs packageArgs(Object... args) { if (refs == null) { refs = new Object[len]; } - refs[i] = value.getClass().getName(); - values[i] = getOrCreateJavaObjectID(value); + // Resolved and registered natively, under the isolate lock. + refs[i] = value; } types[i] = typeId; @@ -1407,11 +1347,11 @@ private Object dispatchCallJSMethodNative(final Object javaObject, final String boolean enableMultithreadedJavascript = this.config.appConfig.getEnableMultithreadedJavascript(); if (enableMultithreadedJavascript || isWorkThread) { - int javaObjectID = resolveJavaObjectID(javaObject, methodName); PackagedArgs packagedArgs = packageArgs(tmpArgs); long[] primitiveResult = newPrimitiveResult(returnType); try { - ret = callJSMethodNative(getRuntimeId(), javaObjectID, methodName, returnType, isConstructor, packagedArgs.types, packagedArgs.values, packagedArgs.refs, primitiveResult); + // NO_ID: resolved natively, under the isolate lock other threads may also hold. + ret = callJSMethodNative(getRuntimeId(), javaObject, ObjectTable.NO_ID, methodName, returnType, isConstructor, packagedArgs.types, packagedArgs.values, packagedArgs.refs, primitiveResult); ret = takePrimitiveResult(ret, returnType, primitiveResult); } catch (NativeScriptException e) { // With multithreaded JS the owner can be torn down between the @@ -1452,7 +1392,7 @@ public void run() { } final PackagedArgs packagedArgs = packageArgs(tmpArgs); final long[] primitiveResult = newPrimitiveResult(returnType); - arr[0] = takePrimitiveResult(callJSMethodNative(getRuntimeId(), javaObjectID, methodName, returnType, isCtor, packagedArgs.types, packagedArgs.values, packagedArgs.refs, primitiveResult), returnType, primitiveResult); + arr[0] = takePrimitiveResult(callJSMethodNative(getRuntimeId(), javaObject, javaObjectID, methodName, returnType, isCtor, packagedArgs.types, packagedArgs.values, packagedArgs.refs, primitiveResult), returnType, primitiveResult); } catch (NativeScriptException e) { if (discardUncaughtJsExceptions && !e.isEscapedFromJs()) { String errorMessage = "Error on \"" + Thread.currentThread().getName() + "\" thread for callJSMethodNative\n"; diff --git a/test-app/runtime/src/test/java/com/tns/ObjectTableTest.java b/test-app/runtime/src/test/java/com/tns/ObjectTableTest.java new file mode 100644 index 000000000..e4791af7c --- /dev/null +++ b/test-app/runtime/src/test/java/com/tns/ObjectTableTest.java @@ -0,0 +1,121 @@ +package com.tns; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; +import java.util.Random; + +public class ObjectTableTest { + @Test + public void registersLooksUpAndRemoves() { + ObjectTable table = new ObjectTable(); + Object a = new Object(); + Object b = new Object(); + table.putStrong(7, a); + table.putStrong(9, b); + + assertEquals(7, table.findId(a)); + assertEquals(9, table.findId(b)); + assertSame(a, table.get(7)); + assertEquals(ObjectTable.NO_ID, table.findId(new Object())); + + table.remove(7); + assertEquals(ObjectTable.NO_ID, table.findId(a)); + assertNull(table.get(7)); + assertEquals(9, table.findId(b)); + assertEquals(1, table.size()); + } + + @Test + public void weakEntriesStayFindableWhileTheObjectLives() { + ObjectTable table = new ObjectTable(); + Object a = new Object(); + table.putStrong(1, a); + + assertTrue(table.weakenAndCheckAlive(1)); + assertSame(a, table.get(1)); + assertEquals(1, table.findId(a)); + assertTrue(table.weakenAndCheckAlive(1)); + assertFalse(table.weakenAndCheckAlive(2)); + } + + @Test + public void registeringAnIdAgainReplacesItsEntry() { + ObjectTable table = new ObjectTable(); + Object a = new Object(); + Object b = new Object(); + table.putStrong(5, a); + table.weakenAndCheckAlive(5); + table.putStrong(5, a); + assertEquals(1, table.size()); + assertSame(a, table.get(5)); + + table.putStrong(5, b); + assertEquals(1, table.size()); + assertSame(b, table.get(5)); + assertEquals(5, table.findId(b)); + assertEquals(ObjectTable.NO_ID, table.findId(a)); + } + + @Test + public void aRegisteredWeakReferenceIsNotMistakenForAWeakEntry() { + ObjectTable table = new ObjectTable(); + java.lang.ref.WeakReference ref = new java.lang.ref.WeakReference<>(new Object()); + table.putStrong(3, ref); + + assertSame(ref, table.get(3)); + assertEquals(3, table.findId(ref)); + } + + @Test + public void matchesReferenceMapsUnderRandomOperations() { + Random random = new Random(42); + ObjectTable table = new ObjectTable(); + Map byId = new HashMap<>(); + Map byObject = new IdentityHashMap<>(); + List live = new ArrayList<>(); + int nextId = 0; + + for (int step = 0; step < 200_000; step++) { + int op = random.nextInt(10); + if (op < 5 || live.isEmpty()) { + Object obj = new Object(); + int id = nextId++; + table.putStrong(id, obj); + byId.put(id, obj); + byObject.put(obj, id); + live.add(id); + } else if (op < 8) { + int id = live.get(random.nextInt(live.size())); + Object obj = byId.get(id); + assertEquals(id, table.findId(obj)); + assertSame(obj, table.get(id)); + } else { + int index = random.nextInt(live.size()); + int id = live.get(index); + live.set(index, live.get(live.size() - 1)); + live.remove(live.size() - 1); + Object obj = byId.remove(id); + byObject.remove(obj); + table.remove(id); + assertEquals(ObjectTable.NO_ID, table.findId(obj)); + assertNull(table.get(id)); + } + } + + assertEquals(byId.size(), table.size()); + for (Map.Entry entry : byObject.entrySet()) { + assertEquals((int) entry.getValue(), table.findId(entry.getKey())); + } + } +} diff --git a/test-app/runtime/src/test/java/com/tns/RuntimeTest.java b/test-app/runtime/src/test/java/com/tns/RuntimeTest.java index 265062ced..3249cecb1 100644 --- a/test-app/runtime/src/test/java/com/tns/RuntimeTest.java +++ b/test-app/runtime/src/test/java/com/tns/RuntimeTest.java @@ -1,22 +1,11 @@ package com.tns; -import org.junit.Assert; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.mockito.Mockito.mock; + import org.junit.Before; import org.junit.Test; -import org.mockito.Mock; - -import org.mockito.MockitoAnnotations; -import org.mockito.Spy; - -import java.lang.ref.WeakReference; -import java.util.HashMap; -import java.util.Map; - -import static org.mockito.Mockito.doReturn; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.mockingDetails; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; public class RuntimeTest { @@ -24,67 +13,39 @@ public class RuntimeTest { private static final Object TESTING_NATIVE_OBJECT = new Object(); private Runtime runtime; - - @Mock - private HashMap strongInstances; - - @Mock - private HashMap> weakInstances; - - @Mock - private NativeScriptHashMap strongJavaObjectToId; - - @Mock - private NativeScriptWeakHashMap weakJavaObjectToId; - - @Mock - private WeakReference testingObjectWeakRefMock; + private ObjectTable objectTable; @Before public void setUp() { - MockitoAnnotations.initMocks(this); + objectTable = new ObjectTable(); runtime = new Runtime(mock(ClassResolver.class), mock(GcListener.class), mock(StaticConfiguration.class), mock(DynamicConfiguration.class), 0, 0, - strongInstances, - weakInstances, - strongJavaObjectToId, - weakJavaObjectToId); + objectTable); } @Test public void testStrongInstanceIsSuccessfullyRemoved() { - when(strongInstances.get(TESTING_NATIVE_OBJECT_ID)).thenReturn(TESTING_NATIVE_OBJECT); - - runtime.releaseNativeCounterpart(TESTING_NATIVE_OBJECT_ID); - - verify(strongInstances).remove(TESTING_NATIVE_OBJECT_ID); - verify(strongJavaObjectToId).remove(TESTING_NATIVE_OBJECT); - } - - @Test - public void testWeakInstanceIsSuccessfullyRemoved(){ - when(weakInstances.get(TESTING_NATIVE_OBJECT_ID)).thenReturn(testingObjectWeakRefMock); + objectTable.putStrong(TESTING_NATIVE_OBJECT_ID, TESTING_NATIVE_OBJECT); runtime.releaseNativeCounterpart(TESTING_NATIVE_OBJECT_ID); - verify(weakInstances).remove(TESTING_NATIVE_OBJECT_ID); - verify(weakJavaObjectToId).remove(testingObjectWeakRefMock); + assertNull(objectTable.get(TESTING_NATIVE_OBJECT_ID)); + assertEquals(ObjectTable.NO_ID, objectTable.findId(TESTING_NATIVE_OBJECT)); } @Test - public void testStrongAndWeakInstancesAreSuccessfullyRemoved(){ - when(strongInstances.get(TESTING_NATIVE_OBJECT_ID)).thenReturn(TESTING_NATIVE_OBJECT); - when(weakInstances.get(TESTING_NATIVE_OBJECT_ID)).thenReturn(testingObjectWeakRefMock); + public void testWeakInstanceIsSuccessfullyRemoved() { + objectTable.putStrong(TESTING_NATIVE_OBJECT_ID, TESTING_NATIVE_OBJECT); + objectTable.weakenAndCheckAlive(TESTING_NATIVE_OBJECT_ID); runtime.releaseNativeCounterpart(TESTING_NATIVE_OBJECT_ID); - verify(strongInstances).remove(TESTING_NATIVE_OBJECT_ID); - verify(strongJavaObjectToId).remove(TESTING_NATIVE_OBJECT); - verify(weakInstances).remove(TESTING_NATIVE_OBJECT_ID); - verify(weakJavaObjectToId).remove(testingObjectWeakRefMock); + assertNull(objectTable.get(TESTING_NATIVE_OBJECT_ID)); + assertEquals(ObjectTable.NO_ID, objectTable.findId(TESTING_NATIVE_OBJECT)); + assertEquals(0, objectTable.size()); } } From 8e830f874e3db06bad1729a3d10b548fc2b73ca8 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Sun, 8 Nov 2026 19:43:10 -0300 Subject: [PATCH 03/42] perf: resolve known objects before taking the isolate lock Resolving every object argument natively cost a JNI upcall per argument inside the lock. ObjectTable gains an optimistic read (StampedLock, API 24+) that any thread may make: writers, already serialized by the isolate lock, bracket each mutation with the write stamp, and a reader that overlaps one gets a miss instead of a torn result. Java passes the ids it reads this way as hints for the receiver and object arguments; the native side uses a hint only when it names a still-linked wrapper (ids are never reused) and resolves exactly under the lock otherwise. --- .../runtime/src/main/cpp/ArgConverter.cpp | 16 +- .../runtime/src/main/cpp/ObjectManager.cpp | 8 + test-app/runtime/src/main/cpp/ObjectManager.h | 4 + test-app/runtime/src/main/cpp/Runtime.cpp | 11 +- .../src/main/java/com/tns/ObjectTable.java | 146 +++++++++++++++++- .../src/main/java/com/tns/Runtime.java | 15 +- .../test/java/com/tns/ObjectTableTest.java | 81 ++++++++++ 7 files changed, 263 insertions(+), 18 deletions(-) diff --git a/test-app/runtime/src/main/cpp/ArgConverter.cpp b/test-app/runtime/src/main/cpp/ArgConverter.cpp index bc3667efe..bcbcf475c 100644 --- a/test-app/runtime/src/main/cpp/ArgConverter.cpp +++ b/test-app/runtime/src/main/cpp/ArgConverter.cpp @@ -155,12 +155,18 @@ void ArgConverter::ConvertJavaArgsToJsArgs(Isolate* isolate, JNIEnv* env, const break; } case Type::JsObject: { - JniLocalRef arg(env->GetObjectArrayElement(args.refs, i)); - auto javaObjectID = objectManager->GetOrCreateObjectId(arg); - jsArg = objectManager->GetJsObjectByJavaObject(javaObjectID); - + // values[i] is an id Java read without the isolate lock, or -1. + auto hintedObjectID = static_cast(bits); + if (hintedObjectID >= 0) { + jsArg = objectManager->GetLinkedJsObjectByJavaObject(hintedObjectID); + } if (jsArg.IsEmpty()) { - jsArg = objectManager->CreateJSWrapper(javaObjectID, "", arg); + JniLocalRef arg(env->GetObjectArrayElement(args.refs, i)); + auto javaObjectID = objectManager->GetOrCreateObjectId(arg); + jsArg = objectManager->GetJsObjectByJavaObject(javaObjectID); + if (jsArg.IsEmpty()) { + jsArg = objectManager->CreateJSWrapper(javaObjectID, "", arg); + } } break; } diff --git a/test-app/runtime/src/main/cpp/ObjectManager.cpp b/test-app/runtime/src/main/cpp/ObjectManager.cpp index f2702fa7b..d83a0cde3 100644 --- a/test-app/runtime/src/main/cpp/ObjectManager.cpp +++ b/test-app/runtime/src/main/cpp/ObjectManager.cpp @@ -283,6 +283,14 @@ Local ObjectManager::GetJsObjectByJavaObject(int javaObjectID) { return handleScope.Escape(localObject); } +Local ObjectManager::GetLinkedJsObjectByJavaObject(int javaObjectID) { + auto it = m_idToObject.find(javaObjectID); + if (it == m_idToObject.end() || it->second->jsInfo == nullptr) { + return Local(); + } + return GetJsObjectByJavaObject(javaObjectID); +} + Local ObjectManager::CreateJSWrapper(jint javaObjectID, const string& typeName) { return CreateJSWrapperHelper(javaObjectID, typeName, nullptr); diff --git a/test-app/runtime/src/main/cpp/ObjectManager.h b/test-app/runtime/src/main/cpp/ObjectManager.h index 9bc06e966..cc2f5d6c0 100644 --- a/test-app/runtime/src/main/cpp/ObjectManager.h +++ b/test-app/runtime/src/main/cpp/ObjectManager.h @@ -51,6 +51,10 @@ class ObjectManager { v8::Local GetJsObjectByJavaObject(int javaObjectID); + // As GetJsObjectByJavaObject, but empty once the wrapper's link to the Java object was + // released. + v8::Local GetLinkedJsObjectByJavaObject(int javaObjectID); + v8::Local CreateJSWrapper(jint javaObjectID, const std::string& typeName); diff --git a/test-app/runtime/src/main/cpp/Runtime.cpp b/test-app/runtime/src/main/cpp/Runtime.cpp index 171288290..a7cd58e9e 100644 --- a/test-app/runtime/src/main/cpp/Runtime.cpp +++ b/test-app/runtime/src/main/cpp/Runtime.cpp @@ -619,15 +619,20 @@ jobject Runtime::CallJSMethodNative(JNIEnv* _env, jobject obj, DEBUG_WRITE("CallJSMethodNative called javaObjectID=%d", javaObjectID); - if (javaObjectID < 0) { + // javaObjectID may be a hint read without the isolate lock; resolve it exactly when it + // does not name a linked wrapper. + Local jsObject; + if (javaObjectID >= 0) { + jsObject = m_objectManager->GetLinkedJsObjectByJavaObject(javaObjectID); + } + if (jsObject.IsEmpty() && javaObject != nullptr) { javaObjectID = m_objectManager->FindObjectId(javaObject); if (javaObjectID < 0) { throw NativeScriptException("Cannot find object id for an instance of " + m_objectManager->GetClassName(javaObject)); } + jsObject = m_objectManager->GetJsObjectByJavaObject(javaObjectID); } - - auto jsObject = m_objectManager->GetJsObjectByJavaObject(javaObjectID); if (jsObject.IsEmpty()) { stringstream ss; ss << "JavaScript object for Java ID " << javaObjectID << " not found." diff --git a/test-app/runtime/src/main/java/com/tns/ObjectTable.java b/test-app/runtime/src/main/java/com/tns/ObjectTable.java index 375e51860..9dc19e4d0 100644 --- a/test-app/runtime/src/main/java/com/tns/ObjectTable.java +++ b/test-app/runtime/src/main/java/com/tns/ObjectTable.java @@ -7,12 +7,112 @@ * holds its object strongly while the JS wrapper is reachable and through a WeakReference * after the wrapper was collected (until the object is collected too). * - * Not synchronized: every access must come from the thread that holds the runtime's isolate - * lock. Neither table allocates per entry. + * Every mutation and every exact read must come from the thread that holds the runtime's + * isolate lock; findIdOptimistic is the one read any thread may make. Neither table + * allocates per entry. */ final class ObjectTable { static final int NO_ID = -1; + // Null where StampedLock is unavailable (API < 24): findIdOptimistic then always misses. + private final Stamps stamps; + + ObjectTable() { + this(false); + } + + ObjectTable(boolean optimisticReads) { + stamps = optimisticReads ? new Stamps() : null; + } + + /** + * Writers are already serialized by the isolate lock; the write stamp only lets an + * optimistic reader detect that a write overlapped it. Kept in its own class so that + * runtimes where StampedLock is missing never load it. + */ + private static final class Stamps { + private final java.util.concurrent.locks.StampedLock lock = new java.util.concurrent.locks.StampedLock(); + + long beginWrite() { + return lock.writeLock(); + } + + void endWrite(long stamp) { + lock.unlockWrite(stamp); + } + + long beginRead() { + return lock.tryOptimisticRead(); + } + + boolean validate(long stamp) { + return lock.validate(stamp); + } + } + + /** + * The id obj is registered under, from any thread, without the isolate lock. A miss + * (NO_ID) is not authoritative: it is also returned when a write overlapped the read. + * A hit was correct at the time of the read; the object may have been released since, + * which the caller must check under the isolate lock. + */ + int findIdOptimistic(Object obj) { + if (stamps == null) { + return NO_ID; + } + long stamp = stamps.beginRead(); + if (stamp == 0) { + return NO_ID; + } + int id; + try { + id = findIdUnlocked(obj); + } catch (RuntimeException e) { + // A torn view of a resize can index past an array; the stamp check below fails then. + id = NO_ID; + } + return stamps.validate(stamp) ? id : NO_ID; + } + + private int findIdUnlocked(Object obj) { + int[] index = identityIndex; + int mask = index.length - 1; + int i = mix(System.identityHashCode(obj)) & mask; + for (int probes = 0; probes <= mask; probes++) { + int entry = index[i]; + if (entry == 0) { + return NO_ID; + } + if (getUnlocked(entry - 1) == obj) { + return entry - 1; + } + i = (i + 1) & mask; + } + return NO_ID; + } + + // get() with every probe bounded: a torn view need not contain the empty slot that + // ends a probe. + private Object getUnlocked(int id) { + int[] keys = idKeys; + Object[] vals = values; + int mask = keys.length - 1; + int i = mix(id) & mask; + int key = id + 1; + for (int probes = 0; probes <= mask; probes++) { + int k = keys[i]; + if (k == key) { + Object value = vals[i]; + return value instanceof WeakRef ? ((WeakRef) value).get() : value; + } + if (k == 0) { + return null; + } + i = (i + 1) & mask; + } + return null; + } + // id -> entry, open addressing on the id. Keys are id + 1 so 0 marks an empty slot. private int[] idKeys = new int[1024]; private Object[] values = new Object[1024]; @@ -25,6 +125,19 @@ final class ObjectTable { /** Registers obj under id, held strongly, replacing whatever id held before. */ void putStrong(int id, Object obj) { + if (stamps == null) { + putStrongLocked(id, obj); + return; + } + long stamp = stamps.beginWrite(); + try { + putStrongLocked(id, obj); + } finally { + stamps.endWrite(stamp); + } + } + + private void putStrongLocked(int id, Object obj) { int existing = findIdSlot(id); if (existing >= 0) { Object value = values[existing]; @@ -32,7 +145,7 @@ void putStrong(int id, Object obj) { values[existing] = obj; return; } - remove(id); + removeLocked(id); } int hash = System.identityHashCode(obj); @@ -85,6 +198,18 @@ boolean contains(int id) { * the object is still alive; an entry whose object was already collected is removed. */ boolean weakenAndCheckAlive(int id) { + if (stamps == null) { + return weakenAndCheckAliveLocked(id); + } + long stamp = stamps.beginWrite(); + try { + return weakenAndCheckAliveLocked(id); + } finally { + stamps.endWrite(stamp); + } + } + + private boolean weakenAndCheckAliveLocked(int id) { int slot = findIdSlot(id); if (slot < 0) { return false; @@ -94,7 +219,7 @@ boolean weakenAndCheckAlive(int id) { if (((WeakRef) value).get() != null) { return true; } - remove(id); + removeLocked(id); return false; } values[slot] = new WeakRef(value); @@ -102,6 +227,19 @@ boolean weakenAndCheckAlive(int id) { } void remove(int id) { + if (stamps == null) { + removeLocked(id); + return; + } + long stamp = stamps.beginWrite(); + try { + removeLocked(id); + } finally { + stamps.endWrite(stamp); + } + } + + private void removeLocked(int id) { int slot = findIdSlot(id); if (slot < 0) { return; diff --git a/test-app/runtime/src/main/java/com/tns/Runtime.java b/test-app/runtime/src/main/java/com/tns/Runtime.java index f4ea292c5..0cc0b27e7 100644 --- a/test-app/runtime/src/main/java/com/tns/Runtime.java +++ b/test-app/runtime/src/main/java/com/tns/Runtime.java @@ -175,8 +175,9 @@ public void run() { "Primitive types need to be manually wrapped in their respective Object wrappers.\n" + "If you are creating an instance of an inner class, make sure to always provide reference to the outer `this` as the first argument."; - // Unsynchronized: only touched by the thread holding this runtime's isolate lock (the - // native side resolves and registers objects after taking it). + // Mutated and read exactly only by the thread holding this runtime's isolate lock (the + // native side resolves and registers objects after taking it); other threads may only + // findIdOptimistic, whose result the native side re-checks. private final ObjectTable objectTable; private Map, JavaScriptImplementation> loadedJavaScriptExtends = new HashMap, JavaScriptImplementation>(); @@ -257,7 +258,7 @@ public Runtime(ClassResolver classResolver, GcListener gcListener, StaticConfigu } public Runtime(StaticConfiguration config, DynamicConfiguration dynamicConfiguration) { - this.objectTable = new ObjectTable(); + this.objectTable = new ObjectTable(android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N); synchronized (Runtime.currentRuntime) { ManualInstrumentation.Frame frame = ManualInstrumentation.start("new Runtime"); try { @@ -1237,8 +1238,9 @@ private PackagedArgs packageArgs(Object... args) { if (refs == null) { refs = new Object[len]; } - // Resolved and registered natively, under the isolate lock. + // A hint; resolved or registered natively, under the isolate lock, on a miss. refs[i] = value; + values[i] = objectTable.findIdOptimistic(value); } types[i] = typeId; @@ -1350,8 +1352,9 @@ private Object dispatchCallJSMethodNative(final Object javaObject, final String PackagedArgs packagedArgs = packageArgs(tmpArgs); long[] primitiveResult = newPrimitiveResult(returnType); try { - // NO_ID: resolved natively, under the isolate lock other threads may also hold. - ret = callJSMethodNative(getRuntimeId(), javaObject, ObjectTable.NO_ID, methodName, returnType, isConstructor, packagedArgs.types, packagedArgs.values, packagedArgs.refs, primitiveResult); + // A hint: other threads may be writing the table, so native re-checks it under the + // isolate lock and resolves the id itself on a miss. + ret = callJSMethodNative(getRuntimeId(), javaObject, objectTable.findIdOptimistic(javaObject), methodName, returnType, isConstructor, packagedArgs.types, packagedArgs.values, packagedArgs.refs, primitiveResult); ret = takePrimitiveResult(ret, returnType, primitiveResult); } catch (NativeScriptException e) { // With multithreaded JS the owner can be torn down between the diff --git a/test-app/runtime/src/test/java/com/tns/ObjectTableTest.java b/test-app/runtime/src/test/java/com/tns/ObjectTableTest.java index e4791af7c..ed8972b2c 100644 --- a/test-app/runtime/src/test/java/com/tns/ObjectTableTest.java +++ b/test-app/runtime/src/test/java/com/tns/ObjectTableTest.java @@ -77,6 +77,87 @@ public void aRegisteredWeakReferenceIsNotMistakenForAWeakEntry() { assertEquals(3, table.findId(ref)); } + @Test + public void optimisticReadsNeverReturnAWrongIdWhileAnotherThreadWrites() throws Exception { + final ObjectTable table = new ObjectTable(true); + final int stableCount = 64; + final Object[] stable = new Object[stableCount]; + for (int i = 0; i < stableCount; i++) { + stable[i] = new Object(); + table.putStrong(i, stable[i]); + } + + final java.util.concurrent.atomic.AtomicBoolean done = new java.util.concurrent.atomic.AtomicBoolean(); + final java.util.concurrent.atomic.AtomicReference failure = new java.util.concurrent.atomic.AtomicReference<>(); + final java.util.concurrent.atomic.AtomicLong hits = new java.util.concurrent.atomic.AtomicLong(); + + Thread writer = new Thread(new Runnable() { + @Override + public void run() { + Random random = new Random(7); + List churn = new ArrayList<>(); + int nextId = stableCount; + while (!done.get()) { + // Grow well past the initial capacity so readers overlap resizes and + // backward-shift deletions, then shrink again. + if (churn.size() < 20_000 && random.nextInt(3) != 0) { + int id = nextId++; + table.putStrong(id, new Object()); + churn.add(id); + } else if (!churn.isEmpty()) { + int id = churn.remove(churn.size() - 1); + if (random.nextBoolean()) { + table.weakenAndCheckAlive(id); + } + table.remove(id); + } + } + } + }); + + Thread[] readers = new Thread[3]; + for (int r = 0; r < readers.length; r++) { + final int seed = r; + readers[r] = new Thread(new Runnable() { + @Override + public void run() { + Random random = new Random(seed); + while (!done.get()) { + int expected = random.nextInt(stableCount); + int id = table.findIdOptimistic(stable[expected]); + if (id == expected) { + hits.incrementAndGet(); + } else if (id != ObjectTable.NO_ID) { + failure.compareAndSet(null, "expected " + expected + " or NO_ID, got " + id); + } + } + } + }); + } + + writer.start(); + for (Thread reader : readers) { + reader.start(); + } + Thread.sleep(2000); + done.set(true); + writer.join(); + for (Thread reader : readers) { + reader.join(); + } + + assertNull(failure.get(), failure.get()); + assertTrue("optimistic reads should mostly hit", hits.get() > 1000); + } + + @Test + public void optimisticReadsMissWhenDisabled() { + ObjectTable table = new ObjectTable(false); + Object a = new Object(); + table.putStrong(1, a); + assertEquals(ObjectTable.NO_ID, table.findIdOptimistic(a)); + } + @Test public void matchesReferenceMapsUnderRandomOperations() { Random random = new Random(42); From f5060ce85d48473409a9174c51ed8cdff4325286 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Sun, 8 Nov 2026 20:11:12 -0300 Subject: [PATCH 04/42] perf: settle collected JS wrappers in batches instead of one JNI call each A JS wrapper's finalizer called Java once per collected wrapper, on its first collection to weaken the Java object and on later ones to ask whether it was still alive. The finalizer now revives the wrapper and queues its id; one processFinalizedBatch call weakens or checks the whole batch through two int arrays and reports the dead ids, whose wrappers are then released. A batch is flushed inside the finalizer once it reaches 1024 ids, so a long synchronous JS loop holds at most that many Java objects strongly past their wrappers' collection, and otherwise from the event loop (V8 runs the GC epilogue before the finalizers). Every native upcall into the object table now asserts, in debug builds, that the calling thread holds the isolate lock. --- .../runtime/src/main/cpp/ObjectManager.cpp | 147 +++++++++++++++--- test-app/runtime/src/main/cpp/ObjectManager.h | 26 +++- .../src/main/java/com/tns/Runtime.java | 20 ++- 3 files changed, 169 insertions(+), 24 deletions(-) diff --git a/test-app/runtime/src/main/cpp/ObjectManager.cpp b/test-app/runtime/src/main/cpp/ObjectManager.cpp index d83a0cde3..7f2496d70 100644 --- a/test-app/runtime/src/main/cpp/ObjectManager.cpp +++ b/test-app/runtime/src/main/cpp/ObjectManager.cpp @@ -61,9 +61,9 @@ void ObjectManager::InitializeJNI() { runtimeClass, "makeInstanceStrong", "(Ljava/lang/Object;I)V"); NS_CHECK(MAKE_INSTANCE_STRONG_METHOD_ID != nullptr); - MAKE_INSTANCE_WEAK_AND_CHECK_IF_ALIVE_METHOD_ID = - env.GetMethodID(runtimeClass, "makeInstanceWeakAndCheckIfAlive", "(I)Z"); - NS_CHECK(MAKE_INSTANCE_WEAK_AND_CHECK_IF_ALIVE_METHOD_ID != nullptr); + PROCESS_FINALIZED_BATCH_METHOD_ID = + env.GetMethodID(runtimeClass, "processFinalizedBatch", "([II[II)V"); + NS_CHECK(PROCESS_FINALIZED_BATCH_METHOD_ID != nullptr); RELEASE_NATIVE_INSTANCE_METHOD_ID = env.GetMethodID(runtimeClass, "releaseNativeCounterpart", "(I)V"); @@ -225,6 +225,7 @@ jweak ObjectManager::GetJavaObjectByID(uint32_t javaObjectID) { jobject ObjectManager::GetJavaObjectByIDImpl(uint32_t javaObjectID) { JEnv env; + AssertIsolateLocked(); jobject object = env.CallObjectMethod( m_javaRuntimeObject, GET_JAVAOBJECT_BY_ID_METHOD_ID, javaObjectID); return object; @@ -250,6 +251,7 @@ void ObjectManager::SetJavaClass(const Local& instance, jclass clazz) { int ObjectManager::GetOrCreateObjectId(jobject object) { JEnv env; + AssertIsolateLocked(); jint javaObjectID = env.CallIntMethod( m_javaRuntimeObject, GET_OR_CREATE_JAVA_OBJECT_ID_METHOD_ID, object); @@ -258,12 +260,14 @@ int ObjectManager::GetOrCreateObjectId(jobject object) { int ObjectManager::FindObjectId(jobject object) { JEnv env; + AssertIsolateLocked(); return env.CallIntMethod(m_javaRuntimeObject, FIND_JAVA_OBJECT_ID_METHOD_ID, object); } void ObjectManager::RegisterStrong(jobject object, int javaObjectID) { JEnv env; + AssertIsolateLocked(); env.CallVoidMethod(m_javaRuntimeObject, MAKE_INSTANCE_STRONG_METHOD_ID, object, javaObjectID); } @@ -424,25 +428,125 @@ void ObjectManager::JSObjectFinalizer(Isolate* isolate, return; } - auto javaObjectID = jsInstanceInfo->JavaObjectID; - JEnv env; - jboolean isJavaInstanceAlive = env.CallBooleanMethod( - m_javaRuntimeObject, MAKE_INSTANCE_WEAK_AND_CHECK_IF_ALIVE_METHOD_ID, - javaObjectID); - if (isJavaInstanceAlive) { - // If the Java instance is alive, keep the JavaScript instance alive. - po->SetWeak(callbackState, JSObjectFinalizerStatic, - WeakCallbackType::kFinalizer); + // Kept alive until the batch has asked the Java side whether it may die. + po->SetWeak(callbackState, JSObjectFinalizerStatic, + WeakCallbackType::kFinalizer); + + if (callbackState->queued) { + return; + } + callbackState->queued = true; + auto javaObjectID = static_cast(jsInstanceInfo->JavaObjectID); + (callbackState->javaWeakened ? m_pendingCheck : m_pendingWeaken) + .push_back(javaObjectID); + + // Flushed here once large, so a long synchronous JS loop can hold at most + // this many Java objects strongly past their wrappers' collection; the + // epilogue runs before finalizers, so the rest waits for the event loop. + constexpr size_t kFlushThreshold = 1024; + if (m_pendingWeaken.size() + m_pendingCheck.size() >= kFlushThreshold) { + FlushFinalizedBatch(); } else { - // If the Java instance is dead, this JavaScript instance can be let die. + ScheduleFinalizedBatchFlush(); + } +} + +void ObjectManager::ScheduleFinalizedBatchFlush() { + if (m_flushScheduled) { + return; + } + auto runtime = Runtime::GetRuntime(m_isolate); + auto eventLoop = runtime->GetEventLoop(); + if (eventLoop == nullptr) { + return; + } + m_flushScheduled = true; + int runtimeId = runtime->GetId(); + eventLoop->PostInternal([runtimeId]() { + // Runs on the runtime's own thread; skip if the runtime is gone. + auto current = Runtime::GetCurrentRuntime(); + if (current == nullptr || current->GetId() != runtimeId) { + return; + } + auto isolate = current->GetIsolate(); + v8::Locker locker(isolate); + Isolate::Scope isolateScope(isolate); + HandleScope handleScope(isolate); + current->GetObjectManager()->FlushFinalizedBatch(); + }); +} + +void ObjectManager::FlushFinalizedBatch() { + m_flushScheduled = false; + if (m_pendingWeaken.empty() && m_pendingCheck.empty()) { + return; + } + + auto weaken = std::move(m_pendingWeaken); + auto check = std::move(m_pendingCheck); + m_pendingWeaken.clear(); + m_pendingCheck.clear(); + + JEnv env; + auto weakenCount = static_cast(weaken.size()); + auto checkCount = static_cast(check.size()); + JniLocalRef weakenArray(env.NewIntArray(weakenCount)); + JniLocalRef checkArray(env.NewIntArray(checkCount)); + JNIEnv* raw = env; + raw->SetIntArrayRegion(weakenArray, 0, weakenCount, weaken.data()); + raw->SetIntArrayRegion(checkArray, 0, checkCount, check.data()); + + // Java overwrites the id of every object that is already dead with -1. + AssertIsolateLocked(); + env.CallVoidMethod(m_javaRuntimeObject, PROCESS_FINALIZED_BATCH_METHOD_ID, + (jintArray) weakenArray, weakenCount, + (jintArray) checkArray, checkCount); + + std::vector weakenResult(weaken.size()); + std::vector checkResult(check.size()); + raw->GetIntArrayRegion(weakenArray, 0, weakenCount, weakenResult.data()); + raw->GetIntArrayRegion(checkArray, 0, checkCount, checkResult.data()); + + auto settle = [this](jint id, jint result, bool weakened) { + auto it = m_idToObject.find(id); + if (it == m_idToObject.end()) { + return; + } + if (result < 0) { + ReleaseCollectedWrapper(id); + return; + } + it->second->queued = false; + if (weakened) { + it->second->javaWeakened = true; + } + }; + for (size_t i = 0; i < weaken.size(); i++) { + settle(weaken[i], weakenResult[i], true); + } + for (size_t i = 0; i < check.size(); i++) { + settle(check[i], checkResult[i], false); + } +} + +void ObjectManager::ReleaseCollectedWrapper(int javaObjectID) { + auto it = m_idToObject.find(javaObjectID); + if (it == m_idToObject.end()) { + return; + } + ObjectWeakCallbackState* state = it->second; + Persistent* po = state->target; + auto object = po->Get(m_isolate); + auto jsInstanceInfo = GetJSInstanceInfoFromRuntimeObject(object); + if (jsInstanceInfo != nullptr) { delete jsInstanceInfo; auto jsInfoIdx = static_cast(MetadataNodeKeys::JsInfo); - po->Get(m_isolate)->SetInternalField(jsInfoIdx, Undefined(m_isolate)); - po->Reset(); - m_idToObject.erase(javaObjectID); - delete po; - delete callbackState; + object->SetInternalField(jsInfoIdx, Undefined(m_isolate)); } + m_idToObject.erase(it); + po->Reset(); + delete po; + delete state; } int ObjectManager::GenerateNewObjectID() { @@ -455,6 +559,9 @@ int ObjectManager::GenerateNewObjectID() { void ObjectManager::ReleaseAllRegistered() { HandleScope handleScope(m_isolate); + m_pendingWeaken.clear(); + m_pendingCheck.clear(); + auto jsInfoIdx = static_cast(MetadataNodeKeys::JsInfo); // Detached first: nothing below should observe a half-emptied map, and the @@ -571,6 +678,7 @@ void ObjectManager::ReleaseNativeCounterpart(v8::Local& object) { owner->GetInternalField(jsInfoIdx).As()->Value(v8::kExternalPointerTypeTagDefault)); JEnv env; + AssertIsolateLocked(); env.CallVoidMethod(m_javaRuntimeObject, RELEASE_NATIVE_INSTANCE_METHOD_ID, jsInstanceInfo->JavaObjectID); @@ -607,6 +715,5 @@ jmethodID ObjectManager::GET_JAVAOBJECT_BY_ID_METHOD_ID = nullptr; jmethodID ObjectManager::GET_OR_CREATE_JAVA_OBJECT_ID_METHOD_ID = nullptr; jmethodID ObjectManager::FIND_JAVA_OBJECT_ID_METHOD_ID = nullptr; jmethodID ObjectManager::MAKE_INSTANCE_STRONG_METHOD_ID = nullptr; -jmethodID ObjectManager::MAKE_INSTANCE_WEAK_AND_CHECK_IF_ALIVE_METHOD_ID = - nullptr; +jmethodID ObjectManager::PROCESS_FINALIZED_BATCH_METHOD_ID = nullptr; jmethodID ObjectManager::RELEASE_NATIVE_INSTANCE_METHOD_ID = nullptr; diff --git a/test-app/runtime/src/main/cpp/ObjectManager.h b/test-app/runtime/src/main/cpp/ObjectManager.h index cc2f5d6c0..92dfee30e 100644 --- a/test-app/runtime/src/main/cpp/ObjectManager.h +++ b/test-app/runtime/src/main/cpp/ObjectManager.h @@ -11,6 +11,7 @@ #include "JEnv.h" #include "JniLocalRef.h" #include "LRUCache.h" +#include "NativeScriptAssert.h" #include "v8.h" namespace tns { @@ -137,6 +138,11 @@ class ObjectManager { // Duplicated from jsInfo: the finalizer has to unregister itself even when // the JsInfo field was already cleared and jsInfo freed. uint32_t javaObjectID; + // The Java side holds the object weakly; a finalization now only asks + // whether it is still alive. + bool javaWeakened = false; + // Queued in a finalizer batch that has not been flushed yet. + bool queued = false; }; JSInstanceInfo* GetJSInstanceInfo(const v8::Local& object); @@ -154,6 +160,24 @@ class ObjectManager { void JSObjectFinalizer(v8::Isolate* isolate, ObjectWeakCallbackState* callbackState); + // The Java object table may only be touched by the thread holding the + // isolate lock; every upcall into it checks that in debug builds. + void AssertIsolateLocked() const { + NS_DCHECK(m_isolate == nullptr || v8::Locker::IsLocked(m_isolate)); + } + + void FlushFinalizedBatch(); + + void ScheduleFinalizedBatchFlush(); + + void ReleaseCollectedWrapper(int javaObjectID); + + // Ids whose JS wrapper was collected, waiting for one JNI call that weakens + // (first collection) or liveness-checks (later ones) their Java objects. + std::vector m_pendingWeaken; + std::vector m_pendingCheck; + bool m_flushScheduled = false; + jweak GetJavaObjectByID(uint32_t javaObjectID); jobject GetJavaObjectByIDImpl(uint32_t javaObjectID); @@ -192,7 +216,7 @@ class ObjectManager { static jmethodID MAKE_INSTANCE_STRONG_METHOD_ID; - static jmethodID MAKE_INSTANCE_WEAK_AND_CHECK_IF_ALIVE_METHOD_ID; + static jmethodID PROCESS_FINALIZED_BATCH_METHOD_ID; static jmethodID RELEASE_NATIVE_INSTANCE_METHOD_ID; diff --git a/test-app/runtime/src/main/java/com/tns/Runtime.java b/test-app/runtime/src/main/java/com/tns/Runtime.java index 0cc0b27e7..f965f853b 100644 --- a/test-app/runtime/src/main/java/com/tns/Runtime.java +++ b/test-app/runtime/src/main/java/com/tns/Runtime.java @@ -1026,12 +1026,26 @@ private void makeInstanceStrong(Object instance, int objectId) { } } + /* + * One call per finalizer batch: the JS wrappers of these ids were collected. weaken holds + * first collections (held strongly until now), check later ones. Each entry whose Java + * object is already gone is overwritten with -1 so its wrapper can be released too. + */ @RuntimeCallable - private boolean makeInstanceWeakAndCheckIfAlive(int javaObjectID) { + private void processFinalizedBatch(int[] weaken, int weakenCount, int[] check, int checkCount) { + for (int i = 0; i < weakenCount; i++) { + if (!objectTable.weakenAndCheckAlive(weaken[i])) { + weaken[i] = -1; + } + } + for (int i = 0; i < checkCount; i++) { + if (!objectTable.weakenAndCheckAlive(check[i])) { + check[i] = -1; + } + } if (logger.isEnabled()) { - logger.write("makeInstanceWeakAndCheckIfAlive instance " + javaObjectID); + logger.write("processFinalizedBatch weakened=" + weakenCount + " checked=" + checkCount); } - return objectTable.weakenAndCheckAlive(javaObjectID); } @RuntimeCallable From 130108845ca9aae3c870c7eda68350abf81863ec Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Sun, 8 Nov 2026 20:48:31 -0300 Subject: [PATCH 05/42] perf: settle collected wrappers at the next JS/Java crossing Finalizers only queue ids now; the batch is flushed when JS next calls into Java or Java next calls into JS, with the posted event-loop task as the fallback for a thread that goes idle. JS cannot create or receive a Java object without crossing, so Java objects kept strong for collected wrappers cannot pile up during a long synchronous stretch, and nothing calls into Java or frees other handles inside V8's finalizer pass. A wrapper handed back to JS while its id is queued is skipped by the flush, so its Java object is not weakened or released while JS holds it. A failed flush (Java OOM) is retried after a pause instead of throwing out of a finalizer or into an unrelated JS call. releaseNativeCounterpart is no longer public, since it may only run under the isolate lock. --- .../app/src/main/assets/app/tests/testGC.js | 55 ++++++ .../java/com/tns/tests/WrapperHolder.java | 7 + .../runtime/src/main/cpp/CallbackHandlers.cpp | 1 + .../runtime/src/main/cpp/ObjectManager.cpp | 160 ++++++++++++------ test-app/runtime/src/main/cpp/ObjectManager.h | 18 +- test-app/runtime/src/main/cpp/Runtime.cpp | 3 + .../src/main/java/com/tns/Runtime.java | 4 +- 7 files changed, 197 insertions(+), 51 deletions(-) create mode 100644 test-app/app/src/main/java/com/tns/tests/WrapperHolder.java diff --git a/test-app/app/src/main/assets/app/tests/testGC.js b/test-app/app/src/main/assets/app/tests/testGC.js index a4b63da59..906f69e17 100644 --- a/test-app/app/src/main/assets/app/tests/testGC.js +++ b/test-app/app/src/main/assets/app/tests/testGC.js @@ -314,4 +314,59 @@ describe("Tests garbage collection", function () { test.postCallback(); }); + describe("collected wrappers waiting for a flush", function () { + var Holder = com.tns.tests.WrapperHolder; + + function collectJava() { + for (var i = 0; i < 3; i++) { + java.lang.Runtime.getRuntime().gc(); + java.lang.System.runFinalization(); + } + } + + afterEach(function () { + Holder.held = null; + }); + + it("keep the Java object of a wrapper handed back to JS before the flush", function () { + (function () { + Holder.held = new java.lang.StringBuilder("reacquired"); + })(); + gc(); + var again = Holder.held; + Holder.held = null; + collectJava(); + + expect(again.toString()).toBe("reacquired"); + }); + + it("keep a wrapper handed back to JS while its liveness check is queued", function () { + (function () { + Holder.held = new java.lang.StringBuilder("checked"); + })(); + gc(); + collectJava(); + gc(); + var again = Holder.held; + collectJava(); + + expect(again.toString()).toBe("checked"); + again = null; + gc(); + collectJava(); + }); + + it("let Java reclaim objects during a synchronous loop that never yields", function () { + // Far more than the Java heap: every buffer must become collectable while the loop runs. + var total = 0; + for (var i = 0; i < 160; i++) { + var stream = new java.io.ByteArrayOutputStream(4 * 1024 * 1024); + total += stream.size() + 1; + if (i % 8 === 7) { + gc(); + } + } + expect(total).toBe(160); + }); + }); }); \ No newline at end of file diff --git a/test-app/app/src/main/java/com/tns/tests/WrapperHolder.java b/test-app/app/src/main/java/com/tns/tests/WrapperHolder.java new file mode 100644 index 000000000..fc063fccf --- /dev/null +++ b/test-app/app/src/main/java/com/tns/tests/WrapperHolder.java @@ -0,0 +1,7 @@ +package com.tns.tests; + +// Field reads and writes reach Java without a method call, which the finalizer batch tests +// rely on. +public class WrapperHolder { + public static Object held; +} diff --git a/test-app/runtime/src/main/cpp/CallbackHandlers.cpp b/test-app/runtime/src/main/cpp/CallbackHandlers.cpp index 803a0805c..c20c6068d 100644 --- a/test-app/runtime/src/main/cpp/CallbackHandlers.cpp +++ b/test-app/runtime/src/main/cpp/CallbackHandlers.cpp @@ -88,6 +88,7 @@ void CallbackHandlers::Init(Isolate *isolate) { namespace { struct JavaCallScope { explicit JavaCallScope(Runtime* runtime) : runtime_(runtime) { + runtime_->GetObjectManager()->FlushFinalizedBatchIfPending(); runtime_->EnterJavaCall(); } ~JavaCallScope() { diff --git a/test-app/runtime/src/main/cpp/ObjectManager.cpp b/test-app/runtime/src/main/cpp/ObjectManager.cpp index 7f2496d70..b23a7bba4 100644 --- a/test-app/runtime/src/main/cpp/ObjectManager.cpp +++ b/test-app/runtime/src/main/cpp/ObjectManager.cpp @@ -281,9 +281,12 @@ Local ObjectManager::GetJsObjectByJavaObject(int javaObjectID) { return handleScope.Escape(Local()); } - Persistent* jsObject = it->second->target; + ObjectWeakCallbackState* state = it->second; + if (state->queued) { + state->reacquired = true; + } - auto localObject = Local::New(isolate, *jsObject); + auto localObject = Local::New(isolate, *state->target); return handleScope.Escape(localObject); } @@ -433,6 +436,8 @@ void ObjectManager::JSObjectFinalizer(Isolate* isolate, WeakCallbackType::kFinalizer); if (callbackState->queued) { + // Collected again before the flush, so whatever handed it back has let go. + callbackState->reacquired = false; return; } callbackState->queued = true; @@ -440,29 +445,24 @@ void ObjectManager::JSObjectFinalizer(Isolate* isolate, (callbackState->javaWeakened ? m_pendingCheck : m_pendingWeaken) .push_back(javaObjectID); - // Flushed here once large, so a long synchronous JS loop can hold at most - // this many Java objects strongly past their wrappers' collection; the - // epilogue runs before finalizers, so the rest waits for the event loop. - constexpr size_t kFlushThreshold = 1024; - if (m_pendingWeaken.size() + m_pendingCheck.size() >= kFlushThreshold) { - FlushFinalizedBatch(); - } else { - ScheduleFinalizedBatchFlush(); - } + // Never flushed from here: that would call into Java and free other + // wrappers' handles in the middle of V8's finalizer pass. The next JS<->Java + // crossing flushes; this task covers a thread that goes idle first. + ScheduleFinalizedBatchFlush(); } -void ObjectManager::ScheduleFinalizedBatchFlush() { +bool ObjectManager::ScheduleFinalizedBatchFlush(double delayMs) { if (m_flushScheduled) { - return; + return true; } auto runtime = Runtime::GetRuntime(m_isolate); auto eventLoop = runtime->GetEventLoop(); if (eventLoop == nullptr) { - return; + return false; } m_flushScheduled = true; int runtimeId = runtime->GetId(); - eventLoop->PostInternal([runtimeId]() { + auto flush = [runtimeId]() { // Runs on the runtime's own thread; skip if the runtime is gone. auto current = Runtime::GetCurrentRuntime(); if (current == nullptr || current->GetId() != runtimeId) { @@ -473,60 +473,122 @@ void ObjectManager::ScheduleFinalizedBatchFlush() { Isolate::Scope isolateScope(isolate); HandleScope handleScope(isolate); current->GetObjectManager()->FlushFinalizedBatch(); - }); + }; + if (delayMs > 0) { + eventLoop->PostInternalDelayed(std::move(flush), delayMs); + } else { + eventLoop->PostInternal(std::move(flush)); + } + return true; } void ObjectManager::FlushFinalizedBatch() { m_flushScheduled = false; + m_flushBackingOff = false; if (m_pendingWeaken.empty() && m_pendingCheck.empty()) { return; } + HandleScope handleScope(m_isolate); auto weaken = std::move(m_pendingWeaken); auto check = std::move(m_pendingCheck); m_pendingWeaken.clear(); m_pendingCheck.clear(); + // A wrapper handed back to JS since it was queued is reachable again: its + // Java side keeps its current strength until a later collection. + auto keepCollected = [this](std::vector& ids) { + size_t kept = 0; + for (jint id : ids) { + auto it = m_idToObject.find(id); + if (it == m_idToObject.end()) { + continue; + } + ObjectWeakCallbackState* state = it->second; + if (state->reacquired) { + state->reacquired = false; + state->queued = false; + continue; + } + ids[kept++] = id; + } + ids.resize(kept); + }; + keepCollected(weaken); + keepCollected(check); + if (weaken.empty() && check.empty()) { + return; + } + JEnv env; - auto weakenCount = static_cast(weaken.size()); - auto checkCount = static_cast(check.size()); - JniLocalRef weakenArray(env.NewIntArray(weakenCount)); - JniLocalRef checkArray(env.NewIntArray(checkCount)); JNIEnv* raw = env; - raw->SetIntArrayRegion(weakenArray, 0, weakenCount, weaken.data()); - raw->SetIntArrayRegion(checkArray, 0, checkCount, check.data()); - - // Java overwrites the id of every object that is already dead with -1. - AssertIsolateLocked(); - env.CallVoidMethod(m_javaRuntimeObject, PROCESS_FINALIZED_BATCH_METHOD_ID, - (jintArray) weakenArray, weakenCount, - (jintArray) checkArray, checkCount); - - std::vector weakenResult(weaken.size()); - std::vector checkResult(check.size()); - raw->GetIntArrayRegion(weakenArray, 0, weakenCount, weakenResult.data()); - raw->GetIntArrayRegion(checkArray, 0, checkCount, checkResult.data()); - - auto settle = [this](jint id, jint result, bool weakened) { - auto it = m_idToObject.find(id); - if (it == m_idToObject.end()) { - return; + auto toJava = [raw](const std::vector& ids) -> jintArray { + if (ids.empty()) { + return nullptr; + } + auto count = static_cast(ids.size()); + jintArray array = raw->NewIntArray(count); + if (array != nullptr) { + raw->SetIntArrayRegion(array, 0, count, ids.data()); + } + return array; + }; + JniLocalRef weakenArray(toJava(weaken)); + JniLocalRef checkArray(toJava(check)); + bool failed = (!weaken.empty() && weakenArray.IsNull()) || + (!check.empty() && checkArray.IsNull()); + if (!failed) { + // Java overwrites the id of every object that is already dead with -1. + // Called without JEnv's check, which would throw into whichever JS call + // happens to be crossing. + AssertIsolateLocked(); + raw->CallVoidMethod(m_javaRuntimeObject, PROCESS_FINALIZED_BATCH_METHOD_ID, + (jintArray) weakenArray, + static_cast(weaken.size()), + (jintArray) checkArray, + static_cast(check.size())); + failed = raw->ExceptionCheck(); + } + if (failed) { + // Most likely an OutOfMemoryError. Every Java-side step is idempotent, so + // the whole batch is retried, after a pause so a persistent failure + // cannot spin the event loop. + if (raw->ExceptionCheck()) { + raw->ExceptionDescribe(); + raw->ExceptionClear(); } - if (result < 0) { - ReleaseCollectedWrapper(id); + m_pendingWeaken.insert(m_pendingWeaken.end(), weaken.begin(), weaken.end()); + m_pendingCheck.insert(m_pendingCheck.end(), check.begin(), check.end()); + constexpr double kRetryDelayMs = 100; + m_flushBackingOff = ScheduleFinalizedBatchFlush(kRetryDelayMs); + return; + } + + auto settle = [this, raw](const std::vector& ids, jintArray array, + bool weakened) { + if (ids.empty()) { return; } - it->second->queued = false; - if (weakened) { - it->second->javaWeakened = true; + std::vector results(ids.size()); + raw->GetIntArrayRegion(array, 0, static_cast(ids.size()), + results.data()); + for (size_t i = 0; i < ids.size(); i++) { + auto it = m_idToObject.find(ids[i]); + if (it == m_idToObject.end()) { + continue; + } + if (results[i] < 0) { + ReleaseCollectedWrapper(ids[i]); + continue; + } + it->second->queued = false; + if (weakened) { + it->second->javaWeakened = true; + } } }; - for (size_t i = 0; i < weaken.size(); i++) { - settle(weaken[i], weakenResult[i], true); - } - for (size_t i = 0; i < check.size(); i++) { - settle(check[i], checkResult[i], false); - } + settle(weaken, (jintArray) weakenArray, true); + settle(check, (jintArray) checkArray, false); } void ObjectManager::ReleaseCollectedWrapper(int javaObjectID) { diff --git a/test-app/runtime/src/main/cpp/ObjectManager.h b/test-app/runtime/src/main/cpp/ObjectManager.h index 92dfee30e..0bbf3b5e3 100644 --- a/test-app/runtime/src/main/cpp/ObjectManager.h +++ b/test-app/runtime/src/main/cpp/ObjectManager.h @@ -52,6 +52,16 @@ class ObjectManager { v8::Local GetJsObjectByJavaObject(int javaObjectID); + // Settles the wrappers collected since the last flush. Called on every JS<->Java + // crossing: JS cannot create or receive a Java object without one, so the Java objects + // still held strongly for collected wrappers cannot grow between a GC and the flush. + void FlushFinalizedBatchIfPending() { + if (!m_flushBackingOff && + (!m_pendingWeaken.empty() || !m_pendingCheck.empty())) { + FlushFinalizedBatch(); + } + } + // As GetJsObjectByJavaObject, but empty once the wrapper's link to the Java object was // released. v8::Local GetLinkedJsObjectByJavaObject(int javaObjectID); @@ -143,6 +153,9 @@ class ObjectManager { bool javaWeakened = false; // Queued in a finalizer batch that has not been flushed yet. bool queued = false; + // Handed back to JS while queued: the wrapper is reachable again, so the flush must + // leave the Java side alone and let a later collection queue it anew. + bool reacquired = false; }; JSInstanceInfo* GetJSInstanceInfo(const v8::Local& object); @@ -168,7 +181,8 @@ class ObjectManager { void FlushFinalizedBatch(); - void ScheduleFinalizedBatchFlush(); + // Whether a flush task is pending afterwards; false on a thread with no event loop. + bool ScheduleFinalizedBatchFlush(double delayMs = 0); void ReleaseCollectedWrapper(int javaObjectID); @@ -177,6 +191,8 @@ class ObjectManager { std::vector m_pendingWeaken; std::vector m_pendingCheck; bool m_flushScheduled = false; + // A flush failed; crossings leave the retry to the delayed task. + bool m_flushBackingOff = false; jweak GetJavaObjectByID(uint32_t javaObjectID); diff --git a/test-app/runtime/src/main/cpp/Runtime.cpp b/test-app/runtime/src/main/cpp/Runtime.cpp index a7cd58e9e..980c93456 100644 --- a/test-app/runtime/src/main/cpp/Runtime.cpp +++ b/test-app/runtime/src/main/cpp/Runtime.cpp @@ -619,6 +619,8 @@ jobject Runtime::CallJSMethodNative(JNIEnv* _env, jobject obj, DEBUG_WRITE("CallJSMethodNative called javaObjectID=%d", javaObjectID); + m_objectManager->FlushFinalizedBatchIfPending(); + // javaObjectID may be a hint read without the isolate lock; resolve it exactly when it // does not name a linked wrapper. Local jsObject; @@ -689,6 +691,7 @@ void Runtime::CreateJSInstanceNative(JNIEnv* _env, jobject obj, JEnv env(_env); + m_objectManager->FlushFinalizedBatchIfPending(); m_objectManager->RegisterStrong(javaObject, javaObjectID); string existingClassName = ArgConverter::jstringToString(className); diff --git a/test-app/runtime/src/main/java/com/tns/Runtime.java b/test-app/runtime/src/main/java/com/tns/Runtime.java index f965f853b..f0e6445c6 100644 --- a/test-app/runtime/src/main/java/com/tns/Runtime.java +++ b/test-app/runtime/src/main/java/com/tns/Runtime.java @@ -544,7 +544,9 @@ public void ResetDateTimeConfigurationCache() { } } - public void releaseNativeCounterpart(int nativeObjectId) { + // Only native code may call this, from the thread holding the isolate lock. + @RuntimeCallable + void releaseNativeCounterpart(int nativeObjectId) { objectTable.remove(nativeObjectId); } From 718783b6743a56f440152c6b2ef0e7482574e1e0 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Mon, 9 Nov 2026 01:07:21 -0300 Subject: [PATCH 06/42] test: benchmark new objects of one class against many classes --- .../app/tests/benchmarks/jniBenchmarks.js | 15 ++- .../java/com/tns/tests/ContentionBench.java | 5 +- .../src/main/java/com/tns/tests/JniBench.java | 101 ++++++++++++++++++ 3 files changed, 119 insertions(+), 2 deletions(-) diff --git a/test-app/app/src/main/assets/app/tests/benchmarks/jniBenchmarks.js b/test-app/app/src/main/assets/app/tests/benchmarks/jniBenchmarks.js index 8a0cd3fd1..baabf02aa 100644 --- a/test-app/app/src/main/assets/app/tests/benchmarks/jniBenchmarks.js +++ b/test-app/app/src/main/assets/app/tests/benchmarks/jniBenchmarks.js @@ -268,6 +268,13 @@ function buildBenchmarks() { return s; }); }, + function () { + return measureJs("static.newObjectOfManyClasses() [16 classes]", 20000, function (n) { + var s; + for (var i = 0; i < n; i++) s = JniBench.newObjectOfManyClasses(); + return s; + }); + }, function () { return measureJs("static.newObjectInJava() [new wrapper]", 20000, function (n) { var s; @@ -359,6 +366,12 @@ function buildBenchmarks() { }, function () { return measureCallback("callback.oneObject(Object)", callback, 4, 20000); + }, + function () { + return measureCallback("callback.oneObject(new Object)", callback, 5, 20000); + }, + function () { + return measureCallback("callback.oneObject(new, 16 classes)", callback, 6, 20000); } ]; } @@ -371,7 +384,7 @@ function buildContentionBenchmarks() { }); var list = []; [1, 2, 4].forEach(function (threads) { - [["no args", Bench.NO_ARGS], ["3 existing objects", Bench.EXISTING_OBJECTS], ["3 new objects", Bench.NEW_OBJECTS]].forEach(function (mode) { + [["no args", Bench.NO_ARGS], ["3 existing objects", Bench.EXISTING_OBJECTS], ["3 new objects", Bench.NEW_OBJECTS], ["3 new objects of 16 classes", Bench.NEW_OBJECTS_MANY_CLASSES]].forEach(function (mode) { list.push(function (done) { measureContention("contention " + threads + " thread(s), " + mode[0], sink, threads, 20000, mode[1], done); }); diff --git a/test-app/app/src/main/java/com/tns/tests/ContentionBench.java b/test-app/app/src/main/java/com/tns/tests/ContentionBench.java index 6443c6aa8..58dd8eb9b 100644 --- a/test-app/app/src/main/java/com/tns/tests/ContentionBench.java +++ b/test-app/app/src/main/java/com/tns/tests/ContentionBench.java @@ -17,6 +17,7 @@ public interface Sink { public static final int NO_ARGS = 0; public static final int EXISTING_OBJECTS = 1; public static final int NEW_OBJECTS = 2; + public static final int NEW_OBJECTS_MANY_CLASSES = 3; private static volatile long resultNanos = -1; @@ -48,8 +49,10 @@ public void run() { sink.call0(); } else if (mode == EXISTING_OBJECTS) { sink.call3(shared[0], shared[1], shared[2]); - } else { + } else if (mode == NEW_OBJECTS) { sink.call3(new Object(), new Object(), new Object()); + } else { + sink.call3(JniBench.newOfClass(i), JniBench.newOfClass(i + 5), JniBench.newOfClass(i + 11)); } } } diff --git a/test-app/app/src/main/java/com/tns/tests/JniBench.java b/test-app/app/src/main/java/com/tns/tests/JniBench.java index ab3313ebb..885ac252e 100644 --- a/test-app/app/src/main/java/com/tns/tests/JniBench.java +++ b/test-app/app/src/main/java/com/tns/tests/JniBench.java @@ -126,6 +126,101 @@ public static Object newObjectInJava() { return new Object(); } + // Sixteen distinct classes, for call sites that see many runtime classes, as the + // generic ones in an app do (View, Object, Drawable...). + public static final class K0 { + } + + public static final class K1 { + } + + public static final class K2 { + } + + public static final class K3 { + } + + public static final class K4 { + } + + public static final class K5 { + } + + public static final class K6 { + } + + public static final class K7 { + } + + public static final class K8 { + } + + public static final class K9 { + } + + public static final class K10 { + } + + public static final class K11 { + } + + public static final class K12 { + } + + public static final class K13 { + } + + public static final class K14 { + } + + public static final class K15 { + } + + public static Object newOfClass(int i) { + switch (i & 15) { + case 0: + return new K0(); + case 1: + return new K1(); + case 2: + return new K2(); + case 3: + return new K3(); + case 4: + return new K4(); + case 5: + return new K5(); + case 6: + return new K6(); + case 7: + return new K7(); + case 8: + return new K8(); + case 9: + return new K9(); + case 10: + return new K10(); + case 11: + return new K11(); + case 12: + return new K12(); + case 13: + return new K13(); + case 14: + return new K14(); + case 15: + return new K15(); + default: + throw new AssertionError(); + } + } + + private static int nextClass; + + public static Object newObjectOfManyClasses() { + return newOfClass(nextClass++); + } + /** Returns total elapsed nanoseconds for {@code n} Java -> JS calls of the given kind. */ public static long runCallbacks(Callback cb, int kind, int n) { Object arg = new Object(); @@ -146,6 +241,12 @@ public static long runCallbacks(Callback cb, int kind, int n) { case 4: for (int i = 0; i < n; i++) cb.oneObject(arg); break; + case 5: + for (int i = 0; i < n; i++) cb.oneObject(new Object()); + break; + case 6: + for (int i = 0; i < n; i++) cb.oneObject(newOfClass(i)); + break; default: throw new IllegalArgumentException("kind " + kind); } From 5e166e9cd2efb0546311d909886276ac91c1e9b9 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Mon, 9 Nov 2026 01:15:25 -0300 Subject: [PATCH 07/42] perf: look up a wrapped object's class by index instead of by name Java hands every class a dense index (once per class per process) and sends it with each object: packed next to the id hint for Java->JS arguments, and returned with the id by the single upcall native already makes for returns and field reads. Native caches the class's JNI name and a global ref of the object's own class by that index, so wrapping a new object no longer calls Class.getName() and FindClass, and links the wrapper to the exact runtime class even when another loader defines the same name. The first sight of a class also stores it by name, which replaces the last-stored-class shortcut. An object that already has an id comes back without its class index; native asks for the class only when it has to create a wrapper. --- .../runtime/src/main/cpp/ArgConverter.cpp | 6 +- test-app/runtime/src/main/cpp/ArrayHelper.cpp | 3 +- .../runtime/src/main/cpp/CallbackHandlers.cpp | 15 +--- .../runtime/src/main/cpp/FieldAccessor.cpp | 7 +- .../runtime/src/main/cpp/ObjectManager.cpp | 80 ++++++++++++++++--- test-app/runtime/src/main/cpp/ObjectManager.h | 26 +++++- .../src/main/java/com/tns/Runtime.java | 57 ++++++++++--- 7 files changed, 147 insertions(+), 47 deletions(-) diff --git a/test-app/runtime/src/main/cpp/ArgConverter.cpp b/test-app/runtime/src/main/cpp/ArgConverter.cpp index bcbcf475c..cb1d5d5c5 100644 --- a/test-app/runtime/src/main/cpp/ArgConverter.cpp +++ b/test-app/runtime/src/main/cpp/ArgConverter.cpp @@ -155,8 +155,10 @@ void ArgConverter::ConvertJavaArgsToJsArgs(Isolate* isolate, JNIEnv* env, const break; } case Type::JsObject: { - // values[i] is an id Java read without the isolate lock, or -1. + // values[i] packs an id Java read without the isolate lock (or -1) in + // its low half and the class index in its high half. auto hintedObjectID = static_cast(bits); + auto classIndex = static_cast(bits >> 32); if (hintedObjectID >= 0) { jsArg = objectManager->GetLinkedJsObjectByJavaObject(hintedObjectID); } @@ -165,7 +167,7 @@ void ArgConverter::ConvertJavaArgsToJsArgs(Isolate* isolate, JNIEnv* env, const auto javaObjectID = objectManager->GetOrCreateObjectId(arg); jsArg = objectManager->GetJsObjectByJavaObject(javaObjectID); if (jsArg.IsEmpty()) { - jsArg = objectManager->CreateJSWrapper(javaObjectID, "", arg); + jsArg = objectManager->CreateJSWrapper(javaObjectID, classIndex, arg); } } break; diff --git a/test-app/runtime/src/main/cpp/ArrayHelper.cpp b/test-app/runtime/src/main/cpp/ArrayHelper.cpp index 668993dbd..c8df01fa7 100644 --- a/test-app/runtime/src/main/cpp/ArrayHelper.cpp +++ b/test-app/runtime/src/main/cpp/ArrayHelper.cpp @@ -113,8 +113,7 @@ void ArrayHelper::CreateJavaArray(const v8::FunctionCallbackInfo& inf return; } - jint javaObjectID = objectManager->GetOrCreateObjectId(array); - auto jsWrapper = objectManager->CreateJSWrapper(javaObjectID, "" /* ignored */, array); + auto jsWrapper = objectManager->GetOrCreateJSWrapper(array); info.GetReturnValue().Set(jsWrapper); } diff --git a/test-app/runtime/src/main/cpp/CallbackHandlers.cpp b/test-app/runtime/src/main/cpp/CallbackHandlers.cpp index c20c6068d..ed72ecb86 100644 --- a/test-app/runtime/src/main/cpp/CallbackHandlers.cpp +++ b/test-app/runtime/src/main/cpp/CallbackHandlers.cpp @@ -561,13 +561,7 @@ void CallbackHandlers::CallJavaMethod(const Local &caller, const string if (isString) { objectResult = ArgConverter::jstringToV8String(isolate, (jstring) result); } else { - jint javaObjectID = objectManager->GetOrCreateObjectId(result); - objectResult = objectManager->GetJsObjectByJavaObject(javaObjectID); - - if (objectResult.IsEmpty()) { - objectResult = objectManager->CreateJSWrapper(javaObjectID, *returnType, - result); - } + objectResult = objectManager->GetOrCreateJSWrapper(result); } args.GetReturnValue().Set(objectResult); @@ -1080,12 +1074,7 @@ Local CallbackHandlers::FindClass(Isolate *isolate, const string &classN auto runtime = Runtime::GetRuntime(isolate); auto objectManager = runtime->GetObjectManager(); - jint javaObjectID = objectManager->GetOrCreateObjectId(c); - clazz = objectManager->GetJsObjectByJavaObject(javaObjectID); - - if (clazz.IsEmpty()) { - clazz = objectManager->CreateJSWrapper(javaObjectID, "Ljava/lang/Class;", c); - } + clazz = objectManager->GetOrCreateJSWrapper(c); } return clazz; } diff --git a/test-app/runtime/src/main/cpp/FieldAccessor.cpp b/test-app/runtime/src/main/cpp/FieldAccessor.cpp index 6837fa214..9a668585f 100644 --- a/test-app/runtime/src/main/cpp/FieldAccessor.cpp +++ b/test-app/runtime/src/main/cpp/FieldAccessor.cpp @@ -165,12 +165,7 @@ Local FieldAccessor::GetJavaField(Isolate* isolate, const Local& auto resultV8Value = ArgConverter::jstringToV8String(isolate, (jstring) result); fieldResult = handleScope.Escape(resultV8Value); } else { - int javaObjectID = objectManager->GetOrCreateObjectId(result); - auto objectResult = objectManager->GetJsObjectByJavaObject(javaObjectID); - - if (objectResult.IsEmpty()) { - objectResult = objectManager->CreateJSWrapper(javaObjectID, fieldTypeName, result); - } + auto objectResult = objectManager->GetOrCreateJSWrapper(result); fieldResult = handleScope.Escape(objectResult); } diff --git a/test-app/runtime/src/main/cpp/ObjectManager.cpp b/test-app/runtime/src/main/cpp/ObjectManager.cpp index b23a7bba4..383df7288 100644 --- a/test-app/runtime/src/main/cpp/ObjectManager.cpp +++ b/test-app/runtime/src/main/cpp/ObjectManager.cpp @@ -53,6 +53,10 @@ void ObjectManager::InitializeJNI() { runtimeClass, "getOrCreateJavaObjectID", "(Ljava/lang/Object;)I"); NS_CHECK(GET_OR_CREATE_JAVA_OBJECT_ID_METHOD_ID != nullptr); + GET_OR_CREATE_JAVA_OBJECT_ID_AND_CLASS_METHOD_ID = env.GetMethodID( + runtimeClass, "getOrCreateJavaObjectIDAndClass", "(Ljava/lang/Object;)J"); + NS_CHECK(GET_OR_CREATE_JAVA_OBJECT_ID_AND_CLASS_METHOD_ID != nullptr); + FIND_JAVA_OBJECT_ID_METHOD_ID = env.GetMethodID( runtimeClass, "findJavaObjectID", "(Ljava/lang/Object;)I"); NS_CHECK(FIND_JAVA_OBJECT_ID_METHOD_ID != nullptr); @@ -303,30 +307,76 @@ Local ObjectManager::CreateJSWrapper(jint javaObjectID, return CreateJSWrapperHelper(javaObjectID, typeName, nullptr); } -Local ObjectManager::CreateJSWrapper(jint javaObjectID, - const string& typeName, +Local ObjectManager::GetOrCreateJSWrapper(jobject object) { + JEnv env; + AssertIsolateLocked(); + jlong packed = env.CallLongMethod( + m_javaRuntimeObject, GET_OR_CREATE_JAVA_OBJECT_ID_AND_CLASS_METHOD_ID, + object); + auto javaObjectID = static_cast(packed); + auto wrapper = GetJsObjectByJavaObject(javaObjectID); + if (!wrapper.IsEmpty()) { + return wrapper; + } + return CreateJSWrapper(javaObjectID, static_cast(packed >> 32), object); +} + +Local ObjectManager::CreateJSWrapper(jint javaObjectID, int classIndex, jobject instance) { + jclass clazz; + std::string scratch; + const auto& className = WrapperClassFor(classIndex, instance, clazz, scratch); + return CreateJSWrapperHelper(javaObjectID, className, clazz); +} + +const std::string& ObjectManager::WrapperClassFor(int classIndex, + jobject instance, + jclass& clazz, + std::string& scratch) { + if (classIndex > 0 && + static_cast(classIndex) < m_wrapperClasses.size() && + m_wrapperClasses[classIndex] != nullptr) { + clazz = m_wrapperClasses[classIndex]->clazz; + return m_wrapperClasses[classIndex]->name; + } + JEnv env; - JniLocalRef clazz(env.GetObjectClass(instance)); + JniLocalRef localClass(env.GetObjectClass(instance)); + auto name = GetClassName(static_cast(localClass)); + if (classIndex <= 0) { + scratch = std::move(name); + clazz = nullptr; + return scratch; + } - return CreateJSWrapperHelper(javaObjectID, typeName, clazz); + // The object's own class, not whatever its name resolves to: they differ + // when two class loaders define the same name. + auto entry = std::make_unique(); + entry->name = std::move(name); + entry->clazz = static_cast(env.NewGlobalRef(localClass)); + if (static_cast(classIndex) >= m_wrapperClasses.size()) { + m_wrapperClasses.resize(classIndex + 1); + } + m_wrapperClasses[classIndex] = std::move(entry); + clazz = m_wrapperClasses[classIndex]->clazz; + return m_wrapperClasses[classIndex]->name; } Local ObjectManager::CreateJSWrapperHelper(jint javaObjectID, - const string& typeName, + const string& className, jclass clazz) { auto isolate = m_isolate; - auto className = (clazz != nullptr) ? GetClassName(clazz) : typeName; - auto node = MetadataNode::GetOrCreate(className); auto jsWrapper = node->CreateJSWrapper(isolate, this); if (!jsWrapper.IsEmpty()) { - JEnv env; - auto claz = env.FindClass(className); - Link(jsWrapper, javaObjectID, claz); + if (clazz == nullptr) { + JEnv env; + clazz = env.FindClass(className); + } + Link(jsWrapper, javaObjectID, clazz); } return jsWrapper; } @@ -663,6 +713,14 @@ ObjectManager::~ObjectManager() { // objects abandoned the whole cache when it died. ART's weak-global table is // bounded, so enough worker cycles turned that leak into an abort. m_cache.clear(); + + JEnv env; + for (const auto& entry : m_wrapperClasses) { + if (entry != nullptr) { + env.DeleteGlobalRef(entry->clazz); + } + } + m_wrapperClasses.clear(); } jweak ObjectManager::NewWeakGlobalRefCallback(const int& javaObjectID, @@ -775,6 +833,8 @@ jclass ObjectManager::JAVA_LANG_CLASS = nullptr; jmethodID ObjectManager::GET_NAME_METHOD_ID = nullptr; jmethodID ObjectManager::GET_JAVAOBJECT_BY_ID_METHOD_ID = nullptr; jmethodID ObjectManager::GET_OR_CREATE_JAVA_OBJECT_ID_METHOD_ID = nullptr; +jmethodID ObjectManager::GET_OR_CREATE_JAVA_OBJECT_ID_AND_CLASS_METHOD_ID = + nullptr; jmethodID ObjectManager::FIND_JAVA_OBJECT_ID_METHOD_ID = nullptr; jmethodID ObjectManager::MAKE_INSTANCE_STRONG_METHOD_ID = nullptr; jmethodID ObjectManager::PROCESS_FINALIZED_BATCH_METHOD_ID = nullptr; diff --git a/test-app/runtime/src/main/cpp/ObjectManager.h b/test-app/runtime/src/main/cpp/ObjectManager.h index 0bbf3b5e3..a68f47ae5 100644 --- a/test-app/runtime/src/main/cpp/ObjectManager.h +++ b/test-app/runtime/src/main/cpp/ObjectManager.h @@ -2,6 +2,7 @@ #define OBJECTMANAGER_H_ #include +#include #include #include #include @@ -69,8 +70,11 @@ class ObjectManager { v8::Local CreateJSWrapper(jint javaObjectID, const std::string& typeName); - v8::Local CreateJSWrapper(jint javaObjectID, - const std::string& typeName, + // The wrapper of object, registering it and creating the wrapper if needed. + v8::Local GetOrCreateJSWrapper(jobject object); + + // classIndex is Runtime.classIndexOf(instance's class), or 0 when unknown. + v8::Local CreateJSWrapper(jint javaObjectID, int classIndex, jobject instance); void Link(const v8::Local& object, uint32_t javaObjectID, @@ -163,10 +167,24 @@ class ObjectManager { JSInstanceInfo* GetJSInstanceInfoFromRuntimeObject( const v8::Local& object); + // clazz must be a global ref, or null to look className up. v8::Local CreateJSWrapperHelper(jint javaObjectID, - const std::string& typeName, + const std::string& className, jclass clazz); + struct WrapperClass { + std::string name; + jclass clazz; // global ref + }; + + // The JNI name and a global ref of instance's class, cached by class index. + const std::string& WrapperClassFor(int classIndex, jobject instance, + jclass& clazz, std::string& scratch); + + // Indexed by class index. Entries are never replaced, so references to them + // survive a nested wrap growing the vector. + std::vector> m_wrapperClasses; + static void JSObjectFinalizerStatic( const v8::WeakCallbackInfo& data); @@ -228,6 +246,8 @@ class ObjectManager { static jmethodID GET_OR_CREATE_JAVA_OBJECT_ID_METHOD_ID; + static jmethodID GET_OR_CREATE_JAVA_OBJECT_ID_AND_CLASS_METHOD_ID; + static jmethodID FIND_JAVA_OBJECT_ID_METHOD_ID; static jmethodID MAKE_INSTANCE_STRONG_METHOD_ID; diff --git a/test-app/runtime/src/main/java/com/tns/Runtime.java b/test-app/runtime/src/main/java/com/tns/Runtime.java index f0e6445c6..43565a284 100644 --- a/test-app/runtime/src/main/java/com/tns/Runtime.java +++ b/test-app/runtime/src/main/java/com/tns/Runtime.java @@ -247,6 +247,34 @@ public int compare(Method lhs, Method rhs) { private static final ClassStorageService classStorageService = new ClassStorageServiceImpl(ClassCacheImpl.INSTANCE, ClassLoadersCollectionImpl.INSTANCE); + // A dense index per runtime class, shared by every runtime, so native code can cache a + // class's name and global ref by index instead of asking Java for them per object. 0 is + // never assigned; native reads it as "no index". + private static final ConcurrentHashMap, Integer> classIndices = new ConcurrentHashMap<>(); + private static int nextClassIndex = 1; + + static int classIndexOf(Class clazz) { + Integer index = classIndices.get(clazz); + return index != null ? index : registerClass(clazz); + } + + private static int registerClass(Class clazz) { + synchronized (classIndices) { + Integer index = classIndices.get(clazz); + if (index == null) { + classStorageService.storeClass(clazz.getName(), clazz); + index = nextClassIndex++; + classIndices.put(clazz, index); + } + return index; + } + } + + // An object id in the low half, the object's class index in the high half. + static long packIdAndClass(int objectId, Object obj) { + return ((long) classIndexOf(obj.getClass()) << 32) | (objectId & 0xFFFFFFFFL); + } + public Runtime(ClassResolver classResolver, GcListener gcListener, StaticConfiguration config, DynamicConfiguration dynamicConfig, int runtimeId, int workerId, ObjectTable objectTable) { this.classResolver = classResolver; this.gcListener = gcListener; @@ -1004,8 +1032,6 @@ private static String getTypeMetadata(Class clazz) { return ret; } - private Class lastStoredClass; - @RuntimeCallable private void makeInstanceStrong(Object instance, int objectId) { if (instance == null) { @@ -1015,13 +1041,8 @@ private void makeInstanceStrong(Object instance, int objectId) { int key = objectId; objectTable.putStrong(key, instance); - Class clazz = instance.getClass(); - // Instances of one class are usually registered back to back, and storeClass takes - // two locks. A racing read only costs a redundant store. - if (clazz != lastStoredClass) { - classStorageService.storeClass(clazz.getName(), clazz); - lastStoredClass = clazz; - } + // Stores the class by name on its first registration. + classIndexOf(instance.getClass()); if (logger != null && logger.isEnabled()) { logger.write("MakeInstanceStrong (" + key + ", " + instance.getClass().toString() + ")"); @@ -1076,6 +1097,19 @@ private int findJavaObjectID(Object obj) { return objectTable.findId(obj); } + // The class index is only needed to create a wrapper, and an object that already has + // an id almost always has one; native asks for the class itself when it does not. + @RuntimeCallable + private long getOrCreateJavaObjectIDAndClass(Object obj) { + int id = objectTable.findId(obj); + if (id != ObjectTable.NO_ID) { + return id & 0xFFFFFFFFL; + } + id = generateNewObjectId(getRuntimeId()); + makeInstanceStrong(obj, id); + return packIdAndClass(id, obj); + } + @RuntimeCallable private int getOrCreateJavaObjectID(Object obj) { Integer result = getJavaObjectID(obj); @@ -1254,9 +1288,10 @@ private PackagedArgs packageArgs(Object... args) { if (refs == null) { refs = new Object[len]; } - // A hint; resolved or registered natively, under the isolate lock, on a miss. + // The id is a hint; resolved or registered natively, under the isolate lock, + // on a miss. refs[i] = value; - values[i] = objectTable.findIdOptimistic(value); + values[i] = packIdAndClass(objectTable.findIdOptimistic(value), value); } types[i] = typeId; From 5eb7b3cba953d73fc94088244f010f7ea12d8902 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Mon, 9 Nov 2026 01:19:27 -0300 Subject: [PATCH 08/42] perf: register new argument objects before taking the isolate lock A Java->JS call with an object JS has never seen used to register it from native code under the isolate lock, so threads calling in with new objects serialized on that upcall. packageArgs now registers the object itself (ObjectTable.findOrRegister: lookup and insert under one write lock, so concurrent registrations agree on one id) and marks the argument fresh; native wraps a fresh argument without asking Java again. The exact reads made under the isolate lock now validate against the same StampedLock, falling back to its read lock, since writers may be on other threads. If a call fails before its arguments are converted, the objects it registered are released again rather than staying strong. Where StampedLock is missing (API < 24) nothing changes: registration stays under the isolate lock. --- .../app/src/main/assets/app/tests/testGC.js | 31 +++++ .../main/java/com/tns/tests/FreshArgs.java | 52 ++++++++ .../runtime/src/main/cpp/ArgConverter.cpp | 13 +- test-app/runtime/src/main/cpp/ArgConverter.h | 7 + .../runtime/src/main/cpp/ObjectManager.cpp | 39 ++++++ test-app/runtime/src/main/cpp/ObjectManager.h | 11 ++ .../runtime/src/main/cpp/com_tns_Runtime.cpp | 5 +- .../src/main/java/com/tns/ObjectTable.java | 105 ++++++++++++++- .../src/main/java/com/tns/Runtime.java | 28 +++- .../test/java/com/tns/ObjectTableTest.java | 126 ++++++++++++++++++ 10 files changed, 404 insertions(+), 13 deletions(-) create mode 100644 test-app/app/src/main/java/com/tns/tests/FreshArgs.java diff --git a/test-app/app/src/main/assets/app/tests/testGC.js b/test-app/app/src/main/assets/app/tests/testGC.js index 906f69e17..4c4d58a75 100644 --- a/test-app/app/src/main/assets/app/tests/testGC.js +++ b/test-app/app/src/main/assets/app/tests/testGC.js @@ -356,6 +356,37 @@ describe("Tests garbage collection", function () { collectJava(); }); + it("release an object Java registered for a call that failed before reaching it", function () { + // Not a function: the call fails before its argument is converted. + var target = new com.tns.tests.FreshArgs.Target({ take: 42 }); + var ref = com.tns.tests.FreshArgs.callWithNewObject(target); + collectJava(); + + expect(ref.get()).toBe(null); + }); + + it("give threads passing the same new object at once one wrapper", function (done) { + var FreshArgs = com.tns.tests.FreshArgs; + var received = []; + var target = new FreshArgs.Target({ + take: function (o) { + received.push(o); + } + }); + FreshArgs.startFromThreadsAtOnce(target, 4); + (function poll() { + if (FreshArgs.finishedCalls() < 4) { + setTimeout(poll, 10); + return; + } + expect(received.length).toBe(4); + for (var i = 1; i < received.length; i++) { + expect(received[i] === received[0]).toBe(true); + } + done(); + })(); + }); + it("let Java reclaim objects during a synchronous loop that never yields", function () { // Far more than the Java heap: every buffer must become collectable while the loop runs. var total = 0; diff --git a/test-app/app/src/main/java/com/tns/tests/FreshArgs.java b/test-app/app/src/main/java/com/tns/tests/FreshArgs.java new file mode 100644 index 000000000..5ca9b899b --- /dev/null +++ b/test-app/app/src/main/java/com/tns/tests/FreshArgs.java @@ -0,0 +1,52 @@ +package com.tns.tests; + +import java.lang.ref.WeakReference; +import java.util.concurrent.CountDownLatch; + +// Java->JS calls with objects JS has never seen, which Java registers before taking the +// isolate lock. +public class FreshArgs { + public interface Target { + void take(Object o); + } + + public static WeakReference callWithNewObject(Target target) { + Object o = new Object(); + WeakReference ref = new WeakReference<>(o); + try { + target.take(o); + } catch (Throwable e) { + // The call is expected to fail before JS sees the argument. + } + return ref; + } + + private static final java.util.concurrent.atomic.AtomicInteger finishedCalls = new java.util.concurrent.atomic.AtomicInteger(); + + public static int finishedCalls() { + return finishedCalls.get(); + } + + // Returns at once: the JS thread must go idle so the workers can take the isolate lock. + public static void startFromThreadsAtOnce(final Target target, int threads) { + finishedCalls.set(0); + final Object shared = new Object(); + final CountDownLatch go = new CountDownLatch(1); + for (int t = 0; t < threads; t++) { + new Thread(new Runnable() { + @Override + public void run() { + try { + go.await(); + target.take(shared); + } catch (InterruptedException e) { + return; + } finally { + finishedCalls.incrementAndGet(); + } + } + }).start(); + } + go.countDown(); + } +} diff --git a/test-app/runtime/src/main/cpp/ArgConverter.cpp b/test-app/runtime/src/main/cpp/ArgConverter.cpp index cb1d5d5c5..25dc2f3ae 100644 --- a/test-app/runtime/src/main/cpp/ArgConverter.cpp +++ b/test-app/runtime/src/main/cpp/ArgConverter.cpp @@ -155,11 +155,15 @@ void ArgConverter::ConvertJavaArgsToJsArgs(Isolate* isolate, JNIEnv* env, const break; } case Type::JsObject: { - // values[i] packs an id Java read without the isolate lock (or -1) in - // its low half and the class index in its high half. + // values[i] packs an id in its low half (exact when FreshArg is set, else a + // hint Java read without the isolate lock, or -1) and the class index above. auto hintedObjectID = static_cast(bits); - auto classIndex = static_cast(bits >> 32); - if (hintedObjectID >= 0) { + auto classIndex = static_cast((bits >> 32) & 0x7fffffff); + if ((bits & JavaCallArgs::FreshArg) != 0 && + !objectManager->HasJsObject(hintedObjectID)) { + JniLocalRef arg(env->GetObjectArrayElement(args.refs, i)); + jsArg = objectManager->CreateJSWrapper(hintedObjectID, classIndex, arg); + } else if (hintedObjectID >= 0) { jsArg = objectManager->GetLinkedJsObjectByJavaObject(hintedObjectID); } if (jsArg.IsEmpty()) { @@ -179,6 +183,7 @@ void ArgConverter::ConvertJavaArgsToJsArgs(Isolate* isolate, JNIEnv* env, const out[i] = jsArg; } + args.converted = true; } Local ArgConverter::ConvertFromJavaLong(Isolate* isolate, jlong value) { diff --git a/test-app/runtime/src/main/cpp/ArgConverter.h b/test-app/runtime/src/main/cpp/ArgConverter.h index 5e416b6b4..e0da38766 100644 --- a/test-app/runtime/src/main/cpp/ArgConverter.h +++ b/test-app/runtime/src/main/cpp/ArgConverter.h @@ -21,6 +21,13 @@ struct JavaCallArgs { jintArray types; jlongArray values; jobjectArray refs; + // Set once every argument has a JS value, so each object Java registered for this + // call (FreshArg) has a wrapper and nothing is left to release if the call fails. + mutable bool converted = false; + + // Set in an object argument's packed value when Java registered the object for this + // call; the low half is then its exact id and the next 31 bits its class index. + static constexpr jlong FreshArg = static_cast(1ULL << 63); }; class ArgConverter { diff --git a/test-app/runtime/src/main/cpp/ObjectManager.cpp b/test-app/runtime/src/main/cpp/ObjectManager.cpp index 383df7288..fdb874e57 100644 --- a/test-app/runtime/src/main/cpp/ObjectManager.cpp +++ b/test-app/runtime/src/main/cpp/ObjectManager.cpp @@ -4,6 +4,7 @@ #include #include "ArgConverter.h" +#include "JType.h" #include "ManualInstrumentation.h" #include "MetadataNode.h" #include "NativeScriptAssert.h" @@ -294,6 +295,44 @@ Local ObjectManager::GetJsObjectByJavaObject(int javaObjectID) { return handleScope.Escape(localObject); } +void ObjectManager::ReleaseUnwrappedArgs(const JavaCallArgs& args) { + if (args.converted || args.types == nullptr || args.values == nullptr) { + return; + } + JEnv env; + JNIEnv* raw = env; + // Runs after the failure was rethrown to Java; keep that exception for the caller. + jthrowable pending = raw->ExceptionOccurred(); + raw->ExceptionClear(); + + jsize argc = raw->GetArrayLength(args.types); + std::vector types(argc); + std::vector values(argc); + raw->GetIntArrayRegion(args.types, 0, argc, types.data()); + raw->GetLongArrayRegion(args.values, 0, argc, values.data()); + AssertIsolateLocked(); + for (jsize i = 0; i < argc; i++) { + if (static_cast(types[i]) != Type::JsObject || + (values[i] & JavaCallArgs::FreshArg) == 0) { + continue; + } + auto javaObjectID = static_cast(values[i]); + if (HasJsObject(javaObjectID)) { + continue; + } + raw->CallVoidMethod(m_javaRuntimeObject, RELEASE_NATIVE_INSTANCE_METHOD_ID, + javaObjectID); + if (raw->ExceptionCheck()) { + raw->ExceptionClear(); + } + } + + if (pending != nullptr) { + raw->Throw(pending); + raw->DeleteLocalRef(pending); + } +} + Local ObjectManager::GetLinkedJsObjectByJavaObject(int javaObjectID) { auto it = m_idToObject.find(javaObjectID); if (it == m_idToObject.end() || it->second->jsInfo == nullptr) { diff --git a/test-app/runtime/src/main/cpp/ObjectManager.h b/test-app/runtime/src/main/cpp/ObjectManager.h index a68f47ae5..a0c1aee7e 100644 --- a/test-app/runtime/src/main/cpp/ObjectManager.h +++ b/test-app/runtime/src/main/cpp/ObjectManager.h @@ -16,6 +16,8 @@ #include "v8.h" namespace tns { +struct JavaCallArgs; + class ObjectManager { public: ObjectManager(jobject javaRuntimeObject); @@ -63,6 +65,15 @@ class ObjectManager { } } + // Whether a wrapper was ever created for this id and not yet collected. + bool HasJsObject(int javaObjectID) const { + return m_idToObject.find(javaObjectID) != m_idToObject.end(); + } + + // After a failed Java->JS call: drops the Java registration of every object Java + // registered for the call (JavaCallArgs::FreshArg) that never got a wrapper. + void ReleaseUnwrappedArgs(const JavaCallArgs& args); + // As GetJsObjectByJavaObject, but empty once the wrapper's link to the Java object was // released. v8::Local GetLinkedJsObjectByJavaObject(int javaObjectID); diff --git a/test-app/runtime/src/main/cpp/com_tns_Runtime.cpp b/test-app/runtime/src/main/cpp/com_tns_Runtime.cpp index 8e044f6e1..d94b65eb1 100644 --- a/test-app/runtime/src/main/cpp/com_tns_Runtime.cpp +++ b/test-app/runtime/src/main/cpp/com_tns_Runtime.cpp @@ -210,8 +210,9 @@ extern "C" JNIEXPORT jobject Java_com_tns_Runtime_callJSMethodNative(JNIEnv* _en auto context = runtime->GetContext(); v8::Context::Scope context_scope(context); + JavaCallArgs args{argTypes, argValues, argRefs}; try { - result = runtime->CallJSMethodNative(_env, obj, javaObject, javaObjectID, methodName, retType, isConstructor, JavaCallArgs{argTypes, argValues, argRefs}, primitiveResult); + result = runtime->CallJSMethodNative(_env, obj, javaObject, javaObjectID, methodName, retType, isConstructor, args, primitiveResult); } catch (NativeScriptException& e) { e.ReThrowToJava(); } catch (std::exception e) { @@ -223,6 +224,8 @@ extern "C" JNIEXPORT jobject Java_com_tns_Runtime_callJSMethodNative(JNIEnv* _en NativeScriptException nsEx(std::string("Error: c++ exception!")); nsEx.ReThrowToJava(); } + // Objects Java registered for a call that never reached them would stay strong. + runtime->GetObjectManager()->ReleaseUnwrappedArgs(args); return result; } diff --git a/test-app/runtime/src/main/java/com/tns/ObjectTable.java b/test-app/runtime/src/main/java/com/tns/ObjectTable.java index 9dc19e4d0..7aa1c5b0d 100644 --- a/test-app/runtime/src/main/java/com/tns/ObjectTable.java +++ b/test-app/runtime/src/main/java/com/tns/ObjectTable.java @@ -7,13 +7,21 @@ * holds its object strongly while the JS wrapper is reachable and through a WeakReference * after the wrapper was collected (until the object is collected too). * - * Every mutation and every exact read must come from the thread that holds the runtime's - * isolate lock; findIdOptimistic is the one read any thread may make. Neither table - * allocates per entry. + * Where StampedLock exists (API 24+) any thread may read and findOrRegister: writers are + * serialized by its write lock and exact reads validate against it. Without it, every + * mutation and every exact read must come from the thread that holds the runtime's + * isolate lock. Neither table allocates per entry. */ final class ObjectTable { static final int NO_ID = -1; + // Set in findOrRegister's result when that call registered the object. + static final long FRESH = 1L << 32; + + interface IdSource { + int newId(); + } + // Null where StampedLock is unavailable (API < 24): findIdOptimistic then always misses. private final Stamps stamps; @@ -45,6 +53,14 @@ long beginRead() { return lock.tryOptimisticRead(); } + long readLock() { + return lock.readLock(); + } + + void unlockRead(long stamp) { + lock.unlockRead(stamp); + } + boolean validate(long stamp) { return lock.validate(stamp); } @@ -162,8 +178,56 @@ private void putStrongLocked(int id, Object obj) { identityCount++; } + boolean allowsConcurrentRegistration() { + return stamps != null; + } + + /** + * The id obj is registered under, registering it strongly under a new id from ids when + * it has none; FRESH is set in the result when this call registered it. The lookup and + * the insert happen under one write lock, so concurrent calls agree on one id. Only + * when allowsConcurrentRegistration(). + */ + long findOrRegister(Object obj, IdSource ids) { + long stamp = stamps.beginWrite(); + try { + int id = findIdLocked(obj); + if (id != NO_ID) { + return id; + } + id = ids.newId(); + putStrongLocked(id, obj); + return id | FRESH; + } finally { + stamps.endWrite(stamp); + } + } + /** The id obj is registered under, or NO_ID. */ int findId(Object obj) { + if (stamps == null) { + return findIdLocked(obj); + } + long stamp = stamps.beginRead(); + if (stamp != 0) { + try { + int id = findIdUnlocked(obj); + if (stamps.validate(stamp)) { + return id; + } + } catch (RuntimeException e) { + // A torn view of a resize; retried under the read lock. + } + } + stamp = stamps.readLock(); + try { + return findIdLocked(obj); + } finally { + stamps.unlockRead(stamp); + } + } + + private int findIdLocked(Object obj) { int mask = identityIndex.length - 1; int i = mix(System.identityHashCode(obj)) & mask; while (true) { @@ -172,7 +236,7 @@ int findId(Object obj) { return NO_ID; } int id = entry - 1; - if (get(id) == obj) { + if (getLocked(id) == obj) { return id; } i = (i + 1) & mask; @@ -181,6 +245,29 @@ int findId(Object obj) { /** The object registered under id; null if there is none or it was collected. */ Object get(int id) { + if (stamps == null) { + return getLocked(id); + } + long stamp = stamps.beginRead(); + if (stamp != 0) { + try { + Object value = getUnlocked(id); + if (stamps.validate(stamp)) { + return value; + } + } catch (RuntimeException e) { + // A torn view of a resize; retried under the read lock. + } + } + stamp = stamps.readLock(); + try { + return getLocked(id); + } finally { + stamps.unlockRead(stamp); + } + } + + private Object getLocked(int id) { int slot = findIdSlot(id); if (slot < 0) { return null; @@ -190,7 +277,15 @@ Object get(int id) { } boolean contains(int id) { - return findIdSlot(id) >= 0; + if (stamps == null) { + return findIdSlot(id) >= 0; + } + long stamp = stamps.readLock(); + try { + return findIdSlot(id) >= 0; + } finally { + stamps.unlockRead(stamp); + } } /** diff --git a/test-app/runtime/src/main/java/com/tns/Runtime.java b/test-app/runtime/src/main/java/com/tns/Runtime.java index 43565a284..d1357a1fc 100644 --- a/test-app/runtime/src/main/java/com/tns/Runtime.java +++ b/test-app/runtime/src/main/java/com/tns/Runtime.java @@ -180,6 +180,17 @@ public void run() { // findIdOptimistic, whose result the native side re-checks. private final ObjectTable objectTable; + private final ObjectTable.IdSource objectIds = new ObjectTable.IdSource() { + @Override + public int newId() { + return generateNewObjectId(getRuntimeId()); + } + }; + + // Set in a packed argument when packageArgs registered the object itself, before the + // isolate lock; native then wraps it without asking Java again. + private static final long FRESH_ARG = 1L << 63; + private Map, JavaScriptImplementation> loadedJavaScriptExtends = new HashMap, JavaScriptImplementation>(); private final java.lang.Runtime dalvikRuntime = java.lang.Runtime.getRuntime(); @@ -1288,10 +1299,21 @@ private PackagedArgs packageArgs(Object... args) { if (refs == null) { refs = new Object[len]; } - // The id is a hint; resolved or registered natively, under the isolate lock, - // on a miss. refs[i] = value; - values[i] = packIdAndClass(objectTable.findIdOptimistic(value), value); + int id = objectTable.findIdOptimistic(value); + long fresh = 0; + if (id == ObjectTable.NO_ID && objectTable.allowsConcurrentRegistration()) { + // Registered here rather than under the isolate lock, so threads calling + // in with new objects only serialize on creating their wrappers. + long found = objectTable.findOrRegister(value, objectIds); + id = (int) found; + if ((found & ObjectTable.FRESH) != 0) { + fresh = FRESH_ARG; + } + } + // Otherwise the id is a hint, resolved or registered natively under the + // isolate lock. + values[i] = packIdAndClass(id, value) | fresh; } types[i] = typeId; diff --git a/test-app/runtime/src/test/java/com/tns/ObjectTableTest.java b/test-app/runtime/src/test/java/com/tns/ObjectTableTest.java index ed8972b2c..9b80a577a 100644 --- a/test-app/runtime/src/test/java/com/tns/ObjectTableTest.java +++ b/test-app/runtime/src/test/java/com/tns/ObjectTableTest.java @@ -150,6 +150,132 @@ public void run() { assertTrue("optimistic reads should mostly hit", hits.get() > 1000); } + @Test + public void concurrentRegistrationsAgreeOnOneIdPerObject() throws Exception { + final ObjectTable table = new ObjectTable(true); + final java.util.concurrent.atomic.AtomicInteger nextId = new java.util.concurrent.atomic.AtomicInteger(); + final ObjectTable.IdSource ids = new ObjectTable.IdSource() { + @Override + public int newId() { + return nextId.getAndIncrement(); + } + }; + final int objectCount = 5000; + final Object[] objects = new Object[objectCount]; + for (int i = 0; i < objectCount; i++) { + objects[i] = new Object(); + } + final int threadCount = 6; + final int[][] seen = new int[threadCount][objectCount]; + final int[] freshCounts = new int[threadCount]; + final java.util.concurrent.CountDownLatch go = new java.util.concurrent.CountDownLatch(1); + Thread[] threads = new Thread[threadCount]; + for (int t = 0; t < threadCount; t++) { + final int index = t; + threads[t] = new Thread(new Runnable() { + @Override + public void run() { + Random random = new Random(index); + try { + go.await(); + } catch (InterruptedException e) { + return; + } + for (int n = 0; n < objectCount; n++) { + int i = (n * 7 + index * 131 + random.nextInt(3)) % objectCount; + long found = table.findOrRegister(objects[i], ids); + if ((found & ObjectTable.FRESH) != 0) { + freshCounts[index]++; + } + seen[index][i] = (int) found + 1; + } + } + }); + threads[t].start(); + } + go.countDown(); + for (Thread thread : threads) { + thread.join(); + } + + int fresh = 0; + for (int count : freshCounts) { + fresh += count; + } + assertEquals(table.size(), fresh); + for (int i = 0; i < objectCount; i++) { + int id = table.findId(objects[i]); + for (int t = 0; t < threadCount; t++) { + if (seen[t][i] != 0) { + assertEquals(id, seen[t][i] - 1); + } + } + } + } + + @Test + public void exactReadsStayExactWhileAnotherThreadRegisters() throws Exception { + final ObjectTable table = new ObjectTable(true); + final java.util.concurrent.atomic.AtomicInteger nextId = new java.util.concurrent.atomic.AtomicInteger(1000); + final ObjectTable.IdSource ids = new ObjectTable.IdSource() { + @Override + public int newId() { + return nextId.getAndIncrement(); + } + }; + final int stableCount = 64; + final Object[] stable = new Object[stableCount]; + for (int i = 0; i < stableCount; i++) { + stable[i] = new Object(); + table.putStrong(i, stable[i]); + } + final java.util.concurrent.atomic.AtomicBoolean done = new java.util.concurrent.atomic.AtomicBoolean(); + final java.util.concurrent.atomic.AtomicReference failure = new java.util.concurrent.atomic.AtomicReference<>(); + + Thread writer = new Thread(new Runnable() { + @Override + public void run() { + List churn = new ArrayList<>(); + Random random = new Random(3); + while (!done.get()) { + // Grow well past the initial capacity, then shrink, so reads overlap resizes. + if (churn.size() < 20_000 && random.nextInt(3) != 0) { + churn.add((int) table.findOrRegister(new Object(), ids)); + } else if (!churn.isEmpty()) { + table.remove(churn.remove(churn.size() - 1)); + } + } + } + }); + Thread reader = new Thread(new Runnable() { + @Override + public void run() { + Random random = new Random(5); + while (!done.get()) { + int i = random.nextInt(stableCount); + int id = table.findId(stable[i]); + if (id != i) { + failure.compareAndSet(null, "findId: expected " + i + ", got " + id); + } + if (table.get(i) != stable[i]) { + failure.compareAndSet(null, "get(" + i + ") returned another object"); + } + if (!table.contains(i)) { + failure.compareAndSet(null, "contains(" + i + ") was false"); + } + } + } + }); + writer.start(); + reader.start(); + Thread.sleep(2000); + done.set(true); + writer.join(); + reader.join(); + + assertNull(failure.get(), failure.get()); + } + @Test public void optimisticReadsMissWhenDisabled() { ObjectTable table = new ObjectTable(false); From 82ad1bf8cca86448766f968d888c131b4fa33e73 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Mon, 9 Nov 2026 02:34:29 -0300 Subject: [PATCH 09/42] test: let the benchmark filter name several rows --- .../assets/app/tests/benchmarks/jniBenchmarks.js | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/test-app/app/src/main/assets/app/tests/benchmarks/jniBenchmarks.js b/test-app/app/src/main/assets/app/tests/benchmarks/jniBenchmarks.js index baabf02aa..f15bb56e1 100644 --- a/test-app/app/src/main/assets/app/tests/benchmarks/jniBenchmarks.js +++ b/test-app/app/src/main/assets/app/tests/benchmarks/jniBenchmarks.js @@ -8,6 +8,11 @@ var JniBench = com.tns.tests.JniBench; var ROUNDS = 7; var FILTER = String(JniBench.getFilter()); +// Several names may be given, separated by "|"; a benchmark runs when its name contains any. +var FILTERS = FILTER ? FILTER.split("|") : []; +function skipped(name) { + return FILTERS.length > 0 && !FILTERS.some(function (f) { return name.indexOf(f) >= 0; }); +} var SCALE = JniBench.getScale(); function median(values) { @@ -27,7 +32,7 @@ function report(name, nsPerOp) { // body(n) runs the operation n times; returning a value keeps V8 from dropping the loop. function measureJs(name, iterations, body) { - if (FILTER && name.indexOf(FILTER) < 0) return null; + if (skipped(name)) return null; iterations *= SCALE; var sink = body(Math.max(1000, iterations >> 2)); var samples = []; @@ -42,7 +47,7 @@ function measureJs(name, iterations, body) { } function measureCallback(name, cb, kind, iterations) { - if (FILTER && name.indexOf(FILTER) < 0) return null; + if (skipped(name)) return null; iterations *= SCALE; JniBench.runCallbacks(cb, kind, Math.max(1000, iterations >> 2)); var samples = []; @@ -58,7 +63,7 @@ function measureCallback(name, cb, kind, iterations) { // V8 GC (wrapper finalization and the Java-side strong -> weak bookkeeping), and the // Java GC that follows. Each phase is reported per object. function measureLifecycle(name, count, create) { - if (FILTER && name.indexOf(FILTER) < 0) return null; + if (skipped(name)) return null; count *= SCALE; var javaRuntime = java.lang.Runtime.getRuntime(); var alloc = [], release = [], javaGc = []; @@ -93,7 +98,7 @@ function measureLifecycle(name, count, create) { // asynchronously: the JS thread must go idle and release the isolate for the // callers to get in, so it polls for the result. function measureContention(name, sink, threads, calls, mode, done) { - if (FILTER && name.indexOf(FILTER) < 0) { + if (skipped(name)) { done(null); return; } @@ -339,7 +344,7 @@ function buildBenchmarks() { }); }, function () { - if (FILTER && "instance.self() [50k live wrappers]".indexOf(FILTER) < 0) return null; + if (skipped("instance.self() [50k live wrappers]")) return null; var live = new Array(50000); for (var i = 0; i < live.length; i++) live[i] = new java.lang.Object(); // Pinned from a global: an optimized frame may drop a local after its last use. From 71702e036c38e815237ad22ead7209757fc5dbdd Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Mon, 9 Nov 2026 04:10:46 -0300 Subject: [PATCH 10/42] perf: settle a finalizer batch under one write lock, and after each call processFinalizedBatch took the table's write lock once per id while other threads may be registering objects before the isolate lock; it now takes it once per batch. Wrappers collected during a Java->JS call are settled before the call releases the isolate, and the idle-thread fallback task is delayed a few milliseconds so a busy runtime no longer wakes its event loop per GC. --- .../runtime/src/main/cpp/ObjectManager.cpp | 6 +++-- .../runtime/src/main/cpp/com_tns_Runtime.cpp | 5 ++++- .../src/main/java/com/tns/ObjectTable.java | 22 +++++++++++++++++++ .../src/main/java/com/tns/Runtime.java | 12 ++-------- 4 files changed, 32 insertions(+), 13 deletions(-) diff --git a/test-app/runtime/src/main/cpp/ObjectManager.cpp b/test-app/runtime/src/main/cpp/ObjectManager.cpp index fdb874e57..9a5b2ae8f 100644 --- a/test-app/runtime/src/main/cpp/ObjectManager.cpp +++ b/test-app/runtime/src/main/cpp/ObjectManager.cpp @@ -536,8 +536,10 @@ void ObjectManager::JSObjectFinalizer(Isolate* isolate, // Never flushed from here: that would call into Java and free other // wrappers' handles in the middle of V8's finalizer pass. The next JS<->Java - // crossing flushes; this task covers a thread that goes idle first. - ScheduleFinalizedBatchFlush(); + // crossing flushes; this task covers a thread that goes idle first, and is + // delayed so that a busy runtime does not wake its event loop per GC. + constexpr double kIdleFlushDelayMs = 4; + ScheduleFinalizedBatchFlush(kIdleFlushDelayMs); } bool ObjectManager::ScheduleFinalizedBatchFlush(double delayMs) { diff --git a/test-app/runtime/src/main/cpp/com_tns_Runtime.cpp b/test-app/runtime/src/main/cpp/com_tns_Runtime.cpp index d94b65eb1..76d14219b 100644 --- a/test-app/runtime/src/main/cpp/com_tns_Runtime.cpp +++ b/test-app/runtime/src/main/cpp/com_tns_Runtime.cpp @@ -224,8 +224,11 @@ extern "C" JNIEXPORT jobject Java_com_tns_Runtime_callJSMethodNative(JNIEnv* _en NativeScriptException nsEx(std::string("Error: c++ exception!")); nsEx.ReThrowToJava(); } + auto objectManager = runtime->GetObjectManager(); // Objects Java registered for a call that never reached them would stay strong. - runtime->GetObjectManager()->ReleaseUnwrappedArgs(args); + objectManager->ReleaseUnwrappedArgs(args); + // Wrappers collected during the call, settled while the isolate is still held. + objectManager->FlushFinalizedBatchIfPending(); return result; } diff --git a/test-app/runtime/src/main/java/com/tns/ObjectTable.java b/test-app/runtime/src/main/java/com/tns/ObjectTable.java index 7aa1c5b0d..73fb75839 100644 --- a/test-app/runtime/src/main/java/com/tns/ObjectTable.java +++ b/test-app/runtime/src/main/java/com/tns/ObjectTable.java @@ -304,6 +304,28 @@ boolean weakenAndCheckAlive(int id) { } } + /** + * weakenAndCheckAlive for ids[0..count), under one write lock; overwrites the id of + * every object no longer alive with NO_ID. + */ + void weakenAndCheckAlive(int[] ids, int count) { + if (count == 0) { + return; + } + long stamp = stamps == null ? 0 : stamps.beginWrite(); + try { + for (int i = 0; i < count; i++) { + if (!weakenAndCheckAliveLocked(ids[i])) { + ids[i] = NO_ID; + } + } + } finally { + if (stamps != null) { + stamps.endWrite(stamp); + } + } + } + private boolean weakenAndCheckAliveLocked(int id) { int slot = findIdSlot(id); if (slot < 0) { diff --git a/test-app/runtime/src/main/java/com/tns/Runtime.java b/test-app/runtime/src/main/java/com/tns/Runtime.java index d1357a1fc..49df299f2 100644 --- a/test-app/runtime/src/main/java/com/tns/Runtime.java +++ b/test-app/runtime/src/main/java/com/tns/Runtime.java @@ -1067,16 +1067,8 @@ private void makeInstanceStrong(Object instance, int objectId) { */ @RuntimeCallable private void processFinalizedBatch(int[] weaken, int weakenCount, int[] check, int checkCount) { - for (int i = 0; i < weakenCount; i++) { - if (!objectTable.weakenAndCheckAlive(weaken[i])) { - weaken[i] = -1; - } - } - for (int i = 0; i < checkCount; i++) { - if (!objectTable.weakenAndCheckAlive(check[i])) { - check[i] = -1; - } - } + objectTable.weakenAndCheckAlive(weaken, weakenCount); + objectTable.weakenAndCheckAlive(check, checkCount); if (logger.isEnabled()) { logger.write("processFinalizedBatch weakened=" + weakenCount + " checked=" + checkCount); } From 0579b0b90aca02ed088661d7c99f8d198cb2ad8f Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Mon, 9 Nov 2026 04:10:46 -0300 Subject: [PATCH 11/42] perf: keep the next Java caller awake while another holds the isolate With two Java threads calling into JS, every isolate handoff put the waiter to sleep: V8's mutex spins only briefly, a call holds the isolate for tens of microseconds, and waking a sleeper took longer than the call itself, so the isolate sat idle for a wake-up per call. Java->JS calls now pass a per-runtime gate before v8::Locker whose waiters spin for up to 50us before sleeping. Nested calls from a thread that already holds the isolate skip it. Runtime.setAdmissionSpinMicros changes the budget (negative disables the gate) for benchmarks. --- test-app/runtime/src/main/cpp/AdmissionGate.h | 118 ++++++++++++++++++ test-app/runtime/src/main/cpp/Runtime.h | 7 ++ .../runtime/src/main/cpp/com_tns_Runtime.cpp | 8 +- .../src/main/java/com/tns/Runtime.java | 6 + 4 files changed, 138 insertions(+), 1 deletion(-) create mode 100644 test-app/runtime/src/main/cpp/AdmissionGate.h diff --git a/test-app/runtime/src/main/cpp/AdmissionGate.h b/test-app/runtime/src/main/cpp/AdmissionGate.h new file mode 100644 index 000000000..bce9173cd --- /dev/null +++ b/test-app/runtime/src/main/cpp/AdmissionGate.h @@ -0,0 +1,118 @@ +#ifndef ADMISSIONGATE_H_ +#define ADMISSIONGATE_H_ + +#include +#include +#include +#include + +#include "v8-locker.h" + +namespace tns { + +/* + * Orders the Java threads calling into one runtime before they take its + * v8::Locker. V8's mutex spins only briefly before sleeping, and waking a + * sleeper takes far longer than a call usually holds the isolate, so with two + * callers every handoff left the isolate idle for a whole wake-up. A waiter + * here spins for a while first, staying on its core to take over at once. + * + * Held for exactly as long as the Locker it precedes. Releasing the isolate + * with a v8::Unlocker while keeping the gate would block every other Java + * caller on an idle isolate. + */ +class AdmissionGate { + public: + void Enter() { + // A woken sleeper spins again rather than getting one try, or under steady + // load the spinners would keep beating it to the gate. + for (;;) { + if (TryTake() || Spin()) { + return; + } + std::unique_lock lock(m_mutex); + m_sleepers.fetch_add(1); + while (m_busy.load()) { + m_wakeup.wait(lock); + } + m_sleepers.fetch_sub(1); + } + } + + void Exit() { + m_busy.store(false); + // Pairs with a sleeper's increment before it checks m_busy: either it sees + // the gate free, or this sees it waiting. + if (m_sleepers.load() > 0) { + std::lock_guard lock(m_mutex); + m_wakeup.notify_one(); + } + } + + // How long a caller spins before sleeping; negative bypasses the gate. + inline static std::atomic s_spinMicros{50}; + + private: + bool Spin() { + int budget = s_spinMicros.load(std::memory_order_relaxed); + if (budget <= 0) { + return false; + } + auto deadline = std::chrono::steady_clock::now() + std::chrono::microseconds(budget); + for (unsigned i = 1;; i++) { + CpuRelax(); + if (TryTake()) { + return true; + } + if ((i & 63) == 0 && std::chrono::steady_clock::now() >= deadline) { + return false; + } + } + } + + bool TryTake() { + bool expected = false; + return !m_busy.load(std::memory_order_relaxed) && + m_busy.compare_exchange_weak(expected, true, std::memory_order_acquire); + } + + static void CpuRelax() { +#if defined(__aarch64__) || defined(__arm__) + asm volatile("yield"); +#elif defined(__x86_64__) || defined(__i386__) + asm volatile("pause"); +#endif + } + + std::atomic m_busy{false}; + std::atomic m_sleepers{0}; + std::mutex m_mutex; + std::condition_variable m_wakeup; +}; + +// Holds the gate for a call into JS from Java, unless this thread already holds +// the isolate (a nested call), where waiting on the gate could deadlock. +class AdmissionScope { + public: + AdmissionScope(AdmissionGate& gate, v8::Isolate* isolate) { + if (AdmissionGate::s_spinMicros.load(std::memory_order_relaxed) >= 0 && + !v8::Locker::IsLocked(isolate)) { + gate.Enter(); + m_gate = &gate; + } + } + ~AdmissionScope() { + if (m_gate != nullptr) { + m_gate->Exit(); + } + } + AdmissionScope(const AdmissionScope&) = delete; + AdmissionScope& operator=(const AdmissionScope&) = delete; + + private: + AdmissionGate* m_gate = nullptr; +}; + +} // namespace tns + +#endif // ADMISSIONGATE_H_ diff --git a/test-app/runtime/src/main/cpp/Runtime.h b/test-app/runtime/src/main/cpp/Runtime.h index 3b02b715e..f9ceafd8c 100644 --- a/test-app/runtime/src/main/cpp/Runtime.h +++ b/test-app/runtime/src/main/cpp/Runtime.h @@ -13,6 +13,7 @@ #include "File.h" #include "Timers.h" #include "EventLoop.h" +#include "AdmissionGate.h" #include #include #include @@ -168,6 +169,10 @@ class Runtime { ObjectManager* GetObjectManager() const; + AdmissionGate& GetAdmissionGate() { + return m_admissionGate; + } + void RunModule(JNIEnv* _env, jobject obj, jstring scriptFile); void RunModule(const char *moduleName); void RunWorker(const std::string& filePath); @@ -333,6 +338,8 @@ class Runtime { ObjectManager* m_objectManager; + AdmissionGate m_admissionGate; + ModuleInternal m_module; ArrayBufferHelper m_arrayBufferHelper; diff --git a/test-app/runtime/src/main/cpp/com_tns_Runtime.cpp b/test-app/runtime/src/main/cpp/com_tns_Runtime.cpp index 76d14219b..162a7be6b 100644 --- a/test-app/runtime/src/main/cpp/com_tns_Runtime.cpp +++ b/test-app/runtime/src/main/cpp/com_tns_Runtime.cpp @@ -204,6 +204,7 @@ extern "C" JNIEXPORT jobject Java_com_tns_Runtime_callJSMethodNative(JNIEnv* _en } auto isolate = runtime->GetIsolate(); + AdmissionScope admission(runtime->GetAdmissionGate(), isolate); v8::Locker locker(isolate); v8::Isolate::Scope isolate_scope(isolate); v8::HandleScope handleScope(isolate); @@ -240,6 +241,7 @@ extern "C" JNIEXPORT void Java_com_tns_Runtime_createJSInstanceNative(JNIEnv* _e } auto isolate = runtime->GetIsolate(); + AdmissionScope admission(runtime->GetAdmissionGate(), isolate); v8::Locker locker(isolate); v8::Isolate::Scope isolate_scope(isolate); v8::HandleScope handleScope(isolate); @@ -379,4 +381,8 @@ extern "C" JNIEXPORT void Java_com_tns_Runtime_ResetDateTimeConfigurationCache(J auto isolate = runtime->GetIsolate(); isolate->DateTimeConfigurationChangeNotification(Isolate::TimeZoneDetection::kRedetect); -} \ No newline at end of file +} + +extern "C" JNIEXPORT void Java_com_tns_Runtime_setAdmissionSpinMicros(JNIEnv* _env, jclass clazz, jint micros) { + AdmissionGate::s_spinMicros.store(micros, std::memory_order_relaxed); +} diff --git a/test-app/runtime/src/main/java/com/tns/Runtime.java b/test-app/runtime/src/main/java/com/tns/Runtime.java index 49df299f2..b2018f6c7 100644 --- a/test-app/runtime/src/main/java/com/tns/Runtime.java +++ b/test-app/runtime/src/main/java/com/tns/Runtime.java @@ -57,6 +57,12 @@ private native void initNativeScript(int runtimeId, String filesPath, String nat private static native int generateNewObjectIdCritical(int runtimeId); private static native int generateNewObjectIdLegacy(int runtimeId); + /** + * How long a Java thread calling into JS spins for the isolate before sleeping, in + * microseconds; negative lets callers go straight to V8's lock. + */ + public static native void setAdmissionSpinMicros(int micros); + private static int generateNewObjectId(int runtimeId) { return SUPPORTS_OPTIMIZED_NATIVE ? generateNewObjectIdCritical(runtimeId) From 3dc8cc483f229642e3cf2a766c140c7909a27b2f Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Mon, 9 Nov 2026 04:10:46 -0300 Subject: [PATCH 12/42] test: alternate admission spin variants within one benchmark launch The phone used for these numbers flips between two clock states about 1.6x apart, which swamps an A/B across launches. Contention rows can now run several admission spin budgets round by round inside one launch, and the number of rounds is configurable. The settings live on JniBenchConfig rather than JniBench: every static added to JniBench is another property on the JS constructor its static benchmarks are called through, and two more were enough to slow those rows by ~10 ns. --- .../app/tests/benchmarks/jniBenchmarks.js | 28 ++++++++++++++----- .../src/main/java/com/tns/tests/JniBench.java | 2 +- .../java/com/tns/tests/JniBenchConfig.java | 21 ++++++++++++++ 3 files changed, 43 insertions(+), 8 deletions(-) create mode 100644 test-app/app/src/main/java/com/tns/tests/JniBenchConfig.java diff --git a/test-app/app/src/main/assets/app/tests/benchmarks/jniBenchmarks.js b/test-app/app/src/main/assets/app/tests/benchmarks/jniBenchmarks.js index f15bb56e1..349f6d7e1 100644 --- a/test-app/app/src/main/assets/app/tests/benchmarks/jniBenchmarks.js +++ b/test-app/app/src/main/assets/app/tests/benchmarks/jniBenchmarks.js @@ -6,8 +6,9 @@ // the median and minimum ns/op. Java -> JS benchmarks are timed in Java with System.nanoTime. var JniBench = com.tns.tests.JniBench; -var ROUNDS = 7; +var ROUNDS = com.tns.tests.JniBenchConfig.getRounds() || 7; var FILTER = String(JniBench.getFilter()); +var VARIANTS = String(com.tns.tests.JniBenchConfig.getVariants()).split(",").filter(function (v) { return v !== ""; }).map(Number); // Several names may be given, separated by "|"; a benchmark runs when its name contains any. var FILTERS = FILTER ? FILTER.split("|") : []; function skipped(name) { @@ -104,9 +105,20 @@ function measureContention(name, sink, threads, calls, mode, done) { } var Bench = com.tns.tests.ContentionBench; calls *= SCALE; - var samples = []; - var round = -1; + // Variants alternate round by round inside one launch, so a change in the device's + // performance state between launches cannot favour one of them. + var variants = VARIANTS.length > 0 ? VARIANTS : [null]; + var samples = variants.map(function () { return []; }); + var round = -variants.length; + var total = ROUNDS * variants.length; + function variantOf(r) { + return ((r % variants.length) + variants.length) % variants.length; + } function startRound() { + var spin = variants[variantOf(round)]; + if (spin !== null) { + com.tns.Runtime.setAdmissionSpinMicros(spin); + } Bench.start(sink, threads, calls, mode); setTimeout(poll, 5); } @@ -117,14 +129,16 @@ function measureContention(name, sink, threads, calls, mode, done) { return; } if (round >= 0) { - samples.push(nanos / (threads * calls)); + samples[variantOf(round)].push(nanos / (threads * calls)); } round++; - if (round < ROUNDS) { + if (round < total) { gc(); setTimeout(startRound, 20); } else { - done(report(name, samples)); + done(variants.map(function (spin, i) { + return report(spin === null ? name : name + " [spin=" + spin + "]", samples[i]); + })); } } startRound(); @@ -421,7 +435,7 @@ exports.run = function () { return; } contention[contentionIndex++](function (result) { - if (result) results.push(result); + if (result) results.push.apply(results, [].concat(result)); setTimeout(runContention, 50); }); } diff --git a/test-app/app/src/main/java/com/tns/tests/JniBench.java b/test-app/app/src/main/java/com/tns/tests/JniBench.java index 885ac252e..c14b1605f 100644 --- a/test-app/app/src/main/java/com/tns/tests/JniBench.java +++ b/test-app/app/src/main/java/com/tns/tests/JniBench.java @@ -45,7 +45,7 @@ public static int getScale() { } } - private static String getProp(String name) { + static String getProp(String name) { try { Process p = new ProcessBuilder("getprop", name).start(); try (BufferedReader r = new BufferedReader(new InputStreamReader(p.getInputStream()))) { diff --git a/test-app/app/src/main/java/com/tns/tests/JniBenchConfig.java b/test-app/app/src/main/java/com/tns/tests/JniBenchConfig.java new file mode 100644 index 000000000..aa563f56a --- /dev/null +++ b/test-app/app/src/main/java/com/tns/tests/JniBenchConfig.java @@ -0,0 +1,21 @@ +package com.tns.tests; + +/** + * Benchmark settings that only the harness reads, kept off JniBench: every static added + * there is another property on the JS constructor its static benchmarks are called through. + */ +public class JniBenchConfig { + /** Timed rounds per benchmark; 0 keeps the default. */ + public static int getRounds() { + try { + return Math.max(0, Integer.parseInt(JniBench.getProp("debug.ns.jnibench.rounds"))); + } catch (NumberFormatException e) { + return 0; + } + } + + /** Comma-separated admission spin budgets to alternate between, round by round, in contention rows. */ + public static String getVariants() { + return JniBench.getProp("debug.ns.jnibench.variants"); + } +} From bbc2e8be87fee5a08cc7dc10e5b5f3465d8344c5 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Mon, 9 Nov 2026 04:39:40 -0300 Subject: [PATCH 13/42] perf: skip the unconverted-argument cleanup for calls without arguments ConvertJavaArgsToJsArgs returned early for zero arguments before marking the call converted, so every Java->JS call without arguments ran ReleaseUnwrappedArgs' slow path: a JEnv lookup, saving and restoring the pending exception and three array reads, about a third of the native time of callback.noArgs(). Calls without arguments now pass null arrays, so they make no array JNI calls at all. --- test-app/runtime/src/main/cpp/ArgConverter.cpp | 1 + test-app/runtime/src/main/java/com/tns/Runtime.java | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/test-app/runtime/src/main/cpp/ArgConverter.cpp b/test-app/runtime/src/main/cpp/ArgConverter.cpp index 25dc2f3ae..3048824e5 100644 --- a/test-app/runtime/src/main/cpp/ArgConverter.cpp +++ b/test-app/runtime/src/main/cpp/ArgConverter.cpp @@ -98,6 +98,7 @@ int ArgConverter::GetJavaArgsLength(JNIEnv* env, const JavaCallArgs& args) { void ArgConverter::ConvertJavaArgsToJsArgs(Isolate* isolate, JNIEnv* env, const JavaCallArgs& args, int argc, Local* out) { if (argc == 0) { + args.converted = true; return; } diff --git a/test-app/runtime/src/main/java/com/tns/Runtime.java b/test-app/runtime/src/main/java/com/tns/Runtime.java index b2018f6c7..a2bb464ea 100644 --- a/test-app/runtime/src/main/java/com/tns/Runtime.java +++ b/test-app/runtime/src/main/java/com/tns/Runtime.java @@ -1242,7 +1242,7 @@ private static final class PackagedArgs { } } - private static final PackagedArgs NO_ARGS = new PackagedArgs(new int[0], new long[0], null); + private static final PackagedArgs NO_ARGS = new PackagedArgs(null, null, null); // If a javaObject has no javaObjectID, meaning no JavaScript object exists // for it yet, it is assigned one. From e1d81c3bc34b8d339080fb122e893dbb8609bb92 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Mon, 9 Nov 2026 04:40:24 -0300 Subject: [PATCH 14/42] perf: read a callback's argument types and values in one JNI call Arguments crossed as an int[] of types and a long[] of values, so native made three JNI calls per callback (the length and two region copies) and Java allocated two arrays. The type ids now ride at the end of the long[], one per byte, and Java passes the count, so native reads everything with a single GetLongArrayRegion. --- .../runtime/src/main/cpp/ArgConverter.cpp | 18 +++------- test-app/runtime/src/main/cpp/ArgConverter.h | 16 ++++++--- .../runtime/src/main/cpp/CallbackHandlers.cpp | 2 +- .../runtime/src/main/cpp/ObjectManager.cpp | 12 +++---- .../runtime/src/main/cpp/com_tns_Runtime.cpp | 4 +-- .../src/main/java/com/tns/Runtime.java | 33 +++++++++---------- 6 files changed, 40 insertions(+), 45 deletions(-) diff --git a/test-app/runtime/src/main/cpp/ArgConverter.cpp b/test-app/runtime/src/main/cpp/ArgConverter.cpp index 3048824e5..24bd2dc93 100644 --- a/test-app/runtime/src/main/cpp/ArgConverter.cpp +++ b/test-app/runtime/src/main/cpp/ArgConverter.cpp @@ -92,10 +92,6 @@ void ArgConverter::NativeScriptLongFunctionCallback(const v8::FunctionCallbackIn } } -int ArgConverter::GetJavaArgsLength(JNIEnv* env, const JavaCallArgs& args) { - return args.types == nullptr ? 0 : env->GetArrayLength(args.types); -} - void ArgConverter::ConvertJavaArgsToJsArgs(Isolate* isolate, JNIEnv* env, const JavaCallArgs& args, int argc, Local* out) { if (argc == 0) { args.converted = true; @@ -103,28 +99,22 @@ void ArgConverter::ConvertJavaArgsToJsArgs(Isolate* isolate, JNIEnv* env, const } constexpr int kInlineArgs = 16; - jint inlineTypes[kInlineArgs]; - jlong inlineValues[kInlineArgs]; - std::vector heapTypes; + jlong inlineValues[JavaCallArgs::PackedLength(kInlineArgs)]; std::vector heapValues; - jint* types = inlineTypes; jlong* values = inlineValues; if (argc > kInlineArgs) { - heapTypes.resize(argc); - heapValues.resize(argc); - types = heapTypes.data(); + heapValues.resize(JavaCallArgs::PackedLength(argc)); values = heapValues.data(); } - env->GetIntArrayRegion(args.types, 0, argc, types); - env->GetLongArrayRegion(args.values, 0, argc, values); + env->GetLongArrayRegion(args.values, 0, JavaCallArgs::PackedLength(argc), values); auto objectManager = Runtime::GetRuntime(isolate)->GetObjectManager(); for (int i = 0; i < argc; i++) { jlong bits = values[i]; Local jsArg; - switch (static_cast(types[i])) { + switch (JavaCallArgs::TypeAt(values, argc, i)) { case Type::Boolean: jsArg = Boolean::New(isolate, bits != 0); break; diff --git a/test-app/runtime/src/main/cpp/ArgConverter.h b/test-app/runtime/src/main/cpp/ArgConverter.h index e0da38766..95d4fe294 100644 --- a/test-app/runtime/src/main/cpp/ArgConverter.h +++ b/test-app/runtime/src/main/cpp/ArgConverter.h @@ -10,6 +10,7 @@ #include "v8.h" #include "JEnv.h" +#include "JType.h" #include #include #include @@ -18,7 +19,8 @@ namespace tns { // The argument arrays com.tns.Runtime.packageArgs builds for a Java -> JS call. struct JavaCallArgs { - jintArray types; + jint count; + // count values, then one type id per byte, eight to a slot: one JNI read gets both. jlongArray values; jobjectArray refs; // Set once every argument has a JS value, so each object Java registered for this @@ -28,15 +30,21 @@ struct JavaCallArgs { // Set in an object argument's packed value when Java registered the object for this // call; the low half is then its exact id and the next 31 bits its class index. static constexpr jlong FreshArg = static_cast(1ULL << 63); + + static constexpr int PackedLength(int count) { + return count + (count + 7) / 8; + } + + static Type TypeAt(const jlong* packed, int count, int i) { + return static_cast((packed[count + i / 8] >> ((i % 8) * 8)) & 0xff); + } }; class ArgConverter { public: static void Init(v8::Local context); - static int GetJavaArgsLength(JNIEnv* env, const JavaCallArgs& args); - - // out must have room for GetJavaArgsLength(args) values. + // out must have room for args.count values. static void ConvertJavaArgsToJsArgs(v8::Isolate* isolate, JNIEnv* env, const JavaCallArgs& args, int argc, v8::Local* out); static v8::Local ConvertFromJavaLong(v8::Isolate* isolate, jlong value); diff --git a/test-app/runtime/src/main/cpp/CallbackHandlers.cpp b/test-app/runtime/src/main/cpp/CallbackHandlers.cpp index ed72ecb86..b22c9cab3 100644 --- a/test-app/runtime/src/main/cpp/CallbackHandlers.cpp +++ b/test-app/runtime/src/main/cpp/CallbackHandlers.cpp @@ -1025,7 +1025,7 @@ Local CallbackHandlers::CallJSMethod(Isolate *isolate, JNIEnv *_env, EscapableHandleScope handleScope(isolate); auto jsMethod = method.As(); - int argc = ArgConverter::GetJavaArgsLength(env, args); + int argc = args.count; constexpr int kInlineArgs = 16; Local inlineArguments[kInlineArgs]; diff --git a/test-app/runtime/src/main/cpp/ObjectManager.cpp b/test-app/runtime/src/main/cpp/ObjectManager.cpp index 9a5b2ae8f..4c861723b 100644 --- a/test-app/runtime/src/main/cpp/ObjectManager.cpp +++ b/test-app/runtime/src/main/cpp/ObjectManager.cpp @@ -296,7 +296,7 @@ Local ObjectManager::GetJsObjectByJavaObject(int javaObjectID) { } void ObjectManager::ReleaseUnwrappedArgs(const JavaCallArgs& args) { - if (args.converted || args.types == nullptr || args.values == nullptr) { + if (args.converted || args.count == 0) { return; } JEnv env; @@ -305,14 +305,12 @@ void ObjectManager::ReleaseUnwrappedArgs(const JavaCallArgs& args) { jthrowable pending = raw->ExceptionOccurred(); raw->ExceptionClear(); - jsize argc = raw->GetArrayLength(args.types); - std::vector types(argc); - std::vector values(argc); - raw->GetIntArrayRegion(args.types, 0, argc, types.data()); - raw->GetLongArrayRegion(args.values, 0, argc, values.data()); + jsize argc = args.count; + std::vector values(JavaCallArgs::PackedLength(argc)); + raw->GetLongArrayRegion(args.values, 0, values.size(), values.data()); AssertIsolateLocked(); for (jsize i = 0; i < argc; i++) { - if (static_cast(types[i]) != Type::JsObject || + if (JavaCallArgs::TypeAt(values.data(), argc, i) != Type::JsObject || (values[i] & JavaCallArgs::FreshArg) == 0) { continue; } diff --git a/test-app/runtime/src/main/cpp/com_tns_Runtime.cpp b/test-app/runtime/src/main/cpp/com_tns_Runtime.cpp index 162a7be6b..5bbd22291 100644 --- a/test-app/runtime/src/main/cpp/com_tns_Runtime.cpp +++ b/test-app/runtime/src/main/cpp/com_tns_Runtime.cpp @@ -194,7 +194,7 @@ extern "C" JNIEXPORT jobject Java_com_tns_Runtime_runScript(JNIEnv* _env, jobjec return result; } -extern "C" JNIEXPORT jobject Java_com_tns_Runtime_callJSMethodNative(JNIEnv* _env, jobject obj, jint runtimeId, jobject javaObject, jint javaObjectID, jstring methodName, jint retType, jboolean isConstructor, jintArray argTypes, jlongArray argValues, jobjectArray argRefs, jlongArray primitiveResult) { +extern "C" JNIEXPORT jobject Java_com_tns_Runtime_callJSMethodNative(JNIEnv* _env, jobject obj, jint runtimeId, jobject javaObject, jint javaObjectID, jstring methodName, jint retType, jboolean isConstructor, jint argCount, jlongArray argValues, jobjectArray argRefs, jlongArray primitiveResult) { JEnv::EntryScope envScope(_env); jobject result = nullptr; @@ -211,7 +211,7 @@ extern "C" JNIEXPORT jobject Java_com_tns_Runtime_callJSMethodNative(JNIEnv* _en auto context = runtime->GetContext(); v8::Context::Scope context_scope(context); - JavaCallArgs args{argTypes, argValues, argRefs}; + JavaCallArgs args{argCount, argValues, argRefs}; try { result = runtime->CallJSMethodNative(_env, obj, javaObject, javaObjectID, methodName, retType, isConstructor, args, primitiveResult); } catch (NativeScriptException& e) { diff --git a/test-app/runtime/src/main/java/com/tns/Runtime.java b/test-app/runtime/src/main/java/com/tns/Runtime.java index a2bb464ea..12f9bcad7 100644 --- a/test-app/runtime/src/main/java/com/tns/Runtime.java +++ b/test-app/runtime/src/main/java/com/tns/Runtime.java @@ -42,7 +42,7 @@ private native void initNativeScript(int runtimeId, String filesPath, String nat private native Object runScript(int runtimeId, String filePath) throws NativeScriptException; - private native Object callJSMethodNative(int runtimeId, Object javaObject, int javaObjectID, String methodName, int retType, boolean isConstructor, int[] argTypes, long[] argValues, Object[] argRefs, long[] primitiveResult) throws NativeScriptException; + private native Object callJSMethodNative(int runtimeId, Object javaObject, int javaObjectID, String methodName, int retType, boolean isConstructor, int argCount, long[] argValues, Object[] argRefs, long[] primitiveResult) throws NativeScriptException; private native void createJSInstanceNative(int runtimeId, Object javaObject, int javaObjectID, String canonicalName); @@ -1223,26 +1223,26 @@ private static String describeInstance(Object javaObject) { } /* - * Arguments cross to native as three parallel arrays so the native side - * reads every primitive with one bulk region copy instead of unboxing each - * through JNI. argValues holds primitives as raw bits (float/double via - * floatToRawIntBits/doubleToRawLongBits) and object IDs; argRefs holds - * strings and, for objects, the class name a missing JS wrapper needs. - * Type IDs are those of TypeIDs.GetObjectTypeId(Object). + * Arguments cross to native in one long[] so the native side reads them with + * a single region copy instead of unboxing each through JNI: count values + * (primitives as raw bits, float/double via floatToRawIntBits/ + * doubleToRawLongBits; objects as a packed id and class index), then the + * TypeIDs.GetObjectTypeId(Object) of each, one per byte. refs holds strings + * and objects. Must match JavaCallArgs in ArgConverter.h. */ private static final class PackagedArgs { - final int[] types; + final int count; final long[] values; final Object[] refs; - PackagedArgs(int[] types, long[] values, Object[] refs) { - this.types = types; + PackagedArgs(int count, long[] values, Object[] refs) { + this.count = count; this.values = values; this.refs = refs; } } - private static final PackagedArgs NO_ARGS = new PackagedArgs(null, null, null); + private static final PackagedArgs NO_ARGS = new PackagedArgs(0, null, null); // If a javaObject has no javaObjectID, meaning no JavaScript object exists // for it yet, it is assigned one. @@ -1252,8 +1252,7 @@ private PackagedArgs packageArgs(Object... args) { return NO_ARGS; } - int[] types = new int[len]; - long[] values = new long[len]; + long[] values = new long[len + (len + 7) / 8]; Object[] refs = null; for (int i = 0; i < len; i++) { @@ -1314,10 +1313,10 @@ private PackagedArgs packageArgs(Object... args) { values[i] = packIdAndClass(id, value) | fresh; } - types[i] = typeId; + values[len + (i >> 3)] |= (long) typeId << ((i & 7) * 8); } - return new PackagedArgs(types, values, refs); + return new PackagedArgs(len, values, refs); } static Class getClassForName(String className) { @@ -1425,7 +1424,7 @@ private Object dispatchCallJSMethodNative(final Object javaObject, final String try { // A hint: other threads may be writing the table, so native re-checks it under the // isolate lock and resolves the id itself on a miss. - ret = callJSMethodNative(getRuntimeId(), javaObject, objectTable.findIdOptimistic(javaObject), methodName, returnType, isConstructor, packagedArgs.types, packagedArgs.values, packagedArgs.refs, primitiveResult); + ret = callJSMethodNative(getRuntimeId(), javaObject, objectTable.findIdOptimistic(javaObject), methodName, returnType, isConstructor, packagedArgs.count, packagedArgs.values, packagedArgs.refs, primitiveResult); ret = takePrimitiveResult(ret, returnType, primitiveResult); } catch (NativeScriptException e) { // With multithreaded JS the owner can be torn down between the @@ -1466,7 +1465,7 @@ public void run() { } final PackagedArgs packagedArgs = packageArgs(tmpArgs); final long[] primitiveResult = newPrimitiveResult(returnType); - arr[0] = takePrimitiveResult(callJSMethodNative(getRuntimeId(), javaObject, javaObjectID, methodName, returnType, isCtor, packagedArgs.types, packagedArgs.values, packagedArgs.refs, primitiveResult), returnType, primitiveResult); + arr[0] = takePrimitiveResult(callJSMethodNative(getRuntimeId(), javaObject, javaObjectID, methodName, returnType, isCtor, packagedArgs.count, packagedArgs.values, packagedArgs.refs, primitiveResult), returnType, primitiveResult); } catch (NativeScriptException e) { if (discardUncaughtJsExceptions && !e.isEscapedFromJs()) { String errorMessage = "Error on \"" + Thread.currentThread().getName() + "\" thread for callJSMethodNative\n"; From bd087166463295f93514ce4b9362b25d83e150a5 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Mon, 9 Nov 2026 04:43:35 -0300 Subject: [PATCH 15/42] perf: register a constructed object and read Java heap usage in one upcall Every object constructed from JS made two upcalls after the constructor: makeInstanceStrong, then getUsedMemory for Isolate::AdjustAmountOfExternalAllocatedMemory. registerConstructedInstance does both. TryCallGC also skips its compare-and-swap when no GC was requested. --- .../runtime/src/main/cpp/CallbackHandlers.cpp | 17 +++++++++-------- .../runtime/src/main/cpp/CallbackHandlers.h | 4 ++-- test-app/runtime/src/main/cpp/Runtime.cpp | 16 ++-------------- test-app/runtime/src/main/cpp/Runtime.h | 3 +-- .../runtime/src/main/java/com/tns/Runtime.java | 8 +++++++- 5 files changed, 21 insertions(+), 27 deletions(-) diff --git a/test-app/runtime/src/main/cpp/CallbackHandlers.cpp b/test-app/runtime/src/main/cpp/CallbackHandlers.cpp index b22c9cab3..0b989eaea 100644 --- a/test-app/runtime/src/main/cpp/CallbackHandlers.cpp +++ b/test-app/runtime/src/main/cpp/CallbackHandlers.cpp @@ -56,9 +56,9 @@ void CallbackHandlers::Init(Isolate *isolate) { CURRENT_OBJECTID_FIELD_ID = env.GetFieldID(RUNTIME_CLASS, "currentObjectId", "I"); NS_CHECK(CURRENT_OBJECTID_FIELD_ID != nullptr); - MAKE_INSTANCE_STRONG_ID = env.GetMethodID(RUNTIME_CLASS, "makeInstanceStrong", - "(Ljava/lang/Object;I)V"); - NS_CHECK(MAKE_INSTANCE_STRONG_ID != nullptr); + REGISTER_CONSTRUCTED_INSTANCE_ID = env.GetMethodID(RUNTIME_CLASS, "registerConstructedInstance", + "(Ljava/lang/Object;I)J"); + NS_CHECK(REGISTER_CONSTRUCTED_INSTANCE_ID != nullptr); GET_TYPE_METADATA = env.GetStaticMethodID(RUNTIME_CLASS, "getTypeMetadata", "(Ljava/lang/String;I)[Ljava/lang/String;"); @@ -147,9 +147,10 @@ bool CallbackHandlers::RegisterInstance(Isolate *isolate, const Local &j } } - env.CallVoidMethod(runtime->GetJavaRuntime(), MAKE_INSTANCE_STRONG_ID, instance, javaObjectID); + jlong usedJavaMemory = env.CallLongMethod(runtime->GetJavaRuntime(), REGISTER_CONSTRUCTED_INSTANCE_ID, + instance, javaObjectID); - AdjustAmountOfExternalAllocatedMemory(env, isolate); + AdjustAmountOfExternalAllocatedMemory(isolate, usedJavaMemory); JniLocalRef localInstance(instance); success = !localInstance.IsNull(); @@ -585,9 +586,9 @@ void CallbackHandlers::CallJavaMethod(const Local &caller, const string // } } -void CallbackHandlers::AdjustAmountOfExternalAllocatedMemory(JEnv &env, Isolate *isolate) { +void CallbackHandlers::AdjustAmountOfExternalAllocatedMemory(Isolate *isolate, int64_t usedJavaMemory) { auto runtime = Runtime::GetRuntime(isolate); - runtime->AdjustAmountOfExternalAllocatedMemory(); + runtime->AdjustAmountOfExternalAllocatedMemory(usedJavaMemory); runtime->TryCallGC(); } @@ -1948,7 +1949,7 @@ jclass CallbackHandlers::RUNTIME_CLASS = nullptr; jclass CallbackHandlers::JAVA_LANG_STRING = nullptr; jfieldID CallbackHandlers::CURRENT_OBJECTID_FIELD_ID = nullptr; jmethodID CallbackHandlers::RESOLVE_CLASS_METHOD_ID = nullptr; -jmethodID CallbackHandlers::MAKE_INSTANCE_STRONG_ID = nullptr; +jmethodID CallbackHandlers::REGISTER_CONSTRUCTED_INSTANCE_ID = nullptr; jmethodID CallbackHandlers::GET_TYPE_METADATA = nullptr; jmethodID CallbackHandlers::ENABLE_VERBOSE_LOGGING_METHOD_ID = nullptr; jmethodID CallbackHandlers::DISABLE_VERBOSE_LOGGING_METHOD_ID = nullptr; diff --git a/test-app/runtime/src/main/cpp/CallbackHandlers.h b/test-app/runtime/src/main/cpp/CallbackHandlers.h index 239522cae..2daee1714 100644 --- a/test-app/runtime/src/main/cpp/CallbackHandlers.h +++ b/test-app/runtime/src/main/cpp/CallbackHandlers.h @@ -168,7 +168,7 @@ namespace tns { CallbackHandlers() { } - static void AdjustAmountOfExternalAllocatedMemory(JEnv &env, v8::Isolate *isolate); + static void AdjustAmountOfExternalAllocatedMemory(v8::Isolate *isolate, int64_t usedJavaMemory); /* * Helper method that creates a java string array for sending strings over JNI @@ -187,7 +187,7 @@ namespace tns { static jfieldID CURRENT_OBJECTID_FIELD_ID; - static jmethodID MAKE_INSTANCE_STRONG_ID; + static jmethodID REGISTER_CONSTRUCTED_INSTANCE_ID; static jmethodID GET_TYPE_METADATA; diff --git a/test-app/runtime/src/main/cpp/Runtime.cpp b/test-app/runtime/src/main/cpp/Runtime.cpp index 980c93456..cbc45c836 100644 --- a/test-app/runtime/src/main/cpp/Runtime.cpp +++ b/test-app/runtime/src/main/cpp/Runtime.cpp @@ -143,15 +143,6 @@ Runtime::Runtime(JNIEnv* env, jobject runtime, int id) std::lock_guard lock(s_runtimeCacheMutex); s_id2RuntimeCache.emplace(id, this); } - - if (GET_USED_MEMORY_METHOD_ID == nullptr) { - auto RUNTIME_CLASS = env->FindClass("com/tns/Runtime"); - NS_CHECK(RUNTIME_CLASS != nullptr); - - GET_USED_MEMORY_METHOD_ID = - env->GetMethodID(RUNTIME_CLASS, "getUsedMemory", "()J"); - NS_CHECK(GET_USED_MEMORY_METHOD_ID != nullptr); - } } Runtime* Runtime::GetRuntime(int runtimeId) { @@ -729,9 +720,7 @@ jint Runtime::GenerateNewObjectId(JNIEnv* env, jobject obj) { return objectId; } -void Runtime::AdjustAmountOfExternalAllocatedMemory() { - JEnv env; - int64_t usedMemory = env.CallLongMethod(m_runtime, GET_USED_MEMORY_METHOD_ID); +void Runtime::AdjustAmountOfExternalAllocatedMemory(int64_t usedMemory) { int64_t changeInBytes = usedMemory - m_lastUsedMemory; int64_t externalMemory = 0; @@ -753,7 +742,7 @@ bool Runtime::NotifyGC(JNIEnv* env, jobject obj) { } bool Runtime::TryCallGC() { - auto success = m_gcFunc != nullptr; + auto success = m_gcFunc != nullptr && m_runGC; if (success) { success = __sync_bool_compare_and_swap(&m_runGC, true, false); if (success) { @@ -1393,7 +1382,6 @@ int Runtime::GetId() { return this->m_id; } JavaVM* Runtime::s_jvm = nullptr; -jmethodID Runtime::GET_USED_MEMORY_METHOD_ID = nullptr; robin_hood::unordered_map Runtime::s_id2RuntimeCache; robin_hood::unordered_map Runtime::s_isolate2RuntimesCache; std::mutex Runtime::s_runtimeCacheMutex; diff --git a/test-app/runtime/src/main/cpp/Runtime.h b/test-app/runtime/src/main/cpp/Runtime.h index f9ceafd8c..701b0d375 100644 --- a/test-app/runtime/src/main/cpp/Runtime.h +++ b/test-app/runtime/src/main/cpp/Runtime.h @@ -180,7 +180,7 @@ class Runtime { jobject CallJSMethodNative(JNIEnv* _env, jobject obj, jobject javaObject, jint javaObjectID, jstring methodName, jint retType, jboolean isConstructor, const JavaCallArgs& args, jlongArray primitiveResult); void CreateJSInstanceNative(JNIEnv* _env, jobject obj, jobject javaObject, jint javaObjectID, jstring className); jint GenerateNewObjectId(JNIEnv* env, jobject obj); - void AdjustAmountOfExternalAllocatedMemory(); + void AdjustAmountOfExternalAllocatedMemory(int64_t usedJavaMemory); bool NotifyGC(JNIEnv* env, jobject obj); bool TryCallGC(); /* @@ -424,7 +424,6 @@ class Runtime { static JavaVM* s_jvm; - static jmethodID GET_USED_MEMORY_METHOD_ID; /* * The main runtime has finished initializing. Distinct from the diff --git a/test-app/runtime/src/main/java/com/tns/Runtime.java b/test-app/runtime/src/main/java/com/tns/Runtime.java index 12f9bcad7..81637a389 100644 --- a/test-app/runtime/src/main/java/com/tns/Runtime.java +++ b/test-app/runtime/src/main/java/com/tns/Runtime.java @@ -836,7 +836,6 @@ private Class resolveClass(String baseClassName, String fullClassName, String return javaClass; } - @RuntimeCallable private long getUsedMemory() { long usedMemory = dalvikRuntime.totalMemory() - dalvikRuntime.freeMemory(); return usedMemory; @@ -1049,6 +1048,13 @@ private static String getTypeMetadata(Class clazz) { return ret; } + // One upcall for what native needs after constructing an object from JS. + @RuntimeCallable + private long registerConstructedInstance(Object instance, int objectId) { + makeInstanceStrong(instance, objectId); + return getUsedMemory(); + } + @RuntimeCallable private void makeInstanceStrong(Object instance, int objectId) { if (instance == null) { From 478e213583578ae0ff45134c3c8b3320be629da2 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Mon, 9 Nov 2026 05:02:36 -0300 Subject: [PATCH 16/42] perf: replace a package child's getter with its value on first read Package objects exposed each child (java.lang, android.view...) through a native data property, so every read of `java.lang.Object` ran the C++ getter once per segment: a std::string conversion, a private-symbol registry lookup, and a HasPrivate/GetPrivate pair, even after the child had been resolved. Children are now lazy data properties: V8 calls the getter once and replaces the property with the value, so later reads are plain inline-cached loads. The property keeps its attributes (writable, enumerable, configurable). --- test-app/runtime/src/main/cpp/MetadataNode.cpp | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/test-app/runtime/src/main/cpp/MetadataNode.cpp b/test-app/runtime/src/main/cpp/MetadataNode.cpp index b54eea536..586b2e2b6 100644 --- a/test-app/runtime/src/main/cpp/MetadataNode.cpp +++ b/test-app/runtime/src/main/cpp/MetadataNode.cpp @@ -339,10 +339,10 @@ Local MetadataNode::CreatePackageObject(Isolate* isolate) { auto ctx = isolate->GetCurrentContext(); auto extData = External::New(isolate, this, v8::kExternalPointerTypeTagDefault); - // Every child is a native data property, so anything that reads a property - // descriptor off a package object - console formatting, most notably - - // would materialize the whole subtree. The marker lets such callers - // recognize a package and stop. + // Every child is a lazy data property (replaced by its value on first read), + // so anything that reads a property descriptor off a package object - + // console formatting, most notably - would materialize the whole subtree. + // The marker lets such callers recognize a package and stop. auto cache = GetMetadataNodeCache(isolate); packageObj->SetPrivate(ctx, Private::ForApi(isolate, Local::New(isolate, *cache->PackageKey)), @@ -352,11 +352,10 @@ Local MetadataNode::CreatePackageObject(Isolate* isolate) { if (ptrChildren != nullptr) { const auto& children = *ptrChildren; for (auto childNode: children) { - packageObj->SetNativeDataProperty(ctx, - ArgConverter::ConvertToV8String(isolate, childNode->name), - PackageGetterCallback, - nullptr, - extData).ToChecked(); + packageObj->SetLazyDataProperty(ctx, + ArgConverter::ConvertToV8String(isolate, childNode->name), + PackageGetterCallback, + extData).ToChecked(); } } From f4cbece2918ab3743f6221d74429a0c8d2c91ab2 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Mon, 9 Nov 2026 05:02:36 -0300 Subject: [PATCH 17/42] perf: look up resolved signatures without allocating Every `new` and every call to an overloaded method built its cache key as a fresh std::string, copied each argument's type name into another, and returned the cached entry by value, copying its signature and return-type strings again. The key is now built in a reused per-thread buffer (copied out only to insert, since resolving calls into Java and may re-enter), the cache is a node map whose entries never move, and lookups return a reference to the entry. MetadataNode::GetName returns a reference instead of a copy. --- .../runtime/src/main/cpp/CallbackHandlers.cpp | 26 ++--- .../runtime/src/main/cpp/MetadataNode.cpp | 2 +- test-app/runtime/src/main/cpp/MetadataNode.h | 2 +- test-app/runtime/src/main/cpp/MethodCache.cpp | 99 ++++++++++--------- test-app/runtime/src/main/cpp/MethodCache.h | 12 ++- 5 files changed, 77 insertions(+), 64 deletions(-) diff --git a/test-app/runtime/src/main/cpp/CallbackHandlers.cpp b/test-app/runtime/src/main/cpp/CallbackHandlers.cpp index 0b989eaea..aa35e1378 100644 --- a/test-app/runtime/src/main/cpp/CallbackHandlers.cpp +++ b/test-app/runtime/src/main/cpp/CallbackHandlers.cpp @@ -126,7 +126,7 @@ bool CallbackHandlers::RegisterInstance(Isolate *isolate, const Local &j objectManager->Link(jsObject, javaObjectID, nullptr); // resolve constructor - auto mi = MethodCache::ResolveConstructorSignature(argWrapper, fullClassName, + const auto &mi = MethodCache::ResolveConstructorSignature(argWrapper, fullClassName, generatedJavaClass, isInterface); // while the "instance" is being created, if an exception is thrown during the construction @@ -254,10 +254,10 @@ void CallbackHandlers::CallJavaMethod(const Local &caller, const string jclass clazz; jmethodID mid; - string *sig = nullptr; - string *returnType = nullptr; + const string *sig = nullptr; + const string *returnType = nullptr; auto retType = MethodReturnType::Unknown; - MethodCache::CacheMethodInfo mi; + const MethodCache::CacheMethodInfo *mi = nullptr; auto isolate = args.GetIsolate(); @@ -340,8 +340,8 @@ void CallbackHandlers::CallJavaMethod(const Local &caller, const string clazz = env.FindClass(className); if (clazz != nullptr) { - mi = MethodCache::ResolveMethodSignature(className, methodName, args, isStatic); - if (mi.mid == nullptr) { + mi = &MethodCache::ResolveMethodSignature(className, methodName, args, isStatic); + if (mi->mid == nullptr) { DEBUG_WRITE("Cannot resolve class=%s, method=%s, isStatic=%d, isSuper=%d", className.c_str(), methodName.c_str(), isStatic, isSuper); return; @@ -351,8 +351,8 @@ void CallbackHandlers::CallJavaMethod(const Local &caller, const string const string callerClassName = callerNode->GetName(); DEBUG_WRITE("Resolving method on caller class: %s.%s on className %s", callerClassName.c_str(), methodName.c_str(), className.c_str()); - mi = MethodCache::ResolveMethodSignature(callerClassName, methodName, args, isStatic); - if (mi.mid == nullptr) { + mi = &MethodCache::ResolveMethodSignature(callerClassName, methodName, args, isStatic); + if (mi->mid == nullptr) { DEBUG_WRITE( "Cannot resolve class=%s, method=%s, isStatic=%d, isSuper=%d, callerClass=%s", className.c_str(), methodName.c_str(), isStatic, isSuper, @@ -361,11 +361,11 @@ void CallbackHandlers::CallJavaMethod(const Local &caller, const string } } - clazz = mi.clazz; - mid = mi.mid; - sig = &mi.signature; - returnType = &mi.returnType; - retType = mi.retType; + clazz = mi->clazz; + mid = mi->mid; + sig = &mi->signature; + returnType = &mi->returnType; + retType = mi->retType; } if (!isStatic) { diff --git a/test-app/runtime/src/main/cpp/MetadataNode.cpp b/test-app/runtime/src/main/cpp/MetadataNode.cpp index 586b2e2b6..2fc2b392d 100644 --- a/test-app/runtime/src/main/cpp/MetadataNode.cpp +++ b/test-app/runtime/src/main/cpp/MetadataNode.cpp @@ -237,7 +237,7 @@ MetadataTreeNode* MetadataNode::GetOrCreateTreeNodeByName(const string& classNam return s_name2TreeNodeCache.emplace(className, result).first->second; } -string MetadataNode::GetName() { +const string& MetadataNode::GetName() { return m_name; } diff --git a/test-app/runtime/src/main/cpp/MetadataNode.h b/test-app/runtime/src/main/cpp/MetadataNode.h index ddb0a1a7d..a1325dd36 100644 --- a/test-app/runtime/src/main/cpp/MetadataNode.h +++ b/test-app/runtime/src/main/cpp/MetadataNode.h @@ -41,7 +41,7 @@ class MetadataNode { static void EnableProfiler(bool enableProfiler); - std::string GetName(); + const std::string& GetName(); bool IsNodeTypeInterface(); diff --git a/test-app/runtime/src/main/cpp/MethodCache.cpp b/test-app/runtime/src/main/cpp/MethodCache.cpp index 823e27160..be7a57951 100644 --- a/test-app/runtime/src/main/cpp/MethodCache.cpp +++ b/test-app/runtime/src/main/cpp/MethodCache.cpp @@ -41,17 +41,23 @@ void MethodCache::Init() { NS_CHECK(RESOLVE_CONSTRUCTOR_SIGNATURE_ID != nullptr); } -MethodCache::CacheMethodInfo MethodCache::ResolveMethodSignature(const string& className, const string& methodName, const FunctionCallbackInfo& args, bool isStatic) { +// Reused for every lookup so a cache hit allocates nothing. +static thread_local std::string s_signatureKey; +static const MethodCache::CacheMethodInfo s_unresolved; + +const MethodCache::CacheMethodInfo& MethodCache::ResolveMethodSignature(const string& className, const string& methodName, const FunctionCallbackInfo& args, bool isStatic) { CacheMethodInfo method_info; - auto encoded_method_signature = EncodeSignature(className, methodName, args, isStatic); + EncodeSignature(s_signatureKey, className, methodName, args, isStatic); { std::shared_lock lock(signatureCacheMutex); - auto it = s_mthod_ctor_signature_cache.find(encoded_method_signature); + auto it = s_mthod_ctor_signature_cache.find(s_signatureKey); if (it != s_mthod_ctor_signature_cache.end()) { return it->second; } } + // Resolving calls into Java, which may call back into JS and reuse s_signatureKey. + string encoded_method_signature = s_signatureKey; auto signature = ResolveJavaMethod(args, className, methodName); @@ -72,24 +78,26 @@ MethodCache::CacheMethodInfo MethodCache::ResolveMethodSignature(const string& c env.GetMethodID(clazz, methodName, signature); std::unique_lock lock(signatureCacheMutex); - s_mthod_ctor_signature_cache.emplace(encoded_method_signature, method_info); + return s_mthod_ctor_signature_cache.emplace(encoded_method_signature, method_info).first->second; } - return method_info; + return s_unresolved; } -MethodCache::CacheMethodInfo MethodCache::ResolveConstructorSignature(const ArgsWrapper& argWrapper, const string& fullClassName, jclass javaClass, bool isInterface) { +const MethodCache::CacheMethodInfo& MethodCache::ResolveConstructorSignature(const ArgsWrapper& argWrapper, const string& fullClassName, jclass javaClass, bool isInterface) { CacheMethodInfo constructor_info; auto& args = argWrapper.args; - auto encoded_ctor_signature = EncodeSignature(fullClassName, "", args, false); + static const string kConstructorName = ""; + EncodeSignature(s_signatureKey, fullClassName, kConstructorName, args, false); { std::shared_lock lock(signatureCacheMutex); - auto it = s_mthod_ctor_signature_cache.find(encoded_ctor_signature); + auto it = s_mthod_ctor_signature_cache.find(s_signatureKey); if (it != s_mthod_ctor_signature_cache.end()) { return it->second; } } + string encoded_ctor_signature = s_signatureKey; auto signature = ResolveConstructor(args, javaClass, isInterface); @@ -102,17 +110,16 @@ MethodCache::CacheMethodInfo MethodCache::ResolveConstructorSignature(const Args constructor_info.mid = env.GetMethodID(javaClass, "", signature); std::unique_lock lock(signatureCacheMutex); - s_mthod_ctor_signature_cache.emplace(encoded_ctor_signature, constructor_info); + return s_mthod_ctor_signature_cache.emplace(encoded_ctor_signature, constructor_info).first->second; } - return constructor_info; + return s_unresolved; } // Encoded signature .S/I....<...> -string MethodCache::EncodeSignature(const string& className, const string& methodName, const FunctionCallbackInfo& args, bool isStatic) { +void MethodCache::EncodeSignature(string& sig, const string& className, const string& methodName, const FunctionCallbackInfo& args, bool isStatic) { int len = args.Length(); - string sig; - sig.reserve(className.size() + methodName.size() + 8 + len * 12); + sig.clear(); sig.append(className); sig.append(isStatic ? ".S." : ".I."); sig.append(methodName); @@ -121,14 +128,11 @@ string MethodCache::EncodeSignature(const string& className, const string& metho for (int i = 0; i < len; i++) { sig.append("."); - sig.append(GetType(args.GetIsolate(), args[i])); + AppendType(sig, args.GetIsolate(), args[i]); } - - return sig; } -string MethodCache::GetType(Isolate* isolate, const v8::Local& jsValue) { - string type; +void MethodCache::AppendType(string& type, Isolate* isolate, const v8::Local& jsValue) { // Overloads resolve against what a Proxy wraps. auto value = ObjectManager::UnwrapProxy(jsValue); @@ -141,48 +145,52 @@ string MethodCache::GetType(Isolate* isolate, const v8::Local& jsValu if (!nullNode.IsEmpty()) { auto treeNode = reinterpret_cast(nullNode.As()->Value(v8::kExternalPointerTypeTagDefault)); - type = (treeNode != nullptr) ? treeNode->GetName() : ""; + if (treeNode != nullptr) { + type += treeNode->GetName(); + } else { + type += ""; + } DEBUG_WRITE("Parameter of type %s with NULL value is passed to the method.", type.c_str()); - return type; + return; } } if (value->IsArray()) { - type = "array"; + type += "array"; } else if (value->IsArrayBuffer() || value->IsSharedArrayBuffer() || value->IsInt8Array() || value->IsUint8Array() || value->IsUint8ClampedArray()) { - type = "bytebuffer"; + type += "bytebuffer"; } else if (value->IsInt16Array() || value->IsUint16Array()) { - type = "shortbuffer"; + type += "shortbuffer"; } else if (value->IsInt32Array() || value->IsUint32Array()) { - type = "intbuffer"; + type += "intbuffer"; } else if (value->IsBigUint64Array() || value->IsBigInt64Array()) { - type = "longbuffer"; + type += "longbuffer"; } else if (value->IsFloat32Array()) { - type = "floatbuffer"; + type += "floatbuffer"; } else if (value->IsFloat64Array()) { - type = "doublebuffer"; + type += "doublebuffer"; } else if (value->IsBoolean() || value->IsBooleanObject() || value->IsFalse() || value->IsTrue()) { - type = "bool"; + type += "bool"; } else if (value->IsDataView()) { - type = "view"; + type += "view"; } else if (value->IsDate()) { - type = "date"; + type += "date"; } else if (value->IsFunction()) { - type = "function"; + type += "function"; } else if (value->IsInt32() || value->IsUint32()) { - type = "int"; + type += "int"; } else if (value->IsNull() || value->IsUndefined()) { - type = "null"; + type += "null"; } else if (value->IsString() || value->IsStringObject()) { - type = "string"; + type += "string"; } else if (value->IsNumber() || value->IsNumberObject()) { auto context = isolate->GetCurrentContext(); double d = value->NumberValue(context).ToChecked(); int64_t i = (int64_t) d; bool isInteger = d == i; - type = isInteger ? "intnumber" : "doublenumber"; + type += isInteger ? "intnumber" : "doublenumber"; } else if (value->IsObject()) { auto context = isolate->GetCurrentContext(); auto object = value->ToObject(context).ToLocalChecked(); @@ -191,39 +199,42 @@ string MethodCache::GetType(Isolate* isolate, const v8::Local& jsValu switch (castType) { case CastType::Char: - type = "char"; + type += "char"; break; case CastType::Byte: - type = "byte"; + type += "byte"; break; case CastType::Short: - type = "short"; + type += "short"; break; case CastType::Long: - type = "long"; + type += "long"; break; case CastType::Float: - type = "float"; + type += "float"; break; case CastType::Double: - type = "double"; + type += "double"; break; case CastType::None: node = MetadataNode::GetNodeFromHandle(object); - type = (node != nullptr) ? node->GetName() : ""; + if (node != nullptr) { + type += node->GetName(); + } else { + type += ""; + } break; default: throw NativeScriptException("Unsupported cast type"); } } - return type; } string MethodCache::ResolveJavaMethod(const FunctionCallbackInfo& args, const string& className, const string& methodName) { @@ -276,7 +287,7 @@ string MethodCache::ResolveConstructor(const FunctionCallbackInfo& args, return resolvedSignature; } -robin_hood::unordered_map MethodCache::s_mthod_ctor_signature_cache; +robin_hood::unordered_node_map MethodCache::s_mthod_ctor_signature_cache; jclass MethodCache::RUNTIME_CLASS = nullptr; jmethodID MethodCache::RESOLVE_METHOD_OVERLOAD_METHOD_ID = nullptr; jmethodID MethodCache::RESOLVE_CONSTRUCTOR_SIGNATURE_ID = nullptr; diff --git a/test-app/runtime/src/main/cpp/MethodCache.h b/test-app/runtime/src/main/cpp/MethodCache.h index b253d2b37..0aac42904 100644 --- a/test-app/runtime/src/main/cpp/MethodCache.h +++ b/test-app/runtime/src/main/cpp/MethodCache.h @@ -32,17 +32,18 @@ class MethodCache { static void Init(); - static CacheMethodInfo ResolveMethodSignature(const std::string& className, const std::string& methodName, const v8::FunctionCallbackInfo& args, bool isStatic); + // The returned entry lives as long as the process; mid is null when nothing matched. + static const CacheMethodInfo& ResolveMethodSignature(const std::string& className, const std::string& methodName, const v8::FunctionCallbackInfo& args, bool isStatic); - static CacheMethodInfo ResolveConstructorSignature(const ArgsWrapper& argWrapper, const std::string& fullClassName, jclass javaClass, bool isInterface); + static const CacheMethodInfo& ResolveConstructorSignature(const ArgsWrapper& argWrapper, const std::string& fullClassName, jclass javaClass, bool isInterface); private: MethodCache() { } - static std::string EncodeSignature(const std::string& className, const std::string& methodName, const v8::FunctionCallbackInfo& args, bool isStatic); + static void EncodeSignature(std::string& out, const std::string& className, const std::string& methodName, const v8::FunctionCallbackInfo& args, bool isStatic); - static std::string GetType(v8::Isolate* isolate, const v8::Local& value); + static void AppendType(std::string& out, v8::Isolate* isolate, const v8::Local& value); static std::string ResolveJavaMethod(const v8::FunctionCallbackInfo& args, const std::string& className, const std::string& methodName); @@ -58,8 +59,9 @@ class MethodCache { * "s_mthod_ctor_signature_cache" holding all resolved CacheMethodInfo against an encoded_signature string. * Used for caching the resolved constructor or method signature. * The encoded signature has template: .S/I....<...> + * A node map, never erased from: callers keep references to its entries. */ - static robin_hood::unordered_map s_mthod_ctor_signature_cache; + static robin_hood::unordered_node_map s_mthod_ctor_signature_cache; }; } From 2a07cb702ed475b2ef15ad6196c19ab3c3d3da9e Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Mon, 9 Nov 2026 06:07:38 -0300 Subject: [PATCH 18/42] perf: internalize the runtime's cached property-name strings The per-isolate constants (prototype, toString, valueOf, ...) were created as ordinary strings, so each property access keyed by one first had to look it up in the string table. They are now created internalized. --- .../src/main/cpp/V8StringConstants.cpp | 7 +++ .../runtime/src/main/cpp/V8StringConstants.h | 48 +++++++++++-------- 2 files changed, 35 insertions(+), 20 deletions(-) diff --git a/test-app/runtime/src/main/cpp/V8StringConstants.cpp b/test-app/runtime/src/main/cpp/V8StringConstants.cpp index 451b503f5..bd1818ea3 100644 --- a/test-app/runtime/src/main/cpp/V8StringConstants.cpp +++ b/test-app/runtime/src/main/cpp/V8StringConstants.cpp @@ -84,6 +84,12 @@ Local V8StringConstants::GetPrototype(Isolate* isolate) { return Local::New(isolate, *consts->PROTOTYPE_PERSISTENT); } +Local V8StringConstants::GetConstructor(Isolate* isolate) { + auto consts = GetConstantsForIsolate(isolate); + + return Local::New(isolate, *consts->CONSTRUCTOR_PERSISTENT); +} + Local V8StringConstants::GetSuper(Isolate* isolate) { auto consts = GetConstantsForIsolate(isolate); @@ -149,6 +155,7 @@ const string V8StringConstants::STACK_TRACE = "stackTrace"; const string V8StringConstants::STACK = "stack"; const string V8StringConstants::LONG_NUMBER = "NativeScriptLongNumber"; const string V8StringConstants::PROTOTYPE = "prototype"; +const string V8StringConstants::CONSTRUCTOR = "constructor"; const string V8StringConstants::SUPER = "super"; const string V8StringConstants::TARGET = "target"; const string V8StringConstants::TO_STRING = "toString"; diff --git a/test-app/runtime/src/main/cpp/V8StringConstants.h b/test-app/runtime/src/main/cpp/V8StringConstants.h index 6d1345a1c..6539d5dd8 100644 --- a/test-app/runtime/src/main/cpp/V8StringConstants.h +++ b/test-app/runtime/src/main/cpp/V8StringConstants.h @@ -29,6 +29,8 @@ class V8StringConstants { static v8::Local GetPrototype(v8::Isolate* isolate); + static v8::Local GetConstructor(v8::Isolate* isolate); + static v8::Local GetSuper(v8::Isolate* isolate); static v8::Local GetTarget(v8::Isolate* isolate); @@ -62,6 +64,7 @@ class V8StringConstants { static const std::string STACK_TRACE; static const std::string LONG_NUMBER; static const std::string PROTOTYPE; + static const std::string CONSTRUCTOR; static const std::string SUPER; static const std::string TARGET; static const std::string TO_STRING; @@ -80,82 +83,85 @@ class V8StringConstants { */ struct PerIsolateV8Constants { PerIsolateV8Constants(v8::Isolate* isolate) { - auto str = String::NewFromUtf8(isolate, CLASS_IMPLEMENTATION_OBJECT.c_str()).ToLocalChecked(); + auto str = String::NewFromUtf8(isolate, CLASS_IMPLEMENTATION_OBJECT.c_str(), NewStringType::kInternalized).ToLocalChecked(); CLASS_IMPLEMENTATION_OBJECT_PERSISTENT = new Persistent(isolate, str); - str = String::NewFromUtf8(isolate, EXTEND.c_str()).ToLocalChecked(); + str = String::NewFromUtf8(isolate, EXTEND.c_str(), NewStringType::kInternalized).ToLocalChecked(); EXTEND_PERSISTENT = new Persistent(isolate, str); - str = String::NewFromUtf8(isolate, NULL_OBJECT.c_str()).ToLocalChecked(); + str = String::NewFromUtf8(isolate, NULL_OBJECT.c_str(), NewStringType::kInternalized).ToLocalChecked(); NULL_OBJECT_PERSISTENT = new Persistent(isolate, str); - str = String::NewFromUtf8(isolate, NULL_NODE_NAME.c_str()).ToLocalChecked(); + str = String::NewFromUtf8(isolate, NULL_NODE_NAME.c_str(), NewStringType::kInternalized).ToLocalChecked(); NULL_NODE_NAME_PERSISTENT = new Persistent(isolate, str); - str = String::NewFromUtf8(isolate, IS_PROTOTYPE_IMPLEMENTATION_OBJECT.c_str()).ToLocalChecked(); + str = String::NewFromUtf8(isolate, IS_PROTOTYPE_IMPLEMENTATION_OBJECT.c_str(), NewStringType::kInternalized).ToLocalChecked(); IS_PROTOTYPE_IMPLEMENTATION_OBJECT_PERSISTENT = new Persistent(isolate, str); - str = String::NewFromUtf8(isolate, NATIVE_EXCEPTION.c_str()).ToLocalChecked(); + str = String::NewFromUtf8(isolate, NATIVE_EXCEPTION.c_str(), NewStringType::kInternalized).ToLocalChecked(); NATIVE_EXCEPTION_PERSISTENT = new Persistent(isolate, str); - str = String::NewFromUtf8(isolate, STACK.c_str()).ToLocalChecked(); + str = String::NewFromUtf8(isolate, STACK.c_str(), NewStringType::kInternalized).ToLocalChecked(); STACK_PERSISTENT = new Persistent(isolate, str); - str = String::NewFromUtf8(isolate, STACK_TRACE.c_str()).ToLocalChecked(); + str = String::NewFromUtf8(isolate, STACK_TRACE.c_str(), NewStringType::kInternalized).ToLocalChecked(); STACK_TRACE_PERSISTENT = new Persistent(isolate, str); - str = String::NewFromUtf8(isolate, LONG_NUMBER.c_str()).ToLocalChecked(); + str = String::NewFromUtf8(isolate, LONG_NUMBER.c_str(), NewStringType::kInternalized).ToLocalChecked(); LONG_NUMBER_PERSISTENT = new Persistent(isolate, str); - str = String::NewFromUtf8(isolate, PROTOTYPE.c_str()).ToLocalChecked(); + str = String::NewFromUtf8(isolate, PROTOTYPE.c_str(), NewStringType::kInternalized).ToLocalChecked(); PROTOTYPE_PERSISTENT = new Persistent(isolate, str); + str = String::NewFromUtf8(isolate, CONSTRUCTOR.c_str(), NewStringType::kInternalized).ToLocalChecked(); + CONSTRUCTOR_PERSISTENT = new Persistent(isolate, str); + - str = String::NewFromUtf8(isolate, SUPER.c_str()).ToLocalChecked(); + str = String::NewFromUtf8(isolate, SUPER.c_str(), NewStringType::kInternalized).ToLocalChecked(); SUPER_PERSISTENT = new Persistent(isolate, str); - str = String::NewFromUtf8(isolate, TARGET.c_str()).ToLocalChecked(); + str = String::NewFromUtf8(isolate, TARGET.c_str(), NewStringType::kInternalized).ToLocalChecked(); TARGET_PERSISTENT = new Persistent(isolate, str); - str = String::NewFromUtf8(isolate, TO_STRING.c_str()).ToLocalChecked(); + str = String::NewFromUtf8(isolate, TO_STRING.c_str(), NewStringType::kInternalized).ToLocalChecked(); TO_STRING_PERSISTENT = new Persistent(isolate, str); - str = String::NewFromUtf8(isolate, JAVA_LONG.c_str()).ToLocalChecked(); + str = String::NewFromUtf8(isolate, JAVA_LONG.c_str(), NewStringType::kInternalized).ToLocalChecked(); JAVA_LONG_PERSISTENT = new Persistent(isolate, str); - str = String::NewFromUtf8(isolate, VALUE.c_str()).ToLocalChecked(); + str = String::NewFromUtf8(isolate, VALUE.c_str(), NewStringType::kInternalized).ToLocalChecked(); VALUE_PERSISTENT = new Persistent(isolate, str); - str = String::NewFromUtf8(isolate, VALUE_OF.c_str()).ToLocalChecked(); + str = String::NewFromUtf8(isolate, VALUE_OF.c_str(), NewStringType::kInternalized).ToLocalChecked(); VALUE_OF_PERSISTENT = new Persistent(isolate, str); - str = String::NewFromUtf8(isolate, UNCAUGHT_ERROR.c_str()).ToLocalChecked(); + str = String::NewFromUtf8(isolate, UNCAUGHT_ERROR.c_str(), NewStringType::kInternalized).ToLocalChecked(); UNCAUGHT_ERROR_PERSISTENT = new Persistent(isolate, str); - str = String::NewFromUtf8(isolate, DISCARDED_ERROR.c_str()).ToLocalChecked(); + str = String::NewFromUtf8(isolate, DISCARDED_ERROR.c_str(), NewStringType::kInternalized).ToLocalChecked(); DISCARDED_ERROR_PERSISTENT = new Persistent(isolate, str); - str = String::NewFromUtf8(isolate, IMPLEMENTATION_OBJECT.c_str()).ToLocalChecked(); + str = String::NewFromUtf8(isolate, IMPLEMENTATION_OBJECT.c_str(), NewStringType::kInternalized).ToLocalChecked(); IMPLEMENTATION_OBJECT_PERSISTENT = new Persistent(isolate, str); // Private::ForApi resolves its name through the isolate's registry // on every call, so the keys read per bridge call are resolved once. NULL_NODE_PRIVATE.Reset(isolate, Private::ForApi(isolate, Local::New(isolate, *NULL_NODE_NAME_PERSISTENT))); - str = String::NewFromUtf8(isolate, CAST_MARKER.c_str()).ToLocalChecked(); + str = String::NewFromUtf8(isolate, CAST_MARKER.c_str(), NewStringType::kInternalized).ToLocalChecked(); CAST_MARKER_PRIVATE.Reset(isolate, Private::ForApi(isolate, str)); }; @@ -178,6 +184,7 @@ class V8StringConstants { ResetAndDelete(NULL_NODE_NAME_PERSISTENT); ResetAndDelete(NULL_OBJECT_PERSISTENT); ResetAndDelete(PROTOTYPE_PERSISTENT); + ResetAndDelete(CONSTRUCTOR_PERSISTENT); ResetAndDelete(STACK_PERSISTENT); ResetAndDelete(STACK_TRACE_PERSISTENT); ResetAndDelete(SUPER_PERSISTENT); @@ -207,6 +214,7 @@ class V8StringConstants { v8::Persistent* STACK_TRACE_PERSISTENT = nullptr; v8::Persistent* LONG_NUMBER_PERSISTENT = nullptr; v8::Persistent* PROTOTYPE_PERSISTENT = nullptr; + v8::Persistent* CONSTRUCTOR_PERSISTENT = nullptr; v8::Persistent* SUPER_PERSISTENT = nullptr; v8::Persistent* TARGET_PERSISTENT = nullptr; v8::Persistent* TO_STRING_PERSISTENT = nullptr; From 61fcbe2d6937bd46ff6e4d481e68a7786da4a727 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Mon, 9 Nov 2026 06:07:38 -0300 Subject: [PATCH 19/42] perf: create a Java object's JS wrapper without a constructor call or a fresh key string Wrapping a Java object created the empty wrapper by calling an API function as a constructor (a full JS -> C++ transition into an empty callback), copied the class's method-callback vector just to confirm its constructor template existed, and set `constructor` with a newly allocated string. The wrapper is now instantiated from the function template's instance template (the same object, without entering the callback), GetConstructorFunction returns the cached function directly, and `constructor` is a cached internalized string. --- test-app/runtime/src/main/cpp/MetadataNode.cpp | 7 ++++++- test-app/runtime/src/main/cpp/ObjectManager.cpp | 6 ++++-- test-app/runtime/src/main/cpp/ObjectManager.h | 1 + 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/test-app/runtime/src/main/cpp/MetadataNode.cpp b/test-app/runtime/src/main/cpp/MetadataNode.cpp index 2fc2b392d..a27c1d036 100644 --- a/test-app/runtime/src/main/cpp/MetadataNode.cpp +++ b/test-app/runtime/src/main/cpp/MetadataNode.cpp @@ -288,7 +288,7 @@ Local MetadataNode::CreateJSWrapper(Isolate* isolate, ObjectManager* obj if (!obj.IsEmpty()) { auto ctorFunc = GetConstructorFunction(isolate); auto context = isolate->GetCurrentContext(); - obj->Set(context, ArgConverter::ConvertToV8String(isolate, "constructor"), ctorFunc); + obj->Set(context, V8StringConstants::GetConstructor(isolate), ctorFunc); obj->SetPrototype(context, ctorFunc->Get(context, V8StringConstants::GetPrototype(isolate)).ToLocalChecked()); SetInstanceMetadata(isolate, obj, this); } @@ -1191,6 +1191,11 @@ Local MetadataNode::GetConstructorFunctionTemplate(Isolate* is } Local MetadataNode::GetConstructorFunction(Isolate* isolate) { + auto cache = GetMetadataNodeCache(isolate); + auto itFound = cache->CtorFunctions.find(this); + if (itFound != cache->CtorFunctions.end()) { + return Local::New(isolate, *itFound->second); + } GetConstructorFunctionTemplate(isolate, m_treeNode); auto ctorFunc = Local::New(isolate, *GetPersistentConstructorFunction(isolate)); diff --git a/test-app/runtime/src/main/cpp/ObjectManager.cpp b/test-app/runtime/src/main/cpp/ObjectManager.cpp index 4c861723b..18d474006 100644 --- a/test-app/runtime/src/main/cpp/ObjectManager.cpp +++ b/test-app/runtime/src/main/cpp/ObjectManager.cpp @@ -95,6 +95,7 @@ void ObjectManager::Init(Isolate* isolate) { auto jsWrapperFunc = jsWrapperFuncTemplate->GetFunction(context).ToLocalChecked(); m_poJsWrapperFunc = new Persistent(isolate, jsWrapperFunc); + m_jsWrapperTemplate.Reset(isolate, jsWrapperFuncTemplate->InstanceTemplate()); } JniLocalRef ObjectManager::GetJavaObjectByJsObject( @@ -743,6 +744,7 @@ void ObjectManager::ReleaseAllRegistered() { delete m_poJsWrapperFunc; m_poJsWrapperFunc = nullptr; } + m_jsWrapperTemplate.Reset(); } ObjectManager::~ObjectManager() { @@ -788,9 +790,9 @@ bool ObjectManager::ValidateWeakGlobalRefCallback(const int& javaObjectID, } Local ObjectManager::GetEmptyObject(Isolate* isolate) { - auto emptyObjCtorFunc = Local::New(isolate, *m_poJsWrapperFunc); + // The same object `new` on m_poJsWrapperFunc makes, without entering its callback. auto context = Runtime::GetRuntime(isolate)->GetContext(); - auto val = emptyObjCtorFunc->CallAsConstructor(context, 0, nullptr); + auto val = m_jsWrapperTemplate.Get(isolate)->NewInstance(context); if (val.IsEmpty()) { return Local(); } diff --git a/test-app/runtime/src/main/cpp/ObjectManager.h b/test-app/runtime/src/main/cpp/ObjectManager.h index a0c1aee7e..e6cfefdb7 100644 --- a/test-app/runtime/src/main/cpp/ObjectManager.h +++ b/test-app/runtime/src/main/cpp/ObjectManager.h @@ -268,6 +268,7 @@ class ObjectManager { static jmethodID RELEASE_NATIVE_INSTANCE_METHOD_ID; v8::Persistent* m_poJsWrapperFunc; + v8::Persistent m_jsWrapperTemplate; }; } // namespace tns From 079ae432e74a6f506da594e163d342368dcd3edb Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Mon, 9 Nov 2026 08:55:07 -0300 Subject: [PATCH 20/42] perf: read each thread's cached env and runtime with one TLS lookup Without ELF TLS (minSdk < 29) every thread_local access goes through __emutls_get_address. JEnv's cached env and its pinned flag, and the runtime's current runtime and id, were separate thread_locals read together on every JNI entry and runtime lookup; each pair is now one struct. --- test-app/runtime/src/main/cpp/JEnv.cpp | 11 ++++---- test-app/runtime/src/main/cpp/JEnv.h | 31 ++++++++++++++++------- test-app/runtime/src/main/cpp/Runtime.cpp | 26 +++++++++---------- test-app/runtime/src/main/cpp/Runtime.h | 10 +++++--- 4 files changed, 47 insertions(+), 31 deletions(-) diff --git a/test-app/runtime/src/main/cpp/JEnv.cpp b/test-app/runtime/src/main/cpp/JEnv.cpp index 28c29ce2e..f7104c3d8 100644 --- a/test-app/runtime/src/main/cpp/JEnv.cpp +++ b/test-app/runtime/src/main/cpp/JEnv.cpp @@ -33,13 +33,13 @@ JNIEnv* JEnv::GetEnvSlow() { } void JEnv::PinThreadEnv(JNIEnv* env) { - s_threadEnv = env; - s_threadEnvPinned = true; + s_thread.env = env; + s_thread.pinned = true; } void JEnv::UnpinThreadEnv() { - s_threadEnv = nullptr; - s_threadEnvPinned = false; + s_thread.env = nullptr; + s_thread.pinned = false; } JEnv::operator JNIEnv* () const { @@ -892,8 +892,7 @@ void JEnv::CheckForJavaException() { } JavaVM *JEnv::s_jvm = nullptr; -thread_local JNIEnv *JEnv::s_threadEnv = nullptr; -thread_local bool JEnv::s_threadEnvPinned = false; +thread_local JEnv::ThreadEnv JEnv::s_thread; robin_hood::unordered_map JEnv::s_classCache; robin_hood::unordered_map JEnv::s_missingClasses; jclass JEnv::RUNTIME_CLASS = nullptr; diff --git a/test-app/runtime/src/main/cpp/JEnv.h b/test-app/runtime/src/main/cpp/JEnv.h index 20d309a83..a9d8b6a8b 100644 --- a/test-app/runtime/src/main/cpp/JEnv.h +++ b/test-app/runtime/src/main/cpp/JEnv.h @@ -8,7 +8,7 @@ namespace tns { class JEnv { public: - JEnv() : m_env(s_threadEnv != nullptr ? s_threadEnv : GetEnvSlow()) { + JEnv() : m_env(CachedOrSlowEnv()) { } JEnv(JNIEnv* jniEnv) : m_env(jniEnv != nullptr ? jniEnv : JEnv().m_env) { @@ -346,6 +346,16 @@ class JEnv { static void UnpinThreadEnv(); + private: + // env is never filled from GetEnvSlow: its lazy attach is never detached by + // us, so nothing would tell us when the cached env goes stale. One struct + // because without ELF TLS (minSdk < 29) every thread_local costs a lookup. + struct ThreadEnv { + JNIEnv* env = nullptr; + bool pinned = false; + }; + + public: /* * Caches a JNI entry point's env for the duration of the call; leaves a * pinned env alone. The env passed to a native method is valid until @@ -354,15 +364,15 @@ class JEnv { class EntryScope { public: explicit EntryScope(JNIEnv* env) - : m_active(!s_threadEnvPinned), m_previous(s_threadEnv) { + : m_thread(s_thread), m_active(!m_thread.pinned), m_previous(m_thread.env) { if (m_active) { - s_threadEnv = env; + m_thread.env = env; } } ~EntryScope() { - if (m_active && !s_threadEnvPinned) { - s_threadEnv = m_previous; + if (m_active && !m_thread.pinned) { + m_thread.env = m_previous; } } @@ -370,6 +380,7 @@ class JEnv { EntryScope& operator=(const EntryScope&) = delete; private: + ThreadEnv& m_thread; bool m_active; JNIEnv* m_previous; }; @@ -377,14 +388,16 @@ class JEnv { private: static JNIEnv* GetEnvSlow(); + static JNIEnv* CachedOrSlowEnv() { + JNIEnv* env = s_thread.env; + return env != nullptr ? env : GetEnvSlow(); + } + void CheckForJavaException(); JNIEnv* m_env; - // Never filled from GetEnvSlow: its lazy attach is never detached by us, - // so nothing would tell us when the cached env goes stale. - static thread_local JNIEnv* s_threadEnv; - static thread_local bool s_threadEnvPinned; + static thread_local ThreadEnv s_thread; static JavaVM* s_jvm; diff --git a/test-app/runtime/src/main/cpp/Runtime.cpp b/test-app/runtime/src/main/cpp/Runtime.cpp index cbc45c836..50209f01a 100644 --- a/test-app/runtime/src/main/cpp/Runtime.cpp +++ b/test-app/runtime/src/main/cpp/Runtime.cpp @@ -134,7 +134,7 @@ Runtime::Runtime(JNIEnv* env, jobject runtime, int id) m_runGC(false) { // GetRuntime(int) and the thread's pinned JNIEnv assume one runtime per // thread; com.tns.Runtime's constructor enforces the same on the Java side. - NS_CHECK(s_currentRuntime == nullptr); + NS_CHECK(s_current.runtime == nullptr); m_runtime = env->NewGlobalRef(runtime); m_objectManager = new ObjectManager(m_runtime); @@ -148,8 +148,9 @@ Runtime::Runtime(JNIEnv* env, jobject runtime, int id) Runtime* Runtime::GetRuntime(int runtimeId) { // A runtime is only destroyed on its own thread, which is busy in this call, // so the calling thread's own runtime needs neither the lock nor the map. - if (runtimeId == s_currentRuntimeId) { - return s_currentRuntime; + auto& current = s_current; + if (runtimeId == current.id) { + return current.runtime; } Runtime* runtime = nullptr; @@ -289,9 +290,9 @@ void Runtime::Init(JNIEnv* env, jstring filesPath, jstring nativeLibDir, Runtime::~Runtime() { NS_CHECK(std::this_thread::get_id() == m_ownerThread); - if (s_currentRuntime == this) { - s_currentRuntime = nullptr; - s_currentRuntimeId = -1; + if (s_current.runtime == this) { + s_current.runtime = nullptr; + s_current.id = -1; } /* @@ -1216,8 +1217,8 @@ Isolate* Runtime::PrepareV8Runtime(const string& filesPath, this->m_context = new Persistent(isolate, context); this->m_napiEnv = NapiEnv::Create(context, m_eventLoop); - s_currentRuntime = this; - s_currentRuntimeId = m_id; + s_current.runtime = this; + s_current.id = m_id; // The runtime's thread stays attached until DestroyRuntime: the main thread // for the process, a worker thread until after it tears its runtime down. JEnv::PinThreadEnv(JEnv()); @@ -1296,9 +1297,9 @@ void Runtime::DestroyRuntime() { NapiEnv::Destroy(static_cast(m_napiEnv)); m_napiEnv = nullptr; } - if (s_currentRuntime == this) { - s_currentRuntime = nullptr; - s_currentRuntimeId = -1; + if (s_current.runtime == this) { + s_current.runtime = nullptr; + s_current.id = -1; JEnv::UnpinThreadEnv(); } // The events state holds v8::Global handles (backing event target, dispatch @@ -1394,8 +1395,7 @@ v8::Platform* Runtime::platform = nullptr; int Runtime::m_androidVersion = Runtime::GetAndroidVersion(); std::shared_ptr Runtime::s_mainEventLoop; -thread_local Runtime* Runtime::s_currentRuntime = nullptr; -thread_local int Runtime::s_currentRuntimeId = -1; +thread_local Runtime::CurrentRuntime Runtime::s_current; thread_local PendingIsolateSetup Runtime::s_pendingIsolateSetup; void Runtime::SetPendingIsolateSetup(PendingIsolateSetup setup) { diff --git a/test-app/runtime/src/main/cpp/Runtime.h b/test-app/runtime/src/main/cpp/Runtime.h index 701b0d375..0adbc9364 100644 --- a/test-app/runtime/src/main/cpp/Runtime.h +++ b/test-app/runtime/src/main/cpp/Runtime.h @@ -109,7 +109,7 @@ class Runtime { * thread hosts at most one runtime and only that thread may destroy it. */ static Runtime* GetCurrentRuntime() { - return s_currentRuntime; + return s_current.runtime; } /* @@ -441,8 +441,12 @@ class Runtime { static std::shared_ptr s_mainEventLoop; - static thread_local Runtime* s_currentRuntime; - static thread_local int s_currentRuntimeId; + // One struct because without ELF TLS (minSdk < 29) every thread_local costs a lookup. + struct CurrentRuntime { + Runtime* runtime = nullptr; + int id = -1; + }; + static thread_local CurrentRuntime s_current; static thread_local PendingIsolateSetup s_pendingIsolateSetup; #ifdef APPLICATION_IN_DEBUG From 7908de0745b14a2563bbcd9085aec26411260d5d Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Mon, 9 Nov 2026 15:14:20 -0300 Subject: [PATCH 21/42] test: benchmark the first access of large framework classes --- .../app/tests/benchmarks/jniBenchmarks.js | 61 ++++++++++++++++++- 1 file changed, 60 insertions(+), 1 deletion(-) diff --git a/test-app/app/src/main/assets/app/tests/benchmarks/jniBenchmarks.js b/test-app/app/src/main/assets/app/tests/benchmarks/jniBenchmarks.js index 349f6d7e1..da6b77c72 100644 --- a/test-app/app/src/main/assets/app/tests/benchmarks/jniBenchmarks.js +++ b/test-app/app/src/main/assets/app/tests/benchmarks/jniBenchmarks.js @@ -204,6 +204,22 @@ function buildBenchmarks() { return n; }); }, + function () { + if (skipped("instance.getAlpha() [android.view.View]")) return null; + var view = new android.view.View(com.tns.NativeScriptApplication.getInstance()); + return measureJs("instance.getAlpha() [android.view.View]", 50000, function (n) { + var s = 0; + for (var i = 0; i < n; i++) s = view.getAlpha(); + return s; + }); + }, + function () { + return measureJs("package member read android.view.View", 200000, function (n) { + var s = null; + for (var i = 0; i < n; i++) s = android.view.View; + return s; + }); + }, function () { return measureJs("instance.getInt()", 50000, function (n) { var s = 0; @@ -412,9 +428,52 @@ function buildContentionBenchmarks() { return list; } +// The first touch of a class builds its JS constructor and installs every static member, once +// per runtime, so each row is a single sample and must run before anything else touches the class. +function measureFirstAccess() { + var rows = [ + ["first access android.R", function () { return android.R; }], + ["first access android.R.attr", function () { return android.R.attr; }], + ["first access android.R.id", function () { return android.R.id; }], + ["first access android.R.style", function () { return android.R.style; }], + ["first access android.R.drawable", function () { return android.R.drawable; }], + ["first access android.R.string", function () { return android.R.string; }], + ["first read android.R.id.content", function () { return android.R.id.content; }], + ["first read android.R.id.text1", function () { return android.R.id.text1; }], + ["first read android.R.attr.textColor", function () { return android.R.attr.textColor; }], + ["first read android.R.style.Theme", function () { return android.R.style.Theme; }], + ["first access android.view [package]", function () { return android.view; }], + ["first access android.view.View", function () { return android.view.View; }], + ["first access android.widget [package]", function () { return android.widget; }], + ["first access android.widget.TextView", function () { return android.widget.TextView; }], + ["first access android.content.Context", function () { return android.content.Context; }], + ["first access android.app.Activity", function () { return android.app.Activity; }] + ]; + var results = []; + rows.forEach(function (row) { + if (skipped(row[0])) return; + gc(); + var start = performance.now(); + globalThis.__jniBenchSink = row[1](); + results.push(report(row[0], [(performance.now() - start) * 1e6])); + }); + var name = "first calls of 20 android.view.View methods"; + if (!skipped(name)) { + var view = new android.view.View(com.tns.NativeScriptApplication.getInstance()); + gc(); + var start = performance.now(); + globalThis.__jniBenchSink = [view.getAlpha(), view.getRotation(), view.getX(), view.getY(), view.getId(), + view.getWidth(), view.getHeight(), view.getLeft(), view.getTop(), view.getRight(), + view.getBottom(), view.getPaddingLeft(), view.getPaddingTop(), view.getScaleX(), view.getScaleY(), + view.getTranslationX(), view.getTranslationY(), view.getVisibility(), view.isEnabled(), view.isFocused()]; + results.push(report(name, [(performance.now() - start) * 1e6])); + } + return results; +} + exports.run = function () { + var results = measureFirstAccess(); var benchmarks = buildBenchmarks(); - var results = []; var index = 0; // Yield to the looper between benchmarks so GC and queued work run outside timed rounds. function next() { From 9eba5c96ff67577add95432e4edf5f9278e1f2b9 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Mon, 9 Nov 2026 15:37:16 -0300 Subject: [PATCH 22/42] test: benchmark static field reads and writes on a small and a huge class --- .../app/tests/benchmarks/jniBenchmarks.js | 31 +++++++++++++++++++ .../java/com/tns/tests/StaticFieldBench.java | 8 +++++ 2 files changed, 39 insertions(+) create mode 100644 test-app/app/src/main/java/com/tns/tests/StaticFieldBench.java diff --git a/test-app/app/src/main/assets/app/tests/benchmarks/jniBenchmarks.js b/test-app/app/src/main/assets/app/tests/benchmarks/jniBenchmarks.js index da6b77c72..ac5caf436 100644 --- a/test-app/app/src/main/assets/app/tests/benchmarks/jniBenchmarks.js +++ b/test-app/app/src/main/assets/app/tests/benchmarks/jniBenchmarks.js @@ -331,6 +331,37 @@ function buildBenchmarks() { return s; }); }, + function () { + return measureJs("static field get int [small class]", 50000, function (n) { + var Fields = com.tns.tests.StaticFieldBench; + var s = 0; + for (var i = 0; i < n; i++) s = Fields.counter; + return s; + }); + }, + function () { + return measureJs("static field get final int [small class]", 50000, function (n) { + var Fields = com.tns.tests.StaticFieldBench; + var s = 0; + for (var i = 0; i < n; i++) s = Fields.CONSTANT; + return s; + }); + }, + function () { + return measureJs("static field set int [small class]", 50000, function (n) { + var Fields = com.tns.tests.StaticFieldBench; + for (var i = 0; i < n; i++) Fields.counter = i; + return n; + }); + }, + function () { + return measureJs("static field get final int [android.R.id]", 50000, function (n) { + var ids = android.R.id; + var s = 0; + for (var i = 0; i < n; i++) s = ids.content; + return s; + }); + }, function () { return measureJs("field.get int", 50000, function (n) { var s = 0; diff --git a/test-app/app/src/main/java/com/tns/tests/StaticFieldBench.java b/test-app/app/src/main/java/com/tns/tests/StaticFieldBench.java new file mode 100644 index 000000000..fb6bc8904 --- /dev/null +++ b/test-app/app/src/main/java/com/tns/tests/StaticFieldBench.java @@ -0,0 +1,8 @@ +package com.tns.tests; + +/** A class with few static members, for static field benchmarks; JniBench has too many. */ +public class StaticFieldBench { + public static int counter = 1; + + public static final int CONSTANT = 7; +} From 706c01225174b9ec078d7a46a82776d686b3005b Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Mon, 9 Nov 2026 15:45:00 -0300 Subject: [PATCH 23/42] perf: install a large class's static fields without a getter and setter function each The first touch of a class installs every static field on its constructor, and each field got two JS functions (a getter and a setter) built from their own FunctionTemplates. For android.R.attr that took ~9 ms on the test phone, android.R.style ~6 ms and android.view.View ~5 ms, per runtime. A class with more than 64 static fields is now moved to dictionary mode first (adding properties one by one to a fast-mode object copies its descriptors each time, which made the native accessors below quadratic) and gets: - for a final field, a lazy data property: the first read goes through JNI and the value replaces the accessor, read-only, so later reads are plain property loads (android.R.id.content: 74 ns -> 9 ns); - for any other field, a native accessor pair instead of two functions. First access: android.R.attr 8.9 ms -> 1.6 ms, android.R.style 6.2 -> 0.9 ms, android.view.View 4.9 -> 2.7 ms. Smaller classes keep their accessor functions. Assigning to a final field of a large class now fails like any read-only property (a TypeError in strict code) instead of with the runtime's own error. --- .../runtime/src/main/cpp/MetadataNode.cpp | 78 +++++++++++++++++++ test-app/runtime/src/main/cpp/MetadataNode.h | 4 + 2 files changed, 82 insertions(+) diff --git a/test-app/runtime/src/main/cpp/MetadataNode.cpp b/test-app/runtime/src/main/cpp/MetadataNode.cpp index a27c1d036..81d691b95 100644 --- a/test-app/runtime/src/main/cpp/MetadataNode.cpp +++ b/test-app/runtime/src/main/cpp/MetadataNode.cpp @@ -477,6 +477,51 @@ void MetadataNode::FieldAccessorGetterCallback(const FunctionCallbackInfo nsEx.ReThrowToV8(); } } +void MetadataNode::StaticFieldGetterCallback(Local property, const PropertyCallbackInfo& info) { + try { + auto fieldCallbackData = reinterpret_cast(info.Data().As()->Value(v8::kExternalPointerTypeTagDefault)); + if (fieldCallbackData->metadata.getDeclaringType() == "") { + info.GetReturnValue().SetUndefined(); + return; + } + auto isolate = info.GetIsolate(); + info.GetReturnValue().Set(CallbackHandlers::GetJavaField(isolate, info.Holder(), fieldCallbackData)); + } catch (NativeScriptException& e) { + e.ReThrowToV8(); + } catch (std::exception e) { + stringstream ss; + ss << "Error: c++ exception: " << e.what() << endl; + NativeScriptException nsEx(ss.str()); + nsEx.ReThrowToV8(); + } catch (...) { + NativeScriptException nsEx(std::string("Error: c++ exception!")); + nsEx.ReThrowToV8(); + } +} + +void MetadataNode::StaticFieldSetterCallback(Local property, Local value, const PropertyCallbackInfo& info) { + try { + auto fieldCallbackData = reinterpret_cast(info.Data().As()->Value(v8::kExternalPointerTypeTagDefault)); + auto &fieldCallbackMetadata = fieldCallbackData->metadata; + if (fieldCallbackMetadata.getIsFinal()) { + stringstream ss; + ss << "You are trying to set \"" << fieldCallbackMetadata.getName() << "\" which is a final field! Final fields can only be read."; + throw NativeScriptException(ss.str()); + } + CallbackHandlers::SetJavaField(info.GetIsolate(), info.Holder(), value, fieldCallbackData); + } catch (NativeScriptException& e) { + e.ReThrowToV8(); + } catch (std::exception e) { + stringstream ss; + ss << "Error: c++ exception: " << e.what() << endl; + NativeScriptException nsEx(ss.str()); + nsEx.ReThrowToV8(); + } catch (...) { + NativeScriptException nsEx(std::string("Error: c++ exception!")); + nsEx.ReThrowToV8(); + } +} + void MetadataNode::FieldAccessorSetterCallback(const FunctionCallbackInfo& info) { auto value = info[0]; try { @@ -958,6 +1003,18 @@ vector MetadataNode::SetInstanceMembersFromRu } +static constexpr int kManyStaticFields = 64; + +// V8 has no API for this; deleting a property other than the last one added does it. +static void MoveToDictionaryMode(Isolate* isolate, Local context, Local object) { + auto first = String::NewFromUtf8Literal(isolate, "__ns_dictionary_a"); + auto second = String::NewFromUtf8Literal(isolate, "__ns_dictionary_b"); + object->Set(context, first, v8::True(isolate)).Check(); + object->Set(context, second, v8::True(isolate)).Check(); + object->Delete(context, first).Check(); + object->Delete(context, second).Check(); +} + void MetadataNode::SetStaticMembers(Isolate* isolate, Local& ctorFunction, MetadataTreeNode* treeNode, uint8_t* &curPtr) { auto hasCustomMetadata = treeNode->metadata != nullptr; auto context = isolate->GetCurrentContext(); @@ -995,11 +1052,32 @@ void MetadataNode::SetStaticMembers(Isolate* isolate, Local& ctorFunct //get candidates from static fields metadata auto staticFieldCout = *reinterpret_cast(curPtr); curPtr += sizeof(uint16_t); + // A class with many static fields (every android.R inner class) installs all of them + // on first touch. Native accessors skip creating a getter and a setter function per + // field, but adding them one by one to a fast-mode object copies its descriptors each + // time, so such a class is moved to dictionary mode first. Its final fields become + // their value on first read (read-only), since a native accessor on a dictionary-mode + // object is slower to call than reading a plain property. + bool manyFields = staticFieldCout > kManyStaticFields; + if (manyFields) { + MoveToDictionaryMode(isolate, context, ctorFunction); + } for (auto i = 0; i < staticFieldCout; i++) { auto entry = MetadataReader::ReadStaticFieldEntry(&curPtr); auto fieldName = ArgConverter::ConvertToV8String(isolate, entry.getName()); auto fieldData = External::New(isolate, cache->Own(entry), v8::kExternalPointerTypeTagDefault); + if (manyFields) { + if (entry.getIsFinal()) { + ctorFunction->SetLazyDataProperty(context, fieldName, StaticFieldGetterCallback, fieldData, + static_cast(PropertyAttribute::ReadOnly | PropertyAttribute::DontDelete)).ToChecked(); + } else { + ctorFunction->SetNativeDataProperty(context, fieldName, StaticFieldGetterCallback, + StaticFieldSetterCallback, fieldData, + PropertyAttribute::DontDelete).ToChecked(); + } + continue; + } auto fieldGetter = FunctionTemplate::New(isolate, FieldAccessorGetterCallback, fieldData) ->GetFunction(context).ToLocalChecked(); auto fieldSetter = FunctionTemplate::New(isolate, FieldAccessorSetterCallback, fieldData) diff --git a/test-app/runtime/src/main/cpp/MetadataNode.h b/test-app/runtime/src/main/cpp/MetadataNode.h index a1325dd36..8e22eadbb 100644 --- a/test-app/runtime/src/main/cpp/MetadataNode.h +++ b/test-app/runtime/src/main/cpp/MetadataNode.h @@ -158,6 +158,10 @@ class MetadataNode { static void FieldAccessorGetterCallback(const v8::FunctionCallbackInfo& info); static void FieldAccessorSetterCallback(const v8::FunctionCallbackInfo& info); + + static void StaticFieldGetterCallback(v8::Local property, const v8::PropertyCallbackInfo& info); + + static void StaticFieldSetterCallback(v8::Local property, v8::Local value, const v8::PropertyCallbackInfo& info); static void PropertyAccessorGetterCallback(const v8::FunctionCallbackInfo& info); static void PropertyAccessorSetterCallback(const v8::FunctionCallbackInfo& info); From cb7022a6737f3a0bbf92f9c1bf86c97dbdca2a15 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Mon, 9 Nov 2026 16:55:45 -0300 Subject: [PATCH 24/42] perf: build a large package's object in dictionary mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A package object gets one lazy property per child, added one by one. On a fast-mode object each addition copies the descriptors, so android.view (~800 µs) and android.widget (~950 µs) cost quadratic time on first access. Packages with more than 64 children now start in dictionary mode, the same way a class with many static fields does. --- .../runtime/src/main/cpp/MetadataNode.cpp | 31 +++++++++++-------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/test-app/runtime/src/main/cpp/MetadataNode.cpp b/test-app/runtime/src/main/cpp/MetadataNode.cpp index 81d691b95..b7a58a039 100644 --- a/test-app/runtime/src/main/cpp/MetadataNode.cpp +++ b/test-app/runtime/src/main/cpp/MetadataNode.cpp @@ -27,6 +27,20 @@ using namespace v8; using namespace std; using namespace tns; +// Objects that get more members than this are moved to dictionary mode before the members are +// added: adding properties one by one to a fast-mode object copies its descriptors each time. +static constexpr size_t kManyMembers = 64; + +// V8 has no API for this; deleting a property other than the last one added does it. +static void MoveToDictionaryMode(Isolate* isolate, Local context, Local object) { + auto first = String::NewFromUtf8Literal(isolate, "__ns_dictionary_a"); + auto second = String::NewFromUtf8Literal(isolate, "__ns_dictionary_b"); + object->Set(context, first, v8::True(isolate)).Check(); + object->Set(context, second, v8::True(isolate)).Check(); + object->Delete(context, first).Check(); + object->Delete(context, second).Check(); +} + void MetadataNode::Init(Isolate* isolate) { auto key = ArgConverter::ConvertToV8String(isolate, "tns::MetadataKey"); auto cache = GetMetadataNodeCache(isolate); @@ -351,6 +365,9 @@ Local MetadataNode::CreatePackageObject(Isolate* isolate) { auto ptrChildren = this->m_treeNode->children; if (ptrChildren != nullptr) { const auto& children = *ptrChildren; + if (children.size() > kManyMembers) { + MoveToDictionaryMode(isolate, ctx, packageObj); + } for (auto childNode: children) { packageObj->SetLazyDataProperty(ctx, ArgConverter::ConvertToV8String(isolate, childNode->name), @@ -1003,18 +1020,6 @@ vector MetadataNode::SetInstanceMembersFromRu } -static constexpr int kManyStaticFields = 64; - -// V8 has no API for this; deleting a property other than the last one added does it. -static void MoveToDictionaryMode(Isolate* isolate, Local context, Local object) { - auto first = String::NewFromUtf8Literal(isolate, "__ns_dictionary_a"); - auto second = String::NewFromUtf8Literal(isolate, "__ns_dictionary_b"); - object->Set(context, first, v8::True(isolate)).Check(); - object->Set(context, second, v8::True(isolate)).Check(); - object->Delete(context, first).Check(); - object->Delete(context, second).Check(); -} - void MetadataNode::SetStaticMembers(Isolate* isolate, Local& ctorFunction, MetadataTreeNode* treeNode, uint8_t* &curPtr) { auto hasCustomMetadata = treeNode->metadata != nullptr; auto context = isolate->GetCurrentContext(); @@ -1058,7 +1063,7 @@ void MetadataNode::SetStaticMembers(Isolate* isolate, Local& ctorFunct // time, so such a class is moved to dictionary mode first. Its final fields become // their value on first read (read-only), since a native accessor on a dictionary-mode // object is slower to call than reading a plain property. - bool manyFields = staticFieldCout > kManyStaticFields; + bool manyFields = staticFieldCout > kManyMembers; if (manyFields) { MoveToDictionaryMode(isolate, context, ctorFunction); } From 7c79224265cfa8a8275c2c608364fe9fb02d6160 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Mon, 9 Nov 2026 16:55:45 -0300 Subject: [PATCH 25/42] perf: create member names internalized, straight from the metadata V8 internalizes every property name, so a name built from a std::string copy with kNormal was copied twice more. Names are now created internalized, and static field names come from a view into the metadata's name table, so installing a static field no longer reads its name into the entry. The field accessors read the name on first use instead. --- .../runtime/src/main/cpp/FieldAccessor.cpp | 14 +++++------ .../runtime/src/main/cpp/MetadataNode.cpp | 24 ++++++++++++------- .../runtime/src/main/cpp/MetadataReader.h | 7 ++++++ 3 files changed, 29 insertions(+), 16 deletions(-) diff --git a/test-app/runtime/src/main/cpp/FieldAccessor.cpp b/test-app/runtime/src/main/cpp/FieldAccessor.cpp index 9a668585f..eacc45bc9 100644 --- a/test-app/runtime/src/main/cpp/FieldAccessor.cpp +++ b/test-app/runtime/src/main/cpp/FieldAccessor.cpp @@ -39,10 +39,10 @@ Local FieldAccessor::GetJavaField(Isolate* isolate, const Local& if (isStatic) { fieldData->clazz = env.FindClass(fieldMetadata.getDeclaringType()); - fieldData->fid = env.GetStaticFieldID(fieldData->clazz, fieldMetadata.name, fieldJniSig); + fieldData->fid = env.GetStaticFieldID(fieldData->clazz, fieldMetadata.getName(), fieldJniSig); } else { fieldData->clazz = env.FindClass(fieldMetadata.getDeclaringType()); - fieldData->fid = env.GetFieldID(fieldData->clazz, fieldMetadata.name, fieldJniSig); + fieldData->fid = env.GetFieldID(fieldData->clazz, fieldMetadata.getName(), fieldJniSig); } } @@ -51,7 +51,7 @@ Local FieldAccessor::GetJavaField(Isolate* isolate, const Local& if (targetJavaObject.IsNull()) { stringstream ss; - ss << "Cannot access property '" << fieldMetadata.name.c_str() << "' because there is no corresponding Java object"; + ss << "Cannot access property '" << fieldMetadata.getName().c_str() << "' because there is no corresponding Java object"; throw NativeScriptException(ss.str()); } } @@ -207,12 +207,12 @@ void FieldAccessor::SetJavaField(Isolate* isolate, const Local& target, if (isStatic) { fieldData->clazz = env.FindClass(fieldMetadata.getDeclaringType()); NS_DCHECK(fieldData->clazz != nullptr); - fieldData->fid = env.GetStaticFieldID(fieldData->clazz, fieldMetadata.name, fieldJniSig); + fieldData->fid = env.GetStaticFieldID(fieldData->clazz, fieldMetadata.getName(), fieldJniSig); NS_DCHECK(fieldData->fid != nullptr); } else { fieldData->clazz = env.FindClass(fieldMetadata.getDeclaringType()); NS_DCHECK(fieldData->clazz != nullptr); - fieldData->fid = env.GetFieldID(fieldData->clazz, fieldMetadata.name, fieldJniSig); + fieldData->fid = env.GetFieldID(fieldData->clazz, fieldMetadata.getName(), fieldJniSig); NS_DCHECK(fieldData->fid != nullptr); } } @@ -222,7 +222,7 @@ void FieldAccessor::SetJavaField(Isolate* isolate, const Local& target, if (targetJavaObject.IsNull()) { stringstream ss; - ss << "Cannot access property '" << fieldMetadata.name.c_str() << "' because there is no corresponding Java object"; + ss << "Cannot access property '" << fieldMetadata.getName().c_str() << "' because there is no corresponding Java object"; throw NativeScriptException(ss.str()); } } @@ -325,7 +325,7 @@ void FieldAccessor::SetJavaField(Isolate* isolate, const Local& target, result = ArgConverter::ConvertToJavaString(value); } else { if (value->IsProxy() && !ObjectManager::UnwrapProxy(value)->IsObject()) { - throw NativeScriptException("Cannot assign a revoked Proxy to field '" + fieldMetadata.name + "'"); + throw NativeScriptException("Cannot assign a revoked Proxy to field '" + fieldMetadata.getName() + "'"); } auto context = isolate->GetCurrentContext(); auto objectWithHiddenID = value->ToObject(context).ToLocalChecked(); diff --git a/test-app/runtime/src/main/cpp/MetadataNode.cpp b/test-app/runtime/src/main/cpp/MetadataNode.cpp index b7a58a039..32393a2e5 100644 --- a/test-app/runtime/src/main/cpp/MetadataNode.cpp +++ b/test-app/runtime/src/main/cpp/MetadataNode.cpp @@ -41,6 +41,12 @@ static void MoveToDictionaryMode(Isolate* isolate, Local context, Local object->Delete(context, second).Check(); } +// Property names are internalized by V8 anyway; creating them internalized skips a copy. +static Local NewMemberName(Isolate* isolate, std::string_view name) { + return String::NewFromUtf8(isolate, name.data(), NewStringType::kInternalized, + static_cast(name.size())).ToLocalChecked(); +} + void MetadataNode::Init(Isolate* isolate) { auto key = ArgConverter::ConvertToV8String(isolate, "tns::MetadataKey"); auto cache = GetMetadataNodeCache(isolate); @@ -370,7 +376,7 @@ Local MetadataNode::CreatePackageObject(Isolate* isolate) { } for (auto childNode: children) { packageObj->SetLazyDataProperty(ctx, - ArgConverter::ConvertToV8String(isolate, childNode->name), + NewMemberName(isolate, childNode->name), PackageGetterCallback, extData).ToChecked(); } @@ -713,8 +719,7 @@ class MetadataNode::PrototypeTemplateFiller { Local funcData = External::New(isolate, methodInfo, v8::kExternalPointerTypeTagDefault); Local funcTemplate = FunctionTemplate::New(isolate, MetadataNode::MethodCallback, funcData); - Local nameString = ArgConverter::ConvertToV8String(isolate, name); - m_protoTemplate->Set(nameString, funcTemplate); + m_protoTemplate->Set(NewMemberName(isolate, name), funcTemplate); m_addedNames.insert(name); } @@ -726,7 +731,7 @@ class MetadataNode::PrototypeTemplateFiller { } Local fieldData = External::New(isolate, fieldInfo, v8::kExternalPointerTypeTagDefault); - Local nameString = ArgConverter::ConvertToV8String(isolate, name); + Local nameString = NewMemberName(isolate, name); m_protoTemplate->SetAccessorProperty( nameString, FunctionTemplate::New(isolate, MetadataNode::FieldAccessorGetterCallback, fieldData), @@ -743,7 +748,7 @@ class MetadataNode::PrototypeTemplateFiller { } Local propertyData = External::New(isolate, propertyInfo, v8::kExternalPointerTypeTagDefault); - Local nameString = ArgConverter::ConvertToV8String(isolate, name); + Local nameString = NewMemberName(isolate, name); m_protoTemplate->SetAccessorProperty( nameString, FunctionTemplate::New(isolate, MetadataNode::PropertyAccessorGetterCallback, propertyData), @@ -1042,7 +1047,7 @@ void MetadataNode::SetStaticMembers(Isolate* isolate, Local& ctorFunct auto funcData = External::New(isolate, callbackData, v8::kExternalPointerTypeTagDefault); auto funcTemplate = FunctionTemplate::New(isolate, MethodCallback, funcData); auto func = funcTemplate->GetFunction(context).ToLocalChecked(); - auto funcName = ArgConverter::ConvertToV8String(isolate, methodName); + auto funcName = NewMemberName(isolate, methodName); ctorFunction->Set(context, funcName, Wrap(isolate, func, methodName, origin, false /* isCtorFunc */)); lastMethodName = methodName; } @@ -1070,7 +1075,7 @@ void MetadataNode::SetStaticMembers(Isolate* isolate, Local& ctorFunct for (auto i = 0; i < staticFieldCout; i++) { auto entry = MetadataReader::ReadStaticFieldEntry(&curPtr); - auto fieldName = ArgConverter::ConvertToV8String(isolate, entry.getName()); + auto fieldName = NewMemberName(isolate, s_metadataReader.ReadNameView(entry.sfi->nameOffset)); auto fieldData = External::New(isolate, cache->Own(entry), v8::kExternalPointerTypeTagDefault); if (manyFields) { if (entry.getIsFinal()) { @@ -1108,12 +1113,13 @@ void MetadataNode::SetInnerTypes(v8::Isolate* isolate, Local& ctorFunc auto context = isolate->GetCurrentContext(); const auto &children = *treeNode->children; for (auto curChild: children) { - bool hasOwnProperty = ctorFunction->HasOwnProperty(context, ArgConverter::ConvertToV8String(isolate, curChild->name)).ToChecked(); + auto childName = NewMemberName(isolate, curChild->name); + bool hasOwnProperty = ctorFunction->HasOwnProperty(context, childName).ToChecked(); // Child is defined as a function already when the inner type is a companion object if (!hasOwnProperty) { ctorFunction->SetNativeDataProperty( context, - v8::String::NewFromUtf8(isolate, curChild->name.c_str()).ToLocalChecked(), + childName, InnerTypeAccessorGetterCallback, nullptr, v8::External::New(isolate, curChild, v8::kExternalPointerTypeTagDefault) ).ToChecked(); diff --git a/test-app/runtime/src/main/cpp/MetadataReader.h b/test-app/runtime/src/main/cpp/MetadataReader.h index 76ade1e1c..3683f2b55 100644 --- a/test-app/runtime/src/main/cpp/MetadataReader.h +++ b/test-app/runtime/src/main/cpp/MetadataReader.h @@ -7,6 +7,7 @@ #include #include #include +#include #include #include "NativeScriptAssert.h" #include "robin_hood.h" @@ -93,6 +94,12 @@ namespace tns { std::string ReadTypeName(MetadataTreeNode *treeNode); + inline std::string_view ReadNameView(uint32_t offset) { + uint16_t length = *reinterpret_cast(m_nameData + offset); + + return {reinterpret_cast(m_nameData + offset + sizeof(uint16_t)), length}; + } + inline std::string ReadName(uint32_t offset) { uint16_t length = *reinterpret_cast(m_nameData + offset); From 85a82dcd939ba127a700bbf0296858b11de2a712 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Mon, 9 Nov 2026 16:55:45 -0300 Subject: [PATCH 26/42] perf: find a method's base-class counterpart by hash, not by a scan Each method of a class scanned every method of its base class, so a large class over a large base (TextView over View) did hundreds of thousands of string comparisons. The base class's methods are now indexed by name once. --- .../runtime/src/main/cpp/MetadataNode.cpp | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/test-app/runtime/src/main/cpp/MetadataNode.cpp b/test-app/runtime/src/main/cpp/MetadataNode.cpp index 32393a2e5..8036efb6a 100644 --- a/test-app/runtime/src/main/cpp/MetadataNode.cpp +++ b/test-app/runtime/src/main/cpp/MetadataNode.cpp @@ -811,6 +811,17 @@ vector MetadataNode::SetInstanceMethodsFromS robin_hood::unordered_map collectedExtensionMethodDatas; + // The base class is fully built, so its method names stay put while this class is. + robin_hood::unordered_flat_map baseMethodsByName; + baseMethodsByName.reserve(baseInstanceMethodsCallbackData.size()); + for (auto baseMethod: baseInstanceMethodsCallbackData) { + baseMethodsByName.emplace(baseMethod->candidates.front().name, baseMethod); + } + auto findBaseMethod = [&baseMethodsByName](const std::string &name) -> MethodCallbackData * { + auto itFound = baseMethodsByName.find(std::string_view(name)); + return itFound != baseMethodsByName.end() ? itFound->second : nullptr; + }; + auto extensionFunctionsCount = *reinterpret_cast(curPtr); curPtr += sizeof(uint16_t); for (auto i = 0; i < extensionFunctionsCount; i++) { @@ -858,14 +869,7 @@ vector MetadataNode::SetInstanceMethodsFromS instanceMethodData.push_back(callbackData); instanceMethodsCallbackData.push_back(callbackData); - auto itBegin = baseInstanceMethodsCallbackData.begin(); - auto itEnd = baseInstanceMethodsCallbackData.end(); - auto itFound = find_if(itBegin, itEnd, [&methodName](MethodCallbackData *x) { - return x->candidates.front().name == methodName; - }); - if (itFound != itEnd) { - callbackData->parent = *itFound; - } + callbackData->parent = findBaseMethod(methodName); if (s_profilerEnabled.load(std::memory_order_relaxed)) { Local funcData = External::New(isolate, callbackData, v8::kExternalPointerTypeTagDefault); From 7a16959e3cfae97d14c75c93247e6e00c8dd59aa Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Mon, 9 Nov 2026 17:50:51 -0300 Subject: [PATCH 27/42] perf: create Java methods and accessors as non-constructors When it instantiates a constructible API function, V8 also builds that function's prototype object, a constructor property on it and an instance map. A Java class's methods never use them, and a large class has hundreds of methods (View's first access: 2.0 -> 1.5 ms). Instance methods, field and property accessors and the runtime's helper functions are now created with ConstructorBehavior::kThrow. `new view.getAlpha()` already threw (no Java object behind `this`); it now throws a TypeError. Class constructors stay constructible, and so do static methods: code like `new SomeClass.staticFactory()` works today because the factory returns an object, and published plugins rely on it. --- .../src/main/assets/app/tests/testMetadata.js | 24 +++++++++++- .../runtime/src/main/cpp/MetadataNode.cpp | 37 ++++++++++++------- 2 files changed, 46 insertions(+), 15 deletions(-) diff --git a/test-app/app/src/main/assets/app/tests/testMetadata.js b/test-app/app/src/main/assets/app/tests/testMetadata.js index 1d7469203..a9d2df4cc 100644 --- a/test-app/app/src/main/assets/app/tests/testMetadata.js +++ b/test-app/app/src/main/assets/app/tests/testMetadata.js @@ -35,4 +35,26 @@ describe("Tests metadata", function () { var expected = 5; expect(keywordClass.getValue5()).toBe(expected); }); -}); \ No newline at end of file +}); +describe("Java members", function () { + it("are not constructors", function () { + var list = new java.util.ArrayList(); + + expect(function () { return new list.size(); }).toThrowError(TypeError); + expect(list.size.prototype).toBeUndefined(); + }); + + it("keep static methods constructible, so new on a static factory returns its object", function () { + var id = new java.util.UUID.randomUUID(); + + expect(id instanceof java.util.UUID).toBe(true); + }); + + it("leave class constructors constructible", function () { + var MyObject = java.lang.Object.extend({ toString: function () { return "mine"; } }); + + expect(new java.util.ArrayList().size()).toBe(0); + expect(new MyObject().toString()).toBe("mine"); + expect(java.util.UUID.randomUUID() instanceof java.util.UUID).toBe(true); + }); +}); diff --git a/test-app/runtime/src/main/cpp/MetadataNode.cpp b/test-app/runtime/src/main/cpp/MetadataNode.cpp index 8036efb6a..dadd3c55a 100644 --- a/test-app/runtime/src/main/cpp/MetadataNode.cpp +++ b/test-app/runtime/src/main/cpp/MetadataNode.cpp @@ -41,6 +41,14 @@ static void MoveToDictionaryMode(Isolate* isolate, Local context, Local object->Delete(context, second).Check(); } +// For everything but class constructors and static methods: V8 builds a prototype object and +// an instance map for each constructible API function it instantiates, which a method never uses. +static Local NewNonConstructor(Isolate* isolate, FunctionCallback callback, + Local data = Local()) { + return FunctionTemplate::New(isolate, callback, data, Local(), 0, + ConstructorBehavior::kThrow); +} + // Property names are internalized by V8 anyway; creating them internalized skips a copy. static Local NewMemberName(Isolate* isolate, std::string_view name) { return String::NewFromUtf8(isolate, name.data(), NewStringType::kInternalized, @@ -389,7 +397,7 @@ void MetadataNode::SetClassAccessor(Local& ctorFunction) { auto isolate = v8::Isolate::GetCurrent(); auto classFieldName = ArgConverter::ConvertToV8String(isolate, "class"); auto context = isolate->GetCurrentContext(); - auto classGetter = FunctionTemplate::New(isolate, ClassAccessorGetterCallback) + auto classGetter = NewNonConstructor(isolate, ClassAccessorGetterCallback) ->GetFunction(context).ToLocalChecked(); ctorFunction->SetAccessorProperty(classFieldName, classGetter, Local(), PropertyAttribute::DontDelete); @@ -430,7 +438,7 @@ void MetadataNode::NullObjectAccessorGetterCallback(const FunctionCallbackInfo(info.Data().As()->Value(v8::kExternalPointerTypeTagDefault)); V8SetPrivateValue(isolate, thiz, V8StringConstants::GetNullNodePrivate(isolate), External::New(isolate, node, v8::kExternalPointerTypeTagDefault)); - auto funcTemplate = FunctionTemplate::New(isolate, MetadataNode::NullValueOfCallback); + auto funcTemplate = NewNonConstructor(isolate, MetadataNode::NullValueOfCallback); auto context = isolate->GetCurrentContext(); thiz->Delete(context, V8StringConstants::GetValueOf(isolate)); thiz->Set(context, V8StringConstants::GetValueOf(isolate), funcTemplate->GetFunction(context).ToLocalChecked()); @@ -718,7 +726,7 @@ class MetadataNode::PrototypeTemplateFiller { } Local funcData = External::New(isolate, methodInfo, v8::kExternalPointerTypeTagDefault); - Local funcTemplate = FunctionTemplate::New(isolate, MetadataNode::MethodCallback, funcData); + Local funcTemplate = NewNonConstructor(isolate, MetadataNode::MethodCallback, funcData); m_protoTemplate->Set(NewMemberName(isolate, name), funcTemplate); m_addedNames.insert(name); } @@ -734,8 +742,8 @@ class MetadataNode::PrototypeTemplateFiller { Local nameString = NewMemberName(isolate, name); m_protoTemplate->SetAccessorProperty( nameString, - FunctionTemplate::New(isolate, MetadataNode::FieldAccessorGetterCallback, fieldData), - FunctionTemplate::New(isolate, MetadataNode::FieldAccessorSetterCallback, fieldData), + NewNonConstructor(isolate, MetadataNode::FieldAccessorGetterCallback, fieldData), + NewNonConstructor(isolate, MetadataNode::FieldAccessorSetterCallback, fieldData), PropertyAttribute::DontDelete); m_addedNames.insert(name); } @@ -751,8 +759,8 @@ class MetadataNode::PrototypeTemplateFiller { Local nameString = NewMemberName(isolate, name); m_protoTemplate->SetAccessorProperty( nameString, - FunctionTemplate::New(isolate, MetadataNode::PropertyAccessorGetterCallback, propertyData), - FunctionTemplate::New(isolate, MetadataNode::PropertyAccessorSetterCallback, propertyData), + NewNonConstructor(isolate, MetadataNode::PropertyAccessorGetterCallback, propertyData), + NewNonConstructor(isolate, MetadataNode::PropertyAccessorSetterCallback, propertyData), PropertyAttribute::DontDelete); m_addedNames.insert(name); } @@ -873,7 +881,7 @@ vector MetadataNode::SetInstanceMethodsFromS if (s_profilerEnabled.load(std::memory_order_relaxed)) { Local funcData = External::New(isolate, callbackData, v8::kExternalPointerTypeTagDefault); - Local funcTemplate = FunctionTemplate::New(isolate, MethodCallback, funcData); + Local funcTemplate = NewNonConstructor(isolate, MethodCallback, funcData); auto func = funcTemplate->GetFunction(context).ToLocalChecked(); std::string origin = Constants::APP_ROOT_FOLDER_PATH + GetOrCreateInternal(treeNode)->m_name; Local wrapperFunc = Wrap(isolate, func, methodName, origin, @@ -1049,6 +1057,7 @@ void MetadataNode::SetStaticMembers(Isolate* isolate, Local& ctorFunct if (methodName != lastMethodName) { callbackData = cache->Own(this); auto funcData = External::New(isolate, callbackData, v8::kExternalPointerTypeTagDefault); + // Constructible on purpose: `new X.staticFactory()` returns the factory's object. auto funcTemplate = FunctionTemplate::New(isolate, MethodCallback, funcData); auto func = funcTemplate->GetFunction(context).ToLocalChecked(); auto funcName = NewMemberName(isolate, methodName); @@ -1060,7 +1069,7 @@ void MetadataNode::SetStaticMembers(Isolate* isolate, Local& ctorFunct //attach .extend function auto extendFuncName = V8StringConstants::GetExtend(isolate); - auto extendFuncTemplate = FunctionTemplate::New(isolate, ExtendMethodCallback, External::New(isolate, this, v8::kExternalPointerTypeTagDefault)); + auto extendFuncTemplate = NewNonConstructor(isolate, ExtendMethodCallback, External::New(isolate, this, v8::kExternalPointerTypeTagDefault)); ctorFunction->Set(context, extendFuncName, extendFuncTemplate->GetFunction(context).ToLocalChecked()); //get candidates from static fields metadata @@ -1092,9 +1101,9 @@ void MetadataNode::SetStaticMembers(Isolate* isolate, Local& ctorFunct } continue; } - auto fieldGetter = FunctionTemplate::New(isolate, FieldAccessorGetterCallback, fieldData) + auto fieldGetter = NewNonConstructor(isolate, FieldAccessorGetterCallback, fieldData) ->GetFunction(context).ToLocalChecked(); - auto fieldSetter = FunctionTemplate::New(isolate, FieldAccessorSetterCallback, fieldData) + auto fieldSetter = NewNonConstructor(isolate, FieldAccessorSetterCallback, fieldData) ->GetFunction(context).ToLocalChecked(); ctorFunction->SetAccessorProperty(fieldName, fieldGetter, fieldSetter, PropertyAttribute::DontDelete); @@ -1104,7 +1113,7 @@ void MetadataNode::SetStaticMembers(Isolate* isolate, Local& ctorFunct Local nullObjectData = External::New(isolate, this, v8::kExternalPointerTypeTagDefault); auto nullObjectGetter = - FunctionTemplate::New(isolate, NullObjectAccessorGetterCallback, nullObjectData) + NewNonConstructor(isolate, NullObjectAccessorGetterCallback, nullObjectData) ->GetFunction(context).ToLocalChecked(); ctorFunction->SetAccessorProperty(nullObjectName, nullObjectGetter); @@ -1947,7 +1956,7 @@ void MetadataNode::ExtendMethodCallback(const v8::FunctionCallbackInfoSetPrototype(context, baseClassCtorFunc->Get(context, prototypeName).ToLocalChecked()); auto superGetter = - FunctionTemplate::New(isolate, SuperAccessorGetterCallback, implementationObject) + NewNonConstructor(isolate, SuperAccessorGetterCallback, implementationObject) ->GetFunction(context).ToLocalChecked(); implementationObject->SetAccessorProperty(V8StringConstants::GetSuper(isolate), superGetter); @@ -2499,7 +2508,7 @@ void MetadataNode::RegisterSymbolHasInstanceCallback(Isolate* isolate, MetadataE } auto extData = External::New(isolate, clazz, v8::kExternalPointerTypeTagDefault); - auto hasInstanceTemplate = FunctionTemplate::New(isolate, MetadataNode::SymbolHasInstanceCallback, extData); + auto hasInstanceTemplate = NewNonConstructor(isolate, MetadataNode::SymbolHasInstanceCallback, extData); auto context = isolate->GetCurrentContext(); auto hasInstanceFunc = hasInstanceTemplate->GetFunction(context).ToLocalChecked(); PropertyDescriptor descriptor(hasInstanceFunc, false); From 8da700054f4195b434027835c08635f0db1dac59 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Tue, 10 Nov 2026 12:31:54 -0300 Subject: [PATCH 28/42] perf: keep each callback method name's V8 string instead of rebuilding it per call Every Java->JS call copied the method name out of its String with two JNI calls and internalized it again. Java now gives each name a process-wide id (a lock-free table keyed on String's cached hash), and each runtime keeps the V8 string for an id, built the first time it is used. callback.noArgs: 1,896 -> 1,599 ns; every callback row drops by ~270-300 ns. --- test-app/runtime/src/main/cpp/Runtime.cpp | 29 ++++++- test-app/runtime/src/main/cpp/Runtime.h | 2 +- .../runtime/src/main/cpp/com_tns_Runtime.cpp | 4 +- .../src/main/java/com/tns/MethodNameIds.java | 87 +++++++++++++++++++ .../src/main/java/com/tns/Runtime.java | 6 +- .../test/java/com/tns/MethodNameIdsTest.java | 77 ++++++++++++++++ 6 files changed, 197 insertions(+), 8 deletions(-) create mode 100644 test-app/runtime/src/main/java/com/tns/MethodNameIds.java create mode 100644 test-app/runtime/src/test/java/com/tns/MethodNameIdsTest.java diff --git a/test-app/runtime/src/main/cpp/Runtime.cpp b/test-app/runtime/src/main/cpp/Runtime.cpp index 50209f01a..281132e85 100644 --- a/test-app/runtime/src/main/cpp/Runtime.cpp +++ b/test-app/runtime/src/main/cpp/Runtime.cpp @@ -597,9 +597,34 @@ static bool TryGetPrimitiveReturnBits(Isolate* isolate, } } +namespace { +// Indexed by the id Java's MethodNameIds gives each method name. +struct CallbackMethodNames { + std::vector> byId; +}; + +Local GetCallbackMethodName(Isolate* isolate, JNIEnv* env, + jstring methodName, jint methodNameId) { + auto* names = RuntimeState::For(isolate); + if (names == nullptr || methodNameId < 0) { + return ArgConverter::jstringToInternalizedV8String(isolate, env, methodName); + } + auto index = static_cast(methodNameId); + if (index < names->byId.size() && !names->byId[index].IsEmpty()) { + return names->byId[index].Get(isolate); + } + auto name = ArgConverter::jstringToInternalizedV8String(isolate, env, methodName); + if (index >= names->byId.size()) { + names->byId.resize(index + 1); + } + names->byId[index].Reset(isolate, name); + return name; +} +} // namespace + jobject Runtime::CallJSMethodNative(JNIEnv* _env, jobject obj, jobject javaObject, jint javaObjectID, - jstring methodName, + jstring methodName, jint methodNameId, jint retType, jboolean isConstructor, const JavaCallArgs& args, jlongArray primitiveResult) { @@ -648,7 +673,7 @@ jobject Runtime::CallJSMethodNative(JNIEnv* _env, jobject obj, jsObject->GetIdentityHash()); auto jsMethodName = - ArgConverter::jstringToInternalizedV8String(m_isolate, env, methodName); + GetCallbackMethodName(m_isolate, _env, methodName, methodNameId); auto jsResult = CallbackHandlers::CallJSMethod(m_isolate, env, jsObject, jsMethodName, args); diff --git a/test-app/runtime/src/main/cpp/Runtime.h b/test-app/runtime/src/main/cpp/Runtime.h index 0adbc9364..bc65fd99e 100644 --- a/test-app/runtime/src/main/cpp/Runtime.h +++ b/test-app/runtime/src/main/cpp/Runtime.h @@ -177,7 +177,7 @@ class Runtime { void RunModule(const char *moduleName); void RunWorker(const std::string& filePath); jobject RunScript(JNIEnv* _env, jobject obj, jstring scriptFile); - jobject CallJSMethodNative(JNIEnv* _env, jobject obj, jobject javaObject, jint javaObjectID, jstring methodName, jint retType, jboolean isConstructor, const JavaCallArgs& args, jlongArray primitiveResult); + jobject CallJSMethodNative(JNIEnv* _env, jobject obj, jobject javaObject, jint javaObjectID, jstring methodName, jint methodNameId, jint retType, jboolean isConstructor, const JavaCallArgs& args, jlongArray primitiveResult); void CreateJSInstanceNative(JNIEnv* _env, jobject obj, jobject javaObject, jint javaObjectID, jstring className); jint GenerateNewObjectId(JNIEnv* env, jobject obj); void AdjustAmountOfExternalAllocatedMemory(int64_t usedJavaMemory); diff --git a/test-app/runtime/src/main/cpp/com_tns_Runtime.cpp b/test-app/runtime/src/main/cpp/com_tns_Runtime.cpp index 5bbd22291..c2c09ff3d 100644 --- a/test-app/runtime/src/main/cpp/com_tns_Runtime.cpp +++ b/test-app/runtime/src/main/cpp/com_tns_Runtime.cpp @@ -194,7 +194,7 @@ extern "C" JNIEXPORT jobject Java_com_tns_Runtime_runScript(JNIEnv* _env, jobjec return result; } -extern "C" JNIEXPORT jobject Java_com_tns_Runtime_callJSMethodNative(JNIEnv* _env, jobject obj, jint runtimeId, jobject javaObject, jint javaObjectID, jstring methodName, jint retType, jboolean isConstructor, jint argCount, jlongArray argValues, jobjectArray argRefs, jlongArray primitiveResult) { +extern "C" JNIEXPORT jobject Java_com_tns_Runtime_callJSMethodNative(JNIEnv* _env, jobject obj, jint runtimeId, jobject javaObject, jint javaObjectID, jstring methodName, jint methodNameId, jint retType, jboolean isConstructor, jint argCount, jlongArray argValues, jobjectArray argRefs, jlongArray primitiveResult) { JEnv::EntryScope envScope(_env); jobject result = nullptr; @@ -213,7 +213,7 @@ extern "C" JNIEXPORT jobject Java_com_tns_Runtime_callJSMethodNative(JNIEnv* _en JavaCallArgs args{argCount, argValues, argRefs}; try { - result = runtime->CallJSMethodNative(_env, obj, javaObject, javaObjectID, methodName, retType, isConstructor, args, primitiveResult); + result = runtime->CallJSMethodNative(_env, obj, javaObject, javaObjectID, methodName, methodNameId, retType, isConstructor, args, primitiveResult); } catch (NativeScriptException& e) { e.ReThrowToJava(); } catch (std::exception e) { diff --git a/test-app/runtime/src/main/java/com/tns/MethodNameIds.java b/test-app/runtime/src/main/java/com/tns/MethodNameIds.java new file mode 100644 index 000000000..87651091f --- /dev/null +++ b/test-app/runtime/src/main/java/com/tns/MethodNameIds.java @@ -0,0 +1,87 @@ +package com.tns; + +/** + * Process-wide dense ids for the method names Java calls into JS with, so native code can + * keep each name's V8 string instead of copying the characters out of the String on every + * call. An id names the same string in every runtime. + * + * Lookups take no lock. The table is open-addressed on String.hashCode(), which String + * caches, and entries are immutable, so a reader racing an insert either sees a complete + * entry or misses and registers under the lock. + */ +final class MethodNameIds { + private static final class Entry { + final String name; + final int id; + + Entry(String name, int id) { + this.name = name; + this.id = id; + } + } + + private static volatile Entry[] table = new Entry[256]; + private static int count; + + private MethodNameIds() { + } + + static int idOf(String name) { + if (name == null) { + return -1; + } + Entry[] t = table; + int mask = t.length - 1; + for (int i = slot(name, mask); ; i = (i + 1) & mask) { + Entry e = t[i]; + if (e == null) { + return register(name); + } + if (e.name == name || e.name.equals(name)) { + return e.id; + } + } + } + + private static synchronized int register(String name) { + Entry[] t = table; + int mask = t.length - 1; + int i = slot(name, mask); + for (Entry e; (e = t[i]) != null; i = (i + 1) & mask) { + if (e.name.equals(name)) { + return e.id; + } + } + int id = count++; + if (count * 2 > t.length) { + t = grow(t); + mask = t.length - 1; + for (i = slot(name, mask); t[i] != null; i = (i + 1) & mask) { + } + } + t[i] = new Entry(name, id); + table = t; + return id; + } + + private static Entry[] grow(Entry[] old) { + Entry[] t = new Entry[old.length * 2]; + int mask = t.length - 1; + for (Entry e : old) { + if (e == null) { + continue; + } + int i = slot(e.name, mask); + while (t[i] != null) { + i = (i + 1) & mask; + } + t[i] = e; + } + return t; + } + + private static int slot(String name, int mask) { + int h = name.hashCode(); + return (h ^ (h >>> 16)) & mask; + } +} diff --git a/test-app/runtime/src/main/java/com/tns/Runtime.java b/test-app/runtime/src/main/java/com/tns/Runtime.java index 81637a389..5cb5d534a 100644 --- a/test-app/runtime/src/main/java/com/tns/Runtime.java +++ b/test-app/runtime/src/main/java/com/tns/Runtime.java @@ -42,7 +42,7 @@ private native void initNativeScript(int runtimeId, String filesPath, String nat private native Object runScript(int runtimeId, String filePath) throws NativeScriptException; - private native Object callJSMethodNative(int runtimeId, Object javaObject, int javaObjectID, String methodName, int retType, boolean isConstructor, int argCount, long[] argValues, Object[] argRefs, long[] primitiveResult) throws NativeScriptException; + private native Object callJSMethodNative(int runtimeId, Object javaObject, int javaObjectID, String methodName, int methodNameId, int retType, boolean isConstructor, int argCount, long[] argValues, Object[] argRefs, long[] primitiveResult) throws NativeScriptException; private native void createJSInstanceNative(int runtimeId, Object javaObject, int javaObjectID, String canonicalName); @@ -1430,7 +1430,7 @@ private Object dispatchCallJSMethodNative(final Object javaObject, final String try { // A hint: other threads may be writing the table, so native re-checks it under the // isolate lock and resolves the id itself on a miss. - ret = callJSMethodNative(getRuntimeId(), javaObject, objectTable.findIdOptimistic(javaObject), methodName, returnType, isConstructor, packagedArgs.count, packagedArgs.values, packagedArgs.refs, primitiveResult); + ret = callJSMethodNative(getRuntimeId(), javaObject, objectTable.findIdOptimistic(javaObject), methodName, MethodNameIds.idOf(methodName), returnType, isConstructor, packagedArgs.count, packagedArgs.values, packagedArgs.refs, primitiveResult); ret = takePrimitiveResult(ret, returnType, primitiveResult); } catch (NativeScriptException e) { // With multithreaded JS the owner can be torn down between the @@ -1471,7 +1471,7 @@ public void run() { } final PackagedArgs packagedArgs = packageArgs(tmpArgs); final long[] primitiveResult = newPrimitiveResult(returnType); - arr[0] = takePrimitiveResult(callJSMethodNative(getRuntimeId(), javaObject, javaObjectID, methodName, returnType, isCtor, packagedArgs.count, packagedArgs.values, packagedArgs.refs, primitiveResult), returnType, primitiveResult); + arr[0] = takePrimitiveResult(callJSMethodNative(getRuntimeId(), javaObject, javaObjectID, methodName, MethodNameIds.idOf(methodName), returnType, isCtor, packagedArgs.count, packagedArgs.values, packagedArgs.refs, primitiveResult), returnType, primitiveResult); } catch (NativeScriptException e) { if (discardUncaughtJsExceptions && !e.isEscapedFromJs()) { String errorMessage = "Error on \"" + Thread.currentThread().getName() + "\" thread for callJSMethodNative\n"; diff --git a/test-app/runtime/src/test/java/com/tns/MethodNameIdsTest.java b/test-app/runtime/src/test/java/com/tns/MethodNameIdsTest.java new file mode 100644 index 000000000..ef832fa23 --- /dev/null +++ b/test-app/runtime/src/test/java/com/tns/MethodNameIdsTest.java @@ -0,0 +1,77 @@ +package com.tns; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; + +public class MethodNameIdsTest { + @Test + public void givesEqualNamesOneIdAndDistinctNamesDistinctIds() { + int a = MethodNameIds.idOf("testName_a"); + int b = MethodNameIds.idOf("testName_b"); + + assertNotEquals(a, b); + assertEquals(a, MethodNameIds.idOf("testName_a")); + assertEquals(a, MethodNameIds.idOf(new String("testName_a"))); + assertEquals(-1, MethodNameIds.idOf(null)); + } + + @Test + public void keepsIdsAcrossGrowth() { + List ids = new ArrayList<>(); + for (int i = 0; i < 2000; i++) { + ids.add(MethodNameIds.idOf("testGrowth_" + i)); + } + Set distinct = new HashSet<>(ids); + assertEquals(2000, distinct.size()); + for (int i = 0; i < 2000; i++) { + assertEquals((int) ids.get(i), MethodNameIds.idOf("testGrowth_" + i)); + } + } + + @Test + public void agreesAcrossThreadsRegisteringTheSameNames() throws Exception { + final int threads = 8; + final int names = 500; + final ConcurrentHashMap seen = new ConcurrentHashMap<>(); + final List failures = new ArrayList<>(); + final CountDownLatch start = new CountDownLatch(1); + List workers = new ArrayList<>(); + for (int t = 0; t < threads; t++) { + Thread worker = new Thread(() -> { + try { + start.await(); + for (int i = 0; i < names; i++) { + String name = "testRace_" + i; + int id = MethodNameIds.idOf(name); + Integer previous = seen.putIfAbsent(name, id); + if (previous != null && previous != id) { + throw new AssertionError(name + " got " + id + " and " + previous); + } + } + } catch (Throwable e) { + synchronized (failures) { + failures.add(e); + } + } + }); + workers.add(worker); + worker.start(); + } + start.countDown(); + for (Thread worker : workers) { + worker.join(); + } + assertTrue(failures.toString(), failures.isEmpty()); + assertEquals(names, new HashSet<>(seen.values()).size()); + } +} From 32c17d1ef6a23fcd2870a31ac030a1dc5c8c8dd4 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Tue, 10 Nov 2026 12:53:56 -0300 Subject: [PATCH 29/42] perf: let a binding carry its JS object id, and look runtimes up by index A Java->JS callback found its target by probing ObjectTable for the instance's JS object id and boxing its runtime tag into a ConcurrentHashMap lookup, on every call. Both generators now give bindings an objectId__ns field next to runtimeTag__ns; initInstance writes it before the tag, and callJSMethod passes it to native as the id hint (native still checks it). Runtimes are also indexed by id in an array, and the two AppConfig flags the call path reads are cached in final fields. --- .../writing/impl/FieldsWriterImpl.java | 3 + .../writing/impl/MethodsWriterImpl.java | 6 ++ .../com/tns/NativeScriptRuntimeBound.java | 7 ++ .../test/GeneratorTest.java | 9 ++- .../com/tns/NativeScriptRuntimeBound.java | 12 ++++ .../src/main/java/com/tns/bindings/Dump.java | 25 ++++--- .../src/main/java/com/tns/Runtime.java | 67 ++++++++++++++++--- 7 files changed, 106 insertions(+), 23 deletions(-) diff --git a/test-app/build-tools/static-binding-generator/src/main/java/org/nativescript/staticbindinggenerator/generating/writing/impl/FieldsWriterImpl.java b/test-app/build-tools/static-binding-generator/src/main/java/org/nativescript/staticbindinggenerator/generating/writing/impl/FieldsWriterImpl.java index 379a57b1b..af7928025 100644 --- a/test-app/build-tools/static-binding-generator/src/main/java/org/nativescript/staticbindinggenerator/generating/writing/impl/FieldsWriterImpl.java +++ b/test-app/build-tools/static-binding-generator/src/main/java/org/nativescript/staticbindinggenerator/generating/writing/impl/FieldsWriterImpl.java @@ -8,6 +8,7 @@ public class FieldsWriterImpl implements FieldsWriter { private static final String THIZ_KEYWORD = "thiz"; private static final String RUNTIME_TAG_FIELD_DECLARATION = "private volatile int runtimeTag__ns;"; + private static final String OBJECT_ID_FIELD_DECLARATION = "private int objectId__ns;"; private final Writer writer; @@ -19,6 +20,8 @@ public FieldsWriterImpl(final Writer writer) { public void writeRuntimeTagField() { writer.write(TABULATION_LITERAL); writer.writeln(RUNTIME_TAG_FIELD_DECLARATION); + writer.write(TABULATION_LITERAL); + writer.writeln(OBJECT_ID_FIELD_DECLARATION); writer.writeln(); } diff --git a/test-app/build-tools/static-binding-generator/src/main/java/org/nativescript/staticbindinggenerator/generating/writing/impl/MethodsWriterImpl.java b/test-app/build-tools/static-binding-generator/src/main/java/org/nativescript/staticbindinggenerator/generating/writing/impl/MethodsWriterImpl.java index 5190f9c6b..1cc504cd3 100644 --- a/test-app/build-tools/static-binding-generator/src/main/java/org/nativescript/staticbindinggenerator/generating/writing/impl/MethodsWriterImpl.java +++ b/test-app/build-tools/static-binding-generator/src/main/java/org/nativescript/staticbindinggenerator/generating/writing/impl/MethodsWriterImpl.java @@ -51,6 +51,10 @@ public class MethodsWriterImpl implements MethodsWriter { private static final String INTERNAL_RUNTIME_GET_RUNTIME_TAG_RETURN_STATEMENT = "return runtimeTag__ns;"; private static final String INTERNAL_RUNTIME_SET_RUNTIME_TAG_METHOD_SIGNATURE = "public void setRuntimeTag__ns(int tag)"; private static final String INTERNAL_RUNTIME_SET_RUNTIME_TAG_ASSIGNMENT = "runtimeTag__ns = tag;"; + private static final String INTERNAL_RUNTIME_GET_OBJECT_ID_METHOD_SIGNATURE = "public int getObjectId__ns()"; + private static final String INTERNAL_RUNTIME_GET_OBJECT_ID_RETURN_STATEMENT = "return objectId__ns;"; + private static final String INTERNAL_RUNTIME_SET_OBJECT_ID_METHOD_SIGNATURE = "public void setObjectId__ns(int id)"; + private static final String INTERNAL_RUNTIME_SET_OBJECT_ID_ASSIGNMENT = "objectId__ns = id;"; private static final String INTERNAL_SERVICES_ONCREATE_METHOD_SIGNATURE = "public void " + ON_CREATE_METHOD_NAME + "()"; private static final String RETURN_KEYWORD = "return"; @@ -406,6 +410,8 @@ public void writeInternalRuntimeHashCodeMethod() { public void writeInternalRuntimeTagAccessorMethods() { writeInternalRuntimeMethod(INTERNAL_RUNTIME_GET_RUNTIME_TAG_METHOD_SIGNATURE, INTERNAL_RUNTIME_GET_RUNTIME_TAG_RETURN_STATEMENT); writeInternalRuntimeMethod(INTERNAL_RUNTIME_SET_RUNTIME_TAG_METHOD_SIGNATURE, INTERNAL_RUNTIME_SET_RUNTIME_TAG_ASSIGNMENT); + writeInternalRuntimeMethod(INTERNAL_RUNTIME_GET_OBJECT_ID_METHOD_SIGNATURE, INTERNAL_RUNTIME_GET_OBJECT_ID_RETURN_STATEMENT); + writeInternalRuntimeMethod(INTERNAL_RUNTIME_SET_OBJECT_ID_METHOD_SIGNATURE, INTERNAL_RUNTIME_SET_OBJECT_ID_ASSIGNMENT); } private void writeInternalRuntimeMethod(String signature, String returnStatement) { diff --git a/test-app/build-tools/static-binding-generator/src/test/java/com/tns/NativeScriptRuntimeBound.java b/test-app/build-tools/static-binding-generator/src/test/java/com/tns/NativeScriptRuntimeBound.java index 0f1c9d6d2..adb75c29d 100644 --- a/test-app/build-tools/static-binding-generator/src/test/java/com/tns/NativeScriptRuntimeBound.java +++ b/test-app/build-tools/static-binding-generator/src/test/java/com/tns/NativeScriptRuntimeBound.java @@ -4,4 +4,11 @@ public interface NativeScriptRuntimeBound { int getRuntimeTag__ns(); void setRuntimeTag__ns(int tag); + + default int getObjectId__ns() { + return -1; + } + + default void setObjectId__ns(int id) { + } } diff --git a/test-app/build-tools/static-binding-generator/src/test/java/org/nativescript/staticbindinggenerator/test/GeneratorTest.java b/test-app/build-tools/static-binding-generator/src/test/java/org/nativescript/staticbindinggenerator/test/GeneratorTest.java index 4cf5b1e61..4c4cadc33 100644 --- a/test-app/build-tools/static-binding-generator/src/test/java/org/nativescript/staticbindinggenerator/test/GeneratorTest.java +++ b/test-app/build-tools/static-binding-generator/src/test/java/org/nativescript/staticbindinggenerator/test/GeneratorTest.java @@ -53,7 +53,7 @@ public void testCanCompileBinding() throws Exception { Class helloClass = InMemoryJavaCompiler.compile(binding.getClassname(), sourceCode.toString(), options); Assert.assertNotNull(helloClass); - Assert.assertEquals(5, helloClass.getDeclaredMethods().length); + Assert.assertEquals(7, helloClass.getDeclaredMethods().length); } @Test @@ -101,6 +101,9 @@ public void testBindingCarriesARuntimeTag() throws Exception { // read on whichever thread a Java->JS call arrives on Assert.assertTrue(java.lang.reflect.Modifier.isVolatile(boundClass.getDeclaredField("runtimeTag__ns").getModifiers())); + + instance.setObjectId__ns(42); + Assert.assertEquals(42, instance.getObjectId__ns()); } @Test @@ -126,7 +129,7 @@ public void testCanCompileBindingOfInterfaceWithStaticInitializer() throws Excep Class helloClass = InMemoryJavaCompiler.compile("com.tns.gen.com.example.MyInterface", sourceCode.toString(), options); Assert.assertNotNull(helloClass); - Assert.assertEquals(5, helloClass.getDeclaredMethods().length); // 5 methods (includes 'hashCode__super', 'equals__super', 'getRuntimeTag__ns' and 'setRuntimeTag__ns') + Assert.assertEquals(7, helloClass.getDeclaredMethods().length); // 7 methods (includes 'hashCode__super', 'equals__super' and the runtime tag and object id accessors) } @Test @@ -174,7 +177,7 @@ public void testCanCompileBindingClassExtendingAnExtendedClassWithMethodsWithThe Class ComplexClass = InMemoryJavaCompiler.compile(binding.getClassname(), sourceCode.toString(), options); Assert.assertNotNull(ComplexClass); - Assert.assertEquals(6, ComplexClass.getDeclaredMethods().length); // 1 + constructor + (equals + hashcode) + (getRuntimeTag__ns + setRuntimeTag__ns) + Assert.assertEquals(8, ComplexClass.getDeclaredMethods().length); // 1 + constructor + (equals + hashcode) + (runtime tag and object id accessors) } @Test diff --git a/test-app/runtime-binding-generator/src/main/java/com/tns/NativeScriptRuntimeBound.java b/test-app/runtime-binding-generator/src/main/java/com/tns/NativeScriptRuntimeBound.java index c6d5ce412..437777637 100644 --- a/test-app/runtime-binding-generator/src/main/java/com/tns/NativeScriptRuntimeBound.java +++ b/test-app/runtime-binding-generator/src/main/java/com/tns/NativeScriptRuntimeBound.java @@ -16,4 +16,16 @@ public interface NativeScriptRuntimeBound { int getRuntimeTag__ns(); void setRuntimeTag__ns(int tag); + + /** + * The id the owning runtime registered the instance under, written before the tag and + * read only once the tag is set. Lets a Java->JS call skip looking the id up; native + * still checks it. The defaults keep a class compiled without the field working. + */ + default int getObjectId__ns() { + return -1; + } + + default void setObjectId__ns(int id) { + } } diff --git a/test-app/runtime-binding-generator/src/main/java/com/tns/bindings/Dump.java b/test-app/runtime-binding-generator/src/main/java/com/tns/bindings/Dump.java index fbd7f0ed5..d2c253cdf 100644 --- a/test-app/runtime-binding-generator/src/main/java/com/tns/bindings/Dump.java +++ b/test-app/runtime-binding-generator/src/main/java/com/tns/bindings/Dump.java @@ -24,6 +24,7 @@ public class Dump { static final String callJSMethodName = "callJSMethod"; static final String initInstanceMethodName = "initInstance"; static final String RUNTIME_TAG_FIELD_NAME = "runtimeTag__ns"; + static final String OBJECT_ID_FIELD_NAME = "objectId__ns"; static final StringBuffer methodDescriptorBuilder = new StringBuffer(); @@ -494,26 +495,28 @@ private void generateMethods(ClassVisitor cv, ClassDescriptor classTo, MethodDes generateEqualsSuper(cv); generateHashCodeSuper(cv); - generateGetRuntimeTag(cv, tnsClassSignature); - generateSetRuntimeTag(cv, tnsClassSignature); + generateIntGetter(cv, tnsClassSignature, "getRuntimeTag__ns", RUNTIME_TAG_FIELD_NAME); + generateIntSetter(cv, tnsClassSignature, "setRuntimeTag__ns", RUNTIME_TAG_FIELD_NAME); + generateIntGetter(cv, tnsClassSignature, "getObjectId__ns", OBJECT_ID_FIELD_NAME); + generateIntSetter(cv, tnsClassSignature, "setObjectId__ns", OBJECT_ID_FIELD_NAME); } // 2 registers, 1 parameter: 'this' takes the last one, v0 is left as scratch - private void generateGetRuntimeTag(ClassVisitor cv, String tnsClassSignature) { - MethodVisitor mv = cv.visitMethod(org.ow2.asmdex.Opcodes.ACC_PUBLIC, "getRuntimeTag__ns", "I", null, null); + private void generateIntGetter(ClassVisitor cv, String tnsClassSignature, String methodName, String fieldName) { + MethodVisitor mv = cv.visitMethod(org.ow2.asmdex.Opcodes.ACC_PUBLIC, methodName, "I", null, null); mv.visitCode(); mv.visitMaxs(2, 0); - mv.visitFieldInsn(org.ow2.asmdex.Opcodes.INSN_IGET, tnsClassSignature, RUNTIME_TAG_FIELD_NAME, "I", 0, 1); + mv.visitFieldInsn(org.ow2.asmdex.Opcodes.INSN_IGET, tnsClassSignature, fieldName, "I", 0, 1); mv.visitIntInsn(org.ow2.asmdex.Opcodes.INSN_RETURN, 0); mv.visitEnd(); } - // 2 registers, 2 parameters: v0 is 'this' and v1 the id, leaving no scratch - private void generateSetRuntimeTag(ClassVisitor cv, String tnsClassSignature) { - MethodVisitor mv = cv.visitMethod(org.ow2.asmdex.Opcodes.ACC_PUBLIC, "setRuntimeTag__ns", "VI", null, null); + // 2 registers, 2 parameters: v0 is 'this' and v1 the value, leaving no scratch + private void generateIntSetter(ClassVisitor cv, String tnsClassSignature, String methodName, String fieldName) { + MethodVisitor mv = cv.visitMethod(org.ow2.asmdex.Opcodes.ACC_PUBLIC, methodName, "VI", null, null); mv.visitCode(); mv.visitMaxs(2, 0); - mv.visitFieldInsn(org.ow2.asmdex.Opcodes.INSN_IPUT, tnsClassSignature, RUNTIME_TAG_FIELD_NAME, "I", 1, 0); + mv.visitFieldInsn(org.ow2.asmdex.Opcodes.INSN_IPUT, tnsClassSignature, fieldName, "I", 1, 0); mv.visitInsn(org.ow2.asmdex.Opcodes.INSN_RETURN_VOID); mv.visitEnd(); } @@ -831,6 +834,10 @@ private void generateFields(ClassVisitor cv) { // left at its zero default, which NativeScriptRuntimeBound defines as "no runtime yet" fv = cv.visitField(org.ow2.asmdex.Opcodes.ACC_PRIVATE | org.ow2.asmdex.Opcodes.ACC_VOLATILE, RUNTIME_TAG_FIELD_NAME, "I", null, null); fv.visitEnd(); + + // only read once the tag is set, which is written after it + fv = cv.visitField(org.ow2.asmdex.Opcodes.ACC_PRIVATE, OBJECT_ID_FIELD_NAME, "I", null, null); + fv.visitEnd(); } static final String[] classImplentedInterfaces = new String[] { "Lcom/tns/NativeScriptHashCodeProvider;", "Lcom/tns/NativeScriptRuntimeBound;" }; diff --git a/test-app/runtime/src/main/java/com/tns/Runtime.java b/test-app/runtime/src/main/java/com/tns/Runtime.java index 5cb5d534a..b29c528ec 100644 --- a/test-app/runtime/src/main/java/com/tns/Runtime.java +++ b/test-app/runtime/src/main/java/com/tns/Runtime.java @@ -239,6 +239,8 @@ public int compare(Method lhs, Method rhs) { }; private final StaticConfiguration config; + private final boolean discardUncaughtJsExceptions; + private final boolean multithreadedJavascript; private static StaticConfiguration staticConfiguration; private final DynamicConfiguration dynamicConfig; @@ -260,6 +262,28 @@ public int compare(Method lhs, Method rhs) { */ private static volatile Runtime mainRuntime; private final static Map runtimeCache = new ConcurrentHashMap<>(); + // runtimeCache by id for the Java->JS call path, which would otherwise box the id and + // probe the map on every call; ids past its length fall back to the map. + private static volatile Runtime[] runtimesById = new Runtime[8]; + private static final int MAX_INDEXED_RUNTIME_ID = 1024; + + private static synchronized void indexRuntime(int runtimeId, Runtime runtime) { + if (runtimeId < 0 || runtimeId >= MAX_INDEXED_RUNTIME_ID) { + return; + } + Runtime[] index = runtimesById; + index = Arrays.copyOf(index, Math.max(index.length, Integer.highestOneBit(runtimeId) * 2)); + index[runtimeId] = runtime; + runtimesById = index; + } + + private static Runtime runtimeForId(int runtimeId) { + Runtime[] index = runtimesById; + if (runtimeId >= 0 && runtimeId < index.length) { + return index[runtimeId]; + } + return runtimeCache.get(runtimeId); + } public static boolean nativeLibraryLoaded; private static final ClassStorageService classStorageService = new ClassStorageServiceImpl(ClassCacheImpl.INSTANCE, ClassLoadersCollectionImpl.INSTANCE); @@ -296,6 +320,8 @@ public Runtime(ClassResolver classResolver, GcListener gcListener, StaticConfigu this.classResolver = classResolver; this.gcListener = gcListener; this.config = config; + this.discardUncaughtJsExceptions = config != null && config.appConfig != null && config.appConfig.getDiscardUncaughtJsExceptions(); + this.multithreadedJavascript = config != null && config.appConfig != null && config.appConfig.getEnableMultithreadedJavascript(); this.dynamicConfig = dynamicConfig; this.runtimeId = runtimeId; this.workerId = workerId; @@ -314,6 +340,8 @@ public Runtime(StaticConfiguration config, DynamicConfiguration dynamicConfigura this.runtimeId = nextRuntimeId.getAndIncrement(); this.config = config; + this.discardUncaughtJsExceptions = config.appConfig.getDiscardUncaughtJsExceptions(); + this.multithreadedJavascript = config.appConfig.getEnableMultithreadedJavascript(); this.dynamicConfig = dynamicConfiguration; this.threadScheduler = dynamicConfiguration.myThreadScheduler; this.workerId = dynamicConfiguration.workerId; @@ -334,6 +362,7 @@ public Runtime(StaticConfiguration config, DynamicConfiguration dynamicConfigura // the thread-local or the cache currentRuntime.set(this); runtimeCache.put(this.runtimeId, this); + indexRuntime(this.runtimeId, this); } finally { frame.close(); } @@ -479,10 +508,12 @@ public static String[] getRemoteModuleAllowlist() { * The tag is the runtime id plus one, so that the field's zero default means * "no owner yet": runtime id 0 is the main runtime's. */ - private static void recordOwningRuntime(Object instance, int runtimeId) { + private static void recordOwningRuntime(Object instance, int runtimeId, int objectId) { if (instance instanceof NativeScriptRuntimeBound) { NativeScriptRuntimeBound bound = (NativeScriptRuntimeBound) instance; if (bound.getRuntimeTag__ns() == 0) { + // Before the tag: readers take the id only once they see a tag. + bound.setObjectId__ns(objectId); bound.setRuntimeTag__ns(runtimeId + 1); } } @@ -654,6 +685,7 @@ public static void runWorkerLoop() { @RuntimeCallable public static void detachWorkerRuntime(int runtimeId) { Runtime runtime = runtimeCache.remove(runtimeId); + indexRuntime(runtimeId, null); if (runtime != null) { GcListener.unsubscribe(runtime); @@ -678,6 +710,7 @@ private static Runtime initRuntime(DynamicConfiguration dynamicConfiguration) { // failed bootstrap doesn't leave a stale, half-initialized runtime // reachable through the caches runtimeCache.remove(runtime.getRuntimeId()); + indexRuntime(runtime.getRuntimeId(), null); currentRuntime.remove(); GcListener.unsubscribe(runtime); throw t; @@ -900,16 +933,16 @@ public static void initInstance(Object instance) { if (objectId != -1) { runtime.makeInstanceStrong(instance, objectId); } else { - runtime.createJSInstance(instance); + objectId = runtime.createJSInstance(instance); } - recordOwningRuntime(instance, runtime.getRuntimeId()); + recordOwningRuntime(instance, runtime.getRuntimeId(), objectId); } finally { frame.close(); } } - private void createJSInstance(Object instance) { + private int createJSInstance(Object instance) { // Registered by createJSInstanceNative, which holds the isolate lock. int javaObjectID = generateNewObjectId(getRuntimeId()); @@ -933,6 +966,8 @@ private void createJSInstance(Object instance) { if (logger.isEnabled()) { logger.write("JSInstance for " + instance.getClass().toString() + " created with overrides"); } + + return javaObjectID; } @RuntimeCallable @@ -1194,11 +1229,20 @@ public static Object callJSMethodWithDelay(Object javaObject, String methodName, } public static Object callJSMethod(Object javaObject, String methodName, Class retType, boolean isConstructor, long delay, Object... args) throws NativeScriptException { - int owningRuntimeId = getOwningRuntimeId(javaObject); + int owningRuntimeId = NO_OWNING_RUNTIME; + int objectIdHint = -1; + if (javaObject instanceof NativeScriptRuntimeBound) { + NativeScriptRuntimeBound bound = (NativeScriptRuntimeBound) javaObject; + int tag = bound.getRuntimeTag__ns(); + if (tag != 0) { + owningRuntimeId = tag - 1; + objectIdHint = bound.getObjectId__ns(); + } + } Runtime runtime; if (owningRuntimeId != NO_OWNING_RUNTIME) { - runtime = runtimeCache.get(owningRuntimeId); + runtime = runtimeForId(owningRuntimeId); if (runtime == null) { return discardCall(methodName, retType, describeInstance(javaObject) @@ -1216,7 +1260,7 @@ public static Object callJSMethod(Object javaObject, String methodName, Class } } - return runtime.dispatchCallJSMethodNative(javaObject, methodName, isConstructor, delay, retType, args); + return runtime.dispatchCallJSMethodNative(javaObject, objectIdHint, methodName, isConstructor, delay, retType, args); } /* @@ -1414,15 +1458,15 @@ private int resolveJavaObjectID(Object javaObject, String methodName) throws Nat return javaObjectID; } - private Object dispatchCallJSMethodNative(final Object javaObject, final String methodName, boolean isConstructor, long delay, Class retType, final Object[] args) throws NativeScriptException { + private Object dispatchCallJSMethodNative(final Object javaObject, int objectIdHint, final String methodName, boolean isConstructor, long delay, Class retType, final Object[] args) throws NativeScriptException { final int returnType = TypeIDs.GetObjectTypeId(retType); Object ret = null; boolean isWorkThread = threadScheduler.getThread().equals(Thread.currentThread()); final Object[] tmpArgs = extendConstructorArgs(methodName, isConstructor, args); - final boolean discardUncaughtJsExceptions = this.config.appConfig.getDiscardUncaughtJsExceptions(); - boolean enableMultithreadedJavascript = this.config.appConfig.getEnableMultithreadedJavascript(); + final boolean discardUncaughtJsExceptions = this.discardUncaughtJsExceptions; + boolean enableMultithreadedJavascript = this.multithreadedJavascript; if (enableMultithreadedJavascript || isWorkThread) { PackagedArgs packagedArgs = packageArgs(tmpArgs); @@ -1430,7 +1474,8 @@ private Object dispatchCallJSMethodNative(final Object javaObject, final String try { // A hint: other threads may be writing the table, so native re-checks it under the // isolate lock and resolves the id itself on a miss. - ret = callJSMethodNative(getRuntimeId(), javaObject, objectTable.findIdOptimistic(javaObject), methodName, MethodNameIds.idOf(methodName), returnType, isConstructor, packagedArgs.count, packagedArgs.values, packagedArgs.refs, primitiveResult); + ret = callJSMethodNative(getRuntimeId(), javaObject, + objectIdHint >= 0 ? objectIdHint : objectTable.findIdOptimistic(javaObject), methodName, MethodNameIds.idOf(methodName), returnType, isConstructor, packagedArgs.count, packagedArgs.values, packagedArgs.refs, primitiveResult); ret = takePrimitiveResult(ret, returnType, primitiveResult); } catch (NativeScriptException e) { // With multithreaded JS the owner can be torn down between the From 1de228ff4324c9426e7f6b1a1752f847025cbe0f Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Tue, 10 Nov 2026 14:41:53 -0300 Subject: [PATCH 30/42] perf: keep each identity-index entry's hash so a lookup miss stays in one array ObjectTable's identity index held only an id, so checking each probe candidate meant looking the id up in the id table and dereferencing its WeakReference. Registering a new object (a miss, then the insert) paid that for every candidate on its probe path, twice. The index now keeps each entry's identity hash next to it, and a probe only looks an id up when the hashes match; findOrRegister also computes the hash once. --- .../src/main/java/com/tns/ObjectTable.java | 56 +++++++++++++------ 1 file changed, 40 insertions(+), 16 deletions(-) diff --git a/test-app/runtime/src/main/java/com/tns/ObjectTable.java b/test-app/runtime/src/main/java/com/tns/ObjectTable.java index 73fb75839..b4762aa82 100644 --- a/test-app/runtime/src/main/java/com/tns/ObjectTable.java +++ b/test-app/runtime/src/main/java/com/tns/ObjectTable.java @@ -92,14 +92,16 @@ int findIdOptimistic(Object obj) { private int findIdUnlocked(Object obj) { int[] index = identityIndex; + int[] hashes = identityIndexHashes; int mask = index.length - 1; - int i = mix(System.identityHashCode(obj)) & mask; + int hash = System.identityHashCode(obj); + int i = mix(hash) & mask; for (int probes = 0; probes <= mask; probes++) { int entry = index[i]; if (entry == 0) { return NO_ID; } - if (getUnlocked(entry - 1) == obj) { + if (hashes[i] == hash && getUnlocked(entry - 1) == obj) { return entry - 1; } i = (i + 1) & mask; @@ -136,7 +138,10 @@ private Object getUnlocked(int id) { private int idCount = 0; // identity hash -> id + 1, open addressing; values[] decides which object an id holds. + // identityIndexHashes[i] is the hash of identityIndex[i]'s object, so a probe only looks + // the id up when the hashes match. private int[] identityIndex = new int[1024]; + private int[] identityIndexHashes = new int[1024]; private int identityCount = 0; /** Registers obj under id, held strongly, replacing whatever id held before. */ @@ -154,6 +159,10 @@ void putStrong(int id, Object obj) { } private void putStrongLocked(int id, Object obj) { + putStrongLocked(id, obj, System.identityHashCode(obj)); + } + + private void putStrongLocked(int id, Object obj, int hash) { int existing = findIdSlot(id); if (existing >= 0) { Object value = values[existing]; @@ -164,7 +173,6 @@ private void putStrongLocked(int id, Object obj) { removeLocked(id); } - int hash = System.identityHashCode(obj); if ((idCount + 1) * 2 > idKeys.length) { growIds(); } @@ -191,12 +199,13 @@ boolean allowsConcurrentRegistration() { long findOrRegister(Object obj, IdSource ids) { long stamp = stamps.beginWrite(); try { - int id = findIdLocked(obj); + int hash = System.identityHashCode(obj); + int id = findIdLocked(obj, hash); if (id != NO_ID) { return id; } id = ids.newId(); - putStrongLocked(id, obj); + putStrongLocked(id, obj, hash); return id | FRESH; } finally { stamps.endWrite(stamp); @@ -228,15 +237,19 @@ int findId(Object obj) { } private int findIdLocked(Object obj) { + return findIdLocked(obj, System.identityHashCode(obj)); + } + + private int findIdLocked(Object obj, int hash) { int mask = identityIndex.length - 1; - int i = mix(System.identityHashCode(obj)) & mask; + int i = mix(hash) & mask; while (true) { int entry = identityIndex[i]; if (entry == 0) { return NO_ID; } int id = entry - 1; - if (getLocked(id) == obj) { + if (identityIndexHashes[i] == hash && getLocked(id) == obj) { return id; } i = (i + 1) & mask; @@ -432,12 +445,17 @@ private void removeIdSlot(int slot) { } private void insertIdentity(int hash, int id) { - int mask = identityIndex.length - 1; + insertIdentity(identityIndex, identityIndexHashes, hash, id); + } + + private static void insertIdentity(int[] index, int[] hashes, int hash, int id) { + int mask = index.length - 1; int i = mix(hash) & mask; - while (identityIndex[i] != 0) { + while (index[i] != 0) { i = (i + 1) & mask; } - identityIndex[i] = id + 1; + hashes[i] = hash; + index[i] = id + 1; } private void removeIdentity(int hash, int id) { @@ -454,13 +472,15 @@ private void removeIdentity(int hash, int id) { if (entry == 0) { break; } - int home = mix(identityHashes[findIdSlot(entry - 1)]) & mask; + int home = mix(identityIndexHashes[j]) & mask; if (!isBetween(hole, home, j)) { identityIndex[hole] = entry; + identityIndexHashes[hole] = identityIndexHashes[j]; hole = j; } } identityIndex[hole] = 0; + identityIndexHashes[hole] = 0; } // Whether home lies cyclically in (hole, j], i.e. the entry at j must stay put. @@ -484,13 +504,17 @@ private void growIds() { } private void growIdentity() { - int[] old = identityIndex; - identityIndex = new int[old.length * 2]; - for (int entry : old) { - if (entry != 0) { - insertIdentity(identityHashes[findIdSlot(entry - 1)], entry - 1); + int[] oldIndex = identityIndex; + int[] oldHashes = identityIndexHashes; + int[] index = new int[oldIndex.length * 2]; + int[] hashes = new int[index.length]; + for (int i = 0; i < oldIndex.length; i++) { + if (oldIndex[i] != 0) { + insertIdentity(index, hashes, oldHashes[i], oldIndex[i] - 1); } } + identityIndexHashes = hashes; + identityIndex = index; } private static int mix(int h) { From 78f344dc0cd94ad1b206b9dbdc862838e1d0eb6f Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Tue, 10 Nov 2026 14:41:54 -0300 Subject: [PATCH 31/42] perf: cache a wrapper class's MetadataNode and allocate a link's handle with its state Creating a wrapper for a new object looked its class's MetadataNode up by name (a mutex and a string hash) and made three allocations to link it. The per-class-index wrapper cache now also keeps the MetadataNode, and the v8::Persistent is a member of the weak-callback state instead of a separate allocation. --- .../runtime/src/main/cpp/ObjectManager.cpp | 34 +++++++++++-------- test-app/runtime/src/main/cpp/ObjectManager.h | 17 +++++++--- 2 files changed, 33 insertions(+), 18 deletions(-) diff --git a/test-app/runtime/src/main/cpp/ObjectManager.cpp b/test-app/runtime/src/main/cpp/ObjectManager.cpp index 18d474006..6523afab2 100644 --- a/test-app/runtime/src/main/cpp/ObjectManager.cpp +++ b/test-app/runtime/src/main/cpp/ObjectManager.cpp @@ -363,19 +363,26 @@ Local ObjectManager::CreateJSWrapper(jint javaObjectID, int classIndex, jobject instance) { jclass clazz; std::string scratch; - const auto& className = WrapperClassFor(classIndex, instance, clazz, scratch); - return CreateJSWrapperHelper(javaObjectID, className, clazz); + MetadataNode* node = nullptr; + const auto& className = WrapperClassFor(classIndex, instance, clazz, scratch, node); + return CreateJSWrapperHelper(javaObjectID, className, clazz, node); } const std::string& ObjectManager::WrapperClassFor(int classIndex, jobject instance, jclass& clazz, - std::string& scratch) { + std::string& scratch, + MetadataNode*& node) { if (classIndex > 0 && static_cast(classIndex) < m_wrapperClasses.size() && m_wrapperClasses[classIndex] != nullptr) { - clazz = m_wrapperClasses[classIndex]->clazz; - return m_wrapperClasses[classIndex]->name; + auto& entry = *m_wrapperClasses[classIndex]; + if (entry.node == nullptr) { + entry.node = MetadataNode::GetOrCreate(entry.name); + } + clazz = entry.clazz; + node = entry.node; + return entry.name; } JEnv env; @@ -402,10 +409,13 @@ const std::string& ObjectManager::WrapperClassFor(int classIndex, Local ObjectManager::CreateJSWrapperHelper(jint javaObjectID, const string& className, - jclass clazz) { + jclass clazz, + MetadataNode* node) { auto isolate = m_isolate; - auto node = MetadataNode::GetOrCreate(className); + if (node == nullptr) { + node = MetadataNode::GetOrCreate(className); + } auto jsWrapper = node->CreateJSWrapper(isolate, this); @@ -437,12 +447,11 @@ void ObjectManager::Link(const Local& object, uint32_t javaObjectID, auto jsInstanceInfo = new JSInstanceInfo(javaObjectID, clazz); - auto objectHandle = new Persistent(isolate, object); - auto state = new ObjectWeakCallbackState(this, jsInstanceInfo, objectHandle); + auto state = new ObjectWeakCallbackState(this, jsInstanceInfo, isolate, object); // subscribe for JS GC event - objectHandle->SetWeak(state, JSObjectFinalizerStatic, - WeakCallbackType::kFinalizer); + state->target->SetWeak(state, JSObjectFinalizerStatic, + WeakCallbackType::kFinalizer); auto jsInfoIdx = static_cast(MetadataNodeKeys::JsInfo); @@ -514,7 +523,6 @@ void ObjectManager::JSObjectFinalizer(Isolate* isolate, // the registration is left to drop. m_idToObject.erase(callbackState->javaObjectID); po->Reset(); - delete po; delete callbackState; return; } @@ -697,7 +705,6 @@ void ObjectManager::ReleaseCollectedWrapper(int javaObjectID) { } m_idToObject.erase(it); po->Reset(); - delete po; delete state; } @@ -734,7 +741,6 @@ void ObjectManager::ReleaseAllRegistered() { po->Reset(); } - delete po; delete state->jsInfo; delete state; } diff --git a/test-app/runtime/src/main/cpp/ObjectManager.h b/test-app/runtime/src/main/cpp/ObjectManager.h index e6cfefdb7..b8ea6dab9 100644 --- a/test-app/runtime/src/main/cpp/ObjectManager.h +++ b/test-app/runtime/src/main/cpp/ObjectManager.h @@ -17,6 +17,7 @@ namespace tns { struct JavaCallArgs; +class MetadataNode; class ObjectManager { public: @@ -151,14 +152,18 @@ class ObjectManager { struct ObjectWeakCallbackState { ObjectWeakCallbackState(ObjectManager* _thisPtr, JSInstanceInfo* _jsInfo, - v8::Persistent* _target) + v8::Isolate* isolate, v8::Local object) : thisPtr(_thisPtr), jsInfo(_jsInfo), - target(_target), + handle(isolate, object), + target(&handle), javaObjectID(_jsInfo->JavaObjectID) {} ObjectManager* thisPtr; JSInstanceInfo* jsInfo; + // Persistent, not Global: its destructor never calls into V8, so a state may be + // freed after its isolate is gone. + v8::Persistent handle; v8::Persistent* target; // Duplicated from jsInfo: the finalizer has to unregister itself even when // the JsInfo field was already cleared and jsInfo freed. @@ -181,16 +186,20 @@ class ObjectManager { // clazz must be a global ref, or null to look className up. v8::Local CreateJSWrapperHelper(jint javaObjectID, const std::string& className, - jclass clazz); + jclass clazz, + MetadataNode* node = nullptr); struct WrapperClass { std::string name; jclass clazz; // global ref + MetadataNode* node = nullptr; // process-wide, never freed }; // The JNI name and a global ref of instance's class, cached by class index. + // node is set only for a cached class. const std::string& WrapperClassFor(int classIndex, jobject instance, - jclass& clazz, std::string& scratch); + jclass& clazz, std::string& scratch, + MetadataNode*& node); // Indexed by class index. Entries are never replaced, so references to them // survive a nested wrap growing the vector. From b63cc5161d20afebeae7df3ce7c169fed21f4f6c Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Tue, 10 Nov 2026 16:36:44 -0300 Subject: [PATCH 32/42] perf: resolve a callback's receiver wrapper with one map lookup GetLinkedJsObjectByJavaObject checked the id's entry and then called GetJsObjectByJavaObject, which looked the same id up again. Both now hand the entry they found to JsObjectFor. --- test-app/runtime/src/main/cpp/ObjectManager.cpp | 11 ++++++----- test-app/runtime/src/main/cpp/ObjectManager.h | 3 +++ 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/test-app/runtime/src/main/cpp/ObjectManager.cpp b/test-app/runtime/src/main/cpp/ObjectManager.cpp index 6523afab2..b0b242e0b 100644 --- a/test-app/runtime/src/main/cpp/ObjectManager.cpp +++ b/test-app/runtime/src/main/cpp/ObjectManager.cpp @@ -287,13 +287,14 @@ Local ObjectManager::GetJsObjectByJavaObject(int javaObjectID) { return handleScope.Escape(Local()); } - ObjectWeakCallbackState* state = it->second; + return handleScope.Escape(JsObjectFor(it->second)); +} + +Local ObjectManager::JsObjectFor(ObjectWeakCallbackState* state) { if (state->queued) { state->reacquired = true; } - - auto localObject = Local::New(isolate, *state->target); - return handleScope.Escape(localObject); + return Local::New(m_isolate, *state->target); } void ObjectManager::ReleaseUnwrappedArgs(const JavaCallArgs& args) { @@ -337,7 +338,7 @@ Local ObjectManager::GetLinkedJsObjectByJavaObject(int javaObjectID) { if (it == m_idToObject.end() || it->second->jsInfo == nullptr) { return Local(); } - return GetJsObjectByJavaObject(javaObjectID); + return JsObjectFor(it->second); } Local ObjectManager::CreateJSWrapper(jint javaObjectID, diff --git a/test-app/runtime/src/main/cpp/ObjectManager.h b/test-app/runtime/src/main/cpp/ObjectManager.h index b8ea6dab9..7b4af7526 100644 --- a/test-app/runtime/src/main/cpp/ObjectManager.h +++ b/test-app/runtime/src/main/cpp/ObjectManager.h @@ -205,6 +205,9 @@ class ObjectManager { // survive a nested wrap growing the vector. std::vector> m_wrapperClasses; + // state's wrapper, marking a queued one as handed back to JS. + v8::Local JsObjectFor(ObjectWeakCallbackState* state); + static void JSObjectFinalizerStatic( const v8::WeakCallbackInfo& data); From 69efd2c175a8b198e8cec0915f5bead14b3ae747 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Tue, 10 Nov 2026 16:50:38 -0300 Subject: [PATCH 33/42] test: time a reference callback alongside every callback benchmark round Callback rows drift with the device's performance state independently of the other rows. Each round now also times a fixed twoInts callback on its own object and reports it as ref/refMin, so launches can be compared by median / ref. --- .../app/tests/benchmarks/jniBenchmarks.js | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/test-app/app/src/main/assets/app/tests/benchmarks/jniBenchmarks.js b/test-app/app/src/main/assets/app/tests/benchmarks/jniBenchmarks.js index ac5caf436..d712d4cb2 100644 --- a/test-app/app/src/main/assets/app/tests/benchmarks/jniBenchmarks.js +++ b/test-app/app/src/main/assets/app/tests/benchmarks/jniBenchmarks.js @@ -21,12 +21,16 @@ function median(values) { return sorted[Math.floor(sorted.length / 2)]; } -function report(name, nsPerOp) { +function report(name, nsPerOp, refNsPerOp) { var result = { name: name, median: Math.round(median(nsPerOp) * 10) / 10, min: Math.round(Math.min.apply(null, nsPerOp) * 10) / 10 }; + if (refNsPerOp) { + result.ref = Math.round(median(refNsPerOp) * 10) / 10; + result.refMin = Math.round(Math.min.apply(null, refNsPerOp) * 10) / 10; + } console.log("JNIBENCH " + JSON.stringify(result)); return result; } @@ -47,16 +51,30 @@ function measureJs(name, iterations, body) { return report(name, samples); } +// Callback rows drift with the device's performance state independently of the other rows, +// so each round also times a fixed reference callback (twoInts on its own object). Compare +// launches by median / ref rather than by median alone. +var REF_CALLBACK_KIND = 1; +var REF_CALLBACK_ITERATIONS = 20000; +var refCallback = null; + function measureCallback(name, cb, kind, iterations) { if (skipped(name)) return null; iterations *= SCALE; + var refIterations = REF_CALLBACK_ITERATIONS * SCALE; + if (refCallback === null) { + refCallback = new JniBench.Callback({ twoInts: function (a, b) { } }); + } + JniBench.runCallbacks(refCallback, REF_CALLBACK_KIND, refIterations >> 2); JniBench.runCallbacks(cb, kind, Math.max(1000, iterations >> 2)); var samples = []; + var ref = []; for (var r = 0; r < ROUNDS; r++) { + ref.push(Number(JniBench.runCallbacks(refCallback, REF_CALLBACK_KIND, refIterations)) / refIterations); var nanos = JniBench.runCallbacks(cb, kind, iterations); samples.push(Number(nanos) / iterations); } - return report(name, samples); + return report(name, samples, ref); } From f1f3669690ae397bcad9f574fd2690d7e9da1081 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Tue, 10 Nov 2026 16:50:38 -0300 Subject: [PATCH 34/42] perf: keep a linked object's state in one allocation, indexed by id Link allocated a JSInstanceInfo, a weak-callback state that duplicated its id, a V8 External pointing at the JSInstanceInfo, and a node in the id map. JSInstanceInfo is now part of the state, the wrapper's JsInfo field holds the object id + 1 instead of an External, and the id map is a radix tree indexed by id. Ids are never reused, so a field left on a stale alias resolves to no state rather than to freed memory, and a leaf or chunk of the tree that empties is freed unless it is the newest. Leaves (64 ids) and chunks (64 leaves) are small, so a long-lived wrapper keeps at most ~1 KB of the tree alive. The + 1 keeps the zero V8 stores in an array buffer's embedder fields from reading as a link. --- test-app/runtime/src/main/cpp/ObjectIdIndex.h | 162 ++++++++++++++++++ .../runtime/src/main/cpp/ObjectManager.cpp | 137 +++++++-------- test-app/runtime/src/main/cpp/ObjectManager.h | 45 +++-- test-app/runtime/src/main/cpp/Runtime.cpp | 4 +- .../runtime/src/main/cpp/V8GlobalHelpers.cpp | 7 +- 5 files changed, 257 insertions(+), 98 deletions(-) create mode 100644 test-app/runtime/src/main/cpp/ObjectIdIndex.h diff --git a/test-app/runtime/src/main/cpp/ObjectIdIndex.h b/test-app/runtime/src/main/cpp/ObjectIdIndex.h new file mode 100644 index 000000000..d62dacfb6 --- /dev/null +++ b/test-app/runtime/src/main/cpp/ObjectIdIndex.h @@ -0,0 +1,162 @@ +#ifndef OBJECTIDINDEX_H_ +#define OBJECTIDINDEX_H_ + +#include +#include +#include +#include +#include + +namespace tns { + +/* + * Object id -> entry, as a radix tree indexed by id: chunks of leaves of slots. Ids come + * from a per-runtime monotonic counter and are never reused, so a leaf or chunk that + * empties is freed unless it is the newest; an id linked after its leaf was freed just + * re-creates it. Leaves and chunks are small, so a long-lived entry keeps at most one of + * each alive. Not thread-safe: used under the isolate lock. + */ +template +class ObjectIdIndex { + public: + T* Find(int id) const { + if (id < 0) { + return nullptr; + } + size_t chunkIndex = static_cast(id) >> kChunkShift; + if (chunkIndex >= m_chunks.size()) { + return nullptr; + } + const Chunk* chunk = m_chunks[chunkIndex].get(); + if (chunk == nullptr) { + return nullptr; + } + const Leaf* leaf = chunk->leaves[LeafIndex(id)].get(); + return leaf == nullptr ? nullptr : leaf->slots[id & kLeafMask]; + } + + // Keeps an existing entry for id, like std::unordered_map::emplace. + void Insert(int id, T* value) { + if (id < 0) { + return; + } + size_t chunkIndex = static_cast(id) >> kChunkShift; + if (chunkIndex >= m_chunks.size()) { + m_chunks.resize(chunkIndex + 1); + } + auto& chunk = m_chunks[chunkIndex]; + if (chunk == nullptr) { + chunk = std::make_unique(); + } + auto& leaf = chunk->leaves[LeafIndex(id)]; + if (leaf == nullptr) { + leaf = std::make_unique(); + chunk->liveLeaves++; + } + T*& slot = leaf->slots[id & kLeafMask]; + size_t leafNumber = static_cast(id) >> kLeafBits; + if (leafNumber > m_newestLeaf) { + size_t previous = m_newestLeaf; + m_newestLeaf = leafNumber; + // Kept while it was the newest; nothing else frees it once it is empty. + ReleaseIfEmpty(previous); + } + if (slot == nullptr) { + slot = value; + leaf->live++; + } + } + + void Erase(int id) { + if (id < 0) { + return; + } + size_t chunkIndex = static_cast(id) >> kChunkShift; + if (chunkIndex >= m_chunks.size() || m_chunks[chunkIndex] == nullptr) { + return; + } + auto& chunk = m_chunks[chunkIndex]; + auto& leaf = chunk->leaves[LeafIndex(id)]; + if (leaf == nullptr) { + return; + } + T*& slot = leaf->slots[id & kLeafMask]; + if (slot == nullptr) { + return; + } + slot = nullptr; + leaf->live--; + size_t leafNumber = static_cast(id) >> kLeafBits; + if (leafNumber != m_newestLeaf) { + ReleaseIfEmpty(leafNumber); + } + } + + // Empties the index, then calls f on every entry it held. + template + void DrainAll(F&& f) { + auto chunks = std::move(m_chunks); + m_chunks.clear(); + m_newestLeaf = 0; + for (auto& chunk : chunks) { + if (chunk == nullptr) { + continue; + } + for (auto& leaf : chunk->leaves) { + if (leaf == nullptr) { + continue; + } + for (T* entry : leaf->slots) { + if (entry != nullptr) { + f(entry); + } + } + } + } + } + + private: + static constexpr int kLeafBits = 6; + static constexpr int kLeafMask = (1 << kLeafBits) - 1; + static constexpr int kChunkLeafBits = 6; + static constexpr int kChunkShift = kLeafBits + kChunkLeafBits; + + struct Leaf { + std::array slots{}; + uint32_t live = 0; + }; + + struct Chunk { + std::array, 1 << kChunkLeafBits> leaves; + uint32_t liveLeaves = 0; + }; + + // Frees leaf leafNumber if it is empty, then its chunk if that empties and is not the + // newest. + void ReleaseIfEmpty(size_t leafNumber) { + size_t chunkIndex = leafNumber >> kChunkLeafBits; + if (chunkIndex >= m_chunks.size() || m_chunks[chunkIndex] == nullptr) { + return; + } + auto& chunk = m_chunks[chunkIndex]; + auto& leaf = chunk->leaves[leafNumber & ((1 << kChunkLeafBits) - 1)]; + if (leaf == nullptr || leaf->live != 0) { + return; + } + leaf.reset(); + if (--chunk->liveLeaves == 0 && chunkIndex != (m_newestLeaf >> kChunkLeafBits)) { + chunk.reset(); + } + } + + static size_t LeafIndex(int id) { + return (static_cast(id) >> kLeafBits) & ((1 << kChunkLeafBits) - 1); + } + + std::vector> m_chunks; + size_t m_newestLeaf = 0; +}; + +} // namespace tns + +#endif /* OBJECTIDINDEX_H_ */ diff --git a/test-app/runtime/src/main/cpp/ObjectManager.cpp b/test-app/runtime/src/main/cpp/ObjectManager.cpp index b0b242e0b..cb2472e68 100644 --- a/test-app/runtime/src/main/cpp/ObjectManager.cpp +++ b/test-app/runtime/src/main/cpp/ObjectManager.cpp @@ -105,12 +105,12 @@ JniLocalRef ObjectManager::GetJavaObjectByJsObject( if (jsInstanceInfo != nullptr) { jweak existingObject; try { - existingObject = GetJavaObjectByID(jsInstanceInfo->JavaObjectID); + existingObject = GetJavaObjectByID(jsInstanceInfo->javaObjectID); } catch (const NativeScriptException& e) { // this captures usually the error - auto className = GetClassName(jsInstanceInfo->ObjectClazz); + auto className = GetClassName(jsInstanceInfo->objectClazz); throw NativeScriptException("Failed to get Java object by ID. id=" + - std::to_string(jsInstanceInfo->JavaObjectID) + + std::to_string(jsInstanceInfo->javaObjectID) + ", class=" + className + ". " + e.GetErrorMessage()); } @@ -183,9 +183,21 @@ MaybeLocal ObjectManager::ReadArrayThroughProxy(Local context, return scope.Escape(snapshot); } +int ObjectManager::LinkedIdOf(const Local& object) { + auto field = object->GetInternalField(static_cast(MetadataNodeKeys::JsInfo)).As(); + if (!field->IsUint32()) { + return -1; + } + return static_cast(field.As()->Value()) - 1; +} + +void ObjectManager::SetJsInfo(const Local& object, uint32_t javaObjectID) { + object->SetInternalField(static_cast(MetadataNodeKeys::JsInfo), + Integer::NewFromUnsigned(m_isolate, javaObjectID + 1)); +} + Local ObjectManager::GetJSInstanceInfoOwner(const Local& object) { - const int jsInfoIdx = static_cast(MetadataNodeKeys::JsInfo); - if (object->GetInternalField(jsInfoIdx).As()->IsExternal()) { + if (HasJsInfo(object)) { return object; } @@ -195,8 +207,7 @@ Local ObjectManager::GetJSInstanceInfoOwner(const Local& object) if (!prototypeObject.IsEmpty() && prototypeObject->IsObject()) { DEBUG_WRITE("GetJSInstanceInfo: need to check prototype :%d", prototypeObject->GetIdentityHash()); - if (IsJsRuntimeObject(prototypeObject) && - prototypeObject->GetInternalField(jsInfoIdx).As()->IsExternal()) { + if (IsJsRuntimeObject(prototypeObject) && HasJsInfo(prototypeObject)) { return prototypeObject; } } @@ -206,15 +217,18 @@ Local ObjectManager::GetJSInstanceInfoOwner(const Local& object) ObjectManager::JSInstanceInfo* ObjectManager::GetJSInstanceInfoFromRuntimeObject(const Local& object) { - HandleScope handleScope(m_isolate); - - auto owner = GetJSInstanceInfoOwner(object); - if (owner.IsEmpty()) { - return nullptr; + int id = LinkedIdOf(object); + if (id < 0) { + HandleScope handleScope(m_isolate); + auto owner = GetJSInstanceInfoOwner(object); + if (owner.IsEmpty()) { + return nullptr; + } + id = LinkedIdOf(owner); } - auto external = owner->GetInternalField(static_cast(MetadataNodeKeys::JsInfo)).As(); - return static_cast(external->Value(v8::kExternalPointerTypeTagDefault)); + auto state = m_idToObject.Find(id); + return state != nullptr && state->linked ? state : nullptr; } bool ObjectManager::IsJsRuntimeObject(const v8::Local& object) { @@ -245,14 +259,14 @@ jclass ObjectManager::GetJavaClass(const Local& instance) { DEBUG_WRITE("GetClass called"); JSInstanceInfo* jsInfo = GetJSInstanceInfo(instance); - jclass clazz = jsInfo->ObjectClazz; + jclass clazz = jsInfo->objectClazz; return clazz; } void ObjectManager::SetJavaClass(const Local& instance, jclass clazz) { JSInstanceInfo* jsInfo = GetJSInstanceInfo(instance); - jsInfo->ObjectClazz = clazz; + jsInfo->objectClazz = clazz; } int ObjectManager::GetOrCreateObjectId(jobject object) { @@ -282,12 +296,12 @@ Local ObjectManager::GetJsObjectByJavaObject(int javaObjectID) { auto isolate = m_isolate; EscapableHandleScope handleScope(isolate); - auto it = m_idToObject.find(javaObjectID); - if (it == m_idToObject.end()) { + auto state = m_idToObject.Find(javaObjectID); + if (state == nullptr) { return handleScope.Escape(Local()); } - return handleScope.Escape(JsObjectFor(it->second)); + return handleScope.Escape(JsObjectFor(state)); } Local ObjectManager::JsObjectFor(ObjectWeakCallbackState* state) { @@ -334,11 +348,11 @@ void ObjectManager::ReleaseUnwrappedArgs(const JavaCallArgs& args) { } Local ObjectManager::GetLinkedJsObjectByJavaObject(int javaObjectID) { - auto it = m_idToObject.find(javaObjectID); - if (it == m_idToObject.end() || it->second->jsInfo == nullptr) { + auto state = m_idToObject.Find(javaObjectID); + if (state == nullptr || !state->linked) { return Local(); } - return JsObjectFor(it->second); + return JsObjectFor(state); } Local ObjectManager::CreateJSWrapper(jint javaObjectID, @@ -445,23 +459,15 @@ void ObjectManager::Link(const Local& object, uint32_t javaObjectID, DEBUG_WRITE("Linking js object: %d and java instance id: %d", object->GetIdentityHash(), javaObjectID); - auto jsInstanceInfo = - new JSInstanceInfo(javaObjectID, clazz); - - auto state = new ObjectWeakCallbackState(this, jsInstanceInfo, isolate, object); + auto state = new ObjectWeakCallbackState(this, javaObjectID, clazz, isolate, object); // subscribe for JS GC event state->target->SetWeak(state, JSObjectFinalizerStatic, WeakCallbackType::kFinalizer); - auto jsInfoIdx = static_cast(MetadataNodeKeys::JsInfo); - - auto jsInfo = External::New(isolate, jsInstanceInfo, v8::kExternalPointerTypeTagDefault); + SetJsInfo(object, javaObjectID); - // link - object->SetInternalField(jsInfoIdx, jsInfo); - - m_idToObject.emplace(javaObjectID, state); + m_idToObject.Insert(javaObjectID, state); } bool ObjectManager::CloneLink(const Local& src, @@ -517,12 +523,11 @@ void ObjectManager::JSObjectFinalizer(Isolate* isolate, ObjectWeakCallbackState* callbackState) { HandleScope handleScope(m_isolate); Persistent* po = callbackState->target; - auto jsInstanceInfo = GetJSInstanceInfoFromRuntimeObject(po->Get(m_isolate)); - if (jsInstanceInfo == nullptr) { + if (!callbackState->linked) { // The link was already torn down (ReleaseNativeCounterpart); nothing but // the registration is left to drop. - m_idToObject.erase(callbackState->javaObjectID); + m_idToObject.Erase(callbackState->javaObjectID); po->Reset(); delete callbackState; return; @@ -538,7 +543,7 @@ void ObjectManager::JSObjectFinalizer(Isolate* isolate, return; } callbackState->queued = true; - auto javaObjectID = static_cast(jsInstanceInfo->JavaObjectID); + auto javaObjectID = static_cast(callbackState->javaObjectID); (callbackState->javaWeakened ? m_pendingCheck : m_pendingWeaken) .push_back(javaObjectID); @@ -599,11 +604,10 @@ void ObjectManager::FlushFinalizedBatch() { auto keepCollected = [this](std::vector& ids) { size_t kept = 0; for (jint id : ids) { - auto it = m_idToObject.find(id); - if (it == m_idToObject.end()) { + ObjectWeakCallbackState* state = m_idToObject.Find(id); + if (state == nullptr) { continue; } - ObjectWeakCallbackState* state = it->second; if (state->reacquired) { state->reacquired = false; state->queued = false; @@ -672,17 +676,17 @@ void ObjectManager::FlushFinalizedBatch() { raw->GetIntArrayRegion(array, 0, static_cast(ids.size()), results.data()); for (size_t i = 0; i < ids.size(); i++) { - auto it = m_idToObject.find(ids[i]); - if (it == m_idToObject.end()) { + ObjectWeakCallbackState* state = m_idToObject.Find(ids[i]); + if (state == nullptr) { continue; } if (results[i] < 0) { ReleaseCollectedWrapper(ids[i]); continue; } - it->second->queued = false; + state->queued = false; if (weakened) { - it->second->javaWeakened = true; + state->javaWeakened = true; } } }; @@ -691,20 +695,16 @@ void ObjectManager::FlushFinalizedBatch() { } void ObjectManager::ReleaseCollectedWrapper(int javaObjectID) { - auto it = m_idToObject.find(javaObjectID); - if (it == m_idToObject.end()) { + ObjectWeakCallbackState* state = m_idToObject.Find(javaObjectID); + if (state == nullptr) { return; } - ObjectWeakCallbackState* state = it->second; Persistent* po = state->target; - auto object = po->Get(m_isolate); - auto jsInstanceInfo = GetJSInstanceInfoFromRuntimeObject(object); - if (jsInstanceInfo != nullptr) { - delete jsInstanceInfo; - auto jsInfoIdx = static_cast(MetadataNodeKeys::JsInfo); - object->SetInternalField(jsInfoIdx, Undefined(m_isolate)); + if (state->linked) { + auto object = po->Get(m_isolate); + object->SetInternalField(static_cast(MetadataNodeKeys::JsInfo), Undefined(m_isolate)); } - m_idToObject.erase(it); + m_idToObject.Erase(javaObjectID); po->Reset(); delete state; } @@ -726,25 +726,19 @@ void ObjectManager::ReleaseAllRegistered() { // Detached first: nothing below should observe a half-emptied map, and the // finalizers these handles were armed with will never run now anyway. - auto survivors = std::move(m_idToObject); - m_idToObject.clear(); - - for (auto& entry : survivors) { - ObjectWeakCallbackState* state = entry.second; + m_idToObject.DrainAll([this, jsInfoIdx](ObjectWeakCallbackState* state) { Persistent* po = state->target; if (!po->IsEmpty()) { auto local = po->Get(m_isolate); if (!local.IsEmpty()) { - // Drop the back-pointer before the JSInstanceInfo goes away. local->SetInternalField(jsInfoIdx, Undefined(m_isolate)); } po->Reset(); } - delete state->jsInfo; delete state; - } + }); if (m_poJsWrapperFunc != nullptr) { m_poJsWrapperFunc->Reset(); @@ -825,7 +819,7 @@ void ObjectManager::ReleaseNativeCounterpart(v8::Local& object) { } auto instance = source.As(); - // A `super` object shares its owner's JsInfo External; the owner is the one + // A `super` object shares its owner's JsInfo link; the owner is the one // registered, so the release is resolved and recorded there. Local superOwner; V8GetPrivateValue(m_isolate, instance, ArgConverter::ConvertToV8String(m_isolate, "superowner"), superOwner); @@ -842,26 +836,23 @@ void ObjectManager::ReleaseNativeCounterpart(v8::Local& object) { } auto jsInfoIdx = static_cast(MetadataNodeKeys::JsInfo); - auto jsInstanceInfo = static_cast( - owner->GetInternalField(jsInfoIdx).As()->Value(v8::kExternalPointerTypeTagDefault)); + auto javaObjectID = LinkedIdOf(owner); JEnv env; AssertIsolateLocked(); env.CallVoidMethod(m_javaRuntimeObject, RELEASE_NATIVE_INSTANCE_METHOD_ID, - jsInstanceInfo->JavaObjectID); + javaObjectID); // The registration outlives the link: it is dropped by the finalizer once - // the wrapper is collected. Until then the entry must not point at the - // JSInstanceInfo being freed here. - auto it = m_idToObject.find(jsInstanceInfo->JavaObjectID); - if (it != m_idToObject.end()) { - it->second->jsInfo = nullptr; + // the wrapper is collected. + auto state = m_idToObject.Find(javaObjectID); + if (state != nullptr) { + state->linked = false; } - delete jsInstanceInfo; owner->SetInternalField(jsInfoIdx, Undefined(m_isolate)); - // Every alias of the freed External must be cleared with it. + // Every alias of the link is cleared with it. Local aliasesValue; V8GetPrivateValue(m_isolate, owner, ArgConverter::ConvertToV8String(m_isolate, "superaliases"), aliasesValue); if (!aliasesValue.IsEmpty() && aliasesValue->IsArray()) { diff --git a/test-app/runtime/src/main/cpp/ObjectManager.h b/test-app/runtime/src/main/cpp/ObjectManager.h index 7b4af7526..dbffe2199 100644 --- a/test-app/runtime/src/main/cpp/ObjectManager.h +++ b/test-app/runtime/src/main/cpp/ObjectManager.h @@ -13,6 +13,7 @@ #include "JniLocalRef.h" #include "LRUCache.h" #include "NativeScriptAssert.h" +#include "ObjectIdIndex.h" #include "v8.h" namespace tns { @@ -68,7 +69,7 @@ class ObjectManager { // Whether a wrapper was ever created for this id and not yet collected. bool HasJsObject(int javaObjectID) const { - return m_idToObject.find(javaObjectID) != m_idToObject.end(); + return m_idToObject.Find(javaObjectID) != nullptr; } // After a failed Java->JS call: drops the Java registration of every object Java @@ -112,6 +113,12 @@ class ObjectManager { */ static v8::Local GetJSInstanceInfoOwner(const v8::Local& object); + // Whether a runtime object's JsInfo field holds a link. The field holds id + 1, so + // the zero V8 stores in a typed array's embedder fields never reads as a link. + static bool HasJsInfo(const v8::Local& object) { + return LinkedIdOf(object) >= 0; + } + /* * The innermost target of a Proxy chain; any other value is returned as is. * A revoked Proxy anywhere in the chain yields null. Internal fields and @@ -141,33 +148,29 @@ class ObjectManager { private: static void InitializeJNI(); - struct JSInstanceInfo { - public: - JSInstanceInfo(uint32_t javaObjectID, jclass claz) - : JavaObjectID(javaObjectID), ObjectClazz(claz) {} - - uint32_t JavaObjectID; - jclass ObjectClazz; - }; + // The registration of one linked Java object id, reached from the wrapper's JsInfo + // field through m_idToObject. struct ObjectWeakCallbackState { - ObjectWeakCallbackState(ObjectManager* _thisPtr, JSInstanceInfo* _jsInfo, - v8::Isolate* isolate, v8::Local object) + ObjectWeakCallbackState(ObjectManager* _thisPtr, uint32_t _javaObjectID, + jclass clazz, v8::Isolate* isolate, + v8::Local object) : thisPtr(_thisPtr), - jsInfo(_jsInfo), handle(isolate, object), target(&handle), - javaObjectID(_jsInfo->JavaObjectID) {} + javaObjectID(_javaObjectID), + objectClazz(clazz) {} ObjectManager* thisPtr; - JSInstanceInfo* jsInfo; // Persistent, not Global: its destructor never calls into V8, so a state may be // freed after its isolate is gone. v8::Persistent handle; v8::Persistent* target; - // Duplicated from jsInfo: the finalizer has to unregister itself even when - // the JsInfo field was already cleared and jsInfo freed. uint32_t javaObjectID; + jclass objectClazz; + // Cleared by ReleaseNativeCounterpart; the registration itself lives until the + // wrapper is collected. + bool linked = true; // The Java side holds the object weakly; a finalization now only asks // whether it is still alive. bool javaWeakened = false; @@ -178,11 +181,19 @@ class ObjectManager { bool reacquired = false; }; + using JSInstanceInfo = ObjectWeakCallbackState; + + // The linked state of object, or null. JSInstanceInfo* GetJSInstanceInfo(const v8::Local& object); JSInstanceInfo* GetJSInstanceInfoFromRuntimeObject( const v8::Local& object); + // The id in a runtime object's JsInfo field, or -1. + static int LinkedIdOf(const v8::Local& object); + + void SetJsInfo(const v8::Local& object, uint32_t javaObjectID); + // clazz must be a global ref, or null to look className up. v8::Local CreateJSWrapperHelper(jint javaObjectID, const std::string& className, @@ -253,7 +264,7 @@ class ObjectManager { v8::Isolate* m_isolate; - std::unordered_map m_idToObject; + ObjectIdIndex m_idToObject; LRUCache m_cache; diff --git a/test-app/runtime/src/main/cpp/Runtime.cpp b/test-app/runtime/src/main/cpp/Runtime.cpp index 281132e85..7e6fec22f 100644 --- a/test-app/runtime/src/main/cpp/Runtime.cpp +++ b/test-app/runtime/src/main/cpp/Runtime.cpp @@ -1356,8 +1356,8 @@ void Runtime::DestroyRuntime() { // is alive and its destructors can still touch v8::Global handles. IsolateTracked::SweepAll(m_isolate); - // Same reason: every still-linked Java<->JS wrapper holds a Persistent, a - // JSInstanceInfo and its weak-callback state, and those finalizers will + // Same reason: every still-linked Java<->JS wrapper holds a Persistent in + // its weak-callback state, and those finalizers will // never run now. The JNI half of ObjectManager is released later, in its // destructor, once the isolate is gone. if (m_objectManager != nullptr) { diff --git a/test-app/runtime/src/main/cpp/V8GlobalHelpers.cpp b/test-app/runtime/src/main/cpp/V8GlobalHelpers.cpp index 05b795769..a9cd161d5 100644 --- a/test-app/runtime/src/main/cpp/V8GlobalHelpers.cpp +++ b/test-app/runtime/src/main/cpp/V8GlobalHelpers.cpp @@ -19,17 +19,12 @@ static std::string ToDottedName(std::string name) { return name; } -// A JS object linked to a Java counterpart holds a JSInstanceInfo External in -// its first internal field. The field count alone does not identify one: V8 -// hands typed arrays and array buffers the same number of embedder fields. static bool HasJavaCounterpart(Isolate* isolate, const Local& object) { auto objectManager = tns::Runtime::GetObjectManager(isolate); if (objectManager == nullptr || !objectManager->IsJsRuntimeObject(object)) { return false; } - - const int jsInfoIdx = static_cast(tns::ObjectManager::MetadataNodeKeys::JsInfo); - return object->GetInternalField(jsInfoIdx).As()->IsExternal(); + return tns::ObjectManager::HasJsInfo(object); } std::string tns::GetNativeWrapperHint(Isolate* isolate, const Local& value) { From 926f3d1c66a28257fb4a6f49275298d6c63d5a60 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Tue, 10 Nov 2026 16:52:56 -0300 Subject: [PATCH 35/42] perf: build Java object wrappers from the class's instance template A wrapper was an empty object from a shared template that then got an own `constructor` property, a new prototype and a private property holding a fresh External to its MetadataNode. Each step was a map transition off one shared map, and the per-class `constructor` values kept generalizing that map's field, so V8 spent most of a wrapper's creation migrating and searching maps. Wrappers now come from the class constructor's instance template, which starts them on the class's own initial map with the right prototype. The MetadataNode goes in a new internal field, as one External per class per runtime shared by all its instances; `new` uses the same field. Wrappers no longer have an own `constructor`: it resolves through the prototype to the same function. Array buffers and views are linked to Java buffers through V8's own two embedder fields and have no Metadata field; they keep the private property. --- .../src/main/assets/app/tests/testMetadata.js | 42 +++++++++++++++ .../runtime/src/main/cpp/MetadataNode.cpp | 53 +++++++++++++++---- test-app/runtime/src/main/cpp/MetadataNode.h | 17 ++++++ .../runtime/src/main/cpp/ObjectManager.cpp | 11 +++- test-app/runtime/src/main/cpp/ObjectManager.h | 7 ++- 5 files changed, 118 insertions(+), 12 deletions(-) diff --git a/test-app/app/src/main/assets/app/tests/testMetadata.js b/test-app/app/src/main/assets/app/tests/testMetadata.js index a9d2df4cc..dbe7c8392 100644 --- a/test-app/app/src/main/assets/app/tests/testMetadata.js +++ b/test-app/app/src/main/assets/app/tests/testMetadata.js @@ -58,3 +58,45 @@ describe("Java members", function () { expect(java.util.UUID.randomUUID() instanceof java.util.UUID).toBe(true); }); }); + +describe("Java object wrappers", function () { + it("resolve constructor through the class prototype, with no own properties", function () { + var id = java.util.UUID.randomUUID(); + + expect(Object.getPrototypeOf(id)).toBe(java.util.UUID.prototype); + expect(id.constructor).toBe(java.util.UUID); + expect(Object.prototype.hasOwnProperty.call(id, "constructor")).toBe(false); + expect(Object.keys(id)).toEqual([]); + }); + + it("share a prototype between Java-created and JS-created instances", function () { + var created = new java.lang.StringBuilder("a"); + var returned = new java.lang.StringBuilder().append("b").append(created); + + expect(Object.getPrototypeOf(returned)).toBe(Object.getPrototypeOf(created)); + expect(returned instanceof java.lang.StringBuilder).toBe(true); + expect(String(returned.toString())).toBe("ba"); + }); + + it("hand back the same wrapper for the same Java object", function () { + var builder = new java.lang.StringBuilder(); + + expect(builder.append("x")).toBe(builder); + }); + + it("stay linked while thousands of later wrappers are collected", function () { + var list = new java.util.ArrayList(); + var first = java.util.UUID.randomUUID(); + list.add(first); + for (var round = 0; round < 3; round++) { + for (var i = 0; i < 3000; i++) { + java.util.UUID.randomUUID(); + } + gc(); + java.lang.System.gc(); + } + + expect(list.get(0)).toBe(first); + expect(String(list.get(0).toString())).toBe(String(first.toString())); + }); +}); diff --git a/test-app/runtime/src/main/cpp/MetadataNode.cpp b/test-app/runtime/src/main/cpp/MetadataNode.cpp index dadd3c55a..ede0d617d 100644 --- a/test-app/runtime/src/main/cpp/MetadataNode.cpp +++ b/test-app/runtime/src/main/cpp/MetadataNode.cpp @@ -125,8 +125,13 @@ static Local ResolveImplementationObject(Isolate* isolate, const Local& value, std::string& out) { - auto cache = GetMetadataNodeCache(isolate); - auto node = static_cast(TryReadPrivateExternal(isolate, value, cache->MetadataKey)); + MetadataNode* node; + if (ObjectManager::HasMetadataField(value)) { + node = GetInstanceMetadata(isolate, value); + } else { + auto cache = GetMetadataNodeCache(isolate); + node = static_cast(TryReadPrivateExternal(isolate, value, cache->MetadataKey)); + } if (node == nullptr) { return false; } @@ -312,13 +317,20 @@ Local MetadataNode::CreateJSWrapper(Isolate* isolate, ObjectManager* obj if (m_isArray) { obj = CreateArrayWrapper(isolate); } else { - obj = objectManager->GetEmptyObject(isolate); - if (!obj.IsEmpty()) { - auto ctorFunc = GetConstructorFunction(isolate); - auto context = isolate->GetCurrentContext(); - obj->Set(context, V8StringConstants::GetConstructor(isolate), ctorFunc); - obj->SetPrototype(context, ctorFunc->Get(context, V8StringConstants::GetPrototype(isolate)).ToLocalChecked()); - SetInstanceMetadata(isolate, obj, this); + auto& data = GetInstanceData(isolate); + if (data.instanceTemplate == nullptr) { + // Throws for a class missing at runtime, which has no template. + GetConstructorFunction(isolate); + auto cache = GetMetadataNodeCache(isolate); + auto ft = Local::New(isolate, *cache->CtorFuncCache.at(m_treeNode).ft); + data.instanceTemplate = new Persistent(isolate, ft->InstanceTemplate()); + } + // Starts on the class's initial map, with the constructor's prototype, and + // without running the constructor callback. + auto context = isolate->GetCurrentContext(); + if (data.instanceTemplate->Get(isolate)->NewInstance(context).ToLocal(&obj)) { + obj->SetInternalField(static_cast(ObjectManager::MetadataNodeKeys::Metadata), + data.metadata->Get(isolate)); } } @@ -1331,7 +1343,24 @@ void MetadataNode::SetTypeMetadata(Isolate* isolate, Local value, Type V8SetPrivateValue(isolate, value, String::NewFromUtf8(isolate, "typemetadata").ToLocalChecked(), External::New(isolate, data, v8::kExternalPointerTypeTagDefault)); } +MetadataNode::InstanceData& MetadataNode::GetInstanceData(Isolate* isolate) { + auto cache = GetMetadataNodeCache(isolate); + auto& data = cache->Instances[this]; + if (data.metadata == nullptr) { + data.metadata = new Persistent(isolate, External::New(isolate, this, v8::kExternalPointerTypeTagDefault)); + } + return data; +} + MetadataNode* MetadataNode::GetInstanceMetadata(Isolate* isolate, const Local& value) { + if (ObjectManager::HasMetadataField(value)) { + auto field = value->GetInternalField(static_cast(ObjectManager::MetadataNodeKeys::Metadata)).As(); + if (!field->IsExternal()) { + return nullptr; + } + return static_cast(field.As()->Value(v8::kExternalPointerTypeTagDefault)); + } + MetadataNode* node = nullptr; auto cache = GetMetadataNodeCache(isolate); auto key = Local::New(isolate, *cache->MetadataKey); @@ -1346,6 +1375,12 @@ MetadataNode* MetadataNode::GetInstanceMetadata(Isolate* isolate, const Local object, MetadataNode* node) { + if (ObjectManager::HasMetadataField(object)) { + object->SetInternalField(static_cast(ObjectManager::MetadataNodeKeys::Metadata), + node->GetInstanceData(isolate).metadata->Get(isolate)); + return; + } + auto cache = GetMetadataNodeCache(isolate); auto key = Local::New(isolate, *cache->MetadataKey); V8SetPrivateValue(isolate, object, key, External::New(isolate, node, v8::kExternalPointerTypeTagDefault)); diff --git a/test-app/runtime/src/main/cpp/MetadataNode.h b/test-app/runtime/src/main/cpp/MetadataNode.h index 8e22eadbb..03b99545e 100644 --- a/test-app/runtime/src/main/cpp/MetadataNode.h +++ b/test-app/runtime/src/main/cpp/MetadataNode.h @@ -139,6 +139,10 @@ class MetadataNode { static MetadataNode* GetInstanceMetadata(v8::Isolate* isolate, const v8::Local& value); + struct InstanceData; + // This runtime's per-instance data for this node, created on first use. + InstanceData& GetInstanceData(v8::Isolate* isolate); + static void SetInstanceMetadata(v8::Isolate* isolate, v8::Local value, MetadataNode* node); static TypeMetadata* GetTypeMetadata(v8::Isolate* isolate, const v8::Local& value); @@ -269,6 +273,13 @@ class MetadataNode { std::string name; }; + // A node's metadata External is shared by all its instances, so marking an + // instance allocates nothing. instanceTemplate is set once a wrapper was made. + struct InstanceData { + v8::Persistent* metadata = nullptr; + v8::Persistent* instanceTemplate = nullptr; + }; + struct CtorCacheData { CtorCacheData(v8::Persistent* _ft, std::vector _instanceMethodCallbacks) : @@ -322,6 +333,8 @@ class MetadataNode { */ robin_hood::unordered_map*> CtorFunctions; + robin_hood::unordered_node_map Instances; + /* * Owns every callback payload this runtime hands to V8 as External * or FunctionTemplate data -- MethodCallbackData, FieldCallbackData, @@ -356,6 +369,10 @@ class MetadataNode { for (auto& entry : CtorFunctions) { delete entry.second; } + for (auto& entry : Instances) { + delete entry.second.metadata; + delete entry.second.instanceTemplate; + } /* * Freed from the maps rather than from CtorCacheData and * ExtendedClassCacheData themselves: both are held by value and diff --git a/test-app/runtime/src/main/cpp/ObjectManager.cpp b/test-app/runtime/src/main/cpp/ObjectManager.cpp index cb2472e68..1059f7ab8 100644 --- a/test-app/runtime/src/main/cpp/ObjectManager.cpp +++ b/test-app/runtime/src/main/cpp/ObjectManager.cpp @@ -231,10 +231,17 @@ ObjectManager::GetJSInstanceInfoFromRuntimeObject(const Local& object) { return state != nullptr && state->linked ? state : nullptr; } +// Array buffers and their views are linked to Java buffers through V8's own embedder +// fields, which must cover JsInfo and CallSuper; they have no Metadata field. +static_assert(V8_ARRAY_BUFFER_INTERNAL_FIELD_COUNT == + static_cast(ObjectManager::MetadataNodeKeys::Metadata)); +static_assert(V8_ARRAY_BUFFER_VIEW_INTERNAL_FIELD_COUNT == + static_cast(ObjectManager::MetadataNodeKeys::Metadata)); + bool ObjectManager::IsJsRuntimeObject(const v8::Local& object) { int internalFieldCount = object->InternalFieldCount(); - const int count = static_cast(MetadataNodeKeys::END); - return internalFieldCount == count; + return internalFieldCount == static_cast(MetadataNodeKeys::END) || + internalFieldCount == V8_ARRAY_BUFFER_INTERNAL_FIELD_COUNT; } jweak ObjectManager::GetJavaObjectByID(uint32_t javaObjectID) { diff --git a/test-app/runtime/src/main/cpp/ObjectManager.h b/test-app/runtime/src/main/cpp/ObjectManager.h index dbffe2199..6e1157188 100644 --- a/test-app/runtime/src/main/cpp/ObjectManager.h +++ b/test-app/runtime/src/main/cpp/ObjectManager.h @@ -105,6 +105,11 @@ class ObjectManager { */ static bool IsJsRuntimeObject(const v8::Local& object); + // Whether object is a runtime object with a Metadata field: not an array buffer. + static bool HasMetadataField(const v8::Local& object) { + return object->InternalFieldCount() == static_cast(MetadataNodeKeys::END); + } + /* * The runtime object holding the JsInfo External for `object`: the object * itself, or its prototype when the TypeScript layout keeps the registered @@ -144,7 +149,7 @@ class ObjectManager { v8::Local GetEmptyObject(v8::Isolate* isolate); - enum class MetadataNodeKeys { JsInfo, CallSuper, END }; + enum class MetadataNodeKeys { JsInfo, CallSuper, Metadata, END }; private: static void InitializeJNI(); From 6feaa50c4b7f25faa35fae444a6bb1919a93b8dd Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Tue, 10 Nov 2026 18:13:53 -0300 Subject: [PATCH 36/42] perf: index ObjectTable's entries directly by id The id side of the table was an open-addressing hash on the id that doubled and rehashed as it grew. It is now a radix tree indexed by id (64-id leaves in 64-leaf chunks), holding each entry's value and identity hash: lookups are three array loads, there is no rehash, and a leaf or chunk that empties is dropped unless it is the newest, as on the native side. The identity index is unchanged. --- .../src/main/java/com/tns/ObjectTable.java | 251 ++++++++++-------- .../test/java/com/tns/ObjectTableTest.java | 31 +++ 2 files changed, 175 insertions(+), 107 deletions(-) diff --git a/test-app/runtime/src/main/java/com/tns/ObjectTable.java b/test-app/runtime/src/main/java/com/tns/ObjectTable.java index b4762aa82..304bcded2 100644 --- a/test-app/runtime/src/main/java/com/tns/ObjectTable.java +++ b/test-app/runtime/src/main/java/com/tns/ObjectTable.java @@ -109,35 +109,65 @@ private int findIdUnlocked(Object obj) { return NO_ID; } - // get() with every probe bounded: a torn view need not contain the empty slot that - // ends a probe. + // get() without the lock. Safe on a torn view: every array it reads is indexed within + // its own bounds, and a stale one only makes the caller's stamp check fail. private Object getUnlocked(int id) { - int[] keys = idKeys; - Object[] vals = values; - int mask = keys.length - 1; - int i = mix(id) & mask; - int key = id + 1; - for (int probes = 0; probes <= mask; probes++) { - int k = keys[i]; - if (k == key) { - Object value = vals[i]; - return value instanceof WeakRef ? ((WeakRef) value).get() : value; - } - if (k == 0) { - return null; - } - i = (i + 1) & mask; + if (id < 0) { + return null; + } + Chunk[] cs = chunks; + int c = id >>> CHUNK_SHIFT; + if (c >= cs.length) { + return null; + } + Chunk chunk = cs[c]; + if (chunk == null) { + return null; } - return null; + Leaf leaf = chunk.leaves[(id >>> LEAF_BITS) & CHUNK_MASK]; + if (leaf == null) { + return null; + } + Object value = leaf.values[id & LEAF_MASK]; + return value == null ? null : deref(value); + } + + private static Object deref(Object value) { + return value instanceof WeakRef ? ((WeakRef) value).get() : value; + } + + /* + * id -> entry, as a radix tree indexed by id: chunks of leaves of slots. Ids come from a + * monotonic counter and are never reused, so a leaf or chunk that empties is dropped + * unless it is the newest; an id registered after its leaf was dropped re-creates it. + * Leaves and chunks are small, so a long-lived object keeps at most one of each alive. + */ + private static final int LEAF_BITS = 6; + private static final int LEAF_SIZE = 1 << LEAF_BITS; + private static final int LEAF_MASK = LEAF_SIZE - 1; + private static final int CHUNK_BITS = 6; + private static final int CHUNK_MASK = (1 << CHUNK_BITS) - 1; + private static final int CHUNK_SHIFT = LEAF_BITS + CHUNK_BITS; + + private static final class Leaf { + // The object, or a WeakRef to it; null for no entry. + final Object[] values = new Object[LEAF_SIZE]; + // The object's identity hash, to find its identity index entry on removal. + final int[] hashes = new int[LEAF_SIZE]; + int live; } - // id -> entry, open addressing on the id. Keys are id + 1 so 0 marks an empty slot. - private int[] idKeys = new int[1024]; - private Object[] values = new Object[1024]; - private int[] identityHashes = new int[1024]; + private static final class Chunk { + final Leaf[] leaves = new Leaf[1 << CHUNK_BITS]; + int liveLeaves; + } + + private Chunk[] chunks = new Chunk[16]; + // id >>> LEAF_BITS of the highest id registered. + private int newestLeaf = 0; private int idCount = 0; - // identity hash -> id + 1, open addressing; values[] decides which object an id holds. + // identity hash -> id + 1, open addressing; the id's entry decides which object it holds. // identityIndexHashes[i] is the hash of identityIndex[i]'s object, so a probe only looks // the id up when the hashes match. private int[] identityIndex = new int[1024]; @@ -163,20 +193,19 @@ private void putStrongLocked(int id, Object obj) { } private void putStrongLocked(int id, Object obj, int hash) { - int existing = findIdSlot(id); - if (existing >= 0) { - Object value = values[existing]; - if ((value instanceof WeakRef ? ((WeakRef) value).get() : value) == obj) { - values[existing] = obj; - return; + Leaf existingLeaf = leafFor(id); + if (existingLeaf != null) { + Object value = existingLeaf.values[id & LEAF_MASK]; + if (value != null) { + if (deref(value) == obj) { + existingLeaf.values[id & LEAF_MASK] = obj; + return; + } + removeLocked(id); } - removeLocked(id); } - if ((idCount + 1) * 2 > idKeys.length) { - growIds(); - } - insertId(id, obj, hash); + insertEntry(id, obj, hash); idCount++; if ((identityCount + 1) * 2 > identityIndex.length) { @@ -281,21 +310,17 @@ Object get(int id) { } private Object getLocked(int id) { - int slot = findIdSlot(id); - if (slot < 0) { - return null; - } - Object value = values[slot]; - return value instanceof WeakRef ? ((WeakRef) value).get() : value; + Object value = rawLocked(id); + return value == null ? null : deref(value); } boolean contains(int id) { if (stamps == null) { - return findIdSlot(id) >= 0; + return rawLocked(id) != null; } long stamp = stamps.readLock(); try { - return findIdSlot(id) >= 0; + return rawLocked(id) != null; } finally { stamps.unlockRead(stamp); } @@ -340,11 +365,11 @@ void weakenAndCheckAlive(int[] ids, int count) { } private boolean weakenAndCheckAliveLocked(int id) { - int slot = findIdSlot(id); - if (slot < 0) { + Leaf leaf = leafFor(id); + Object value = leaf == null ? null : leaf.values[id & LEAF_MASK]; + if (value == null) { return false; } - Object value = values[slot]; if (value instanceof WeakRef) { if (((WeakRef) value).get() != null) { return true; @@ -352,7 +377,7 @@ private boolean weakenAndCheckAliveLocked(int id) { removeLocked(id); return false; } - values[slot] = new WeakRef(value); + leaf.values[id & LEAF_MASK] = new WeakRef(value); return true; } @@ -370,12 +395,19 @@ void remove(int id) { } private void removeLocked(int id) { - int slot = findIdSlot(id); - if (slot < 0) { + Leaf leaf = leafFor(id); + int i = id & LEAF_MASK; + if (leaf == null || leaf.values[i] == null) { return; } - int hash = identityHashes[slot]; - removeIdSlot(slot); + int hash = leaf.hashes[i]; + leaf.values[i] = null; + leaf.hashes[i] = 0; + leaf.live--; + int leafNumber = id >>> LEAF_BITS; + if (leafNumber != newestLeaf) { + releaseIfEmpty(leafNumber); + } idCount--; removeIdentity(hash, id); identityCount--; @@ -393,55 +425,75 @@ private static final class WeakRef extends WeakReference { } } - private int findIdSlot(int id) { - int mask = idKeys.length - 1; - int i = mix(id) & mask; - int key = id + 1; - while (true) { - int k = idKeys[i]; - if (k == key) { - return i; - } - if (k == 0) { - return -1; - } - i = (i + 1) & mask; + private Leaf leafFor(int id) { + if (id < 0) { + return null; } + int c = id >>> CHUNK_SHIFT; + Chunk chunk = c < chunks.length ? chunks[c] : null; + return chunk == null ? null : chunk.leaves[(id >>> LEAF_BITS) & CHUNK_MASK]; } - private void insertId(int id, Object value, int hash) { - int mask = idKeys.length - 1; - int i = mix(id) & mask; - while (idKeys[i] != 0) { - i = (i + 1) & mask; - } - idKeys[i] = id + 1; - values[i] = value; - identityHashes[i] = hash; + // The entry for id as stored (the object or a WeakRef to it), or null. + private Object rawLocked(int id) { + Leaf leaf = leafFor(id); + return leaf == null ? null : leaf.values[id & LEAF_MASK]; } - // Backward-shift deletion keeps linear probing tombstone-free. - private void removeIdSlot(int slot) { - int mask = idKeys.length - 1; - int hole = slot; - int j = slot; - while (true) { - j = (j + 1) & mask; - int key = idKeys[j]; - if (key == 0) { - break; - } - int home = mix(key - 1) & mask; - if (!isBetween(hole, home, j)) { - idKeys[hole] = key; - values[hole] = values[j]; - identityHashes[hole] = identityHashes[j]; - hole = j; + // Adds an entry for an id that has none. + private void insertEntry(int id, Object value, int hash) { + int c = id >>> CHUNK_SHIFT; + if (c >= chunks.length) { + int length = chunks.length; + while (length <= c) { + length *= 2; } + Chunk[] grown = new Chunk[length]; + System.arraycopy(chunks, 0, grown, 0, chunks.length); + chunks = grown; + } + Chunk chunk = chunks[c]; + if (chunk == null) { + chunk = new Chunk(); + chunks[c] = chunk; + } + int l = (id >>> LEAF_BITS) & CHUNK_MASK; + Leaf leaf = chunk.leaves[l]; + if (leaf == null) { + leaf = new Leaf(); + chunk.leaves[l] = leaf; + chunk.liveLeaves++; + } + int i = id & LEAF_MASK; + leaf.values[i] = value; + leaf.hashes[i] = hash; + leaf.live++; + int leafNumber = id >>> LEAF_BITS; + if (leafNumber > newestLeaf) { + int previous = newestLeaf; + newestLeaf = leafNumber; + // Kept while it was the newest; nothing else drops it once it is empty. + releaseIfEmpty(previous); + } + } + + // Drops leaf leafNumber if it is empty, then its chunk if that empties and is not the + // newest. + private void releaseIfEmpty(int leafNumber) { + int c = leafNumber >>> CHUNK_BITS; + Chunk chunk = c < chunks.length ? chunks[c] : null; + if (chunk == null) { + return; + } + int l = leafNumber & CHUNK_MASK; + Leaf leaf = chunk.leaves[l]; + if (leaf == null || leaf.live != 0) { + return; + } + chunk.leaves[l] = null; + if (--chunk.liveLeaves == 0 && c != (newestLeaf >>> CHUNK_BITS)) { + chunks[c] = null; } - idKeys[hole] = 0; - values[hole] = null; - identityHashes[hole] = 0; } private void insertIdentity(int hash, int id) { @@ -488,21 +540,6 @@ private static boolean isBetween(int hole, int home, int j) { return hole <= j ? (hole < home && home <= j) : (hole < home || home <= j); } - private void growIds() { - int[] oldKeys = idKeys; - Object[] oldValues = values; - int[] oldHashes = identityHashes; - int capacity = oldKeys.length * 2; - idKeys = new int[capacity]; - values = new Object[capacity]; - identityHashes = new int[capacity]; - for (int i = 0; i < oldKeys.length; i++) { - if (oldKeys[i] != 0) { - insertId(oldKeys[i] - 1, oldValues[i], oldHashes[i]); - } - } - } - private void growIdentity() { int[] oldIndex = identityIndex; int[] oldHashes = identityIndexHashes; diff --git a/test-app/runtime/src/test/java/com/tns/ObjectTableTest.java b/test-app/runtime/src/test/java/com/tns/ObjectTableTest.java index 9b80a577a..125f164f5 100644 --- a/test-app/runtime/src/test/java/com/tns/ObjectTableTest.java +++ b/test-app/runtime/src/test/java/com/tns/ObjectTableTest.java @@ -276,6 +276,37 @@ public void run() { assertNull(failure.get(), failure.get()); } + @Test + public void keepsSurvivorsOfReleasedRangesAndOldIdsRegisteredLate() { + ObjectTable table = new ObjectTable(); + Object[] objects = new Object[3000]; + for (int id = 0; id < objects.length; id++) { + objects[id] = new Object(); + table.putStrong(id, objects[id]); + } + for (int id = 0; id < 2048; id++) { + if (id != 5 && id != 1500) { + table.remove(id); + } + } + + assertTrue(table.weakenAndCheckAlive(5)); + assertSame(objects[5], table.get(5)); + assertEquals(5, table.findId(objects[5])); + assertEquals(1500, table.findId(objects[1500])); + + Object late = new Object(); + table.putStrong(700, late); + assertSame(late, table.get(700)); + assertEquals(700, table.findId(late)); + + table.remove(5); + assertNull(table.get(5)); + assertFalse(table.contains(5)); + assertEquals(ObjectTable.NO_ID, table.findId(objects[5])); + assertEquals(954, table.size()); + } + @Test public void optimisticReadsMissWhenDisabled() { ObjectTable table = new ObjectTable(false); From 350ddd5a5eaa468d87325da397d40248ff115570 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Tue, 10 Nov 2026 18:13:53 -0300 Subject: [PATCH 37/42] build: bind calls between the runtime's own functions directly The regular runtime exports everything in the static V8 monolith, so every call between those functions went through the PLT: 45,008 of the library's 45,279 jump slots pointed back into the library itself. -Bsymbolic-functions binds them at link time. Exported functions stay callable from plugins and data symbols stay interposable. The optimized runtimes hide V8's internals with a version script and already had only 271 jump slots; this leaves them unchanged. --- test-app/runtime/CMakeLists.txt | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/test-app/runtime/CMakeLists.txt b/test-app/runtime/CMakeLists.txt index eb2ca29ac..25644f2a1 100644 --- a/test-app/runtime/CMakeLists.txt +++ b/test-app/runtime/CMakeLists.txt @@ -262,18 +262,23 @@ add_library( set(NATIVES_BLOB_INCLUDE_DIRECTORIES ${PROJECT_SOURCE_DIR}/src/main/libs/${ANDROID_ABI}/include) +# Calls between this library's own exported functions bind directly instead of going +# through the PLT. Exported functions stay callable from plugins; data symbols stay +# interposable so a plugin and the runtime never end up with two copies of one variable. +set(NS_SYMBOLIC_FUNCTIONS "-Wl,-Bsymbolic-functions") + if (OPTIMIZED_BUILD OR OPTIMIZED_WITH_INSPECTOR_BUILD) set_target_properties( NativeScript # The version script keeps V8's public API linkable for plugins while hiding # the ~60k v8::internal:: symbols the static monolith would otherwise export. - PROPERTIES LINK_FLAGS "-Wl,--allow-multiple-definition -Wl,--gc-sections -Wl,--version-script=${CMAKE_CURRENT_SOURCE_DIR}/exported-symbols.map" + PROPERTIES LINK_FLAGS "-Wl,--allow-multiple-definition -Wl,--gc-sections -Wl,--version-script=${CMAKE_CURRENT_SOURCE_DIR}/exported-symbols.map ${NS_SYMBOLIC_FUNCTIONS}" INTERFACE_INCLUDE_DIRECTORIES NATIVES_BLOB_INCLUDE_DIRECTORIES ) else () set_target_properties( NativeScript - PROPERTIES LINK_FLAGS -Wl,--allow-multiple-definition + PROPERTIES LINK_FLAGS "-Wl,--allow-multiple-definition ${NS_SYMBOLIC_FUNCTIONS}" INTERFACE_INCLUDE_DIRECTORIES NATIVES_BLOB_INCLUDE_DIRECTORIES ) endif () From cde9b0f60990bf3b4710960bc1060b6443fc1219 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Tue, 10 Nov 2026 19:38:09 -0300 Subject: [PATCH 38/42] fix: address review findings on the object lifecycle changes - A Java->JS call that threw returned through FlushFinalizedBatch with its Java exception pending: the flush made JNI calls with it pending (CheckJNI aborts) and then took it for its own failure and cleared it, so the caller got null. A flush now leaves a pending exception alone and defers to the scheduled flush task. - Reading `super` through an object that is not a Java instance (an implementation object) dereferenced a null MetadataNode; the metadata field is left undefined, as before. - getOrCreateJavaObjectID(AndClass) looked an object up and registered it in two steps while other threads may register call arguments without the isolate lock; a miss now registers through findOrRegister. - Object ids are unsigned 32-bit values in both id indexes, with only -1 reserved, so ids past 2^31 keep working as they did with the hash maps. - Package children are read-only again, as main's setter-less native data properties were. - A method signature key is encoded without running JS (Number objects are read with ValueOf), so a re-entrant lookup cannot clobber the shared key. - Debug check against linking one id twice; m_isolate is initialized; releaseNativeCounterpart is public again; comments corrected. --- .../app/src/main/assets/app/tests/testGC.js | 17 +++++++ .../src/main/assets/app/tests/testMetadata.js | 7 +++ .../runtime/src/main/cpp/ArgConverter.cpp | 2 +- .../runtime/src/main/cpp/MetadataNode.cpp | 9 ++-- test-app/runtime/src/main/cpp/MethodCache.cpp | 5 +- test-app/runtime/src/main/cpp/ObjectIdIndex.h | 50 +++++++++++-------- .../runtime/src/main/cpp/ObjectManager.cpp | 17 +++++-- test-app/runtime/src/main/cpp/ObjectManager.h | 15 +++--- test-app/runtime/src/main/cpp/Runtime.cpp | 4 +- .../src/main/java/com/tns/ObjectTable.java | 11 ++-- .../src/main/java/com/tns/Runtime.java | 49 +++++++++++------- .../test/java/com/tns/ObjectTableTest.java | 28 +++++++++++ 12 files changed, 153 insertions(+), 61 deletions(-) diff --git a/test-app/app/src/main/assets/app/tests/testGC.js b/test-app/app/src/main/assets/app/tests/testGC.js index 4c4d58a75..43c5b9ae5 100644 --- a/test-app/app/src/main/assets/app/tests/testGC.js +++ b/test-app/app/src/main/assets/app/tests/testGC.js @@ -317,6 +317,23 @@ describe("Tests garbage collection", function () { describe("collected wrappers waiting for a flush", function () { var Holder = com.tns.tests.WrapperHolder; + it("keep a callback's error when wrappers were collected after its last Java call", function () { + var callable = new java.util.concurrent.Callable({ + call: function () { + for (var i = 0; i < 2000; i++) { + new java.lang.Object(); + } + gc(); + throw new Error("thrown with a batch pending"); + } + }); + var task = new java.util.concurrent.FutureTask(callable); + task.run(); + + expect(task.isDone()).toBe(true); + expect(function () { task.get(); }).toThrow(); + }); + function collectJava() { for (var i = 0; i < 3; i++) { java.lang.Runtime.getRuntime().gc(); diff --git a/test-app/app/src/main/assets/app/tests/testMetadata.js b/test-app/app/src/main/assets/app/tests/testMetadata.js index dbe7c8392..7b48f2ca0 100644 --- a/test-app/app/src/main/assets/app/tests/testMetadata.js +++ b/test-app/app/src/main/assets/app/tests/testMetadata.js @@ -78,6 +78,13 @@ describe("Java object wrappers", function () { expect(String(returned.toString())).toBe("ba"); }); + it("read super through an implementation object that is not a Java instance", function () { + var impl = { toString: function () { return "impl"; } }; + java.lang.Object.extend(impl); + + expect(function () { return impl.super; }).not.toThrow(); + }); + it("hand back the same wrapper for the same Java object", function () { var builder = new java.lang.StringBuilder(); diff --git a/test-app/runtime/src/main/cpp/ArgConverter.cpp b/test-app/runtime/src/main/cpp/ArgConverter.cpp index 24bd2dc93..20b8bf52a 100644 --- a/test-app/runtime/src/main/cpp/ArgConverter.cpp +++ b/test-app/runtime/src/main/cpp/ArgConverter.cpp @@ -154,7 +154,7 @@ void ArgConverter::ConvertJavaArgsToJsArgs(Isolate* isolate, JNIEnv* env, const !objectManager->HasJsObject(hintedObjectID)) { JniLocalRef arg(env->GetObjectArrayElement(args.refs, i)); jsArg = objectManager->CreateJSWrapper(hintedObjectID, classIndex, arg); - } else if (hintedObjectID >= 0) { + } else if (hintedObjectID != -1) { jsArg = objectManager->GetLinkedJsObjectByJavaObject(hintedObjectID); } if (jsArg.IsEmpty()) { diff --git a/test-app/runtime/src/main/cpp/MetadataNode.cpp b/test-app/runtime/src/main/cpp/MetadataNode.cpp index ede0d617d..f485d073c 100644 --- a/test-app/runtime/src/main/cpp/MetadataNode.cpp +++ b/test-app/runtime/src/main/cpp/MetadataNode.cpp @@ -398,7 +398,8 @@ Local MetadataNode::CreatePackageObject(Isolate* isolate) { packageObj->SetLazyDataProperty(ctx, NewMemberName(isolate, childNode->name), PackageGetterCallback, - extData).ToChecked(); + extData, + PropertyAttribute::ReadOnly).ToChecked(); } } @@ -1376,8 +1377,10 @@ MetadataNode* MetadataNode::GetInstanceMetadata(Isolate* isolate, const Local object, MetadataNode* node) { if (ObjectManager::HasMetadataField(object)) { - object->SetInternalField(static_cast(ObjectManager::MetadataNodeKeys::Metadata), - node->GetInstanceData(isolate).metadata->Get(isolate)); + // node is null for `super` read through an object that is not a Java instance. + Local metadata = node == nullptr ? Local(Undefined(isolate)) + : node->GetInstanceData(isolate).metadata->Get(isolate); + object->SetInternalField(static_cast(ObjectManager::MetadataNodeKeys::Metadata), metadata); return; } diff --git a/test-app/runtime/src/main/cpp/MethodCache.cpp b/test-app/runtime/src/main/cpp/MethodCache.cpp index be7a57951..2a73205e8 100644 --- a/test-app/runtime/src/main/cpp/MethodCache.cpp +++ b/test-app/runtime/src/main/cpp/MethodCache.cpp @@ -185,8 +185,9 @@ void MethodCache::AppendType(string& type, Isolate* isolate, const v8::LocalIsString() || value->IsStringObject()) { type += "string"; } else if (value->IsNumber() || value->IsNumberObject()) { - auto context = isolate->GetCurrentContext(); - double d = value->NumberValue(context).ToChecked(); + // Not NumberValue: a user valueOf could call into Java and reuse the thread's key. + double d = value->IsNumberObject() ? value.As()->ValueOf() + : value.As()->Value(); int64_t i = (int64_t) d; bool isInteger = d == i; diff --git a/test-app/runtime/src/main/cpp/ObjectIdIndex.h b/test-app/runtime/src/main/cpp/ObjectIdIndex.h index d62dacfb6..9966e6944 100644 --- a/test-app/runtime/src/main/cpp/ObjectIdIndex.h +++ b/test-app/runtime/src/main/cpp/ObjectIdIndex.h @@ -10,8 +10,9 @@ namespace tns { /* - * Object id -> entry, as a radix tree indexed by id: chunks of leaves of slots. Ids come - * from a per-runtime monotonic counter and are never reused, so a leaf or chunk that + * Object id -> entry, as a radix tree indexed by id: chunks of leaves of slots. Ids are + * unsigned 32-bit values carried in an int, -1 meaning none; they come from a per-runtime + * monotonic counter and are never reused, so a leaf or chunk that * empties is freed unless it is the newest; an id linked after its leaf was freed just * re-creates it. Leaves and chunks are small, so a long-lived entry keeps at most one of * each alive. Not thread-safe: used under the isolate lock. @@ -20,10 +21,10 @@ template class ObjectIdIndex { public: T* Find(int id) const { - if (id < 0) { + if (id == kNoId) { return nullptr; } - size_t chunkIndex = static_cast(id) >> kChunkShift; + size_t chunkIndex = Key(id) >> kChunkShift; if (chunkIndex >= m_chunks.size()) { return nullptr; } @@ -32,15 +33,16 @@ class ObjectIdIndex { return nullptr; } const Leaf* leaf = chunk->leaves[LeafIndex(id)].get(); - return leaf == nullptr ? nullptr : leaf->slots[id & kLeafMask]; + return leaf == nullptr ? nullptr : leaf->slots[Key(id) & kLeafMask]; } - // Keeps an existing entry for id, like std::unordered_map::emplace. - void Insert(int id, T* value) { - if (id < 0) { - return; + // Keeps an existing entry for id, like std::unordered_map::emplace; returns whether value + // was inserted. + bool Insert(int id, T* value) { + if (id == kNoId) { + return false; } - size_t chunkIndex = static_cast(id) >> kChunkShift; + size_t chunkIndex = Key(id) >> kChunkShift; if (chunkIndex >= m_chunks.size()) { m_chunks.resize(chunkIndex + 1); } @@ -53,25 +55,27 @@ class ObjectIdIndex { leaf = std::make_unique(); chunk->liveLeaves++; } - T*& slot = leaf->slots[id & kLeafMask]; - size_t leafNumber = static_cast(id) >> kLeafBits; + T*& slot = leaf->slots[Key(id) & kLeafMask]; + size_t leafNumber = Key(id) >> kLeafBits; if (leafNumber > m_newestLeaf) { size_t previous = m_newestLeaf; m_newestLeaf = leafNumber; // Kept while it was the newest; nothing else frees it once it is empty. ReleaseIfEmpty(previous); } - if (slot == nullptr) { - slot = value; - leaf->live++; + if (slot != nullptr) { + return false; } + slot = value; + leaf->live++; + return true; } void Erase(int id) { - if (id < 0) { + if (id == kNoId) { return; } - size_t chunkIndex = static_cast(id) >> kChunkShift; + size_t chunkIndex = Key(id) >> kChunkShift; if (chunkIndex >= m_chunks.size() || m_chunks[chunkIndex] == nullptr) { return; } @@ -80,13 +84,13 @@ class ObjectIdIndex { if (leaf == nullptr) { return; } - T*& slot = leaf->slots[id & kLeafMask]; + T*& slot = leaf->slots[Key(id) & kLeafMask]; if (slot == nullptr) { return; } slot = nullptr; leaf->live--; - size_t leafNumber = static_cast(id) >> kLeafBits; + size_t leafNumber = Key(id) >> kLeafBits; if (leafNumber != m_newestLeaf) { ReleaseIfEmpty(leafNumber); } @@ -149,8 +153,14 @@ class ObjectIdIndex { } } + static constexpr int kNoId = -1; + + static size_t Key(int id) { + return static_cast(id); + } + static size_t LeafIndex(int id) { - return (static_cast(id) >> kLeafBits) & ((1 << kChunkLeafBits) - 1); + return (Key(id) >> kLeafBits) & ((1 << kChunkLeafBits) - 1); } std::vector> m_chunks; diff --git a/test-app/runtime/src/main/cpp/ObjectManager.cpp b/test-app/runtime/src/main/cpp/ObjectManager.cpp index 1059f7ab8..164401677 100644 --- a/test-app/runtime/src/main/cpp/ObjectManager.cpp +++ b/test-app/runtime/src/main/cpp/ObjectManager.cpp @@ -21,6 +21,7 @@ using namespace tns; ObjectManager::ObjectManager(jobject javaRuntimeObject) : m_javaRuntimeObject(javaRuntimeObject), + m_isolate(nullptr), m_currentObjectId(0), m_cache(NewWeakGlobalRefCallback, DeleteWeakGlobalRefCallback, ValidateWeakGlobalRefCallback, 1000, this) { @@ -188,7 +189,7 @@ int ObjectManager::LinkedIdOf(const Local& object) { if (!field->IsUint32()) { return -1; } - return static_cast(field.As()->Value()) - 1; + return static_cast(field.As()->Value() - 1u); } void ObjectManager::SetJsInfo(const Local& object, uint32_t javaObjectID) { @@ -218,7 +219,7 @@ Local ObjectManager::GetJSInstanceInfoOwner(const Local& object) ObjectManager::JSInstanceInfo* ObjectManager::GetJSInstanceInfoFromRuntimeObject(const Local& object) { int id = LinkedIdOf(object); - if (id < 0) { + if (id == -1) { HandleScope handleScope(m_isolate); auto owner = GetJSInstanceInfoOwner(object); if (owner.IsEmpty()) { @@ -474,7 +475,11 @@ void ObjectManager::Link(const Local& object, uint32_t javaObjectID, SetJsInfo(object, javaObjectID); - m_idToObject.Insert(javaObjectID, state); + bool inserted = m_idToObject.Insert(javaObjectID, state); + // A second link for one id would leave this state's finalizer weakening the first + // wrapper's Java object. + NS_DCHECK(inserted); + (void) inserted; } bool ObjectManager::CloneLink(const Local& src, @@ -599,6 +604,12 @@ void ObjectManager::FlushFinalizedBatch() { if (m_pendingWeaken.empty() && m_pendingCheck.empty()) { return; } + // A failed Java->JS call returns with its exception pending: JNI may not be used until it + // is thrown, and the flush must not mistake it for its own failure and clear it. + if (JEnv().ExceptionCheck()) { + ScheduleFinalizedBatchFlush(); + return; + } HandleScope handleScope(m_isolate); auto weaken = std::move(m_pendingWeaken); diff --git a/test-app/runtime/src/main/cpp/ObjectManager.h b/test-app/runtime/src/main/cpp/ObjectManager.h index 6e1157188..5f94f1385 100644 --- a/test-app/runtime/src/main/cpp/ObjectManager.h +++ b/test-app/runtime/src/main/cpp/ObjectManager.h @@ -57,9 +57,9 @@ class ObjectManager { v8::Local GetJsObjectByJavaObject(int javaObjectID); - // Settles the wrappers collected since the last flush. Called on every JS<->Java - // crossing: JS cannot create or receive a Java object without one, so the Java objects - // still held strongly for collected wrappers cannot grow between a GC and the flush. + // Settles the wrappers collected since the last flush. Called on JS<->Java calls and + // constructions; field and array-element reads that hand JS a Java object skip it and + // rely on the delayed flush task. void FlushFinalizedBatchIfPending() { if (!m_flushBackingOff && (!m_pendingWeaken.empty() || !m_pendingCheck.empty())) { @@ -101,7 +101,9 @@ class ObjectManager { /* * Whether GetInternalField is safe to call on this exact object. False for * a Proxy even when its target qualifies; unwrap first where a Proxy should - * be accepted. + * be accepted. Any object with this many internal fields is read as a runtime + * object, so embedder code must never store an aligned pointer in field 0 of + * an object with 2 or 3 internal fields. */ static bool IsJsRuntimeObject(const v8::Local& object); @@ -121,7 +123,7 @@ class ObjectManager { // Whether a runtime object's JsInfo field holds a link. The field holds id + 1, so // the zero V8 stores in a typed array's embedder fields never reads as a link. static bool HasJsInfo(const v8::Local& object) { - return LinkedIdOf(object) >= 0; + return LinkedIdOf(object) != -1; } /* @@ -194,7 +196,8 @@ class ObjectManager { JSInstanceInfo* GetJSInstanceInfoFromRuntimeObject( const v8::Local& object); - // The id in a runtime object's JsInfo field, or -1. + // The id in a runtime object's JsInfo field (an unsigned 32-bit id carried in an int), + // or -1. static int LinkedIdOf(const v8::Local& object); void SetJsInfo(const v8::Local& object, uint32_t javaObjectID); diff --git a/test-app/runtime/src/main/cpp/Runtime.cpp b/test-app/runtime/src/main/cpp/Runtime.cpp index 7e6fec22f..93e7c9439 100644 --- a/test-app/runtime/src/main/cpp/Runtime.cpp +++ b/test-app/runtime/src/main/cpp/Runtime.cpp @@ -641,12 +641,12 @@ jobject Runtime::CallJSMethodNative(JNIEnv* _env, jobject obj, // javaObjectID may be a hint read without the isolate lock; resolve it exactly when it // does not name a linked wrapper. Local jsObject; - if (javaObjectID >= 0) { + if (javaObjectID != -1) { jsObject = m_objectManager->GetLinkedJsObjectByJavaObject(javaObjectID); } if (jsObject.IsEmpty() && javaObject != nullptr) { javaObjectID = m_objectManager->FindObjectId(javaObject); - if (javaObjectID < 0) { + if (javaObjectID == -1) { throw NativeScriptException("Cannot find object id for an instance of " + m_objectManager->GetClassName(javaObject)); } diff --git a/test-app/runtime/src/main/java/com/tns/ObjectTable.java b/test-app/runtime/src/main/java/com/tns/ObjectTable.java index 304bcded2..4962d3488 100644 --- a/test-app/runtime/src/main/java/com/tns/ObjectTable.java +++ b/test-app/runtime/src/main/java/com/tns/ObjectTable.java @@ -112,7 +112,7 @@ private int findIdUnlocked(Object obj) { // get() without the lock. Safe on a torn view: every array it reads is indexed within // its own bounds, and a stale one only makes the caller's stamp check fail. private Object getUnlocked(int id) { - if (id < 0) { + if (id == NO_ID) { return null; } Chunk[] cs = chunks; @@ -137,7 +137,8 @@ private static Object deref(Object value) { } /* - * id -> entry, as a radix tree indexed by id: chunks of leaves of slots. Ids come from a + * id -> entry, as a radix tree indexed by id: chunks of leaves of slots. Ids are unsigned + * 32-bit values carried in an int (NO_ID is the one excluded value); they come from a * monotonic counter and are never reused, so a leaf or chunk that empties is dropped * unless it is the newest; an id registered after its leaf was dropped re-creates it. * Leaves and chunks are small, so a long-lived object keeps at most one of each alive. @@ -231,11 +232,11 @@ long findOrRegister(Object obj, IdSource ids) { int hash = System.identityHashCode(obj); int id = findIdLocked(obj, hash); if (id != NO_ID) { - return id; + return id & 0xFFFFFFFFL; } id = ids.newId(); putStrongLocked(id, obj, hash); - return id | FRESH; + return (id & 0xFFFFFFFFL) | FRESH; } finally { stamps.endWrite(stamp); } @@ -426,7 +427,7 @@ private static final class WeakRef extends WeakReference { } private Leaf leafFor(int id) { - if (id < 0) { + if (id == NO_ID) { return null; } int c = id >>> CHUNK_SHIFT; diff --git a/test-app/runtime/src/main/java/com/tns/Runtime.java b/test-app/runtime/src/main/java/com/tns/Runtime.java index b29c528ec..461c936a8 100644 --- a/test-app/runtime/src/main/java/com/tns/Runtime.java +++ b/test-app/runtime/src/main/java/com/tns/Runtime.java @@ -181,9 +181,9 @@ public void run() { "Primitive types need to be manually wrapped in their respective Object wrappers.\n" + "If you are creating an instance of an inner class, make sure to always provide reference to the outer `this` as the first argument."; - // Mutated and read exactly only by the thread holding this runtime's isolate lock (the - // native side resolves and registers objects after taking it); other threads may only - // findIdOptimistic, whose result the native side re-checks. + // Any thread may read it optimistically and register new objects through findOrRegister + // (see ObjectTable); every other mutation and every exact read comes from the thread + // holding this runtime's isolate lock. private final ObjectTable objectTable; private final ObjectTable.IdSource objectIds = new ObjectTable.IdSource() { @@ -622,7 +622,7 @@ public void ResetDateTimeConfigurationCache() { // Only native code may call this, from the thread holding the isolate lock. @RuntimeCallable - void releaseNativeCounterpart(int nativeObjectId) { + public void releaseNativeCounterpart(int nativeObjectId) { objectTable.remove(nativeObjectId); } @@ -1151,27 +1151,38 @@ private int findJavaObjectID(Object obj) { // an id almost always has one; native asks for the class itself when it does not. @RuntimeCallable private long getOrCreateJavaObjectIDAndClass(Object obj) { - int id = objectTable.findId(obj); - if (id != ObjectTable.NO_ID) { - return id & 0xFFFFFFFFL; + long found = getOrCreateObjectId(obj); + if ((found & ObjectTable.FRESH) == 0) { + return found; } - id = generateNewObjectId(getRuntimeId()); - makeInstanceStrong(obj, id); - return packIdAndClass(id, obj); + return packIdAndClass((int) found, obj); } @RuntimeCallable private int getOrCreateJavaObjectID(Object obj) { - Integer result = getJavaObjectID(obj); - - if (result == null) { - int objectId = generateNewObjectId(getRuntimeId()); - makeInstanceStrong(obj, objectId); - - result = objectId; + long found = getOrCreateObjectId(obj); + if ((found & ObjectTable.FRESH) != 0) { + // Stores the class by name on its first registration. + classIndexOf(obj.getClass()); } + return (int) found; + } - return result; + // obj's id (low 32 bits), registering it strongly under a new id if it has none, with + // ObjectTable.FRESH set then. Threads registering call arguments (packageArgs) do not + // hold the isolate lock, so where they may, the lookup and insert are one atomic step. + private long getOrCreateObjectId(Object obj) { + // Exact: only the isolate-lock holder (this thread) removes entries. + int id = objectTable.findId(obj); + if (id != ObjectTable.NO_ID) { + return id & 0xFFFFFFFFL; + } + if (objectTable.allowsConcurrentRegistration()) { + return objectTable.findOrRegister(obj, objectIds); + } + id = generateNewObjectId(getRuntimeId()); + makeInstanceStrong(obj, id); + return (id & 0xFFFFFFFFL) | ObjectTable.FRESH; } public static Object callJSMethodFromPossibleNonMainThread(Object javaObject, String methodName, Class retType, Object... args) throws NativeScriptException { @@ -1475,7 +1486,7 @@ private Object dispatchCallJSMethodNative(final Object javaObject, int objectIdH // A hint: other threads may be writing the table, so native re-checks it under the // isolate lock and resolves the id itself on a miss. ret = callJSMethodNative(getRuntimeId(), javaObject, - objectIdHint >= 0 ? objectIdHint : objectTable.findIdOptimistic(javaObject), methodName, MethodNameIds.idOf(methodName), returnType, isConstructor, packagedArgs.count, packagedArgs.values, packagedArgs.refs, primitiveResult); + objectIdHint != ObjectTable.NO_ID ? objectIdHint : objectTable.findIdOptimistic(javaObject), methodName, MethodNameIds.idOf(methodName), returnType, isConstructor, packagedArgs.count, packagedArgs.values, packagedArgs.refs, primitiveResult); ret = takePrimitiveResult(ret, returnType, primitiveResult); } catch (NativeScriptException e) { // With multithreaded JS the owner can be torn down between the diff --git a/test-app/runtime/src/test/java/com/tns/ObjectTableTest.java b/test-app/runtime/src/test/java/com/tns/ObjectTableTest.java index 125f164f5..f2d88ae41 100644 --- a/test-app/runtime/src/test/java/com/tns/ObjectTableTest.java +++ b/test-app/runtime/src/test/java/com/tns/ObjectTableTest.java @@ -307,6 +307,34 @@ public void keepsSurvivorsOfReleasedRangesAndOldIdsRegisteredLate() { assertEquals(954, table.size()); } + @Test + public void idsPastIntMaxAreUnsigned() { + ObjectTable table = new ObjectTable(true); + Object a = new Object(); + Object b = new Object(); + table.putStrong(Integer.MIN_VALUE + 3, a); + + assertSame(a, table.get(Integer.MIN_VALUE + 3)); + assertEquals(Integer.MIN_VALUE + 3, table.findId(a)); + assertTrue(table.weakenAndCheckAlive(Integer.MIN_VALUE + 3)); + + long found = table.findOrRegister(b, new ObjectTable.IdSource() { + @Override + public int newId() { + return -7; + } + }); + assertTrue((found & ObjectTable.FRESH) != 0); + assertEquals(-7, (int) found); + assertEquals(-7, table.findId(b)); + assertEquals(-7L & 0xFFFFFFFFL, table.findOrRegister(b, null)); + + table.remove(-7); + assertNull(table.get(-7)); + assertEquals(ObjectTable.NO_ID, table.findId(b)); + assertEquals(1, table.size()); + } + @Test public void optimisticReadsMissWhenDisabled() { ObjectTable table = new ObjectTable(false); From a5c973b336cb48a810bdab8a80c9d080767dd43b Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Tue, 10 Nov 2026 19:58:14 -0300 Subject: [PATCH 39/42] fix: let only the instance JS is constructing take its object id While JS constructs a Java object, the native side publishes the id it reserved in Runtime.currentObjectId, and the binding's initInstance takes it. Any other binding instance whose initInstance ran in that window took the same id: an instance Java created there (by reflection, or a custom view inflated by the constructor) was registered under the outer id, and since bindings now carry their id, its callbacks ran on the outer object's JS. The class being constructed is now published with the id, and only an instance of exactly that class takes it, once; others get their own JS object as Java-created instances do. The scope also restored -1 instead of the enclosing construction's id when a callback made by a constructor constructed another object. It now restores the enclosing id and class. The class is written only when it changes, so constructing one class repeatedly costs the same two field writes as before. --- .../src/main/assets/app/tests/testMetadata.js | 24 ++++++++++ .../com/tns/tests/NestedConstruction.java | 17 +++++++ .../runtime/src/main/cpp/CallbackHandlers.cpp | 44 +++++++++++++++++-- .../runtime/src/main/cpp/CallbackHandlers.h | 19 ++------ test-app/runtime/src/main/cpp/Runtime.h | 20 +++++++++ .../src/main/java/com/tns/Runtime.java | 8 +++- 6 files changed, 112 insertions(+), 20 deletions(-) create mode 100644 test-app/app/src/main/java/com/tns/tests/NestedConstruction.java diff --git a/test-app/app/src/main/assets/app/tests/testMetadata.js b/test-app/app/src/main/assets/app/tests/testMetadata.js index 7b48f2ca0..91e2dc8d7 100644 --- a/test-app/app/src/main/assets/app/tests/testMetadata.js +++ b/test-app/app/src/main/assets/app/tests/testMetadata.js @@ -85,6 +85,30 @@ describe("Java object wrappers", function () { expect(function () { return impl.super; }).not.toThrow(); }); + it("leave an extended instance Java creates during a JS construction its own JS object", function () { + var Inner = java.lang.Object.extend({ toString: function () { return "inner"; } }); + var Outer = com.tns.tests.NestedConstruction.extend({ toString: function () { return "outer"; } }); + var outer = new Outer(new Inner().getClass(), null); + + expect(String(outer.toString())).toBe("outer"); + expect(String(outer.getCreated().toString())).toBe("inner"); + }); + + it("keep a JS construction's instance when a callback constructs another during it", function () { + var Inner = java.lang.Object.extend({ toString: function () { return "inner"; } }); + var Outer = com.tns.tests.NestedConstruction.extend({ toString: function () { return "outer:" + this.tag; } }); + var made = null; + var outer = new Outer(null, new java.lang.Runnable({ + run: function () { + made = new Inner(); + } + })); + outer.tag = "mine"; + + expect(String(outer.toString())).toBe("outer:mine"); + expect(String(made.toString())).toBe("inner"); + }); + it("hand back the same wrapper for the same Java object", function () { var builder = new java.lang.StringBuilder(); diff --git a/test-app/app/src/main/java/com/tns/tests/NestedConstruction.java b/test-app/app/src/main/java/com/tns/tests/NestedConstruction.java new file mode 100644 index 000000000..db3018249 --- /dev/null +++ b/test-app/app/src/main/java/com/tns/tests/NestedConstruction.java @@ -0,0 +1,17 @@ +package com.tns.tests; + +/** Creates objects while its own constructor runs, for the construction-window specs. */ +public class NestedConstruction { + private final Object created; + + public NestedConstruction(Class toCreate, Runnable during) throws Exception { + created = toCreate == null ? null : toCreate.newInstance(); + if (during != null) { + during.run(); + } + } + + public Object getCreated() { + return created; + } +} diff --git a/test-app/runtime/src/main/cpp/CallbackHandlers.cpp b/test-app/runtime/src/main/cpp/CallbackHandlers.cpp index aa35e1378..9a2b89c97 100644 --- a/test-app/runtime/src/main/cpp/CallbackHandlers.cpp +++ b/test-app/runtime/src/main/cpp/CallbackHandlers.cpp @@ -56,6 +56,9 @@ void CallbackHandlers::Init(Isolate *isolate) { CURRENT_OBJECTID_FIELD_ID = env.GetFieldID(RUNTIME_CLASS, "currentObjectId", "I"); NS_CHECK(CURRENT_OBJECTID_FIELD_ID != nullptr); + CURRENT_OBJECTCLASS_FIELD_ID = env.GetFieldID(RUNTIME_CLASS, "currentObjectClass", "Ljava/lang/Class;"); + NS_CHECK(CURRENT_OBJECTCLASS_FIELD_ID != nullptr); + REGISTER_CONSTRUCTED_INSTANCE_ID = env.GetMethodID(RUNTIME_CLASS, "registerConstructedInstance", "(Ljava/lang/Object;I)J"); NS_CHECK(REGISTER_CONSTRUCTED_INSTANCE_ID != nullptr); @@ -98,6 +101,41 @@ struct JavaCallScope { }; } // namespace +// Publishes the instance JS is constructing to Runtime.initInstance for the duration of the +// Java constructor, and restores the enclosing construction afterwards: JS run by a +// callback the constructor makes may construct another instance. Raw JNI, so that the +// destructor never throws. +class CallbackHandlers::ConstructionScope { +public: + ConstructionScope(Runtime* runtime, int javaObjectId, jclass clazz) + : _runtime(runtime), _previous(runtime->CurrentConstruction()) { + Publish({javaObjectId, clazz}); + } + + ~ConstructionScope() { + Publish(_previous); + } + +private: + // The class is written only when it changes: initInstance reads it only while an id is + // published, so a stale one is harmless, and repeated constructions of one class then + // cost a single field write each way. + void Publish(const Runtime::Construction& construction) { + _runtime->CurrentConstruction() = construction; + JNIEnv* env = JEnv(); + jobject javaRuntime = _runtime->GetJavaRuntime(); + jclass& published = _runtime->PublishedConstructionClass(); + if (construction.id != -1 && construction.clazz != published) { + env->SetObjectField(javaRuntime, CURRENT_OBJECTCLASS_FIELD_ID, construction.clazz); + published = construction.clazz; + } + env->SetIntField(javaRuntime, CURRENT_OBJECTID_FIELD_ID, construction.id); + } + + Runtime* _runtime; + Runtime::Construction _previous; +}; + bool CallbackHandlers::RegisterInstance(Isolate *isolate, const Local &jsObject, const std::string &fullClassName, const ArgsWrapper &argWrapper, @@ -129,12 +167,9 @@ bool CallbackHandlers::RegisterInstance(Isolate *isolate, const Local &j const auto &mi = MethodCache::ResolveConstructorSignature(argWrapper, fullClassName, generatedJavaClass, isInterface); - // while the "instance" is being created, if an exception is thrown during the construction - // this scope will guarantee the "javaObjectID" will be set to -1 and won't have an invalid value jobject instance; { - JavaObjectIdScope objIdScope(env, CURRENT_OBJECTID_FIELD_ID, runtime->GetJavaRuntime(), - javaObjectID); + ConstructionScope construction(runtime, javaObjectID, generatedJavaClass); if (argWrapper.type == ArgType::Interface) { instance = env.NewObject(generatedJavaClass, mi.mid); @@ -1948,6 +1983,7 @@ short CallbackHandlers::MAX_JAVA_STRING_ARRAY_LENGTH = 100; jclass CallbackHandlers::RUNTIME_CLASS = nullptr; jclass CallbackHandlers::JAVA_LANG_STRING = nullptr; jfieldID CallbackHandlers::CURRENT_OBJECTID_FIELD_ID = nullptr; +jfieldID CallbackHandlers::CURRENT_OBJECTCLASS_FIELD_ID = nullptr; jmethodID CallbackHandlers::RESOLVE_CLASS_METHOD_ID = nullptr; jmethodID CallbackHandlers::REGISTER_CONSTRUCTED_INSTANCE_ID = nullptr; jmethodID CallbackHandlers::GET_TYPE_METADATA = nullptr; diff --git a/test-app/runtime/src/main/cpp/CallbackHandlers.h b/test-app/runtime/src/main/cpp/CallbackHandlers.h index 2daee1714..f78380cdd 100644 --- a/test-app/runtime/src/main/cpp/CallbackHandlers.h +++ b/test-app/runtime/src/main/cpp/CallbackHandlers.h @@ -187,6 +187,10 @@ namespace tns { static jfieldID CURRENT_OBJECTID_FIELD_ID; + static jfieldID CURRENT_OBJECTCLASS_FIELD_ID; + + class ConstructionScope; + static jmethodID REGISTER_CONSTRUCTED_INSTANCE_ID; static jmethodID GET_TYPE_METADATA; @@ -201,21 +205,6 @@ namespace tns { static FieldAccessor fieldAccessor; - struct JavaObjectIdScope { - JavaObjectIdScope(JEnv &env, jfieldID fieldId, jobject runtime, int javaObjectId) - : _env(env), _fieldID(fieldId), _runtime(runtime) { - _env.SetIntField(_runtime, _fieldID, javaObjectId); - } - - ~JavaObjectIdScope() { - _env.SetIntField(_runtime, _fieldID, -1); - } - - private: - JEnv _env; - jfieldID _fieldID; - jobject _runtime; - }; static std::atomic_int64_t count_; diff --git a/test-app/runtime/src/main/cpp/Runtime.h b/test-app/runtime/src/main/cpp/Runtime.h index bc65fd99e..77913df69 100644 --- a/test-app/runtime/src/main/cpp/Runtime.h +++ b/test-app/runtime/src/main/cpp/Runtime.h @@ -173,6 +173,22 @@ class Runtime { return m_admissionGate; } + // The instance JS is constructing, as last published to the Java runtime's + // currentObjectId and currentObjectClass. + struct Construction { + int id = -1; + jclass clazz = nullptr; + }; + + Construction& CurrentConstruction() { + return m_construction; + } + + // What the Java runtime's currentObjectClass holds; only native code writes it. + jclass& PublishedConstructionClass() { + return m_publishedConstructionClass; + } + void RunModule(JNIEnv* _env, jobject obj, jstring scriptFile); void RunModule(const char *moduleName); void RunWorker(const std::string& filePath); @@ -340,6 +356,10 @@ class Runtime { AdmissionGate m_admissionGate; + Construction m_construction; + + jclass m_publishedConstructionClass = nullptr; + ModuleInternal m_module; ArrayBufferHelper m_arrayBufferHelper; diff --git a/test-app/runtime/src/main/java/com/tns/Runtime.java b/test-app/runtime/src/main/java/com/tns/Runtime.java index 461c936a8..aad8e893e 100644 --- a/test-app/runtime/src/main/java/com/tns/Runtime.java +++ b/test-app/runtime/src/main/java/com/tns/Runtime.java @@ -202,7 +202,12 @@ public int newId() { private final java.lang.Runtime dalvikRuntime = java.lang.Runtime.getRuntime(); private final Object keyNotFoundObject = new Object(); + // The id and class of the instance JS is constructing, set by the native side around + // the Java constructor call. Only an instance of exactly that class may take the id: any + // other binding instance created while that constructor runs was created by Java. The + // class is only meaningful while the id is set, and only native code writes it. private int currentObjectId = -1; + private Class currentObjectClass; private ExtractPolicy extractPolicy; @@ -930,7 +935,8 @@ public static void initInstance(Object instance) { int objectId = runtime.currentObjectId; - if (objectId != -1) { + if (objectId != -1 && instance.getClass() == runtime.currentObjectClass) { + runtime.currentObjectId = -1; runtime.makeInstanceStrong(instance, objectId); } else { objectId = runtime.createJSInstance(instance); From a85fa887a0c1922461284831e84f387715a5dcfd Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Tue, 10 Nov 2026 20:40:59 -0300 Subject: [PATCH 40/42] perf: pass a binding argument's own object id to a callback packageArgs probed ObjectTable's identity index for every object argument. A binding of the receiving runtime (a NativeScriptRuntimeBound whose tag matches) carries its object id, written before the tag and never reused, so it is passed as the hint instead; native resolves it like any other hint and falls back to the exact lookup when it no longer names a linked wrapper. The new benchmark row passes the callback object itself. --- .../assets/app/tests/benchmarks/jniBenchmarks.js | 3 +++ .../app/src/main/java/com/tns/tests/JniBench.java | 4 ++++ test-app/runtime/src/main/java/com/tns/Runtime.java | 13 ++++++++++++- 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/test-app/app/src/main/assets/app/tests/benchmarks/jniBenchmarks.js b/test-app/app/src/main/assets/app/tests/benchmarks/jniBenchmarks.js index d712d4cb2..07ec7d1a2 100644 --- a/test-app/app/src/main/assets/app/tests/benchmarks/jniBenchmarks.js +++ b/test-app/app/src/main/assets/app/tests/benchmarks/jniBenchmarks.js @@ -456,6 +456,9 @@ function buildBenchmarks() { }, function () { return measureCallback("callback.oneObject(new, 16 classes)", callback, 6, 20000); + }, + function () { + return measureCallback("callback.oneObject(binding)", callback, 7, 20000); } ]; } diff --git a/test-app/app/src/main/java/com/tns/tests/JniBench.java b/test-app/app/src/main/java/com/tns/tests/JniBench.java index c14b1605f..0a5a9b5bb 100644 --- a/test-app/app/src/main/java/com/tns/tests/JniBench.java +++ b/test-app/app/src/main/java/com/tns/tests/JniBench.java @@ -247,6 +247,10 @@ public static long runCallbacks(Callback cb, int kind, int n) { case 6: for (int i = 0; i < n; i++) cb.oneObject(newOfClass(i)); break; + case 7: + // The callback is itself a binding: the argument carries its object id. + for (int i = 0; i < n; i++) cb.oneObject(cb); + break; default: throw new IllegalArgumentException("kind " + kind); } diff --git a/test-app/runtime/src/main/java/com/tns/Runtime.java b/test-app/runtime/src/main/java/com/tns/Runtime.java index aad8e893e..e2bf3189d 100644 --- a/test-app/runtime/src/main/java/com/tns/Runtime.java +++ b/test-app/runtime/src/main/java/com/tns/Runtime.java @@ -1364,7 +1364,18 @@ private PackagedArgs packageArgs(Object... args) { refs = new Object[len]; } refs[i] = value; - int id = objectTable.findIdOptimistic(value); + int id = ObjectTable.NO_ID; + if (value instanceof NativeScriptRuntimeBound) { + // A binding of this runtime carries its id (written before the tag, never + // reused); native resolves it like an identity-index hint. + NativeScriptRuntimeBound bound = (NativeScriptRuntimeBound) value; + if (bound.getRuntimeTag__ns() == getRuntimeId() + 1) { + id = bound.getObjectId__ns(); + } + } + if (id == ObjectTable.NO_ID) { + id = objectTable.findIdOptimistic(value); + } long fresh = 0; if (id == ObjectTable.NO_ID && objectTable.allowsConcurrentRegistration()) { // Registered here rather than under the isolate lock, so threads calling From c118b09c9f5b801476c7d3e87f1972fc7b28f417 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Tue, 10 Nov 2026 20:40:59 -0300 Subject: [PATCH 41/42] perf: build wrappers of Java-created extended instances from their template When Java creates an instance of an extended class (an Activity, an inflated custom view, reflection), its JS object was an empty object from the shared wrapper template that then got a new prototype and an own `constructor`. It now comes from the extended class's instance template, already on its own map with the right prototype, like other wrappers, and without an own `constructor`. The new benchmark row instantiates an extended class from Java. --- .../assets/app/tests/benchmarks/jniBenchmarks.js | 11 +++++++++++ .../app/src/main/assets/app/tests/testMetadata.js | 1 + .../src/main/java/com/tns/tests/JavaCreated.java | 8 ++++++++ test-app/runtime/src/main/cpp/MetadataNode.cpp | 15 +++++++-------- test-app/runtime/src/main/cpp/MetadataNode.h | 7 +++++-- 5 files changed, 32 insertions(+), 10 deletions(-) create mode 100644 test-app/app/src/main/java/com/tns/tests/JavaCreated.java diff --git a/test-app/app/src/main/assets/app/tests/benchmarks/jniBenchmarks.js b/test-app/app/src/main/assets/app/tests/benchmarks/jniBenchmarks.js index 07ec7d1a2..4d3d1c4a2 100644 --- a/test-app/app/src/main/assets/app/tests/benchmarks/jniBenchmarks.js +++ b/test-app/app/src/main/assets/app/tests/benchmarks/jniBenchmarks.js @@ -417,6 +417,17 @@ function buildBenchmarks() { return new Extended(); }); }, + function () { + if (skipped("static.newInstance(extended class) [Java-created]")) return null; + var Created = java.lang.Object.extend({ toString: function () { return "created"; } }); + var createdClass = new Created().getClass(); + var JavaCreated = com.tns.tests.JavaCreated; + return measureJs("static.newInstance(extended class) [Java-created]", 10000, function (n) { + var o; + for (var i = 0; i < n; i++) o = JavaCreated.newInstance(createdClass); + return o; + }); + }, function () { return measureLifecycle("lifecycle JniBench.newObjectInJava()", 20000, function () { return JniBench.newObjectInJava(); diff --git a/test-app/app/src/main/assets/app/tests/testMetadata.js b/test-app/app/src/main/assets/app/tests/testMetadata.js index 91e2dc8d7..2aa96f01d 100644 --- a/test-app/app/src/main/assets/app/tests/testMetadata.js +++ b/test-app/app/src/main/assets/app/tests/testMetadata.js @@ -92,6 +92,7 @@ describe("Java object wrappers", function () { expect(String(outer.toString())).toBe("outer"); expect(String(outer.getCreated().toString())).toBe("inner"); + expect(Object.prototype.hasOwnProperty.call(outer.getCreated(), "constructor")).toBe(false); }); it("keep a JS construction's instance when a callback constructs another during it", function () { diff --git a/test-app/app/src/main/java/com/tns/tests/JavaCreated.java b/test-app/app/src/main/java/com/tns/tests/JavaCreated.java new file mode 100644 index 000000000..a4ae44fe2 --- /dev/null +++ b/test-app/app/src/main/java/com/tns/tests/JavaCreated.java @@ -0,0 +1,8 @@ +package com.tns.tests; + +/** Instantiates a class from Java, for benchmarks of objects JS did not construct. */ +public class JavaCreated { + public static Object newInstance(Class clazz) throws Exception { + return clazz.getDeclaredConstructor().newInstance(); + } +} diff --git a/test-app/runtime/src/main/cpp/MetadataNode.cpp b/test-app/runtime/src/main/cpp/MetadataNode.cpp index f485d073c..c6192455c 100644 --- a/test-app/runtime/src/main/cpp/MetadataNode.cpp +++ b/test-app/runtime/src/main/cpp/MetadataNode.cpp @@ -194,14 +194,13 @@ Local MetadataNode::CreateExtendedJSWrapper(Isolate* isolate, ObjectMana auto cacheData = GetCachedExtendedClassData(isolate, proxyClassName); if (cacheData.node != nullptr) { - extInstance = objectManager->GetEmptyObject(isolate); - extInstance->SetInternalField(static_cast(ObjectManager::MetadataNodeKeys::CallSuper), True(isolate)); - auto extdCtorFunc = Local::New(isolate, *cacheData.extendedCtorFunction); + // Starts on the extended class's initial map, with its prototype, and without + // running its constructor callback. auto context = Runtime::GetRuntime(isolate)->GetContext(); - extInstance->SetPrototype(context, extdCtorFunc->Get(context, V8StringConstants::GetPrototype(isolate)).ToLocalChecked()); - extInstance->Set(context, ArgConverter::ConvertToV8String(isolate, "constructor"), extdCtorFunc); - - SetInstanceMetadata(isolate, extInstance, cacheData.node); + if (cacheData.instanceTemplate->Get(isolate)->NewInstance(context).ToLocal(&extInstance)) { + extInstance->SetInternalField(static_cast(ObjectManager::MetadataNodeKeys::CallSuper), True(isolate)); + SetInstanceMetadata(isolate, extInstance, cacheData.node); + } } return extInstance; @@ -2012,7 +2011,7 @@ void MetadataNode::ExtendMethodCallback(const v8::FunctionCallbackInfoInstanceTemplate(), fullExtendedName, node); cache->ExtendedCtorFuncCache.emplace(fullExtendedName, cacheData); if (frame.check()) { diff --git a/test-app/runtime/src/main/cpp/MetadataNode.h b/test-app/runtime/src/main/cpp/MetadataNode.h index 03b99545e..8380285ef 100644 --- a/test-app/runtime/src/main/cpp/MetadataNode.h +++ b/test-app/runtime/src/main/cpp/MetadataNode.h @@ -293,14 +293,16 @@ class MetadataNode { struct ExtendedClassCacheData { ExtendedClassCacheData() : - extendedCtorFunction(nullptr), node(nullptr) { + extendedCtorFunction(nullptr), instanceTemplate(nullptr), node(nullptr) { } - ExtendedClassCacheData(const v8::Local& extCtorFunc, const std::string& _extendedName, MetadataNode* _node) + ExtendedClassCacheData(const v8::Local& extCtorFunc, const v8::Local& _instanceTemplate, const std::string& _extendedName, MetadataNode* _node) : extendedName(_extendedName), node(_node) { extendedCtorFunction = new v8::Persistent(v8::Isolate::GetCurrent(), extCtorFunc); + instanceTemplate = new v8::Persistent(v8::Isolate::GetCurrent(), _instanceTemplate); } v8::Persistent* extendedCtorFunction; + v8::Persistent* instanceTemplate; std::string extendedName; MetadataNode* node; }; @@ -385,6 +387,7 @@ class MetadataNode { } for (auto& entry : ExtendedCtorFuncCache) { delete entry.second.extendedCtorFunction; + delete entry.second.instanceTemplate; } } }; From a97a4e9b756c65341e42dffac7e385092448ffb0 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Tue, 10 Nov 2026 22:35:50 -0300 Subject: [PATCH 42/42] fix: keep a Java-created extended instance on its class's current prototype V8 copies instances of a template from a cached first instantiation, which keeps the prototype the extended class had then: after code reassigns the class's `prototype` property, Java-created instances still got the old one (the new spec fails without this). The wrapper now checks the class's current prototype and only sets it when it differs, which never happens unless the property was reassigned; a profile of the Java-created benchmark shows no SetPrototype calls. --- .../app/src/main/assets/app/tests/testMetadata.js | 13 +++++++++++++ test-app/runtime/src/main/cpp/MetadataNode.cpp | 10 ++++++++++ 2 files changed, 23 insertions(+) diff --git a/test-app/app/src/main/assets/app/tests/testMetadata.js b/test-app/app/src/main/assets/app/tests/testMetadata.js index 2aa96f01d..739917953 100644 --- a/test-app/app/src/main/assets/app/tests/testMetadata.js +++ b/test-app/app/src/main/assets/app/tests/testMetadata.js @@ -110,6 +110,19 @@ describe("Java object wrappers", function () { expect(String(made.toString())).toBe("inner"); }); + it("give a Java-created extended instance its class's current prototype", function () { + var Created = java.lang.Object.extend({ toString: function () { return "created"; } }); + var createdClass = new Created().getClass(); + com.tns.tests.JavaCreated.newInstance(createdClass); + var replacement = Object.create(Object.getPrototypeOf(Created.prototype)); + Created.prototype = replacement; + + var created = com.tns.tests.JavaCreated.newInstance(createdClass); + + expect(Object.getPrototypeOf(created)).toBe(replacement); + expect(String(created.toString())).toBe("created"); + }); + it("hand back the same wrapper for the same Java object", function () { var builder = new java.lang.StringBuilder(); diff --git a/test-app/runtime/src/main/cpp/MetadataNode.cpp b/test-app/runtime/src/main/cpp/MetadataNode.cpp index c6192455c..5691efcf7 100644 --- a/test-app/runtime/src/main/cpp/MetadataNode.cpp +++ b/test-app/runtime/src/main/cpp/MetadataNode.cpp @@ -198,6 +198,16 @@ Local MetadataNode::CreateExtendedJSWrapper(Isolate* isolate, ObjectMana // running its constructor callback. auto context = Runtime::GetRuntime(isolate)->GetContext(); if (cacheData.instanceTemplate->Get(isolate)->NewInstance(context).ToLocal(&extInstance)) { + // V8 may copy instances from a cached instantiation of the template, which keeps the + // prototype the class had then. This only matters if code reassigns the extended + // class's `prototype` property, which nothing in the runtime or core does; consider + // dropping the check. + auto extdCtorFunc = Local::New(isolate, *cacheData.extendedCtorFunction); + Local prototype; + if (extdCtorFunc->Get(context, V8StringConstants::GetPrototype(isolate)).ToLocal(&prototype) && + !prototype->StrictEquals(extInstance->GetPrototype())) { + extInstance->SetPrototype(context, prototype); + } extInstance->SetInternalField(static_cast(ObjectManager::MetadataNodeKeys::CallSuper), True(isolate)); SetInstanceMetadata(isolate, extInstance, cacheData.node); }