C# on the Java Virtual Machine — Performance Comparison### Introduction
C# and Java both target managed runtime environments, but historically each has its own runtime: the .NET CLR for C# and the Java Virtual Machine (JVM) for Java. Recently, several projects and tools have made it possible to run C# code on the JVM. This article compares performance characteristics of running C# on the JVM versus running native C# on a .NET runtime and versus equivalent Java code on the JVM. It explains key factors that affect performance, summarizes common interoperability and compilation approaches, presents benchmark patterns and pitfalls, and gives practical recommendations.
How C# can run on the JVM — approaches overview
There are three primary approaches to executing C# on the JVM:
-
Ahead-of-time (AOT) transpilation to JVM bytecode. Tools translate C# (and sometimes IL) into Java bytecode so it runs directly on a JVM. Examples: IKVM.NET historically transpiled Java to .NET; reverse projects or custom transpilers aim to translate C# to Java bytecode.
-
Runtime bytecode interpretation or hybrid runtimes. A runtime interprets C# IL and maps it to JVM operations either by interpretation or by dynamic translation.
-
Source-level transpilation to Java. C# source is converted to Java source, then compiled by a standard Java compiler.
Each approach differs in fidelity to C# semantics (language features, reflection, dynamic code emission) and in performance trade-offs.
Performance factors to consider
Performance differences depend on multiple layers and interactions:
- Language and library mapping: how efficiently .NET APIs map onto JVM equivalents (collections, threading, I/O, LINQ, async/await).
- Type system and runtime representation: layout of objects, value types (structs) vs Java primitives/objects, boxing/unboxing costs.
- Garbage collector (GC) behavior: JVM GC tuning, generational vs server vs low-latency collectors versus .NET GC characteristics.
- JIT compilation strategies: HotSpot’s JIT vs GraalVM vs .NET’s RyuJIT; warm-up behavior and tiered compilation affect throughput and latency.
- Dynamic features: reflection, dynamic code generation (Expression trees, System.Reflection.Emit) and how they are implemented on JVM.
- Interop overhead: calls between translated C# and native Java libraries (or vice versa) may incur marshaling or bridging costs.
- Memory layout and object headers: differences in object header sizes, pointer compression, and object alignment affect footprint and cache behavior.
- Threading model and synchronization primitives: mapping of C# synchronization (lock, Monitor) to JVM monitors and the impact on contention.
Typical microbenchmark patterns & expected outcomes
Microbenchmarks expose specific overheads; real applications may differ.
- CPU-bound, tight loops with primitives
- If C# value types map efficiently to JVM primitives or boxed minimally, performance can be similar to Java.
- However, if frequent boxing occurs (e.g., using generics with value types poorly mapped), JVM code can be slower due to extra allocations and GC pressure.
- JIT behavior: HotSpot and GraalVM are highly optimized for numeric loops; translated bytecode that resembles idiomatic Java benefits similarly.
- Object allocation and GC-heavy workloads
- JVM GC implementations (G1, Shenandoah, ZGC) are mature and may outperform .NET in some allocation patterns; conversely, .NET’s server GC excels with many short-lived objects.
- Translated runtimes that produce extra temporary objects (due to emulation of .NET constructs) will suffer higher GC overhead.
- Reflection, dynamic invocation, and code generation
- .NET’s reflection and dynamic code gen are powerful; on JVM translated implementations often emulate these via wrappers or by using invokedynamic. Emulation can be slower than native .NET or native Java equivalents.
- Expression trees and System.Reflection.Emit often perform poorly when emulated, increasing startup cost and degrading long-running performance if used frequently.
- I/O and concurrency
- For I/O-bound workloads, much depends on how .NET async/await maps to JVM concurrency primitives and whether native Java NIO is used. Proper mapping can yield comparable performance; naive mappings cause thread explosion or blocking behavior.
- Thread pooling semantics and scheduling differences may affect latency and throughput.
Real-world benchmark case studies (summary)
Note: results vary wildly by project, toolchain version, JVM/CLR versions, OS, and benchmark specifics. The following are generalized observations drawn from community reports and experiments:
- CPU-heavy numerical code: Java on JVM and well-translated C# both perform similarly when the translation produces straightforward JVM bytecode without extra allocations.
- Collections and LINQ-style heavy allocations: Native C# on .NET often wins if LINQ operators are implemented with low-allocation iterators; translated implementations that convert LINQ into object-heavy patterns run slower.
- Startup time: JVM warm-up can make short-running translated C# programs slower to reach peak performance compared to .NET Native/AOT builds; GraalVM native-image can mitigate this but changes trade-offs.
- Interop with platform libraries: Running C# on its native runtime (.NET) usually has easier, faster access to .NET ecosystem libraries. On JVM, crossing boundaries to use Java libraries adds bridging overhead but can still be efficient for many use cases.
- Memory footprint: Some translation layers increase object count and retain additional metadata, increasing footprint compared to native .NET or idiomatic Java.
Benchmark methodology recommendations
To compare fairly, follow these guidelines:
- Use realistic workloads that reflect your application (not just microbenchmarks).
- Run on the same hardware, OS, and under the same JVM/.NET versions tuned appropriately.
- Warm up thoroughly to allow JIT optimizations to stabilize (or use tiered compilation controls).
- Measure both throughput and latency (p99/p95) and memory usage.
- Use proper benchmarking harnesses: JMH for JVM-based code, BenchmarkDotNet for .NET. When benchmarking translated C# on JVM, run the translated artifact under JMH if possible.
- Isolate GC effects and measure allocation rates.
- Profile hotspots to understand whether performance issues come from translation, GC, or algorithmic differences.
Common pitfalls when moving C# to the JVM
- Assuming one-to-one semantic equivalence: differences in value type semantics, finalization, and exception handling can create subtle behavior changes or performance regressions.
- Ignoring allocation patterns: translations that convert structs to boxed objects cause GC pressure.
- Overlooking async/await mapping: naive thread-per-async implementations kill scalability.
- Using Reflection.Emit or heavy dynamic code: emulation is often costly.
- Not tuning the target runtime: JVM flags (GC, heap sizing, JIT settings) and .NET GC modes materially affect results.
Practical recommendations
- For high-performance needs, prefer idiomatic code for the target runtime: if targeting JVM, adopt Java/JVM-friendly data structures and concurrency primitives rather than blindly porting .NET idioms.
- Profile early: find hotspots and allocation sources, then optimize translation boundaries rather than guessing.
- Where possible, avoid heavy dynamic code patterns or provide alternative implementations when running on the JVM.
- Consider hybrid architecture: keep performance-critical components native to .NET and expose services over RPC or use shared libraries for hot paths.
- When portability is the goal, evaluate costs: if interoperability with Java ecosystem is required, weigh developer productivity vs performance hit of translation.
Conclusions
Running C# on the JVM is feasible and in many cases can deliver acceptable performance, but outcomes depend on the translation approach, how idiomatic the translated code is for the JVM, and how runtime features are mapped. For CPU-bound, allocation-light code, performance can be close to native Java or .NET. For workloads that rely heavily on value types, dynamic features, or produce many short-lived objects, native runtimes often have an advantage. Careful benchmarks, tuning, and sometimes rewriting hotspots to be JVM-friendly are necessary steps to achieve competitive performance.