深入分析java與C#底層控制能力區別及示例詳解

大傢好,我是辣條。

刷到瞭一個很有意思的問題,Java和C#最大的不同是什麼,辣條對Java和C#都沒有研究的特別深,但是下面這個回答可供大傢參考,同時歡迎大傢在評論留下自己的看法。

我覺得拋開語法而談,最主要的還是對底層的控制能力不同。

比如在 C# 裡面你能幹的

var x = new int[10];
fixed (int* p = x)
{
    Console.WriteLine(*((long*)p - 1)); // 10
}

上述代碼會輸出 10,為什麼?因為 .NET 中數組的長度存儲於數組第一個元素之前的 8 字節內存中。如果你再接著輸出 *((long*)p - 2),將會直接得到這個對象的 TypeHandle 地址:

Console.WriteLine((long)typeof(int[]).TypeHandle.Value == *((long*)p - 2)); // True

然後拿著這個指針又接著能去訪問對象的 MethodTable

再有你還可以手動在棧上分配空間

var x = stackalloc int[2]; // 或者 Span<int> x = stackalloc int[2]; 做安全訪存
x[0] = 3;
x[1] = 1;
Console.WriteLine(x[0] + x[1]); // 4

接著你想繞過 GC 直接手動分配堆內存

var array = (int*)NativeMemory.Alloc(10, sizeof(int));
array[0] = 1;
array[1] = 3;
Console.WriteLine(array[0] + array[1]); // 4
NativeMemory.Free(array);

上述調用等價於你在 C 語言中調用的 malloc,此外還有 AllocAlignedReallocAllocZeroed 等等,可以直接控制內存對齊。

接下來你想創建一個顯式內存佈局的結構 Foo

var obj = new Foo();
obj.Float = 1;
Console.WriteLine(obj.Int); // 1065353216
Console.WriteLine(obj.Bytes[0]); // 0
Console.WriteLine(obj.Bytes[1]); // 0
Console.WriteLine(obj.Bytes[2]); // 128
Console.WriteLine(obj.Bytes[3]); // 63
[StructLayout(LayoutKind.Explicit)]
struct Foo
{
    [FieldOffset(0)] public int Int;
    [FieldOffset(0)] public float Float;
    [FieldOffset(0)] public unsafe fixed byte Bytes[4];
}

然後你就成功模擬出瞭一個 C 的 Union,之所以會有上面的輸出,是因為單精度浮點數 1 的二進制表示為 0x00111111100000000000000000000000,以小端方式存儲後占 4 個字節,分別是 0x000000000x000000000x100000000x00111111

進一步,你還能直接從內存數據沒有任何拷貝開銷地構造對象:

var data = stackalloc byte[] { 0, 0, 128, 63 };
var foo = Unsafe.AsRef<Foo>(data);
Console.WriteLine(foo.Float); // 1
[StructLayout(LayoutKind.Explicit)]
struct Foo
{
    [FieldOffset(0)] public int Int;
    [FieldOffset(0)] public float Float;
    [FieldOffset(0)] public unsafe fixed byte Bytes[4];
}

甚至這樣:

var data = 1065353216;
var foo = Unsafe.AsRef<Foo>(&data);
Console.WriteLine(foo.Float); // 1 
[StructLayout(LayoutKind.Explicit)]
struct Foo
{
    [FieldOffset(0)] public int Int;
    [FieldOffset(0)] public float Float;
    [FieldOffset(0)] public unsafe fixed byte Bytes[4];
}

從堆內存創建自然也沒問題

var data = new byte[] { 0, 0, 128, 63 };
fixed (void* p = data)
{
    var foo = Unsafe.AsRef<Foo>(p);
    Console.WriteLine(foo.Float); // 1
}
[StructLayout(LayoutKind.Explicit)]
struct Foo
{
    [FieldOffset(0)] public int Int;
    [FieldOffset(0)] public float Float;
    [FieldOffset(0)] public unsafe fixed byte Bytes[4];
}

再比如,此時你面前有一個使用 C++ 編寫的庫,其中有這麼一段代碼:

#include <cstring>
#include <cstdio>
extern "C" __declspec(dllexport)
char* __cdecl foo(char* (*gen)(int), int count) {
    return gen(count);
}

然後我們編寫如下 C# 代碼:

