Java Web 笔记

Contents

  1. 1. 一次请求过程
  2. 2. NIO
  3. 3. 几种访问文件的方式
  4. 4. 序列化
  5. 5. I/O调优
    1. 5.1. 磁盘 I/O 优化
    2. 5.2. 网络 I/O 优化
  6. 6. Java io包中的设计模式
  7. 7. 编码
    1. 7.1. Unicode
    2. 7.2. Java中String 的相关方法
    3. 7.3. Java Web 涉及到的编码
      1. 7.3.1. URL中的编码
      2. 7.3.2. Header中的编码
      3. 7.3.3. POST表单中的编码
      4. 7.3.4. HTTP BODY 的编解码
      5. 7.3.5. JS中的编码
      6. 7.3.6. 其它需要编码的地方
  8. 8. 常见加载器类错误分析
  9. 9. 热部署
  10. 10. 执行引擎
  11. 11. Session与Cookie
    1. 11.1. Cookie
    2. 11.2. Session
    3. 11.3. 表单重复提交问题
    4. 11.4. 多终端Session统一
  12. 12. 跨域资源共享(CORS)

这篇记录一下浏览器的请求过程,NIO,字符编码等。

一次请求过程

  1. 浏览器查找域名的 IP 地址
    这一步包括 DNS 具体的查找过程,会依次从浏览器缓存、系统缓存、hosts中查找,如果这个过程中有一个地方有结果,查询就结束了,否则向本地域名服务器(LDNS)发送DNS请求(UDP);如果LDNS没有命中,LDNS直接向Root Server域名服务器请求解析,根域名服务器会返回给LDNS一个所查询的主域名服务器(gTLD Server,.com,.cn,.org等)地址;本地域名服务器再向gTLD服务器发送请求,gTLD服务器会返回此域名对应的Name Server域名服务器(就是你注册的域名服务器)的地址,Name Server会查询存储的域名、IP映射表,然后返回IP和TTL值(这个值用来指定DNS缓存时间);最后LDNS Server缓存结果并将结果返回给用户。Name Server 也可能会有多级。
    Linux和Windows下nslookup,Linux下dig可以跟踪DNS解析过程。
    如果域名对应的IP地址变了,但是本地还有缓存,会造成访问不了的问题,可以清除缓存的域名,Windows下可以ipconfig /flushdns,Linux下/etc/init.d/nscd restart
    JVM也会缓存DNS解析结果,是通过InetAddress类完成的,这个类最好是使用单例模式,否则没有缓存每次都解析会很耗时。
    域名解析方式
    • A 记录,多个域名可以解析岛一个IP地址,不能将一个域名解析到多个IP。如taobao.com指定到110.75.115.70
    • MX 记录,Mail Exchange,可将摸个域名下的邮件服务器指向自己的Mail Server。
    • CNAME 记录,Canonical Name别名。如指定xxx.github.io的别名xxx.com
    • NS 记录,为某域名指定DNS域名服务器。
    • TXT 记录,为某个主机或域名设置说明,如xxx.com的TXT记录为XXX的博客
  2. 浏览器根据解析得到的IP地址向 web 服务器发送一个 HTTP 请求
    通过DNS获取到IP后,目标IP和本机IP分别与子网掩码相与的结果相同,那么它们在一个子网,那么通过ARP协议可以查到目标主机的MAC地址,否则的话,需要通过网关转发,也就是目标MAC是网关的MAC。
    请求需要进行编码,生成一个HTTP数据包,依次打上TCP、IP、以太网协议的头部。其中TCP头部主要信息是本机端口和目标端口号等信息,用于标识同一个主机的不同进程,对于HTTP协议,服务器端的默认端口号是80,本机浏览器的话生成一个1024到65535之间的端口号。IP头部主要包含本地IP和目标IP等信息。以太网协议头部主要是双方的MAC地址,目标MAC可以由第一条所诉方法得到。以太网数据包的数据部分,最大长度为1500字节,所以如果IP包太大的话还要拆包,比如IP包5000字节,要分为4包,每一包都包含一个IP头部。
    服务器接收请求就要层层解包,对于HTTP包要进行解码,解码时如果编码不对的话就有可能乱码了。
  3. 服务器可能有很多,由哪台来处理请求,还需要负载均衡设备来平均分配所有用户的请求。
    负载均衡,即对工作任务进行平衡,分摊到多个操作单元上执行,如图片服务器,应用服务器。可分为链路负载均衡(通过DNS解析成不同IP,用户根据这个IP访问不同的服务器,由于中间没有代理,其特点是快,但是如果某台服务器挂了,可能造成用户无法访问的问题),集群负载均衡,操作系统负载均衡(通过硬中断和软中断实现)
    集群负载均衡又分为硬件负载均衡和软件负载均衡,前者非常贵,不嫩能够进行动态扩容,后者成本低,但需要多次代理,增加了延时。
  4. 请求的数据可能存储在分布式缓存或者静态文件再或者数据库中,数据库中。如果请求的数据是静态文件,如果在CDN上,那么CDN服务器又会处理这个用户的请求。如果在数据库中需要向数据库发起查询请求。
    CDN, Content Delivery Network,一个比喻:CDN = 镜像+缓存+整体负载均衡。用户访问离他最近节点的服务器的内容。通过DNS迭代解析,会返回公司的DNS解析服务器,它又会指定DNS负载均衡器,由其决定用户访问最近的CDN节点。
    如果CDN节点没有资源,会去源站请求,然后缓存并返回给用户。
  5. 服务器返回一个 HTTP 响应,如果返回状态码304,浏览器可以直接使用之前缓存的资源。对于内容响应,浏览器需要进行响应解码,渲染显示。
    缓存机制
    • Expires,指定一个时间,告诉浏览器,在此之前可以直接使用缓存而不需要请求,当然F5还是会去请求
    • Cache-control,更细致的控制缓存,比Expires优先级高。
      • Public指示响应可被任何缓存区缓存。
      • Private指示对于单个用户的整个或部分响应消息,不能被共享缓存处理。这允许服务器仅仅描述当用户的部分响应消息,此响应消息对于其他用户的请求无效。
      • no-cache指示请求或响应消息不能缓存
      • no-store用于防止重要的信息被无意的发布。在请求消息中发送将使得请求和响应消息都不使用缓存。
      • max-age指示客户机可以接收生存期不大于指定时间(以秒为单位)的响应。
      • min-fresh指示客户机可以接收响应时间小于当前时间加上指定时间的响应。
      • max-stale指示客户机可以接收超出超时期间的响应消息。如果指定max-stale消息的值,那么客户机可以接收超出超时期指定值之内的响应消息。
    • Last-Modified/If-Modified-Since,Last-Modified标示这个响应资源的最后修改时间。当资源过期时(使用Cache-Control标识的max-age),发现资源具有Last-Modified声明,则再次向web服务器请求时带上头 If-Modified-Since,web服务器收到请求后发现有头If-Modified-Since 则与被请求资源的最后修改时间进行比对,判断是否可以继续使用缓存资源。
    • Etag/If-None-Match,Etag/If-None-Match也要配合Cache-Control使用。Apache中,ETag的值,默认是对文件的索引节(INode),大小(Size)和最后修改时间(MTime)进行Hash后得到的。当资源过期时(使用Cache-Control标识的max-age),发现资源具有Etag声明,则再次向web服务器请求时带上头If-None-Match (Etag的值)。web服务器根据If-None-Match 决定是否使用缓存。
      Last-Modified标注的最后修改只能精确到秒级,如果一秒内资源改变了,却还是使用之前的缓存,这时就需要Etag来控制缓存了。Last-Modified与ETag是可以一起使用的,服务器会优先验证ETag,一致的情况下,才会继续比对Last-Modified,最后才决定是否返回304。
      Ctrl+F5快捷键刷新页面,将会在头部加上Pragma:no-cacheCache-Control:no-cache,从而获取最新的资源。

