The term COM object is frequently thrown around, but people really access interfaces off of objects. The objects themselves are hidden. Managed is quite a bit different since interfaces are part of the system, but people can also access objects directly. When COM objects are accessed from managed, they are wrapped by a managed object called a runtime callable wrapper (RCW). The type of this runtime callable wrapper is either System.__ComObject or a strongly typed derived type as discussed here.
Instead of dealing with objects at an interface level, the CLR attempts to map back to the actual COM object being wrapped. We rely on a fundamental principle of COM that if you are given two interface pointers and QueryInterface() each of them explicitly for IID_IUnknown, they are required to give back an identical value if they in fact refer to the same object. This is basically the canonical IUnknown for a COM object and creates the basis of identity in COM.
In managed, we have a per app domain (please see any CLR reference for a definition of app domain) cache mapping canonical IUnknowns back to RCWs. When an IUnknown enters the system (through a marshal call, through activation, as a return parameter from a method call, etc.), we check the cache to see if an RCW already exists for the COM object. If a mapping exists, a reference to the existing RCW is returned. Otherwise a new RCW is created and a cache mapping is added.
Many people don’t understand that RCWs are shared and that anything that is done to the interface pointers wrapped by the RCW or RCW wide operations like System.Runtime.InteropServices.Marshal.ReleaseComObject() affect all users of the RCW. If people explicitly want a new RCW to be created even if a mapping already exists in the cache, they should call System.Runtime.InteropServices.Marshal.GetUniqueObjectForIUnknown(). However, they need to understand that RCWs are expensive so there is a good reason why we do this sharing.
Once an RCW is created, it can be cast to any interface that can be reached through QueryInterface() on the underlying COM object. Under the covers, we keep a small stash of interface pointers for each interface that has been accessed up to the size of the cache. Once the cache is full, any additional calls on new interfaces will result in a QueryInterface() on each method call. This is pretty important because some operations (like CoSetProxyBlanket) have affects on a given interface pointer and will not work with RCWs since we might generate a new interface pointer on the fly per call.
It is also important to remember that interface pointers have COM apartment / COM+ activity affinity and need the help of the Global Interface Table or CoMarshalInterThreadInterfaceInStream() to cross boundaries. RCWs shield users from this by doing all of the gymnastics to make this work behind the scenes for the user. This is both a good and bad thing. RCWs are most efficient when accessed from the apartment from which they were created; however, it is important to remember that the RCW might be getting pulled from the app domain wide cache (and the use case that caused the RCW to be created could be in a different apartment and cause unexpected performance sapping apartment transitions).
Unfortunately, even though RCWs are managed objects, most of the functionality for them is buried in the native implementation of the CLR virtual machine so there isn’t a lot of end user transparency into how they operate. If you have any questions about additional RCW or other Interop issues that you would like to see addressed, please don’t hesitate to email me.