虽然 Java™ 生态系统中存在许多 Web 框架,但它们都直接或间接地基于 Servlets 基础设施。Servlets API 提供大量有用特性,包括通过 HttpSession
和 ServletContext
机制提供的状态管理,它允许应用程序在跨多个用户请求时保持状态。然而,在 Web 应用程序中使用共享状态受一些微妙的(并且大部分没有进行说明)的规则控制着,因此导致许多应用程序无意中触犯了规则。结果是许多有状态 Web 应用程序存在难以发觉的严重缺陷。
在 Servlet 规范中,ServletContext
、HttpSession
和 HttpRequest
对象被称为 范围容器(Scoped container)。它们都有 getAttribute()
和 setAttribute()
方法,为应用程序存储数据。这些范围容器的区别在于它们的生命周期不同。对于 HttpRequest
,数据只在请求期间有效;对于 HttpSession
,数据在用户和应用程序的会话期间有效;而对于 ServletContext
,数据在应用程序的整个生命周期中都有效。
由于 HTTP 协议是没有状态的,所以范围容器在构造有状态 Web 应用程序时十分有用;servlet 容器负责管理应用程序的状态和数据的生命周期。尽管这个规范没有强调,但需要保证嵌套在会话或应用程序中的容器在某种程度上是线程安全的,因为 getAttribute()
和 setAttribute()
方法随时都可能被不同的线程调用(这个规范没有直接要求这些实现必须是线程安全的,但它们提供的服务实际上提出了这一点)。
范围容器还为 Web 应用程序提供一个潜在的好处:容器可以透明地管理应用程序状态的复制和故障转移。
session 是特定用户和 Web 应用程序之间的一系列请求-响应交换。用户希望 Web 站点记住他的身份验证凭证、购物车的物品,以及在前面的请求中输入到 Web 表单的信息。但核心 HTTP 协议是没有状态的,这意味着必须将所有请求信息存储到请求本身。因此,如果要创建比一个请求-响应周期更长的有用交互,就必须存储会话状态。servlet 框架允许将每个请求与一个会话关联起来,并提供充当值存储库的 HttpSession
接口,用于存储与该会话相关的(键,值)数据项。清单 1 是一段典型的 servlet 代码,它用于在 HttpSession
中存储购物车数据:
清单 1. 使用 HttpSession 存储购物车数据
HttpSession session = request.getSession(true); ShoppingCart cart = (ShoppingCart)session.getAttribute("shoppingCart"); if (cart == null) { cart = new ShoppingCart(...); session.setAttribute("shoppingCart"); } doSomethingWith(cart); |
清单 1 提供的是 servlet 的典型用途;这个应用程序查看是否已将一个对象放到会话中,如果还没有,它将创建一个该会话的后续请求可以使用的对象。构建在 servlet 之上的 Web 框架(比如 JSP、JSF 和 SpringMVC 等)隐藏了细节情况,但对于标记为嵌入到会话中的数据,它们替您执行了实际操作。不幸的是,清单 1 中的用法很可能是不正确的。
当 HTTP 请求到达 servlet 容器之后,会在 servlet 容器管理的线程上下文中创建 HttpRequest
和 HttpResponse
对象,并传递给 servlet 的 service()
方法。servlet 负责生成响应;在响应完成之前 servlet 一直控制这个线程,响应完成时该线程返回到可用的线程池。Servlet 容器没有保持线程与会话之间的联系;某个会话的下一个请求很可能由另一个不同的线程来处理。事实上,可能有多个请求同时进入同一个会话(当用户与页面交互时,使用框架或 AJAX 技术从服务器获取数据的 Web 应用程序可能发生这种现象)。在这种情况,同一用户可能发出多个请求,这些请求在不同的线程上并行执行。
大多数情况下,这类线程问题与 Web 应用程序开发人员无关。由于自身没有状态,HTTP 鼓励只有存储在请求中的数据(不与其他并发请求共享)和存储在存储库(比如数据库)中的、已进行并发性控制的数据,才具有响应功能。然而,一旦 Web 应用程序将数据存储在 HttpSession
或 ServletContext
等共享容器之后,该 Web 应用程序就具有了并发性,因此必须考虑应用程序内部的线程安全问题。
尽管我们常用线程安全描述代码,但实际上它是描述数据的。具体来说,线程安全是指适当地协调对被多个线程访问的可变数据的访问。Servlet 应用程序通常是线程安全的,因为它们没有共享任何可变数据,因此就不需要额外的同步。但可以通过很多种办法将共享状态引入到 Web 应用程序 — 除了 HttpSession
和 ServletContext
等范围容器外,还可以使用 HttpServlet
对象的静态字段和实例字段。如果要让 Web 应用程序跨请求共享数据,开发人员就必须注意共享数据的位置,并保证访问共享数据时线程之间有足够的协作(同步),以避免与线程相关的危险。
如果 Web 应用程序将购物车等可变会话数据存储在 HttpSession
中,就有可能出现两个请求试图同时访问该购物车。这可能导致以下几种故障:
- 原子性故障。在数据不一致的状态下,一个线程正在更新多个数据项,而另一个线程正在读取这些数据
- 读线程和写线程之间的可见性故障。一个线程修改购物车,但另一个线程看到的购物车内容的状态是过时的或不一致的
清单 2 展示了一个用于在游戏应用程序中设置和获取最高分的不恰当的方法实现。它使用一个 PlayerScore
对象来表示最高分,这实际上是一个具有 name
和 score
属性的普通 JavaBean 类,存储在内嵌于应用程序的 ServletContext
中(假设在应用程序启动时,初始最高分在 ServletContext
中被设置为 highScore
属性,因此 getAttribute()
调用不会失败)。
清单 2. 在范围容器中存储相关项的不恰当模式
public PlayerScore getHighScore() { ServletContext ctx = getServletConfig().getServletContext(); PlayerScore hs = (PlayerScore) ctx.getAttribute("highScore"); PlayerScore result = new PlayerScore(); result.setName(hs.getName()); result.setScore(hs.getScore()); return result; } public void updateHighScore(PlayerScore newScore) { ServletContext ctx = getServletConfig().getServletContext(); PlayerScore hs = (PlayerScore) ctx.getAttribute("highScore"); if (newScore.getScore() > hs.getScore()) { hs.setName(newScore.getName()); hs.setScore(newScore.getScore()); } } |
清单 2 中的代码不够好。这里采用的方法是在 ServletContext
中存储一个包含最高分玩家的名字和分数的可变容器。当打破记录时,必须更新名字和分数。
假如当前的最高分玩家是 Bob,他的分数为 1000,但是 Joe 以 1100 分打破了这个记录。在正要设置 Joe 的分数时,另一个玩家又发出获得最高分的请求。getHighScore()
将从 servlet 上下文获取 PlayerScore
对象,然后从该对象获取名字和分数。如果不慎出现计时失误,就有可能获取 Bob 的名字和 Joe 的分数,显示 Bob 取得了 1100 分,而实际上 Bob 并没有获得这个分数(这种故障在免费游戏站点上是可以接受的,因为 “分数” 并不是 “银行存款”)。这是一种原子性故障,因为彼此之间本应该是原子关系的两个操作 — 获取名字/分数对和更新名字/分数对 — 实际上并没有按照原子关系执行,而且其中一个线程可以在不一致状态下查看共享数据。
另外,由于分数更新逻辑遵循 check-then-act 模式,因此可能出现两个线程 “争夺” 更新最高分,从而导致难以预料的结果。假设当前的最高分是 1000,有两个玩家同时注册更高的分数 1100 和 1200。如果出现计时失误,这两个分数都能够通过 “高于现有分数的最高分” 检查,并且都进入到更新最高分的代码块中。和前面一样,根据计时的实际情况,最后的结果可能不一致(采用一个玩家的名字和另一个玩家的分数),或者出现错误(分数为 1100 的玩家可能覆盖分数为 1200 的玩家)。
比原子性故障更复杂的是可见性 故障。没有同步时,如果一个线程读取另外一个线程正在写的变量,读的线程将看到过时的 数据。更糟糕的是,读线程还可能会看到 x
变量的最新数据和 y
变量的过时数据,即使先写 y
变量也是这样。可见性故障非常复杂,因为它的发生是随机的,甚至是频繁的,这会导致罕见的难以调试的间发性故障。可见性故障是由数据争夺引起的 — 访问共享变量时不能正确同步。争夺数据的程序,不管它是什么样的,都属于有漏洞的程序,因为它们的行为不能可靠预测。
Java Memory Model(JMM)定义一些条件,它们保证读变量的线程能够看到另一个线程的写入结果(详细讲解 JMM 超出了本文的范围;参见 参考资料)。JMM 在一个称为 happens-before 的程序的操作上定义一个排序。只有在通用锁上执行同步或访问一个通用的可变变量时,才能创建跨线程的 Happens-before 排序。在没有 happens-before 排序的情况下,Java 平台可以延迟或更改顺序,按照这个顺序,一个线程的写操作对于另一个读取同一变量的线程是可见的。
清单 2 中的代码不仅有原子性故障,还有可见性故障。updateHighScore()
方法从 ServletContext
获取 HighScore
对象,然后修改它的状态。这样做的目的是让其他调用 getHighScore()
的线程看见这些修改,但是如果 updateHighScore()
的 name 和 score 属性的写操作和其他调用 getHighScore()
的线程的 name 和 score 属性的读操作之间没有 happens-before 排序,我们只能期盼运气好些,让读线程能够看到正确的值。
尽管 servlet 规范没有充分地描述 servlet 容器必须提供的 happens-before 保证,但可以得出结论:将一个属性放置在共享范围容器(HttpSession
或 ServletContext
)应该在另一个线程获取该属性之前发生。(参见 JCiP4.5.1 了解这个结论的推理过程。该规范中这样描述:“执行请求线程的多个 servlets 可能同时积极地访问单个会话对象。开发人员负责恰当地同步对会话资源的访问)。
更新存储在其他会话容器中的可变数据时,必须在修改该数据后再次调用 setAttribute()
。这是一种常用的最佳实践。清单 3 展示了重写 updateHighScore()
以使用这个技巧的示例(这个技巧的目的之一是提示容器值已经更改,因此可以在分布式 Web 应用程序的各个实例之间重新同步会话和应用程序状态)。
清单 3. 使用 set-after-write 技巧提示 servlet 容器值已经更新
public void updateHighScore(PlayerScore newScore) { ServletContext ctx = getServletConfig().getServletContext(); PlayerScore hs = (PlayerScore) ctx.getAttribute("highScore"); if (newScore.getScore() > hs.getScore()) { hs.setName(newScore.getName()); hs.setScore(newScore.getScore()); ctx.setAttribute("highScore", hs); } } |
不幸的是,尽管这个技巧能够在集群应用程序中高效地复制会话和应用程序状态,但它不能解决本例中的基本线程安全问题。它可以减轻可见性问题(即另一个玩家可能永远看不到在 updateHighScore()
中更新的值),但还不能解决许多潜在的原子性问题。
set-after-write 技巧可以消除可见性问题,因为 happens-before 排序是可传递的,因而调用 updateHighScore()
中的 setAttribute()
和调用 getHighScore()
中的 getAttribute()
之间有一个边缘地带。因为 HighScore
状态的更新在 setAttribute()
之前发生,setAttribute()
状态的更新在从 getAttribute()
返回之前发生,getAttribute()
状态的更新在 getHighScore()
的调用方使用状态之前发生,所以通过这种传递可以得出结论:调用方 getHighScore()
看到的值至少和 setAttribute()
的最近一次调用一样新。这个技巧称为利用同步(piggybacking on synchronization),因为 getHighScore()
和 updateHighScore()
方法能够在 getAttribute()
和 setAttribute()
中使用同步信息来提供一些可见性保证。然而,在上面这个例子中,这还不能完全解决问题。set-after-write 技巧可能对状态复制非常有用,但还不能提供线程安全。
要创建线程安全的应用程序,一个有用的技巧便是尽可能多地使用不可修改的数据。清单 4 展示了重写后的最高分示例,它使用了 HighScore
的不可修改的 实现,从而避免了原子性故障(允许调用方看见不存在的玩家/分数对)和可见性故障(阻止 getHighScore()
的调用方看见在调用 updateHighScore()
时写的最新值):
清单 4. 使用不可修改的 HighScore 对象修复原子性和可见性漏洞
Public class HighScore { public final String name; public final int score; public HighScore(String name, int score) { this.name = name; this.score = score; } } public PlayerScore getHighScore() { ServletContext ctx = getServletConfig().getServletContext(); return (PlayerScore) ctx.getAttribute("highScore"); } public void updateHighScore(PlayerScore newScore) { ServletContext ctx = getServletConfig().getServletContext(); PlayerScore hs = (PlayerScore) ctx.getAttribute("highScore"); if (newScore.score > hs.score) ctx.setAttribute("highScore", newScore); } |
清单 4 中的代码的潜在故障很少。在 setAttribute()
和 getAttribute()
中使用同步保证了可见性。实际上,仅存储单个不可修改数据项消除了潜在的原子性故障,即 getHighScore()
的调用方可以看见名字/分数对的不一致更新。
将不可修改对象放置在范围容器避免了许多原子性和可见性故障;将有效不可修改性 对象放置在范围容器中也是安全的。有效不可修改性对象是指那些虽然理论上是可修改的,但实际上在发布之后再没有被更改过的对象,比如 JavaBean,将一个对象放置到 HttpSession
中之后,它的 setter 方法就不再被调用。
放置在 HttpSession
中的数据不仅被该会话的请求访问;它还可能被容器本身访问(如果容器进行状态复制的话)。
所有放置在HttpSession
或ServletContext
中的数据应该是线程安全的或有效不可修改的。
但是 清单 4 中的代码仍然有一个问题 —updateHighScore()
中的 check-then-act 仍然使两个试图更新最高分数的线程之间存在潜在 “争夺”。如果计时失误,有一个更新可能会丢失。两个线程可能同时通过了 “高于现有分数的新最高分” 检查,造成它们同时调用 setAttribute()
。不能确保两个分数中最高者获得调用,这取决于计时。要修复这个最后的漏洞,我们需要一种原子性地更新分数引用的方法,同时又要保证不受干扰。有几种方法可以实现这个目的。
清单 5 为 updateHighScore()
添加了同步,确保更新进程中固有的 check-then-act 不和另一个更新并发执行。如果所有条件修改逻辑获得 updateHighScore()
使用的同一个锁,用这种方法就可以了。
清单 5. 使用同步修复最后一个原子性漏洞
public void updateHighScore(PlayerScore newScore) { ServletContext ctx = getServletConfig().getServletContext(); PlayerScore hs = (PlayerScore) ctx.getAttribute("highScore"); synchronized (lock) { if (newScore.score > hs.score) ctx.setAttribute("highScore", newScore); } } |
虽然清单 5 中的技术是可行的,但还有一个更好的技术:使用 java.util.concurrent
包中的 AtomicReference
类。这个类的用途就是通过 compareAndSet()
调用提供原子条件更新。清单 6 展示了如何使用 AtomicReference
来修复本示例的最后一个原子性问题。这个方法比清单 5 中的代码好,因为很难违背更新最高分数的规则。
清单 6. 使用 AtomicReference 来修复最后一个原子性漏洞
public PlayerScore getHighScore() { ServletContext ctx = getServletConfig().getServletContext(); AtomicReference<PlayerScore> holder = (AtomicReference<PlayerScore>) ctx.getAttribute("highScore"); return holder.get(); } public void updateHighScore(PlayerScore newScore) { ServletContext ctx = getServletConfig().getServletContext(); AtomicReference<PlayerScore> holder = (AtomicReference<PlayerScore>) ctx.getAttribute("highScore"); while (true) { HighScore old = holder.get(); if (old.score >= newScore.score) break; else if (holder.compareAndSet(old, newScore)) break; } } |
对于放置在范围容器中的可修改数据,应该将它们的状态转换变成原子性的,这可以通过同步或
java.util.concurrent
中的原子变量类来实现。
在我已给出的示例中,我试图避免与访问整个应用程序中的 ServletContext
相关的各种危险。很明显,访问 ServletContext
时需要细心的协作,因为任何请求都可以访问 ServletContext
。然而,大多数有状态 Web 应用程序严重依赖于内嵌于会话的容器 HttpSession
。同一个会话中发生多个同步请求的原因不是很直观;毕竟,每个会话都是绑定到一个特定用户或浏览器会话的,并且用户不一定一次请求多个页面。但是在编程式地生成请求的应用程序中(比如 AJAX 应用程序),一个会话中的请求是可以重叠的。
单个会话中的请求当然可以是重叠的,但这不是一件好事。如果可以轻松地序列化会话中的请求,当访问 HttpSession
中的共享对象时,这里提到的所有问题几乎都可以解决;序列化可以阻止原子性故障,并且利用 HttpSession
中的同步可以阻止可见性故障。序列化特定会话的请求不会对吞吐量造成很大的影响,因为一个会话中的请求很少重叠,在一个会话中出现很多请求重叠就更罕见了。
不幸的是,servlet 规范并没有提到 “序列化同一会话中的请求”。不过 SpringMVC 框架提供了一种方法,并且这种方法可以在其他框架中轻松实现。SpringMVC 控制器的基类 AbstractController
提供了一个布尔变量 synchronizeOnSession
;设置这里之后,它将使用一个锁,确保一个会话中只同时执行一个请求。
序列化
HttpSession
上的请求消除了很多并发性危险。同样,将对象限制在 Event Dispatch Thread(EDT)中减少了 Swing 应用程序中的同步需求。
许多有状态 Web 应用程序有很严重的并发性漏洞。在没有足够协调的情况下,访问存储在 HttpSession
和 ServletContext
等范围容器中的可变数据就会产生这些漏洞。开发人员通常会误认为 getAttribute()
和 setAttribute()
方法中的同步已经足够 — 但这只能应付特定情况,比如当属性是不可修改、有效不可修改或线程安全的对象时,或当可能访问容器的请求被序列化时。通常,放置在范围容器中的所有内容都应该是高度不可修改的或线程安全的。servlet 规范提供的范围容器机制并不管理没有提供它们自己的同步的可变对象。将普通的 JavaBean 类存储在 HttpSession
中是很大的隐患。只有将 JavaBean 存储在会话中之后不再对其进行修改时,这种方法才有效。