Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
[Java.Interop] Avoid JNIEnv::NewObject().
(This unexpectedly gigantic 2653 line patch brought to you by Isanity, Experience, and YOU DON'T KNOW, MAN! YOU WEREN'T THERE!) JNI, in its infinite wisdom, has two different mechanisms to create an instance of a Java object: the easy way, and the correct way. 1. The Easy Way: JNIEnv::NewObject() 2. The Correct Way: JNIEnv::AllocObject() + JNIEnv::CallNonvirtualVoidMethod() The easy way is JNIEnv::NewObject(class, constructor, args): give it the type, the constructor, and (optional) arguments, and you get back an object. The correct way is JNIEnv::AllocObject(), which _creates_ the object but DOES NOT RUN THE CONSTRUCTOR, followed by JNIEnv::CallNonvirtualVoidMethod(), which executes the constructor. Why does it matter? Leaky abstractions, and virtual method invocation from the constructor. Just as with C#, and unlike C++, in Java virtual methods always resolve to the most derived implementation: // C# class B {public B() {Console.WriteLine("B..ctor"); CalledFromConstructor();} public virtual void CalledFromConstructor() {}} class D : B {public D() {Console.WriteLine ("d..ctor");} public override void CalledFromConstructor() {Console.WriteLine("D.CalledFromConstructor");}} // ... new D(); The above prints: B..ctor D.CalledFromConstructor d..ctor Note that D.M()is invoked _before_ the D constructor executes! (In actuality _part_ of the D constructor executes _first_, but that's the part that ~immediately calls the B constructor. The only way to intervene in this chain is with member initializers, and "hacks" with constructor parameters.) The "problem" is that the same thing happens in Java, and if `D` has a native method implemented in C#, and JNIEnv::NewObject() is used, then things go all "funky," because the JNI method marshaler could be called before there's a C# instance to invoke the method upon: // Hypothetical D.CalledFromConstructorHandler impl partial class D { static void CalledFromConstructorHandler (IntPtr jnienv, IntPtr self) { var self = JavaVM.Current.GetObject<D>(n_self); self.CalledFromConstructor (); } } So our runtime behavior would be: C#: D..ctor(), eventually calls JNIEnv::NewObject() Java: B.<init>() Java: B.calledFromConstructor() Java: D.n_calledFromConstructor(); C#: D.CalledFromConstructorHandler() C#: JavaVM.GetObject(): no object registered At this point one of two things will happen: Option 1: D has a (JniReferenceSafeHandle, JniHandleOwnership) constructor: C#: JavaVM.GetObject() creates a new "dummy" D C#: D.CalledFromConstructorHandler() calls D.CalledFromConstructor(). The problem is that at the end of this, the "original" execution path is referencing _one_ 'D' instance, which is unrelated to the 'D' instance that D.CalledFromConstructorHandler() created. There are _two_ 'D' instances! Insanity quickly follows. Option 2: D doesn't have a (JniReferenceSafeHandle, JniHandleOwnership) constructor. C#: JavaVM.GetObject() throws an exception **BOOM** Becuase we don't have correct exception propogation implemented yet, this results in corrupting OpenJDK (we unwound native Java stack frames!), and the process later aborts: malloc: *** error for object 0x79d8c248: Non-aligned pointer being freed *** set a breakpoint in malloc_error_break to debug Stacktrace: at <unknown> <0xffffffff> at (wrapper managed-to-native) object.wrapper_native_0x3a47b05 (Java.Interop.JniEnvironmentSafeHandle,Java.Interop.JniReferenceSafeHandle) <IL 0x00092, 0xffffffff> at Java.Interop.JniEnvironment/Handles.NewLocalRef (Java.Interop.JniReferenceSafeHandle) [0x0001b] in /Users/Shared/Dropbox/Developer/Java.Interop/src/Java.Interop/Java.Interop/JniEnvironment.g.cs:881 at Java.Interop.JniReferenceSafeHandle.NewLocalRef () [0x00002] in /Users/Shared/Dropbox/Developer/Java.Interop/src/Java.Interop/Java.Interop/JniReferenceSafeHandle.cs:40 at Java.Interop.JavaVM.SetObjectSafeHandle<T> (T,Java.Interop.JniReferenceSafeHandle,Java.Interop.JniHandleOwnership) [0x00030] in /Users/Shared/Dropbox The above stacktrace is meaningless; the corruption happened long ago, and things are blowing up. So the problem with JNIEnv::NewObject() is that it implicitly requires creating "throwaway" instances, and very often you DO NOT WANT "throwaway" instances to be created! What's the fix then? JNIEnv::AllocObject(), which does NOT invoke the Java constructor. This allows us to (temporarily!) register the instance _during constructor execution_ so that JNI method marshalers can lookup the already created instance. This results in the saner control flow: C#: D..ctor(), eventually calls JNIEnv::AllocObject() C#: D instance is registered with AllocObject()'d handle. C#: JNIEnv::CallNonvirtualVoidMethod() invoked on B.<init>() Java: B.<init>() Java: B.calledFromConstructor() Java: D.n_calledFromConstructor(); C#: D.CalledFromConstructorHandler() C#: JavaVM.GetObject(): registered instance found, used. - execution returns back through B.<init>() to D..ctor() No hair is lost in the process. There is one "minor" problem here, though: Android. Specifically, Android prior to v3.0 Honeycomb doesn't properly support JNIEnv::AllocObject() + JNIEnv::CallNonvirtualVoidMethod(); it throws CloneNotSupportedException: https://code.google.com/p/android/issues/detail?id=13832 Using JNIEnv::NewObject() leads to insanity. Requiring JNIEnv::AllocObject() means things CANNOT work on Android < v3.0. The fix? Options! (Or don't support older Android versions...) Add a new JavaVMOptions.NewObjectRequired property: if True, then JNIEnv::NewObject() is used. If False (the default!), then JNIEnv::AllocObject() is used, sanity is retained, and everyone rejoices. (Yay.) (~150 lines to describe WHAT's being addressed!) How's this implemented? Through three sets of methods: 1. JniPeerInstanceMethods.StartCreateInstance() 2. JniPeerInstanceMethods.FinishCreateInstance() 3. JavaObject.SetSafeHandle(), JavaException.SetSafeHandle(). JniPeerInstanceMethods.StartCreateInstance() kicks things off: if JavaVMOptions.NewObjectRequired is True, then it's JNIEnv::NewObject(); if False, it's JNIEnv::AllocObject(). StartCreateInstance() returns the JNI handle. JniPeerInstanceMethods.FinishCreateInstance() does nothing if JavaVMOptions.NewObjectRequired is True; if false, then it calls JNIEnv::CallNonvirtualVoidMethod(). JavaObject.SetSafeHandle() and JavaException.SetSafeHandle() ties them all together: it takes the handle returned from JniPeerInstanceMethods.StartCreateInstance(), (optionally) registers the instance, and then (later) will unregister the instance. class SomeClass : JavaObject { public SomeClass(Args...) { using (SetSafeHandle ( JniPeerMembers.InstanceMethods.StartCreateInstance (JNI_SIGNATURE, GetType (), args...), JniHandleOwnership.Transfer)) { JniPeerMembers.InstanceMethods.FinishCreateInstance (JNI_SIGNATURE, this, args...); } } Flow of control (ideal case): 1. StartCreateInstance() calls JNIEnv::AllocObject() 2. SetSafeHandle() registers the handle::instance mapping, returns "Cleanup" instance. 3. FinishCreateInstance() calls JNIEnv::CallNonvirtualVoidMethod() to invoke the constructor. 4. "Cleanup".Dispose() unregisters the instance mapping from (2). Just as with JniPeerInstanceMethods.Call*Method(), JniPeerInstanceMethods.StartCreateInstance() and JniPeerInstanceMethods.FinishCreateInstance() are overloaded to generically marshal up to 16 parameters. Finally, CACHING: we need the appropriate jclass and jmethodID for the most derived subclass that we're creating. In Xamarin.Android these WERE NOT CACHED: they were looked up (and destroyed) when creating each instance of a type with an "Android Callable Wrapper" (generated Java "stub" type). Only jclass and constructor jmethodID instances for "normal" Java types were cached. JniPeerInstanceMethods DOES cache: it maintains a JniPeerInstanceMethods.SubclassConstructors mapping that tracks all encountered subclasses and their constructors, so that future instance creation doesn't need to relookup the jclass and jmethodID values.
- Loading branch information