Servlet之存在安全隐患的域对象

Web Server 与 Servlet Container

它们之间的区别请参考:https://dzone.com/articles/what-servlet-container

Servlet Demo

在进一步了解Servlet之前,我先写一个Demo,看看写一个Servlet具体都有什么步骤。Servlets是一个Java类,它处理相应的HTTP请求并且实现了javax.servlet.Servlet接口。Web应用开发者写servlets通常是继承javax.servlet.http.HttpServlet. HttpServlet是一个abstract类,它实现了Servlet接口并且它是为了处理HTTP请求专门设计的。

Sample Code for Hello World

HelloWorld.java文件如下。

public class HelloWorld extends HttpServlet {

    private String message;
    
    public void init() throws ServletException {
      // Do required initialization
      message = "Hello World";
    }

	public void doGet(HttpServletRequest request, HttpServletResponse response)
			throws ServletException, IOException {
        // Set response content type
        response.setContentType("text/html");

        // Actual logic goes here.
        PrintWriter out = response.getWriter();
        out.println("<h1>" + message + "</h1>");
	}
	
	public void doPost(HttpServletRequest request, HttpServletResponse response)
			throws ServletException, IOException {
		doGet(request, response);
	}
	
	public void destroy() {
        // do nothing.
    }
}

web.xml文件如下。

<servlet>
   <servlet-name>HelloWorld</servlet-name>
   <!-- 全限定类名 -->
   <servlet-class>HelloWorld</servlet-class>
</servlet>

<servlet-mapping>
   <servlet-name>HelloWorld</servlet-name>
   <url-pattern>/HelloWorld</url-pattern>
</servlet-mapping>

访问路径为:http://localhost:8080/HelloWorld

Servlet生命周期

一个Servlet的生命周期可以定义为从创建到销毁的整个过程。The following are the paths followed by a servlet

  • 调用**init()**方法初始化servlet
  • servlet调用**service()**方法处理一个客户端的请求
  • 调用**destroy()**方法终结servlet
  • 最后,JVM的garbage collector对servlet进行垃圾收集

接下来,让我们讨论生命周期方法的具体细节。

init()

当一个用户第一次通过URL请求时,URL对应的servlet实例被创建,init方法被调用并且它只被调用一次,对于剩下的请求,它不会再次被调用。但是,你也可以通过配置让servlet在服务器启动时就创建实例。比如,对于上面的例子来说,配置如下:

<servlet>
   <servlet-name>HelloWorld</servlet-name>
   <!-- 全限定类名 -->
   <servlet-class>HelloWorld</servlet-class>
   <!-- 值越小,优先级越高 -->
   <load-on-startup>1</load-on-startup>  
</servlet>

service()

service方法是执行实际任务的主要方法。servlet容器(例如:Tomcat)调用service方法去处理来自客户端(例如:浏览器)的请求,接着把一些内容返回到客户端。servlet容器每次收到请求时,它会给这个请求分配一个线程并调用service方法,接着service方法检查HTTP请求的类型(GET, POST, PUT, DELETE, etc.)并根据相应的类型调用doGet, doPost, doPut, doDelete, etc. 因此,你只需要根据对应的请求类型,在你自己的servlet中重写,例如:doGet方法就就行。

destroy()

当servlet容器关闭时,它会卸载掉所有的Web应用,调用各个servlet实例的destroy方法,destroy方法也只被调用一次。你可以在destroy方法中关闭database连接、终止后台线程、把cookie列表或点击次数写入到硬盘或者执行一些其它的清理任务。

Servlet生命周期总结

下图描述了servlet生命周期:

Servlet生命周期

  • 首先,HTTP请求到达servlet容器,容器找到相应的servlet方法,如果是第一次请求这个servlet,则调用init方法。
  • 然后,servlet容器为每个请求都分配相应的线程,调用对应servlet的service方法。注意:每个servlet只有一个实例。
  • 最后,当容器关闭时,容器调用各个Servlet的destroy方法。

Servlet线程安全性

从上面我描述的生命周期可以看出,当一个请求通过容器第一次到达对应的Servlet时,容器初始化Servlet,并创建相应的Servlet实例。剩下的请求再次到达这个Servlet时,容器不会再次创建一个Servlet实例了,而是用已经存在的这个Servlet实例去响应请求。因此,如果你在Servlet中定义实例变量,它会被所有的请求所共享,因此它是线程不安全的。如果想要它是线程安全的,你尽量不要在Servlet中定义实例变量,或者你定义的变量是不可变对象(或者理论上可变,实际上不变的对象也行)。或者你也可以用一些现有的框架解决这个问题,比如Struts 2,它的action是线程安全的,每个请求都对应着一个action实例,因此action的实例变量只被单个请求所访问。

Scoped containers

