Skip to content

createObject/new fast-path: kill exception-driven control flow + cache hot lookups#2800

Open
zspitzer wants to merge 1 commit into
lucee:7.1from
zspitzer:fastpath-createobject
Open

createObject/new fast-path: kill exception-driven control flow + cache hot lookups#2800
zspitzer wants to merge 1 commit into
lucee:7.1from
zspitzer:fastpath-createobject

Conversation

@zspitzer

Copy link
Copy Markdown
Member

Summary

Removes two exception-driven control-flow anti-patterns from the
createObject("java", ...) / new SomeJavaClass() paths and adds several
hot-path caches. Profiled on Lucee 7.1 with a 2M-iteration
createObject / new java.lang.StringBuilder() bench under JFR
(jfc-soak.jfc).

Throws eliminated

  • new SomeJavaClass() was throwing
    ExpressionException: "file or directory [some/java/Class.cfc] does not exist"
    on every call. ComponentLoader._search calls
    ResourceUtil.toResourceExisting(..., defaultValue) to "safely" probe
    for the CFC file — the inner implementation throws when the file
    doesn't exist and the outer catches and returns the default. Refactored
    ResourceUtil.toResourceExisting family to share a non-throwing
    _findExisting helper; the throw is now reserved for the genuinely
    no-defaultValue overload.
  • new SomeJavaClass( arg ) was also throwing
    ApplicationException: "can't convert [java.lang.Long] to [java.lang.CharSequence]"
    for each non-matching constructor overload during selection.
    Reflector._convert threw on no-match, and Clazz.getConstructor /
    getMethod caught and skipped to the next overload. Introduced a
    static Reflector.UNCONVERTIBLE sentinel and convertSafe method
    so overload selection iterates via return value rather than exception.
    Behaviour of the throwing public _convert unchanged.

In the bench, JFR-captured exception throws inside the hot loop dropped
from 6,011,340 to 0.

Caches added (all keyed via JDK ClassValue where appropriate)

  • OSGiUtil.isClassInBootelegation / isPackageInBootelegation — was
    walking the boot-delegation list with String.substring /
    String.startsWith on every call. Boot-delegation list is loaded
    once and never changes, so the result is now cached in a
    ConcurrentHashMap<String, Boolean>.
  • ConfigImplgetRPCClassLoader(reload, js) was rebuilding a
    composite cache key (HashUtil.create64BitHashAsString + Long.toString)
    on every call before dispatching to the existing factory cache. Added
    a volatile ClassLoader defaultRpcClassLoader field that short-circuits
    when js == null || js == getJavaSettings(). Invalidated in
    clearRPCClassLoader() and resetJavaSettings().
  • ReflectorClassValue<Class[]> INTERFACES_CACHE replaces direct
    Class.getInterfaces() calls in both _checkInterfaces overloads.
    The JDK clones the interfaces array per call; ClassValue is the
    purpose-built mechanism and survives class unload correctly.
  • ClassUtilClassValue<Constructor> NO_ARG_CONSTRUCTOR caches the
    resolved no-arg constructor per class. ClassValue<ConcurrentHashMap<Class, Constructor>> ONE_ARG_CONSTRUCTOR_CACHE caches single-arg lookups
    keyed by args[0].getClass(). Cache hits convert args[0] explicitly
    via Reflector.convertSafe against the cached parameter type, then
    call Constructor.newInstance directly — bypassing the
    Reflector.getConstructorInstance + Clazz.getConstructor iteration.
  • JavaProxyConcurrentHashMap<ClassLoader, ConcurrentHashMap<String, Class<?>>> CLASS_CACHE for resolved class lookups when no
    path/bundleName is supplied. Helper tryCachedLoad /
    cacheClassLookup exposed so _CreateComponent.loadClass shares the
    same cache on the new path.

Other

  • _CreateComponent — when path starts with java. / javax. /
    jakarta. / sun. / jdk. / com.sun. / org.w3c. / org.xml.,
    skip the ComponentLoader.searchComponent traversal entirely. These
    paths are unambiguously Java FQNs; the search would walk every
    component mapping and find nothing. Only applies when no explicit
    type:cfml is requested.
  • ClassUtil.checkPrimaryTypes — rewritten with length-based fast
    exits and equalsIgnoreCase instead of toLowerCase() + equals.
    Avoids allocating two intermediate strings per call for any className
    that isn't a primary-type candidate.
  • ClassUtil.loadClass(ClassLoader, String)new HashSet<Throwable>()
    deferred to the failure path. Cosmetic — C2 was already
    escape-analysing this away.

Bug surfaced during the work

Clazz.getConstructor(args, convertArgument=true, convertComparsion=true)
mutates the input args[] array in place during the convertComparsion
loop. The method only returns the resolved Constructor, but
ConstructorInstance.invoke() calls constr.newInstance(args) after
the mutation, relying on the side effect for type coercion. Caching
the resolved Constructor bypasses the mutation; the 1-arg cache works
around this by passing a copy during the lookup and converting args
explicitly per call from the cached paramTypes[0].

Throughput change (2M iterations, JIT-warm, OpenJDK 21)

Pattern Before After
createObject( "java", X ) 1.68M ops/sec 16.8M ops/sec
createObject( "java", X ).init() 1.34M 10.0M
new java.lang.StringBuilder() 81k 17.9M
new java.lang.StringBuilder( 16 ) 61k 13.6M

Per-call cost: ~1.32μs → ~56–60ns on the non-init paths. Whole bench
wallclock: 62s → 3s.

Risk notes for review

  • The jakarta.* short-circuit in _CreateComponent.isJavaFqn could
    collide with a user-mapped CFC in jakarta.*. The java.* / javax.*
    / sun.* / jdk.* / com.sun.* entries are JDK-internal and risk-free.
    Happy to drop the wider entries or gate behind a sysprop if the
    collision risk is worth eliminating.
  • JavaProxy.CLASS_CACHE holds a strong reference to the ClassLoader as
    the outer key. Cleared via ConfigImpl.clearRPCClassLoader() paths
    indirectly (new classloader → new cache entry; old loader remains
    cached until invalidated). Production may want explicit eviction.
  • The 1-arg constructor cache keys on args[0].getClass() only. For
    classes with multiple overloads at the same arity where rating
    tiebreaks decide, the cached Constructor could pick the wrong one
    on a tied rating. Bench's StringBuilder(int) vs
    StringBuilder(CharSequence) vs StringBuilder(String) with arg type
    Long resolves deterministically; edge cases with two equally-rated
    candidates would need additional logic (probably caching null for
    ambiguous and falling back).
  • Third Reflector.convert call site in Clazz.getMethod
    cached-dispatch (lines ~365–376) still uses try/catch. Not on the
    constructor hot path; safe to leave for follow-up.

Test plan

  • Local mvn test (full suite, ~4:37 min): 0 failures, 0 errors,
    BUILD SUCCESS
    . test.functions.createObject — 33 tests passed
    in 24.5s.
  • Local 2M-iteration createObject / new benchmark (above
    throughput table)
  • JFR confirmation that loop throws dropped 6,011,340 → 0
  • Reviewer to verify Reflector edge cases hold:
    UDF→FunctionalInterface proxies, ObjectWrap unwrapping, Pojo
    conversions, primitive-vs-reference paths through
    _convertOrSentinel
  • CI test suite green across all matrix jobs

…, add class/ctor/interfaces/bootdelegation caches, FQN short-circuit
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant