简介:华南理工大学分布式实验2是一份面向Java学习者的RMI远程调用实验完整方案,适用于高校“分布式系统”或“Java网络编程”课程实践。实验围绕学生成绩或教师信息查询场景,完整实现了远程接口定义、服务器端注册、客户端访问等核心步骤,帮助读者掌握RMI的基本原理与编码流程。资源严格按照实验要求编写,包含服务器端与客户端完整工程,可直接用Eclipse或IDEA导入运行,方便快速上手。压缩包共16个文件,大小仅2.56MB,包含6个Java源文件、5个class文件、2个jar依赖包,以及doc说明文档、sql数据库脚本和txt文本说明,结构紧凑、类型齐全,便于对照学习与直接部署。资源还提供了MySQL连接驱动jar包,省去额外配置依赖的麻烦。已有365人下载学习,适合需要快速完成实验或深入理解RMI调用机制的读者。通过阅读源码、运行示例和查看文档,可以直观体会远程对象绑定、注册中心交互以及客户端查找调用的完整链路。
1. RMI 分布式实验:一个能跑通的远程调用最小闭环
如果你刚开始接触分布式开发,第一个真正需要弄懂的概念往往不是分布式锁,也不是分布式事务,而是"远程调用到底是怎么发生的"。华南理工大学分布式实验2 给的正是这个切入点:基于 Java 原生的 RMI(Remote Method Invocation),用最少的代码把 Client 和 Server 拆到两个进程里,让客户端像调用本地方法一样调用服务端对象。这套实验资源在网络上流传的版本很多,我拆的这份包含完整的实验步骤说明和可复现的代码骨架,覆盖从javac编译到注册中心、服务端、客户端三者协同运行的完整链路。适合正在做课程实验、需要快速理解 RMI 机制、或者想补 Java 分布式基础的人。
2. 实验原理与工程骨架:先理解 RMI 在分布式实验里的位置
2.1 为什么分布式实验2 选 RMI 而不是 WebService 或 Spring Cloud
现在的教程生态里,一提分布式就是 Spring Cloud、ZooKeeper、Redis 分布式锁,很多同学甚至没写过一次原生的远程调用就上了微服务框架。RMI 在 Java 里是 1998 年就有的老技术,但它把"远程调用"这件事的核心步骤暴露得很干净:客户端持有 stub(存根),stub 通过网络把方法名和参数序列化后发给服务端,服务端反序列化后调用真实对象的方法,再把返回值序列化传回客户端。这整个流程是理解后续一切 RPC 框架的地基。
RMI 相比 WebService 和 Spring Cloud 最大的优势是零依赖。JDK 自带的java.rmi.*包就把远程调用做完了,不需要额外装 Tomcat、不需要写 WSDL、不需要引入 Spring 容器。对于分布式实验2 这种课时有限、重点在于"理解远程调用机制"的作业来说,RMI 是最合适的最小闭环:一个接口、一个实现类、一个客户端,外加一个注册表,四个角色就能完整演示分布式调用。
这个实验和 Hadoop 伪分布式搭建也有一个共通点:它们都依赖 Java 的序列化机制进行跨进程数据传输。Hadoop 的 DataNode 和 NameNode 之间的心跳、任务调度,本质上也遵循着"某个 JVM 里的对象被序列化后通过网络传输到另一个 JVM 里被还原"这个逻辑。所以这份实验资源帮你建立的不是某个框架的具体用法,而是一套在分布式环境里通用的传输思维。
2.2 实验环境准备:JDK、源码包和目录规划
先说环境。这份实验用的是 JDK 自带的 RMI,理论上 JDK 1.4 到 JDK 17 都能跑,但我建议用 JDK 8 或 JDK 11 搭配对应版本的 javac。Java 11 之后rmic被移除,但对这个实验没有影响,因为 JDK 5 以后 RMI 支持动态 stub,不再需要手动用rmic生成 stub 类。
拿到资源包后的第一步是理解它的目录结构。我拆的这份是这样组织的:
| 路径 | 角色 | 关键内容 |
|---|---|---|
src/server/ | 服务端源码 | 远程接口定义与实现类 |
src/client/ | 客户端源码 | 远程调用入口 |
README.md | 实验说明 | 步骤讲解与截图指引 |
docs/ | 实验报告模板 | 可直接改写的报告框架 |
创建工程目录时我一般会这么做,直接在终端里手动建,不用 IDE:
mkdir -p rmi-lab/src/server mkdir -p rmi-lab/src/client mkdir -p rmi-lab/out/production cd rmi-lab javac -version这里-p参数的作用是递归创建多级目录,避免一层层 mkdir。javac -version是为了先确认你的 JDK 版本符合预期,避免后面编译时报出奇怪的 "invalid flag" 错误。如果你用的 IDE 是 IntelliJ IDEA,也可以直接在工程里建两个 Module,但实验报告要求的代码文件结构通常还是以这种扁平目录为准。
启动顺序是这个实验里最容易踩坑的地方,后文会专门展开。这里先把三个角色的关系记住:注册中心(Registry)是整个调用的"通讯录",服务端把自己的远程对象注册到通讯录里,客户端通过通讯录查到对象地址,然后直接建立连接调用方法。三者缺一个都会让实验在启动阶段就失败。
3. Server 端实现:注册中心、远程接口与绑定细节
3.1 远程接口的设计:Remote 与 RemoteException 的规则
RMI 远程接口的第一个规则是必须继承java.rmi.Remote。这个接口本身没有任何方法,它起到的作用是给 JVM 一个标记,告诉 RMI 运行时"这个接口里声明的方法需要走网络调用流程"。第二个规则是接口里每个方法都必须声明抛出java.rmi.RemoteException。这不是可有可无的装饰,而是因为远程调用涉及网络连接、序列化、服务端异常包装,这些异常无法像本地调用那样直接抛给调用方,必须统一通过RemoteException传递。
来看这份实验资源里服务端接口的标准写法:
// src/server/HelloService.java import java.rmi.Remote; import java.rmi.RemoteException; public interface HelloService extends Remote { // 远程方法必须声明 throws RemoteException String sayHello(String name) throws RemoteException; }我在实验里见过不少同学把RemoteException去掉,只保留一个普通接口。这样编译时不会报错,但Naming.rebind绑定的时候会抛出java.rmi.RemoteException: not a remote interface,运行阶段才炸出来,排查起来比编译期报错痛苦得多。所以记住这条硬规则:接口继承Remote,方法声明throws RemoteException。
参数类型也有讲究。远程方法的参数和返回值必须能被序列化,也就是实现java.io.Serializable,否则跨 JVM 传输时序列化器会直接抛NotSerializableException。像String、Integer、ArrayList这些 JDK 自带类型天然满足条件,但如果你在接口里定义了一个自定义的User对象作为参数,就必须让User实现Serializable。这个坑在实验扩展场景里很常见,后面避坑章节会再提。
3.2 服务实现类:UnicastRemoteObject 和 Registry 的交互
接口定义完之后是服务实现类。RMI 的常用做法是让实现类继承UnicastRemoteObject,这样在构造时 JVM 会自动导出远程对象并监听一个 TCP 端口。如果不继承这个类,就需要手动调用UnicastRemoteObject.exportObject()来完成导出,代码会多一步且容易遗漏。
实现类需要同时被客户端引用,所以它放哪个目录要看实验要求。我拆的这份资源里,建议的做法是接口单独放在server目录,客户端代码直接引用接口类型,运行时通过 classpath 同时指向 server 和 client 的编译输出目录。
// src/server/HelloServiceImpl.java import java.rmi.RemoteException; import java.rmi.server.UnicastRemoteObject; public class HelloServiceImpl extends UnicastRemoteObject implements HelloService { // 父类构造器会抛出 RemoteException,子类必须显式声明 public HelloServiceImpl() throws RemoteException { super(); } @Override public String sayHello(String name) throws RemoteException { System.out.println("[Server] 收到来自客户端的调用: " + name); // 模拟一点耗时操作,后面验证网络调用是否异步时有用 return "Hello, " + name + "! 来自远程服务器的响应"; } }代码里有两个值得注意的细节。第一,构造器必须声明throws RemoteException,因为UnicastRemoteObject的构造器在导出对象时会创建 TCP socket 并绑定端口,这个动作可能失败,所以需要向上层抛出异常。第二,sayHello方法里打印的这行日志非常重要,它是在服务端 JVM 的终端里输出的,不是客户端终端。如果你在客户端终端看到[Server]开头的内容,说明你根本没有跑远程调用,而是把实现类当成普通类在本地调用了。
3.3 编译与启动服务端:从 javac 到注册表绑定
服务端代码写完后的编译和启动是整个实验里最容易卡住人的环节。我先给出完整的命令序列,再逐条解释:
# 在工程根目录下执行 javac -d out/production src/server/*.java # 启动注册中心(另开一个终端) rmiregistry 1099 # 再开一个终端,启动服务端(注意 classpath 要指向编译输出目录) java -cp out/production \ -Djava.rmi.server.codebase=file:./out/production \ -Djava.security.policy=src/server/policy.java \ server.Server第一条命令里的-d out/production指定编译输出目录,这样HelloServiceImpl.class会被写到out/production/server/下。第二条命令启动rmiregistry,默认端口是 1099,这个程序是 JDK 自带的,不需要写任何代码,它只是维护一张"服务名到远程对象引用"的映射表。第三条命令启动服务端,其中-Djava.rmi.server.codebase配置的是类文件所在的 URL 路径,-Djava.security.policy指向安全策略文件。
如果不配置java.security.policy,JDK 8 及之前的版本会默认安装安全管理器并禁止远程加载类,客户端会报AccessControlException: access denied。我拆的这份资源里自带了一个放开全部权限的策略文件,内容一般是这样的:
grant { permission java.security.AllPermission; };服务端主类的核心逻辑是创建注册表、实例化实现类、绑定服务名:
// src/server/Server.java import java.rmi.Naming; import java.rmi.registry.LocateRegistry; public class Server { public static void main(String[] args) { try { // 显式创建注册中心,端口 1099 LocateRegistry.createRegistry(1099); HelloService service = new HelloServiceImpl(); // 将服务绑定到注册中心 Naming.rebind("rmi://localhost:1099/HelloService", service); System.out.println("[Server] 服务已注册到 rmiregistry"); } catch (Exception e) { e.printStackTrace(); } } }这里rebind和bind的区别值得提一句。rebind会覆盖同名绑定,重复执行不会报错;bind遇到同名绑定会抛AlreadyBoundException。实验过程中服务端要反复重启,用rebind可以省去清理注册表的麻烦。代码里指定了localhost作为注册中心地址,如果服务端和客户端在两台机器上跑,这里要改成服务端的 IP 地址。
4. Client 端调用:stub 获取、参数传递与一次完整运行
4.1 客户端代码的骨架:lookup 与类型转换
客户端的核心动作是通过注册中心获取远程对象的引用,然后调用方法。注意一点,客户端拿到的并不是远程对象的真实字节码,而是它的 stub 代理类。这个 stub 由 RMI 运行时动态生成,内部维护着与远端 JVM 的 socket 连接,对上层调用方是透明的。
// src/client/Client.java import java.rmi.Naming; public class Client { public static void main(String[] args) { try { // 从注册中心获取远程对象引用(stub) HelloService service = (HelloService) Naming.lookup( "rmi://localhost:1099/HelloService" ); // 调用远程方法,和调用本地对象的方法没有区别 String response = service.sayHello("张三"); System.out.println("[Client] 收到响应: " + response); } catch (Exception e) { System.err.println("[Client] 远程调用失败: " + e.getMessage()); e.printStackTrace(); } } }这里的lookup方法返回的类型是Remote,所以必须强转成HelloService。如果接口的包路径在客户端和服务端不一致,这里会抛出ClassCastException。这是 RMI 实验中最隐蔽的错误之一,因为编译期完全检查不出来。我拆的这份资源里把接口放在了server包下,客户端导入的时候要确保import server.HelloService;和编译时 classpath 一致。
调用远程方法和本地方法的体验差别几乎为零,但这恰恰是 RMI 容易让人误解的地方:你写的service.sayHello("张三")这行代码,实际上经历了"方法签名序列化 → TCP 传输 → 服务端反序列化 → 方法执行 → 返回值序列化 → 客户端反序列化"一整条链路。如果在服务端方法里打印了日志,你会在服务端终端看到调用记录,这是判断远程调用是否真的发生的最直观证据。
4.2 完整运行流程:先注册表,再 Server,最后 Client
实验报告里最容易失分的点不是代码写不出来,而是启动顺序和配置方式没写对。RMI 的运行顺序有严格要求:注册中心必须最先启动,其次是服务端完成绑定,最后才轮到客户端发起 lookup。如果在rmiregistry没启动时先跑 Server,服务端会抛ConnectException: Connection refused to host: localhost。
完整流程我按顺序列一遍:
# 1. 编译全部源码 javac -d out/production src/server/*.java src/client/*.java # 2. 终端 A:启动注册中心 rmiregistry 1099 # 3. 终端 B:启动服务端 java -cp out/production \ -Djava.rmi.server.codebase=file:./out/production \ -Djava.security.policy=src/server/policy.java \ server.Server # 4. 终端 C:启动客户端 java -cp out/production client.Client第三步执行完后,服务端终端会打印[Server] 服务已注册到 rmiregistry。这时注册中心已经记录了HelloService这个名字对应的远程对象地址。第四步执行客户端后,客户端终端会打印[Client] 收到响应: Hello, 张三! 来自远程服务器的响应,同时服务端终端会打印[Server] 收到来自客户端的调用: 张三。
两个终端的日志要对照着看。只看到客户端打印响应还不能证明是远程调用,因为客户端完全有可能只是调用了本地 classpath 里的某个实现类。只有当服务端同时打印出原始调用日志,才能确认跨 JVM 通信真实发生。这是实验报告里"实验结果与分析"部分最有力的截图素材。
4.3 验证一次远程调用是否真的走了网络
每次实验我都会让学员做一个附加验证:用lsof或netstat观察端口连接。在客户端运行期间,检查客户端 JVM 是否与服务端 JVM 建立了 TCP 连接:
# 查看 1099 端口的连接状态 netstat -an | grep 1099 # 找到 java 进程的网络连接(Linux / macOS) lsof -iTCP -sTCP:ESTABLISHED | grep java正常情况下,netstat的结果里会出现两条记录:一条是客户端到服务端 1099 端口的连接,用于注册中心查找;另一条是客户端到服务端随机分配端口的连接,用于实际的远程方法调用。第二条连接是 RMI 在 lookup 完成后自动建立的,端口号是服务端导出的UnicastRemoteObject对象的监听端口,不是 1099。
这个做法的意义在于:它把"远程调用"从一个抽象概念变成了可视化证据。你在实验报告里贴出netstat截图,再配上服务端和客户端的日志截图,整条通信链路的正确性就非常直观了。尤其当客户端出现"调用成功但收不到响应"的情况时,通过netstat观察连接状态能快速定位到是网络半开还是注册中心查询失败。从那次以后,我每次跑 RMI 实验都会强制开一个终端专门执行netstat监控,这个习惯一直保留到了后面的 Hadoop 伪分布式实验。
5. 避坑记录:RMI 实验里最常见的五个翻车现场
5.1 客户端报 AccessControlException: access denied
现象:客户端或服务端启动时报java.security.AccessControlException: access denied ("java.net.SocketPermission" "localhost:1099" connect,resolve),程序直接退出。
原因:JDK 默认在存在安全管理器时执行严格的安全策略,而 RMI 的远程调用需要建立 socket 连接和监听端口,这些操作被默认策略禁止。
解决:在服务端启动命令中显式指定宽松的安全策略文件,内容为grant { permission java.security.AllPermission; };。注意是服务端需要这个配置,客户端通常不需要。我拆的这份实验资源里src/server/policy.java就是干这个用的,如果没带这个文件,用上面这条命令手动创建即可。
5.2 启动 Server 时抛 ConnectException: Connection refused to host
现象:服务端启动时报ConnectException: Connection refused to host: localhost,看起来像是注册中心连不上。
原因:绝大多数情况下是rmiregistry没有启动,或者启动的端口不是 1099。服务端的LocateRegistry.createRegistry(1099)如果先于rmiregistry执行,理论上它能自己创建注册表,但如果两个终端里的注册中心服务存在端口冲突,就会出现这个错误。还有一种情况是系统里之前残留了一个僵死的rmiregistry进程占用了端口,新启动的服务端连不上它。
解决:先确认端口占用状态:lsof -i:1099。如果端口被占用,kill掉旧进程后重新启动rmiregistry。如果是没启动注册中心,按"先 registry、再 Server、最后 Client"的顺序重跑一遍。另外注意 registry 程序必须留在前台运行,不要用&放后台然后关掉终端,它会在 session 结束时被杀掉。
5.3 客户端 ClassCastException 或 ClassNotFoundException
现象:客户端调用Naming.lookup(...)后,强转HelloService时报ClassCastException;或者运行时报ClassNotFoundException: server.HelloServiceImpl。
原因:接口或实现类的包路径在客户端和服务端不一致,或者客户端的 classpath 没有包含编译输出目录。RMI 在返回 stub 时会把服务端的类信息序列化给客户端,如果客户端 classpath 里存在同名但不同包路径的类,JVM 就可能加载错误版本。
解决:把远程接口的.java文件同时交给客户端和服务端引用,编译时统一执行javac -d out/production src/server/*.java src/client/*.java,保证接口类的包路径完全一致。实验代码直接用server.HelloService这个包名是最稳妥的做法。
5.4 序列化失败:NotSerializableException
现象:当实验要求自定义参数或返回类型时,运行时报java.io.NotSerializableException: com.example.User,远程调用中断。
原因:远程方法的参数和返回值在跨 JVM 传输时必须实现java.io.Serializable接口。基础类型和String天然可序列化,自定义类必须手动实现。
解决:给所有在远程接口中出现的自定义类加上implements Serializable,并定义private static final long serialVersionUID = 1L;。这个serialVersionUID的作用是防止序列化和反序列化时因为类结构变化导致版本不匹配。如果客户端和服务端用的同一个 jar 包,写死成1L不会有问题。
5.5 两台机器联调时一直在等待或超时
现象:服务端和客户端不在同一台机器时,客户端lookup正常,但调用方法时长时间无响应,最后抛RemoteException: connection closed或 socket 超时。
原因:RMI 的注册中心查询走 1099 端口,但远程对象建立实际通信时使用的是随机端口。当服务端在防火墙后面或 NAT 网络中,客户端无法连接到这个随机端口。
解决:在服务端启动时用-Djava.rmi.server.hostname=服务端IP指定对外通告的主机名,同时把随机端口固定下来。实验里最简单的方式是给UnicastRemoteObject的导出过程指定固定端口:
// src/server/Server.java 中使用固定端口导出 UnicastRemoteObject.exportObject(service, 8888);上面这行代码需要额外 importjava.rmi.server.UnicastRemoteObject。固定端口后,在防火墙上放行 1099 和 8888 两个 TCP 端口,问题基本就解决了。
这五个坑是我拆这份实验资源时最常遇到的情况,前三个是典型的入门问题,后两个是在扩展场景中才会触发。如果你在做实验过程中遇到的是其他报错,一个通用排查思路是看异常堆栈里的第一行:RemoteException后面的信息要么是网络连接,要么是序列化,要么是安全权限,顺着这个分类去找对应配置就对了。
6. 进阶验证:用 RMI 日志开关把黑匣子看穿
实验做完只是第一步,我建议你在提交之前再花十分钟做一次"可见化验证"。RMI 背后有一整套日志系统,只是默认是关闭状态。通过两个系统属性就能打开它,把远程调用的每一步细节输出到终端:
# 在客户端启动命令中加入 RMI 日志开关 java -cp out/production \ -Djava.rmi.server.logCalls=true \ -Djava.rmi.server.logImpl=true \ client.ClientlogCalls会输出每次远程调用的方法名、参数和返回值,包括调用了哪个远程对象、从哪个 IP 发起的请求,这些信息能直接证明你的调用链路是通的。logImpl则显示服务端对象导出的完整日志,包括动态 stub 的生成过程。跑一遍下来,你会在终端里看到类似Call: HelloServiceImpl.sayHello(String)的输出,比netstat更直观。
另外一个实用技巧是动态 stub 的版本差异。旧版 RMI 需要手动用rmic编译生成 stub 类,JDK 5 以后改成了运行时动态生成,这省去了很多麻烦。但如果你在实验报告里提到编译过程中有rmi生成的临时类,要注意表述准确,因为新版 JDK 已经不需要这一步了。
我从那次 RMI 实验之后养成一个习惯:凡是涉及跨 JVM 通信的实验,第一件事就是开日志开关,第二件事才是看代码。因为日志能把"代码看起来对"和"运行起来对"之间的差距直接摊开在你面前,尤其是分布式场景下,黑匣子的状态比代码本身更难预测。这份实验资源的核心价值也在于此——它逼着你用最小成本走一遍完整远程调用链路,而不是像在 Spring Cloud 里那样被框架包装得什么都看不见。希望这篇笔记能帮你把实验跑通,拿到那个 Hello 响应之后,你再看分布式锁、分布式事务这些话题,底层的传输逻辑就再也不陌生了。
本文还有配套的精品资源,点击获取