NIO

IO NIO
面向流 面向缓冲
阻塞IO 非阻塞IO
选择器

原来的 I/O 库(在 java.io.*中) 与 NIO 最重要的区别是数据打包和传输的方式。原来的 I/O 以流的方式处理数据,而 NIO 以块的方式处理数据。NIO快得多

java.io.* 已经以 NIO 为基础重新实现了,所以现在它可以利用 NIO 的一些特性。

通道 和 缓冲区 是 NIO 中的核心对象,几乎在每一个 I/O 操作中都要使用它们。

NIO中的三个概念Buffer,Channel 和 Selector,下面是一个示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
public void selector() throws IOException {
ByteBuffer buffer = ByteBuffer.allocate(1024);
// 1. 调用 Selector 的静态工厂创建一个选择器
Selector selector = Selector.open();
// 2. 创建一个服务端的 Channel
ServerSocketChannel ssc = ServerSocketChannel.open();
// 3. 把这个通信信道设置为非阻塞模式
ssc.configureBlocking(false);
// 4. 绑定到一个 Socket 对象
ssc.socket().bind(new InetSocketAddress(8080));
// 5. 把这个通信信道注册到选择器上
ssc.register(selector, SelectionKey.OP_ACCEPT);
while (true) {
// 6. 调用 Selector selectedKeys 方法来检查已经注册在这个选择器上的所有通信信道是否有需要的事件发生,如果有某个事件发生时,将会返回所有的 SelectionKey
Set selectedKeys = selector.selectedKeys();
Iterator it = selectedKeys.iterator();
while (it.hasNext()) {
SelectionKey key = (SelectionKey) it.next();
if ((key.readyOps() & SelectionKey.OP_ACCEPT) == SelectionKey.OP_ACCEPT) {
// 7. 通过这个对象 Channel 方法就可以取得这个通信信道对象从而可以读取通信的数据
ServerSocketChannel ssChannel = (ServerSocketChannel) key.channel();
SocketChannel sc = ssChannel.accept();//接受到服务端的请求
sc.configureBlocking(false);
sc.register(selector, SelectionKey.OP_READ);
it.remove();
} else if ((key.readyOps() & SelectionKey.OP_READ) == SelectionKey.OP_READ) {
SocketChannel sc = (SocketChannel) key.channel();
while (true) {
buffer.clear();
// 8. 读取的数据是 Buffer,这个 Buffer 是我们可以控制的缓冲器
int n = sc.read(buffer);//读取数据到buffer中,也就是向buffer写数据
if (n <= 0) {
break;
}
buffer.flip();
}
it.remove();
}
}
}
}

Java NIO的通道类似流,但又有些不同:

  • 既可以从通道中读取数据,又可以写数据到通道。但流的读写通常是单向的。
  • 通道可以异步地读写。
  • 通道中的数据总是要先读到一个Buffer,或者总是要从一个Buffer中写入。

Buffer内部实现是一个数组,但是一个缓冲区不仅仅是一个数组。缓冲区提供了对数据的结构化访问,而且还可以跟踪系统的读/写进程。每个基本类型都有对应的Buffer类。

为了理解Buffer的工作原理,需要熟悉它的几个属性:

  • capacity
  • position
  • limit
  • mark
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Write Mode
+-------------------------+
| | | | | | | | | | | | | |
| | | | | | | | | | | | | |
+-------------------------+
^ ^
| |
position limit,capacity
Read Mode
+-------------------------+
| | | | | | | | | | | | | |
| | | | | | | | | | | | | |
+-------------------------+
^ ^ ^
| | |
position limit capacity

position和limit的含义取决于Buffer处在读模式还是写模式。不管Buffer处在什么模式,capacity的含义总是一样的。

  • capacity

    作为一个内存块,Buffer有一个固定的大小值,也叫“capacity”.你只能往里写capacity个byte、long,char等类型。一旦Buffer满了,需要将其清空(通过读数据或者清除数据)才能继续写数据往里写数据。

  • position

    当你写数据到Buffer中时,position表示当前的位置。初始的position值为0.当一个byte、long等数据写到Buffer后, position会向前移动到下一个可插入数据的Buffer单元。position最大可为capacity – 1.

    当读取数据时,也是从某个特定位置读。当将Buffer从写模式切换到读模式,position会被重置为0. 当从Buffer的position处读取数据时,position向前移动到下一个可读的位置。

  • limit

    在写模式下,Buffer的limit表示你最多能往Buffer里写多少数据。 写模式下,limit等于Buffer的capacity。

    当切换Buffer到读模式时, limit表示你最多能读到多少数据。因此,当切换Buffer到读模式时,limit会被设置成写模式下的position值。换句话说,你能读到之前写入的所有数据(limit被设置成已写数据的数量,这个值在写模式下就是position)

  • mark

    用于记录当前 position 的前一个位置或者默认是 0

写数据到Buffer有两种方式:

  • 从Channel写到Buffer。int bytesRead = inChannel.read(buf); //read into buffer.
  • 通过Buffer的put()方法写到Buffer里。buf.put(127);

从Buffer中读取数据有两种方式:

  • 从Buffer读取数据到Channel。int bytesWritten = inChannel.write(buf);
  • 使用get()方法从Buffer中读取数据。byte aByte = buf.get();

Buffer中的几个方法

  • allocate方法

    每一个Buffer类都有一个allocate方法。下面是一个分配48字节capacity的ByteBuffer的例子。ByteBuffer buf = ByteBuffer.allocate(48);

  • flip()方法

    flip方法将Buffer从写模式切换到读模式。调用flip()方法会将position设回0,并将limit设置成之前position的值。

  • rewind()方法

    Buffer.rewind()将position设回0,所以你可以重读Buffer中的所有数据。limit保持不变,仍然表示能从Buffer中读取多少个元素(byte、char等)。

  • clear()与compact()方法

    一旦读完Buffer中的数据,需要让Buffer准备好再次被写入。可以通过clear()或compact()方法来完成。

    如果调用的是clear()方法,position将被设回0,limit被设置成 capacity的值。换句话说,Buffer 被清空了。Buffer中的数据并未清除,只是这些标记告诉我们可以从哪里开始往Buffer里写数据。

    如果Buffer中有一些未读的数据,调用clear()方法,数据将“被遗忘”,意味着不再有任何标记会告诉你哪些数据被读过,哪些还没有。

    如果Buffer中仍有未读的数据,且后续还需要这些数据,但是此时想要先先写些数据,那么使用compact()方法。

    compact()方法将所有未读的数据拷贝到Buffer起始处。然后将position设到最后一个未读元素正后面。limit属性依然像clear()方法一样,设置成capacity。现在Buffer准备好写数据了,但是不会覆盖未读的数据。

  • mark()与reset()方法

    通过调用Buffer.mark()方法,可以标记Buffer中的一个特定position。之后可以通过调用Buffer.reset()方法恢复到这个position。

参考: http://ifeve.com/java-nio-all/

