Java与C#对比:为何C#这么甜,在国内游戏服务端却“水土不服”?
前公司的游戏服务端的技术栈是Java,C++/Lua,C#。偶然和其他项目Java主程进行交流,这是发生的一段对话。
“你们为什么用C#写服务器,是不是服务器用的winserver?”
”不是,服务器是Ubuntu。“
“那你们用的是Mono那套吗?”
“不是的,.Net很早就能跨平台了,.Net5就统一了(该不会以为我说的还是.Net Framework吧)。”
和很多人解释过C#可跨平台,发现他们对于C#的印象还停留在很多年前,但是对于主程这个级别的人还需要解释感觉有点不可思议,按理说两个这么像的语言,双方开发者应该都关注一下的。
同时用过Java与C#,一直很纳闷,为什么C#写起来这么爽,在国内却不温不火。 最近在看游戏服务端的工作,发现用C#的太少了,基本都是C++,Java,Golang。
接下来我从以下几个方面进行对比一下Java与C#两个语言。
跨平台
以下是一些.Net跨平台的关键技术节点
年份 | 版本/项目 | 跨平台意义 |
---|---|---|
2004 | Mono | 首次证明 .NET 可跨平台运行 |
2016 | .NET Core 1.0 | 微软官方跨平台起点 |
2018 | .NET Core 2.1 | LTS 版本,企业级跨平台应用成熟 |
2020 | .NET 5 | 统一 .NET 生态的开始 |
对于Java,出生就是为了跨平台的。
C# 开源跨平台还是太晚了,Java都把份额抢走了。目前国内C#主要是在游戏,上位机,桌面开发等领域。
Java虚拟线程与C# async await
在虚拟线程之前还能用“Java不适合写异步逻辑,只能写多段逻辑和死亡回调”来嘲笑Java。比如以下这个C#逻辑换做Java来写就很麻烦,用C#写就如行云流水,用Java写就得拆分成多段。
// 发送C2R_Ping并且等待响应消息R2C_Ping
R2C_Ping pong = await session.Call(new C2R_Ping()) as R2C_Ping;
Log.Debug("收到R2C_Ping");
// 向mongodb查询一个id为1的Player,并且等待返回
Player player = await Game.Scene.GetComponent<DBProxyComponent>().Query<Player>(1);
Log.Debug($"打印player name: {player.Name}")
Java19中提供了预览版的虚拟线程,在Java21中正式发布。没有引入await async关键字,隐式异步,以同步写法实现异步逻辑,适用于IO密集型任务,由JVM调度。
由于在虚拟线程中阻塞不会阻塞平台线程,所以之前用异步回调的写法在虚拟线程中可改成同步,一定程度上缓解了死亡回调的问题,也让代码逻辑变得连贯,不用再拆成多段逻辑。
C# 中的 async/await通过编译器将异步代码转换为状态机,实现非阻塞操作。其中不好的一点就是,只要代码里一处有异步,会连带调用的方法都改成异步,一路await到底,有种“异步的传染性”。
调试对比
我在20年的时候用Idea写Java,当时Idea还不能在调试的时候进行热重载,需要安装JRebel插件。前几天装了Idea新版本,发现能直接支持热重载了,我又重新体验了一下写Java。
在调试时修改代码并热重载发现,Main类里修改方法不会生效,在改了其他的类,旧的对象里的方法不会生效,创建新的对象会生效。
Idea里调试Java只能向前执行,比Rider少了一些按钮,调试C#的时候能反向调试。
我在写C#的时候,发现C#的热重载简直是神奇。我甚至能先点开调试让程序运行,再去对应的方法里断点,然后写代码,边写代码边热重载边调试。这在写很复杂的逻辑时非常爽,比如写一个英雄的技能逻辑,我能边调试边写代码,然后看最后结果,直到符合要求。
根本原因对比
维度 | Java (IDEA 调试器) | C# (Visual Studio/Rider 调试器) |
---|---|---|
调试协议 | JPDA (Java Platform Debugger Architecture) | 基于 CLR 的调试 API + 历史调试记录 |
执行控制 | 仅支持向前执行(基于 JVM 字节码线性执行) | 支持时间旅行调试(Time-Travel Debugging/TTD) |
运行时支持 | JVM 不记录执行历史 | CLR 可记录线程状态和内存快照 |
技术实现 | 基于断点的即时状态检查 | 预先录制执行轨迹 + 反向回放 |
热更新
在游戏服务器中,Java可通过动态加载新类来替换旧类实现热更新。C#可通过动态加载程序集进行热更新。
泛型
都说Java里的泛型是个残废,由于类型擦除导致运行时拿不到类型信息。Java的类型擦除是为了兼容旧代码。Java泛型是在JDK 5(2004年)引入的,而旧版本的Java类库比如 ArrayList没有泛型。通过类型擦除,泛型代码编译后的字节码能与非泛型代码兼容。还不如C#激进一点,早解决了就不用一直照顾老代码至今还在擦屁股。
维度 | Java (类型擦除) | C# (具现化泛型) |
---|---|---|
运行时类型 | List<String> → List | List<string> 保留类型信息 |
性能 | 装箱开销(原始类型需包装类) | 无装箱(支持 List<int>) |
约束 | 有限(如不能 new T()) | 强大(可 where T : new()) |
协变/逆变 | 通配符 (? extends) | in/out 关键字 |
// C#(编译时类型安全)
public T CreateInstance<T>() where T : new()
{
return new T();
}
/* Java
* 通过反射创建指定类型的实例
* 由于类型擦除,泛型类型T在运行时不可用,
* 所以需要显式传入Class<T>参数来保留类型信息
*/
public <T> T createInstance(Class<T> type) throws Exception {
return type.newInstance();
}
值类型
Java里除了基础类型外没有值类型,C#可定义struct明确栈分配,除非装箱。对于性能的影响就是Java自动装箱开销大,C#零开销。
值类型在游戏中还是很重要的,比如向量之间的计算。
其他
还有一些没提到的点,比如指针操作等Java没有,以后也不会有的东西。
在游戏开发的领域,如果客户端是Unity的话,最优解是这样的
客户端:Unity+HybirdCLR热更 -> 全C#脚本与热更新
服务器:C#游戏服务器 -> 全C#脚本与热更新,与客户端可共用协议代码与相关逻辑代码,例如战斗逻辑或是写客户端机器人进行压测。代码逻辑直接复用,这是其他的语言都做不到的。
数据库:Mongodb -> 相比于关系型数据库不用写Sql语句,关注点主要放在游戏业务实现,大大提升开发效率
但是国内意识到C#很适合游戏双端开发的公司很少(网易有的项目组是这样的),估计是熟悉Java的老登还没到退休年纪,都做到了管理层,一套框架用十年,自然就不会考虑新技术栈了。