[DllImport("./foo.dll", EntryPoint = "foo"), SuppressGCTransition]
static extern string Foo(delegate* unmanaged[Cdecl]<int, nint> gen, int count);
[UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) }), SuppressGCTransition]
static nint Generate(int count)
{
    var str = Enumerable.Repeat("w", count).Aggregate((a, b) => $"{a}{b}");
    return Marshal.StringToHGlobalAnsi(str);
} 
var f = (delegate* unmanaged[Cdecl]<int, nint>)&Generate;
var result = Foo(f, 5);
Console.WriteLine(result); // wwwww

上面的代碼幹瞭什麼事情?我們將 C# 的函數指針傳到瞭 C++ 代碼中,然後在 C++ 側調用 C# 函數生成瞭一個字符串 wwwww,然後將這個字符串返回給 C# 側。而就算不用函數指針換成使用委托也沒有區別,因為 .NET 中的委托下面就是函數指針。

甚至,如果我們不想讓 .NET 導入 foo.dll

我們想自行決定動態庫的生命周期

還可以這麼寫:

[UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) }), SuppressGCTransition]
static nint Generate(int count)
{
    var str = Enumerable.Repeat("w", count).Aggregate((a, b) => $"{a}{b}");
    return Marshal.StringToHGlobalAnsi(str);
}
var f = (delegate* unmanaged[Cdecl]<int, nint>)&Generate;
var library = NativeLibrary.Load("./foo.dll");
var foo = (delegate* unmanaged[Cdecl, SuppressGCTransition]<delegate* unmanaged[Cdecl]<int, nint>, int, string>)NativeLibrary.GetExport(library, "foo");
var result = foo(f, 5);
Console.WriteLine(result); // wwwww
NativeLibrary.Free(library);

上面這些都不是 Windows 專用,在 Linux、macOS 上導入 .so.dylib 都完全不在話下。

再有,我們有一些數據想要進行計算,但是我們想使用 SIMD 進行處理,那隻需要這麼寫:

var vec1 = Vector128.Create(1.1f, 2.2f, 3.3f, 4.4f);
var vec2 = Vector128.Create(5.5f, 6.6f, 7.7f, 8.8f);
Console.WriteLine(Calc(vec1, vec2));
float Calc(Vector128<float> l, Vector128<float> r)
{
    if (Avx2.IsSupported)
    {
        var result = Avx2.Multiply(vec1, vec2);
        float sum = 0;
        for (var i = 0; i < Vector128<float>.Count; i++) sum += result.GetElement(i);
        return sum;
    }
    else if (Rdm.IsSupported)
    {
        var result = Rdm.Multiply(vec1, vec2);
        float sum = 0;
        for (var i = 0; i < Vector128<float>.Count; i++) sum += result.GetElement(i);
        return sum;
    }
    else
    {
        float sum = 0;
        for (int i = 0; i < Vector128<float>.Count; i++)
        {
            sum += l.GetElement(i) * r.GetElement(i);
        }
        return sum;
    }
}

可以看看在 X86 平臺上生成瞭什麼代碼:

vzeroupper	
vmovupd	xmm0, [r8]
vmulps	xmm0, xmm0, [r8+0x10]
vmovaps	xmm1, xmm0
vxorps	xmm2, xmm2, xmm2
vaddss	xmm1, xmm1, xmm2
vmovshdup	xmm2, xmm0
vaddss	xmm1, xmm2, xmm1
vunpckhps	xmm2, xmm0, xmm0
vaddss	xmm1, xmm2, xmm1
vshufps	xmm0, xmm0, xmm0, 0xff
vaddss	xmm1, xmm0, xmm1
vmovaps	xmm0, xmm1
ret

平臺判斷的分支會被 JIT 自動消除。但其實除瞭手動編寫 SIMD 代碼之外,前兩個分支完全可以不寫,而隻留下:

float Calc(Vector128<float> l, Vector128<float> r)
{
    float sum = 0;
    for (int i = 0; i < Vector128<float>.Count; i++)
    {
        sum += l.GetElement(i) * r.GetElement(i);
    }
    return sum;
}

因為現階段當循環邊界條件是向量長度時,.NET 會自動為我們做向量化並展開循環。

那麼繼續,我們還有refinout來做引用傳遞。