几种访问文件的方式

  1. 标准访问文件方式 标准访问文件方式就是当应用程序调用read()接口时,操作系统检查内核的高速缓存中有没有需要的数据,如果已经缓存了,那么就直接从缓存中返回,如果没有,从磁盘中读取,然后缓存在操作系统的缓存中。

    写入的方式是,用户的应用程序调用write()接口将数据从用户地址空间复制到内核地址空间的缓存中。这时对用户程序来说写操作就已经完成,至于什么时候再写到磁盘中由操作系统决定,除非显式地调用了sync同步命令。

  2. 直接I/O方式 所谓直接I/O方式就是应用程序直接访问磁盘数据,而不经过操作系统内核数据缓冲区,这样做的目的就是减少一次从内核缓冲区到用户程序缓存的数据复制。这种访问文件的方式通常是在对数据的缓存管理由应用程序实现的数据库管理系统中。如数据库管理系统中,系统明确地知道应该缓存哪些数据,应该失效哪些数捃,还可以对一些热点数据做预加载,提前将热点数据加载到内存,可以加速数据的访问效率。直接I/O也有负面影响,如果访问的数据不在应用程序缓存中,那么每次数据都会直接从磁盘加载,这种直接加载会非常缓慢。通常直接I/O与异步I/O结合使用,会得到比较好的性能。

  3. 同步访问文件方式 同步访问文件方式比较容易理解,就是数据的读取和写入都是同步操作的,它与标准访问文件方式不同的是,只有当数据被成功写到磁盘时才返回给应用程序成功标志。这种访问文件方式性能比较差,只有在一些对数据安全性要求比较高的场景中才会使用,而且通常这种操作方式的硬件都是定制的。

  4. 异步访问文件方式 异步访问文件方式就是当访问数据的线程发出请求之后,线程会接着去处理其他事情,而不是阻塞等待,当请求的数据返回后继续处理下面的操作。这种访问文件的方式可以明显地提高应用程序的效率,但是不会改变访问文件的效率。

  5. 内存映射方式 内存映射方式是指操作系统将内存中的某一块区域与磁盘中的文件关联起来,当要访问内存中一段数据时,转换为访问文件的某一段数据。这种方式的目的同样是减少数据从内核空间缓存到用户空间缓存的数据复制操作,因为这两个空间的数据是共享的。

序列化

  • 当父类继承Serializable接口,所有子类都可以被序列化。
  • 子类实现了Serializable接口,父类没有,父类中的属性不能序列化(不报错,数据会丢失),但是子类中属性仍能正确序列化。
  • 如果序列化的属性是对象,这个对象也必须实现Serializable接口,否则会报错。
  • 在反序列化时,如果对象的属性有修改或删减,修改的部分属性会丢失,但不会报错。
  • 在反序列化时,如果seriaIVersionUID被修改,那么反序列化时会失败。

序列化后很难被其它语言反序列化,所以通常用json,xml这样的结构数据。

I/O调优

磁盘 I/O 优化

  • 性能检测

    我们可以压力测试应用程序看系统的 I/O wait 指标是否正常,例如测试机器有 4 个 CPU,那么理想的 I/O wait 参数不应该超过 25%,如果超过 25% 的话,I/O 很可能成为应用程序的性能瓶颈。Linux 操作系统下可以通过 iostat 命令查看。

    通常我们在判断 I/O 性能时还会看另外一个参数就是 IOPS,每个磁盘的 IOPS 通常是在一个范围内,这和存储在磁盘的数据块的大小和访问方式也有关。但是主要是由磁盘的转速决定的,磁盘的转速越高磁盘的 IOPS 也越高。

    现在为了提高磁盘 I/O 的性能,通常采用一种叫 RAID 的技术,就是将不同的磁盘组合起来来提高 I/O 性能,目前有多种 RAID 技术,每种 RAID 技术对 I/O 性能提升会有不同,可以用一个 RAID 因子来代表,磁盘的读写吞吐量可以通过 iostat 命令来获取,于是我们可以计算出一个理论的 IOPS 值,计算公式如下所以:

    (磁盘数 * 每块磁盘的 IOPS)/(磁盘读的吞吐量 +RAID 因子 * 磁盘写的吞吐量)=IOPS

  • 提升 I/O 性能

    • 增加缓存,减少磁盘访问次数
    • 优化磁盘的管理系统,设计最优的磁盘访问策略,以及磁盘的寻址策略,这里是在底层操作系统层面考虑的。
    • 设计合理的磁盘存储数据块,以及访问这些数据块的策略,这里是在应用层面考虑的。如我们可以给存放的数据设计索引,通过寻址索引来加快和减少磁盘的访问,还有可以采用异步和非阻塞的方式加快磁盘的访问效率。
    • 应用合理的 RAID 策略提升磁盘 IO,每种 RAID 的区别我们可以用下表所示:
磁盘阵列 说明
RAID 0 数据被平均写到多个磁盘阵列中,写数据和读数据都是并行的,所以磁盘的 IOPS 可以提高一倍。
RAID 1 RAID 1 的主要作用是能够提高数据的安全性,它将一份数据分别复制到多个磁盘阵列中。并不能提升 IOPS 但是相同的数据有多个备份。通常用于对数据安全性较高的场合中。
RAID 5 这中设计方式是前两种的折中方式,它将数据平均写到所有磁盘阵列总数减一的磁盘中,往另外一个磁盘中写入这份数据的奇偶校验信息。如果其中一个磁盘损坏,可以通过其它磁盘的数据和这个数据的奇偶校验信息来恢复这份数据。
RAID 0+1 如名字一样,就是根据数据的备份情况进行分组,一份数据同时写到多个备份磁盘分组中,同时多个分组也会并行读写。

网络 I/O 优化

网络 I/O 优化通常有一些基本处理原则:

  • 一个是减少网络交互的次数,如静态文件合并,多次数据库查询合并为一次。
  • 减少网络传输数据量的大小,如 gzip 压缩。还有就是通过设计简单的协议,尽量通过读取协议头来获取有用的价值信息。
  • 尽量减少编码,尽量直接以字节形式发送。也就是尽量提前将字符转化为字节,或者减少字符到字节的转化过程。
  • 根据应用场景设计合适的交互方式,所谓的交互场景主要包括同步与异步阻塞与非阻塞方式,下面将详细介绍。
组合方式 性能分析
同步阻塞 最常用的一种用法,使用也是最简单的,但是 I/O 性能一般很差,CPU 大部分在空闲状态。
同步非阻塞 提升 I/O 性能的常用手段,就是将 I/O 的阻塞改成非阻塞方式,尤其在网络 I/O 是长连接,同时传输数据也不是很多的情况下,提升性能非常有效。这种方式通常能提升 I/O 性能,但是会增加 CPU 消耗,要考虑增加的 I/O 性能能不能补偿 CPU 的消耗,也就是系统的瓶颈是在 I/O 还是在 CPU 上。
异步阻塞 这种方式在分布式数据库中经常用到,例如在网一个分布式数据库中写一条记录,通常会有一份是同步阻塞的记录,而还有两至三份是备份记录会写到其它机器上,这些备份记录通常都是采用异步阻塞的方式写 I/O。异步阻塞对网络 I/O 能够提升效率,尤其像上面这种同时写多份相同数据的情况。
异步非阻塞 这种组合方式用起来比较复杂,只有在一些非常复杂的分布式情况下使用,像集群之间的消息同步机制一般用这种 I/O 组合方式。如 Cassandra 的 Gossip 通信机制就是采用异步非阻塞的方式。它适合同时要传多份相同的数据到集群中不同的机器,同时数据的传输量虽然不大,但是却非常频繁。这种网络 I/O 用这个方式性能能达到最高。

