尽管Java™生态系统中有许多Web框架,但它们都直接或间接基于Servlets基础结构。 Servlets API提供了许多有用的功能,包括通过HttpSession和ServletContext机制进行的状态管理,这些功能允许应用程序维护在多个用户请求之间持久存在的状态。 但是,一些微妙的(且大部分是未成文的)规则控制Web应用程序中共享状态的使用,其中许多应用程序在不知不觉中都无法实现。 结果是许多有状态的Web应用程序都有细微而严重的缺陷。
Servlet规范中的ServletContext , HttpSession和HttpRequest对象称为范围容器 。 每个方法都有getAttribute()和setAttribute()方法,它们代表应用程序存储数据。 它们之间的区别是作用域容器的生存期。 对于HttpRequest ,数据仅在请求的生存期内保留; 对于HttpSession ,它在用户和应用程序之间的会话生存期内一直存在; 对于ServletContext ,它在应用程序的生存ServletContext一直存在。
由于HTTP协议是无状态的,因此作用域容器在有状态Web应用程序的构造中非常有用。 Servlet容器负责管理应用程序状态和数据生命周期。 尽管规范在这个主题上没有任何内容,但是基于会话和应用程序范围的容器在某种程度上也必须是线程安全的,因为getAttribute()和setAttribute()方法可以随时由不同的线程调用。 (规范并没有直接要求这些实现必须是线程安全的,但是它们有效提供的服务的性质需要它。)
范围限定的容器还为Web应用程序提供了另一个潜在的重大好处:容器可以透明地管理复制和应用程序状态的故障转移。
会话是特定用户和Web应用程序之间的一系列请求-响应交换。 用户希望网站记住他们的身份验证凭据,购物车中的内容以及在先前请求中以Web表单输入的信息,但是核心HTTP协议是无状态的,这意味着有关请求的所有信息都必须存储在请求中本身。 因此,要与持续时间长于单个请求-响应周期的用户进行有用的交互,必须在某处维护会话状态。 Servlet框架允许将每个请求与会话相关联,并提供HttpSession接口以充当与该会话相关的(键,值)数据项的值存储。 清单1展示了典型的servlet代码,它将购物车数据存储在HttpSession :
清单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中的代码的许多事情都被打破了。 这里采用的方法是在ServletContext存储一个可变的持有人,以获取得分高的球员的姓名和得分。 当达到新的高分时,名称和分数都必须更新。
假设当前的高得分玩家是Bob,得分为1000,而他的得分被Joe击败,得分为1100。在安装Joe得分的时间临近时,另一位玩家要求获得高分。 getHighScore()方法将从Servlet上下文中检索PlayerScore对象,并从中获取名称和得分。 但是,由于时机不佳,可以检索Bob的名字和Joe的分数,这表明Bob的分数达到了1100,这从未发生过。 (对于免费游戏网站,此失败可能是可以接受的,但是用“银行余额”代替“得分”,似乎没有什么危害。)这是原子性失败,因为两个操作相对于彼此是原子性的实际上,获取名称/分数对和更新名称/分数对并不相互原子执行,并且允许线程之一以不一致的状态查看共享数据。
此外,由于分数更新逻辑遵循“ 先检查后行动”的模式,因此两个线程有可能“竞相”更新高分数,从而产生不可预测的结果。 假设当前的高分是1000,并且两个玩家同时注册了1100和1200的高分。在一些不幸的时机下,他们都将通过“新分数是否高于现有高分”的测试,并且都将进入更新的区块高分。 同样,取决于时间安排,结果可能不一致(一个玩家的名字和另一个玩家的高分),或者是错误的(玩家得分1100可能会覆盖玩家得分1200的名字和得分)。
比原子性故障更微妙的是可见性故障 。 在没有同步的情况下,如果一个线程写入一个变量,而另一个线程读取该变量,则读取线程可能会看到陈旧的或过时的数据。 更糟糕的是,即使y是在x之前写入的,读取线程也可能会看到变量x的最新数据和变量y陈旧数据。 可见性故障之所以微妙,是因为它们不是可预测的,甚至是不经常发生的,从而导致罕见且难以调试的间歇性故障。 可见性故障是由数据争用造成的,即在访问共享变量时无法正确同步。 出于所有目的和目的,具有数据争用的程序都已被破坏,因为无法可靠地预测其行为。
Java内存模型(JMM)定义了确保读取变量的线程在其他线程中看到写入结果的条件。 (JMM的的完整解释超出了本文的范围;请参阅相关主题 。)的JMM定义了一个程序的调用操作的顺序之前发生 。 线程之间的先发生顺序只能通过在公共锁上进行同步或访问公共volatile变量来创建。 在没有发生顺序之前,Java平台具有很大的自由度来延迟或更改一个线程中的写入对于另一线程中的同一变量的读取可见的顺序。
清单2中的代码具有可见性故障以及原子性故障。 所述updateHighScore()方法检索HighScore从物体ServletContext ,然后修改的状态HighScore对象。 目的是使那些修改对调用getHighScore()其他线程可见,但在没有发生任何情况的情况下-在对updateHighScore()的name和score属性的写入与其他线程中的这些属性的读取之间进行排序之前调用getHighScore() ,我们依靠好运来使读取线程看到正确的值。
尽管servlet规范没有充分描述servlet容器必须提供的事前保证,但人们不得不得出这样的结论:在另一个线程检索相同属性之前,将属性放置在共享作用域的容器( HttpSession或ServletContext )中。 (有关此结论背后的原因,请参见JCiP 4.5.1。所有规范都说:“执行请求线程的多个servlet可能同时具有对单个会话对象的活动访问权限。开发人员负责同步对会话资源的访问,适当。”)
通常引用的“最佳实践”是,当更新存储在作用域会话容器中的可变数据时,必须在修改数据后再次调用setAttribute() 。 清单3显示了使用此技术重写的updateHighScore()的示例。 (此技术的动机之一是向容器提示值已更改,以便可以在分布式Web应用程序中的各个实例之间重新同步会话或应用程序状态。)
不幸的是,尽管此技术解决了在集群应用程序中有效复制会话和应用程序状态的问题,但仅解决示例中的基本线程安全问题还不够。 足以缓解可见性问题(另一个玩家可能永远不会看到updateHighScore()更新的值),但不足以解决多个潜在的原子性问题。
写后设置技术能够消除可见性问题,因为在排序之前发生的情况是可传递的,并且在updateHighScore()对setAttribute()的调用与在updateHighScore()的对getAttribute()的调用之间存在一个before-before边缘。 getHighScore() 。 由于对HighScore状态的更新发生在setAttribute()之前setAttribute()发生在getAttribute()返回之前getAttribute() (发生在getHighScore()的调用者使用状态之前getHighScore() ,因此传递性可以让我们得出结论,即getHighScore()至少与最近对setAttribute()调用一样。 这项技术称为“ 背负式同步” ,因为getHighScore()和updateHighScore()方法能够使用其在getAttribute()和setAttribute()的同步知识来提供对可见性的最小保证。 但是,在编写的示例中,这还不够。 写后置位技术对于状态复制可能有用,但是不足以提供线程安全性。
创建线程安全应用程序的一种有用技术是尽可能依赖不变的数据。 清单4显示了我们的高分示例,该示例改写为使用HighScore的不可变实现,该实现没有原子性故障(允许调用者查看不存在的玩家/得分对),以及可见性故障getHighScore()阻止了getHighScore()的调用者) getHighScore()查看调用updateHighScore()写入的最新值:
清单4中的代码具有更少的潜在故障模式。 在setAttribute()和getAttribute()同步可以保证可见性。 仅存储单个不可变数据项的事实消除了潜在的原子性故障,即getHighScore()的调用者可能会看到名称/得分对的不一致更新。
将不可变对象放置在有作用域的容器中可避免大多数原子性和可见性失败; 将有效的不可变对象放置在有作用域的容器中也是安全的。 有效的不可变对象是那些虽然在理论上是可变的但在发布后从未真正修改过的对象,例如JavaBean,其将设置对象放置在HttpSession之后从未调用过setter。
放置在HttpSession数据不仅可以被该会话上的请求访问; 如果容器正在进行任何形式的状态复制,则容器本身也可以访问它。
放置在HttpSession或ServletContext中的所有数据都应该是线程安全的或有效地不可变的。但是, 清单4中的代码仍然存在一个问题updateHighScore()的check-then-act方法仍然允许试图更新高分的两个线程之间发生潜在的竞争。 如果运气不好,更新可能会丢失。 两个线程可能同时通过“新的高分比旧的高分”检查,从而导致两者都调用setAttribute() 。 根据时间的不同,不能保证这两个分数中的较高者会获胜。 要关闭最后一个漏洞,我们需要一种在保证不受干扰的同时自动更新得分参考的方法。 可以使用几种方法来这样做。
清单5向updateHighScore()添加了同步,以确保更新过程中固有的updateHighScore()后行动不能与另一个更新同时执行。 只要所有这些条件修改逻辑都获得updateHighScore()使用的相同锁,则此方法就足够了。
尽管清单5中的技术有效,但还有一种更好的技术:使用java.util.concurrent包中的AtomicReference类。 此类设计为通过compareAndSet()调用提供原子条件更新。 清单6显示了如何使用AtomicReference将最后的原子性还原到我们的示例中。 这种方法比清单5中的代码更可取,因为很难偶然违反关于如何更新高分的假设。
在到目前为止的示例中,我尝试避免了与访问整个应用程序范围ServletContext中的数据相关的各种危险。 显然,访问ServletContext时需要仔细协调,因为可以从任何请求访问ServletContext 。 但是,大多数有状态的Web应用程序更多地依赖于会话范围的容器HttpSession 。 在同一个会话中如何同时发生多个并发请求可能并不明显; 毕竟,一个会话与特定的用户和浏览器会话相关联,并且用户似乎可能不会一次请求多个页面。 但是,会话请求可能会在以编程方式生成请求的应用程序(例如AJAX应用程序)中重叠。
单个会话上的请求确实可以重叠,这种能力是不幸的。 如果可以很容易地序列化会话上的请求,则访问HttpSession共享库时,这里描述的几乎所有危险都不会成为问题。 序列化将防止原子性失败,而在HttpSession隐式进行的同步HttpSession将防止可见性失败。 并且序列化绑定到特定会话的请求不太可能对吞吐量产生任何重大影响,因为在一个会话上有多个请求完全重叠是很少见的,而在一个会话上有多个请求却很少见。
不幸的是,servlet规范中没有选项说“强制将同一会话上的请求序列化”。 但是,SpringMVC框架提供了一种要求这样做的方法,并且可以轻松地在其他框架中重新实现该方法。 SpringMVC控制器的基类AbstractController提供了一个布尔变量synchronizeOnSession ; 设置此选项后,它将使用锁来确保会话中只有一个请求可以同时执行。
在HttpSession上对请求进行序列化会消除许多并发危险,这类似于将对象限制在事件分发线程(EDT)中,从而减少了Swing应用程序中同步的需求。许多有状态的Web应用程序都具有严重的并发漏洞,这些漏洞是由于在没有充分协调的情况下访问可变数据存储的范围限定的容器(如HttpSession和ServletContext 。 容易错误地假设getAttribute()和setAttribute()方法中固有的同步就足够了,但是仅在某些情况下才成立,例如,当属性是不可变的,有效的不可变的或线程安全的时对象,或何时可能访问容器的请求被序列化。
通常,放置在有作用域的容器中的所有内容都应有效地保持不变或线程安全。 Servlet规范提供的作用域容器机制从未打算管理不提供其自身同步的可变对象。 最大的罪魁祸首是将普通的JavaBeans类存储在HttpSession 。 只有在将JavaBean存储在会话中之后再也不要修改JavaBean时,才能保证使用此技术。
翻译自: https://www.ibm.com/developerworks/java/library/j-jtp09238/index.html