在servlet规范中,ServletContext、HttpSession和HttpServletRequest这3个对象被称为scoped containers. 它们3个都有getAttribute()和setAttribute()方法,但是它们3个的有效期不同。对于HttpRequest来说,数据仅仅保持在一次请求的范围内;对于HttpSession来说,它保存的数据存在于用户与web应用之间的会话范围内;而对于ServletContext来说,它保存的数据在整个应用期间一直存在。

ServletContext对象

ServletContext的生命周期

当Servlet容器(例如:Tomcat)启动时,它将会加载所有的Web应用。当一个Web应用加载完毕,Servlet容器将创建一个ServletContext对象并放入服务器的内存当中,以便后续使用。每个Web应用下面的web.xml文件被容器解析,创建相应的Servlet实例(需要加标签)并放入到服务器的内存中。当Servlet容器关闭时,它将卸载所有的Web应用,调用相应Servlet的destroy()方法,最后回收内存中的ServletContext对象。

ServletContext线程安全性

如果你去查看servlet specification,官方并没有详细说明关于ServletContext接口的线程安全性问题。但是,你可以去看一下Java并发编程实战书中的4.5.1节,它带你去“猜测”ServletContext接口安全性的问题,这里我只说书中得出的结论,具体细节请参考书。

用setAttribute方法发布属性是线程安全的。同样的,用getAttribute方法读取发布后的属性也是线程安全的。但是,开发者必须确保共享对象本身是线程安全的。如果你用setAttribute方法发布一个HashMap对象,因此你自己有责任去负责HashMap对象的线程安全性问题。(比如:你可以把HashMap换成ConcurrentHashMap)

下面,我举个例子。假设你的boss让你去开发一款角色类的网页游戏,因此你肯定有个功能就是所有的玩家都应该看到整个区最厉害的玩家,而且随着玩家不断地打怪升级分数,最厉害的玩家也会改变。下面,我给出具体的代码,大家在继续向下读文章之前,自己好好想一想我这样的代码会出现什么问题?假设我在容器启动的时候,我就用ServletContextListener把highScore属性初始化到ServletContext中了。

public PlayerScore getHighScore() {
    ServletContext ctx = getServletConfig().getServletContext();
    // PlayerScore是一个普通的JavaBean类,具有name和score属性
    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.setScore(newScore.getScore());
        hs.setName(newScore.getName());
    }
}

在单线程的情况下,上面的代码没有任何问题。但是在高并发的情况下,上面的代码就会出现一些很严重的问题。

  • 问题一:假设当前最厉害的玩家是“小明”,他的分数为1000; 现在有一个叫“小芳”的玩家经过不断地努力得到了1200的高分,她的分数比小明高,因此我需要更新最高分。假设我刚更新完分数,要姓名还没有更新,另一个线程去访问getHighScore方法,它有可能会得到(小明,1200)的情况,这明显是不对的。这叫做atomicity failure
  • 问题二:分数更新逻辑遵循着check-then-act模式,因此这有可能出现2个线程竞争去更新分数。假设现在又有一个玩家“小刚”,它的分数为1100,由于1100和1200都比先前的1000分要大,那么很可能出现的这2个较大的分数一起通过了if的测试,一起去更新分数,前面的更新有可能被后面的更新覆盖。
  • 问题三:上面的代码存在visibility failures问题。updateHighScore方法从ServletContext中取出highScore属性对应的PlayerScore对象,并修改PlayerScore对象的状态。修改PlayerScore对象的目的是让另一个调用getHighScore的线程去看到对象的修改,从而获取真正的最高分。但是,由于updateHighScore方法中的写入操作和getHighScore方法中的读取操作缺乏happens-before顺序原则(关于happens-before,大家可以参考Java并发编程实战的第16章),想要读线程读取到正确的值我们只能依靠运气。

下面,我针对上面的问题给出一些解决方案供大家参考。首先,当我们更新Scoped containers中的可变对象时,一个最佳实践是在更新完可变对象之后,再一次调用setAttribute()方法。

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);
    }
}

这样做有一个好处就是,在分布式Web应用中,它可以有效地复制session和应用状态,这是因为当我们调用setAttribute方法以后,它表明scoped container中的值已经发生了改变,因此session和应用状态要在分布式Web应用中重新同步。它可以减缓上面出现的visibility failures问题,但是,它并不足以改正上面的atomicity问题。

接下来,让我们继续解决线程安全问题。采取的方法是把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);
}

上面的代码已经大大减少了存在的线程安全性问题。先前的代码是更新name/score对,这样会出现atomicity failure问题,而现在我们只存一个不可变的数据项可以解决线程安全性问题。

所有放在HttpSession或者ServletContext中的数据应该是线程安全的或者是实际不可变的(实际不可变是指:理论上可变的数据,但是在发布以后从来没有实际改变过的数据)

现在,还有最后一个问题,就是线程间的竞争问题。我们可以加一个锁来解决这个竞争问题。

public void updateHighScore(PlayerScore newScore) {
     ServletContext ctx = getServletConfig().getServletContext();
     synchronized (ctx) {
         PlayerScore hs = (PlayerScore) ctx.getAttribute("highScore");
         if (newScore.score > hs.score)
             ctx.setAttribute("highScore", newScore);
     }
}