Java io包中的设计模式

  • 适配器模式。InputStreamReader。目标接口是Reader。源角色是InputStream
  • 装饰器模式。FilterInputStream及其子类。

两者都是包装模式,区别是目的不同,前者意在转换接口,后者重在强化功能。

编码

  • ASSIC,单字节编码,共有128个字符,一字节可以容纳256个,所以其最高位为0,除去第[0,32]以及第127为控制字符,有效字符为94个。
  • ISO-8859-1,单字节编码。又称Latin-1,是对ASSIC的扩展,在一些软件或协议中是默认编码,通常如果有?问号这样的乱码,很可能就是由于这个编码。
  • GB2312,对于落在ASSIC范围内的编码还是使用单字节,其它双字节编码。使用二位区位编号,行为区,列为位,总计94x94个编号,区位均从1起始,一个字符用区码加位码编号,区码、位码各加32就是国标码,国标码区位各加128就是机内码,区位码区位各加0XA0就是机内码了。机内码是实际使用的编码。
  • GBK,单字节,双字节变长编码,对于ASSIC码是单字节。是对GB2312的扩展,K即扩展。UTF-16和GB开头的编码转换需要通过查表,而Unicode的几种编码之间有相应的转换规则。
  • GB18030,可能单字节、双字节、四字节编码,兼容GB2312,部分兼容GBK
  • UTF-8,1到4个字节。
  • UTF-16,两个字节或四个字节。两个字节时基本可以认为和UCS-2等同。
  • UTF-32,四个字节定长编码。可以认为和UCS-4等同。

Unicode

Unicode的核心就是为每个字符提供唯一一个数字编号。Unicode provides a unique number for every character.

后三个都是Unicode字符集中的编码。字符集和编码几乎都是一对一的,但是Unicode是个例外,Unicode只是给出了个字符集,每个字符有对应的码点(code point),比如美元$符号对应码点是U+0024,至于怎么对这个码点编码这就是UTF-X(Unicode (or UCS) Transformation Format)的事情了,这个X代表了码元(code unit)是多少位的,比如UTF-8的码元是8bits也就是一个字节,一种转换格式可以有整数个码元,比如UTF-16码元是16位,两个字节,其可以包含一个或两个码元,也就是两个或四个字节。

码点范围是U+0000U+10FFFF(表示为4到6个16进制数,不足4位补零,超过4位,是多少位就还是多少位),最多用到21比特位,可以表示(1+2^4)x2^16个字符,可分为17个部分,每个部分称为平面,平面从0起始,第一个平面即是BMP(Basic Multilingual Plane 基本多语言平面,也叫Plane 0,它的码点范围是U+0000U+FFFF),UTF-16用两个字节表示BMP平面的字符。后续的16个平面称为SP(Supplementary Planes)。显然,这些码点已经是超过U+FFFF的了,所以已经超过了16位空间的理论上限,对于这些平面内的字符,UTF-16采用了四字节编码。BMP平面从D8~DF都是空白的。其中D800–DBFF属于高代理区(High Surrogate Area),DC00–DFFF属于低代理区(Low Surrogate Area),各自的大小均为4×256=1024。2^10x2^10=2^4x2^16正好可以表示后面的16个平面,UTF-16就是使用代理对表示BMP平面外的字符的。

还有一个代码页的概念,配置vim编码时碰到过一个cp936的,这个就是一个代码页(code page),可以说代码页是编码的一个别名,如cp936和GBK其实就是一个东西。cp65001和UTF-8也是一个东西。Windows命令窗口可以用chcp 65001改变其编码。

码点到UTF-8的转换

字为例,其码点是U+4F60

1
2
3
4
5
0100 1111   0110 0000   码点的二进制形式
0100 111101 1000004+6+6分组
1110XXXX 10XXXXXX 10XXXXXX UTF-8三字节模板
11100100 10111101 10100000 替换有效编码位
E4 BD A0

UTF-8 有以下编码规则:

  • 如果一个字节,最高位(第 8 位)为 0,表示这是一个 ASSIC 字符(00 - 7F)。可见,所有 ASSIC 编码已经是 UTF-8 了。
  • 如果一个字节,以 11 开头,连续的 1 的个数暗示这个字符的字节数,例如:110xxxxx 代表它是双字节 UTF-8 字符的首字节。
  • 如果一个字节,以 10 开始,表示它不是首字节,需要向前查找才能得到当前字符的首字节

码点到UTF-16的转换

  1. BMP中直接对应,无须转换;
  2. 增补平面SP中,则需要做相应的计算。
    拿到一个码点,先减去10000(16),再除以400(16)(=1024(10))就是所在行了,余数就是所在列了,再加上行与列所在的起始值,就得到了代理对了。(16)表示是16位。
1
2
Lead = (码点 - 10000(16)) ÷ 400(16) + D800
Trail = (码点 - 10000(16)) % 400(16) + DC00

下面以前面的U+1D11E具体示例了代理对的(16进制)计算:

1
2
Lead = (1D11E - 10000) ÷ 400 + DB00 = D11E ÷ 400 + D800 = 34 + D800 = D834
Trail = (1D11E - 10000) % 400 + DC00 = D11E % 400 + DC00 = 11E + DC00 = DD1E

码点到UTF-32无需转换,补零就行了

记事本中可以保存Unicode编码其实就是UTF-16,默认是小端编码。另外记事本保存UTF-8默认是带BOM的。

提到小端编码了,顺便说说大小端编码的问题,也就是字节序的问题,涉及到的是多字节数据存储时的字节顺序问题(对于单字节数据来说,一般不需要考虑各比特位的存储顺序问题。UTF-8以字节为编码单元,没有字节序的问题。UTF-16以两个字节为编码单元,就要考虑字节序了)。大端序是指数据的高字节保存在内存的低地址中,小端序是指数据的高字节保存在内存的高地址中。按照自左向右的顺序,以左为先,把先出现的当做低地址。对于UTF-16的一个码元来说比如0x597D这两个字节,大端表示的话就是0x597D,小端表示的话是0x7D59。可以看出大端表示法跟我们的书写习惯是一致的,而小端的话对我们来说有点不自然,但对于电脑存储可能就比较自然了,因为它的高低字节和内存的高低地址正好就是对应的。

说完大小端再说说BOM,其全称是Byte Order Marker,从全称,我们就知道它是干嘛用的了,标识字节序的。它对应Unicode中的U+FEFF码点,对于UTF-16来说,如果开头是FEFF那就是大端序了,如果是FFFE那就是小端序。

关于编码更详细的内容可以看这个系列博客http://my.oschina.net/goldenshaw/blog?catalog=536953

Java中String 的相关方法

getBytes有四个重载方法,有一个是过时的,总的说来就是两种,指定了编码的和没有指定编码的,没有指定编码,它会使用平台的缺省编码,这个平台是指JVM,如果JVM启动时没有指定编码,那么这个缺省编码也就是操作系统的缺省编码了。这个方法的作用是把字符串按指定的编码方式编码为字节数组,其实质应该就是将内存中存储的UTF-16的编码数组转换为其它编码方式的字节数组。

length方法,这个方法是常用的一个方法,它返回字符串的长度,这个长度不是字符的个数,而是码元的个数,在Java里也就是UTF-16的码元个数了。像charAt, substring这些方法,其参数实际也是基于码元的概念而不是字符。不过呢,大多的字符在BMP平面就能表示了,所以平时接触到的可能这个码元个数和字符个数都是对应的。

下面看些具体的例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
final String interesting = "hi你好";
System.out.println(interesting.length()); // 4

