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的老登还没到退休年纪,都做到了管理层,一套框架用十年,自然就不会考虑新技术栈了。

返回顶部