现在为止,我们已经解决了所有上面我提出的线程安全性问题。

HttpSession对象

HttpSession生命周期

当在某个Servlet中调用request.getSession()时,HttpSession对象被创建,或者如果你访问一个JSP页面,容器将自动创建一个session,如果你不想在访问JSP页面时创建session,你可以在页面中加上<%@ page session=“false”>

当servlet容器创建一个新的HttpSession对象时,它同时也生成了一个唯一的session ID(可以用session.getId()获取)。servlet容器也在response的Set-Cookie头中设置了一个cookie,JSESSIONID作为一个cookie名,session ID作为cookie值。

在服务器端,HttpSession的存活时间为web.xml文件中< session-timeout >所指定的时间。默认为30分钟。因此,当客户端与Web应用之间在30分钟之内都没有交互,那么servlet容器将废弃session.

在客户端,与session关联的cookie的默认存活时间为浏览器实例运行的时间。因此,当用户关闭浏览器实例(即所有的tabs),那么session将在客户端方面被废弃。当你再次重新打开一个浏览器实例时,关联先前那个session的cookie将不会在发送给Web应用。

HttpSession线程安全性

毋庸置疑,HttpSession像ServletContext一样都存在线程安全性问题,我上面描述的在ServletContext环境下出现的问题,在HttpSession中也有可能出现。大家可能怀疑,一个用户不可能同时请求到同一个HttpSession对象吧,他/她哪有那么快的速度啊,可是大家别忘了Ajax技术,一个用户做一些操作的时候访问到HttpSession,同时异步请求也可能访问到HttpSession,这样就有可能导致并发访问同一个HttpSession从而出现线程安全问题。对于HttpSession scoped container的线程安全性问题来说,我不建议大家用像上面解决ServletContext的方法去处理HttpSession的线程安全性问题。我建议大家串行化访问HttpSession,如果HttpSession能被串行化访问,就不会出现上面的安全性问题,同时串行化访问HttpSession不会严重影响吞吐量,因为基本上不会出现有很多请求同时访问同一个HttpSession的情况。

然而不幸的是,在servlet specification中,并不可以选择去强制串行化请求同一个session。但是,SpringMVC框架提供了一种方式可以做到这个并且这种方式可以很容易地被其它框架重新实现。SpringMVC中的AbstractController控制器提供了setSynchronizeOnSession方法,一旦它被设置成True,它将确保只有一个请求去访问HttpSession

HttpServletRequest对象

HttpServletRequest生命周期

HttpServletRequest和HttpServletResponse的生命周期为从客户端发出请求到响应完成这段时间。Servlet容器被部署到服务器上以后,它在给定的一个端口号下监听HTTP请求,开发环境下通常是8080端口,生产环境下通常是80端口。当一个客户端(通常是浏览器)发送HTTP请求,Servlet容器创建一个对应这个请求的新的HttpServletRequest和HttpServletResponse对象,并把这2个对象传递到对应Servlet的service()方法并根据request.getMethod()方法判断请求的类型,然后调用相应的doXXX()方法,方法中处理相应逻辑并返回到客户端。当HTTP响应完成,request和response对象被垃圾回收(由于创建对象的开销大,实际上,大部分的容器的做法是清空request和response对象的状态,重新再利用实例)。

HttpServletRequest线程安全性

由于一个request只对应着一个线程,因此它一定是线程安全的。

Scoped containers线程安全性总结

由于没有适当地协调访问HttpSession或ServletContext中的可变对象,导致许多具有状态性的Web应用有严重的并发安全问题。只有当HttpSession或ServletContext中的属性是不变的、或实际上是不变的、或是线程安全的或当串行化访问时,才不需要一些同步机制。通常情况下,你放入scoped container中的属性应该是线程安全的。servlet specification中提供的scoped container机制从来就没有打算去管理不需要自己同步的可变对象。最有可能违反这个原则的就是将普通的JavaBeans类存入到HttpSession中,只有在你把JavaBeans存入到session中之后不去修改它,这样才能保证不会出错,否则就很有可能出现线程安全问题。

cookies和sessions

服务器保存相关的信息(比如用户授权信息)到session对象中并创建一个与当前session对应的session ID以cookie的形式发送回客户端。当客户端再次请求服务器时会把cookie发送回来,服务器用cookie中保存的session ID来查找对应的session对象,因此,如果你删除了cookie,那么与之对应的session对象将会丢失。session ID也可以用URL重写的方式传递回服务器。但是,现在大部分的网站都用cookie的形式来传递session的标识符,而不去用URL的方式。

引用

Servlets - Examples

Servlets - Life Cycle

How do servlets work? Instantiation, sessions, shared variables and multithreading

Thread Safety of ServletContext objects

Java theory and practice: Are all stateful Web applications broken?

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值