final byte[] utf8Bytes = interesting.getBytes("UTF-8");
// 输出8,每个英文字母占一个字节,每个汉字3个字节
System.out.println(utf8Bytes.length);
//68 69 E4 BD A0 E5 A5 BD
System.out.println(toHex(utf8Bytes));
System.out.println(utf8Bytes.length);
final byte[] utf16Bytes = interesting.getBytes("UTF-16");
// 输出10,每个字符2个字节,外加BOM两个字节
System.out.println(utf16Bytes.length);
//输出中开头的FE FF就是传说中的BOM(Byte Order Marker用来表明大端还是小端的)了,其实就是U+FEFF码点
//EF BB BF 是这一码点在UTF-8下的编码,UTF-8只有大端,没有大小端之分,所以不建议保存时带BOM,对于不支持BOM的软件会带来未知问题
//FE FF 00 68 00 69 4F 60 59 7D
System.out.println(toHex(utf16Bytes));
final byte[] utf16beBytes = interesting.getBytes("UTF-16BE");
// 输出8,每个字符2个字节,UTF-16大端编码,可以看到后面输出的结果除了没有BOM标记之外和UTF-16是一样的。
System.out.println(utf16beBytes.length);
//00 68 00 69 4F 60 59 7D
System.out.println(toHex(utf16beBytes));
final byte[] utf32Bytes = interesting.getBytes("UTF-32");
// 输出16,每个字符4个字节,UTF-32其实也是可以有大小端之分的
System.out.println(utf32Bytes.length);
//00 00 00 68 00 00 00 69 00 00 4F 60 00 00 59 7D
System.out.println(toHex(utf32Bytes));
final byte[] isoBytes = interesting.getBytes("ISO-8859-1");
// 输出4,两个汉字超出它的表示范围,它不认识,然后就被编程`?`了
System.out.println(isoBytes.length);
//68 69 3F 3F
System.out.println(toHex(isoBytes));
final byte[] gb2312Bytes = interesting.getBytes("GB2312");
// 输出6
System.out.println(gb2312Bytes.length);
//68 69 C4 E3 BA C3
System.out.println(toHex(gb2312Bytes));
final byte[] gbkBytes = interesting.getBytes("GBK");
// 输出6, 是对GB2312的扩展,所以这个编码一样
System.out.println(gbkBytes.length);
// 68 69 C4 E3 BA C3
System.out.println(toHex(gbkBytes));

//s是增补平面的一个字符,UTF-16需要四个字节存,这里如果给char类型赋值,会报错,char总是两个字节的,很显然它存不下
// char c = '?';
String s = "?";
//Returns the length of this string. The length is equal to the number of Unicode code units in the string.
//上面是length方法的注释,可以看到这个字符串长度,实际是码元的个数,而Java是用UTF-16存储字符串的,所以这里就是UTF-16的码元个数了,即2
System.out.println(s.length()); //2 输出的是码元数目,也就是UTF-16码元的数目
//下面的两个方法其实也是针对码元的,如果是按字符算的话,s只有一个字符,下面两个方法可就都越界了,事实上它们并没有
System.out.println(s.charAt(1));
System.out.println(s.substring(1));
System.out.println(s.equals("\uD869\uDEA5"));//s的转义表示,就是UTF-16的代理对
System.out.println(s.codePointAt(0) == 0x2A6A5);//s码点是U+2A6A5
System.out.println(toHex(s.getBytes("utf-16be"))); //D8 69 DE A5
//下面是一个组合字符,神一样的存在
s = "???????????????????????????????????????";
System.out.println(s.length()); //39,意味着有39个码元!但是显示的确实是一个字符
//0E 2A 0E 49 0E 49 0E 49 0E 49 0E 49 0E 49 0E 49 0E 49 0E 49 0E 49 0E 49 0E 49 0E 49 0E 49 0E 49 0E 49 0E 49 0E 49 0E 49 0E 49 0E 49 0E 49 0E 49 0E 49 0E 49 0E 49 0E 49 0E 49 0E 49 0E 49 0E 49 0E 49 0E 49 0E 49 0E 49 0E 49 0E 49 0E 49
System.out.println(toHex(s.getBytes("utf-16be")));

//两个汉字变成了三个字符,因为UTF-8对汉字编码是三字节,而GBK是两个字节,其实就是六个字节的两种组合
System.out.println(new String(interesting.getBytes("UTF-8"),"GBK"));
//两个汉字变成了六个字符,因为UTF-8对汉字编码是三字节,而ISO-8859-1是单字节编码,两个三字节被当做六个单字节来解了
System.out.println(new String(interesting.getBytes("UTF-8"),"ISO-8859-1"));
//两个汉字变成了两个'?',因为ISO-8859-1是单字节编码,不认识的都按3F也就是'?'编码,解码时GBK也就当是问号了
System.out.println(new String(interesting.getBytes("ISO-8859-1"),"GBK"));
//两个汉字变成了四个字符,因为GBK对汉字编码是两字节,而ISO-8859-1是单字节编码,两个双字节被当做四个单字节来解了
System.out.println(new String(interesting.getBytes("GBK"),"ISO-8859-1"));
// 68 69 C4 E3 BA C3 -> 68 69 3F 3F 3F 3F
System.out.println(toHex(new String(interesting.getBytes("GBK"),"ISO-8859-1").getBytes("GBK")));
//两个汉字变成了四个'?',涉及两次编解码,这里有个疑问,第一次按GBK编码再按ISO-8859-1解码,字节数没变每个字节里存储的内容没变还是68 69 C4 E3 BA C3
//为什么再拿GBK编码后面就变成3F了,GBK不是按照C4E3 BAC3这么分组编码的?我觉得应该跟字符串的存储有关系,getBytes这个方法其实还是从UTF-16到其它编码的一个转换过程
//C4 E3 BA C3 存储可能是00C4 00E3 00BA 00C3然后00C4是什么GBK没有对应的编码,然后就又3F了
System.out.println(new String(new String(interesting.getBytes("GBK"),"ISO-8859-1").getBytes("GBK"),"GBK"));
//下面这个结果就是正确的了,第二次编码时,数组的内容还是68 69 C4 E3 BA C3 再按GBK解码自然能够把C4E3 BAC3转为对应的汉字
System.out.println(new String(new String(interesting.getBytes("GBK"),"ISO-8859-1").getBytes("ISO-8859-1"),"GBK"));

Java Web 涉及到的编码

URL中的编码

1
2
3
4
5
6
7
 +-----------------URL-------------------------+
/ +---------URI------------\
/ \
http://localhost:8080/examples/servlets/servlet/你好?param=你好#h1
^ ^ ^ ^ ^ ^ ^
| | | | | | |
scheme domain port context path servlet path path info query string

Port 对应在 Tomcat 的 <Connector port="8080"/> 中配置,而 Context Path 在 <Context path="/examples"/> 中配置,Servlet Path 在 Web 应用的 web.xml 中的<url-pattern> 中配置,PathInfo 是我们请求的具体的 Servlet,QueryString 是要传递的参数