假設我們有一個很大的 struct,我們為瞭避免傳遞時發生拷貝,可以直接用 in 來做隻讀引用傳遞:

void Test(in Foo v) { }
struct Foo
{
    public long A, B, C, D, E, F, G, H, I, J, K, L, M, N;
}

而對於小的 struct,.NET 有專門的優化幫我們徹底消除掉內存分配,完全將 struct 放在寄存器中,例如如下代碼:

double Test(int x1, int y1, int x2, int y2)
{
    var p1 = new Point(x1, y1);
    var p2 = new Point(x2, y2);
    return GetDistance(p1, p2);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
double GetDistance(Point a, Point b)
{
    return Math.Sqrt((a.X - b.X) * (a.X - b.X) + (a.Y - b.Y) * (a.Y - b.Y));
}
struct Point
{
    public Point(int x, int y)
    {
        X = x; Y = y;
    }    
    public int X { get; set; }
    public int Y { get; set; }
}

上述代碼 GetDistance 考慮是個熱點路徑,因此我加 MethodImplOptions.AggressiveInlining 來指導 JIT 有保證地內聯此函數,最後為 Test 生成瞭如下的代碼:

vzeroupper	
sub	ecx, r8d
mov	eax, ecx
imul	eax, ecx
sub	edx, r9d
mov	ecx, edx
imul	edx, ecx
add	eax, edx
vxorps	xmm0, xmm0, xmm0
vcvtsi2sd	xmm0, xmm0, eax
vsqrtsd	xmm0, xmm0, xmm0
ret

全程沒有一句指令訪存,非常的高效。

我們還可以借用 ref 的引用語義來做原地更新

var vec = new Vector(10);
vec[2] = 5;
Console.WriteLine(vec[2]); // 5
ref var x = ref vec[3];
x = 7;
Console.WriteLine(vec[3]); // 7
class Vector
{
    private int[] _array;
    public Vector(int count) => _array = new int[count];
    public ref int this[int index] => ref _array[index];
}

甚至還能搭配指針和手動分配內存來使用

var vec = new Vector(10);
vec[2] = 5;
Console.WriteLine(vec[2]); // 5
ref var x = ref vec[3];
x = 7;
Console.WriteLine(vec[3]); // 7
unsafe class Vector
{
    private int* _memory;
    public Vector(uint count) => _memory = (int*)NativeMemory.Alloc(count, sizeof(int));
    public ref int this[int index] => ref _memory[index];
    ~Vector() => NativeMemory.Free(_memory);
}

C# 的泛型不像 Java 采用擦除,而是真真正正會對所有的類型參數特化代碼(盡管對於引用類型會共享實現采用運行時分發),這也就意味著能最大程度確保性能,並且對應的類型擁有根據類型參數大小不同而特化的內存佈局。還是上面那個 Point 的例子,我們將下面的數據 int 換成泛型參數 T,並做值類型數字的泛型約束:

double Test1(double x1, double y1, double x2, double y2)
{
    var p1 = new Point<double>(x1, y1);
    var p2 = new Point<double>(x2, y2);
    var result = GetDistanceSquare(p1, p2);
    return Math.Sqrt(result);
}
double Test2(int x1, int y1, int x2, int y2)
{
    var p1 = new Point<int>(x1, y1);
    var p2 = new Point<int>(x2, y2);
    var result = GetDistanceSquare(p1, p2);
    return Math.Sqrt(result);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
T GetDistanceSquare<T>(Point<T> a, Point<T> b) where T : struct, IBinaryNumber<T>
{
    return (a.X - b.X) * (a.X - b.X) + (a.Y - b.Y) * (a.Y - b.Y);
}
struct Point<T> where T : struct, IBinaryNumber<T>
{
    public Point(T x, T y)
    {
        X = x; Y = y;
    }
 
    public T X { get; set; }
    public T Y { get; set; }
}

無論是 Test1 還是 Test2,生成的代碼都非常優秀,不僅不存在任何的裝箱拆箱,甚至沒有任何的訪存操作:

' Test1
vzeroupper	
vsubsd	xmm0, xmm0, xmm2
vmovaps	xmm2, xmm0
vmulsd	xmm0, xmm0, xmm2
vsubsd	xmm1, xmm1, xmm3
vmovaps	xmm2, xmm1
vmulsd	xmm1, xmm1, xmm2
vaddsd	xmm0, xmm1, xmm0
vsqrtsd	xmm0, xmm0, xmm0
ret	
 
' Test2
vzeroupper	
sub	ecx, r8d
mov	eax, ecx
imul	eax, ecx
sub	edx, r9d
mov	ecx, edx
imul	edx, ecx
add	eax, edx
vxorps	xmm0, xmm0, xmm0
vcvtsi2sd	xmm0, xmm0, eax
vsqrtsd	xmm0, xmm0, xmm0
ret

接著講,我們有時候為瞭高性能想要臨時暫停 GC 的回收,隻需要簡單的一句:

GC.TryStartNoGCRegion(1024 * 1024 * 128);

就能告訴 GC 如果還能分配 128mb 內存那就不要做回收瞭,然後一段時間內以後的代碼我們盡管在這個預算內分配內存,任何 GC 都不會發生。甚至還能阻止在內存不夠分配的情況下進行阻塞式 Full GC:

GC.TryStartNoGCRegion(1024 * 1024 * 128, true);

代碼執行完瞭,最後的時候調用一句:

GC.EndNoGCRegion();

即可恢復 GC 行為。

除此之外,我們還能在運行時指定 GC 的模式來最大化性能:

GCSettings.LatencyMode = GCLatencyMode.Batch;
GCSettings.LatencyMode = GCLatencyMode.Interactive;
GCSettings.LatencyMode = GCLatencyMode.LowLatency;
GCSettings.LatencyMode = GCLatencyMode.NoGCRegion;
GCSettings.LatencyMode = GCLatencyMode.SustainedLowLatency;

更進一步,我們甚至可以直接將堆內存中的代碼執行,在 .NET 上自己造一個 JIT,直接從內存創建一塊可執行的區域然後往裡面塞一段代碼用來將兩個32位整數相加:

var kernel32 = NativeLibrary.Load("kernel32.dll");
var virtualProtectEx = (delegate* unmanaged[Cdecl, SuppressGCTransition]<nint, void*, nint, int, out int, bool>)NativeLibrary.GetExport(kernel32, "VirtualProtectEx");
var processHandle = Process.GetCurrentProcess().Handle;
Memory<byte> code = new byte[] {
    0x8d, 0x04, 0x11, // lea rax, [rcx+rdx]
    0xc3              // ret
}
using (var handle = code.Pin())
{
    virtualProtectEx(processHandle, handle.Pointer, code.Length, 0x40, out _);
    var f = (delegate*<int, int, int>)handle.Pointer;
    Console.WriteLine(f(2, 3)); // 5
}
virtualProtectEx = null;
NativeLibrary.Free(kernel32);

除此之外,C# 還有更多數不清的底層寫法來和操作系統交互,甚至利用 C# 的編譯器取消鏈接到自己的標準庫,直接用從 0 開始造基礎類型然後通過 NativeAOT 編譯出完全無 GC、能夠在裸機硬件上執行引導系統的 EFI 固件都是沒有問題的。

另外還有 ILGPU 讓你把 C# 代碼直接跑在 GPU 上面,以及跑在嵌入式設備上直接操作 I2C、PWM、GPIO 等等,就不再舉例子瞭。

而 C# 已經進瞭 roadmap 的後續更新內容:允許聲明引用字段、添加表達固定長度內存的類型、允許傳數組時消除數組分配、允許在棧上分配任何對象等等,無一不是在改進這些底層性能設施。

以上就是我認為的 C# 和 Java 最大的不同。

在 C# 中當你不需要上面這些的東西時,它們仿佛從來都不存在,允許動態類型、不斷吸收各種函數式特性、還有各種語法糖加持,簡潔度和靈活度甚至不輸 Python,非常愉快和簡單地就能編寫各種代碼;而一旦你需要,你可以擁有從上層到底層的幾乎完全的控制能力,而這些能力將能讓你有需要時無需思考各種奇怪的 workaround 就能直接榨幹機器,達到 C、C++ 的性能,甚至因為有運行時 PGO 而超出 C、C++ 的性能。

以上就是深入分析java與C#底層控制能力不同的詳細內容,更多關於java與C#底層控制能力不同分析的資料請關註WalkonNet其它相關文章!

推薦閱讀: