大家好,我是辣条。

刷到了一个很有意思的问题,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]); // 7class 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]); // 7unsafe 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# 最大的不同是对底层的控制能力不同相关推荐

  1. java 对象压缩_理解Java对象:要从内存布局及底层机制说起,话说....

    前言 大家好,又见面了,今天是JVM专题的第二篇文章,在上一篇文章中我们说了Java的类和对象在JVM中的存储方式,并使用HSDB进行佐证,没有看过上一篇文章的小伙伴可以点这里:< 这篇文章主要 ...

  2. 【Java集合】一文快速了解HashMap底层原理

    目录 一.HashMap底层的数据结构(简单讲解原理) 1.1 当我们向HashMap存入一个元素的时候 1.2 当我们取获取这个元素的时候 二.JDK 1.8中对hash算法和寻址算法是如何优化的? ...

  3. 理解Java对象:要从内存布局及底层机制说起,话说....

    点击上方蓝色"方志朋",选择"设为星标" 回复"666"获取独家整理的学习资料! 前言 大家好,又见面了,今天是JVM专题的第二篇文章,在上 ...

  4. Java 探究集合List、Map、Set底层的数据结构【钢镚核恒】

    Java 集合 简介 集合 (collection)是存储对象的容器.例如:6个人一个团队(集合) 集合与数组类通,数组是存储具体数据的容器.集合的底层有用到数组 这篇文章是加深对集合的理解,探究各种 ...

  5. java中redis原理_Redis字符串键的底层原理

    before C语言基础 Redis基础 导入 redis的命令如下: set x "hello"; get x; hello Redis作为一种存储字符串的缓存结构,其具体实现是 ...

  6. JAVA→HashMap、常用方法、遍历方式、底层原理、初始化性能分析

    Map方法 HashMap HashMap()遍历 HashMap()四种遍历性能分析 HashMap()底层原理 HashMap()是否初始化大小性能分析 LinkedHashMap()

  7. java piwik_piwik数据量变大后修改为底层运行

    发表于 2020-08-23 08:52:43 by 月小升 piwik被收购后新名字为matomo,近期数据量过大,网页显示加载失败了.首先想到的就是以前依靠浏览器跑数据的模式,需要改成底层了. 官 ...

  8. JAVA关于基本数据类型之间进行强制转换底层剖析

    1.什么是基本类型之间的强制转换 先来聊一聊基本类型 基本数据类型 所占用的字节数 表示数的范围 整型 byte 1 -128~+127 short 2 -32768~32767 int 4 -214 ...

  9. java面试题2019 答案

    Java 面试随着时间的改变而改变.在过去的日子里,当你知道 String 和 StringBuilder 的区别(String 类型和 StringBuffer 类型的主要性能区别其实在于 Stri ...

最新文章

  1. 后端码农谈前端(CSS篇)第三课:选择器
  2. AI研发新药登上Nature子刊:46天合成潜在新药候选分子,比传统方法快15倍 | 开源...
  3. Linux Namespace系列(09):利用Namespace创建一个简单可用的容器
  4. 对Linux0.11 中 进程0 和 进程1分析
  5. 未来已来——工作空间WorkSpace和物联网IoT (3)
  6. 数据结构(四)---栈的顺序存储的实现---java版
  7. IOS开发地理编码与反向编码
  8. .Net 4.X 提前用上 .Net Core 的配置模式以及热重载配置
  9. React开发(198):需要加个{}
  10. MyEclipse创建struts.xml
  11. Python项目实战
  12. 一张图看透办公网安全
  13. Luogu5816 [CQOI2010]内部白点
  14. 冲击波病毒内幕点滴(2) (转)
  15. 微信小程序图片上传功能(PHP后端)
  16. MSSQL2008中的时间日期类型摘录
  17. vce 题库导入_PDF 题库转VCE 文件
  18. SQLServer查询某天数据语法
  19. 研究生生涯的一些经验和感悟
  20. 零基础入门学习Python(21):魔法方法(1)构造和析构

热门文章

  1. Flex中让Panel等容器可拖动
  2. 人生长途,感受着不断拼搏...
  3. windows mysql导入sql文件
  4. .NET Core 管道
  5. 【转】JVM--内存区域划分
  6. lombok无法解析log
  7. binlog2sql闪回恢复数据
  8. 名企笔试:京东 2016 算法工程师笔试题(登楼梯)
  9. Ibatis SqlMapclient对象
  10. Vue三大核心概念之二(事件)