1
2
3
4
<servlet-mapping>
<servlet-name>example</servlet-name>
<url-pattern>/servlets/servlet/*</url-pattern>
</servlet-mapping>

tomcat服务器对URL解码使用的编码是由 <Connector URIEncoding="UTF-8"/>指定的,而对于QueryString 的解码字符集要么是 Header 中 ContentType 中定义的 Charset 要么就是默认的 ISO-8859-1,要使用 ContentType 中定义的编码就要设置 connector 的 <Connector URIEncoding="UTF-8" useBodyEncodingForURI="true"/> 中的 useBodyEncodingForURI 设置为 true。

Header中的编码

对 Header 中的项进行解码是在调用 request.getHeader 是进行的,如果请求的 Header 项没有解码则调用 MessageBytes 的 toString 方法,这个方法将从 byte 到 char 的转化使用的默认编码也是 ISO-8859-1,而我们也不能设置 Header 的其它解码格式,所以如果你设置 Header 中有非 ASSIC 字符解码肯定会有乱码。

我们在添加 Header 时也是同样的道理,不要在 Header 中传递非 ASSIC 字符,如果一定要传递的话,我们可以先将这些字符用 org.apache.catalina.util.URLEncoder 编码然后再添加到 Header 中。

几个例子:

Cookie: 如果包含了中文,而且没有编码,你可能会看到异常:java.lang.IllegalArgumentException: Control character in cookie value or attribute.

Location: 这个字段一般可能不是我们直接设置,但是当重定向的时候比如在Controller里,你写了return "redirect:/index?cnParam=中文";,这时实际会设置Location头部,如果对中文没有进行编码处理,就会有乱码的问题出现了。如果是参数中包含中文,除了对链接调用URLEncoder.encode进行编码,还可以attributes.addAttribute("cnParam", "中文");return "redirect:/index";,这样Spring框架会进行Url编码,具体代码可以参考org.springframework.web.servlet.view.RedirectView#appendQueryProperties。(Tomcat7即使不对Url编码也没有问题,但是Tomcat6.0.29就不行,所以如果有中文最好还是编码一下吧)

Content-Disposition: 一般是上传下载文件时候会遇到编码的问题,可以参考正确处理下载文件时HTTP头的编码问题(Content-Disposition)

POST表单中的编码

POST 表单提交的参数的解码是在第一次调用 request.getParameter 发生的,POST 表单参数传递方式与 QueryString 不同,它是通过 HTTP 的 BODY 传递到服务端的。当我们在页面上点击 submit 按钮时浏览器首先将根据 ContentType 的 Charset 编码格式对表单填的参数进行编码然后提交到服务器端,在服务器端同样也是用 ContentType 中字符集进行解码。所以通过 POST 表单提交的参数一般不会出现问题,而且这个字符集编码是我们自己设置的,可以通过 request.setCharacterEncoding(charset) 来设置,这个调用一定要在第一次调用 request.getParameter前调用。

另外针对 multipart/form-data 类型的参数,也就是上传的文件编码同样也是使用 ContentType 定义的字符集编码,值得注意的地方是上传文件是用字节流的方式传输到服务器的本地临时目录,这个过程并没有涉及到字符编码,而真正编码是在将文件内容添加到 parameters 中,如果用这个编码不能编码时将会用默认编码 ISO-8859-1 来编码。

HTTP BODY 的编解码

当用户请求的资源已经成功获取后,这些内容将通过 Response 返回给客户端浏览器,这个过程先要经过编码再到浏览器进行解码。这个过程的编解码字符集可以通过 response.setCharacterEncoding 来设置,它将会覆盖 request.getCharacterEncoding 的值,并且通过 Header 的 Content-Type 返回客户端,浏览器接受到返回的 socket 流时将通过 Content-Type 的 charset 来解码,如果返回的 HTTP Header 中 Content-Type 没有设置 charset,那么浏览器将根据 Html 的 <meta HTTP-equiv="Content-Type" content="text/html; charset=GBK" /> 中的 charset 来解码。如果也没有定义的话,那么浏览器将使用默认的编码来解码。

JS中的编码

外部引入js文件

1
<script src="static/js/all.js" charset="gbk"></script>

对URL编码

  • escape已废弃
  • encodeURI
  • encodeURIComponent 和encodeURI方法的区别是编码更加彻底,比如像:,#,?这种字符也会被编码。Java中的URLEncoder和URLDecoder与encodeURIComponent和decodeURIComponent是基本对应的。

如果没有指定字符集,那么将使用当前页面的编码。

其它需要编码的地方

xml 文件可以通过设置头来制定编码格式

1
<?xml version="1.0" encoding="UTF-8"?>

Velocity 模版设置编码格式:

1
services.VelocityService.input.encoding=UTF-8

JSP 设置编码格式:

1
<%@page contentType="text/html; charset=UTF-8"%>

访问数据库都是通过客户端 JDBC 驱动来完成,用 JDBC 来存取数据要和数据的内置编码保持一致,可以通过设置 JDBC URL 来制定如 MySQL:url=”jdbc:mysql://localhost:3306/DB?useUnicode=true&characterEncoding=GBK”。

常见加载器类错误分析

  • ClassNotFoundException
    这个异常通常发生在显式加载类的时候,例如,用如下方式调用加载一个类时就报这个错了:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    public class NotFoundException {
    public static void main(String[] args) {
    try {
    Class.forName("NotFoundException");
    }catch (ClassNotFoundException e) {
    e.printStackTrace();
    }
    }
    }

    显式加载一个类通常有如下方式:

    • 通过类Class中的forName()方法。

    • 通过类 ClassLoader 中的 loadClass()方法。

    • 通过类 ClassLoader 中的 findSystemClass()方法。

      出现这类错误也很好理解,就是当JVM要加载指定文件的字节码到内存时,并没有找到这个文件对应的字节码,也就是这个文件并不存在。解决的办法就是检査在当前的classpath目录下有没有指定的文件存在。如果不知道当前的classpath路径,就可以通过如下命令来获取:this.getClass().getClassLoader().getResource("").toString()

  • NoClassDefFoundError
    NoClassDefFoundError是另外一个经常遇到的异常,这个异常在第一次使用命令行执行Java类时很可能会碰到,如: java -cp example.jar Example

    报错是因为你在命令行中没有加类的包名,正确的写法是这样的: java -cp example.jar net.xulingbo.Example

    而之前同时报了NoClassDefFoundError和 ClassNotFoundException异常,原因是 Java虚拟机隐式加载了exanple.jar后显式加载Example时没有找到这个类,所以是ClassNotFoundException 引发了 NoClassDefFoundError 异常。

    在JVM的规范中描述了出现NoClassDefFoundError可能的情况就是使用new关键字、属性引用某个类、继承某个接口或类,以及方法的某个参数中引用了某个类,这时会触发JVM隐式加载这些类时发现这些类不存在的异常。

    解决这个错误的办法就是确保每个类引用的类都在当前的classpath路径下面。

  • UnsatisfiedLinkError
    这个异常倒不是很常见,但是出错的话,通常是在JVM启动的时候,如果一不小心将在JVM中的某个lib删除了,可能就会报这个错误了,代码如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    public class NoLibException {
    public native void nativeMethod();
    static {
    System.loadLibrary("NoLib");
    }
    public static void main(String[] args) {
    new NoLibException().nativeMethod();
    }
    }

    这个错误通常是在解析native标识的方法时JVM找不到对应的本机库文件时出现

  • ClassCastException
    这个错误也很常见,通常在程序出现强制类型转换时出现这个错误,如下面这段代码所示:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    public class CastExceptlon {
    public static Map m = new HashMap(){
    {
    put ("a""2");
    }
    };
    public static void main(String[] args) {
    Integer islnt =(Integer)m.get("a");
    System.out.print(islnt);
    }
    }

    当强制将本来不是Integer类型的字符串转成Integer类型时会报错

    JVM在做类型转换时会按照如下规侧进行检査:

    • 对于普通对象,对象必须是目标类的实例或目标类的子类的实例。如果目标类是接口,那么会把它当作实现了该接口的一个子类。

    • 对于数组类型,目标类必须是数组类型或java.lang.Object 、java.lang.Cloneable或java.io.Serializable。

      如果不满足上面的规则,JVM就会报这个错误。要避免这个错误有两种方式:

    • 在容器类型中显式地指明这个容器所包含的对象类型,如在上面的Map中可以写为 Map<String,Integer> m = new HashMap<String,Integer>()。这祥上面的代码在编译阶段就会检査通过。

    • 先通过instanceof检查是不是目标类型,然后再进强制类型转换。

  • ExceptionInInitializerError
    这个错误在JVM规范中是这样定义的:

    • 如果Java虚拟机试图创建类ExceptionlnInitializerError的新实例,但是因为出现Out-Of-Memory-Error而无法创建新实倒.,那么就抛出OutOfMemoryError对象作为替代。

    • 如果初始化器抛出一些Exception,而且Exception类不是Error或者它的某个子类,那么就会创建ExceptionlnlnitializeiError类的一个新实例,并用Exception作为参数,用这个实例代替Exception。

      将上面的代码例子稍微改了一下:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      public class CastExceptlon {
      public static Map m = new HashMap(){
      {
      m.put ("a""2");
      }
      };
      public static void main(String[] args) {
      Integer islnt =(Integer)m.get("a");
      System.out.print(islnt);
      }
      }

      在初始化这个类时,给静态属性m赋值时出现了异常导致抛出错误ExceptionInInitializerError.

热部署

先简单说下类加载器。

  1. JVM通过类加载器(ClassLoader)加载类。JVM中提供的三种类加载器:BootStrap类加载器,加载JRE/lib;ExtClassLoader,加载 JRE/lib/ext;AppClassLoader 加载classpath。
  2. JVM加载采用了双亲委派机制,就是当加载一个类时,当前的ClassLoader先请求父ClassLoader,依次类推,直到父ClassLoader无法加载时,才通过当前的ClassLoader加载,这就保证了像String这样的类型肯定是由BootStrap类加载器加载的。
  3. 在JVM中,一个实例是通过本身的完整类名+加载它的ClassLoader实例识别的,也就是说即使同一个ClassLoader类的不同的实例加载同一个类在JVM也是不同的。
  4. 同一个ClassLoader是不允许多次加载一个类的,否则会报java.lang.LinkageError。JVM在加载类之前会检査请求的类是否已经被加载过来,也就是要调用flndLoadedClass()方法查看是否能够返回类实例。如果类已经加载过来,再调用loadClass()将会导致类冲突。

热部署是在不重启 Java 虚拟机的前提下,能自动侦测到 class 文件的变化,更新运行时 class 的行为。JVM并不支持热部署,那么要实现热部署,就必须自定义ClassLoader,当类被修改过后,可以创建不同的ClassLoader的实例对象,然后通过这个不同的实例对象来加载同名的类。

不需要担心这样会使PermGen区无限增大,因为ClassLoader 也是个对象,当不再被引用时也会被回收,之后在Full GC时,由其加载的类,如果没用了,也会被收回。

1
2
3
4
5
6
7
ClassReloader reloader = new ClassReloader(classpath);
Class r = reloader.findClass("HelloWorld.class");
System.out.println(r.newInstance());

ClassReloader reloader2 = new ClassReloader(classpath);
Class r2 = reloader2.findClass("HelloWorld.class");
System.out.println(r2.newInstance());

Class.forName()和ClassLoader.loadClass()区别

  • Class.forName():将类的.class文件加载到jvm中之外,还会对类进行解释,执行类中的static块;
  • ClassLoader.loadClass():只干一件事情,就是将.class文件加载到jvm中,不会执行static中的内容,只有在newInstance才会去执行static块。

在Tomcat中JSP就是热部署的。其实现原理也是通过创建新的类加载器实例,加载修改后的类。

对于WEB-INF/classes下的class文件,可以通过配置使其自动加载。

1
<Context docBase="xxx" path="/xxx" reloadable="true"/>

reloadable:如果这个属性设为true,tomcat服务器在运行状态下会监视在WEB-INF/classes和WEB-INF/lib目录下class文件的改动,如果监测到有class文件被更新的,服务器会自动重新加载Web应用。默认值是false。这个配置能干嘛,其实就是你文件变了,它重启下容器,也就重新加载类了,这个跟之前提到的热部署可能还有些出入。

如果搜“tomcat 热部署“,可能会搜到autoDeploy和crossContext两个属性,前者为Host属性,指示Tomcat运行时,如有新的WEB程序加入appBase指定的目录下,是否为自动布署,默认值为true。后者为Context属性,指示是否允许跨域访问,为true时,在程序内调用ServletContext.getContext()方法将返回一个虚拟主机上其它web程序的请求调度器。默认值为false,调 径用getContext()返回为null。所以这两个配置跟热部署也没多大关系了。

有一个工具可以实现这里说的热部署:JRebel。这个工具需要付费使用。

执行引擎

每个Java线程就是一个执行引擎实例,那么一个JVM实例中就会同时存在多个执行引擎工作。

执行引擎可以是基于栈的,如Hotspot,也可以是基于寄存器的,如Dalvik。前者是为了更好地支持跨平台,很难针对不同平台设计通用基于寄存器的指令,而后者主要运用于手机等智能设备,基本基于特定的芯片的寄存器设计。

Session与Cookie

为什么会使用Session和Cookie?因为HTTP是无状态的。而我们服务器端又需要知道用户状态,比如说这次请求和另一次请求是不是一个用户之类的,Session和Cookie就是用来记录状态的机制。

Cookie在客户端存储,一般会受到浏览器的限制,比如大多数浏览器要求每个域名只能保存50个Cookie、Cookie大小不能超过4KB。并且还需要防止Cookie被盗,Cookie伪造等问题。如果Cookie很大,还会占用带宽,可以考虑压缩Cookie,比如使用gzip或deflate进行压缩,压缩后再使用BASE64编码。

Session保存在服务器上。当访问增多,会比较占用服务器的资源,并且还需要考虑如何在服务器之间共享Session。

Session和Cookie也并不是必须的,如RESTful应用在服务端不会保存状态的,它的状态由客户端去维护,像浏览器这样的客户端也可以将状态保存在本地存储中(localstorage、sessionstorage)。这样可以不用考虑像Session共享这样的问题了。

Cookie一般是由服务器端生成,通过在响应头添加Set-Cookie使浏览器保存Cookie(临时或持久化保存),下次请求同一网站时浏览器会在请求头携带Cookie给服务器(前提是浏览器设置为启用Cookie)。Cookie主要就是键值对,有一种比较特殊的Cookie,就是SeesionID,Tomcat中其name是JSESSIONID,根据SeesionID服务器可以找到对应的Session,进而获取保存的状态。

当前 Cookie 有两个版本:Version 0 和 Version 1。通过它们有两种设置响应头的标识,分别是 “Set-Cookie”和“Set-Cookie2”,后者不常用。

Version 0 属性项介绍

属性项 属性项介绍
NAME=VALUE 键值对,可以设置要保存的 Key/Value,注意这里的 NAME 不能和其他属性项的名字一样,否则会抛出IllegalArgumentException。Value不能是非ASSIC字符,非ASSIC字符可以使用URLEncoder进行编码
Expires 过期时间,在设置的某个时间点后该 Cookie 就会失效,如 expires=Wednesday, 09-Nov-99 23:12:40 GMT。用Max-Age更多些
Domain 生成该 Cookie 的域名,Cookie不能跨域访问,如 images.google.com 不能访问 www.google.com的Cookie。但是对于二级域名,如果设置Domain=".google.com"的话,images.google.com 也能访问 www.google.com的Cookie
Path 该 Cookie 是在当前的哪个路径下生成的,Cookie只能在相同path下访问。如 path=/wp-admin/ 这样/wp-content/下就无法访问该Cookie。注意,path应该以/结束
Secure 如果设置了这个属性,那么只会在HTTPS和SSL等安全协议时才会回传该 Cookie

Version 1 属性项介绍

属性项 属性项介绍
NAME=VALUE 与 Version 0 相同
Version 通过 Set-Cookie2 设置的响应头创建必须符合 RFC2965 规范,如果通过 Set-Cookie 响应头设置,默认值为 0,如果要设置为 1,则该 Cookie 要遵循 RFC 2109 规范
Comment 注释项,用户说明该 Cookie 有何用途
CommentURL 服务器为此 Cookie 提供的 URI 注释
Discard 是否在会话结束后丢弃该 Cookie 项,默认为 fasle
Domain 类似于 Version 0
Max-Age 最大失效时间,为正数,会被持久化,即存储到文件中,指定多少秒后失效;为负数,不被持久化,关闭浏览器后失效,0表示立即失效,可以起到删除Cookie的作用,这里需要保证 name, path和domain 是相同的
Path 类似于 Version 0
Port 该 Cookie 在什么端口下可以回传服务端,如果有多个端口,以逗号隔开,如 Port=”80,81,8080”
Secure 类似于 Version 0

JavaEE中对应的类是javax.servlet.http.Cookie包含name,value,secure,path,domain,comment,version属性。通过request.getCookies()获取客户端提交的所有Cookie,通过response.addCookie(Cookie cookie)向客户端设置Cookie。

如果我们通过浏览器查看当前网站的Cookie,可能会看到两种Cookie,一种是当前网站本身设置的 Cookie,另一种是来自在网页上嵌入广告或图片等其他域来源的 第三方 Cookie (网站可通过使用这些 Cookie 跟踪你的使用信息)。可以确定的是对于Cookie来说肯定是不允许垮域访问的。无论是通过JS还是Server端程序来说都是如此,但是通过一些技术可以,比如天猫如何能跨域获取淘宝的Cookie,一种办法就是淘宝提供接口可以获取到其下的Cookie

Session

Session是另一种记录客户状态的机制,保存在服务器上。

Session存储在服务器哪里?为了获得更高的存取速度,服务器一般把Session放在内存里。也可以将其存储在数据库、文件系统、缓存中。当Servlet容器关闭时StandardManager类会把Session对象写入SESSIONS.ser文件中,要想持久化保存Servlet容器中的Session对象,必须通过调用Servlet容器的start和stop方法。

何时生成Session?调用request.getSession()方法时,如果不存在Session就会创建一个新的,用户第一次访问服务器时,访问JSP或Servlet等程序才会创建Session,只访问HTML、图片等静态资源并不会创建Session。服务器会更新Session最后访问时间。

何时失效?服务器会把长时间内没有活跃的Session从内存删除。这个时间就是Session的超时时间 。如果超过了超时时间没访问过服务器,Session就自动失效了。Session的超时时间为maxInactiveInterval属性,可以通过对应的getMaxInactiveInterval()获取,通过setMaxInactiveInterval(long interval)修改。Session的超时时间也可以在web.xml中修改。另外,通过调用Session的invalidate()方法可以使Session失效。

如何来识别特定用户?依靠Cookie,服务端会在HTTP协议中告诉客户端,需要在 Cookie 里面记录一个Session ID,以后每次请求把这个会话ID发送到服务器,我就知道你是谁了。如果客户端的浏览器禁用了 Cookie 怎么办?一般这种情况下,会使用一种叫做URL重写的技术来进行会话跟踪,即每次HTTP交互,URL后面都会被附加上一个诸如 ;JSESSIONID=xxxxx 这样的参数,服务端据此来识别用户。

通过订阅服务器(如Zookeeper集群管理服务器)统一各个服务器的Session配置项,通过分布式缓存(如MemCache)存储Session,解决Session共享问题。

表单重复提交问题

每次生成表单时,都生成一个隐藏表单项,其值为一个唯一的token,并且这个token存储在Session中,表单提交到服务器后,比对表单中的token和服务器Session中的token是否一致,如果一致则没有重复提交,进行后续处理,然后重新生成Session中的token。如果不一致,说明表单已经成功提交过一次了,这是重复提交。

多终端Session统一

一种情况是在移动设备上访问无限服务端系统和PC服务端系统,这两者之间Session的共享,这个可以通过分布式Session框架解决。另一种情况是在移动端已经登陆的前提下,PC端通过扫码登录,这种情况下二维码携带一个特定标识,标识事这个客户端通过手机端登录了。当手机扫码成功后,会在服务器端设置这个二维码对应的标识为已经登录成功,PC端通过心跳请求验证是否登陆成功。

跨域资源共享(CORS)

出于安全考虑,浏览器会限制脚本中发起的跨站请求。比如,使用 XMLHttpRequest 对象发起 HTTP 请求就必须遵守同源策略(same-origin policy)。

简单记录下两种跨域请求

  • 简单请求
    这种跨站请求与以往浏览器发出的跨站请求并无异同。并且,如果服务器不给出适当的响应头,则不会有任何数据返回给请求方。因此,那些不允许跨站请求的网站无需为这一新的 HTTP 访问控制特性担心。这种请求具备以下两个条件:

    • 只使用 GET, HEAD 或者 POST 请求方法。如果使用 POST 向服务器端传送数据,则数据类型(Content-Type)只能是 application/x-www-form-urlencoded, multipart/form-data 或 text/plain中的一种。
    • 不会使用自定义请求头(类似于 X-Modified 这种)。
  • 预请求(preflight request)
    不同于上面讨论的简单请求,“预请求”要求必须先发送一个 OPTIONS 请求给目的站点,来查明这个跨站请求对于目的站点是不是安全可接受的。这样做,是因为跨站请求可能会对目的站点的数据造成破坏。这种场景下,总比正常请求多一次请求。当请求具备以下条件,就会被当成预请求处理:

    • 请求以 GET, HEAD 或者 POST 以外的方法发起请求。或者,使用 POST,但请求数据为 application/x-www-form-urlencoded, multipart/form-data 或者 text/plain 以外的数据类型。比如说,用 POST 发送数据类型为 application/xml 或者 text/xml 的 XML 数据的请求。
    • 使用自定义请求头(比如添加诸如 X-PINGOTHER)

跨域请求请求头可能带有Origin、Access-Control-Request-Method、Access-Control-Request-Headers,分别用来标识发起请求的URI,请求方法,请求头

响应头部可能带有Access-Control-Allow-Origin,Access-Control-Max-Age,Access-Control-Allow-Methods,Access-Control-Allow-Headers分别表示允许发起访问的URI,预请求的结果的有效期,允许的访问方法,允许的请求头

Tomcat 中配置CORS过滤器

1
2
3
4
5
6
7
8
<filter>
<filter-name>CorsFilter</filter-name>
<filter-class>org.apache.catalina.filters.CorsFilter</filter-class>
</filter>
<filter-mapping>
<filter-name>CorsFilter</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>

跨域资源共享更详细的内容可以参考:
http://www.w3.org/TR/cors/

HTTP访问控制(CORS)

不同的服务器配置CORS

君山的博客中整理了一些资料

Updated: