一个靠谱的OJ后端判题机最难的不是题目数据和前端页面而是怎么安全执行用户提交的Java代码。我在给某高校的在线评测系统做后端重构时就被“原生实现Java安全管理器环境隔离”这个需求卡了很久——说难也不难说简单又到处是坑。这篇文章就把整套判题机架构和基于SecurityManager的隔离方案拆开讲清楚适合正在自研OJ判题模块、或者想理解沙箱原理的朋友参考。1. 判题机整体架构从提交到状态的完整链路1.1 判题机在OJ系统中的位置一次典型的OJ请求是这样的用户在网页提交代码前端把代码文本和题目ID扔给后端服务后端负责编译、运行、采集输出、和标准答案比对最后把“通过/超时/运行错误/答案错误”写回数据库。这套过程中真正危险的是“运行”这一步因为跑的是用户写的任意代码。你要是直接Runtime.exec或者反射调用一个类的main方法那基本等于把服务器钥匙交给了陌生人。所以判题机本质上是OJ系统的“隔离舱”。它要回答三个问题代码能不能编译通过运行结果是否在限制的时间和内存内产生这段代码有没有试图干超出题目范围的事我的做法是把判题机拆成独立模块不跟Web服务混在一起用消息队列接收判题请求。每个判题任务都是一个标准化的对象包含源码、语言、时间限制、内存限制、标准输入和题目特殊要求。判题机拿到任务后依次执行编译、执行、比对三个步骤每一步都有独立的异常捕获和状态回传机制。提示判题机不要再依赖Web层的HTTP请求直接触发一定要走异步队列。否则一个恶意死循环就能把后端线程池全部占满导致整个OJ页面卡死。1.2 为什么选择原生SecurityManager而不是Docker现在一提代码沙箱大家第一反应都是Docker干净、彻底、好管理。但当时我们的环境有一个现实约束——判题机要跑在一些老旧服务器上内核版本不够新Docker运行不稳定而且运维团队对容器技术不熟。再加上题目场景集中在Java语言不需要支持多语言通用沙箱所以我把目标缩小为只隔离Java进程内的用户代码。Java自己提供了一套安全管理机制SecurityManager配合AccessController和权限策略文件可以精确控制“哪段代码能读哪个文件、能不能网络连接、能不能写系统属性”。虽然它不是操作系统级别的隔离但对“跑一段OJ提交的Java代码”这个场景来说足够拦住99%的恶意行为。这不是说Docker不好而是说在特定约束下原生方案更轻、更可控。如果你要一个判题机同时支持Python、C、Java老老实实上容器如果只是Java单语言、并且Java版本可控SecurityManager是成本最低的解法。1.3 判题机核心模块划分整个判题机我分成了五个模块任务接收模块监听队列把原始提交转换成内部任务对象。编译模块调用Java编译器把内存中的源码写到临时目录编译成class文件。隔离执行模块启用SecurityManager在受限线程中加载并运行用户类。资源监控模块采集执行线程的CPU、内存、运行时间。结果比对模块对输出做规范化处理和标准输出比对。其中隔离执行模块是最核心的后面所有内容都会围绕它展开。2. Java安全管理器隔离的原理与策略设计2.1 SecurityManager到底拦住了什么SecurityManager是Java从1.0就有的东西它本质上是一个“检查员”每次关键操作发生前JVM都会调用安全管理器里对应的checkXxx()方法如果该方法抛出SecurityException操作就被拒绝。你可以把它理解为小区门口的保安。用户代码想读/etc/passwd保安就问你有出入证吗你的Permission里有FilePermission吗没有就不让进。写系统属性、连网络、访问环境变量、加载未授权的类库全都要经过这一层检查。但光理解概念没用实际使用中有个关键点基于特权机制的权限检查在现代JDK里大部分是通过AccessController.checkPermission()来做的。而用户代码如果自己包了一个AccessController.doPrivileged可能会绕过你设置的某些检查前提是它能执行到那个特权块。所以判题机里不能只依赖SecurityManager的默认检查还得配合自定义逻辑。2.2 权限策略文件从零写一个最小白名单Java的权限策略文件是一个后缀为.policy的文本我们可以给它配置一个“白名单”。网上很多教程只会给一个全局AllPermission那等于没设。我实际跑OJ时用的策略是这样的grant codeBase file:/judge/tmp/user/* { permission java.lang.RuntimePermission accessDeclaredMembers; permission java.lang.RuntimePermission accessClassInPackage.sun.reflect; permission java.lang.PropertyPermission java.version, read; permission java.lang.PropertyPermission java.vm.name, read; permission java.lang.PropertyPermission os.name, read; permission java.lang.PropertyPermission line.separator, read; permission java.lang.PropertyPermission file.separator, read; permission java.lang.PropertyPermission path.separator, read; permission java.util.PropertyPermission user.name, read; permission java.io.FilePermission /judge/tmp/user/-, read,write,delete; permission java.lang.RuntimePermission setContextClassLoader; };这份策略就表达了用户在/judge/tmp/user/目录下的代码只能读几个系统属性、访问自己目录下的文件其他一律不行。这里没有给java.net.SocketPermission所以用户代码发起任何网络连接都会直接抛异常没有给java.lang.RuntimePermission exitVM所以System.exit根本执行不了。注意策略文件里的codeBase必须和用户class文件实际加载路径完全匹配否则权限不生效。我踩过的最深的坑就是把用户class输出到了带空格或中文的路径策略匹配不上所有权限全部失效。2.3 动态权限控制通过自定义SecurityManager按需放行静态策略文件只能做“一刀切”的白名单但判题场景经常需要某些操作在受限线程内“临时但可控地”打开。比如判题机自身要读用户目录下的输入文件而用户代码不需要这个权限。我写了一个自定义SecurityManager重写了关键点的检查public class JudgeSecurityManager extends SecurityManager { private Thread judgeThread; public JudgeSecurityManager(Thread judgeThread) { this.judgeThread judgeThread; } Override public void checkPermission(Permission perm) { // 判题机自身的线程全部放行 if (Thread.currentThread() judgeThread) { return; } // 用户线程禁止退出JVM、禁止网络、禁止写系统属性等 if (perm instanceof RuntimePermission) { String name perm.getName(); if (exitVM.equals(name) || stopThread.equals(name) || modifyThreadGroup.equals(name) || setSecurityManager.equals(name)) { throw new SecurityException(Forbidden: name); } } if (perm instanceof SocketPermission) { throw new SecurityException(Network is blocked); } if (perm instanceof PropertyPermission write.equals(perm.getActions())) { throw new SecurityException(System property write is blocked); } // 其他情况交给默认的策略检查 super.checkPermission(perm); } }这段代码解决了两个问题一是判题机自己的框架代码不受干扰因为线程匹配放行二是用户代码无论从哪个入口触发都会被我们自定义的规则二次拦截。3. 判题机核心流程的落地实现3.1 编译阶段的处理用户提交的代码首先要编译成class。这里我不会直接调用javac命令行而是用Java Compiler API在内存里编译或者编译到指定临时目录。好处是能捕获编译诊断信息还能控制编译期是否允许某些危险操作。我把源码写到一个以提交ID为名的临时目录下比如/judge/tmp/user/20123/Main.java然后调用JavaCompiler。编译阶段本身也要加上-encoding UTF-8避免中文注释出问题。编译完成后工作目录里会有Main.class后续执行就针对这个class。编译期安全比较容易被忽略。其实用户代码如果写了import java.net.*编译期是拦不住的必须在运行期通过SecurityManager拦截。但有些东西编译期就该拦比如动态生成无数类导致Metaspace被打爆——这个我会在资源限制部分细说。3.2 在多线程中执行用户代码并捕获输出执行用户代码不能直接在主判题线程里Class.forName(...).getMethod(main).invoke(...)因为一旦用户代码抛了底层异常或者暂停了线程判题流程就乱了。我采用的是“专用线程 输出重定向”方案ByteArrayOutputStream buffer new ByteArrayOutputStream(); PrintStream userOut new PrintStream(buffer, true, StandardCharsets.UTF_8); PrintStream userErr new PrintStream(new ByteArrayOutputStream(), true, StandardCharsets.UTF_8); Thread userThread new Thread(() - { // 绑定系统输出让System.out和System.err进入缓冲区 PrintStream originalOut System.out; PrintStream originalErr System.err; System.setOut(userOut); System.setErr(userErr); try { Class? clazz Class.forName(Main, true, userClassLoader); Method main clazz.getMethod(main, String[].class); main.invoke(null, (Object) args); } catch (InvocationTargetException e) { throw (RuntimeException) e.getCause(); } finally { System.setOut(originalOut); System.setErr(originalErr); } }, UserCodeThread);这里有个细节System.setOut是全局的而判题机自身日志也会走System.out。如果不在finally里恢复一个用户线程就会把整个判题进程的输出污染掉。更稳妥的做法是不要改全局的System.out而是通过SecurityManager配合自定义PrintStream的按线程过滤——不过这属于高级玩法了我最后是用ThreadLocal标记加自定义流实现的后面有机会再说。3.3 让用户代码无法System.exit这个其实是Java安全管理器最经典的关卡。调用System.exit会触发RuntimePermission exitVM。默认情况下任何代码都能退出JVM因为你没设置SecurityManager。解决方法已经有标准答案启动时启用SecurityManager。但要注意如果你直接通过启动参数-Djava.security.manager启用策略文件里的规则对所有线程生效这会影响整个判题进程。更好的方式是在判题进程启动后、执行用户代码前动态安装我们的JudgeSecurityManagerSystem.setSecurityManager(new JudgeSecurityManager(judgeThread));一旦安装了自定义SecurityManagerSystem.exit在进入原生终止逻辑前会先把控制权交给checkExitOverride public void checkExit(int status) { throw new SecurityException(System.exit blocked for user code); }我见过有人用SecurityException拦截System.exit后没有处理异常链导致用户本来想正常return 0退出却被判成“运行错误”。所以执行用户main的代码块里要对SecurityException做特殊封装如果用户代码主动System.exit(0)且前面的标注输出完全正常可以判“通过”。3.4 超时控制与强制终止所有判题系统都会遇到死循环不处理会拖垮整个判题进程。超时控制有多种做法常见的有Thread.stop()已废弃极其危险不要用。ExecutorServiceFuture.get(timeout)可以在超时后取消Future但实际上并不能真正中断一个跑在死循环里的Java线程。future.cancel()只是打中断标记如果用户代码不响应中断线程会一直占着CPU。进程级终止在独立JVM中运行用户代码超时后杀掉整个子进程。这是最干净的方案。我当时为了“原生实现”选择了一种混合方案把判题逻辑放进一个单独的守护进程每次判题都新建一个JVM子进程父进程用超时监控超时就destroy子进程。子进程内部再用线程跑用户代码。这样一来即使线程停不掉整个进程也会被父进程杀掉彻底回收资源。我用ProcessBuilder拉起子进程传参就是任务ID、临时目录、时间限制、内存限制。子进程的main方法负责启用SecurityManager、加载用户类、执行并输出结果。父进程只负责等待和超时终止Process process processBuilder.start(); if (!process.waitFor(timeoutSeconds, TimeUnit.SECONDS)) { process.destroyForcibly(); result.setStatus(TLE); }提示destroyForcibly()在Windows和Linux上的行为有差异Linux下会直接发SIGKILL彻底终止。判题机一般都部署在Linux实测没问题。4. 资源限制的进阶方案4.1 内存限制的坑SecurityManager能拦文件和网络但拦不住用户代码申请堆内存。用户代码写Listbyte[] list new ArrayList(); while(true){ list.add(new byte[1024*1024]); }没一会儿就能把堆打满抛OutOfMemoryError。如果JVM堆被撑爆整个判题进程可能直接崩掉。我采用的策略是给判题子进程设置JVM堆上限例如-Xmx256m。如果用户内存使用超限子进程会抛出OutOfMemoryError并异常退出父进程捕获到“非正常退出码”后就把该判题任务标记为MLE内存超限。这里最需要注意的是OutOfMemoryError不一定正好发生在用户代码里它可能发生在判题子进程的框架代码里所以判题框架代码也要尽量轻量不能和用户代码共享大量缓存。另一个坑是元空间Metaspace。用户代码如果动态生成大量类Metaspace会涨。所以我给子进程也加-XX:MaxMetaspaceSize64m。多亏这样后来有用户提交动态代理代码才没把判题机搞挂。注意SecurityManager的RuntimePermission setFactory等也会影响一些常用库如果你在子进程里引入了复杂依赖要仔细核对权限。对于判题子进程我的建议是只依赖JDK自带库不要用Spring这类框架否则权限策略会被依赖框架搞得很痛苦。4.2 CPU/时间限制JVM进程自身的CPU时间Java没有直接获取的API。如果你走“子进程方式”可以在父进程端读取/proc/pid/stat里的CPU时间字段或者用ThreadMXBean在子进程内部测量用户线程的CPU时间。我用的是子进程内部方案在用户代码执行前记录当前线程的ThreadMXBean.getThreadCpuTime()然后启动一个监控线程每隔50ms检查一次如果CPU时间超过限制就强制终止用户线程并抛出超时异常。但这里有个微妙的点用户线程可能在等IO比如等输入此时CPU时间不涨但墙钟时间在走。所以判题超时要区分“CPU时间超限”和“墙钟时间超限”。一般OJ题目限制的是“运行总时间”通俗讲就是从开始到结束的墙钟时间。我最终是同时监控两种墙钟超了直接判TLECPU时间超了也判TLE但打印信息里会注明超的是CPU时间。4.3 文件系统与网络隔离细节权限策略里我只放开了/judge/tmp/user/-目录但这个目录下的文件用户代码能读能写意味着它可以往自己目录写大量垃圾文件把磁盘填满。只是“不炸系统”还不够还要“不炸自己的临时目录”。我在子进程里安装了一个自定义FilePermission检查限制每个用户目录的大小。具体做法比较暴力每写入一定量数据就检查当前用户目录总大小超过阈值比如20MB就抛SecurityException。判题场景下题目标准输入输出本身就不大20MB完全够用。网络访问方面除了策略文件不给SocketPermission外我还在自定义SecurityManager里直接禁止所有SocketPermission包括连接、监听、接受动作。这样即使有人绕过策略文件也会被第二道防线拦下。5. 常见问题与排查技巧实录5.1 安全策略不生效怎么办新手最容易遇到的问题是明明设置了SecurityManager但用户代码还是能读文件或写系统属性。排查思路确认SecurityManager真的被安装成功。在子进程启动后立即打印System.getSecurityManager()如果你得到null那就是安装失败或者被后来人覆盖了。确认codeBase路径匹配。Class文件的实际路径是不是和策略文件里完全一致file:/judge/tmp/user/*里的*只能匹配一层目录无法匹配多层路径这点特别容易踩。确认用户代码不是通过AccessController.doPrivileged绕过的。如果你的策略里给某个目录授权过宽比如grant { ... }没有codeBase那么所有代码都有那份权限包括用户代码。写策略文件时永远不要写不带codeBase的全局grant除非你确实想开放给所有代码。5.2 用户线程不被中断怎么办我前面提过线程中断不是万能的。如果你用Thread.interrupt()用户代码里的Thread.sleep()会响应但一个纯计算的死循环是永远不响应中断的。这时候只能靠进程级终止。如果你的判题机没有采用子进程方式就会出现“任务超时了但CPU一直高”的假死状态。排查时可以用jstack pid看线程栈如果发现用户线程一直停在run方法里的循环字节码上说明确实停不下来只能杀进程。5.3 用户代码探测类库信息有些提交者会试图反射获取类路径下的文件或者读取已知类库的类名。SecurityManager能限制文件访问但无法完全阻止反射获取JDK内部的类信息。不过OJ场景下这种探测类库的行为威胁不大我们只需要保证核心判题数据比如标准答案、其他用户提交代码不在可访问目录中即可。判断用户是否在做恶意探测可以在输出里检测敏感字符串但这种策略容易误伤我没做太多。5.4 踩坑记录并发判题下的安全管理器冲突如果多线程共用一个JVM进行判题那么System.setSecurityManager是全局状态。用户A的线程设置了安全管理器可能导致用户B的线程权限异常。所以我的最终架构是“一个判题任务一个子进程”子进程之间天然隔离根本不存在共享安全管理器的问题。代价是启动子进程有几百毫秒开销但对于一般OJ的并发量来说完全可接受。另一个坑是用户代码通过自定义ClassLoader加载其他class。默认系统类加载器只会加载classpath下的类。我在子进程里给用户代码单独建了一个URLClassLoader指向/judge/tmp/user/taskId/这样用户代码之间不会互相影响。同时这个ClassLoader要交给Thread.currentThread().setContextClassLoader否则有些动态代理会加载失败。5.5 结果判定的“玄学”问题比对输出时最影响体验的是末尾换行和空行。我比对前会先去除首尾空白、统一换行符为\n。但对于需要精确空格的题目不能盲目trim。所以我提供了两种比对模式精确模式一模一样和宽松模式忽略行尾空白。这不算安全范畴但直接影响OJ的判题准确性。最后分享一点个人体会说实话用原生Java SecurityManager做OJ判题隔离已经是相对“古典”的做法了。JDK 11之后SecurityManager被标记为deprecated不用太久JDK新版本里可能真的会被移除。如果你现在是从零开始设计一个长期维护的OJ后端我建议你优先考虑容器化方案——Docker或更轻的gVisor。但如果你维护的是一个老系统、Java版本锁死在8或11或者你只是想理解“代码执行隔离”背后的原理那这套原生实现依然非常有学习价值。我在整个项目里最大的收获不是“跑通了判题”而是理解了JVM权限模型和线程控制的底层逻辑。很多问题只有在真实对抗恶意代码时才会暴露比如你以为System.exit拦住了其实用户换一种方式调用又绕过去了你以为线程超时靠interrupt就够结果CPU还是被打满。这些坑不亲自踩一遍很难对“安全执行不可信代码”有真正的敬畏感。最后再分享一个小技巧如果你不想维护复杂的策略文件可以把判题子进程的SecurityManager逻辑单独抽成一个公共库然后用Java Agent的方式注入这样主进程和判题子进程的权限模型完全解耦后续切换容器化方案时也只需要改一层接口不用动判题核心逻辑。