JAVA-XML与JSON

分類 编程语言, Java

XML简介

XML是可扩展标记语言(eXtensible Markup Language)的缩写,它是一种数据表示格式,可以描述非常复杂的数据结构,常用于传输和存储数据。

例如,一个描述书籍的XML文档可能如下:

1
2
3
4
5
6
7
8
9
10
11
12
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE note SYSTEM "book.dtd">
<book id="1">
<name>Java核心技术</name>
<author>Cay S. Horstmann</author>
<isbn lang="CN">1234567</isbn>
<tags>
<tag>Java</tag>
<tag>Network</tag>
</tags>
<pubDate/>
</book>

XML有几个特点:一是纯文本,默认使用UTF-8编码,二是可嵌套,适合表示结构化数据。如果把XML内容存为文件,那么它就是一个XML文件,例如book.xml。此外,XML内容经常通过网络作为消息传输。

XML的结构

XML有固定的结构,首行必定是<?xml version="1.0"?>,可以加上可选的编码。紧接着,如果以类似<!DOCTYPE note SYSTEM "book.dtd">声明的是文档定义类型(DTD:Document Type Definition),DTD是可选的。接下来是XML的文档内容,一个XML文档有且仅有一个根元素,根元素可以包含任意个子元素,元素可以包含属性,例如,<isbn lang="CN">1234567</isbn>包含一个属性lang="CN",且元素必须正确嵌套。如果是空元素,可以用<tag/>表示。

由于使用了<>以及引号等标识符,如果内容出现了特殊符号,需要使用&???;表示转义。例如,Java<tm>必须写成:

1
<name>Java&lt;tm&gt;</name>

常见的特殊字符如下:

字符 表示
< <
> >
& &
" "
'

格式正确的XML(Well Formed)是指XML的格式是正确的,可以被解析器正常读取。而合法的XML是指,不但XML格式正确,而且它的数据结构可以被DTD或者XSD验证。

DTD文档可以指定一系列规则,例如:

  • 根元素必须是book
  • book元素必须包含nameauthor等指定元素
  • isbn元素必须包含属性lang

如何验证XML文件的正确性呢?最简单的方式是通过浏览器验证。可以直接把XML文件拖拽到浏览器窗口,如果格式错误,浏览器会报错。

和结构类似的HTML不同,浏览器对HTML有一定的“容错性”,缺少关闭标签也可以被解析,但XML要求严格的格式,任何没有正确嵌套的标签都会导致错误。

XML是一个技术体系,除了我们经常用到的XML文档本身外,XML还支持:

  • DTD和XSD:验证XML结构和数据是否有效;
  • Namespace:XML节点和属性的名字空间;
  • XSLT:把XML转化为另一种文本;
  • XPath:一种XML节点查询语言;

实际上,XML的这些相关技术实现起来非常复杂,在实际应用中很少用到,通常了解一下就可以了。

小结

XML使用嵌套结构的数据表示方式,支持格式验证;

XML常用于配置文件、网络消息传输等。



使用DOM

因为XML是一种树形结构的文档,它有两种标准的解析API:

  • DOM:一次性读取XML,并在内存中表示为树形结构;
  • SAX:以流的形式读取XML,使用事件回调。

我们先来看如何使用DOM来读取XML。

DOM是Document Object Model的缩写,DOM模型就是把XML结构作为一个树形结构处理,从根节点开始,每个节点都可以包含任意个子节点。

我们以下面的XML为例:

1
2
3
4
5
6
7
8
9
10
11
<?xml version="1.0" encoding="UTF-8" ?>
<book id="1">
<name>Java核心技术</name>
<author>Cay S. Horstmann</author>
<isbn lang="CN">1234567</isbn>
<tags>
<tag>Java</tag>
<tag>Network</tag>
</tags>
<pubDate/>
</book>

如果解析为DOM结构,它大概长这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
                      ┌─────────┐
│document │
└─────────┘


┌─────────┐
│ book │
└─────────┘

┌──────────┬──────────┼──────────┬──────────┐
▼ ▼ ▼ ▼ ▼
┌─────────┐┌─────────┐┌─────────┐┌─────────┐┌─────────┐
│ name ││ author ││ isbn ││ tags ││ pubDate │
└─────────┘└─────────┘└─────────┘└─────────┘└─────────┘

┌────┴────┐
▼ ▼
┌───────┐ ┌───────┐
│ tag │ │ tag │
└───────┘ └───────┘

注意到最顶层的document代表XML文档,它是真正的“根”,而<book>虽然是根元素,但它是document的一个子节点。

Java提供了DOM API来解析XML,它使用下面的对象来表示XML的内容:

  • Document:代表整个XML文档;
  • Element:代表一个XML元素;
  • Attribute:代表一个元素的某个属性。

使用DOM API解析一个XML文档的代码如下:

1
2
3
4
InputStream input = Main.class.getResourceAsStream("/book.xml");
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
DocumentBuilder db = dbf.newDocumentBuilder();
Document doc = db.parse(input);

DocumentBuilder.parse()用于解析一个XML,它可以接收InputStream,File或者URL,如果解析无误,我们将获得一个Document对象,这个对象代表了整个XML文档的树形结构,需要遍历以便读取指定元素的值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
void printNode(Node n, int indent) {
for (int i = 0; i < indent; i++) {
System.out.print(' ');
}
switch (n.getNodeType()) {
case Node.DOCUMENT_NODE: // Document节点
System.out.println("Document: " + n.getNodeName());
break;
case Node.ELEMENT_NODE: // 元素节点
System.out.println("Element: " + n.getNodeName());
break;
case Node.TEXT_NODE: // 文本
System.out.println("Text: " + n.getNodeName() + " = " + n.getNodeValue());
break;
case Node.ATTRIBUTE_NODE: // 属性
System.out.println("Attr: " + n.getNodeName() + " = " + n.getNodeValue());
break;
default: // 其他
System.out.println("NodeType: " + n.getNodeType() + ", NodeName: " + n.getNodeName());
}
for (Node child = n.getFirstChild(); child != null; child = child.getNextSibling()) {
printNode(child, indent + 1);
}
}

解析结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
Document: #document
Element: book
Text: #text =

Element: name
Text: #text = Java核心技术
Text: #text =

Element: author
Text: #text = Cay S. Horstmann
Text: #text =
...

对于DOM API解析出来的结构,我们从根节点Document出发,可以遍历所有子节点,获取所有元素、属性、文本数据,还可以包括注释,这些节点被统称为Node,每个Node都有自己的Type,根据Type来区分一个Node到底是元素,还是属性,还是文本,等等。

使用DOM API时,如果要读取某个元素的文本,需要访问它的Text类型的子节点,所以使用起来还是比较繁琐的。

练习

使用DOM解析XML。

下载练习

小结

Java提供的DOM API可以将XML解析为DOM结构,以Document对象表示;

DOM可在内存中完整表示XML数据结构;

DOM解析速度慢,内存占用大。



使用SAX

使用DOM解析XML的优点是用起来省事,但它的主要缺点是内存占用太大。

另一种解析XML的方式是SAX。SAX是Simple API for XML的缩写,它是一种基于流的解析方式,边读取XML边解析,并以事件回调的方式让调用者获取数据。因为是一边读一边解析,所以无论XML有多大,占用的内存都很小。

SAX解析会触发一系列事件:

  • startDocument:开始读取XML文档;
  • startElement:读取到了一个元素,例如<book>
  • characters:读取到了字符;
  • endElement:读取到了一个结束的元素,例如</book>
  • endDocument:读取XML文档结束。

如果我们用SAX API解析XML,Java代码如下:

1
2
3
4
InputStream input = Main.class.getResourceAsStream("/book.xml");
SAXParserFactory spf = SAXParserFactory.newInstance();
SAXParser saxParser = spf.newSAXParser();
saxParser.parse(input, new MyHandler());

关键代码SAXParser.parse()除了需要传入一个InputStream外,还需要传入一个回调对象,这个对象要继承自DefaultHandler

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
class MyHandler extends DefaultHandler {
public void startDocument() throws SAXException {
print("start document");
}

public void endDocument() throws SAXException {
print("end document");
}

public void startElement(String uri, String localName, String qName, Attributes attributes) throws SAXException {
print("start element:", localName, qName);
}

public void endElement(String uri, String localName, String qName) throws SAXException {
print("end element:", localName, qName);
}

public void characters(char[] ch, int start, int length) throws SAXException {
print("characters:", new String(ch, start, length));
}

public void error(SAXParseException e) throws SAXException {
print("error:", e);
}

void print(Object... objs) {
for (Object obj : objs) {
System.out.print(obj);
System.out.print(" ");
}
System.out.println();
}
}

运行SAX解析代码,可以打印出下面的结果:

1
2
3
4
5
6
7
8
9
10
11
start document
start element: book
characters:

start element: name
characters: Java核心技术
end element: name
characters:

start element: author
...

如果要读取<name>节点的文本,我们就必须在解析过程中根据startElement()endElement()定位当前正在读取的节点,可以使用栈结构保存,每遇到一个startElement()入栈,每遇到一个endElement()出栈,这样,读到characters()时我们才知道当前读取的文本是哪个节点的。可见,使用SAX API仍然比较麻烦。

练习

使用SAX解析XML。

下载练习

小结

SAX是一种流式解析XML的API;

SAX通过事件触发,读取速度快,消耗内存少;

调用方必须通过回调方法获得解析过程中的数据。



使用Jackson

前面我们介绍了DOM和SAX两种解析XML的标准接口。但是,无论是DOM还是SAX,使用起来都不直观。

观察XML文档的结构:

1
2
3
4
5
6
7
8
9
10
11
<?xml version="1.0" encoding="UTF-8" ?>
<book id="1">
<name>Java核心技术</name>
<author>Cay S. Horstmann</author>
<isbn lang="CN">1234567</isbn>
<tags>
<tag>Java</tag>
<tag>Network</tag>
</tags>
<pubDate/>
</book>

我们发现,它完全可以对应到一个定义好的JavaBean中:

1
2
3
4
5
6
7
8
public class Book {
public long id;
public String name;
public String author;
public String isbn;
public List<String> tags;
public String pubDate;
}

如果能直接从XML文档解析成一个JavaBean,那比DOM或者SAX不知道容易到哪里去了。

幸运的是,一个名叫Jackson的开源的第三方库可以轻松做到XML到JavaBean的转换。我们要使用Jackson,先添加一个Maven的依赖:

  • com.fasterxml.jackson.dataformat:jackson-dataformat-xml:2.10.1

然后,定义好JavaBean,就可以用下面几行代码解析:

1
2
3
4
5
6
7
8
9
10
InputStream input = Main.class.getResourceAsStream("/book.xml");
JacksonXmlModule module = new JacksonXmlModule();
XmlMapper mapper = new XmlMapper(module);
Book book = mapper.readValue(input, Book.class);
System.out.println(book.id);
System.out.println(book.name);
System.out.println(book.author);
System.out.println(book.isbn);
System.out.println(book.tags);
System.out.println(book.pubDate);

注意到XmlMapper就是我们需要创建的核心对象,可以用readValue(InputStream, Class)直接读取XML并返回一个JavaBean。运行上述代码,就可以直接从Book对象中拿到数据:

1
2
3
4
5
6
1
Java核心技术
Cay S. Horstmann
1234567
[Java, Network]
null

如果要解析的数据格式不是Jackson内置的标准格式,那么需要编写一点额外的扩展来告诉Jackson如何自定义解析。这里我们不做深入讨论,可以参考Jackson的官方文档

练习

使用Jackson解析XML。

下载练习

小结

使用Jackson解析XML,可以直接把XML解析为JavaBean,十分方便。



前面我们讨论了XML这种数据格式。XML的特点是功能全面,但标签繁琐,格式复杂。在Web上使用XML现在越来越少,取而代之的是JSON这种数据结构。

JSON是JavaScript Object Notation的缩写,它去除了所有JavaScript执行代码,只保留JavaScript的对象格式。一个典型的JSON如下:

1
2
3
4
5
6
7
8
9
10
{
"id": 1,
"name": "Java核心技术",
"author": {
"firstName": "Abc",
"lastName": "Xyz"
},
"isbn": "1234567",
"tags": ["Java", "Network"]
}

JSON作为数据传输的格式,有几个显著的优点:

  • JSON只允许使用UTF-8编码,不存在编码问题;
  • JSON只允许使用双引号作为key,特殊字符用\转义,格式简单;
  • 浏览器内置JSON支持,如果把数据用JSON发送给浏览器,可以用JavaScript直接处理。

因此,JSON适合表示层次结构,因为它格式简单,仅支持以下几种数据类型:

  • 键值对:{"key": value}
  • 数组:[1, 2, 3]
  • 字符串:"abc"
  • 数值(整数和浮点数):12.34
  • 布尔值:truefalse
  • 空值:null

浏览器直接支持使用JavaScript对JSON进行读写:

1
2
3
4
5
// JSON string to JavaScript object:
jsObj = JSON.parse(jsonStr);

// JavaScript object to JSON string:
jsonStr = JSON.stringify(jsObj);

所以,开发Web应用的时候,使用JSON作为数据传输,在浏览器端非常方便。因为JSON天生适合JavaScript处理,所以,绝大多数REST API都选择JSON作为数据传输格式。

现在问题来了:使用Java如何对JSON进行读写?

在Java中,针对JSON也有标准的JSR 353 API,但是我们在前面讲XML的时候发现,如果能直接在XML和JavaBean之间互相转换是最好的。类似的,如果能直接在JSON和JavaBean之间转换,那么用起来就简单多了。

常用的用于解析JSON的第三方库有:

  • Jackson
  • GSON
  • JSON-lib

注意到上一节提到的那个可以解析XML的浓眉大眼的Jackson也可以解析JSON!因此我们只需要引入以下Maven依赖:

  • com.fasterxml.jackson.core:jackson-databind:2.12.0

就可以使用下面的代码解析一个JSON文件:

1
2
3
4
5
InputStream input = Main.class.getResourceAsStream("/book.json");
ObjectMapper mapper = new ObjectMapper();
// 反序列化时忽略不存在的JavaBean属性:
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
Book book = mapper.readValue(input, Book.class);

核心代码是创建一个ObjectMapper对象。关闭DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES功能使得解析时如果JavaBean不存在该属性时解析不会报错。

把JSON解析为JavaBean的过程称为反序列化。如果把JavaBean变为JSON,那就是序列化。要实现JavaBean到JSON的序列化,只需要一行代码:

1
String json = mapper.writeValueAsString(book);

要把JSON的某些值解析为特定的Java对象,例如LocalDate,也是完全可以的。例如:

1
2
3
4
{
"name": "Java核心技术",
"pubDate": "2016-09-01"
}

要解析为:

1
2
3
4
public class Book {
public String name;
public LocalDate pubDate;
}

只需要引入标准的JSR 310关于JavaTime的数据格式定义至Maven:

  • com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.12.0

然后,在创建ObjectMapper时,注册一个新的JavaTimeModule

1
ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule());

有些时候,内置的解析规则和扩展的解析规则如果都不满足我们的需求,还可以自定义解析。

举个例子,假设Book类的isbn是一个BigInteger

1
2
3
4
public class Book {
public String name;
public BigInteger isbn;
}

但JSON数据并不是标准的整形格式:

1
2
3
4
{
"name": "Java核心技术",
"isbn": "978-7-111-54742-6"
}

直接解析,肯定报错。这时,我们需要自定义一个IsbnDeserializer,用于解析含有非数字的字符串:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class IsbnDeserializer extends JsonDeserializer<BigInteger> {
public BigInteger deserialize(JsonParser p, DeserializationContext ctxt) throws IOException, JsonProcessingException {
// 读取原始的JSON字符串内容:
String s = p.getValueAsString();
if (s != null) {
try {
return new BigInteger(s.replace("-", ""));
} catch (NumberFormatException e) {
throw new JsonParseException(p, s, e);
}
}
return null;
}
}

然后,在Book类中使用注解标注:

1
2
3
4
5
6
public class Book {
public String name;
// 表示反序列化isbn时使用自定义的IsbnDeserializer:
@JsonDeserialize(using = IsbnDeserializer.class)
public BigInteger isbn;
}

类似的,自定义序列化时我们需要自定义一个IsbnSerializer,然后在Book类中标注@JsonSerialize(using = ...)即可。

反序列化

在反序列化时,Jackson要求Java类需要一个默认的无参数构造方法,否则,无法直接实例化此类。存在带参数构造方法的类,如果要反序列化,注意再提供一个无参数构造方法。

对于enum字段,Jackson按String类型处理,即:

1
2
3
class Book {
public DayOfWeek start = MONDAY;
}

序列化为:

1
2
3
{
"start": "MONDAY"
}

对于record类型,Jackson会自动找出它的带参数构造方法,并根据JSON的key进行匹配,可直接反序列化。对record类型的支持需要版本2.12.0以上。

练习

使用Jackson解析JSON。

下载练习

小结

JSON是轻量级的数据表示方式,常用于Web应用;

Jackson可以实现JavaBean和JSON之间的转换;

可以通过Module扩展Jackson能处理的数据类型;

可以自定义JsonSerializerJsonDeserializer来定制序列化和反序列化。

留言與分享

JAVA-网络编程

分類 编程语言, Java

在学习Java网络编程之前,我们先来了解什么是计算机网络。

计算机网络是指两台或更多的计算机组成的网络,在同一个网络中,任意两台计算机都可以直接通信,因为所有计算机都需要遵循同一种网络协议。

那什么是互联网呢?互联网是网络的网络(Internet),即把很多计算机网络连接起来,形成一个全球统一的互联网。

对某个特定的计算机网络来说,它可能使用网络协议ABC,而另一个计算机网络可能使用网络协议XYZ。如果计算机网络各自的通讯协议不统一,就没法把不同的网络连接起来形成互联网。因此,为了把计算机网络接入互联网,就必须使用TCP/IP协议。

TCP/IP协议泛指互联网协议,其中最重要的两个协议是TCP协议和IP协议。只有使用TCP/IP协议的计算机才能够联入互联网,使用其他网络协议(例如NetBIOS、AppleTalk协议等)是无法联入互联网的。

IP地址

在互联网中,一个IP地址用于唯一标识一个网络接口(Network Interface)。一台联入互联网的计算机肯定有一个IP地址,但也可能有多个IP地址。

IP地址分为IPv4和IPv6两种。IPv4采用32位地址,类似101.202.99.12,而IPv6采用128位地址,类似2001:0DA8:100A:0000:0000:1020:F2F3:1428。IPv4地址总共有232个(大约42亿),而IPv6地址则总共有2128个(大约340万亿亿亿亿),IPv4的地址目前已耗尽,而IPv6的地址是根本用不完的。

IP地址又分为公网IP地址和内网IP地址。公网IP地址可以直接被访问,内网IP地址只能在内网访问。内网IP地址类似于:

  • 192.168.x.x
  • 10.x.x.x

有一个特殊的IP地址,称之为本机地址,它总是127.0.0.1

IPv4地址实际上是一个32位整数。例如:

1
2
3
1707762444 = 0x65ca630c
= 65 ca 63 0c
= 101.202.99.12

如果一台计算机只有一个网卡,并且接入了网络,那么,它有一个本机地址127.0.0.1,还有一个IP地址,例如101.202.99.12,可以通过这个IP地址接入网络。

如果一台计算机有两块网卡,那么除了本机地址,它可以有两个IP地址,可以分别接入两个网络。通常连接两个网络的设备是路由器或者交换机,它至少有两个IP地址,分别接入不同的网络,让网络之间连接起来。

如果两台计算机位于同一个网络,那么他们之间可以直接通信,因为他们的IP地址前段是相同的,也就是网络号是相同的。网络号是IP地址通过子网掩码过滤后得到的。例如:

某台计算机的IP是101.202.99.2,子网掩码是255.255.255.0,那么计算该计算机的网络号是:

1
2
3
IP = 101.202.99.2
Mask = 255.255.255.0
Network = IP & Mask = 101.202.99.0

每台计算机都需要正确配置IP地址和子网掩码,根据这两个就可以计算网络号,如果两台计算机计算出的网络号相同,说明两台计算机在同一个网络,可以直接通信。如果两台计算机计算出的网络号不同,那么两台计算机不在同一个网络,不能直接通信,它们之间必须通过路由器或者交换机这样的网络设备间接通信,我们把这种设备称为网关。

网关的作用就是连接多个网络,负责把来自一个网络的数据包发到另一个网络,这个过程叫路由。

所以,一台计算机的一个网卡会有3个关键配置:

network

  • IP地址,例如:10.0.2.15
  • 子网掩码,例如:255.255.255.0
  • 网关的IP地址,例如:10.0.2.2

域名

因为直接记忆IP地址非常困难,所以我们通常使用域名访问某个特定的服务。域名解析服务器DNS负责把域名翻译成对应的IP,客户端再根据IP地址访问服务器。

nslookup可以查看域名对应的IP地址:

1
2
3
4
5
6
7
$ nslookup liaoxuefeng.com
Server: xxx.xxx.xxx.xxx
Address: xxx.xxx.xxx.xxx#53

Non-authoritative answer:
Name: liaoxuefeng.com
Address: xxx.xxx.xxx.xxx

有一个特殊的本机域名localhost,它对应的IP地址总是本机地址127.0.0.1

网络模型

由于计算机网络从底层的传输到高层的软件设计十分复杂,要合理地设计计算机网络模型,必须采用分层模型,每一层负责处理自己的操作。OSI(Open System Interconnect)网络模型是ISO组织定义的一个计算机互联的标准模型,注意它只是一个定义,目的是为了简化网络各层的操作,提供标准接口便于实现和维护。这个模型从上到下依次是:

  • 应用层,提供应用程序之间的通信;
  • 表示层:处理数据格式,加解密等等;
  • 会话层:负责建立和维护会话;
  • 传输层:负责提供端到端的可靠传输;
  • 网络层:负责根据目标地址选择路由来传输数据;
  • 链路层和物理层负责把数据进行分片并且真正通过物理网络传输,例如,无线网、光纤等。

互联网实际使用的TCP/IP模型并不是对应到OSI的7层模型,而是大致对应OSI的5层模型:

OSI TCP/IP
应用层 应用层
表示层
会话层
传输层 传输层
网络层 IP层
链路层 网络接口层
物理层

常用协议

IP协议是一个分组交换,它不保证可靠传输。而TCP协议是传输控制协议,它是面向连接的协议,支持可靠传输和双向通信。TCP协议是建立在IP协议之上的,简单地说,IP协议只负责发数据包,不保证顺序和正确性,而TCP协议负责控制数据包传输,它在传输数据之前需要先建立连接,建立连接后才能传输数据,传输完后还需要断开连接。TCP协议之所以能保证数据的可靠传输,是通过接收确认、超时重传这些机制实现的。并且,TCP协议允许双向通信,即通信双方可以同时发送和接收数据。

TCP协议也是应用最广泛的协议,许多高级协议都是建立在TCP协议之上的,例如HTTP、SMTP等。

UDP协议(User Datagram Protocol)是一种数据报文协议,它是无连接协议,不保证可靠传输。因为UDP协议在通信前不需要建立连接,因此它的传输效率比TCP高,而且UDP协议比TCP协议要简单得多。

选择UDP协议时,传输的数据通常是能容忍丢失的,例如,一些语音视频通信的应用会选择UDP协议。

小结

计算机网络的基本概念主要有:

  • 计算机网络:由两台或更多计算机组成的网络;
  • 互联网:连接网络的网络;
  • IP地址:计算机的网络接口(通常是网卡)在网络中的唯一标识;
  • 网关:负责连接多个网络,并在多个网络之间转发数据的计算机,通常是路由器或交换机;
  • 网络协议:互联网使用TCP/IP协议,它泛指互联网协议簇;
  • IP协议:一种分组交换传输协议;
  • TCP协议:一种面向连接,可靠传输的协议;
  • UDP协议:一种无连接,不可靠传输的协议。

在开发网络应用程序的时候,我们又会遇到Socket这个概念。Socket是一个抽象概念,一个应用程序通过一个Socket来建立一个远程连接,而Socket内部通过TCP/IP协议把数据传输到网络:

1
2
3
4
5
6
7
8
9
┌───────────┐                                ┌───────────┐
│Application│ │Application│
├───────────┤ ├───────────┤
│ Socket │ │ Socket │
├───────────┤ ├───────────┤
│ TCP │ │ TCP │
├───────────┤ ┌──────┐ ┌──────┐ ├───────────┤
│ IP │◀───▶│Router│◀────▶│Router│◀───▶│ IP │
└───────────┘ └──────┘ └──────┘ └───────────┘

Socket、TCP和部分IP的功能都是由操作系统提供的,不同的编程语言只是提供了对操作系统调用的简单的封装。例如,Java提供的几个Socket相关的类就封装了操作系统提供的接口。

为什么需要Socket进行网络通信?因为仅仅通过IP地址进行通信是不够的,同一台计算机同一时间会运行多个网络应用程序,例如浏览器、QQ、邮件客户端等。当操作系统接收到一个数据包的时候,如果只有IP地址,它没法判断应该发给哪个应用程序,所以,操作系统抽象出Socket接口,每个应用程序需要各自对应到不同的Socket,数据包才能根据Socket正确地发到对应的应用程序。

一个Socket就是由IP地址和端口号(范围是0~65535)组成,可以把Socket简单理解为IP地址加端口号。端口号总是由操作系统分配,它是一个0~65535之间的数字,其中,小于1024的端口属于特权端口,需要管理员权限,大于1024的端口可以由任意用户的应用程序打开。

  • Browser: 101.202.99.2:1201
  • QQ: 101.202.99.2:1304
  • Email: 101.202.99.2:15000

使用Socket进行网络编程时,本质上就是两个进程之间的网络通信。其中一个进程必须充当服务器端,它会主动监听某个指定的端口,另一个进程必须充当客户端,它必须主动连接服务器的IP地址和指定端口,如果连接成功,服务器端和客户端就成功地建立了一个TCP连接,双方后续就可以随时发送和接收数据。

因此,当Socket连接成功地在服务器端和客户端之间建立后:

  • 对服务器端来说,它的Socket是指定的IP地址和指定的端口号;
  • 对客户端来说,它的Socket是它所在计算机的IP地址和一个由操作系统分配的随机端口号。

服务器端

要使用Socket编程,我们首先要编写服务器端程序。Java标准库提供了ServerSocket来实现对指定IP和指定端口的监听。ServerSocket的典型实现代码如下:

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
public class Server {
public static void main(String[] args) throws IOException {
ServerSocket ss = new ServerSocket(6666); // 监听指定端口
System.out.println("server is running...");
for (;;) {
Socket sock = ss.accept();
System.out.println("connected from " + sock.getRemoteSocketAddress());
Thread t = new Handler(sock);
t.start();
}
}
}

class Handler extends Thread {
Socket sock;

public Handler(Socket sock) {
this.sock = sock;
}

@Override
public void run() {
try (InputStream input = this.sock.getInputStream()) {
try (OutputStream output = this.sock.getOutputStream()) {
handle(input, output);
}
} catch (Exception e) {
try {
this.sock.close();
} catch (IOException ioe) {
}
System.out.println("client disconnected.");
}
}

private void handle(InputStream input, OutputStream output) throws IOException {
var writer = new BufferedWriter(new OutputStreamWriter(output, StandardCharsets.UTF_8));
var reader = new BufferedReader(new InputStreamReader(input, StandardCharsets.UTF_8));
writer.write("hello\n");
writer.flush();
for (;;) {
String s = reader.readLine();
if (s.equals("bye")) {
writer.write("bye\n");
writer.flush();
break;
}
writer.write("ok: " + s + "\n");
writer.flush();
}
}
}

服务器端通过代码:

1
ServerSocket ss = new ServerSocket(6666);

在指定端口6666监听。这里我们没有指定IP地址,表示在计算机的所有网络接口上进行监听。

如果ServerSocket监听成功,我们就使用一个无限循环来处理客户端的连接:

1
2
3
4
5
for (;;) {
Socket sock = ss.accept();
Thread t = new Handler(sock);
t.start();
}

注意到代码ss.accept()表示每当有新的客户端连接进来后,就返回一个Socket实例,这个Socket实例就是用来和刚连接的客户端进行通信的。由于客户端很多,要实现并发处理,我们就必须为每个新的Socket创建一个新线程来处理,这样,主线程的作用就是接收新的连接,每当收到新连接后,就创建一个新线程进行处理。

我们在多线程编程的章节中介绍过线程池,这里也完全可以利用线程池来处理客户端连接,能大大提高运行效率。

如果没有客户端连接进来,accept()方法会阻塞并一直等待。如果有多个客户端同时连接进来,ServerSocket会把连接扔到队列里,然后一个一个处理。对于Java程序而言,只需要通过循环不断调用accept()就可以获取新的连接。

客户端

相比服务器端,客户端程序就要简单很多。一个典型的客户端程序如下:

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
public class Client {
public static void main(String[] args) throws IOException {
Socket sock = new Socket("localhost", 6666); // 连接指定服务器和端口
try (InputStream input = sock.getInputStream()) {
try (OutputStream output = sock.getOutputStream()) {
handle(input, output);
}
}
sock.close();
System.out.println("disconnected.");
}

private static void handle(InputStream input, OutputStream output) throws IOException {
var writer = new BufferedWriter(new OutputStreamWriter(output, StandardCharsets.UTF_8));
var reader = new BufferedReader(new InputStreamReader(input, StandardCharsets.UTF_8));
Scanner scanner = new Scanner(System.in);
System.out.println("[server] " + reader.readLine());
for (;;) {
System.out.print(">>> "); // 打印提示
String s = scanner.nextLine(); // 读取一行输入
writer.write(s);
writer.newLine();
writer.flush();
String resp = reader.readLine();
System.out.println("<<< " + resp);
if (resp.equals("bye")) {
break;
}
}
}
}

客户端程序通过:

1
Socket sock = new Socket("localhost", 6666);

连接到服务器端,注意上述代码的服务器地址是"localhost",表示本机地址,端口号是6666。如果连接成功,将返回一个Socket实例,用于后续通信。

Socket流

当Socket连接创建成功后,无论是服务器端,还是客户端,我们都使用Socket实例进行网络通信。因为TCP是一种基于流的协议,因此,Java标准库使用InputStreamOutputStream来封装Socket的数据流,这样我们使用Socket的流,和普通IO流类似:

1
2
3
4
// 用于读取网络数据:
InputStream in = sock.getInputStream();
// 用于写入网络数据:
OutputStream out = sock.getOutputStream();

最后我们重点来看看,为什么写入网络数据时,要调用flush()方法。

如果不调用flush(),我们很可能会发现,客户端和服务器都收不到数据,这并不是Java标准库的设计问题,而是我们以流的形式写入数据的时候,并不是一写入就立刻发送到网络,而是先写入内存缓冲区,直到缓冲区满了以后,才会一次性真正发送到网络,这样设计的目的是为了提高传输效率。如果缓冲区的数据很少,而我们又想强制把这些数据发送到网络,就必须调用flush()强制把缓冲区数据发送出去。

练习

使用Socket实现服务器和客户端通信。

下载练习

小结

使用Java进行TCP编程时,需要使用Socket模型:

  • 服务器端用ServerSocket监听指定端口;
  • 客户端使用Socket(InetAddress, port)连接服务器;
  • 服务器端用accept()接收连接并返回Socket
  • 双方通过Socket打开InputStream/OutputStream读写数据;
  • 服务器端通常使用多线程同时处理多个客户端连接,利用线程池可大幅提升效率;
  • flush()用于强制输出缓冲区到网络。

和TCP编程相比,UDP编程就简单得多,因为UDP没有创建连接,数据包也是一次收发一个,所以没有流的概念。

在Java中使用UDP编程,仍然需要使用Socket,因为应用程序在使用UDP时必须指定网络接口(IP)和端口号。注意:UDP端口和TCP端口虽然都使用0~65535,但他们是两套独立的端口,即一个应用程序用TCP占用了端口1234,不影响另一个应用程序用UDP占用端口1234。

服务器端

在服务器端,使用UDP也需要监听指定的端口。Java提供了DatagramSocket来实现这个功能,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
DatagramSocket ds = new DatagramSocket(6666); // 监听指定端口
for (;;) { // 无限循环
// 数据缓冲区:
byte[] buffer = new byte[1024];
DatagramPacket packet = new DatagramPacket(buffer, buffer.length);
ds.receive(packet); // 收取一个UDP数据包
// 收取到的数据存储在buffer中,由packet.getOffset(), packet.getLength()指定起始位置和长度
// 将其按UTF-8编码转换为String:
String s = new String(packet.getData(), packet.getOffset(), packet.getLength(), StandardCharsets.UTF_8);
// 发送数据:
byte[] data = "ACK".getBytes(StandardCharsets.UTF_8);
packet.setData(data);
ds.send(packet);
}

服务器端首先使用如下语句在指定的端口监听UDP数据包:

1
DatagramSocket ds = new DatagramSocket(6666);

如果没有其他应用程序占据这个端口,那么监听成功,我们就使用一个无限循环来处理收到的UDP数据包:

1
2
3
for (;;) {
...
}

要接收一个UDP数据包,需要准备一个byte[]缓冲区,并通过DatagramPacket实现接收:

1
2
3
byte[] buffer = new byte[1024];
DatagramPacket packet = new DatagramPacket(buffer, buffer.length);
ds.receive(packet);

假设我们收取到的是一个String,那么,通过DatagramPacket返回的packet.getOffset()packet.getLength()确定数据在缓冲区的起止位置:

1
String s = new String(packet.getData(), packet.getOffset(), packet.getLength(), StandardCharsets.UTF_8);

当服务器收到一个DatagramPacket后,通常必须立刻回复一个或多个UDP包,因为客户端地址在DatagramPacket中,每次收到的DatagramPacket可能是不同的客户端,如果不回复,客户端就收不到任何UDP包。

发送UDP包也是通过DatagramPacket实现的,发送代码非常简单:

1
2
3
byte[] data = ...
packet.setData(data);
ds.send(packet);

客户端

和服务器端相比,客户端使用UDP时,只需要直接向服务器端发送UDP包,然后接收返回的UDP包:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
DatagramSocket ds = new DatagramSocket();
ds.setSoTimeout(1000);
ds.connect(InetAddress.getByName("localhost"), 6666); // 连接指定服务器和端口
// 发送:
byte[] data = "Hello".getBytes();
DatagramPacket packet = new DatagramPacket(data, data.length);
ds.send(packet);
// 接收:
byte[] buffer = new byte[1024];
packet = new DatagramPacket(buffer, buffer.length);
ds.receive(packet);
String resp = new String(packet.getData(), packet.getOffset(), packet.getLength());
ds.disconnect();
// 关闭:
ds.close();

客户端打开一个DatagramSocket使用以下代码:

1
2
3
DatagramSocket ds = new DatagramSocket();
ds.setSoTimeout(1000);
ds.connect(InetAddress.getByName("localhost"), 6666);

客户端创建DatagramSocket实例时并不需要指定端口,而是由操作系统自动指定一个当前未使用的端口。紧接着,调用setSoTimeout(1000)设定超时1秒,意思是后续接收UDP包时,等待时间最多不会超过1秒,否则在没有收到UDP包时,客户端会无限等待下去。这一点和服务器端不一样,服务器端可以无限等待,因为它本来就被设计成长时间运行。

注意到客户端的DatagramSocket还调用了一个connect()方法“连接”到指定的服务器端。不是说UDP是无连接的协议吗?为啥这里需要connect()

这个connect()方法不是真连接,它是为了在客户端的DatagramSocket实例中保存服务器端的IP和端口号,确保这个DatagramSocket实例只能往指定的地址和端口发送UDP包,不能往其他地址和端口发送。这么做不是UDP的限制,而是Java内置了安全检查。

后续的收发数据和服务器端是一致的。通常来说,客户端必须先发UDP包,因为客户端不发UDP包,服务器端就根本不知道客户端的地址和端口号。

如果客户端认为通信结束,就可以调用disconnect()断开连接:

1
ds.disconnect();

注意到disconnect()也不是真正地断开连接,它只是清除了客户端DatagramSocket实例记录的远程服务器地址和端口号,这样,DatagramSocket实例就可以连接另一个服务器端。

如果客户端希望向两个不同的服务器发送UDP包,有两种方法:

  1. 客户端可以创建两个DatagramSocket实例,用connect()连接到不同的服务器;
  2. 客户端也可以不调用connect()方法,而是在创建DatagramPacket的时候指定服务器地址,这样可以用一个DatagramSocket实例发送DatagramPacket到不同的服务器。

不调用connect()方法的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
DatagramSocket ds = new DatagramSocket();
ds.setSoTimeout(1000);
// 发送到localhost:6666:
byte[] data1 = "Hello".getBytes();
var packet1 = new DatagramPacket(data1, data1.length, InetAddress.getByName("localhost"), 6666);
ds.send(packet1);
// 发送到localhost:8888:
byte[] data2 = "Hi".getBytes();
var packet2 = new DatagramPacket(data2, data2.length, InetAddress.getByName("localhost"), 8888);
ds.send(packet2);
// 关闭:
ds.close();

练习

使用UDP实现服务器和客户端通信。

下载练习

小结

使用UDP协议通信时,服务器和客户端双方无需建立连接:

  • 服务器端用DatagramSocket(port)监听端口;
  • 客户端使用DatagramSocket.connect()指定远程地址和端口;
  • 双方通过receive()send()读写数据;
  • DatagramSocket没有IO流接口,数据被直接写入byte[]缓冲区。

Email就是电子邮件。电子邮件的应用已经有几十年的历史了,我们熟悉的邮箱地址比如abc@example.com,邮件软件比如Outlook都是用来收发邮件的。

使用Java程序也可以收发电子邮件。我们先来看一下传统的邮件是如何发送的。

传统的邮件是通过邮局投递,然后从一个邮局到另一个邮局,最终到达用户的邮箱:

1
2
3
4
5
6
           ┌──────────┐    ┌──────────┐
│PostOffice│ │PostOffice│ .───.
┌─────┐ ├──────────┤ ├──────────┤ ( ( )
│═══ ░│───▶│ ┌─┐ ┌┐┌┐ │───▶│ ┌─┐ ┌┐┌┐ │───▶ `─┬─'
└─────┘ │ │░│ └┘└┘ │ │ │░│ └┘└┘ │ │
└─┴─┴──────┘ └─┴─┴──────┘ │

电子邮件的发送过程也是类似的,只不过是电子邮件是从用户电脑的邮件软件,例如Outlook,发送到邮件服务器上,可能经过若干个邮件服务器的中转,最终到达对方邮件服务器上,收件方就可以用软件接收邮件:

1
2
3
4
5
6
7
8
             ┌─────────┐    ┌─────────┐    ┌─────────┐
│░░░░░░░░░│ │░░░░░░░░░│ │░░░░░░░░░│
┌───────┐ ├─────────┤ ├─────────┤ ├─────────┤ ┌───────┐
│░░░░░░░│ │░░░░░░░░░│ │░░░░░░░░░│ │░░░░░░░░░│ │░░░░░░░│
├───────┤ ├─────────┤ ├─────────┤ ├─────────┤ ├───────┤
│ │───▶│O ░░░░░░░│───▶│O ░░░░░░░│───▶│O ░░░░░░░│◀───│ │
└───────┘ └─────────┘ └─────────┘ └─────────┘ └───────┘
MUA MTA MTA MDA MUA

我们把类似Outlook这样的邮件软件称为MUA:Mail User Agent,意思是给用户服务的邮件代理;邮件服务器则称为MTA:Mail Transfer Agent,意思是邮件中转的代理;最终到达的邮件服务器称为MDA:Mail Delivery Agent,意思是邮件到达的代理。电子邮件一旦到达MDA,就不再动了。实际上,电子邮件通常就存储在MDA服务器的硬盘上,然后等收件人通过软件或者登陆浏览器查看邮件。

MTA和MDA这样的服务器软件通常是现成的,我们不关心这些服务器内部是如何运行的。要发送邮件,我们关心的是如何编写一个MUA的软件,把邮件发送到MTA上。

MUA到MTA发送邮件的协议就是SMTP协议,它是Simple Mail Transport Protocol的缩写,使用标准端口25,也可以使用加密端口465587

SMTP协议是一个建立在TCP之上的协议,任何程序发送邮件都必须遵守SMTP协议。使用Java程序发送邮件时,我们无需关心SMTP协议的底层原理,只需要使用JavaMail这个标准API就可以直接发送邮件。

准备SMTP登录信息

假设我们准备使用自己的邮件地址me@example.com给小明发送邮件,已知小明的邮件地址是xiaoming@somewhere.com,发送邮件前,我们首先要确定作为MTA的邮件服务器地址和端口号。邮件服务器地址通常是smtp.example.com,端口号由邮件服务商确定使用25、465还是587。以下是一些常用邮件服务商的SMTP信息:

  • QQ邮箱:SMTP服务器是smtp.qq.com,端口是465/587;
  • 163邮箱:SMTP服务器是smtp.163.com,端口是465;
  • Gmail邮箱:SMTP服务器是smtp.gmail.com,端口是465/587。

有了SMTP服务器的域名和端口号,我们还需要SMTP服务器的登录信息,通常是使用自己的邮件地址作为用户名,登录口令是用户口令或者一个独立设置的SMTP口令。

我们来看看如何使用JavaMail发送邮件。

首先,我们需要创建一个Maven工程,并把JavaMail相关的两个依赖加入进来:

  • jakarta.mail:javax.mail-api:2.0.1
  • com.sun.mail:jakarta.mail:2.0.1

这两个包一个是接口定义,一个是具体实现。如果使用早期的1.x版本,则需注意引入的包名有所不同:

  • javax.mail:javax.mail-api:1.6.2
  • com.sun.mail:javax.mail:1.6.2

并且代码引用的jakarta.mail需替换为javax.mail

然后,我们通过JavaMail API连接到SMTP服务器上:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 服务器地址:
String smtp = "smtp.office365.com";
// 登录用户名:
String username = "jxsmtp101@outlook.com";
// 登录口令:
String password = "********";
// 连接到SMTP服务器587端口:
Properties props = new Properties();
props.put("mail.smtp.host", smtp); // SMTP主机名
props.put("mail.smtp.port", "587"); // 主机端口号
props.put("mail.smtp.auth", "true"); // 是否需要用户认证
props.put("mail.smtp.starttls.enable", "true"); // 启用TLS加密
// 获取Session实例:
Session session = Session.getInstance(props, new Authenticator() {
protected PasswordAuthentication getPasswordAuthentication() {
return new PasswordAuthentication(username, password);
}
});
// 设置debug模式便于调试:
session.setDebug(true);

以587端口为例,连接SMTP服务器时,需要准备一个Properties对象,填入相关信息。最后获取Session实例时,如果服务器需要认证,还需要传入一个Authenticator对象,并返回指定的用户名和口令。

当我们获取到Session实例后,打开调试模式可以看到SMTP通信的详细内容,便于调试。

发送邮件

发送邮件时,我们需要构造一个Message对象,然后调用Transport.send(Message)即可完成发送:

1
2
3
4
5
6
7
8
9
10
11
MimeMessage message = new MimeMessage(session);
// 设置发送方地址:
message.setFrom(new InternetAddress("me@example.com"));
// 设置接收方地址:
message.setRecipient(Message.RecipientType.TO, new InternetAddress("xiaoming@somewhere.com"));
// 设置邮件主题:
message.setSubject("Hello", "UTF-8");
// 设置邮件正文:
message.setText("Hi Xiaoming...", "UTF-8");
// 发送:
Transport.send(message);

绝大多数邮件服务器要求发送方地址和登录用户名必须一致,否则发送将失败。

填入真实的地址,运行上述代码,我们可以在控制台看到JavaMail打印的调试信息:

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
这是JavaMail打印的调试信息:
DEBUG: setDebug: JavaMail version 1.6.2
DEBUG: getProvider() returning javax.mail.Provider[TRANSPORT,smtp,com.sun.mail.smtp.SMTPTransport,Oracle]
DEBUG SMTP: need username and password for authentication
DEBUG SMTP: protocolConnect returning false, host=smtp.office365.com, ...
DEBUG SMTP: useEhlo true, useAuth true
开始尝试连接smtp.office365.com:
DEBUG SMTP: trying to connect to host "smtp.office365.com", port 587, ...
DEBUG SMTP: connected to host "smtp.office365.com", port: 587
发送命令EHLO:
EHLO localhost
SMTP服务器响应250:
250-SG3P274CA0024.outlook.office365.com Hello
250-SIZE 157286400
...
DEBUG SMTP: Found extension "SIZE", arg "157286400"
发送命令STARTTLS:
STARTTLS
SMTP服务器响应220:
220 2.0.0 SMTP server ready
EHLO localhost
250-SG3P274CA0024.outlook.office365.com Hello [111.196.164.63]
250-SIZE 157286400
250-PIPELINING
250-...
DEBUG SMTP: Found extension "SIZE", arg "157286400"
...
尝试登录:
DEBUG SMTP: protocolConnect login, host=smtp.office365.com, user=********, password=********
DEBUG SMTP: Attempt to authenticate using mechanisms: LOGIN PLAIN DIGEST-MD5 NTLM XOAUTH2
DEBUG SMTP: Using mechanism LOGIN
DEBUG SMTP: AUTH LOGIN command trace suppressed
登录成功:
DEBUG SMTP: AUTH LOGIN succeeded
DEBUG SMTP: use8bit false
开发发送邮件,设置FROM:
MAIL FROM:<********@outlook.com>
250 2.1.0 Sender OK
设置TO:
RCPT TO:<********@sina.com>
250 2.1.5 Recipient OK
发送邮件数据:
DATA
服务器响应354:
354 Start mail input; end with <CRLF>.<CRLF>
真正的邮件数据:
Date: Mon, 2 Dec 2019 09:37:52 +0800 (CST)
From: ********@outlook.com
To: ********001@sina.com
Message-ID: <1617791695.0.1575250672483@localhost>
邮件主题是编码后的文本:
Subject: =?UTF-8?Q?JavaMail=E9=82=AE=E4=BB=B6?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: base64

邮件正文是Base64编码的文本:
SGVsbG8sIOi/meaYr+S4gOWwgeadpeiHqmphdmFtYWls55qE6YKu5Lu277yB
.
邮件数据发送完成后,以\r\n.\r\n结束,服务器响应250表示发送成功:
250 2.0.0 OK <HK0PR03MB4961.apcprd03.prod.outlook.com> [Hostname=HK0PR03MB4961.apcprd03.prod.outlook.com]
DEBUG SMTP: message successfully delivered to mail server
发送QUIT命令:
QUIT
服务器响应221结束TCP连接:
221 2.0.0 Service closing transmission channel

从上面的调试信息可以看出,SMTP协议是一个请求-响应协议,客户端总是发送命令,然后等待服务器响应。服务器响应总是以数字开头,后面的信息才是用于调试的文本。这些响应码已经被定义在SMTP协议中了,查看具体的响应码就可以知道出错原因。

如果一切顺利,对方将收到一封文本格式的电子邮件:

mail-text

发送HTML邮件

发送HTML邮件和文本邮件是类似的,只需要把:

1
message.setText(body, "UTF-8");

改为:

1
message.setText(body, "UTF-8", "html");

传入的body是类似<h1>Hello</h1><p>Hi, xxx</p>这样的HTML字符串即可。

HTML邮件可以在邮件客户端直接显示为网页格式:

mail-html

发送附件

要在电子邮件中携带附件,我们就不能直接调用message.setText()方法,而是要构造一个Multipart对象:

1
2
3
4
5
6
7
8
9
10
11
12
Multipart multipart = new MimeMultipart();
// 添加text:
BodyPart textpart = new MimeBodyPart();
textpart.setContent(body, "text/html;charset=utf-8");
multipart.addBodyPart(textpart);
// 添加image:
BodyPart imagepart = new MimeBodyPart();
imagepart.setFileName(fileName);
imagepart.setDataHandler(new DataHandler(new ByteArrayDataSource(input, "application/octet-stream")));
multipart.addBodyPart(imagepart);
// 设置邮件内容为multipart:
message.setContent(multipart);

一个Multipart对象可以添加若干个BodyPart,其中第一个BodyPart是文本,即邮件正文,后面的BodyPart是附件。BodyPart依靠setContent()决定添加的内容,如果添加文本,用setContent("...", "text/plain;charset=utf-8")添加纯文本,或者用setContent("...", "text/html;charset=utf-8")添加HTML文本。如果添加附件,需要设置文件名(不一定和真实文件名一致),并且添加一个DataHandler(),传入文件的MIME类型。二进制文件可以用application/octet-stream,Word文档则是application/msword

最后,通过setContent()Multipart添加到Message中,即可发送。

带附件的邮件在客户端会被提示下载:

mail-attachment

发送内嵌图片的HTML邮件

有些童鞋可能注意到,HTML邮件中可以内嵌图片,这是怎么做到的?

如果给一个<img src="http://example.com/test.jpg">,这样的外部图片链接通常会被邮件客户端过滤,并提示用户显示图片并不安全。只有内嵌的图片才能正常在邮件中显示。

内嵌图片实际上也是一个附件,即邮件本身也是Multipart,但需要做一点额外的处理:

1
2
3
4
5
6
7
8
9
10
11
12
Multipart multipart = new MimeMultipart();
// 添加text:
BodyPart textpart = new MimeBodyPart();
textpart.setContent("<h1>Hello</h1><p><img src=\"cid:img01\"></p>", "text/html;charset=utf-8");
multipart.addBodyPart(textpart);
// 添加image:
BodyPart imagepart = new MimeBodyPart();
imagepart.setFileName(fileName);
imagepart.setDataHandler(new DataHandler(new ByteArrayDataSource(input, "image/jpeg")));
// 与HTML的<img src="cid:img01">关联:
imagepart.setHeader("Content-ID", "<img01>");
multipart.addBodyPart(imagepart);

在HTML邮件中引用图片时,需要设定一个ID,用类似<img src=\"cid:img01\">引用,然后,在添加图片作为BodyPart时,除了要正确设置MIME类型(根据图片类型使用image/jpegimage/png),还需要设置一个Header:

1
imagepart.setHeader("Content-ID", "<img01>");

这个ID和HTML中引用的ID对应起来,邮件客户端就可以正常显示内嵌图片:

mail-image

常见问题

如果用户名或口令错误,会导致535登录失败:

1
2
DEBUG SMTP: AUTH LOGIN failed
Exception in thread "main" javax.mail.AuthenticationFailedException: 535 5.7.3 Authentication unsuccessful [HK0PR03CA0105.apcprd03.prod.outlook.com]

如果登录用户和发件人不一致,会导致554拒绝发送错误:

1
2
DEBUG SMTP: MessagingException while sending, THROW: 
com.sun.mail.smtp.SMTPSendFailedException: 554 5.2.0 STOREDRV.Submission.Exception:SendAsDeniedException.MapiExceptionSendAsDenied;

有些时候,如果邮件主题和正文过于简单,会导致554被识别为垃圾邮件的错误:

1
2
DEBUG SMTP: MessagingException while sending, THROW: 
com.sun.mail.smtp.SMTPSendFailedException: 554 DT:SPM

总之,出错时,需要查看DEBUG信息,找到服务器返回的错误码和描述信息来定位错误原因。

练习

使用SMTP发送邮件。

下载练习

小结

使用JavaMail API发送邮件本质上是一个MUA软件通过SMTP协议发送邮件至MTA服务器;

打开调试模式可以看到详细的SMTP交互信息;

某些邮件服务商需要开启SMTP,并需要独立的SMTP登录密码。

发送Email的过程我们在上一节已经讲过了,客户端总是通过SMTP协议把邮件发送给MTA。

接收Email则相反,因为邮件最终到达收件人的MDA服务器,所以,接收邮件是收件人用自己的客户端把邮件从MDA服务器上抓取到本地的过程。

接收邮件使用最广泛的协议是POP3:Post Office Protocol version 3,它也是一个建立在TCP连接之上的协议。POP3服务器的标准端口是110,如果整个会话需要加密,那么使用加密端口995

另一种接收邮件的协议是IMAP:Internet Mail Access Protocol,它使用标准端口143和加密端口993。IMAP和POP3的主要区别是,IMAP协议在本地的所有操作都会自动同步到服务器上,并且,IMAP可以允许用户在邮件服务器的收件箱中创建文件夹。

JavaMail也提供了IMAP协议的支持。因为POP3和IMAP的使用方式非常类似,这里我们只介绍POP3的用法。

使用POP3收取Email时,我们无需关心POP3协议底层,因为JavaMail提供了高层接口。首先需要连接到Store对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 准备登录信息:
String host = "pop3.example.com";
int port = 995;
String username = "bob@example.com";
String password = "password";

Properties props = new Properties();
props.setProperty("mail.store.protocol", "pop3"); // 协议名称
props.setProperty("mail.pop3.host", host);// POP3主机名
props.setProperty("mail.pop3.port", String.valueOf(port)); // 端口号
// 启动SSL:
props.put("mail.smtp.socketFactory.class", "javax.net.ssl.SSLSocketFactory");
props.put("mail.smtp.socketFactory.port", String.valueOf(port));

// 连接到Store:
URLName url = new URLName("pop3", host, post, "", username, password);
Session session = Session.getInstance(props, null);
session.setDebug(true); // 显示调试信息
Store store = new POP3SSLStore(session, url);
store.connect();

一个Store对象表示整个邮箱的存储,要收取邮件,我们需要通过Store访问指定的Folder(文件夹),通常是INBOX表示收件箱:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 获取收件箱:
Folder folder = store.getFolder("INBOX");
// 以读写方式打开:
folder.open(Folder.READ_WRITE);
// 打印邮件总数/新邮件数量/未读数量/已删除数量:
System.out.println("Total messages: " + folder.getMessageCount());
System.out.println("New messages: " + folder.getNewMessageCount());
System.out.println("Unread messages: " + folder.getUnreadMessageCount());
System.out.println("Deleted messages: " + folder.getDeletedMessageCount());
// 获取每一封邮件:
Message[] messages = folder.getMessages();
for (Message message : messages) {
// 打印每一封邮件:
printMessage((MimeMessage) message);
}

当我们获取到一个Message对象时,可以强制转型为MimeMessage,然后打印出邮件主题、发件人、收件人等信息:

1
2
3
4
5
6
7
8
9
10
11
12
void printMessage(MimeMessage msg) throws IOException, MessagingException {
// 邮件主题:
System.out.println("Subject: " + MimeUtility.decodeText(msg.getSubject()));
// 发件人:
Address[] froms = msg.getFrom();
InternetAddress address = (InternetAddress) froms[0];
String personal = address.getPersonal();
String from = personal == null ? address.getAddress() : (MimeUtility.decodeText(personal) + " <" + address.getAddress() + ">");
System.out.println("From: " + from);
// 继续打印收件人:
...
}

比较麻烦的是获取邮件的正文。一个MimeMessage对象也是一个Part对象,它可能只包含一个文本,也可能是一个Multipart对象,即由几个Part构成,因此,需要递归地解析出完整的正文:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
String getBody(Part part) throws MessagingException, IOException {
if (part.isMimeType("text/*")) {
// Part是文本:
return part.getContent().toString();
}
if (part.isMimeType("multipart/*")) {
// Part是一个Multipart对象:
Multipart multipart = (Multipart) part.getContent();
// 循环解析每个子Part:
for (int i = 0; i < multipart.getCount(); i++) {
BodyPart bodyPart = multipart.getBodyPart(i);
String body = getBody(bodyPart);
if (!body.isEmpty()) {
return body;
}
}
}
return "";
}

最后记得关闭FolderStore

1
2
folder.close(true); // 传入true表示删除操作会同步到服务器上(即删除服务器收件箱的邮件)
store.close();

练习

使用POP3接收邮件。

下载练习

小结

使用Java接收Email时,可以用POP3协议或IMAP协议。

使用POP3协议时,需要用Maven引入JavaMail依赖,并确定POP3服务器的域名/端口/是否使用SSL等,然后,调用相关API接收Email。

设置debug模式可以查看通信详细内容,便于排查错误。

什么是HTTP?HTTP就是目前使用最广泛的Web应用程序使用的基础协议,例如,浏览器访问网站,手机App访问后台服务器,都是通过HTTP协议实现的。

HTTP是HyperText Transfer Protocol的缩写,翻译为超文本传输协议,它是基于TCP协议之上的一种请求-响应协议。

我们来看一下浏览器请求访问某个网站时发送的HTTP请求-响应。当浏览器希望访问某个网站时,浏览器和网站服务器之间首先建立TCP连接,且服务器总是使用80端口和加密端口443,然后,浏览器向服务器发送一个HTTP请求,服务器收到后,返回一个HTTP响应,并且在响应中包含了HTML的网页内容,这样,浏览器解析HTML后就可以给用户显示网页了。一个完整的HTTP请求-响应如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
            GET / HTTP/1.1
Host: www.sina.com.cn
User-Agent: Mozilla/5 MSIE
Accept: */* ┌────────┐
┌─────────┐ Accept-Language: zh-CN,en │░░░░░░░░│
│O ░░░░░░░│───────────────────────────▶├────────┤
├─────────┤◀───────────────────────────│░░░░░░░░│
│ │ HTTP/1.1 200 OK ├────────┤
│ │ Content-Type: text/html │░░░░░░░░│
└─────────┘ Content-Length: 133251 └────────┘
Browser <!DOCTYPE html> Server
<html><body>
<h1>Hello</h1>
...

HTTP请求的格式是固定的,它由HTTP Header和HTTP Body两部分构成。第一行总是请求方法 路径 HTTP版本,例如,GET / HTTP/1.1表示使用GET请求,路径是/,版本是HTTP/1.1

后续的每一行都是固定的Header: Value格式,我们称为HTTP Header,服务器依靠某些特定的Header来识别客户端请求,例如:

  • Host:表示请求的域名,因为一台服务器上可能有多个网站,因此有必要依靠Host来识别请求是发给哪个网站的;
  • User-Agent:表示客户端自身标识信息,不同的浏览器有不同的标识,服务器依靠User-Agent判断客户端类型是IE还是Chrome,是Firefox还是一个Python爬虫;
  • Accept:表示客户端能处理的HTTP响应格式,*/*表示任意格式,text/*表示任意文本,image/png表示PNG格式的图片;
  • Accept-Language:表示客户端接收的语言,多种语言按优先级排序,服务器依靠该字段给用户返回特定语言的网页版本。

如果是GET请求,那么该HTTP请求只有HTTP Header,没有HTTP Body。如果是POST请求,那么该HTTP请求带有Body,以一个空行分隔。一个典型的带Body的HTTP请求如下:

1
2
3
4
5
6
POST /login HTTP/1.1
Host: www.example.com
Content-Type: application/x-www-form-urlencoded
Content-Length: 30

username=hello&password=123456

POST请求通常要设置Content-Type表示Body的类型,Content-Length表示Body的长度,这样服务器就可以根据请求的Header和Body做出正确的响应。

此外,GET请求的参数必须附加在URL上,并以URLEncode方式编码,例如:http://www.example.com/?a=1&b=K%26R,参数分别是a=1b=K&R。因为URL的长度限制,GET请求的参数不能太多,而POST请求的参数就没有长度限制,因为POST请求的参数必须放到Body中。并且,POST请求的参数不一定是URL编码,可以按任意格式编码,只需要在Content-Type中正确设置即可。常见的发送JSON的POST请求如下:

1
2
3
4
5
POST /login HTTP/1.1
Content-Type: application/json
Content-Length: 38

{"username":"bob","password":"123456"}

HTTP响应也是由Header和Body两部分组成,一个典型的HTTP响应如下:

1
2
3
4
5
6
7
8
HTTP/1.1 200 OK
Content-Type: text/html
Content-Length: 133251

<!DOCTYPE html>
<html><body>
<h1>Hello</h1>
</body></html>

响应的第一行总是HTTP版本 响应代码 响应说明,例如,HTTP/1.1 200 OK表示版本是HTTP/1.1,响应代码是200,响应说明是OK。客户端只依赖响应代码判断HTTP响应是否成功。HTTP有固定的响应代码:

  • 1xx:表示一个提示性响应,例如101表示将切换协议,常见于WebSocket连接;
  • 2xx:表示一个成功的响应,例如200表示成功,206表示只发送了部分内容;
  • 3xx:表示一个重定向的响应,例如301表示永久重定向,303表示客户端应该按指定路径重新发送请求;
  • 4xx:表示一个因为客户端问题导致的错误响应,例如400表示因为Content-Type等各种原因导致的无效请求,404表示指定的路径不存在;
  • 5xx:表示一个因为服务器问题导致的错误响应,例如500表示服务器内部故障,503表示服务器暂时无法响应。

当浏览器收到第一个HTTP响应后,它解析HTML后,又会发送一系列HTTP请求,例如,GET /logo.jpg HTTP/1.1请求一个图片,服务器响应图片请求后,会直接把二进制内容的图片发送给浏览器:

1
2
3
4
5
HTTP/1.1 200 OK
Content-Type: image/jpeg
Content-Length: 18391

????JFIFHH??XExifMM?i&??X?...(二进制的JPEG图片)

因此,服务器总是被动地接收客户端的一个HTTP请求,然后响应它。客户端则根据需要发送若干个HTTP请求。

对于最早期的HTTP/1.0协议,每次发送一个HTTP请求,客户端都需要先创建一个新的TCP连接,然后,收到服务器响应后,关闭这个TCP连接。由于建立TCP连接就比较耗时,因此,为了提高效率,HTTP/1.1协议允许在一个TCP连接中反复发送-响应,这样就能大大提高效率:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
                       ┌─────────┐
┌─────────┐ │░░░░░░░░░│
│O ░░░░░░░│ ├─────────┤
├─────────┤ │░░░░░░░░░│
│ │ ├─────────┤
│ │ │░░░░░░░░░│
└─────────┘ └─────────┘
│ request 1 │
│─────────────────────▶│
│ response 1 │
│◀─────────────────────│
│ request 2 │
│─────────────────────▶│
│ response 2 │
│◀─────────────────────│
│ request 3 │
│─────────────────────▶│
│ response 3 │
│◀─────────────────────│
▼ ▼

因为HTTP协议是一个请求-响应协议,客户端在发送了一个HTTP请求后,必须等待服务器响应后,才能发送下一个请求,这样一来,如果某个响应太慢,它就会堵住后面的请求。

所以,为了进一步提速,HTTP/2.0允许客户端在没有收到响应的时候,发送多个HTTP请求,服务器返回响应的时候,不一定按顺序返回,只要双方能识别出哪个响应对应哪个请求,就可以做到并行发送和接收:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
                       ┌─────────┐
┌─────────┐ │░░░░░░░░░│
│O ░░░░░░░│ ├─────────┤
├─────────┤ │░░░░░░░░░│
│ │ ├─────────┤
│ │ │░░░░░░░░░│
└─────────┘ └─────────┘
│ request 1 │
│─────────────────────▶│
│ request 2 │
│─────────────────────▶│
│ response 1 │
│◀─────────────────────│
│ request 3 │
│─────────────────────▶│
│ response 3 │
│◀─────────────────────│
│ response 2 │
│◀─────────────────────│
▼ ▼

可见,HTTP/2.0进一步提高了效率。

HTTP编程

既然HTTP涉及到客户端和服务器端,和TCP类似,我们也需要针对客户端编程和针对服务器端编程。

本节我们不讨论服务器端的HTTP编程,因为服务器端的HTTP编程本质上就是编写Web服务器,这是一个非常复杂的体系,也是JavaEE开发的核心内容,我们在后面的章节再仔细研究。

本节我们只讨论作为客户端的HTTP编程。

因为浏览器也是一种HTTP客户端,所以,客户端的HTTP编程,它的行为本质上和浏览器是一样的,即发送一个HTTP请求,接收服务器响应后,获得响应内容。只不过浏览器进一步把响应内容解析后渲染并展示给了用户,而我们使用Java进行HTTP客户端编程仅限于获得响应内容。

我们来看一下Java如何使用HTTP客户端编程。

Java标准库提供了基于HTTP的包,但是要注意,早期的JDK版本是通过HttpURLConnection访问HTTP,典型代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
URL url = new URL("http://www.example.com/path/to/target?a=1&b=2");
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("GET");
conn.setUseCaches(false);
conn.setConnectTimeout(5000); // 请求超时5秒
// 设置HTTP头:
conn.setRequestProperty("Accept", "*/*");
conn.setRequestProperty("User-Agent", "Mozilla/5.0 (compatible; MSIE 11; Windows NT 5.1)");
// 连接并发送HTTP请求:
conn.connect();
// 判断HTTP响应是否200:
if (conn.getResponseCode() != 200) {
throw new RuntimeException("bad response");
}
// 获取所有响应Header:
Map<String, List<String>> map = conn.getHeaderFields();
for (String key : map.keySet()) {
System.out.println(key + ": " + map.get(key));
}
// 获取响应内容:
InputStream input = conn.getInputStream();
...

上述代码编写比较繁琐,并且需要手动处理InputStream,所以用起来很麻烦。

从Java 11开始,引入了新的HttpClient,它使用链式调用的API,能大大简化HTTP的处理。

我们来看一下如何使用新版的HttpClient。首先需要创建一个全局HttpClient实例,因为HttpClient内部使用线程池优化多个HTTP连接,可以复用:

1
static HttpClient httpClient = HttpClient.newBuilder().build();

使用GET请求获取文本内容代码如下:

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
import java.net.URI;
import java.net.http.*;
import java.net.http.HttpClient.Version;
import java.time.Duration;
import java.util.*;

public class Main {
// 全局HttpClient:
static HttpClient httpClient = HttpClient.newBuilder().build();

public static void main(String[] args) throws Exception {
String url = "https://www.sina.com.cn/";
HttpRequest request = HttpRequest.newBuilder(new URI(url))
// 设置Header:
.header("User-Agent", "Java HttpClient").header("Accept", "*/*")
// 设置超时:
.timeout(Duration.ofSeconds(5))
// 设置版本:
.version(Version.HTTP_2).build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
// HTTP允许重复的Header,因此一个Header可对应多个Value:
Map<String, List<String>> headers = response.headers().map();
for (String header : headers.keySet()) {
System.out.println(header + ": " + headers.get(header).get(0));
}
System.out.println(response.body().substring(0, 1024) + "...");
}
}

如果我们要获取图片这样的二进制内容,只需要把HttpResponse.BodyHandlers.ofString()换成HttpResponse.BodyHandlers.ofByteArray(),就可以获得一个HttpResponse<byte[]>对象。如果响应的内容很大,不希望一次性全部加载到内存,可以使用HttpResponse.BodyHandlers.ofInputStream()获取一个InputStream流。

要使用POST请求,我们要准备好发送的Body数据并正确设置Content-Type

1
2
3
4
5
6
7
8
9
10
11
12
13
14
String url = "http://www.example.com/login";
String body = "username=bob&password=123456";
HttpRequest request = HttpRequest.newBuilder(new URI(url))
// 设置Header:
.header("Accept", "*/*")
.header("Content-Type", "application/x-www-form-urlencoded")
// 设置超时:
.timeout(Duration.ofSeconds(5))
// 设置版本:
.version(Version.HTTP_2)
// 使用POST并设置Body:
.POST(BodyPublishers.ofString(body, StandardCharsets.UTF_8)).build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
String s = response.body();

可见发送POST数据也十分简单。

练习

使用HttpClient。

下载练习

小结

Java提供了HttpClient作为新的HTTP客户端编程接口用于取代老的HttpURLConnection接口;

HttpClient使用链式调用并通过内置的BodyPublishersBodyHandlers来更方便地处理数据。

Java的RMI远程调用是指,一个JVM中的代码可以通过网络实现远程调用另一个JVM的某个方法。RMI是Remote Method Invocation的缩写。

提供服务的一方我们称之为服务器,而实现远程调用的一方我们称之为客户端。

我们先来实现一个最简单的RMI:服务器会提供一个WorldClock服务,允许客户端获取指定时区的时间,即允许客户端调用下面的方法:

1
LocalDateTime getLocalDateTime(String zoneId);

要实现RMI,服务器和客户端必须共享同一个接口。我们定义一个WorldClock接口,代码如下:

1
2
3
public interface WorldClock extends Remote {
LocalDateTime getLocalDateTime(String zoneId) throws RemoteException;
}

Java的RMI规定此接口必须派生自java.rmi.Remote,并在每个方法声明抛出RemoteException

下一步是编写服务器的实现类,因为客户端请求的调用方法getLocalDateTime()最终会通过这个实现类返回结果。实现类WorldClockService代码如下:

1
2
3
4
5
6
public class WorldClockService implements WorldClock {
@Override
public LocalDateTime getLocalDateTime(String zoneId) throws RemoteException {
return LocalDateTime.now(ZoneId.of(zoneId)).withNano(0);
}
}

现在,服务器端的服务相关代码就编写完毕。我们需要通过Java RMI提供的一系列底层支持接口,把上面编写的服务以RMI的形式暴露在网络上,客户端才能调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Server {
public static void main(String[] args) throws RemoteException {
System.out.println("create World clock remote service...");
// 实例化一个WorldClock:
WorldClock worldClock = new WorldClockService();
// 将此服务转换为远程服务接口:
WorldClock skeleton = (WorldClock) UnicastRemoteObject.exportObject(worldClock, 0);
// 将RMI服务注册到1099端口:
Registry registry = LocateRegistry.createRegistry(1099);
// 注册此服务,服务名为"WorldClock":
registry.rebind("WorldClock", skeleton);
}
}

上述代码主要目的是通过RMI提供的相关类,将我们自己的WorldClock实例注册到RMI服务上。RMI的默认端口是1099,最后一步注册服务时通过rebind()指定服务名称为"WorldClock"

下一步我们就可以编写客户端代码。RMI要求服务器和客户端共享同一个接口,因此我们要把WorldClock.java这个接口文件复制到客户端,然后在客户端实现RMI调用:

1
2
3
4
5
6
7
8
9
10
11
12
public class Client {
public static void main(String[] args) throws RemoteException, NotBoundException {
// 连接到服务器localhost,端口1099:
Registry registry = LocateRegistry.getRegistry("localhost", 1099);
// 查找名称为"WorldClock"的服务并强制转型为WorldClock接口:
WorldClock worldClock = (WorldClock) registry.lookup("WorldClock");
// 正常调用接口方法:
LocalDateTime now = worldClock.getLocalDateTime("Asia/Shanghai");
// 打印调用结果:
System.out.println(now);
}
}

先运行服务器,再运行客户端。从运行结果可知,因为客户端只有接口,并没有实现类,因此,客户端获得的接口方法返回值实际上是通过网络从服务器端获取的。整个过程实际上非常简单,对客户端来说,客户端持有的WorldClock接口实际上对应了一个“实现类”,它是由Registry内部动态生成的,并负责把方法调用通过网络传递到服务器端。而服务器端接收网络调用的服务并不是我们自己编写的WorldClockService,而是Registry自动生成的代码。我们把客户端的“实现类”称为stub,而服务器端的网络服务类称为skeleton,它会真正调用服务器端的WorldClockService,获取结果,然后把结果通过网络传递给客户端。整个过程由RMI底层负责实现序列化和反序列化:

1
2
3
4
5
6
7
8
9
10
11
┌ ─ ─ ─ ─ ─ ─ ─ ─ ┐         ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐
┌─────────────┐ ┌────────────┐
│ │ Service │ │ │ │ Service │ │
└─────────────┘ └────────────┘
│ ▲ │ │ ▲ │
│ │
│ │ │ │ │ │
┌─────────────┐ Network ┌───────────────┐ ┌────────────┐
│ │ Client Stub ├─┼─────────┼─▶│Server Skeleton│──▶│Service Impl│ │
└─────────────┘ └───────────────┘ └────────────┘
└ ─ ─ ─ ─ ─ ─ ─ ─ ┘ └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘

Java的RMI严重依赖序列化和反序列化,而这种情况下可能会造成严重的安全漏洞,因为Java的序列化和反序列化不但涉及到数据,还涉及到二进制的字节码,即使使用白名单机制也很难保证100%排除恶意构造的字节码。因此,使用RMI时,双方必须是内网互相信任的机器,不要把1099端口暴露在公网上作为对外服务。

此外,Java的RMI调用机制决定了双方必须是Java程序,其他语言很难调用Java的RMI。如果要使用不同语言进行RPC调用,可以选择更通用的协议,例如gRPC

练习

使用RMI实现远程调用。

下载练习

小结

Java提供了RMI实现远程方法调用:

RMI通过自动生成stub和skeleton实现网络调用,客户端只需要查找服务并获得接口实例,服务器端只需要编写实现类并注册为服务;

RMI的序列化和反序列化可能会造成安全漏洞,因此调用双方必须是内网互相信任的机器,不要把1099端口暴露在公网上作为对外服务。

留言與分享

JAVA-Maven基础

分類 编程语言, Java

在了解Maven之前,我们先来看看一个Java项目需要的东西。首先,我们需要确定引入哪些依赖包。例如,如果我们需要用到commons logging,我们就必须把commons logging的jar包放入classpath。如果我们还需要log4j,就需要把log4j相关的jar包都放到classpath中。这些就是依赖包的管理。

其次,我们要确定项目的目录结构。例如,src目录存放Java源码,resources目录存放配置文件,bin目录存放编译生成的.class文件。

此外,我们还需要配置环境,例如JDK的版本,编译打包的流程,当前代码的版本号。

最后,除了使用Eclipse这样的IDE进行编译外,我们还必须能通过命令行工具进行编译,才能够让项目在一个独立的服务器上编译、测试、部署。

这些工作难度不大,但是非常琐碎且耗时。如果每一个项目都自己搞一套配置,肯定会一团糟。我们需要的是一个标准化的Java项目管理和构建工具。

Maven就是是专门为Java项目打造的管理和构建工具,它的主要功能有:

  • 提供了一套标准化的项目结构;
  • 提供了一套标准化的构建流程(编译,测试,打包,发布……);
  • 提供了一套依赖管理机制。

Maven项目结构

一个使用Maven管理的普通的Java项目,它的目录结构默认如下:

1
2
3
4
5
6
7
8
9
10
a-maven-project
├── pom.xml
├── src
│   ├── main
│   │   ├── java
│   │   └── resources
│   └── test
│   ├── java
│   └── resources
└── target

项目的根目录a-maven-project是项目名,它有一个项目描述文件pom.xml,存放Java源码的目录是src/main/java,存放资源文件的目录是src/main/resources,存放测试源码的目录是src/test/java,存放测试资源的目录是src/test/resources,最后,所有编译、打包生成的文件都放在target目录里。这些就是一个Maven项目的标准目录结构。

所有的目录结构都是约定好的标准结构,我们千万不要随意修改目录结构。使用标准结构不需要做任何配置,Maven就可以正常使用。

我们再来看最关键的一个项目描述文件pom.xml,它的内容长得像下面:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<project ...>
<modelVersion>4.0.0</modelVersion>
<groupId>com.itranswarp.learnjava</groupId>
<artifactId>hello</artifactId>
<version>1.0</version>
<packaging>jar</packaging>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven.compiler.release>17</maven.compiler.release>
</properties>
<dependencies>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-simple</artifactId>
<version>2.0.16</version>
</dependency>
</dependencies>
</project>

其中,groupId类似于Java的包名,通常是公司或组织名称,artifactId类似于Java的类名,通常是项目名称,再加上version,一个Maven工程就是由groupIdartifactIdversion作为唯一标识。

我们在引用其他第三方库的时候,也是通过这3个变量确定。例如,依赖org.slfj4:slf4j-simple:2.0.16

1
2
3
4
5
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-simple</artifactId>
<version>2.0.16</version>
</dependency>

使用<dependency>声明一个依赖后,Maven就会自动下载这个依赖包并把它放到classpath中。

另外,注意到<properties>定义了一些属性,常用的属性有:

  • project.build.sourceEncoding:表示项目源码的字符编码,通常应设定为UTF-8
  • maven.compiler.release:表示使用的JDK版本,例如21
  • maven.compiler.source:表示Java编译器读取的源码版本;
  • maven.compiler.target:表示Java编译器编译的Class版本。

从Java 9开始,推荐使用maven.compiler.release属性,保证编译时输入的源码和编译输出版本一致。如果源码和输出版本不同,则应该分别设置maven.compiler.sourcemaven.compiler.target

通过<properties>定义的属性,就可以固定JDK版本,防止同一个项目的不同的开发者各自使用不同版本的JDK。

安装Maven

要安装Maven,可以从Maven官网下载最新的Maven 3.9.x,然后在本地解压,设置几个环境变量:

1
2
M2_HOME=/path/to/maven-3.9.x
PATH=$PATH:$M2_HOME/bin

Windows可以把%M2_HOME%\bin添加到系统Path变量中。

然后,打开命令行窗口,输入mvn -version,应该看到Maven的版本信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
┌─────────────────────────────────────────────────────────┐
│Windows PowerShell - □ x │
├─────────────────────────────────────────────────────────┤
│Windows PowerShell │
│Copyright (C) Microsoft Corporation. All rights reserved.│
│ │
│PS C:\Users\liaoxuefeng> mvn -version │
│Apache Maven 3.9.x ... │
│Maven home: C:\Users\liaoxuefeng\maven │
│Java version: ... │
│... │
│ │
└─────────────────────────────────────────────────────────┘

如果提示命令未找到,说明系统PATH路径有误,需要修复后再运行。

小结

Maven是一个Java项目的管理和构建工具:

  • Maven使用pom.xml定义项目内容,并使用预设的目录结构;
  • 在Maven中声明一个依赖项可以自动下载并导入classpath;
  • Maven使用groupIdartifactIdversion唯一定位一个依赖。

如果我们的项目依赖第三方的jar包,例如commons logging,那么问题来了:commons logging发布的jar包在哪下载?

如果我们还希望依赖log4j,那么使用log4j需要哪些jar包?

类似的依赖还包括:JUnit,JavaMail,MySQL驱动等等,一个可行的方法是通过搜索引擎搜索到项目的官网,然后手动下载zip包,解压,放入classpath。但是,这个过程非常繁琐。

Maven解决了依赖管理问题。例如,我们的项目依赖abc这个jar包,而abc又依赖xyz这个jar包:

1
2
3
4
5
6
7
8
9
10
11
12
13
┌──────────────┐
│Sample Project│
└──────────────┘


┌──────────────┐
│ abc │
└──────────────┘


┌──────────────┐
│ xyz │
└──────────────┘

当我们声明了abc的依赖时,Maven自动把abcxyz都加入了我们的项目依赖,不需要我们自己去研究abc是否需要依赖xyz

因此,Maven的第一个作用就是解决依赖管理。我们声明了自己的项目需要abc,Maven会自动导入abc的jar包,再判断出abc需要xyz,又会自动导入xyz的jar包,这样,最终我们的项目会依赖abcxyz两个jar包。

我们来看一个复杂依赖示例:

1
2
3
4
5
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>1.4.2.RELEASE</version>
</dependency>

当我们声明一个spring-boot-starter-web依赖时,Maven会自动解析并判断最终需要大概二三十个其他依赖:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
spring-boot-starter-web
spring-boot-starter
spring-boot
sprint-boot-autoconfigure
spring-boot-starter-logging
logback-classic
logback-core
slf4j-api
jcl-over-slf4j
slf4j-api
jul-to-slf4j
slf4j-api
log4j-over-slf4j
slf4j-api
spring-core
snakeyaml
spring-boot-starter-tomcat
tomcat-embed-core
tomcat-embed-el
tomcat-embed-websocket
tomcat-embed-core
jackson-databind
...

如果我们自己去手动管理这些依赖是非常费时费力的,而且出错的概率很大。

依赖关系

Maven定义了几种依赖关系,分别是compiletestruntimeprovided

scope 说明 示例
compile 编译时需要用到该jar包(默认) commons-logging
test 编译Test时需要用到该jar包 junit
runtime 编译时不需要,但运行时需要用到 mysql
provided 编译时需要用到,但运行时由JDK或某个服务器提供 servlet-api

其中,默认的compile是最常用的,Maven会把这种类型的依赖直接放入classpath。

test依赖表示仅在测试时使用,正常运行时并不需要。最常用的test依赖就是JUnit:

1
2
3
4
5
6
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
<version>5.3.2</version>
<scope>test</scope>
</dependency>

runtime依赖表示编译时不需要,但运行时需要。最典型的runtime依赖是JDBC驱动,例如MySQL驱动:

1
2
3
4
5
6
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>5.1.48</version>
<scope>runtime</scope>
</dependency>

provided依赖表示编译时需要,但运行时不需要。最典型的provided依赖是Servlet API,编译的时候需要,但是运行时,Servlet服务器内置了相关的jar,所以运行期不需要:

1
2
3
4
5
6
<dependency>
<groupId>jakarta.servlet</groupId>
<artifactId>jakarta.servlet-api</artifactId>
<version>4.0.0</version>
<scope>provided</scope>
</dependency>

最后一个问题是,Maven如何知道从何处下载所需的依赖?也就是相关的jar包?答案是Maven维护了一个中央仓库(repo1.maven.org),所有第三方库将自身的jar以及相关信息上传至中央仓库,Maven就可以从中央仓库把所需依赖下载到本地。

Maven并不会每次都从中央仓库下载jar包。一个jar包一旦被下载过,就会被Maven自动缓存在本地目录(用户主目录的.m2目录),所以,除了第一次编译时因为下载需要时间会比较慢,后续过程因为有本地缓存,并不会重复下载相同的jar包。

唯一ID

对于某个依赖,Maven只需要3个变量即可唯一确定某个jar包:

  • groupId:属于组织的名称,类似Java的包名;
  • artifactId:该jar包自身的名称,类似Java的类名;
  • version:该jar包的版本。

通过上述3个变量,即可唯一确定某个jar包。Maven通过对jar包进行PGP签名确保任何一个jar包一经发布就无法修改。修改已发布jar包的唯一方法是发布一个新版本。

因此,某个jar包一旦被Maven下载过,即可永久地安全缓存在本地。

注:只有以-SNAPSHOT结尾的版本号会被Maven视为开发版本,开发版本每次都会重复下载,这种SNAPSHOT版本只能用于内部私有的Maven repo,公开发布的版本不允许出现SNAPSHOT。

提示

后续我们在表示Maven依赖时,使用简写形式groupId:artifactId:version,例如:org.slf4j:slf4j-api:2.0.4。

Maven镜像

除了可以从Maven的中央仓库下载外,还可以从Maven的镜像仓库下载。如果访问Maven的中央仓库非常慢,我们可以选择一个速度较快的Maven的镜像仓库。Maven镜像仓库定期从中央仓库同步:

1
2
3
4
5
6
7
8
9
           slow    ┌───────────────────┐
┌─────────────▶│Maven Central Repo.│
│ └───────────────────┘
│ │
│ │sync
│ ▼
┌───────┐ fast ┌───────────────────┐
│ User │─────────▶│Maven Mirror Repo. │
└───────┘ └───────────────────┘

中国区用户可以使用阿里云提供的Maven镜像仓库。使用Maven镜像仓库需要一个配置,在用户主目录下进入.m2目录,创建一个settings.xml配置文件,内容如下:

1
2
3
4
5
6
7
8
9
10
11
<settings>
<mirrors>
<mirror>
<id>aliyun</id>
<name>aliyun</name>
<mirrorOf>central</mirrorOf>
<!-- 国内推荐阿里云的Maven镜像 -->
<url>https://maven.aliyun.com/repository/central</url>
</mirror>
</mirrors>
</settings>

配置镜像仓库后,Maven的下载速度就会非常快。

搜索第三方组件

最后一个问题:如果我们要引用一个第三方组件,比如okhttp,如何确切地获得它的groupIdartifactIdversion?方法是通过search.maven.org搜索关键字,找到对应的组件后,直接复制:

maven-info

命令行编译

在命令中,进入到pom.xml所在目录,输入以下命令:

1
$ mvn clean package

如果一切顺利,即可在target目录下获得编译后自动打包的jar。

在IDE中使用Maven

几乎所有的IDE都内置了对Maven的支持。在Eclipse中,可以直接创建或导入Maven项目。如果导入后的Maven项目有错误,可以尝试选择项目后点击右键,选择Maven - Update Project…更新:

update-maven-project

练习

使用Maven编译hello项目。

下载练习

小结

Maven通过解析依赖关系确定项目所需的jar包,常用的4种scope有:compile(默认),testruntimeprovided

Maven从中央仓库下载所需的jar包并缓存在本地;

可以通过镜像仓库加速下载。

构建流程

Maven不但有标准化的项目结构,而且还有一套标准化的构建流程,可以自动化实现编译,打包,发布,等等。

Lifecycle和Phase

使用Maven时,我们首先要了解什么是Maven的生命周期(lifecycle)。

Maven的生命周期由一系列阶段(phase)构成,以内置的生命周期default为例,它包含以下phase:

  • validate
  • initialize
  • generate-sources
  • process-sources
  • generate-resources
  • process-resources
  • compile
  • process-classes
  • generate-test-sources
  • process-test-sources
  • generate-test-resources
  • process-test-resources
  • test-compile
  • process-test-classes
  • test
  • prepare-package
  • package
  • pre-integration-test
  • integration-test
  • post-integration-test
  • verify
  • install
  • deploy

如果我们运行mvn package,Maven就会执行default生命周期,它会从开始一直运行到package这个phase为止:

  • validate
  • initialize
  • prepare-package
  • package

如果我们运行mvn compile,Maven也会执行default生命周期,但这次它只会运行到compile,即以下几个phase:

  • validate
  • initialize
  • process-resources
  • compile

Maven另一个常用的生命周期是clean,它会执行3个phase:

  • pre-clean
  • clean (注意这个clean不是lifecycle而是phase)
  • post-clean

所以,我们使用mvn这个命令时,后面的参数是phase,Maven自动根据生命周期运行到指定的phase。

更复杂的例子是指定多个phase,例如,运行mvn clean package,Maven先执行clean生命周期并运行到clean这个phase,然后执行default生命周期并运行到package这个phase,实际执行的phase如下:

  • pre-clean
  • clean (注意这个clean是phase)
  • validate (开始执行default生命周期的第一个phase)
  • initialize
  • prepare-package
  • package

在实际开发过程中,经常使用的命令有:

mvn clean:清理所有生成的class和jar;

mvn clean compile:先清理,再执行到compile

mvn clean test:先清理,再执行到test,因为执行test前必须执行compile,所以这里不必指定compile

mvn clean package:先清理,再执行到package

大多数phase在执行过程中,因为我们通常没有在pom.xml中配置相关的设置,所以这些phase什么事情都不做。

经常用到的phase其实只有几个:

  • clean:清理
  • compile:编译
  • test:运行测试
  • package:打包

Goal

执行一个phase又会触发一个或多个goal:

执行的Phase 对应执行的Goal
compile compiler:compile
test compiler:testCompile
surefire:test

goal的命名总是abc:xyz这种形式。

看到这里,相信大家对lifecycle、phase和goal已经明白了吧?

meng

其实我们类比一下就明白了:

  • lifecycle相当于Java的package,它包含一个或多个phase;
  • phase相当于Java的class,它包含一个或多个goal;
  • goal相当于class的method,它其实才是真正干活的。

大多数情况,我们只要指定phase,就默认执行这些phase默认绑定的goal,只有少数情况,我们可以直接指定运行一个goal,例如,启动Tomcat服务器:

1
$ mvn tomcat:run

小结

Maven通过lifecycle、phase和goal来提供标准的构建流程。

最常用的构建命令是指定phase,然后让Maven执行到指定的phase:

  • mvn clean
  • mvn clean compile
  • mvn clean test
  • mvn clean package

通常情况,我们总是执行phase默认绑定的goal,因此不必指定goal。

使用插件

我们在前面介绍了Maven的lifecycle,phase和goal:使用Maven构建项目就是执行lifecycle,执行到指定的phase为止。每个phase会执行自己默认的一个或多个goal。goal是最小任务单元。

我们以compile这个phase为例,如果执行:

1
$ mvn compile

Maven将执行compile这个phase,这个phase会调用compiler插件执行关联的compiler:compile这个goal。

实际上,执行每个phase,都是通过某个插件(plugin)来执行的,Maven本身其实并不知道如何执行compile,它只是负责找到对应的compiler插件,然后执行默认的compiler:compile这个goal来完成编译。

所以,使用Maven,实际上就是配置好需要使用的插件,然后通过phase调用它们。

Maven已经内置了一些常用的标准插件:

插件名称 对应执行的phase
clean clean
compiler compile
surefire test
jar package

如果标准插件无法满足需求,我们还可以使用自定义插件。使用自定义插件的时候,需要声明。例如,使用maven-shade-plugin可以创建一个可执行的jar,要使用这个插件,需要在pom.xml中声明它:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<project>
...
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.2.1</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
...插件配置...
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>

自定义插件往往需要一些配置,例如,maven-shade-plugin需要指定Java程序的入口,它的配置是:

1
2
3
4
5
6
7
<configuration>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>com.itranswarp.learnjava.Main</mainClass>
</transformer>
</transformers>
</configuration>

注意,Maven自带的标准插件例如compiler是无需声明的,只有引入其它的插件才需要声明。

下面列举了一些常用的插件:

  • maven-shade-plugin:打包所有依赖包并生成可执行jar;
  • cobertura-maven-plugin:生成单元测试覆盖率报告;
  • findbugs-maven-plugin:对Java源码进行静态分析以找出潜在问题。

练习

使用maven-shade-plugin创建可执行jar。

下载练习

小结

Maven通过自定义插件可以执行项目构建时需要的额外功能,使用自定义插件必须在pom.xml中声明插件及配置;

插件会在某个phase被执行时执行;

插件的配置和用法需参考插件的官方文档。



在软件开发中,把一个大项目分拆为多个模块是降低软件复杂度的有效方法:

1
2
3
4
5
6
7
8
9
10
11
                        ┌ ─ ─ ─ ─ ─ ─ ┐
┌─────────┐
│ │Module A │ │
└─────────┘
┌──────────────┐ split │ ┌─────────┐ │
│Single Project│───────▶ │Module B │
└──────────────┘ │ └─────────┘ │
┌─────────┐
│ │Module C │ │
└─────────┘
└ ─ ─ ─ ─ ─ ─ ┘

对于Maven工程来说,原来是一个大项目:

1
2
3
single-project
├── pom.xml
└── src

现在可以分拆成3个模块:

1
2
3
4
5
6
7
8
9
10
multiple-projects
├── module-a
│   ├── pom.xml
│   └── src
├── module-b
│   ├── pom.xml
│   └── src
└── module-c
├── pom.xml
└── src

Maven可以有效地管理多个模块,我们只需要把每个模块当作一个独立的Maven项目,它们有各自独立的pom.xml。例如,模块A的pom.xml

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
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<groupId>com.itranswarp.learnjava</groupId>
<artifactId>module-a</artifactId>
<version>1.0</version>
<packaging>jar</packaging>

<name>module-a</name>

<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>
<java.version>11</java.version>
</properties>

<dependencies>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>1.7.28</version>
</dependency>
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<version>1.2.3</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<version>5.5.2</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>

模块B的pom.xml

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
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<groupId>com.itranswarp.learnjava</groupId>
<artifactId>module-b</artifactId>
<version>1.0</version>
<packaging>jar</packaging>

<name>module-b</name>

<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>
<java.version>11</java.version>
</properties>

<dependencies>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>1.7.28</version>
</dependency>
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<version>1.2.3</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<version>5.5.2</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>

可以看出来,模块A和模块B的pom.xml高度相似,因此,我们可以提取出共同部分作为parent

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
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<groupId>com.itranswarp.learnjava</groupId>
<artifactId>parent</artifactId>
<version>1.0</version>
<packaging>pom</packaging>

<name>parent</name>

<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>
<java.version>11</java.version>
</properties>

<dependencies>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>1.7.28</version>
</dependency>
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<version>1.2.3</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<version>5.5.2</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>

注意到parent的<packaging>pom而不是jar,因为parent本身不含任何Java代码。编写parentpom.xml只是为了在各个模块中减少重复的配置。现在我们的整个工程结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
multiple-project
├── pom.xml
├── parent
│   └── pom.xml
├── module-a
│   ├── pom.xml
│   └── src
├── module-b
│   ├── pom.xml
│   └── src
└── module-c
├── pom.xml
└── src

这样模块A就可以简化为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<parent>
<groupId>com.itranswarp.learnjava</groupId>
<artifactId>parent</artifactId>
<version>1.0</version>
<relativePath>../parent/pom.xml</relativePath>
</parent>

<artifactId>module-a</artifactId>
<packaging>jar</packaging>
<name>module-a</name>
</project>

模块B、模块C都可以直接从parent继承,大幅简化了pom.xml的编写。

如果模块A依赖模块B,则模块A需要模块B的jar包才能正常编译,我们需要在模块A中引入模块B:

1
2
3
4
5
6
7
8
...
<dependencies>
<dependency>
<groupId>com.itranswarp.learnjava</groupId>
<artifactId>module-b</artifactId>
<version>1.0</version>
</dependency>
</dependencies>

最后,在编译的时候,需要在根目录创建一个pom.xml统一编译:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">

<modelVersion>4.0.0</modelVersion>
<groupId>com.itranswarp.learnjava</groupId>
<artifactId>build</artifactId>
<version>1.0</version>
<packaging>pom</packaging>
<name>build</name>

<modules>
<module>parent</module>
<module>module-a</module>
<module>module-b</module>
<module>module-c</module>
</modules>
</project>

这样,在根目录执行mvn clean package时,Maven根据根目录的pom.xml找到包括parent在内的共4个<module>,一次性全部编译。

中央仓库

其实我们使用的大多数第三方模块都是这个用法,例如,我们使用commons logging、log4j这些第三方模块,就是第三方模块的开发者自己把编译好的jar包发布到Maven的中央仓库中。

私有仓库

私有仓库是指公司内部如果不希望把源码和jar包放到公网上,那么可以搭建私有仓库。私有仓库总是在公司内部使用,它只需要在本地的~/.m2/settings.xml中配置好,使用方式和中央仓位没有任何区别。

本地仓库

本地仓库是指把本地开发的项目“发布”在本地,这样其他项目可以通过本地仓库引用它。但是我们不推荐把自己的模块安装到Maven的本地仓库,因为每次修改某个模块的源码,都需要重新安装,非常容易出现版本不一致的情况。更好的方法是使用模块化编译,在编译的时候,告诉Maven几个模块之间存在依赖关系,需要一块编译,Maven就会自动按依赖顺序编译这些模块。

小结

Maven支持模块化管理,可以把一个大项目拆成几个模块:

  • 可以通过继承在parent的pom.xml统一定义重复配置;
  • 可以通过<modules>编译多个模块。

使用mvnw

我们使用Maven时,基本上只会用到mvn这一个命令。有些童鞋可能听说过mvnw,这个是啥?

mvnw是Maven Wrapper的缩写。因为我们安装Maven时,默认情况下,系统所有项目都会使用全局安装的这个Maven版本。但是,对于某些项目来说,它可能必须使用某个特定的Maven版本,这个时候,就可以使用Maven Wrapper,它可以负责给这个特定的项目安装指定版本的Maven,而其他项目不受影响。

简单地说,Maven Wrapper就是给一个项目提供一个独立的,指定版本的Maven给它使用。

安装Maven Wrapper

安装Maven Wrapper最简单的方式是在项目的根目录(即pom.xml所在的目录)下运行安装命令:

1
$ mvn wrapper:wrapper

它会自动使用最新版本的Maven。如果要指定使用的Maven版本,使用下面的安装命令指定版本,例如3.9.0

1
$ mvn wrapper:wrapper -Dmaven=3.9.0

安装后,查看项目结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
my-project
├── .mvn
│ └── wrapper
│ └── maven-wrapper.properties
├── mvnw
├── mvnw.cmd
├── pom.xml
└── src
   ├── main
   │ ├── java
   │ └── resources
   └── test
   ├── java
   └── resources

发现多了mvnwmvnw.cmd.mvn目录,我们只需要把mvn命令改成mvnw就可以使用跟项目关联的Maven。例如:

1
mvnw clean package

在Linux或macOS下运行时需要加上./

1
$ ./mvnw clean package

Maven Wrapper的另一个作用是把项目的mvnwmvnw.cmd.mvn提交到版本库中,可以使所有开发人员使用统一的Maven版本。

练习

使用mvnw编译hello项目。

下载练习

小结

使用Maven Wrapper,可以为一个项目指定特定的Maven版本。



当我们使用commons-logging这些第三方开源库的时候,我们实际上是通过Maven自动下载它的jar包,并根据其pom.xml解析依赖,自动把相关依赖包都下载后加入到classpath。

那么问题来了:当我们自己写了一个牛逼的开源库时,非常希望别人也能使用,总不能直接放个jar包的链接让别人下载吧?

如果我们把自己的开源库放到Maven的repo中,那么,别人只需按标准引用groupId:artifactId:version,即可自动下载jar包以及相关依赖。因此,本节我们介绍如何发布一个库到Maven的repo中。

把自己的库发布到Maven的repo中有好几种方法,我们介绍3种最常用的方法。

以静态文件发布

如果我们观察一个中央仓库的Artifact结构,例如Commons Math,它的groupId是org.apache.commons,artifactId是commons-math3,以版本3.6.1为例,发布在中央仓库的文件夹路径就是https://repo1.maven.org/maven2/org/apache/commons/commons-math3/3.6.1/,在此文件夹下,commons-math3-3.6.1.jar就是发布的jar包,commons-math3-3.6.1.pom就是它的pom.xml描述文件,commons-math3-3.6.1-sources.jar是源代码,commons-math3-3.6.1-javadoc.jar是文档。其它以.asc.md5.sha1结尾的文件分别是GPG签名、MD5摘要和SHA-1摘要。

我们只要按照这种目录结构组织文件,它就是一个有效的Maven仓库。

我们以广受好评的开源项目how-to-become-rich为例,先创建Maven工程目录结构如下:

1
2
3
4
5
6
7
8
9
10
11
how-to-become-rich
├── maven-repo <-- Maven本地文件仓库
├── pom.xml <-- 项目文件
├── src
│   ├── main
│   │   ├── java <-- 源码目录
│   │   └── resources <-- 资源目录
│   └── test
│   ├── java <-- 测试源码目录
│   └── resources <-- 测试资源目录
└── target <-- 编译输出目录

pom.xml中添加如下内容:

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
<project ...>
...
<distributionManagement>
<repository>
<id>local-repo-release</id>
<name>GitHub Release</name>
<url>file://${project.basedir}/maven-repo</url>
</repository>
</distributionManagement>

<build>
<plugins>
<plugin>
<artifactId>maven-source-plugin</artifactId>
<executions>
<execution>
<id>attach-sources</id>
<phase>package</phase>
<goals>
<goal>jar-no-fork</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<artifactId>maven-javadoc-plugin</artifactId>
<executions>
<execution>
<id>attach-javadocs</id>
<phase>package</phase>
<goals>
<goal>jar</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>

注意到<distributionManagement>,它指示了发布的软件包的位置,这里的<url>是项目根目录下的maven-repo目录,在<build>中定义的两个插件maven-source-pluginmaven-javadoc-plugin分别用来创建源码和javadoc,如果不想发布源码,可以把对应的插件去掉。

我们直接在项目根目录下运行Maven命令mvn clean package deploy,如果一切顺利,我们就可以在maven-repo目录下找到部署后的所有文件如下:

how-to-become-rich-repo

最后一步,是把这个工程推到GitHub上,并选择Settings-GitHub Pages,选择master branch启用Pages服务:

enable-github-pages

这样,把全部内容推送至GitHub后,即可作为静态网站访问Maven的repo,它的地址是https://michaelliao.github.io/how-to-become-rich/maven-repo/。版本1.0.0对应的jar包地址是:

1
https://michaelliao.github.io/how-to-become-rich/maven-repo/com/itranswarp/rich/how-to-become-rich/1.0.0/how-to-become-rich-1.0.0.jar

现在,如果其他人希望引用这个Maven包,我们可以告知如下依赖即可:

1
2
3
4
5
<dependency>
<groupId>com.itranswarp.rich</groupId>
<artifactId>how-to-become-rich</artifactId>
<version>1.0.0</version>
</dependency>

但是,除了正常导入依赖外,对方还需要再添加一个<repository>的声明,即使用方完整的pom.xml如下:

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
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<groupId>example</groupId>
<artifactId>how-to-become-rich-usage</artifactId>
<version>1.0-SNAPSHOT</version>
<packaging>jar</packaging>

<properties>
<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>
<java.version>11</java.version>
</properties>

<repositories>
<repository>
<id>github-rich-repo</id>
<name>The Maven Repository on Github</name>
<url>https://michaelliao.github.io/how-to-become-rich/maven-repo/</url>
</repository>
</repositories>

<dependencies>
<dependency>
<groupId>com.itranswarp.rich</groupId>
<artifactId>how-to-become-rich</artifactId>
<version>1.0.0</version>
</dependency>
</dependencies>
</project>

<repository>中,我们必须声明发布的Maven的repo地址,其中<id><name>可以任意填写,<url>填入GitHub Pages提供的地址+/maven-repo/后缀。现在,即可正常引用这个库并编写代码如下:

1
2
Millionaire millionaire = new Millionaire();
System.out.println(millionaire.howToBecomeRich());

有的童鞋会问,为什么使用commons-logging等第三方库时,并不需要声明repo地址?这是因为这些库都是发布到Maven中央仓库的,发布到中央仓库后,不需要告诉Maven仓库地址,因为它知道中央仓库的地址默认是https://repo1.maven.org/maven2/,也可以通过~/.m2/settings.xml指定一个代理仓库地址以替代中央仓库来提高速度(参考依赖管理的Maven镜像)。

因为GitHub Pages并不会把我们发布的Maven包同步到中央仓库,所以使用方必须手动添加一个我们提供的仓库地址。

此外,通过GitHub Pages发布Maven repo时需要注意一点,即不要改动已发布的版本。因为Maven的仓库是不允许修改任何版本的,对一个库进行修改的唯一方法是发布一个新版本。但是通过静态文件的方式发布repo,实际上我们是可以修改jar文件的,但最好遵守规范,不要修改已发布版本。

通过Nexus发布到中央仓库

有的童鞋会问,能不能把自己的开源库发布到Maven的中央仓库,这样用户就不需要声明repo地址,可以直接引用,显得更专业。

当然可以,但我们不能直接发布到Maven中央仓库,而是通过曲线救国的方式,发布到central.sonatype.org,它会定期自动同步到Maven的中央仓库。Nexus是一个支持Maven仓库的软件,由Sonatype开发,有免费版和专业版两个版本,很多大公司内部都使用Nexus作为自己的私有Maven仓库,而这个central.sonatype.org相当于面向开源的一个Nexus公共服务。

所以,第一步是在central.sonatype.org上注册一个账号,如果注册顺利并审核通过,会得到一个登录账号,然后,通过这个页面一步一步操作就可以成功地将自己的Artifact发布到Nexus上,再耐心等待几个小时后,你的Artifact就会出现在Maven的中央仓库中。

这里简单提一下发布重点与难点:

  • 必须正确创建GPG签名,Linux和Mac下推荐使用gnupg2;
  • 必须在~/.m2/settings.xml中配置好登录用户名和口令,以及GPG口令:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<settings ...>
...
<servers>
<server>
<id>ossrh</id>
<username>OSSRH-USERNAME</username>
<password>OSSRH-PASSWORD</password>
</server>
</servers>
<profiles>
<profile>
<id>ossrh</id>
<activation>
<activeByDefault>true</activeByDefault>
</activation>
<properties>
<gpg.executable>gpg2</gpg.executable>
<gpg.passphrase>GPG-PASSWORD</gpg.passphrase>
</properties>
</profile>
</profiles>
</settings>

在待发布的Artifact的pom.xml中添加OSS的Maven repo地址,以及maven-jar-pluginmaven-source-pluginmaven-javadoc-pluginmaven-gpg-pluginnexus-staging-maven-plugin

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
77
78
79
80
81
82
83
84
85
<project ...>
...
<distributionManagement>
<snapshotRepository>
<id>ossrh</id>
<url>https://oss.sonatype.org/content/repositories/snapshots</url>
</snapshotRepository>

<repository>
<id>ossrh</id>
<name>Nexus Release Repository</name>
<url>http://oss.sonatype.org/service/local/staging/deploy/maven2/</url>
</repository>
</distributionManagement>

<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<executions>
<execution>
<goals>
<goal>jar</goal>
<goal>test-jar</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-source-plugin</artifactId>
<executions>
<execution>
<id>attach-sources</id>
<goals>
<goal>jar-no-fork</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-javadoc-plugin</artifactId>
<executions>
<execution>
<id>attach-javadocs</id>
<goals>
<goal>jar</goal>
</goals>
<configuration>
<additionalOption>
<additionalOption>-Xdoclint:none</additionalOption>
</additionalOption>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-gpg-plugin</artifactId>
<executions>
<execution>
<id>sign-artifacts</id>
<phase>verify</phase>
<goals>
<goal>sign</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.sonatype.plugins</groupId>
<artifactId>nexus-staging-maven-plugin</artifactId>
<version>1.6.3</version>
<extensions>true</extensions>
<configuration>
<serverId>ossrh</serverId>
<nexusUrl>https://oss.sonatype.org/</nexusUrl>
<autoReleaseAfterClose>true</autoReleaseAfterClose>
</configuration>
</plugin>
</plugins>
</build>
</project>

最后执行命令mvn clean package deploy即可发布至central.sonatype.org

此方法前期需要复杂的申请账号和项目的流程,后期需要安装调试GPG,但只要跑通流程,后续发布都只需要一行命令。

发布到私有仓库

通过nexus-staging-maven-plugin除了可以发布到central.sonatype.org外,也可以发布到私有仓库,例如,公司内部自己搭建的Nexus服务器。

如果没有私有Nexus服务器,还可以发布到GitHub Packages。GitHub Packages是GitHub提供的仓库服务,支持Maven、NPM、Docker等。使用GitHub Packages时,无论是发布Artifact,还是引用已发布的Artifact,都需要明确的授权Token,因此,GitHub Packages只能作为私有仓库使用。

在发布前,我们必须首先登录后在用户的Settings-Developer settings-Personal access tokens中创建两个Token,一个用于发布,一个用于使用。发布Artifact的Token必须有repowrite:packagesread:packages权限:

token-scopes

使用Artifact的Token只需要read:packages权限。

在发布端,把GitHub的用户名和发布Token写入~/.m2/settings.xml配置中:

1
2
3
4
5
6
7
8
9
10
<settings ...>
...
<servers>
<server>
<id>github-release</id>
<username>GITHUB-USERNAME</username>
<password>f052...c21f</password>
</server>
</servers>
</settings>

然后,在需要发布的Artifact的pom.xml中,添加一个<repository>声明:

1
2
3
4
5
6
7
8
9
10
<project ...>
...
<distributionManagement>
<repository>
<id>github-release</id>
<name>GitHub Release</name>
<url>https://maven.pkg.github.com/michaelliao/complex</url>
</repository>
</distributionManagement>
</project>

注意到<id>~/.m2/settings.xml配置中的<id>要保持一致,因为发布时Maven根据id找到用于登录的用户名和Token,才能成功上传文件到GitHub。我们直接通过命令mvn clean package deploy部署,成功后,在GitHub用户页面可以看到该Artifact:

github-packages

完整的配置请参考complex项目,这是一个非常简单的支持复数运算的库。

使用该Artifact时,因为GitHub的Package只能作为私有仓库使用,所以除了在使用方的pom.xml中声明<repository>外:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<project ...>
...
<repositories>
<repository>
<id>github-release</id>
<name>GitHub Release</name>
<url>https://maven.pkg.github.com/michaelliao/complex</url>
</repository>
</repositories>

<dependencies>
<dependency>
<groupId>com.itranswarp</groupId>
<artifactId>complex</artifactId>
<version>1.0.0</version>
</dependency>
</dependencies>
...
</project>

还需要把有读权限的Token配置到~/.m2/settings.xml文件中。

练习

使用maven-deploy-plugin把Artifact发布到本地。

小结

使用Maven发布一个Artifact时:

  • 可以发布到本地,然后推送到远程Git库,由静态服务器提供基于网页的repo服务,使用方必须声明repo地址;
  • 可以发布到central.sonatype.org,并自动同步到Maven中央仓库,需要前期申请账号以及本地配置;
  • 可以发布到GitHub Packages作为私有仓库使用,必须提供Token以及正确的权限才能发布和使用。

留言與分享

JAVA-多线程3

分類 编程语言, Java

Java语言虽然内置了多线程支持,启动一个新线程非常方便,但是,创建线程需要操作系统资源(线程资源,栈空间等),频繁创建和销毁大量线程需要消耗大量时间。

如果可以复用一组线程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
┌─────┐ execute  ┌──────────────────┐
│Task1│─────────▶│ThreadPool │
├─────┤ │┌───────┐┌───────┐│
│Task2│ ││Thread1││Thread2││
├─────┤ │└───────┘└───────┘│
│Task3│ │┌───────┐┌───────┐│
├─────┤ ││Thread3││Thread4││
│Task4│ │└───────┘└───────┘│
├─────┤ └──────────────────┘
│Task5│
├─────┤
│Task6│
└─────┘
...

那么我们就可以把很多小任务让一组线程来执行,而不是一个任务对应一个新线程。这种能接收大量小任务并进行分发处理的就是线程池。

简单地说,线程池内部维护了若干个线程,没有任务的时候,这些线程都处于等待状态。如果有新任务,就分配一个空闲线程执行。如果所有线程都处于忙碌状态,新任务要么放入队列等待,要么增加一个新线程进行处理。

Java标准库提供了ExecutorService接口表示线程池,它的典型用法如下:

1
2
3
4
5
6
7
8
// 创建固定大小的线程池:
ExecutorService executor = Executors.newFixedThreadPool(3);
// 提交任务:
executor.submit(task1);
executor.submit(task2);
executor.submit(task3);
executor.submit(task4);
executor.submit(task5);

因为ExecutorService只是接口,Java标准库提供的几个常用实现类有:

  • FixedThreadPool:线程数固定的线程池;
  • CachedThreadPool:线程数根据任务动态调整的线程池;
  • SingleThreadExecutor:仅单线程执行的线程池。

创建这些线程池的方法都被封装到Executors这个类中。我们以FixedThreadPool为例,看看线程池的执行逻辑:

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
// thread-pool
import java.util.concurrent.*;

public class Main {
public static void main(String[] args) {
// 创建一个固定大小的线程池:
ExecutorService es = Executors.newFixedThreadPool(4);
for (int i = 0; i < 6; i++) {
es.submit(new Task("" + i));
}
// 关闭线程池:
es.shutdown();
}
}

class Task implements Runnable {
private final String name;

public Task(String name) {
this.name = name;
}

@Override
public void run() {
System.out.println("start task " + name);
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
}
System.out.println("end task " + name);
}
}

我们观察执行结果,一次性放入6个任务,由于线程池只有固定的4个线程,因此,前4个任务会同时执行,等到有线程空闲后,才会执行后面的两个任务。

线程池在程序结束的时候要关闭。使用shutdown()方法关闭线程池的时候,它会等待正在执行的任务先完成,然后再关闭。shutdownNow()会立刻停止正在执行的任务,awaitTermination()则会等待指定的时间让线程池关闭。

如果我们把线程池改为CachedThreadPool,由于这个线程池的实现会根据任务数量动态调整线程池的大小,所以6个任务可一次性全部同时执行。

如果我们想把线程池的大小限制在4~10个之间动态调整怎么办?我们查看Executors.newCachedThreadPool()方法的源码:

1
2
3
4
5
6
public static ExecutorService newCachedThreadPool() {
return new ThreadPoolExecutor(
0, Integer.MAX_VALUE,
60L, TimeUnit.SECONDS,
new SynchronousQueue<Runnable>());
}

因此,想创建指定动态范围的线程池,可以这么写:

1
2
3
4
5
6
int min = 4;
int max = 10;
ExecutorService es = new ThreadPoolExecutor(
min, max,
60L, TimeUnit.SECONDS,
new SynchronousQueue<Runnable>());

ScheduledThreadPool

还有一种任务,需要定期反复执行,例如,每秒刷新证券价格。这种任务本身固定,需要反复执行的,可以使用ScheduledThreadPool。放入ScheduledThreadPool的任务可以定期反复执行。

创建一个ScheduledThreadPool仍然是通过Executors类:

1
ScheduledExecutorService ses = Executors.newScheduledThreadPool(4);

我们可以提交一次性任务,它会在指定延迟后只执行一次:

1
2
// 1秒后执行一次性任务:
ses.schedule(new Task("one-time"), 1, TimeUnit.SECONDS);

如果任务以固定的每3秒执行,我们可以这样写:

1
2
// 2秒后开始执行定时任务,每3秒执行:
ses.scheduleAtFixedRate(new Task("fixed-rate"), 2, 3, TimeUnit.SECONDS);

如果任务以固定的3秒为间隔执行,我们可以这样写:

1
2
// 2秒后开始执行定时任务,以3秒为间隔执行:
ses.scheduleWithFixedDelay(new Task("fixed-delay"), 2, 3, TimeUnit.SECONDS);

注意FixedRate和FixedDelay的区别。FixedRate是指任务总是以固定时间间隔触发,不管任务执行多长时间:

1
2
3
│░░░░   │░░░░░░ │░░░    │░░░░░  │░░░  
├───────┼───────┼───────┼───────┼────▶
│◀─────▶│◀─────▶│◀─────▶│◀─────▶│

而FixedDelay是指,上一次任务执行完毕后,等待固定的时间间隔,再执行下一次任务:

1
2
3
│░░░│       │░░░░░│       │░░│       │░
└───┼───────┼─────┼───────┼──┼───────┼──▶
│◀─────▶│ │◀─────▶│ │◀─────▶│

因此,使用ScheduledThreadPool时,我们要根据需要选择执行一次、FixedRate执行还是FixedDelay执行。

细心的童鞋还可以思考下面的问题:

  • 在FixedRate模式下,假设每秒触发,如果某次任务执行时间超过1秒,后续任务会不会并发执行?
  • 如果任务抛出了异常,后续任务是否继续执行?

Java标准库还提供了一个java.util.Timer类,这个类也可以定期执行任务,但是,一个Timer会对应一个Thread,所以,一个Timer只能定期执行一个任务,多个定时任务必须启动多个Timer,而一个ScheduledThreadPool就可以调度多个定时任务,所以,我们完全可以用ScheduledThreadPool取代旧的Timer

练习

使用线程池复用线程。

下载练习

小结

JDK提供了ExecutorService实现了线程池功能:

  • 线程池内部维护一组线程,可以高效执行大量小任务;
  • Executors提供了静态方法创建不同类型的ExecutorService
  • 必须调用shutdown()关闭ExecutorService
  • ScheduledThreadPool可以定期调度多个任务。

使用Future

在执行多个任务的时候,使用Java标准库提供的线程池是非常方便的。我们提交的任务只需要实现Runnable接口,就可以让线程池去执行:

1
2
3
4
5
6
7
class Task implements Runnable {
public String result;

public void run() {
this.result = longTimeCalculation();
}
}

Runnable接口有个问题,它的方法没有返回值。如果任务需要一个返回结果,那么只能保存到变量,还要提供额外的方法读取,非常不便。所以,Java标准库还提供了一个Callable接口,和Runnable接口比,它多了一个返回值:

1
2
3
4
5
class Task implements Callable<String> {
public String call() throws Exception {
return longTimeCalculation();
}
}

并且Callable接口是一个泛型接口,可以返回指定类型的结果。

现在的问题是,如何获得异步执行的结果?

如果仔细看ExecutorService.submit()方法,可以看到,它返回了一个Future类型,一个Future类型的实例代表一个未来能获取结果的对象:

1
2
3
4
5
6
7
ExecutorService executor = Executors.newFixedThreadPool(4); 
// 定义任务:
Callable<String> task = new Task();
// 提交任务并获得Future:
Future<String> future = executor.submit(task);
// 从Future获取异步执行返回的结果:
String result = future.get(); // 可能阻塞

当我们提交一个Callable任务后,我们会同时获得一个Future对象,然后,我们在主线程某个时刻调用Future对象的get()方法,就可以获得异步执行的结果。在调用get()时,如果异步任务已经完成,我们就直接获得结果。如果异步任务还没有完成,那么get()会阻塞,直到任务完成后才返回结果。

一个Future<V>接口表示一个未来可能会返回的结果,它定义的方法有:

  • get():获取结果(可能会等待)
  • get(long timeout, TimeUnit unit):获取结果,但只等待指定的时间;
  • cancel(boolean mayInterruptIfRunning):取消当前任务;
  • isDone():判断任务是否已完成。

练习

使用Future获取异步执行结果。

下载练习

小结

对线程池提交一个Callable任务,可以获得一个Future对象;

可以用Future在将来某个时刻获取结果。



使用Future获得异步执行结果时,要么调用阻塞方法get(),要么轮询看isDone()是否为true,这两种方法都不是很好,因为主线程也会被迫等待。

从Java 8开始引入了CompletableFuture,它针对Future做了改进,可以传入回调对象,当异步任务完成或者发生异常时,自动调用回调对象的回调方法。

我们以获取股票价格为例,看看如何使用CompletableFuture

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
// CompletableFuture
import java.util.concurrent.CompletableFuture;

public class Main {
public static void main(String[] args) throws Exception {
// 创建异步执行任务:
CompletableFuture<Double> cf = CompletableFuture.supplyAsync(Main::fetchPrice);
// 如果执行成功:
cf.thenAccept((result) -> {
System.out.println("price: " + result);
});
// 如果执行异常:
cf.exceptionally((e) -> {
e.printStackTrace();
return null;
});
// 主线程不要立刻结束,否则CompletableFuture默认使用的线程池会立刻关闭:
Thread.sleep(200);
}

static Double fetchPrice() {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
}
if (Math.random() < 0.3) {
throw new RuntimeException("fetch price failed!");
}
return 5 + Math.random() * 20;
}
}

创建一个CompletableFuture是通过CompletableFuture.supplyAsync()实现的,它需要一个实现了Supplier接口的对象:

1
2
3
public interface Supplier<T> {
T get();
}

这里我们用lambda语法简化了一下,直接传入Main::fetchPrice,因为Main.fetchPrice()静态方法的签名符合Supplier接口的定义(除了方法名外)。

紧接着,CompletableFuture已经被提交给默认的线程池执行了,我们需要定义的是CompletableFuture完成时和异常时需要回调的实例。完成时,CompletableFuture会调用Consumer对象:

1
2
3
public interface Consumer<T> {
void accept(T t);
}

异常时,CompletableFuture会调用Function对象:

1
2
3
public interface Function<T, R> {
R apply(T t);
}

这里我们都用lambda语法简化了代码。

可见CompletableFuture的优点是:

  • 异步任务结束时,会自动回调某个对象的方法;
  • 异步任务出错时,会自动回调某个对象的方法;
  • 主线程设置好回调后,不再关心异步任务的执行。

如果只是实现了异步回调机制,我们还看不出CompletableFuture相比Future的优势。CompletableFuture更强大的功能是,多个CompletableFuture可以串行执行,例如,定义两个CompletableFuture,第一个CompletableFuture根据证券名称查询证券代码,第二个CompletableFuture根据证券代码查询证券价格,这两个CompletableFuture实现串行操作如下:

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
// CompletableFuture
import java.util.concurrent.CompletableFuture;

public class Main {
public static void main(String[] args) throws Exception {
// 第一个任务:
CompletableFuture<String> cfQuery = CompletableFuture.supplyAsync(() -> {
return queryCode("中国石油");
});
// cfQuery成功后继续执行下一个任务:
CompletableFuture<Double> cfFetch = cfQuery.thenApplyAsync((code) -> {
return fetchPrice(code);
});
// cfFetch成功后打印结果:
cfFetch.thenAccept((result) -> {
System.out.println("price: " + result);
});
// 主线程不要立刻结束,否则CompletableFuture默认使用的线程池会立刻关闭:
Thread.sleep(2000);
}

static String queryCode(String name) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
}
return "601857";
}

static Double fetchPrice(String code) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
}
return 5 + Math.random() * 20;
}
}

除了串行执行外,多个CompletableFuture还可以并行执行。例如,我们考虑这样的场景:

同时从新浪和网易查询证券代码,只要任意一个返回结果,就进行下一步查询价格,查询价格也同时从新浪和网易查询,只要任意一个返回结果,就完成操作:

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
// CompletableFuture
import java.util.concurrent.CompletableFuture;

public class Main {
public static void main(String[] args) throws Exception {
// 两个CompletableFuture执行异步查询:
CompletableFuture<String> cfQueryFromSina = CompletableFuture.supplyAsync(() -> {
return queryCode("中国石油", "https://finance.sina.com.cn/code/");
});
CompletableFuture<String> cfQueryFrom163 = CompletableFuture.supplyAsync(() -> {
return queryCode("中国石油", "https://money.163.com/code/");
});

// 用anyOf合并为一个新的CompletableFuture:
CompletableFuture<Object> cfQuery = CompletableFuture.anyOf(cfQueryFromSina, cfQueryFrom163);

// 两个CompletableFuture执行异步查询:
CompletableFuture<Double> cfFetchFromSina = cfQuery.thenApplyAsync((code) -> {
return fetchPrice((String) code, "https://finance.sina.com.cn/price/");
});
CompletableFuture<Double> cfFetchFrom163 = cfQuery.thenApplyAsync((code) -> {
return fetchPrice((String) code, "https://money.163.com/price/");
});

// 用anyOf合并为一个新的CompletableFuture:
CompletableFuture<Object> cfFetch = CompletableFuture.anyOf(cfFetchFromSina, cfFetchFrom163);

// 最终结果:
cfFetch.thenAccept((result) -> {
System.out.println("price: " + result);
});
// 主线程不要立刻结束,否则CompletableFuture默认使用的线程池会立刻关闭:
Thread.sleep(200);
}

static String queryCode(String name, String url) {
System.out.println("query code from " + url + "...");
try {
Thread.sleep((long) (Math.random() * 100));
} catch (InterruptedException e) {
}
return "601857";
}

static Double fetchPrice(String code, String url) {
System.out.println("query price from " + url + "...");
try {
Thread.sleep((long) (Math.random() * 100));
} catch (InterruptedException e) {
}
return 5 + Math.random() * 20;
}
}

上述逻辑实现的异步查询规则实际上是:

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
┌─────────────┐ ┌─────────────┐
│ Query Code │ │ Query Code │
│ from sina │ │ from 163 │
└─────────────┘ └─────────────┘
│ │
└───────┬───────┘

┌─────────────┐
│ anyOf │
└─────────────┘

┌───────┴────────┐
▼ ▼
┌─────────────┐ ┌─────────────┐
│ Query Price │ │ Query Price │
│ from sina │ │ from 163 │
└─────────────┘ └─────────────┘
│ │
└────────┬───────┘

┌─────────────┐
│ anyOf │
└─────────────┘


┌─────────────┐
│Display Price│
└─────────────┘

除了anyOf()可以实现“任意个CompletableFuture只要一个成功”,allOf()可以实现“所有CompletableFuture都必须成功”,这些组合操作可以实现非常复杂的异步流程控制。

最后我们注意CompletableFuture的命名规则:

  • xxx():表示该方法将继续在已有的线程中执行;
  • xxxAsync():表示将异步在线程池中执行。

练习

使用CompletableFuture。

下载练习

小结

CompletableFuture可以指定异步处理流程:

  • thenAccept()处理正常结果;
  • exceptional()处理异常结果;
  • thenApplyAsync()用于串行化另一个CompletableFuture
  • anyOf()allOf()用于并行化多个CompletableFuture

Java 7开始引入了一种新的Fork/Join线程池,它可以执行一种特殊的任务:把一个大任务拆成多个小任务并行执行。

我们举个例子:如果要计算一个超大数组的和,最简单的做法是用一个循环在一个线程内完成:

1
2
┌─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┐
└─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┘

还有一种方法,可以把数组拆成两部分,分别计算,最后加起来就是最终结果,这样可以用两个线程并行执行:

1
2
3
4
┌─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┐
└─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┘
┌─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┐
└─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┘

如果拆成两部分还是很大,我们还可以继续拆,用4个线程并行执行:

1
2
3
4
5
6
7
8
┌─┬─┬─┬─┬─┬─┐
└─┴─┴─┴─┴─┴─┘
┌─┬─┬─┬─┬─┬─┐
└─┴─┴─┴─┴─┴─┘
┌─┬─┬─┬─┬─┬─┐
└─┴─┴─┴─┴─┴─┘
┌─┬─┬─┬─┬─┬─┐
└─┴─┴─┴─┴─┴─┘

这就是Fork/Join任务的原理:判断一个任务是否足够小,如果是,直接计算,否则,就分拆成几个小任务分别计算。这个过程可以反复“裂变”成一系列小任务。

我们来看如何使用Fork/Join对大数据进行并行求和:

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
import java.util.Random;
import java.util.concurrent.*;

public class Main {
public static void main(String[] args) throws Exception {
// 创建2000个随机数组成的数组:
long[] array = new long[2000];
long expectedSum = 0;
for (int i = 0; i < array.length; i++) {
array[i] = random();
expectedSum += array[i];
}
System.out.println("Expected sum: " + expectedSum);
// fork/join:
ForkJoinTask<Long> task = new SumTask(array, 0, array.length);
long startTime = System.currentTimeMillis();
Long result = ForkJoinPool.commonPool().invoke(task);
long endTime = System.currentTimeMillis();
System.out.println("Fork/join sum: " + result + " in " + (endTime - startTime) + " ms.");
}

static Random random = new Random(0);

static long random() {
return random.nextInt(10000);
}
}

class SumTask extends RecursiveTask<Long> {
static final int THRESHOLD = 500;
long[] array;
int start;
int end;

SumTask(long[] array, int start, int end) {
this.array = array;
this.start = start;
this.end = end;
}

@Override
protected Long compute() {
if (end - start <= THRESHOLD) {
// 如果任务足够小,直接计算:
long sum = 0;
for (int i = start; i < end; i++) {
sum += this.array[i];
// 故意放慢计算速度:
try {
Thread.sleep(1);
} catch (InterruptedException e) {
}
}
return sum;
}
// 任务太大,一分为二:
int middle = (end + start) / 2;
System.out.println(String.format("split %d~%d ==> %d~%d, %d~%d", start, end, start, middle, middle, end));
SumTask subtask1 = new SumTask(this.array, start, middle);
SumTask subtask2 = new SumTask(this.array, middle, end);
invokeAll(subtask1, subtask2);
Long subresult1 = subtask1.join();
Long subresult2 = subtask2.join();
Long result = subresult1 + subresult2;
System.out.println("result = " + subresult1 + " + " + subresult2 + " ==> " + result);
return result;
}
}

观察上述代码的执行过程,一个大的计算任务0~2000首先分裂为两个小任务0~1000和1000~2000,这两个小任务仍然太大,继续分裂为更小的0~500,500~1000,1000~1500,1500~2000,最后,计算结果被依次合并,得到最终结果。

因此,核心代码SumTask继承自RecursiveTask,在compute()方法中,关键是如何“分裂”出子任务并且提交子任务:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class SumTask extends RecursiveTask<Long> {
protected Long compute() {
// “分裂”子任务:
SumTask subtask1 = new SumTask(...);
SumTask subtask2 = new SumTask(...);
// invokeAll会并行运行两个子任务:
invokeAll(subtask1, subtask2);
// 获得子任务的结果:
Long subresult1 = subtask1.join();
Long subresult2 = subtask2.join();
// 汇总结果:
return subresult1 + subresult2;
}
}

Fork/Join线程池在Java标准库中就有应用。Java标准库提供的java.util.Arrays.parallelSort(array)可以进行并行排序,它的原理就是内部通过Fork/Join对大数组分拆进行并行排序,在多核CPU上就可以大大提高排序的速度。

练习

使用Fork/Join。

下载练习

小结

Fork/Join是一种基于“分治”的算法:通过分解任务,并行执行,最后合并结果得到最终结果。

ForkJoinPool线程池可以把一个大任务分拆成小任务并行执行,任务类必须继承自RecursiveTaskRecursiveAction

使用Fork/Join模式可以进行并行计算以提高效率。

多线程是Java实现多任务的基础,Thread对象代表一个线程,我们可以在代码中调用Thread.currentThread()获取当前线程。例如,打印日志时,可以同时打印出当前线程的名字:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Thread
public class Main {
public static void main(String[] args) throws Exception {
log("start main...");
new Thread(() -> {
log("run task...");
}).start();
new Thread(() -> {
log("print...");
}).start();
log("end main.");
}

static void log(String s) {
System.out.println(Thread.currentThread().getName() + ": " + s);
}
}

对于多任务,Java标准库提供的线程池可以方便地执行这些任务,同时复用线程。Web应用程序就是典型的多任务应用,每个用户请求页面时,我们都会创建一个任务,类似:

1
2
3
4
5
6
public void process(User user) {
checkPermission();
doWork();
saveStatus();
sendResponse();
}

然后,通过线程池去执行这些任务。

观察process()方法,它内部需要调用若干其他方法,同时,我们遇到一个问题:如何在一个线程内传递状态?

process()方法需要传递的状态就是User实例。有的童鞋会想,简单地传入User就可以了:

1
2
3
4
5
6
public void process(User user) {
checkPermission(user);
doWork(user);
saveStatus(user);
sendResponse(user);
}

但是往往一个方法又会调用其他很多方法,这样会导致User传递到所有地方:

1
2
3
4
5
6
void doWork(User user) {
queryStatus(user);
checkStatus();
setNewStatus(user);
log();
}

这种在一个线程中,横跨若干方法调用,需要传递的对象,我们通常称之为上下文(Context),它是一种状态,可以是用户身份、任务信息等。

给每个方法增加一个context参数非常麻烦,而且有些时候,如果调用链有无法修改源码的第三方库,User对象就传不进去了。

Java标准库提供了一个特殊的ThreadLocal,它可以在一个线程中传递同一个对象。

ThreadLocal实例通常总是以静态字段初始化如下:

1
static ThreadLocal<User> threadLocalUser = new ThreadLocal<>();

它的典型使用方式如下:

1
2
3
4
5
6
7
8
9
10
void processUser(user) {
try {
threadLocalUser.set(user);
step1();
step2();
log();
} finally {
threadLocalUser.remove();
}
}

通过设置一个User实例关联到ThreadLocal中,在移除之前,所有方法都可以随时获取到该User实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void step1() {
User u = threadLocalUser.get();
log();
printUser();
}

void step2() {
User u = threadLocalUser.get();
checkUser(u.id);
}

void log() {
User u = threadLocalUser.get();
println(u.name);
}

注意到普通的方法调用一定是同一个线程执行的,所以,step1()step2()以及log()方法内,threadLocalUser.get()获取的User对象是同一个实例。

实际上,可以把ThreadLocal看成一个全局Map<Thread, Object>:每个线程获取ThreadLocal变量时,总是使用Thread自身作为key:

1
Object threadLocalValue = threadLocalMap.get(Thread.currentThread());

因此,ThreadLocal相当于给每个线程都开辟了一个独立的存储空间,各个线程的ThreadLocal关联的实例互不干扰。

最后,特别注意ThreadLocal一定要在finally中清除:

1
2
3
4
5
6
try {
threadLocalUser.set(user);
...
} finally {
threadLocalUser.remove();
}

这是因为当前线程执行完相关代码后,很可能会被重新放入线程池中,如果ThreadLocal没有被清除,该线程执行其他代码时,会把上一次的状态带进去。

为了保证能释放ThreadLocal关联的实例,我们可以通过AutoCloseable接口配合try (resource) {...}结构,让编译器自动为我们关闭。例如,一个保存了当前用户名的ThreadLocal可以封装为一个UserContext对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class UserContext implements AutoCloseable {

static final ThreadLocal<String> ctx = new ThreadLocal<>();

public UserContext(String user) {
ctx.set(user);
}

public static String currentUser() {
return ctx.get();
}

@Override
public void close() {
ctx.remove();
}
}

使用的时候,我们借助try (resource) {...}结构,可以这么写:

1
2
3
4
try (var ctx = new UserContext("Bob")) {
// 可任意调用UserContext.currentUser():
String currentUser = UserContext.currentUser();
} // 在此自动调用UserContext.close()方法释放ThreadLocal关联对象

这样就在UserContext中完全封装了ThreadLocal,外部代码在try (resource) {...}内部可以随时调用UserContext.currentUser()获取当前线程绑定的用户名。

练习

练习使用ThreadLocal。

下载练习

小结

ThreadLocal表示线程的“局部变量”,它确保每个线程的ThreadLocal变量都是各自独立的;

ThreadLocal适合在一个线程的处理流程中保持上下文(避免了同一参数在所有方法中传递);

使用ThreadLocal要用try ... finally结构,并在finally中清除。

虚拟线程(Virtual Thread)是Java 19引入的一种轻量级线程,它在很多其他语言中被称为协程、纤程、绿色线程、用户态线程等。

在理解虚拟线程前,我们先回顾一下线程的特点:

  • 线程是由操作系统创建并调度的资源;
  • 线程切换会耗费大量CPU时间;
  • 一个系统能同时调度的线程数量是有限的,通常在几百至几千级别。

因此,我们说线程是一种重量级资源。在服务器端,对用户请求,通常都实现为一个线程处理一个请求。由于用户的请求数往往远超操作系统能同时调度的线程数量,所以通常使用线程池来尽量减少频繁创建和销毁线程的成本。

对于需要处理大量IO请求的任务来说,使用线程是低效的,因为一旦读写IO,线程就必须进入等待状态,直到IO数据返回。常见的IO操作包括:

  • 读写文件;
  • 读写网络,例如HTTP请求;
  • 读写数据库,本质上是通过JDBC实现网络调用。

我们举个例子,一个处理HTTP请求的线程,它在读写网络、文件的时候就会进入等待状态:

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
Begin
────────
Blocking ──▶ Read HTTP Request
Wait...
Wait...
Wait...
────────
Running
────────
Blocking ──▶ Read Config File
Wait...
────────
Running
────────
Blocking ──▶ Read Database
Wait...
Wait...
Wait...
────────
Running
────────
Blocking ──▶ Send HTTP Response
Wait...
Wait...
────────
End

真正由CPU执行的代码消耗的时间非常少,线程的大部分时间都在等待IO。我们把这类任务称为IO密集型任务。

为了能高效执行IO密集型任务,Java从19开始引入了虚拟线程。虚拟线程的接口和普通线程是一样的,但是执行方式不一样。虚拟线程不是由操作系统调度,而是由普通线程调度,即成百上千个虚拟线程可以由一个普通线程调度。任何时刻,只能执行一个虚拟线程,但是,一旦该虚拟线程执行一个IO操作进入等待时,它会被立刻“挂起”,然后执行下一个虚拟线程。什么时候IO数据返回了,这个挂起的虚拟线程才会被再次调度。因此,若干个虚拟线程可以在一个普通线程中交替运行:

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
Begin
───────────
V1 Runing
V1 Blocking ──▶ Read HTTP Request
───────────
V2 Runing
V2 Blocking ──▶ Read HTTP Request
───────────
V3 Runing
V3 Blocking ──▶ Read HTTP Request
───────────
V1 Runing
V1 Blocking ──▶ Read Config File
───────────
V2 Runing
V2 Blocking ──▶ Read Database
───────────
V1 Runing
V1 Blocking ──▶ Read Database
───────────
V3 Runing
V3 Blocking ──▶ Read Database
───────────
V2 Runing
V2 Blocking ──▶ Send HTTP Response
───────────
V1 Runing
V1 Blocking ──▶ Send HTTP Response
───────────
V3 Runing
V3 Blocking ──▶ Send HTTP Response
───────────
End

如果我们单独看一个虚拟线程的代码,在一个方法中:

1
2
3
4
5
6
7
8
9
10
void register() {
config = readConfigFile("./config.json"); // #1
if (config.useFullName) {
name = req.firstName + " " + req.lastName;
}
insertInto(db, name); // #2
if (config.cache) {
redis.set(key, name); // #3
}
}

涉及到IO读写的#1、#2、#3处,执行到这些地方的时候(进入相关的JNI方法内部时)会自动挂起,并切换到其他虚拟线程执行。等到数据返回后,当前虚拟线程会再次调度并执行,因此,代码看起来是同步执行,但实际上是异步执行的。

使用虚拟线程

虚拟线程的接口和普通线程一样,唯一区别在于创建虚拟线程只能通过特定方法。

方法一:直接创建虚拟线程并运行:

1
2
3
4
5
6
// 传入Runnable实例并立刻运行:
Thread vt = Thread.startVirtualThread(() -> {
System.out.println("Start virtual thread...");
Thread.sleep(10);
System.out.println("End virtual thread.");
});

方法二:创建虚拟线程但不自动运行,而是手动调用start()开始运行:

1
2
3
4
5
6
7
8
// 创建VirtualThread:
Thread.ofVirtual().unstarted(() -> {
System.out.println("Start virtual thread...");
Thread.sleep(1000);
System.out.println("End virtual thread.");
});
// 运行:
vt.start();

方法三:通过虚拟线程的ThreadFactory创建虚拟线程,然后手动调用start()开始运行:

1
2
3
4
5
6
7
8
9
10
// 创建ThreadFactory:
ThreadFactory tf = Thread.ofVirtual().factory();
// 创建VirtualThread:
Thread vt = tf.newThread(() -> {
System.out.println("Start virtual thread...");
Thread.sleep(1000);
System.out.println("End virtual thread.");
});
// 运行:
vt.start();

直接调用start()实际上是由ForkJoinPool的线程来调度的。我们也可以自己创建调度线程,然后运行虚拟线程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 创建调度器:
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
// 创建大量虚拟线程并调度:
ThreadFactory tf = Thread.ofVirtual().factory();
for (int i=0; i<100000; i++) {
Thread vt = tf.newThread(() -> { ... });
executor.submit(vt);
// 也可以直接传入Runnable或Callable:
executor.submit(() -> {
System.out.println("Start virtual thread...");
Thread.sleep(1000);
System.out.println("End virtual thread.");
return true;
});
}

由于虚拟线程属于非常轻量级的资源,因此,用时创建,用完就扔,不要池化虚拟线程。

最后注意,虚拟线程在Java 21正式发布,在Java 19/20是预览功能,默认关闭,需要添加参数--enable-preview启用:

1
java --source 19 --enable-preview Main.java

使用限制

注意到只有以虚拟线程方式运行的代码,才会在执行IO操作时自动被挂起并切换到其他虚拟线程。普通线程的IO操作仍然会等待,例如,我们在main()方法中读写文件,是不会有调度和自动挂起的。

可以自动引发调度切换的操作包括:

  • 文件IO;
  • 网络IO;
  • 使用Concurrent库引发等待;
  • Thread.sleep()操作。

这是因为JDK为了实现虚拟线程,已经对底层相关操作进行了修改,这样应用层的Java代码无需修改即可使用虚拟线程。无法自动切换的语言需要用户手动调用await来实现异步操作:

1
2
3
4
async function doWork() {
await readFile();
await sendNetworkData();
}

在虚拟线程中,如果绕过JDK的IO接口,直接通过JNI读写文件或网络是无法实现调度的。此外,在synchronized块内部也无法调度。

练习

使用虚拟线程调度10万个任务并观察耗时:

1
2
3
4
5
6
7
8
9
10
11
12
public class Main {
public static void main(String[] args) {
ExecutorService es = Executors.newVirtualThreadPerTaskExecutor();
for (int i=0; i<100000; i++) {
es.submit(() -> {
Thread.sleep(1000);
return 0;
});
}
es.close();
}
}

再将ExecutorService改为线程池模式并对比结果。

下载练习

小结

Java 19引入的虚拟线程是为了解决IO密集型任务的吞吐量,它可以高效通过少数线程去调度大量虚拟线程;

虚拟线程在执行到IO操作或Blocking操作时,会自动切换到其他虚拟线程执行,从而避免当前线程等待,能最大化线程的执行效率;

虚拟线程使用普通线程相同的接口,最大的好处是无需修改任何代码,就可以将现有的IO操作异步化获得更大的吞吐能力。

计算密集型任务不应使用虚拟线程,只能通过增加CPU核心解决,或者利用分布式计算资源。

留言與分享

JAVA-多线程2(线程同步)

分類 编程语言, Java

当多个线程同时运行时,线程的调度由操作系统决定,程序本身无法决定。因此,任何一个线程都有可能在任何指令处被操作系统暂停,然后在某个时间段后继续执行。

这个时候,有个单线程模型下不存在的问题就来了:如果多个线程同时读写共享变量,会出现数据不一致的问题。

我们来看一个例子:

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
// 多线程
public class Main {
public static void main(String[] args) throws Exception {
var add = new AddThread();
var dec = new DecThread();
add.start();
dec.start();
add.join();
dec.join();
System.out.println(Counter.count);
}
}

class Counter {
public static int count = 0;
}

class AddThread extends Thread {
public void run() {
for (int i=0; i<10000; i++) { Counter.count += 1; }
}
}

class DecThread extends Thread {
public void run() {
for (int i=0; i<10000; i++) { Counter.count -= 1; }
}
}

上面的代码很简单,两个线程同时对一个int变量进行操作,一个加10000次,一个减10000次,最后结果应该是0,但是,每次运行,结果实际上都是不一样的。

这是因为对变量进行读取和写入时,结果要正确,必须保证是原子操作。原子操作是指不能被中断的一个或一系列操作。

例如,对于语句:

1
n = n + 1;

看上去是一行语句,实际上对应了3条指令:

1
2
3
ILOAD
IADD
ISTORE

我们假设n的值是100,如果两个线程同时执行n = n + 1,得到的结果很可能不是102,而是101,原因在于:

1
2
3
4
5
6
7
8
9
10
11
┌───────┐     ┌───────┐
│Thread1│ │Thread2│
└───┬───┘ └───┬───┘
│ │
│ILOAD (100) │
│ │ILOAD (100)
│ │IADD
│ │ISTORE (101)
│IADD │
│ISTORE (101) │
▼ ▼

如果线程1在执行ILOAD后被操作系统中断,此刻如果线程2被调度执行,它执行ILOAD后获取的值仍然是100,最终结果被两个线程的ISTORE写入后变成了101,而不是期待的102

这说明多线程模型下,要保证逻辑正确,对共享变量进行读写时,必须保证一组指令以原子方式执行:即某一个线程执行时,其他线程必须等待:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
┌───────┐     ┌───────┐
│Thread1│ │Thread2│
└───┬───┘ └───┬───┘
│ │
│-- lock -- │
│ILOAD (100) │
│IADD │
│ISTORE (101) │
│-- unlock -- │
│ │-- lock --
│ │ILOAD (101)
│ │IADD
│ │ISTORE (102)
│ │-- unlock --
▼ ▼

通过加锁和解锁的操作,就能保证3条指令总是在一个线程执行期间,不会有其他线程会进入此指令区间。即使在执行期线程被操作系统中断执行,其他线程也会因为无法获得锁导致无法进入此指令区间。只有执行线程将锁释放后,其他线程才有机会获得锁并执行。这种加锁和解锁之间的代码块我们称之为临界区(Critical Section),任何时候临界区最多只有一个线程能执行。

可见,保证一段代码的原子性就是通过加锁和解锁实现的。Java程序使用synchronized关键字对一个对象进行加锁:

1
2
3
synchronized(lock) {
n = n + 1;
}

synchronized保证了代码块在任意时刻最多只有一个线程能执行。我们把上面的代码用synchronized改写如下:

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
// 多线程
public class Main {
public static void main(String[] args) throws Exception {
var add = new AddThread();
var dec = new DecThread();
add.start();
dec.start();
add.join();
dec.join();
System.out.println(Counter.count);
}
}

class Counter {
public static final Object lock = new Object();
public static int count = 0;
}

class AddThread extends Thread {
public void run() {
for (int i=0; i<10000; i++) {
synchronized(Counter.lock) {
Counter.count += 1;
}
}
}
}

class DecThread extends Thread {
public void run() {
for (int i=0; i<10000; i++) {
synchronized(Counter.lock) {
Counter.count -= 1;
}
}
}
}

注意到代码:

1
2
3
synchronized(Counter.lock) { // 获取锁
...
} // 释放锁

它表示用Counter.lock实例作为锁,两个线程在执行各自的synchronized(Counter.lock) { ... }代码块时,必须先获得锁,才能进入代码块进行。执行结束后,在synchronized语句块结束会自动释放锁。这样一来,对Counter.count变量进行读写就不可能同时进行。上述代码无论运行多少次,最终结果都是0。

使用synchronized解决了多线程同步访问共享变量的正确性问题。但是,它的缺点是带来了性能下降。因为synchronized代码块无法并发执行。此外,加锁和解锁需要消耗一定的时间,所以,synchronized会降低程序的执行效率。

我们来概括一下如何使用synchronized

  1. 找出修改共享变量的线程代码块;
  2. 选择一个共享实例作为锁;
  3. 使用synchronized(lockObject) { ... }

在使用synchronized的时候,不必担心抛出异常。因为无论是否有异常,都会在synchronized结束处正确释放锁:

1
2
3
4
5
6
7
8
public void add(int m) {
synchronized (obj) {
if (m < 0) {
throw new RuntimeException();
}
this.value += m;
} // 无论有无异常,都会在此释放锁
}

我们再来看一个错误使用synchronized的例子:

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
// 多线程
public class Main {
public static void main(String[] args) throws Exception {
var add = new AddThread();
var dec = new DecThread();
add.start();
dec.start();
add.join();
dec.join();
System.out.println(Counter.count);
}
}

class Counter {
public static final Object lock1 = new Object();
public static final Object lock2 = new Object();
public static int count = 0;
}

class AddThread extends Thread {
public void run() {
for (int i=0; i<10000; i++) {
synchronized(Counter.lock1) {
Counter.count += 1;
}
}
}
}

class DecThread extends Thread {
public void run() {
for (int i=0; i<10000; i++) {
synchronized(Counter.lock2) {
Counter.count -= 1;
}
}
}
}

结果并不是0,这是因为两个线程各自的synchronized锁住的不是同一个对象!这使得两个线程各自都可以同时获得锁:因为JVM只保证同一个锁在任意时刻只能被一个线程获取,但两个不同的锁在同一时刻可以被两个线程分别获取。

因此,使用synchronized的时候,获取到的是哪个锁非常重要。锁对象如果不对,代码逻辑就不对。

我们再看一个例子:

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
// 多线程
public class Main {
public static void main(String[] args) throws Exception {
var ts = new Thread[] { new AddStudentThread(), new DecStudentThread(), new AddTeacherThread(), new DecTeacherThread() };
for (var t : ts) {
t.start();
}
for (var t : ts) {
t.join();
}
System.out.println(Counter.studentCount);
System.out.println(Counter.teacherCount);
}
}

class Counter {
public static final Object lock = new Object();
public static int studentCount = 0;
public static int teacherCount = 0;
}

class AddStudentThread extends Thread {
public void run() {
for (int i=0; i<10000; i++) {
synchronized(Counter.lock) {
Counter.studentCount += 1;
}
}
}
}

class DecStudentThread extends Thread {
public void run() {
for (int i=0; i<10000; i++) {
synchronized(Counter.lock) {
Counter.studentCount -= 1;
}
}
}
}

class AddTeacherThread extends Thread {
public void run() {
for (int i=0; i<10000; i++) {
synchronized(Counter.lock) {
Counter.teacherCount += 1;
}
}
}
}

class DecTeacherThread extends Thread {
public void run() {
for (int i=0; i<10000; i++) {
synchronized(Counter.lock) {
Counter.teacherCount -= 1;
}
}
}
}

上述代码的4个线程对两个共享变量分别进行读写操作,但是使用的锁都是Counter.lock这一个对象,这就造成了原本可以并发执行的Counter.studentCount += 1Counter.teacherCount += 1,现在无法并发执行了,执行效率大大降低。实际上,需要同步的线程可以分成两组:AddStudentThreadDecStudentThreadAddTeacherThreadDecTeacherThread,组之间不存在竞争,因此,应该使用两个不同的锁,即:

AddStudentThreadDecStudentThread使用lockStudent锁:

1
2
3
synchronized(Counter.lockStudent) {
...
}

AddTeacherThreadDecTeacherThread使用lockTeacher锁:

1
2
3
synchronized(Counter.lockTeacher) {
...
}

这样才能最大化地提高执行效率。

不需要synchronized的操作

JVM规范定义了几种原子操作:

  • 基本类型(longdouble除外)赋值,例如:int n = m
  • 引用类型赋值,例如:List<String> list = anotherList

longdouble是64位数据,JVM没有明确规定64位赋值操作是不是一个原子操作,不过在x64平台的JVM是把longdouble的赋值作为原子操作实现的。

单条原子操作的语句不需要同步。例如:

1
2
3
4
5
public void set(int m) {
synchronized(lock) {
this.value = m;
}
}

就不需要同步。

对引用也是类似。例如:

1
2
3
public void set(String s) {
this.value = s;
}

上述赋值语句并不需要同步。

但是,如果是多行赋值语句,就必须保证是同步操作,例如:

1
2
3
4
5
6
7
8
9
10
class Point {
int x;
int y;
public void set(int x, int y) {
synchronized(this) {
this.x = x;
this.y = y;
}
}
}

提示

多线程连续读写多个变量时,同步的目的是为了保证程序逻辑正确!

不但写需要同步,读也需要同步:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Point {
int x;
int y;

public void set(int x, int y) {
synchronized(this) {
this.x = x;
this.y = y;
}
}

public int[] get() {
int[] copy = new int[2];
copy[0] = x;
copy[1] = y;
}
}

假定当前坐标是(100, 200),那么当设置新坐标为(110, 220)时,上述未同步的多线程读到的值可能有:

  • (100, 200):x,y更新前;
  • (110, 200):x更新后,y更新前;
  • (110, 220):x,y更新后。

如果读取到(110, 200),即读到了更新后的x,更新前的y,那么可能会造成程序的逻辑错误,无法保证读取的多个变量状态保持一致。

有些时候,通过一些巧妙的转换,可以把非原子操作变为原子操作。例如,上述代码如果改造成:

1
2
3
4
5
6
7
class Point {
int[] ps;
public void set(int x, int y) {
int[] ps = new int[] { x, y };
this.ps = ps;
}
}

就不再需要写同步,因为this.ps = ps是引用赋值的原子操作。而语句:

1
int[] ps = new int[] { x, y };

这里的ps是方法内部定义的局部变量,每个线程都会有各自的局部变量,互不影响,并且互不可见,并不需要同步。

不过要注意,读方法在复制int[]数组的过程中仍然需要同步。

不可变对象无需同步

如果多线程读写的是一个不可变对象,那么无需同步,因为不会修改对象的状态:

1
2
3
4
5
6
7
8
9
class Data {
List<String> names;
void set(String[] names) {
this.names = List.of(names);
}
List<String> get() {
return this.names;
}
}

注意到set()方法内部创建了一个不可变List,这个List包含的对象也是不可变对象String,因此,整个List<String>对象都是不可变的,因此读写均无需同步。

分析变量是否能被多线程访问时,首先要理清概念,多线程同时执行的是方法。对于下面这个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Status {
List<String> names;
int x;
int y;
void set(String[] names, int n) {
List<String> ns = List.of(names);
this.names = ns;
int step = n * 10;
this.x += step;
this.y += step;
}
StatusRecord get() {
return new StatusRecord(this.names, this.x, this.y);
}
}

如果有A、B两个线程,同时执行是指:

  • 可能同时执行set();
  • 可能同时执行get();
  • 可能A执行set(),同时B执行get()。

类的成员变量namesxy显然能被多线程同时读写,但局部变量(包括方法参数)如果没有“逃逸”,那么只有当前线程可见。局部变量step仅在set()方法内部使用,因此每个线程同时执行set时都有一份独立的step存储在线程的栈上,互不影响,但是局部变量ns虽然每个线程也各有一份,但后续赋值后对其他线程就变成可见了。对set()方法同步时,如果要最小化synchronized代码块,可以改写如下:

1
2
3
4
5
6
7
8
9
10
void set(String[] names, int n) {
// 局部变量其他线程不可见:
List<String> ns = List.of(names);
int step = n * 10;
synchronized(this) {
this.names = ns;
this.x += step;
this.y += step;
}
}

因此,深入理解多线程还需理解变量在栈上的存储方式,基本类型和引用类型的存储方式也不同。

小结

多线程同时读写共享变量时,可能会造成逻辑错误,因此需要通过synchronized同步;

同步的本质就是给指定对象加锁,加锁后才能继续执行后续代码;

注意加锁对象必须是同一个实例;

对JVM定义的单个原子操作不需要同步。

我们知道Java程序依靠synchronized对线程进行同步,使用synchronized的时候,锁住的是哪个对象非常重要。

让线程自己选择锁对象往往会使得代码逻辑混乱,也不利于封装。更好的方法是把synchronized逻辑封装起来。例如,我们编写一个计数器如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class Counter {
private int count = 0;

public void add(int n) {
synchronized(this) {
count += n;
}
}

public void dec(int n) {
synchronized(this) {
count -= n;
}
}

public int get() {
return count;
}
}

这样一来,线程调用add()dec()方法时,它不必关心同步逻辑,因为synchronized代码块在add()dec()方法内部。并且,我们注意到,synchronized锁住的对象是this,即当前实例,这又使得创建多个Counter实例的时候,它们之间互不影响,可以并发执行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var c1 = Counter();
var c2 = Counter();

// 对c1进行操作的线程:
new Thread(() -> {
c1.add();
}).start();
new Thread(() -> {
c1.dec();
}).start();

// 对c2进行操作的线程:
new Thread(() -> {
c2.add();
}).start();
new Thread(() -> {
c2.dec();
}).start();

现在,对于Counter类,多线程可以正确调用。

如果一个类被设计为允许多线程正确访问,我们就说这个类就是“线程安全”的(thread-safe),上面的Counter类就是线程安全的。Java标准库的java.lang.StringBuffer也是线程安全的。

还有一些不变类,例如StringIntegerLocalDate,它们的所有成员变量都是final,多线程同时访问时只能读不能写,这些不变类也是线程安全的。

最后,类似Math这些只提供静态方法,没有成员变量的类,也是线程安全的。

除了上述几种少数情况,大部分类,例如ArrayList,都是非线程安全的类,我们不能在多线程中修改它们。但是,如果所有线程都只读取,不写入,那么ArrayList是可以安全地在线程间共享的。

我们再观察Counter的代码:

1
2
3
4
5
6
7
8
public class Counter {
public void add(int n) {
synchronized(this) {
count += n;
}
}
...
}

当我们锁住的是this实例时,实际上可以用synchronized修饰这个方法。下面两种写法是等价的:

1
2
3
4
5
public void add(int n) {
synchronized(this) { // 锁住this
count += n;
} // 解锁
}

写法二:

1
2
3
public synchronized void add(int n) { // 锁住this
count += n;
} // 解锁

因此,用synchronized修饰的方法就是同步方法,它表示整个方法都必须用this实例加锁。

我们再思考一下,如果对一个静态方法添加synchronized修饰符,它锁住的是哪个对象?

1
2
3
public synchronized static void test(int n) {
...
}

对于static方法,是没有this实例的,因为static方法是针对类而不是实例。但是我们注意到任何一个类都有一个由JVM自动创建的Class实例,因此,对static方法添加synchronized,锁住的是该类的Class实例。上述synchronized static方法实际上相当于:

1
2
3
4
5
6
7
public class Counter {
public static void test(int n) {
synchronized(Counter.class) {
...
}
}
}

我们再考察Counterget()方法:

1
2
3
4
5
6
7
8
public class Counter {
private int count;

public int get() {
return count;
}
...
}

它没有同步,因为读一个int变量不需要同步。

然而,如果我们把代码稍微改一下,返回一个包含两个int的对象:

1
2
3
4
5
6
7
8
9
10
11
12
public class Counter {
private int first;
private int last;

public Pair get() {
Pair p = new Pair();
p.first = first;
p.last = last;
return p;
}
...
}

就必须要同步了。

小结

synchronized修饰方法可以把整个方法变为同步代码块,synchronized方法加锁对象是this

通过合理的设计和数据封装可以让一个类变为“线程安全”;

一个类没有特殊说明,默认不是thread-safe;

多线程能否安全访问某个非线程安全的实例,需要具体问题具体分析。

死锁

Java的线程锁是可重入的锁。

什么是可重入的锁?我们还是来看例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Counter {
private int count = 0;

public synchronized void add(int n) {
if (n < 0) {
dec(-n);
} else {
count += n;
}
}

public synchronized void dec(int n) {
count += n;
}
}

观察synchronized修饰的add()方法,一旦线程执行到add()方法内部,说明它已经获取了当前实例的this锁。如果传入的n < 0,将在add()方法内部调用dec()方法。由于dec()方法也需要获取this锁,现在问题来了:

对同一个线程,能否在获取到锁以后继续获取同一个锁?

答案是肯定的。JVM允许同一个线程重复获取同一个锁,这种能被同一个线程反复获取的锁,就叫做可重入锁

由于Java的线程锁是可重入锁,所以,获取锁的时候,不但要判断是否是第一次获取,还要记录这是第几次获取。每获取一次锁,记录+1,每退出synchronized块,记录-1,减到0的时候,才会真正释放锁。

死锁

一个线程可以获取一个锁后,再继续获取另一个锁。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public void add(int m) {
synchronized(lockA) { // 获得lockA的锁
this.value += m;
synchronized(lockB) { // 获得lockB的锁
this.another += m;
} // 释放lockB的锁
} // 释放lockA的锁
}

public void dec(int m) {
synchronized(lockB) { // 获得lockB的锁
this.another -= m;
synchronized(lockA) { // 获得lockA的锁
this.value -= m;
} // 释放lockA的锁
} // 释放lockB的锁
}

在获取多个锁的时候,不同线程获取多个不同对象的锁可能导致死锁。对于上述代码,线程1和线程2如果分别执行add()dec()方法时:

  • 线程1:进入add(),获得lockA
  • 线程2:进入dec(),获得lockB

随后:

  • 线程1:准备获得lockB,失败,等待中;
  • 线程2:准备获得lockA,失败,等待中。

此时,两个线程各自持有不同的锁,然后各自试图获取对方手里的锁,造成了双方无限等待下去,这就是死锁。

死锁发生后,没有任何机制能解除死锁,只能强制结束JVM进程。

因此,在编写多线程应用时,要特别注意防止死锁。因为死锁一旦形成,就只能强制结束进程。

那么我们应该如何避免死锁呢?答案是:线程获取锁的顺序要一致。即严格按照先获取lockA,再获取lockB的顺序,改写dec()方法如下:

1
2
3
4
5
6
7
8
public void dec(int m) {
synchronized(lockA) { // 获得lockA的锁
this.value -= m;
synchronized(lockB) { // 获得lockB的锁
this.another -= m;
} // 释放lockB的锁
} // 释放lockA的锁
}

练习

请观察死锁的代码输出,然后修复。

下载练习

小结

Java的synchronized锁是可重入锁;

死锁产生的条件是多线程各自持有不同的锁,并互相试图获取对方已持有的锁,导致无限等待;

避免死锁的方法是多线程获取锁的顺序要一致。



在Java程序中,synchronized解决了多线程竞争的问题。例如,对于一个任务管理器,多个线程同时往队列中添加任务,可以用synchronized加锁:

1
2
3
4
5
6
7
class TaskQueue {
Queue<String> queue = new LinkedList<>();

public synchronized void addTask(String s) {
this.queue.add(s);
}
}

但是synchronized并没有解决多线程协调的问题。

仍然以上面的TaskQueue为例,我们再编写一个getTask()方法取出队列的第一个任务:

1
2
3
4
5
6
7
8
9
10
11
12
13
class TaskQueue {
Queue<String> queue = new LinkedList<>();

public synchronized void addTask(String s) {
this.queue.add(s);
}

public synchronized String getTask() {
while (queue.isEmpty()) {
}
return queue.remove();
}
}

上述代码看上去没有问题:getTask()内部先判断队列是否为空,如果为空,就循环等待,直到另一个线程往队列中放入了一个任务,while()循环退出,就可以返回队列的元素了。

但实际上while()循环永远不会退出。因为线程在执行while()循环时,已经在getTask()入口获取了this锁,其他线程根本无法调用addTask(),因为addTask()执行条件也是获取this锁。

因此,执行上述代码,线程会在getTask()中因为死循环而100%占用CPU资源。

如果深入思考一下,我们想要的执行效果是:

  • 线程1可以调用addTask()不断往队列中添加任务;
  • 线程2可以调用getTask()从队列中获取任务。如果队列为空,则getTask()应该等待,直到队列中至少有一个任务时再返回。

因此,多线程协调运行的原则就是:当条件不满足时,线程进入等待状态;当条件满足时,线程被唤醒,继续执行任务。

对于上述TaskQueue,我们先改造getTask()方法,在条件不满足时,线程进入等待状态:

1
2
3
4
5
6
public synchronized String getTask() {
while (queue.isEmpty()) {
this.wait();
}
return queue.remove();
}

当一个线程执行到getTask()方法内部的while循环时,它必定已经获取到了this锁,此时,线程执行while条件判断,如果条件成立(队列为空),线程将执行this.wait(),进入等待状态。

这里的关键是:wait()方法必须在当前获取的锁对象上调用,这里获取的是this锁,因此调用this.wait()

调用wait()方法后,线程进入等待状态,wait()方法不会返回,直到将来某个时刻,线程从等待状态被其他线程唤醒后,wait()方法才会返回,然后,继续执行下一条语句。

有些仔细的童鞋会指出:即使线程在getTask()内部等待,其他线程如果拿不到this锁,照样无法执行addTask(),肿么办?

这个问题的关键就在于wait()方法的执行机制非常复杂。首先,它不是一个普通的Java方法,而是定义在Object类的一个native方法,也就是由JVM的C代码实现的。其次,必须在synchronized块中才能调用wait()方法,因为wait()方法调用时,会释放线程获得的锁,wait()方法返回时,线程又会重新试图获得锁。

因此,只能在锁对象上调用wait()方法。因为在getTask()中,我们获得了this锁,因此,只能在this对象上调用wait()方法:

1
2
3
4
5
6
7
8
public synchronized String getTask() {
while (queue.isEmpty()) {
// 释放this锁:
this.wait();
// 重新获取this锁
}
return queue.remove();
}

当一个线程在this.wait()等待时,它就会释放this锁,从而使得其他线程能够在addTask()方法获得this锁。

现在我们面临第二个问题:如何让等待的线程被重新唤醒,然后从wait()方法返回?答案是在相同的锁对象上调用notify()方法。我们修改addTask()如下:

1
2
3
4
public synchronized void addTask(String s) {
this.queue.add(s);
this.notify(); // 唤醒在this锁等待的线程
}

注意到在往队列中添加了任务后,线程立刻对this锁对象调用notify()方法,这个方法会唤醒一个正在this锁等待的线程(就是在getTask()中位于this.wait()的线程),从而使得等待线程从this.wait()方法返回。

我们来看一个完整的例子:

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
import java.util.*;

public class Main {
public static void main(String[] args) throws InterruptedException {
var q = new TaskQueue();
var ts = new ArrayList<Thread>();
for (int i=0; i<5; i++) {
var t = new Thread() {
public void run() {
// 执行task:
while (true) {
try {
String s = q.getTask();
System.out.println("execute task: " + s);
} catch (InterruptedException e) {
return;
}
}
}
};
t.start();
ts.add(t);
}
var add = new Thread(() -> {
for (int i=0; i<10; i++) {
// 放入task:
String s = "t-" + Math.random();
System.out.println("add task: " + s);
q.addTask(s);
try { Thread.sleep(100); } catch(InterruptedException e) {}
}
});
add.start();
add.join();
Thread.sleep(100);
for (var t : ts) {
t.interrupt();
}
}
}

class TaskQueue {
Queue<String> queue = new LinkedList<>();

public synchronized void addTask(String s) {
this.queue.add(s);
this.notifyAll();
}

public synchronized String getTask() throws InterruptedException {
while (queue.isEmpty()) {
this.wait();
}
return queue.remove();
}
}

这个例子中,我们重点关注addTask()方法,内部调用了this.notifyAll()而不是this.notify(),使用notifyAll()将唤醒所有当前正在this锁等待的线程,而notify()只会唤醒其中一个(具体哪个依赖操作系统,有一定的随机性)。这是因为可能有多个线程正在getTask()方法内部的wait()中等待,使用notifyAll()将一次性全部唤醒。通常来说,notifyAll()更安全。有些时候,如果我们的代码逻辑考虑不周,用notify()会导致只唤醒了一个线程,而其他线程可能永远等待下去醒不过来了。

但是,注意到wait()方法返回时需要重新获得this锁。假设当前有3个线程被唤醒,唤醒后,首先要等待执行addTask()的线程结束此方法后,才能释放this锁,随后,这3个线程中只能有一个获取到this锁,剩下两个将继续等待。

再注意到我们在while()循环中调用wait(),而不是if语句:

1
2
3
4
5
6
public synchronized String getTask() throws InterruptedException {
if (queue.isEmpty()) {
this.wait();
}
return queue.remove();
}

这种写法实际上是错误的,因为线程被唤醒时,需要再次获取this锁。多个线程被唤醒后,只有一个线程能获取this锁,此刻,该线程执行queue.remove()可以获取到队列的元素,然而,剩下的线程如果获取this锁后执行queue.remove(),此刻队列可能已经没有任何元素了,所以,要始终在while循环中wait(),并且每次被唤醒后拿到this锁就必须再次判断:

1
2
3
while (queue.isEmpty()) {
this.wait();
}

所以,正确编写多线程代码是非常困难的,需要仔细考虑的条件非常多,任何一个地方考虑不周,都会导致多线程运行时不正常。

multithread

小结

waitnotify用于多线程协调运行:

  • synchronized内部可以调用wait()使线程进入等待状态;
  • 必须在已获得的锁对象上调用wait()方法;
  • synchronized内部可以调用notify()notifyAll()唤醒其他等待线程;
  • 必须在已获得的锁对象上调用notify()notifyAll()方法;
  • 已唤醒的线程还需要重新获得锁后才能继续执行。

使用ReentrantLock

从Java 5开始,引入了一个高级的处理并发的java.util.concurrent包,它提供了大量更高级的并发功能,能大大简化多线程程序的编写。

我们知道Java语言直接提供了synchronized关键字用于加锁,但这种锁一是很重,二是获取时必须一直等待,没有额外的尝试机制。

java.util.concurrent.locks包提供的ReentrantLock用于替代synchronized加锁,我们来看一下传统的synchronized代码:

1
2
3
4
5
6
7
8
9
public class Counter {
private int count;

public void add(int n) {
synchronized(this) {
count += n;
}
}
}

如果用ReentrantLock替代,可以把代码改造为:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Counter {
private final Lock lock = new ReentrantLock();
private int count;

public void add(int n) {
lock.lock();
try {
count += n;
} finally {
lock.unlock();
}
}
}

因为synchronized是Java语言层面提供的语法,所以我们不需要考虑异常,而ReentrantLock是Java代码实现的锁,我们就必须先获取锁,然后在finally中正确释放锁。

顾名思义,ReentrantLock是可重入锁,它和synchronized一样,一个线程可以多次获取同一个锁。

synchronized不同的是,ReentrantLock可以尝试获取锁:

1
2
3
4
5
6
7
if (lock.tryLock(1, TimeUnit.SECONDS)) {
try {
...
} finally {
lock.unlock();
}
}

上述代码在尝试获取锁的时候,最多等待1秒。如果1秒后仍未获取到锁,tryLock()返回false,程序就可以做一些额外处理,而不是无限等待下去。

所以,使用ReentrantLock比直接使用synchronized更安全,线程在tryLock()失败的时候不会导致死锁。

小结

ReentrantLock可以替代synchronized进行同步;

ReentrantLock获取锁更安全;

必须先获取到锁,再进入try {...}代码块,最后使用finally保证释放锁;

可以使用tryLock()尝试获取锁。



使用Condition

使用ReentrantLock比直接使用synchronized更安全,可以替代synchronized进行线程同步。

但是,synchronized可以配合waitnotify实现线程在条件不满足时等待,条件满足时唤醒,用ReentrantLock我们怎么编写waitnotify的功能呢?

答案是使用Condition对象来实现waitnotify的功能。

我们仍然以TaskQueue为例,把前面用synchronized实现的功能通过ReentrantLockCondition来实现:

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
class TaskQueue {
private final Lock lock = new ReentrantLock();
private final Condition condition = lock.newCondition();
private Queue<String> queue = new LinkedList<>();

public void addTask(String s) {
lock.lock();
try {
queue.add(s);
condition.signalAll();
} finally {
lock.unlock();
}
}

public String getTask() {
lock.lock();
try {
while (queue.isEmpty()) {
condition.await();
}
return queue.remove();
} finally {
lock.unlock();
}
}
}

可见,使用Condition时,引用的Condition对象必须从Lock实例的newCondition()返回,这样才能获得一个绑定了Lock实例的Condition实例。

Condition提供的await()signal()signalAll()原理和synchronized锁对象的wait()notify()notifyAll()是一致的,并且其行为也是一样的:

  • await()会释放当前锁,进入等待状态;
  • signal()会唤醒某个等待线程;
  • signalAll()会唤醒所有等待线程;
  • 唤醒线程从await()返回后需要重新获得锁。

此外,和tryLock()类似,await()可以在等待指定时间后,如果还没有被其他线程通过signal()signalAll()唤醒,可以自己醒来:

1
2
3
4
5
if (condition.await(1, TimeUnit.SECOND)) {
// 被其他线程唤醒
} else {
// 指定时间内没有被其他线程唤醒
}

可见,使用Condition配合Lock,我们可以实现更灵活的线程同步。

小结

Condition可以替代waitnotify

Condition对象必须从Lock对象获取。



使用ReadWriteLock

前面讲到的ReentrantLock保证了只有一个线程可以执行临界区代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class Counter {
private final Lock lock = new ReentrantLock();
private int[] counts = new int[10];

public void inc(int index) {
lock.lock();
try {
counts[index] += 1;
} finally {
lock.unlock();
}
}

public int[] get() {
lock.lock();
try {
return Arrays.copyOf(counts, counts.length);
} finally {
lock.unlock();
}
}
}

但是有些时候,这种保护有点过头。因为我们发现,任何时刻,只允许一个线程修改,也就是调用inc()方法是必须获取锁,但是,get()方法只读取数据,不修改数据,它实际上允许多个线程同时调用。

实际上我们想要的是:允许多个线程同时读,但只要有一个线程在写,其他线程就必须等待:

允许 不允许
不允许 不允许

使用ReadWriteLock可以解决这个问题,它保证:

  • 只允许一个线程写入(其他线程既不能写入也不能读取);
  • 没有写入时,多个线程允许同时读(提高性能)。

ReadWriteLock实现这个功能十分容易。我们需要创建一个ReadWriteLock实例,然后分别获取读锁和写锁:

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
public class Counter {
private final ReadWriteLock rwlock = new ReentrantReadWriteLock();
// 注意: 一对读锁和写锁必须从同一个rwlock获取:
private final Lock rlock = rwlock.readLock();
private final Lock wlock = rwlock.writeLock();
private int[] counts = new int[10];

public void inc(int index) {
wlock.lock(); // 加写锁
try {
counts[index] += 1;
} finally {
wlock.unlock(); // 释放写锁
}
}

public int[] get() {
rlock.lock(); // 加读锁
try {
return Arrays.copyOf(counts, counts.length);
} finally {
rlock.unlock(); // 释放读锁
}
}
}

把读写操作分别用读锁和写锁来加锁,在读取时,多个线程可以同时获得读锁,这样就大大提高了并发读的执行效率。

使用ReadWriteLock时,适用条件是同一个数据,有大量线程读取,但仅有少数线程修改。

例如,一个论坛的帖子,回复可以看做写入操作,它是不频繁的,但是,浏览可以看做读取操作,是非常频繁的,这种情况就可以使用ReadWriteLock

小结

使用ReadWriteLock可以提高读取效率:

  • ReadWriteLock只允许一个线程写入;
  • ReadWriteLock允许多个线程在没有写入时同时读取;
  • ReadWriteLock适合读多写少的场景。


前面介绍的ReadWriteLock可以解决多线程同时读,但只有一个线程能写的问题。

如果我们深入分析ReadWriteLock,会发现它有个潜在的问题:如果有线程正在读,写线程需要等待读线程释放锁后才能获取写锁,即读的过程中不允许写,这是一种悲观的读锁。

要进一步提升并发执行效率,Java 8引入了新的读写锁:StampedLock

StampedLockReadWriteLock相比,改进之处在于:读的过程中也允许获取写锁后写入!这样一来,我们读的数据就可能不一致,所以,需要一点额外的代码来判断读的过程中是否有写入,这种读锁是一种乐观锁。

乐观锁的意思就是乐观地估计读的过程中大概率不会有写入,因此被称为乐观锁。反过来,悲观锁则是读的过程中拒绝有写入,也就是写入必须等待。显然乐观锁的并发效率更高,但一旦有小概率的写入导致读取的数据不一致,需要能检测出来,再读一遍就行。

我们来看例子:

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
public class Point {
private final StampedLock stampedLock = new StampedLock();

private double x;
private double y;

public void move(double deltaX, double deltaY) {
long stamp = stampedLock.writeLock(); // 获取写锁
try {
x += deltaX;
y += deltaY;
} finally {
stampedLock.unlockWrite(stamp); // 释放写锁
}
}

public double distanceFromOrigin() {
long stamp = stampedLock.tryOptimisticRead(); // 获得一个乐观读锁
// 注意下面两行代码不是原子操作
// 假设x,y = (100,200)
double currentX = x;
// 此处已读取到x=100,但x,y可能被写线程修改为(300,400)
double currentY = y;
// 此处已读取到y,如果没有写入,读取是正确的(100,200)
// 如果有写入,读取是错误的(100,400)
if (!stampedLock.validate(stamp)) { // 检查乐观读锁后是否有其他写锁发生
stamp = stampedLock.readLock(); // 获取一个悲观读锁
try {
currentX = x;
currentY = y;
} finally {
stampedLock.unlockRead(stamp); // 释放悲观读锁
}
}
return Math.sqrt(currentX * currentX + currentY * currentY);
}
}

ReadWriteLock相比,写入的加锁是完全一样的,不同的是读取。注意到首先我们通过tryOptimisticRead()获取一个乐观读锁,并返回版本号。接着进行读取,读取完成后,我们通过validate()去验证版本号,如果在读取过程中没有写入,版本号不变,验证成功,我们就可以放心地继续后续操作。如果在读取过程中有写入,版本号会发生变化,验证将失败。在失败的时候,我们再通过获取悲观读锁再次读取。由于写入的概率不高,程序在绝大部分情况下可以通过乐观读锁获取数据,极少数情况下使用悲观读锁获取数据。

可见,StampedLock把读锁细分为乐观读和悲观读,能进一步提升并发效率。但这也是有代价的:一是代码更加复杂,二是StampedLock是不可重入锁,不能在一个线程中反复获取同一个锁。

StampedLock还提供了更复杂的将悲观读锁升级为写锁的功能,它主要使用在if-then-update的场景:即先读,如果读的数据满足条件,就返回,如果读的数据不满足条件,再尝试写。

小结

StampedLock提供了乐观读锁,可取代ReadWriteLock以进一步提升并发性能;

StampedLock是不可重入锁。

使用Semaphore

前面我们讲了各种锁的实现,本质上锁的目的是保护一种受限资源,保证同一时刻只有一个线程能访问(ReentrantLock),或者只有一个线程能写入(ReadWriteLock)。

还有一种受限资源,它需要保证同一时刻最多有N个线程能访问,比如同一时刻最多创建100个数据库连接,最多允许10个用户下载等。

这种限制数量的锁,如果用Lock数组来实现,就太麻烦了。

这种情况就可以使用Semaphore,例如,最多允许3个线程同时访问:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class AccessLimitControl {
// 任意时刻仅允许最多3个线程获取许可:
final Semaphore semaphore = new Semaphore(3);

public String access() throws Exception {
// 如果超过了许可数量,其他线程将在此等待:
semaphore.acquire();
try {
// TODO:
return UUID.randomUUID().toString();
} finally {
semaphore.release();
}
}
}

使用Semaphore先调用acquire()获取,然后通过try ... finally保证在finally中释放。

调用acquire()可能会进入等待,直到满足条件为止。也可以使用tryAcquire()指定等待时间:

1
2
3
4
5
6
7
8
if (semaphore.tryAcquire(3, TimeUnit.SECONDS)) {
// 指定等待时间3秒内获取到许可:
try {
// TODO:
} finally {
semaphore.release();
}
}

Semaphore本质上就是一个信号计数器,用于限制同一时间的最大访问数量。

小结

如果要对某一受限资源进行限流访问,可以使用Semaphore,保证同一时间最多N个线程访问受限资源。



使用Concurrent集合

我们在前面已经通过ReentrantLockCondition实现了一个BlockingQueue

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
public class TaskQueue {
private final Lock lock = new ReentrantLock();
private final Condition condition = lock.newCondition();
private Queue<String> queue = new LinkedList<>();

public void addTask(String s) {
lock.lock();
try {
queue.add(s);
condition.signalAll();
} finally {
lock.unlock();
}
}

public String getTask() {
lock.lock();
try {
while (queue.isEmpty()) {
condition.await();
}
return queue.remove();
} finally {
lock.unlock();
}
}
}

BlockingQueue的意思就是说,当一个线程调用这个TaskQueuegetTask()方法时,该方法内部可能会让线程变成等待状态,直到队列条件满足不为空,线程被唤醒后,getTask()方法才会返回。

因为BlockingQueue非常有用,所以我们不必自己编写,可以直接使用Java标准库的java.util.concurrent包提供的线程安全的集合:ArrayBlockingQueue

除了BlockingQueue外,针对ListMapSetDeque等,java.util.concurrent包也提供了对应的并发集合类。我们归纳一下:

interface non-thread-safe thread-safe
List ArrayList CopyOnWriteArrayList
Map HashMap ConcurrentHashMap
Set HashSet / TreeSet CopyOnWriteArraySet
Queue ArrayDeque / LinkedList ArrayBlockingQueue / LinkedBlockingQueue
Deque ArrayDeque / LinkedList LinkedBlockingDeque

使用这些并发集合与使用非线程安全的集合类完全相同。我们以ConcurrentHashMap为例:

1
2
3
4
5
Map<String, String> map = new ConcurrentHashMap<>();
// 在不同的线程读写:
map.put("A", "1");
map.put("B", "2");
map.get("A", "1");

因为所有的同步和加锁的逻辑都在集合内部实现,对外部调用者来说,只需要正常按接口引用,其他代码和原来的非线程安全代码完全一样。即当我们需要多线程访问时,把:

1
Map<String, String> map = new HashMap<>();

改为:

1
Map<String, String> map = new ConcurrentHashMap<>();

就可以了。

java.util.Collections工具类还提供了一个旧的线程安全集合转换器,可以这么用:

1
2
Map unsafeMap = new HashMap();
Map threadSafeMap = Collections.synchronizedMap(unsafeMap);

但是它实际上是用一个包装类包装了非线程安全的Map,然后对所有读写方法都用synchronized加锁,这样获得的线程安全集合的性能比java.util.concurrent集合要低很多,所以不推荐使用。

小结

使用java.util.concurrent包提供的线程安全的并发集合可以大大简化多线程编程:

多线程同时读写并发集合是安全的;

尽量使用Java标准库提供的并发集合,避免自己编写同步代码。



使用Atomic

Java的java.util.concurrent包除了提供底层锁、并发集合外,还提供了一组原子操作的封装类,它们位于java.util.concurrent.atomic包。

我们以AtomicInteger为例,它提供的主要操作有:

  • 增加值并返回新值:int addAndGet(int delta)
  • 加1后返回新值:int incrementAndGet()
  • 获取当前值:int get()
  • 用CAS方式设置:int compareAndSet(int expect, int update)

Atomic类是通过无锁(lock-free)的方式实现的线程安全(thread-safe)访问。它的主要原理是利用了CAS:Compare and Set。

如果我们自己通过CAS编写incrementAndGet(),它大概长这样:

1
2
3
4
5
6
7
8
public int incrementAndGet(AtomicInteger var) {
int prev, next;
do {
prev = var.get();
next = prev + 1;
} while ( ! var.compareAndSet(prev, next));
return next;
}

CAS是指,在这个操作中,如果AtomicInteger的当前值是prev,那么就更新为next,返回true。如果AtomicInteger的当前值不是prev,就什么也不干,返回false。通过CAS操作并配合do ... while循环,即使其他线程修改了AtomicInteger的值,最终的结果也是正确的。

我们利用AtomicLong可以编写一个多线程安全的全局唯一ID生成器:

1
2
3
4
5
6
7
class IdGenerator {
AtomicLong var = new AtomicLong(0);

public long getNextId() {
return var.incrementAndGet();
}
}

通常情况下,我们并不需要直接用do ... while循环调用compareAndSet实现复杂的并发操作,而是用incrementAndGet()这样的封装好的方法,因此,使用起来非常简单。

在高度竞争的情况下,还可以使用Java 8提供的LongAdderLongAccumulator

小结

使用java.util.concurrent.atomic提供的原子操作可以简化多线程编程:

  • 原子操作实现了无锁的线程安全;
  • 适用于计数器,累加器等。


留言與分享

JAVA-多线程1

分類 编程语言, Java

现代操作系统(Windows,macOS,Linux)都可以执行多任务。多任务就是同时运行多个任务,例如:

multitask

CPU执行代码都是一条一条顺序执行的,但是,即使是单核cpu,也可以同时运行多个任务。因为操作系统执行多任务实际上就是让CPU对多个任务轮流交替执行。

例如,假设我们有语文、数学、英语3门作业要做,每个作业需要30分钟。我们把这3门作业看成是3个任务,可以做1分钟语文作业,再做1分钟数学作业,再做1分钟英语作业:

hurry

这样轮流做下去,在某些人眼里看来,做作业的速度就非常快,看上去就像同时在做3门作业一样

ooops

类似的,操作系统轮流让多个任务交替执行,例如,让浏览器执行0.001秒,让QQ执行0.001秒,再让音乐播放器执行0.001秒,在人看来,CPU就是在同时执行多个任务。

即使是多核CPU,因为通常任务的数量远远多于CPU的核数,所以任务也是交替执行的。

进程

在计算机中,我们把一个任务称为一个进程,浏览器就是一个进程,视频播放器是另一个进程,类似的,音乐播放器和Word都是进程。

某些进程内部还需要同时执行多个子任务。例如,我们在使用Word时,Word可以让我们一边打字,一边进行拼写检查,同时还可以在后台进行打印,我们把子任务称为线程。

进程和线程的关系就是:一个进程可以包含一个或多个线程,但至少会有一个线程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
                        ┌──────────┐
│Process │
│┌────────┐│
┌──────────┐││ Thread ││┌──────────┐
│Process ││└────────┘││Process │
│┌────────┐││┌────────┐││┌────────┐│
┌──────────┐││ Thread ││││ Thread ││││ Thread ││
│Process ││└────────┘││└────────┘││└────────┘│
│┌────────┐││┌────────┐││┌────────┐││┌────────┐│
││ Thread ││││ Thread ││││ Thread ││││ Thread ││
│└────────┘││└────────┘││└────────┘││└────────┘│
└──────────┘└──────────┘└──────────┘└──────────┘
┌──────────────────────────────────────────────┐
│ Operating System │
└──────────────────────────────────────────────┘

操作系统调度的最小任务单位其实不是进程,而是线程。常用的Windows、Linux等操作系统都采用抢占式多任务,如何调度线程完全由操作系统决定,程序自己不能决定什么时候执行,以及执行多长时间。

因为同一个应用程序,既可以有多个进程,也可以有多个线程,因此,实现多任务的方法,有以下几种:

多进程模式(每个进程只有一个线程):

1
2
3
4
5
6
┌──────────┐ ┌──────────┐ ┌──────────┐
│Process │ │Process │ │Process │
│┌────────┐│ │┌────────┐│ │┌────────┐│
││ Thread ││ ││ Thread ││ ││ Thread ││
│└────────┘│ │└────────┘│ │└────────┘│
└──────────┘ └──────────┘ └──────────┘

多线程模式(一个进程有多个线程):

1
2
3
4
5
6
7
8
9
┌────────────────────┐
│Process │
│┌────────┐┌────────┐│
││ Thread ││ Thread ││
│└────────┘└────────┘│
│┌────────┐┌────────┐│
││ Thread ││ Thread ││
│└────────┘└────────┘│
└────────────────────┘

多进程+多线程模式(复杂度最高):

1
2
3
4
5
6
7
8
9
┌──────────┐┌──────────┐┌──────────┐
│Process ││Process ││Process │
│┌────────┐││┌────────┐││┌────────┐│
││ Thread ││││ Thread ││││ Thread ││
│└────────┘││└────────┘││└────────┘│
│┌────────┐││┌────────┐││┌────────┐│
││ Thread ││││ Thread ││││ Thread ││
│└────────┘││└────────┘││└────────┘│
└──────────┘└──────────┘└──────────┘

进程 vs 线程

进程和线程是包含关系,但是多任务既可以由多进程实现,也可以由单进程内的多线程实现,还可以混合多进程+多线程。

具体采用哪种方式,要考虑到进程和线程的特点。

和多线程相比,多进程的缺点在于:

  • 创建进程比创建线程开销大,尤其是在Windows系统上;
  • 进程间通信比线程间通信要慢,因为线程间通信就是读写同一个变量,速度很快。

而多进程的优点在于:

多进程稳定性比多线程高,因为在多进程的情况下,一个进程崩溃不会影响其他进程,而在多线程的情况下,任何一个线程崩溃会直接导致整个进程崩溃。

多线程

Java语言内置了多线程支持:一个Java程序实际上是一个JVM进程,JVM进程用一个主线程来执行main()方法,在main()方法内部,我们又可以启动多个线程。此外,JVM还有负责垃圾回收的其他工作线程等。

因此,对于大多数Java程序来说,我们说多任务,实际上是说如何使用多线程实现多任务。

和单线程相比,多线程编程的特点在于:多线程经常需要读写共享数据,并且需要同步。例如,播放电影时,就必须由一个线程播放视频,另一个线程播放音频,两个线程需要协调运行,否则画面和声音就不同步。因此,多线程编程的复杂度高,调试更困难。

Java多线程编程的特点又在于:

  • 多线程模型是Java程序最基本的并发模型;
  • 后续读写网络、数据库、Web开发等都依赖Java多线程模型。

因此,必须掌握Java多线程编程才能继续深入学习其他内容。

Java语言内置了多线程支持。当Java程序启动的时候,实际上是启动了一个JVM进程,然后,JVM启动主线程来执行main()方法。在main()方法中,我们又可以启动其他线程。

要创建一个新线程非常容易,我们需要实例化一个Thread实例,然后调用它的start()方法:

1
2
3
4
5
6
7
// 多线程
public class Main {
public static void main(String[] args) {
Thread t = new Thread();
t.start(); // 启动新线程
}
}

但是这个线程启动后实际上什么也不做就立刻结束了。我们希望新线程能执行指定的代码,有以下几种方法:

方法一:从Thread派生一个自定义类,然后覆写run()方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 多线程
public class Main {
public static void main(String[] args) {
Thread t = new MyThread();
t.start(); // 启动新线程
}
}

class MyThread extends Thread {
@Override
public void run() {
System.out.println("start new thread!");
}
}

执行上述代码,注意到start()方法会在内部自动调用实例的run()方法。

方法二:创建Thread实例时,传入一个Runnable实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 多线程
public class Main {
public static void main(String[] args) {
Thread t = new Thread(new MyRunnable());
t.start(); // 启动新线程
}
}

class MyRunnable implements Runnable {
@Override
public void run() {
System.out.println("start new thread!");
}
}

或者用Java 8引入的lambda语法进一步简写为:

1
2
3
4
5
6
7
8
9
// 多线程
public class Main {
public static void main(String[] args) {
Thread t = new Thread(() -> {
System.out.println("start new thread!");
});
t.start(); // 启动新线程
}
}

有童鞋会问,使用线程执行的打印语句,和直接在main()方法执行有区别吗?

区别大了去了。我们看以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Main {
public static void main(String[] args) {
System.out.println("main start...");
Thread t = new Thread() {
public void run() {
System.out.println("thread run...");
System.out.println("thread end.");
}
};
t.start();
System.out.println("main end...");
}
}

我们用蓝色表示主线程,也就是main线程,main线程执行的代码有4行,首先打印main start,然后创建Thread对象,紧接着调用start()启动新线程。当start()方法被调用时,JVM就创建了一个新线程,我们通过实例变量t来表示这个新线程对象,并开始执行。

接着,main线程继续执行打印main end语句,而t线程在main线程执行的同时会并发执行,打印thread runthread end语句。

run()方法结束时,新线程就结束了。而main()方法结束时,主线程也结束了。

我们再来看线程的执行顺序:

  1. main线程肯定是先打印main start,再打印main end
  2. t线程肯定是先打印thread run,再打印thread end

但是,除了可以肯定,main start会先打印外,main end打印在thread run之前、thread end之后或者之间,都无法确定。因为从t线程开始运行以后,两个线程就开始同时运行了,并且由操作系统调度,程序本身无法确定线程的调度顺序。

要模拟并发执行的效果,我们可以在线程中调用Thread.sleep(),强迫当前线程暂停一段时间:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 多线程
public class Main {
public static void main(String[] args) {
System.out.println("main start...");
Thread t = new Thread() {
public void run() {
System.out.println("thread run...");
try {
Thread.sleep(10);
} catch (InterruptedException e) {}
System.out.println("thread end.");
}
};
t.start();
try {
Thread.sleep(20);
} catch (InterruptedException e) {}
System.out.println("main end...");
}
}

sleep()传入的参数是毫秒。调整暂停时间的大小,我们可以看到main线程和t线程执行的先后顺序。

要特别注意:直接调用Thread实例的run()方法是无效的:

1
2
3
4
5
6
7
8
9
10
11
12
public class Main {
public static void main(String[] args) {
Thread t = new MyThread();
t.run();
}
}

class MyThread extends Thread {
public void run() {
System.out.println("hello");
}
}

直接调用run()方法,相当于调用了一个普通的Java方法,当前线程并没有任何改变,也不会启动新线程。上述代码实际上是在main()方法内部又调用了run()方法,打印hello语句是在main线程中执行的,没有任何新线程被创建。

必须调用Thread实例的start()方法才能启动新线程,如果我们查看Thread类的源代码,会看到start()方法内部调用了一个private native void start0()方法,native修饰符表示这个方法是由JVM虚拟机内部的C代码实现的,不是由Java代码实现的。

线程的优先级

可以对线程设定优先级,设定优先级的方法是:

1
Thread.setPriority(int n) // 1~10, 默认值5

JVM自动把1(低)~10(高)的优先级映射到操作系统实际优先级上(不同操作系统有不同的优先级数量)。优先级高的线程被操作系统调度的优先级较高,操作系统对高优先级线程可能调度更频繁,但我们决不能通过设置优先级来确保高优先级的线程一定会先执行。

练习

创建一个新线程。

下载练习

小结

Java用Thread对象表示一个线程,通过调用start()启动一个新线程;

一个线程对象只能调用一次start()方法;

线程的执行代码写在run()方法中;

线程调度由操作系统决定,程序本身无法决定调度顺序;

Thread.sleep()可以把当前线程暂停一段时间。

线程的状态

在Java程序中,一个线程对象只能调用一次start()方法启动新线程,并在新线程中执行run()方法。一旦run()方法执行完毕,线程就结束了。因此,Java线程的状态有以下几种:

  • New:新创建的线程,尚未执行;
  • Runnable:运行中的线程,正在执行run()方法的Java代码;
  • Blocked:运行中的线程,因为某些操作被阻塞而挂起;
  • Waiting:运行中的线程,因为某些操作在等待中;
  • Timed Waiting:运行中的线程,因为执行sleep()方法正在计时等待;
  • Terminated:线程已终止,因为run()方法执行完毕。

用一个状态转移图表示如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
         ┌─────────────┐
│ New │
└─────────────┘


┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐
┌─────────────┐ ┌─────────────┐
││ Runnable │ │ Blocked ││
└─────────────┘ └─────────────┘
│┌─────────────┐ ┌─────────────┐│
│ Waiting │ │Timed Waiting│
│└─────────────┘ └─────────────┘│
─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─


┌─────────────┐
│ Terminated │
└─────────────┘

当线程启动后,它可以在RunnableBlockedWaitingTimed Waiting这几个状态之间切换,直到最后变成Terminated状态,线程终止。

线程终止的原因有:

  • 线程正常终止:run()方法执行到return语句返回;
  • 线程意外终止:run()方法因为未捕获的异常导致线程终止;
  • 对某个线程的Thread实例调用stop()方法强制终止(强烈不推荐使用)。

一个线程还可以等待另一个线程直到其运行结束。例如,main线程在启动t线程后,可以通过t.join()等待t线程结束后再继续运行:

1
2
3
4
5
6
7
8
9
10
11
12
// 多线程
public class Main {
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(() -> {
System.out.println("hello");
});
System.out.println("start");
t.start(); // 启动t线程
t.join(); // 此处main线程会等待t结束
System.out.println("end");
}
}

main线程对线程对象t调用join()方法时,主线程将等待变量t表示的线程运行结束,即join就是指等待该线程结束,然后才继续往下执行自身线程。所以,上述代码打印顺序可以肯定是main线程先打印startt线程再打印hellomain线程最后再打印end

如果t线程已经结束,对实例t调用join()会立刻返回。此外,join(long)的重载方法也可以指定一个等待时间,超过等待时间后就不再继续等待。

小结

Java线程对象Thread的状态包括:NewRunnableBlockedWaitingTimed WaitingTerminated

通过对另一个线程对象调用join()方法可以等待其执行结束;

可以指定等待时间,超过等待时间线程仍然没有结束就不再等待;

对已经运行结束的线程调用join()方法会立刻返回。



如果线程需要执行一个长时间任务,就可能需要能中断线程。中断线程就是其他线程给该线程发一个信号,该线程收到信号后结束执行run()方法,使得自身线程能立刻结束运行。

我们举个栗子:假设从网络下载一个100M的文件,如果网速很慢,用户等得不耐烦,就可能在下载过程中点“取消”,这时,程序就需要中断下载线程的执行。

中断一个线程非常简单,只需要在其他线程中对目标线程调用interrupt()方法,目标线程需要反复检测自身状态是否是interrupted状态,如果是,就立刻结束运行。

我们还是看示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 中断线程
public class Main {
public static void main(String[] args) throws InterruptedException {
Thread t = new MyThread();
t.start();
Thread.sleep(1); // 暂停1毫秒
t.interrupt(); // 中断t线程
t.join(); // 等待t线程结束
System.out.println("end");
}
}

class MyThread extends Thread {
public void run() {
int n = 0;
while (! isInterrupted()) {
n ++;
System.out.println(n + " hello!");
}
}
}

仔细看上述代码,main线程通过调用t.interrupt()方法中断t线程,但是要注意,interrupt()方法仅仅向t线程发出了“中断请求”,至于t线程是否能立刻响应,要看具体代码。而t线程的while循环会检测isInterrupted(),所以上述代码能正确响应interrupt()请求,使得自身立刻结束运行run()方法。

如果线程处于等待状态,例如,t.join()会让main线程进入等待状态,此时,如果对main线程调用interrupt()join()方法会立刻抛出InterruptedException,因此,目标线程只要捕获到join()方法抛出的InterruptedException,就说明有其他线程对其调用了interrupt()方法,通常情况下该线程应该立刻结束运行。

我们来看下面的示例代码:

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
// 中断线程
public class Main {
public static void main(String[] args) throws InterruptedException {
Thread t = new MyThread();
t.start();
Thread.sleep(1000);
t.interrupt(); // 中断t线程
t.join(); // 等待t线程结束
System.out.println("end");
}
}

class MyThread extends Thread {
public void run() {
Thread hello = new HelloThread();
hello.start(); // 启动hello线程
try {
hello.join(); // 等待hello线程结束
} catch (InterruptedException e) {
System.out.println("interrupted!");
}
hello.interrupt();
}
}

class HelloThread extends Thread {
public void run() {
int n = 0;
while (!isInterrupted()) {
n++;
System.out.println(n + " hello!");
try {
Thread.sleep(100);
} catch (InterruptedException e) {
break;
}
}
}
}

main线程通过调用t.interrupt()从而通知t线程中断,而此时t线程正位于hello.join()的等待中,此方法会立刻结束等待并抛出InterruptedException。由于我们在t线程中捕获了InterruptedException,因此,就可以准备结束该线程。在t线程结束前,对hello线程也进行了interrupt()调用通知其中断。如果去掉这一行代码,可以发现hello线程仍然会继续运行,且JVM不会退出。

另一个常用的中断线程的方法是设置标志位。我们通常会用一个running标志位来标识线程是否应该继续运行,在外部线程中,通过把HelloThread.running置为false,就可以让线程结束:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 中断线程
public class Main {
public static void main(String[] args) throws InterruptedException {
HelloThread t = new HelloThread();
t.start();
Thread.sleep(1);
t.running = false; // 标志位置为false
}
}

class HelloThread extends Thread {
public volatile boolean running = true;
public void run() {
int n = 0;
while (running) {
n ++;
System.out.println(n + " hello!");
}
System.out.println("end!");
}
}

注意到HelloThread的标志位boolean running是一个线程间共享的变量。线程间共享变量需要使用volatile关键字标记,确保每个线程都能读取到更新后的变量值。

为什么要对线程间共享的变量用关键字volatile声明?这涉及到Java的内存模型。在Java虚拟机中,变量的值保存在主内存中,但是,当线程访问变量时,它会先获取一个副本,并保存在自己的工作内存中。如果线程修改了变量的值,虚拟机会在某个时刻把修改后的值回写到主内存,但是,这个时间是不确定的!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐
Main Memory
│ │
┌───────┐┌───────┐┌───────┐
│ │ var A ││ var B ││ var C │ │
└───────┘└───────┘└───────┘
│ │ ▲ │ ▲ │
─ ─ ─│─│─ ─ ─ ─ ─ ─ ─ ─│─│─ ─ ─
│ │ │ │
┌ ─ ─ ┼ ┼ ─ ─ ┐ ┌ ─ ─ ┼ ┼ ─ ─ ┐
▼ │ ▼ │
│ ┌───────┐ │ │ ┌───────┐ │
│ var A │ │ var C │
│ └───────┘ │ │ └───────┘ │
Thread 1 Thread 2
└ ─ ─ ─ ─ ─ ─ ┘ └ ─ ─ ─ ─ ─ ─ ┘

这会导致如果一个线程更新了某个变量,另一个线程读取的值可能还是更新前的。例如,主内存的变量a = true,线程1执行a = false时,它在此刻仅仅是把变量a的副本变成了false,主内存的变量a还是true,在JVM把修改后的a回写到主内存之前,其他线程读取到的a的值仍然是true,这就造成了多线程之间共享的变量不一致。

因此,volatile关键字的目的是告诉虚拟机:

  • 每次访问变量时,总是获取主内存的最新值;
  • 每次修改变量后,立刻回写到主内存。

volatile关键字解决的是可见性问题:当一个线程修改了某个共享变量的值,其他线程能够立刻看到修改后的值。

如果我们去掉volatile关键字,运行上述程序,发现效果和带volatile差不多,这是因为在x86的架构下,JVM回写主内存的速度非常快,但是,换成ARM的架构,就会有显著的延迟。

小结

对目标线程调用interrupt()方法可以请求中断一个线程,目标线程通过检测isInterrupted()标志获取自身是否已中断。如果目标线程处于等待状态,该线程会捕获到InterruptedException

目标线程检测到isInterrupted()true或者捕获了InterruptedException都应该立刻结束自身线程;

通过标志位判断需要正确使用volatile关键字;

volatile关键字解决了共享变量在线程间的可见性问题。

守护线程

Java程序入口就是由JVM启动main线程,main线程又可以启动其他线程。当所有线程都运行结束时,JVM退出,进程结束。

如果有一个线程没有退出,JVM进程就不会退出。所以,必须保证所有线程都能及时结束。

但是有一种线程的目的就是无限循环,例如,一个定时触发任务的线程:

1
2
3
4
5
6
7
8
9
10
11
12
13
class TimerThread extends Thread {
@Override
public void run() {
while (true) {
System.out.println(LocalTime.now());
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
break;
}
}
}
}

如果这个线程不结束,JVM进程就无法结束。问题是,由谁负责结束这个线程?

然而这类线程经常没有负责人来负责结束它们。但是,当其他线程结束时,JVM进程又必须要结束,怎么办?

答案是使用守护线程(Daemon Thread)。

守护线程是指为其他线程服务的线程。在JVM中,所有非守护线程都执行完毕后,无论有没有守护线程,虚拟机都会自动退出。

因此,JVM退出时,不必关心守护线程是否已结束。

如何创建守护线程呢?方法和普通线程一样,只是在调用start()方法前,调用setDaemon(true)把该线程标记为守护线程:

1
2
3
Thread t = new MyThread();
t.setDaemon(true);
t.start();

在守护线程中,编写代码要注意:守护线程不能持有任何需要关闭的资源,例如打开文件等,因为虚拟机退出时,守护线程没有任何机会来关闭文件,这会导致数据丢失。

练习

使用守护线程。

下载练习

小结

守护线程是为其他线程服务的线程;

所有非守护线程都执行完毕后,虚拟机退出,守护线程随之结束;

守护线程不能持有需要关闭的资源(如打开文件等)。



留言與分享

JAVA-加密与安全

分類 编程语言, Java

在计算机系统中,什么是加密与安全呢?

我们举个栗子:假设Bob要给Alice发一封邮件,在邮件传送的过程中,黑客可能会窃取到邮件的内容,所以需要防窃听。黑客还可能会篡改邮件的内容,Alice必须有能力识别出邮件有没有被篡改。最后,黑客可能假冒Bob给Alice发邮件,Alice必须有能力识别出伪造的邮件。

所以,应对潜在的安全威胁,需要做到三防:

  • 防窃听
  • 防篡改
  • 防伪造

java-security

计算机加密技术就是为了实现上述目标,而现代计算机密码学理论是建立在严格的数学理论基础上的,密码学已经逐渐发展成一门科学。对于绝大多数开发者来说,设计一个安全的加密算法非常困难,验证一个加密算法是否安全更加困难,当前被认为安全的加密算法仅仅是迄今为止尚未被攻破。因此,要编写安全的计算机程序,我们要做到:

  • 不要自己设计山寨的加密算法;
  • 不要自己实现已有的加密算法;
  • 不要自己修改已有的加密算法。

本章我们会介绍最常用的加密算法,以及如何通过Java代码实现。

要学习编码算法,我们先来看一看什么是编码。

ASCII码就是一种编码,字母A的编码是十六进制的0x41,字母B0x42,以此类推:

字母 ASCII编码
A 0x41
B 0x42
C 0x43
D 0x44

因为ASCII编码最多只能有128个字符,要想对更多的文字进行编码,就需要用Unicode。而中文的中使用Unicode编码就是0x4e2d,使用UTF-8则需要3个字节编码:

汉字 Unicode编码 UTF-8编码
0x4e2d 0xe4b8ad
0x6587 0xe69687
0x7f16 0xe7bc96
0x7801 0xe7a081

因此,最简单的编码是直接给每个字符指定一个若干字节表示的整数,复杂一点的编码就需要根据一个已有的编码推算出来。

比如UTF-8编码,它是一种不定长编码,但可以从给定字符的Unicode编码推算出来。

URL编码

URL编码是浏览器发送数据给服务器时使用的编码,它通常附加在URL的参数部分,例如:

https://www.baidu.com/s?wd=%E4%B8%AD%E6%96%87

之所以需要URL编码,是因为出于兼容性考虑,很多服务器只识别ASCII字符。但如果URL中包含中文、日文这些非ASCII字符怎么办?不要紧,URL编码有一套规则:

  • 如果字符是A~Za~z0~9以及-_.*,则保持不变;
  • 如果是其他字符,先转换为UTF-8编码,然后对每个字节以%XX表示。

例如:字符的UTF-8编码是0xe4b8ad,因此,它的URL编码是%E4%B8%AD。URL编码总是大写。

Java标准库提供了一个URLEncoder类来对任意字符串进行URL编码:

1
2
3
4
5
6
7
8
9
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;

public class Main {
public static void main(String[] args) {
String encoded = URLEncoder.encode("中文!", StandardCharsets.UTF_8);
System.out.println(encoded);
}
}

上述代码的运行结果是%E4%B8%AD%E6%96%87%21的URL编码是%E4%B8%AD的URL编码是%E6%96%87!虽然是ASCII字符,也要对其编码为%21

和标准的URL编码稍有不同,URLEncoder把空格字符编码成+,而现在的URL编码标准要求空格被编码为%20,不过,服务器都可以处理这两种情况。

如果服务器收到URL编码的字符串,就可以对其进行解码,还原成原始字符串。Java标准库的URLDecoder就可以解码:

1
2
3
4
5
6
7
8
9
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;

public class Main {
public static void main(String[] args) {
String decoded = URLDecoder.decode("%E4%B8%AD%E6%96%87%21", StandardCharsets.UTF_8);
System.out.println(decoded);
}
}

要特别注意:URL编码是编码算法,不是加密算法。URL编码的目的是把任意文本数据编码为%前缀表示的文本,编码后的文本仅包含A~Za~z0~9-_.*%,便于浏览器和服务器处理。

Base64编码

URL编码是对字符进行编码,表示成%xx的形式,而Base64编码是对二进制数据进行编码,表示成文本格式。

Base64编码可以把任意长度的二进制数据变为纯文本,且只包含A~Za~z0~9+/=这些字符。它的原理是把3字节的二进制数据按6bit一组,用4个int整数表示,然后查表,把int整数用索引对应到字符,得到编码后的字符串。

举个例子:3个byte数据分别是e4b8ad,按6bit分组得到390b222d

1
2
3
4
5
6
7
8
9
┌───────────────┬───────────────┬───────────────┐
│ e4 │ b8 │ ad │
└───────────────┴───────────────┴───────────────┘
┌─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┐
│1│1│1│0│0│1│0│0│1│0│1│1│1│0│0│0│1│0│1│0│1│1│0│1│
└─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┘
┌───────────┬───────────┬───────────┬───────────┐
│ 39 │ 0b │ 22 │ 2d │
└───────────┴───────────┴───────────┴───────────┘

因为6位整数的范围总是0~63,所以,能用64个字符表示:字符A~Z对应索引0~25,字符a~z对应索引26~51,字符0~9对应索引52~61,最后两个索引6263分别用字符+/表示。

在Java中,二进制数据就是byte[]数组。Java标准库提供了Base64来对byte[]数组进行编解码:

1
2
3
4
5
6
7
8
9
import java.util.*;

public class Main {
public static void main(String[] args) {
byte[] input = new byte[] { (byte) 0xe4, (byte) 0xb8, (byte) 0xad };
String b64encoded = Base64.getEncoder().encodeToString(input);
System.out.println(b64encoded);
}
}

编码后得到5Lit4个字符。要对Base64解码,仍然用Base64这个类:

1
2
3
4
5
6
7
8
import java.util.*;

public class Main {
public static void main(String[] args) {
byte[] output = Base64.getDecoder().decode("5Lit");
System.out.println(Arrays.toString(output)); // [-28, -72, -83]
}
}

有的童鞋会问:如果输入的byte[]数组长度不是3的整数倍肿么办?这种情况下,需要对输入的末尾补一个或两个0x00,编码后,在结尾加一个=表示补充了1个0x00,加两个=表示补充了2个0x00,解码的时候,去掉末尾补充的一个或两个0x00即可。

实际上,因为编码后的长度加上=总是4的倍数,所以即使不加=也可以计算出原始输入的byte[]。Base64编码的时候可以用withoutPadding()去掉=,解码出来的结果是一样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
import java.util.*;

public class Main {
public static void main(String[] args) {
byte[] input = new byte[] { (byte) 0xe4, (byte) 0xb8, (byte) 0xad, 0x21 };
String b64encoded = Base64.getEncoder().encodeToString(input);
String b64encoded2 = Base64.getEncoder().withoutPadding().encodeToString(input);
System.out.println(b64encoded);
System.out.println(b64encoded2);
byte[] output = Base64.getDecoder().decode(b64encoded2);
System.out.println(Arrays.toString(output));
}
}

因为标准的Base64编码会出现+/=,所以不适合把Base64编码后的字符串放到URL中。一种针对URL的Base64编码可以在URL中使用的Base64编码,它仅仅是把+变成-/变成_

1
2
3
4
5
6
7
8
9
10
11
import java.util.*;

public class Main {
public static void main(String[] args) {
byte[] input = new byte[] { 0x01, 0x02, 0x7f, 0x00 };
String b64encoded = Base64.getUrlEncoder().encodeToString(input);
System.out.println(b64encoded);
byte[] output = Base64.getUrlDecoder().decode(b64encoded);
System.out.println(Arrays.toString(output));
}
}

Base64编码的目的是把二进制数据变成文本格式,这样在很多文本中就可以处理二进制数据。例如,电子邮件协议就是文本协议,如果要在电子邮件中添加一个二进制文件,就可以用Base64编码,然后以文本的形式传送。

Base64编码的缺点是传输效率会降低,因为它把原始数据的长度增加了1/3。

和URL编码一样,Base64编码是一种编码算法,不是加密算法。

如果把Base64的64个字符编码表换成32个、48个或者58个,就可以使用Base32编码,Base48编码和Base58编码。字符越少,编码的效率就会越低。

小结

URL编码和Base64编码都是编码算法,它们不是加密算法;

URL编码的目的是把任意文本数据编码为%前缀表示的文本,便于浏览器和服务器处理;

Base64编码的目的是把任意二进制数据编码为文本,但编码后数据量会增加1/3。

哈希算法(Hash)又称摘要算法(Digest),它的作用是:对任意一组输入数据进行计算,得到一个固定长度的输出摘要。

哈希算法最重要的特点就是:

  • 相同的输入一定得到相同的输出;
  • 不同的输入大概率得到不同的输出。

哈希算法的目的就是为了验证原始数据是否被篡改。

Java字符串的hashCode()就是一个哈希算法,它的输入是任意字符串,输出是固定的4字节int整数:

1
2
3
"hello".hashCode(); // 0x5e918d2
"hello, java".hashCode(); // 0x7a9d88e8
"hello, bob".hashCode(); // 0xa0dbae2f

两个相同的字符串永远会计算出相同的hashCode,否则基于hashCode定位的HashMap就无法正常工作。这也是为什么当我们自定义一个class时,覆写equals()方法时我们必须正确覆写hashCode()方法。

哈希碰撞

哈希碰撞是指,两个不同的输入得到了相同的输出:

1
2
"AaAaAa".hashCode(); // 0x7460e8c0
"BBAaBB".hashCode(); // 0x7460e8c0

有童鞋会问:碰撞能不能避免?答案是不能。碰撞是一定会出现的,因为输出的字节长度是固定的,StringhashCode()输出是4字节整数,最多只有4294967296种输出,但输入的数据长度是不固定的,有无数种输入。所以,哈希算法是把一个无限的输入集合映射到一个有限的输出集合,必然会产生碰撞。

碰撞不可怕,我们担心的不是碰撞,而是碰撞的概率,因为碰撞概率的高低关系到哈希算法的安全性。一个安全的哈希算法必须满足:

  • 碰撞概率低;
  • 不能猜测输出。

不能猜测输出是指,输入的任意一个bit的变化会造成输出完全不同,这样就很难从输出反推输入(只能依靠暴力穷举)。假设一种哈希算法有如下规律:

1
2
3
hashA("java001") = "123456"
hashA("java002") = "123457"
hashA("java003") = "123458"

那么很容易从输出123459反推输入,这种哈希算法就不安全。安全的哈希算法从输出是看不出任何规律的:

1
2
3
hashB("java001") = "123456"
hashB("java002") = "580271"
hashB("java003") = ???

常用的哈希算法有:

算法 输出长度(位) 输出长度(字节)
MD5 128 bits 16 bytes
SHA-1 160 bits 20 bytes
RipeMD-160 160 bits 20 bytes
SHA-256 256 bits 32 bytes
SHA-512 512 bits 64 bytes

根据碰撞概率,哈希算法的输出长度越长,就越难产生碰撞,也就越安全。

Java标准库提供了常用的哈希算法,并且有一套统一的接口。我们以MD5算法为例,看看如何对输入计算哈希:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import java.security.MessageDigest;
import java.util.HexFormat;

public class Main {
public static void main(String[] args) throws Exception {
// 创建一个MessageDigest实例:
MessageDigest md = MessageDigest.getInstance("MD5");
// 反复调用update输入数据:
md.update("Hello".getBytes("UTF-8"));
md.update("World".getBytes("UTF-8"));
byte[] result = md.digest(); // 16 bytes: 68e109f0f40ca72a15e05cc22786f8e6
System.out.println(HexFormat.of().formatHex(result));
}
}

使用MessageDigest时,我们首先根据哈希算法获取一个MessageDigest实例,然后,反复调用update(byte[])输入数据。当输入结束后,调用digest()方法获得byte[]数组表示的摘要,最后,把它转换为十六进制的字符串。

运行上述代码,可以得到输入HelloWorld的MD5是68e109f0f40ca72a15e05cc22786f8e6

哈希算法的用途

因为相同的输入永远会得到相同的输出,因此,如果输入被修改了,得到的输出就会不同。

我们在网站上下载软件的时候,经常看到下载页显示的哈希:

file-md5

如何判断下载到本地的软件是原始的、未经篡改的文件?我们只需要自己计算一下本地文件的哈希值,再与官网公开的哈希值对比,如果相同,说明文件下载正确,否则,说明文件已被篡改。

哈希算法的另一个重要用途是存储用户口令。如果直接将用户的原始口令存放到数据库中,会产生极大的安全风险:

  • 数据库管理员能够看到用户明文口令;
  • 数据库数据一旦泄漏,黑客即可获取用户明文口令。

不存储用户的原始口令,那么如何对用户进行认证?

方法是存储用户口令的哈希,例如,MD5。

在用户输入原始口令后,系统计算用户输入的原始口令的MD5并与数据库存储的MD5对比,如果一致,说明口令正确,否则,口令错误。

因此,数据库存储用户名和口令的表内容应该像下面这样:

username password
bob f30aa7a662c728b7407c54ae6bfd27d1
alice 25d55ad283aa400af464c76d713c07ad
tim bed128365216c019988915ed3add75fb

这样一来,数据库管理员看不到用户的原始口令。即使数据库泄漏,黑客也无法拿到用户的原始口令。想要拿到用户的原始口令,必须用暴力穷举的方法,一个口令一个口令地试,直到某个口令计算的MD5恰好等于指定值。

使用哈希口令时,还要注意防止彩虹表攻击。

什么是彩虹表?难道是这个:

彩虹表

上面讲到了,如果只拿到MD5,从MD5反推明文口令,只能使用暴力穷举的方法。

然而黑客并不笨,暴力穷举会消耗大量的算力和时间。但是,如果有一个预先计算好的常用口令和它们的MD5的对照表:

常用口令 MD5
hello123 f30aa7a662c728b7407c54ae6bfd27d1
12345678 25d55ad283aa400af464c76d713c07ad
passw0rd bed128365216c019988915ed3add75fb
19700101 570da6d5277a646f6552b8832012f5dc
20201231 6879c0ae9117b50074ce0a0d4c843060

这个表就是彩虹表。如果用户使用了常用口令,黑客从MD5一下就能反查到原始口令:

bob的MD5:f30aa7a662c728b7407c54ae6bfd27d1,原始口令:hello123

alice的MD5:25d55ad283aa400af464c76d713c07ad,原始口令:12345678

tim的MD5:bed128365216c019988915ed3add75fb,原始口令:passw0rd

这就是为什么不要使用常用密码,以及不要使用生日作为密码的原因。

即使用户使用了常用口令,我们也可以采取措施来抵御彩虹表攻击,方法是对每个口令额外添加随机数,这个方法称之为加盐(salt):

1
digest = md5(salt+inputPassword)

经过加盐处理的数据库表,内容如下:

username salt password
bob H1r0a a5022319ff4c56955e22a74abcc2c210
alice 7$p2w e5de688c99e961ed6e560b972dab8b6a
tim z5Sk9 1eee304b92dc0d105904e7ab58fd2f64

加盐的目的在于使黑客的彩虹表失效,即使用户使用常用口令,也无法从MD5反推原始口令。

SHA-1

SHA-1也是一种哈希算法,它的输出是160 bits,即20字节。SHA-1是由美国国家安全局开发的,SHA算法实际上是一个系列,包括SHA-0(已废弃)、SHA-1、SHA-256、SHA-512等。

在Java中使用SHA-1,和MD5完全一样,只需要把算法名称改为"SHA-1"

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import java.security.MessageDigest;
import java.util.HexFormat;

public class Main {
public static void main(String[] args) throws Exception {
// 创建一个MessageDigest实例:
MessageDigest md = MessageDigest.getInstance("SHA-1");
// 反复调用update输入数据:
md.update("Hello".getBytes("UTF-8"));
md.update("World".getBytes("UTF-8"));
byte[] result = md.digest(); // 20 bytes: db8ac1c259eb89d4a131b253bacfca5f319d54f2
System.out.println(HexFormat.of().formatHex(result));
}
}

类似的,计算SHA-256,我们需要传入名称"SHA-256",计算SHA-512,我们需要传入名称"SHA-512"。Java标准库支持的所有哈希算法可以在这里查到。

注意

MD5因为输出长度较短,短时间内破解是可能的,目前已经不推荐使用。

小结

哈希算法可用于验证数据完整性,具有防篡改检测的功能;

常用的哈希算法有MD5、SHA-1等;

用哈希存储口令时要考虑彩虹表攻击。

BouncyCastle

我们知道,Java标准库提供了一系列常用的哈希算法。

但如果我们要用的某种算法,Java标准库没有提供怎么办?

方法一:自己写一个,难度很大;

方法二:找一个现成的第三方库,直接使用。

BouncyCastle就是一个提供了很多哈希算法和加密算法的第三方库。它提供了Java标准库没有的一些算法,例如,RipeMD160哈希算法。

我们来看一下如何使用BouncyCastle这个第三方提供的算法。

首先,我们必须把BouncyCastle提供的jar包放到classpath中。这个jar包就是bcprov-jdk18on-xxx.jar,可以从官方网站下载。

Java标准库的java.security包提供了一种标准机制,允许第三方提供商无缝接入。我们要使用BouncyCastle提供的RipeMD160算法,需要先把BouncyCastle注册一下:

1
2
3
4
5
6
7
8
9
10
11
public class Main {
public static void main(String[] args) throws Exception {
// 注册BouncyCastle:
Security.addProvider(new BouncyCastleProvider());
// 按名称正常调用:
MessageDigest md = MessageDigest.getInstance("RipeMD160");
md.update("HelloWorld".getBytes("UTF-8"));
byte[] result = md.digest();
System.out.println(HexFormat.of().formatHex(result));
}
}

其中,注册BouncyCastle是通过下面的语句实现的:

1
Security.addProvider(new BouncyCastleProvider());

注册只需要在启动时进行一次,后续就可以使用BouncyCastle提供的所有哈希算法和加密算法。

练习

使用BouncyCastle提供的RipeMD160计算哈希。

下载练习

小结

BouncyCastle是一个开源的第三方算法提供商;

BouncyCastle提供了很多Java标准库没有提供的哈希算法和加密算法;

使用第三方算法前需要通过Security.addProvider()注册。



Hmac算法

在前面讲到哈希算法时,我们说,存储用户的哈希口令时,要加盐存储,目的就在于抵御彩虹表攻击。

我们回顾一下哈希算法:

1
digest = hash(input)

正是因为相同的输入会产生相同的输出,我们加盐的目的就在于,使得输入有所变化:

1
digest = hash(salt + input)

这个salt可以看作是一个额外的“认证码”,同样的输入,不同的认证码,会产生不同的输出。因此,要验证输出的哈希,必须同时提供“认证码”。

Hmac算法就是一种基于密钥的消息认证码算法,它的全称是Hash-based Message Authentication Code,是一种更安全的消息摘要算法。

Hmac算法总是和某种哈希算法配合起来用的。例如,我们使用MD5算法,对应的就是HmacMD5算法,它相当于“加盐”的MD5:

1
HmacMD5 ≈ md5(secure_random_key, input)

因此,HmacMD5可以看作带有一个安全的key的MD5。使用HmacMD5而不是用MD5加salt,有如下好处:

  • HmacMD5使用的key长度是64字节,更安全;
  • Hmac是标准算法,同样适用于SHA-1等其他哈希算法;
  • Hmac输出和原有的哈希算法长度一致。

可见,Hmac本质上就是把key混入摘要的算法。验证此哈希时,除了原始的输入数据,还要提供key。

为了保证安全,我们不会自己指定key,而是通过Java标准库的KeyGenerator生成一个安全的随机的key。下面是使用HmacMD5的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import javax.crypto.*;
import java.util.HexFormat;

public class Main {
public static void main(String[] args) throws Exception {
KeyGenerator keyGen = KeyGenerator.getInstance("HmacMD5");
SecretKey key = keyGen.generateKey();
// 打印随机生成的key:
byte[] skey = key.getEncoded();
System.out.println(HexFormat.of().formatHex(skey));
Mac mac = Mac.getInstance("HmacMD5");
mac.init(key);
mac.update("HelloWorld".getBytes("UTF-8"));
byte[] result = mac.doFinal();
System.out.println(HexFormat.of().formatHex(result));
}
}

和MD5相比,使用HmacMD5的步骤是:

  1. 通过名称HmacMD5获取KeyGenerator实例;
  2. 通过KeyGenerator创建一个SecretKey实例;
  3. 通过名称HmacMD5获取Mac实例;
  4. SecretKey初始化Mac实例;
  5. Mac实例反复调用update(byte[])输入数据;
  6. 调用Mac实例的doFinal()获取最终的哈希值。

我们可以用Hmac算法取代原有的自定义的加盐算法,因此,存储用户名和口令的数据库结构如下:

username secret_key (64 bytes) password
bob a8c06e05f92e…5e16 7e0387872a57c85ef6dddbaa12f376de
alice e6a343693985…f4be c1f929ac2552642b302e739bc0cdbaac
tim f27a973dfdc0…6003 af57651c3a8a73303515804d4af43790

有了Hmac计算的哈希和SecretKey,我们想要验证怎么办?这时,SecretKey不能从KeyGenerator生成,而是从一个byte[]数组恢复:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import javax.crypto.*;
import javax.crypto.spec.*;
import java.util.HexFormat;

public class Main {
public static void main(String[] args) throws Exception {
byte[] hkey = HexFormat.of().parseHex(
"b648ee779d658c420420d86291ec70f5" +
"cf97521c740330972697a8fad0b55f5c" +
"5a7924e4afa99d8c5883e07d7c3f9ed0" +
"76aa544d25ed2f5ceea59dcc122babc8");
SecretKey key = new SecretKeySpec(hkey, "HmacMD5");
Mac mac = Mac.getInstance("HmacMD5");
mac.init(key);
mac.update("HelloWorld".getBytes("UTF-8"));
byte[] result = mac.doFinal();
System.out.println(HexFormat.of().formatHex(result)); // 4af40be7864efaae1473a4c601b650ae
}
}

恢复SecretKey的语句就是new SecretKeySpec(hkey, "HmacMD5")

小结

Hmac算法是一种标准的基于密钥的哈希算法,可以配合MD5、SHA-1等哈希算法,计算的摘要长度和原摘要算法长度相同。



对称加密算法就是传统的用一个密码进行加密和解密。例如,我们常用的WinZIP和WinRAR对压缩包的加密和解密,就是使用对称加密算法:

winrar

从程序的角度看,所谓加密,就是这样一个函数,它接收密码和明文,然后输出密文:

1
secret = encrypt(key, message);

而解密则相反,它接收密码和密文,然后输出明文:

1
plain = decrypt(key, secret);

在软件开发中,常用的对称加密算法有:

算法 密钥长度 工作模式 填充模式
DES 56/64 ECB/CBC/PCBC/CTR/… NoPadding/PKCS5Padding/…
AES 128/192/256 ECB/CBC/PCBC/CTR/… NoPadding/PKCS5Padding/PKCS7Padding/…
IDEA 128 ECB PKCS5Padding/PKCS7Padding/…

密钥长度直接决定加密强度,而工作模式和填充模式可以看成是对称加密算法的参数和格式选择。Java标准库提供的算法实现并不包括所有的工作模式和所有填充模式,但是通常我们只需要挑选常用的使用就可以了。

最后注意,DES算法由于密钥过短,可以在短时间内被暴力破解,所以现在已经不安全了。

使用AES加密

AES算法是目前应用最广泛的加密算法。我们先用ECB模式加密并解密:

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
import java.security.*;
import java.util.Base64;

import javax.crypto.*;
import javax.crypto.spec.*;

public class Main {
public static void main(String[] args) throws Exception {
// 原文:
String message = "Hello, world!";
System.out.println("Message: " + message);
// 128位密钥 = 16 bytes Key:
byte[] key = "1234567890abcdef".getBytes("UTF-8");
// 加密:
byte[] data = message.getBytes("UTF-8");
byte[] encrypted = encrypt(key, data);
System.out.println("Encrypted: " + Base64.getEncoder().encodeToString(encrypted));
// 解密:
byte[] decrypted = decrypt(key, encrypted);
System.out.println("Decrypted: " + new String(decrypted, "UTF-8"));
}

// 加密:
public static byte[] encrypt(byte[] key, byte[] input) throws GeneralSecurityException {
Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
SecretKey keySpec = new SecretKeySpec(key, "AES");
cipher.init(Cipher.ENCRYPT_MODE, keySpec);
return cipher.doFinal(input);
}

// 解密:
public static byte[] decrypt(byte[] key, byte[] input) throws GeneralSecurityException {
Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
SecretKey keySpec = new SecretKeySpec(key, "AES");
cipher.init(Cipher.DECRYPT_MODE, keySpec);
return cipher.doFinal(input);
}
}

Java标准库提供的对称加密接口非常简单,使用时按以下步骤编写代码:

  1. 根据算法名称/工作模式/填充模式获取Cipher实例;
  2. 根据算法名称初始化一个SecretKey实例,密钥必须是指定长度;
  3. 使用SecretKey初始化Cipher实例,并设置加密或解密模式;
  4. 传入明文或密文,获得密文或明文。

ECB模式是最简单的AES加密模式,它只需要一个固定长度的密钥,固定的明文会生成固定的密文,这种一对一的加密方式会导致安全性降低,更好的方式是通过CBC模式,它需要一个随机数作为IV参数,这样对于同一份明文,每次生成的密文都不同:

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
import java.security.*;
import java.util.Base64;
import javax.crypto.*;
import javax.crypto.spec.*;

public class Main {
public static void main(String[] args) throws Exception {
// 原文:
String message = "Hello, world!";
System.out.println("Message: " + message);
// 256位密钥 = 32 bytes Key:
byte[] key = "1234567890abcdef1234567890abcdef".getBytes("UTF-8");
// 加密:
byte[] data = message.getBytes("UTF-8");
byte[] encrypted = encrypt(key, data);
System.out.println("Encrypted: " + Base64.getEncoder().encodeToString(encrypted));
// 解密:
byte[] decrypted = decrypt(key, encrypted);
System.out.println("Decrypted: " + new String(decrypted, "UTF-8"));
}

// 加密:
public static byte[] encrypt(byte[] key, byte[] input) throws GeneralSecurityException {
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
SecretKeySpec keySpec = new SecretKeySpec(key, "AES");
// CBC模式需要生成一个16 bytes的initialization vector:
SecureRandom sr = SecureRandom.getInstanceStrong();
byte[] iv = sr.generateSeed(16);
IvParameterSpec ivps = new IvParameterSpec(iv);
cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivps);
byte[] data = cipher.doFinal(input);
// IV不需要保密,把IV和密文一起返回:
return join(iv, data);
}

// 解密:
public static byte[] decrypt(byte[] key, byte[] input) throws GeneralSecurityException {
// 把input分割成IV和密文:
byte[] iv = new byte[16];
byte[] data = new byte[input.length - 16];
System.arraycopy(input, 0, iv, 0, 16);
System.arraycopy(input, 16, data, 0, data.length);
// 解密:
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
SecretKeySpec keySpec = new SecretKeySpec(key, "AES");
IvParameterSpec ivps = new IvParameterSpec(iv);
cipher.init(Cipher.DECRYPT_MODE, keySpec, ivps);
return cipher.doFinal(data);
}

public static byte[] join(byte[] bs1, byte[] bs2) {
byte[] r = new byte[bs1.length + bs2.length];
System.arraycopy(bs1, 0, r, 0, bs1.length);
System.arraycopy(bs2, 0, r, bs1.length, bs2.length);
return r;
}
}

在CBC模式下,需要一个随机生成的16字节IV参数,必须使用SecureRandom生成。因为多了一个IvParameterSpec实例,因此,初始化方法需要调用Cipher的一个重载方法并传入IvParameterSpec

观察输出,可以发现每次生成的IV不同,密文也不同。

小结

对称加密算法使用同一个密钥进行加密和解密,常用算法有DES、AES和IDEA等;

密钥长度由算法设计决定,AES的密钥长度是128/192/256位;

使用对称加密算法需要指定算法名称、工作模式和填充模式。

上一节我们讲的AES加密,细心的童鞋可能会发现,密钥长度是固定的128/192/256位,而不是我们用WinZip/WinRAR那样,随便输入几位都可以。

这是因为对称加密算法决定了口令必须是固定长度,然后对明文进行分块加密。又因为安全需求,口令长度往往都是128位以上,即至少16个字符。

但是我们平时使用的加密软件,输入6位、8位都可以,难道加密方式不一样?

实际上用户输入的口令并不能直接作为AES的密钥进行加密(除非长度恰好是128/192/256位),并且用户输入的口令一般都有规律,安全性远远不如安全随机数产生的随机口令。因此,用户输入的口令,通常还需要使用PBE算法,采用随机数杂凑计算出真正的密钥,再进行加密。

PBE就是Password Based Encryption的缩写,它的作用如下:

1
key = generate(userPassword, secureRandomPassword);

PBE的作用就是把用户输入的口令和一个安全随机的口令采用杂凑后计算出真正的密钥。以AES密钥为例,我们让用户输入一个口令,然后生成一个随机数,通过PBE算法计算出真正的AES口令,再进行加密,代码如下:

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
public class Main {
public static void main(String[] args) throws Exception {
// 把BouncyCastle作为Provider添加到java.security:
Security.addProvider(new BouncyCastleProvider());
// 原文:
String message = "Hello, world!";
// 加密口令:
String password = "hello12345";
// 16 bytes随机Salt:
byte[] salt = SecureRandom.getInstanceStrong().generateSeed(16);
System.out.println(HexFormat.of().formatHex(salt));
// 加密:
byte[] data = message.getBytes("UTF-8");
byte[] encrypted = encrypt(password, salt, data);
System.out.println("encrypted: " + Base64.getEncoder().encodeToString(encrypted));
// 解密:
byte[] decrypted = decrypt(password, salt, encrypted);
System.out.println("decrypted: " + new String(decrypted, "UTF-8"));
}

// 加密:
public static byte[] encrypt(String password, byte[] salt, byte[] input) throws GeneralSecurityException {
PBEKeySpec keySpec = new PBEKeySpec(password.toCharArray());
SecretKeyFactory skeyFactory = SecretKeyFactory.getInstance("PBEwithSHA1and128bitAES-CBC-BC");
SecretKey skey = skeyFactory.generateSecret(keySpec);
PBEParameterSpec pbeps = new PBEParameterSpec(salt, 1000);
Cipher cipher = Cipher.getInstance("PBEwithSHA1and128bitAES-CBC-BC");
cipher.init(Cipher.ENCRYPT_MODE, skey, pbeps);
return cipher.doFinal(input);
}

// 解密:
public static byte[] decrypt(String password, byte[] salt, byte[] input) throws GeneralSecurityException {
PBEKeySpec keySpec = new PBEKeySpec(password.toCharArray());
SecretKeyFactory skeyFactory = SecretKeyFactory.getInstance("PBEwithSHA1and128bitAES-CBC-BC");
SecretKey skey = skeyFactory.generateSecret(keySpec);
PBEParameterSpec pbeps = new PBEParameterSpec(salt, 1000);
Cipher cipher = Cipher.getInstance("PBEwithSHA1and128bitAES-CBC-BC");
cipher.init(Cipher.DECRYPT_MODE, skey, pbeps);
return cipher.doFinal(input);
}
}

使用PBE时,我们还需要引入BouncyCastle,并指定算法是PBEwithSHA1and128bitAES-CBC-BC。观察代码,实际上真正的AES密钥是调用Cipherinit()方法时同时传入SecretKeyPBEParameterSpec实现的。在创建PBEParameterSpec的时候,我们还指定了循环次数1000,循环次数越多,暴力破解需要的计算量就越大。

如果我们把salt和循环次数固定,就得到了一个通用的“口令”加密软件。如果我们把随机生成的salt存储在U盘,就得到了一个“口令”加USB Key的加密软件,它的好处在于,即使用户使用了一个非常弱的口令,没有USB Key仍然无法解密,因为USB Key存储的随机数密钥安全性非常高。

小结

PBE算法通过用户口令和安全的随机salt计算出Key,然后再进行加密;

Key通过口令和安全的随机salt计算得出,大大提高了安全性;

PBE算法内部使用的仍然是标准对称加密算法(例如AES)。

密钥交换算法

对称加密算法解决了数据加密的问题。我们以AES加密为例,在现实世界中,小明要向路人甲发送一个加密文件,他可以先生成一个AES密钥,对文件进行加密,然后把加密文件发送给对方。因为对方要解密,就必须需要小明生成的密钥。

现在问题来了:如何传递密钥?

在不安全的信道上传递加密文件是没有问题的,因为黑客拿到加密文件没有用。但是,如何如何在不安全的信道上安全地传输密钥?

要解决这个问题,密钥交换算法即DH算法:Diffie-Hellman算法应运而生。

DH算法解决了密钥在双方不直接传递密钥的情况下完成密钥交换,这个神奇的交换原理完全由数学理论支持。

我们来看DH算法交换密钥的步骤。假设甲乙双方需要传递密钥,他们之间可以这么做:

  1. 甲首先选择一个素数p,例如97,底数gp的一个原根,例如5,随机数a,例如123,然后计算A=g^a mod p,结果是34,然后,甲发送p=97g=5A=34给乙;
  2. 乙方收到后,也选择一个随机数b,例如,456,然后计算B = g^b mod p,结果是75,乙再同时计算s = A^b mod p,结果是22;
  3. 乙把计算的B=75发给甲,甲计算s = B^a mod p,计算结果与乙算出的结果一样,都是22。

所以最终双方协商出的密钥s是22。注意到这个密钥s并没有在网络上传输。而通过网络传输的pgAB是无法推算出s的,因为实际算法选择的素数是非常大的。

所以,更确切地说,DH算法是一个密钥协商算法,双方最终协商出一个共同的密钥,而这个密钥不会通过网络传输。

如果我们把a看成甲的私钥,A看成甲的公钥,b看成乙的私钥,B看成乙的公钥,DH算法的本质就是双方各自生成自己的私钥和公钥,私钥仅对自己可见,然后交换公钥,并根据自己的私钥和对方的公钥,生成最终的密钥secretKey,DH算法通过数学定律保证了双方各自计算出的secretKey是相同的。

使用Java实现DH算法的代码如下:

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
import java.security.*;
import java.security.spec.*;
import javax.crypto.KeyAgreement;
import java.util.HexFormat;

public class Main {
public static void main(String[] args) {
// Bob和Alice:
Person bob = new Person("Bob");
Person alice = new Person("Alice");

// 各自生成KeyPair:
bob.generateKeyPair();
alice.generateKeyPair();

// 双方交换各自的PublicKey:
// Bob根据Alice的PublicKey生成自己的本地密钥:
bob.generateSecretKey(alice.publicKey.getEncoded());
// Alice根据Bob的PublicKey生成自己的本地密钥:
alice.generateSecretKey(bob.publicKey.getEncoded());

// 检查双方的本地密钥是否相同:
bob.printKeys();
alice.printKeys();
// 双方的SecretKey相同,后续通信将使用SecretKey作为密钥进行AES加解密...
}
}

class Person {
public final String name;

public PublicKey publicKey;
private PrivateKey privateKey;
private byte[] secretKey;

public Person(String name) {
this.name = name;
}

// 生成本地KeyPair:
public void generateKeyPair() {
try {
KeyPairGenerator kpGen = KeyPairGenerator.getInstance("DH");
kpGen.initialize(512);
KeyPair kp = kpGen.generateKeyPair();
this.privateKey = kp.getPrivate();
this.publicKey = kp.getPublic();
} catch (GeneralSecurityException e) {
throw new RuntimeException(e);
}
}

public void generateSecretKey(byte[] receivedPubKeyBytes) {
try {
// 从byte[]恢复PublicKey:
X509EncodedKeySpec keySpec = new X509EncodedKeySpec(receivedPubKeyBytes);
KeyFactory kf = KeyFactory.getInstance("DH");
PublicKey receivedPublicKey = kf.generatePublic(keySpec);
// 生成本地密钥:
KeyAgreement keyAgreement = KeyAgreement.getInstance("DH");
keyAgreement.init(this.privateKey); // 自己的PrivateKey
keyAgreement.doPhase(receivedPublicKey, true); // 对方的PublicKey
// 生成SecretKey密钥:
this.secretKey = keyAgreement.generateSecret();
} catch (GeneralSecurityException e) {
throw new RuntimeException(e);
}
}

public void printKeys() {
System.out.println("Name: " + this.name);
System.out.println("Private key: " + HexFormat.of().formatHex(this.privateKey.getEncoded()));
System.out.println("Public key: " + HexFormat.of().formatHex(this.publicKey.getEncoded()));
System.out.println("Secret key: " + HexFormat.of().formatHex(this.secretKey));
}
}

但是DH算法并未解决中间人攻击,即甲乙双方并不能确保与自己通信的是否真的是对方。消除中间人攻击需要其他方法。

练习

使用DH算法生成密钥。

下载练习

小结

DH算法是一种密钥交换协议,通信双方通过不安全的信道协商密钥,然后进行对称加密传输。

DH算法没有解决中间人攻击。



从DH算法我们可以看到,公钥-私钥组成的密钥对是非常有用的加密方式,因为公钥是可以公开的,而私钥是完全保密的,由此奠定了非对称加密的基础。

非对称加密就是加密和解密使用的不是相同的密钥:只有同一个公钥-私钥对才能正常加解密。

因此,如果小明要加密一个文件发送给小红,他应该首先向小红索取她的公钥,然后,他用小红的公钥加密,把加密文件发送给小红,此文件只能由小红的私钥解开,因为小红的私钥在她自己手里,所以,除了小红,没有任何人能解开此文件。

非对称加密的典型算法就是RSA算法,它是由Ron Rivest,Adi Shamir,Leonard Adleman这三个哥们一起发明的,所以用他们仨的姓的首字母缩写表示。

非对称加密相比对称加密的显著优点在于,对称加密需要协商密钥,而非对称加密可以安全地公开各自的公钥,在N个人之间通信的时候:使用非对称加密只需要N个密钥对,每个人只管理自己的密钥对。而使用对称加密需要则需要N*(N-1)/2个密钥,因此每个人需要管理N-1个密钥,密钥管理难度大,而且非常容易泄漏。

既然非对称加密这么好,那我们抛弃对称加密,完全使用非对称加密行不行?也不行。因为非对称加密的缺点就是运算速度非常慢,比对称加密要慢很多。

所以,在实际应用的时候,非对称加密总是和对称加密一起使用。假设小明需要给小红需要传输加密文件,他俩首先交换了各自的公钥,然后:

  1. 小明生成一个随机的AES口令,然后用小红的公钥通过RSA加密这个口令,并发给小红;
  2. 小红用自己的RSA私钥解密得到AES口令;
  3. 双方使用这个共享的AES口令用AES加密通信。

可见非对称加密实际上应用在第一步,即加密“AES口令”。这也是我们在浏览器中常用的HTTPS协议的做法,即浏览器和服务器先通过RSA交换AES口令,接下来双方通信实际上采用的是速度较快的AES对称加密,而不是缓慢的RSA非对称加密。

Java标准库提供了RSA算法的实现,示例代码如下:

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
import java.security.*;
import javax.crypto.Cipher;
import java.util.HexFormat;

public class Main {
public static void main(String[] args) throws Exception {
// 明文:
byte[] plain = "Hello, encrypt use RSA".getBytes("UTF-8");
// 创建公钥/私钥对:
Person alice = new Person("Alice");
// 用Alice的公钥加密:
byte[] pk = alice.getPublicKey();
System.out.println("public key: " + HexFormat.of().formatHex(pk));
byte[] encrypted = alice.encrypt(plain);
System.out.println("encrypted: " + HexFormat.of().formatHex(encrypted));
// 用Alice的私钥解密:
byte[] sk = alice.getPrivateKey();
System.out.println("private key: " + HexFormat.of().formatHex(sk));
byte[] decrypted = alice.decrypt(encrypted);
System.out.println(new String(decrypted, "UTF-8"));
}
}

class Person {
String name;
// 私钥:
PrivateKey sk;
// 公钥:
PublicKey pk;

public Person(String name) throws GeneralSecurityException {
this.name = name;
// 生成公钥/私钥对:
KeyPairGenerator kpGen = KeyPairGenerator.getInstance("RSA");
kpGen.initialize(1024);
KeyPair kp = kpGen.generateKeyPair();
this.sk = kp.getPrivate();
this.pk = kp.getPublic();
}

// 把私钥导出为字节
public byte[] getPrivateKey() {
return this.sk.getEncoded();
}

// 把公钥导出为字节
public byte[] getPublicKey() {
return this.pk.getEncoded();
}

// 用公钥加密:
public byte[] encrypt(byte[] message) throws GeneralSecurityException {
Cipher cipher = Cipher.getInstance("RSA");
cipher.init(Cipher.ENCRYPT_MODE, this.pk);
return cipher.doFinal(message);
}

// 用私钥解密:
public byte[] decrypt(byte[] input) throws GeneralSecurityException {
Cipher cipher = Cipher.getInstance("RSA");
cipher.init(Cipher.DECRYPT_MODE, this.sk);
return cipher.doFinal(input);
}
}

RSA的公钥和私钥都可以通过getEncoded()方法获得以byte[]表示的二进制数据,并根据需要保存到文件中。要从byte[]数组恢复公钥或私钥,可以这么写:

1
2
3
4
5
6
7
8
9
byte[] pkData = ...
byte[] skData = ...
KeyFactory kf = KeyFactory.getInstance("RSA");
// 恢复公钥:
X509EncodedKeySpec pkSpec = new X509EncodedKeySpec(pkData);
PublicKey pk = kf.generatePublic(pkSpec);
// 恢复私钥:
PKCS8EncodedKeySpec skSpec = new PKCS8EncodedKeySpec(skData);
PrivateKey sk = kf.generatePrivate(skSpec);

以RSA算法为例,它的密钥有256/512/1024/2048/4096等不同的长度。长度越长,密码强度越大,当然计算速度也越慢。

如果修改待加密的byte[]数据的大小,可以发现,使用512bit的RSA加密时,明文长度不能超过53字节,使用1024bit的RSA加密时,明文长度不能超过117字节,这也是为什么使用RSA的时候,总是配合AES一起使用,即用AES加密任意长度的明文,用RSA加密AES口令。

此外,只使用非对称加密算法不能防止中间人攻击。

练习

使用RSA算法加密。

下载练习

小结

非对称加密就是加密和解密使用的不是相同的密钥,只有同一个公钥-私钥对才能正常加解密;

只使用非对称加密算法不能防止中间人攻击。

签名算法

我们使用非对称加密算法的时候,对于一个公钥-私钥对,通常是用公钥加密,私钥解密。

如果使用私钥加密,公钥解密是否可行呢?实际上是完全可行的。

不过我们再仔细想一想,私钥是保密的,而公钥是公开的,用私钥加密,那相当于所有人都可以用公钥解密。这个加密有什么意义?

这个加密的意义在于,如果小明用自己的私钥加密了一条消息,比如小明喜欢小红,然后他公开了加密消息,由于任何人都可以用小明的公钥解密,从而使得任何人都可以确认小明喜欢小红这条消息肯定是小明发出的,其他人不能伪造这个消息,小明也不能抵赖这条消息不是自己写的。

因此,私钥加密得到的密文实际上就是数字签名,要验证这个签名是否正确,只能用私钥持有者的公钥进行解密验证。使用数字签名的目的是为了确认某个信息确实是由某个发送方发送的,任何人都不可能伪造消息,并且,发送方也不能抵赖。

在实际应用的时候,签名实际上并不是针对原始消息,而是针对原始消息的哈希进行签名,即:

1
signature = encrypt(privateKey, sha256(message))

对签名进行验证实际上就是用公钥解密:

1
hash = decrypt(publicKey, signature)

然后把解密后的哈希与原始消息的哈希进行对比。

因为用户总是使用自己的私钥进行签名,所以,私钥就相当于用户身份。而公钥用来给外部验证用户身份。

常用数字签名算法有:

  • MD5withRSA
  • SHA1withRSA
  • SHA256withRSA

它们实际上就是指定某种哈希算法进行RSA签名的方式。

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
import java.nio.charset.StandardCharsets;
import java.security.*;
import java.util.HexFormat;

public class Main {
public static void main(String[] args) throws GeneralSecurityException {
// 生成RSA公钥/私钥:
KeyPairGenerator kpGen = KeyPairGenerator.getInstance("RSA");
kpGen.initialize(1024);
KeyPair kp = kpGen.generateKeyPair();
PrivateKey sk = kp.getPrivate();
PublicKey pk = kp.getPublic();

// 待签名的消息:
byte[] message = "Hello, I am Bob!".getBytes(StandardCharsets.UTF_8);

// 用私钥签名:
Signature s = Signature.getInstance("SHA1withRSA");
s.initSign(sk);
s.update(message);
byte[] signed = s.sign();
System.out.println("signature: " + HexFormat.of().formatHex(signed));

// 用公钥验证:
Signature v = Signature.getInstance("SHA1withRSA");
v.initVerify(pk);
v.update(message);
boolean valid = v.verify(signed);
System.out.println("valid? " + valid);
}
}

使用其他公钥,或者验证签名的时候修改原始信息,都无法验证成功。

DSA签名

除了RSA可以签名外,还可以使用DSA算法进行签名。DSA是Digital Signature Algorithm的缩写,它使用ElGamal数字签名算法。

DSA只能配合SHA使用,常用的算法有:

  • SHA1withDSA
  • SHA256withDSA
  • SHA512withDSA

和RSA数字签名相比,DSA的优点是更快。

ECDSA签名

椭圆曲线签名算法ECDSA:Elliptic Curve Digital Signature Algorithm也是一种常用的签名算法,它的特点是可以从私钥推出公钥。比特币的签名算法就采用了ECDSA算法,使用标准椭圆曲线secp256k1。BouncyCastle提供了ECDSA的完整实现。

练习

使用RSA算法进行签名。

下载练习

小结

数字签名就是用发送方的私钥对原始数据进行签名,只有用发送方公钥才能通过签名验证。

数字签名用于:

  • 防止伪造;
  • 防止抵赖;
  • 检测篡改。

常用的数字签名算法包括:MD5withRSA/SHA1withRSA/SHA256withRSA/SHA1withDSA/SHA256withDSA/SHA512withDSA/ECDSA等。



我们知道,摘要算法用来确保数据没有被篡改,非对称加密算法可以对数据进行加解密,签名算法可以确保数据完整性和抗否认性,把这些算法集合到一起,并搞一套完善的标准,这就是数字证书。

因此,数字证书就是集合了多种密码学算法,用于实现数据加解密、身份认证、签名等多种功能的一种安全标准。

数字证书可以防止中间人攻击,因为它采用链式签名认证,即通过根证书(Root CA)去签名下一级证书,这样层层签名,直到最终的用户证书。而Root CA证书内置于操作系统中,所以,任何经过CA认证的数字证书都可以对其本身进行校验,确保证书本身不是伪造的。

我们在上网时常用的HTTPS协议就是数字证书的应用。浏览器会自动验证证书的有效性:

certificate

要使用数字证书,首先需要创建证书。正常情况下,一个合法的数字证书需要经过CA签名,这需要认证域名并支付一定的费用。开发的时候,我们可以使用自签名的证书,这种证书可以正常开发调试,但不能对外作为服务使用,因为其他客户端并不认可未经CA签名的证书。

注:腾讯云可申请有效期1年的免费SSL证书,Let’s Encrypt可申请有效期90天的免费SSL证书。

在Java程序中,数字证书存储在一种Java专用的key store文件中,JDK提供了一系列命令来创建和管理key store。我们用下面的命令创建一个key store,并设定口令123456:

1
keytool -storepass 123456 -genkeypair -keyalg RSA -keysize 1024 -sigalg SHA1withRSA -validity 3650 -alias mycert -keystore my.keystore -dname "CN=www.sample.com, OU=sample, O=sample, L=BJ, ST=BJ, C=CN"

几个主要的参数是:

  • keyalg:指定RSA加密算法;
  • sigalg:指定SHA1withRSA签名算法;
  • validity:指定证书有效期3650天;
  • alias:指定证书在程序中引用的名称;
  • dname:最重要的CN=www.sample.com指定了Common Name,如果证书用在HTTPS中,这个名称必须与域名完全一致。

执行上述命令,JDK会在当前目录创建一个my.keystore文件,并存储创建成功的一个私钥和一个证书,它的别名是mycert

有了key store存储的证书,我们就可以通过数字证书进行加解密和签名:

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
import java.io.InputStream;
import java.security.*;
import java.security.cert.*;
import javax.crypto.Cipher;
import java.util.HexFormat;

public class Main {
public static void main(String[] args) throws Exception {
byte[] message = "Hello, use X.509 cert!".getBytes("UTF-8");
// 读取KeyStore:
KeyStore ks = loadKeyStore("/my.keystore", "123456");
// 读取私钥:
PrivateKey privateKey = (PrivateKey) ks.getKey("mycert", "123456".toCharArray());
// 读取证书:
X509Certificate certificate = (X509Certificate) ks.getCertificate("mycert");
// 加密:
byte[] encrypted = encrypt(certificate, message);
System.out.println("encrypted: " + HexFormat.of().formatHex(encrypted));
// 解密:
byte[] decrypted = decrypt(privateKey, encrypted);
System.out.println("decrypted: " + new String(decrypted, "UTF-8"));
// 签名:
byte[] sign = sign(privateKey, certificate, message);
System.out.println("signature: " + HexFormat.of().formatHex(sign));
// 验证签名:
boolean verified = verify(certificate, message, sign);
System.out.println("verify: " + verified);
}

static KeyStore loadKeyStore(String keyStoreFile, String password) {
try (InputStream input = Main.class.getResourceAsStream(keyStoreFile)) {
if (input == null) {
throw new RuntimeException("file not found in classpath: " + keyStoreFile);
}
KeyStore ks = KeyStore.getInstance(KeyStore.getDefaultType());
ks.load(input, password.toCharArray());
return ks;
} catch (Exception e) {
throw new RuntimeException(e);
}
}

static byte[] encrypt(X509Certificate certificate, byte[] message) throws GeneralSecurityException {
Cipher cipher = Cipher.getInstance(certificate.getPublicKey().getAlgorithm());
cipher.init(Cipher.ENCRYPT_MODE, certificate.getPublicKey());
return cipher.doFinal(message);
}

static byte[] decrypt(PrivateKey privateKey, byte[] data) throws GeneralSecurityException {
Cipher cipher = Cipher.getInstance(privateKey.getAlgorithm());
cipher.init(Cipher.DECRYPT_MODE, privateKey);
return cipher.doFinal(data);
}

static byte[] sign(PrivateKey privateKey, X509Certificate certificate, byte[] message)
throws GeneralSecurityException {
Signature signature = Signature.getInstance(certificate.getSigAlgName());
signature.initSign(privateKey);
signature.update(message);
return signature.sign();
}

static boolean verify(X509Certificate certificate, byte[] message, byte[] sig) throws GeneralSecurityException {
Signature signature = Signature.getInstance(certificate.getSigAlgName());
signature.initVerify(certificate);
signature.update(message);
return signature.verify(sig);
}
}

在上述代码中,我们从key store直接读取了私钥-公钥对,私钥以PrivateKey实例表示,公钥以X509Certificate表示,实际上数字证书只包含公钥,因此,读取证书并不需要口令,只有读取私钥才需要。如果部署到Web服务器上,例如Nginx,需要把私钥导出为Private Key格式,把证书导出为X509Certificate格式。

以HTTPS协议为例,浏览器和服务器建立安全连接的步骤如下:

  1. 浏览器向服务器发起请求,服务器向浏览器发送自己的数字证书;
  2. 浏览器用操作系统内置的Root CA来验证服务器的证书是否有效,如果有效,就使用该证书加密一个随机的AES口令并发送给服务器;
  3. 服务器用自己的私钥解密获得AES口令,并在后续通讯中使用AES加密。

上述流程只是一种最常见的单向验证。如果服务器还要验证客户端,那么客户端也需要把自己的证书发送给服务器验证,这种场景常见于网银等。

注意:数字证书存储的是公钥,以及相关的证书链和算法信息。私钥必须严格保密,如果数字证书对应的私钥泄漏,就会造成严重的安全威胁。如果CA证书的私钥泄漏,那么该CA证书签发的所有证书将不可信。数字证书服务商DigiNotar就发生过私钥泄漏导致公司破产的事故。

练习

使用数字证书实现消息加密。

下载练习

小结

数字证书就是集合了多种密码学算法,用于实现数据加解密、身份认证、签名等多种功能的一种安全标准;

数字证书采用链式签名管理,顶级的Root CA证书已内置在操作系统中;

数字证书存储的是公钥,可以安全公开,而私钥必须严格保密。

留言與分享

JAVA-正则表达式

分類 编程语言, Java

正则表达式简介

在了解正则表达式之前,我们先看几个非常常见的问题:

  • 如何判断字符串是否是有效的电话号码?例如:010-1234567123ABC45613510001000等;
  • 如何判断字符串是否是有效的电子邮件地址?例如:test@example.comtest#example等;
  • 如何判断字符串是否是有效的时间?例如:12:3409:6099:99等。

一种直观的想法是通过程序判断,这种方法需要为每种用例创建规则,然后用代码实现。下面是判断手机号的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
boolean isValidMobileNumber(String s) {
// 是否是11位?
if (s.length() != 11) {
return false;
}
// 每一位都是0~9:
for (int i=0; i<s.length(); i++) {
char c = s.charAt(i);
if (c < '0' || c > '9') {
return false;
}
}
return true;
}

上述代码仅仅做了非常粗略的判断,并未考虑首位数字不能为0等更详细的情况。

除了判断手机号,我们还需要判断电子邮件地址、电话、邮编等等:

  • boolean isValidMobileNumber(String s) { … }
  • boolean isValidEmail(String s) { … }
  • boolean isValidPhoneNumber(String s) { … }
  • boolean isValidZipCode(String s) { … }

为每一种判断逻辑编写代码实在是太繁琐了。有没有更简单的方法?

有!用正则表达式!

正则表达式可以用字符串来描述规则,并用来匹配字符串。例如,判断手机号,我们用正则表达式\d{11}

1
2
3
boolean isValidMobileNumber(String s) {
return s.matches("\\d{11}");
}

使用正则表达式的好处有哪些?一个正则表达式就是一个描述规则的字符串,所以,只需要编写正确的规则,我们就可以让正则表达式引擎去判断目标字符串是否符合规则。

正则表达式是一套标准,它可以用于任何语言。Java标准库的java.util.regex包内置了正则表达式引擎,在Java程序中使用正则表达式非常简单。

举个例子:要判断用户输入的年份是否是20##年,我们先写出规则如下:

一共有4个字符,分别是:200~9任意数字0~9任意数字

对应的正则表达式就是:20\d\d,其中\d表示任意一个数字。

把正则表达式转换为Java字符串就变成了20\\d\\d,注意Java字符串用\\表示\

最后,用正则表达式匹配一个字符串的代码如下:

1
2
3
4
5
6
7
8
// regex
public class Main {
public static void main(String[] args) {
String regex = "20\\d\\d";
System.out.println("2019".matches(regex)); // true
System.out.println("2100".matches(regex)); // false
}
}

可见,使用正则表达式,不必编写复杂的代码来判断,只需给出一个字符串表达的正则规则即可。

小结

正则表达式是用字符串描述的一个匹配规则,使用正则表达式可以快速判断给定的字符串是否符合匹配规则;

Java标准库java.util.regex内建了正则表达式引擎。



正则表达式的匹配规则是从左到右按规则匹配。我们首先来看如何使用正则表达式来做精确匹配。

对于正则表达式abc来说,它只能精确地匹配字符串"abc",不能匹配"ab""Abc""abcd"等其他任何字符串。

如果正则表达式有特殊字符,那就需要用\转义。例如,正则表达式a\&c,其中\&是用来匹配特殊字符&的,它能精确匹配字符串"a&c",但不能匹配"ac""a-c""a&&c"等。

要注意正则表达式在Java代码中也是一个字符串,所以,对于正则表达式a\&c来说,对应的Java字符串是"a\\&c",因为\也是Java字符串的转义字符,两个\\实际上表示的是一个\

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// regex
public class Main {
public static void main(String[] args) {
String re1 = "abc";
System.out.println("abc".matches(re1));
System.out.println("Abc".matches(re1));
System.out.println("abcd".matches(re1));

String re2 = "a\\&c"; // 对应的正则是a\&c
System.out.println("a&c".matches(re2));
System.out.println("a-c".matches(re2));
System.out.println("a&&c".matches(re2));
}
}

如果想匹配非ASCII字符,例如中文,那就用\u####的十六进制表示,例如:a\u548cc匹配字符串"a和c",中文字符的Unicode编码是548c

匹配任意字符

精确匹配实际上用处不大,因为我们直接用String.equals()就可以做到。大多数情况下,我们想要的匹配规则更多的是模糊匹配。我们可以用.匹配一个任意字符。

例如,正则表达式a.c中间的.可以匹配一个任意字符,例如,下面的字符串都可以被匹配:

  • "abc",因为.可以匹配字符b
  • "a&c",因为.可以匹配字符&
  • "acc",因为.可以匹配字符c

但它不能匹配"ac""a&&c",因为.匹配一个字符且仅限一个字符。

匹配数字

.可以匹配任意字符,这个口子开得有点大。如果我们只想匹配0~9这样的数字,可以用\d匹配。例如,正则表达式00\d可以匹配:

  • "007",因为\d可以匹配字符7
  • "008",因为\d可以匹配字符8

它不能匹配"00A""0077",因为\d仅限单个数字字符。

匹配常用字符

\w可以匹配一个字母、数字或下划线,w的意思是word。例如,java\w可以匹配:

  • "javac",因为\w可以匹配英文字符c
  • "java9",因为\w可以匹配数字字符9;。
  • "java_",因为\w可以匹配下划线_

它不能匹配"java#""java ",因为\w不能匹配#、空格等字符。

匹配空格字符

\s可以匹配一个空格字符,注意空格字符不但包括空格 ,还包括tab字符(在Java中用\t表示)。例如,a\sc可以匹配:

  • "a c",因为\s可以匹配空格字符 ;
  • "a c",因为\s可以匹配tab字符\t

它不能匹配"ac""abc"等。

匹配非数字

\d可以匹配一个数字,而\D则匹配一个非数字。例如,00\D可以匹配:

  • "00A",因为\D可以匹配非数字字符A
  • "00#",因为\D可以匹配非数字字符#

00\d可以匹配的字符串"007""008"等,00\D是不能匹配的。

类似的,\W可以匹配\w不能匹配的字符,\S可以匹配\s不能匹配的字符,这几个正好是反着来的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// regex
public class Main {
public static void main(String[] args) {
String re1 = "java\\d"; // 对应的正则是java\d
System.out.println("java9".matches(re1));
System.out.println("java10".matches(re1));
System.out.println("javac".matches(re1));

String re2 = "java\\D";
System.out.println("javax".matches(re2));
System.out.println("java#".matches(re2));
System.out.println("java5".matches(re2));
}
}

重复匹配

我们用\d可以匹配一个数字,例如,A\d可以匹配"A0""A1",如果要匹配多个数字,比如"A380",怎么办?

修饰符*可以匹配任意个字符,包括0个字符。我们用A\d*可以匹配:

  • A:因为\d*可以匹配0个数字;
  • A0:因为\d*可以匹配1个数字0
  • A380:因为\d*可以匹配多个数字380

修饰符+可以匹配至少一个字符。我们用A\d+可以匹配:

  • A0:因为\d+可以匹配1个数字0
  • A380:因为\d+可以匹配多个数字380

但它无法匹配"A",因为修饰符+要求至少一个字符。

修饰符?可以匹配0个或一个字符。我们用A\d?可以匹配:

  • A:因为\d?可以匹配0个数字;
  • A0:因为\d?可以匹配1个数字0

但它无法匹配"A380",因为修饰符?超过1个字符就不能匹配了。

如果我们想精确指定n个字符怎么办?用修饰符{n}就可以。A\d{3}可以精确匹配:

  • A380:因为\d{3}可以匹配3个数字380

如果我们想指定匹配n~m个字符怎么办?用修饰符{n,m}就可以。A\d{3,5}可以精确匹配:

  • A380:因为\d{3,5}可以匹配3个数字380
  • A3800:因为\d{3,5}可以匹配4个数字3800
  • A38000:因为\d{3,5}可以匹配5个数字38000

如果没有上限,那么修饰符{n,}就可以匹配至少n个字符。

练习

请编写一个正则表达式匹配国内的电话号码规则:3~4位区号加7~8位电话,中间用-连接,例如:010-12345678

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// regex
import java.util.*;

public class Main {
public static void main(String[] args) throws Exception {
String re = "\\d";
for (String s : List.of("010-12345678", "020-9999999", "0755-7654321")) {
if (!s.matches(re)) {
System.out.println("测试失败: " + s);
return;
}
}
for (String s : List.of("010 12345678", "A20-9999999", "0755-7654.321")) {
if (s.matches(re)) {
System.out.println("测试失败: " + s);
return;
}
}
System.out.println("测试成功!");
}
}

下载练习

进阶:国内区号必须以0开头,而电话号码不能以0开头,试修改正则表达式,使之能更精确地匹配。

提示:\d\D这种简单的规则暂时做不到,我们需要更复杂规则,后面会详细讲解。

小结

单个字符的匹配规则如下:

正则表达式 规则 可以匹配
A 指定字符 A
\u548c 指定Unicode字符
. 任意字符 ab&0
\d 数字0~9 0~9
\w 大小写字母,数字和下划线 a~zA~Z0~9_
\s 空格、Tab键 空格,Tab
\D 非数字 aA&_,……
\W 非\w &@,……
\S 非\s aA&_,……

多个字符的匹配规则如下:

正则表达式 规则 可以匹配
A* 任意个数字符 空,AAAAAA,……
A+ 至少1个字符 AAAAAA,……
A? 0个或1个字符 空,A
A{3} 指定个数字符 AAA
A{2,3} 指定范围个数字符 AAAAA
A{2,} 至少n个字符 AAAAAAAAA,……
A{0,3} 最多n个字符 空,AAAAAA

复杂匹配规则

匹配开头和结尾

用正则表达式进行多行匹配时,我们用^表示开头,$表示结尾。例如,^A\d{3}$,可以匹配"A001""A380"

匹配指定范围

如果我们规定一个7~8位数字的电话号码不能以0开头,应该怎么写匹配规则呢?\d{7,8}是不行的,因为第一个\d可以匹配到0

使用[...]可以匹配范围内的字符,例如,[123456789]可以匹配1~9,这样就可以写出上述电话号码的规则:[123456789]\d{6,7}

把所有字符全列出来太麻烦,[...]还有一种写法,直接写[1-9]就可以。

要匹配大小写不限的十六进制数,比如1A2b3c,我们可以这样写:[0-9a-fA-F],它表示一共可以匹配以下任意范围的字符:

  • 0-9:字符0~9
  • a-f:字符a~f
  • A-F:字符A~F

如果要匹配6位十六进制数,前面讲过的{n}仍然可以继续配合使用:[0-9a-fA-F]{6}

[...]还有一种排除法,即不包含指定范围的字符。假设我们要匹配任意字符,但不包括数字,可以写[^1-9]{3}

  • 可以匹配"ABC",因为不包含字符1~9
  • 可以匹配"A00",因为不包含字符1~9
  • 不能匹配"A01",因为包含字符1
  • 不能匹配"A05",因为包含字符5

或规则匹配

|连接的两个正则规则是规则,例如,AB|CD表示可以匹配ABCD

我们来看这个正则表达式java|php

1
2
3
4
5
6
7
8
9
// regex
public class Main {
public static void main(String[] args) {
String re = "java|php";
System.out.println("java".matches(re));
System.out.println("php".matches(re));
System.out.println("go".matches(re));
}
}

它可以匹配"java""php",但无法匹配"go"

要把go也加进来匹配,可以改写为java|php|go

使用括号

现在我们想要匹配字符串learn javalearn phplearn go怎么办?一个最简单的规则是learn\sjava|learn\sphp|learn\sgo,但是这个规则太复杂了,可以把公共部分提出来,然后用(...)把子规则括起来表示成learn\s(java|php|go)

1
2
3
4
5
6
7
8
9
10
// regex
public class Main {
public static void main(String[] args) {
String re = "learn\\s(java|php|go)";
System.out.println("learn java".matches(re));
System.out.println("learn Java".matches(re));
System.out.println("learn php".matches(re));
System.out.println("learn Go".matches(re));
}
}

上面的规则仍然不能匹配learn Javalearn Go这样的字符串。试修改正则,使之能匹配大写字母开头的learn Javalearn Phplearn Go

小结

复杂匹配规则主要有:

正则表达式 规则 可以匹配
^ 开头 字符串开头
$ 结尾 字符串结束
[ABC] […]内任意字符 A,B,C
[A-F0-9xy] 指定范围的字符 A,……,F0,……,9xy
[^A-F] 指定范围外的任意字符 A~F
AB CD EF


我们前面讲到的(...)可以用来把一个子规则括起来,这样写learn\s(java|php|go)就可以更方便地匹配长字符串了。

实际上(...)还有一个重要作用,就是分组匹配。

我们来看一下如何用正则匹配区号-电话号码这个规则。利用前面讲到的匹配规则,写出来很容易:

1
\d{3,4}\-\d{6,8}

虽然这个正则匹配规则很简单,但是往往匹配成功后,下一步是提取区号和电话号码,分别存入数据库。于是问题来了:如何提取匹配的子串?

当然可以用String提供的indexOf()substring()这些方法,但它们从正则匹配的字符串中提取子串没有通用性,下一次要提取learn\s(java|php)还得改代码。

正确的方法是用(...)先把要提取的规则分组,把上述正则表达式变为(\d{3,4})\-(\d{6,8})

现在问题又来了:匹配后,如何按括号提取子串?

现在我们没办法用String.matches()这样简单的判断方法了,必须引入java.util.regex包,用Pattern对象匹配,匹配后获得一个Matcher对象,如果匹配成功,就可以直接从Matcher.group(index)返回子串:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import java.util.regex.*;

public class Main {
public static void main(String[] args) {
Pattern p = Pattern.compile("(\\d{3,4})\\-(\\d{7,8})");
Matcher m = p.matcher("010-12345678");
if (m.matches()) {
String g1 = m.group(1);
String g2 = m.group(2);
System.out.println(g1); // 010
System.out.println(g2); // 12345678
} else {
System.out.println("匹配失败!");
}
}
}

运行上述代码,会得到两个匹配上的子串01012345678

要特别注意,Matcher.group(index)方法的参数用1表示第一个子串,2表示第二个子串。如果我们传入0会得到什么呢?答案是010-12345678,即整个正则匹配到的字符串。

Pattern

我们在前面的代码中用到的正则表达式代码是String.matches()方法,而我们在分组提取的代码中用的是java.util.regex包里面的Pattern类和Matcher类。实际上这两种代码本质上是一样的,因为String.matches()方法内部调用的就是PatternMatcher类的方法。

但是反复使用String.matches()对同一个正则表达式进行多次匹配效率较低,因为每次都会创建出一样的Pattern对象。完全可以先创建出一个Pattern对象,然后反复使用,就可以实现编译一次,多次匹配:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import java.util.regex.*;

public class Main {
public static void main(String[] args) {
Pattern pattern = Pattern.compile("(\\d{3,4})\\-(\\d{7,8})");
pattern.matcher("010-12345678").matches(); // true
pattern.matcher("021-123456").matches(); // false
pattern.matcher("022#1234567").matches(); // false
// 获得Matcher对象:
Matcher matcher = pattern.matcher("010-12345678");
if (matcher.matches()) {
String whole = matcher.group(0); // "010-12345678", 0表示匹配的整个字符串
String area = matcher.group(1); // "010", 1表示匹配的第1个子串
String tel = matcher.group(2); // "12345678", 2表示匹配的第2个子串
System.out.println(area);
System.out.println(tel);
}
}
}

使用Matcher时,必须首先调用matches()判断是否匹配成功,匹配成功后,才能调用group()提取子串。

利用提取子串的功能,我们轻松获得了区号和号码两部分。

练习

利用分组匹配,从字符串"23:01:59"提取时、分、秒。

下载练习

小结

正则表达式用(...)分组可以通过Matcher对象快速提取子串:

  • group(0)表示匹配的整个字符串;
  • group(1)表示第1个子串,group(2)表示第2个子串,以此类推。

非贪婪匹配

在介绍非贪婪匹配前,我们先看一个简单的问题:

给定一个字符串表示的数字,判断该数字末尾0的个数。例如:

  • "123000":3个0
  • "10100":2个0
  • "1001":0个0

可以很容易地写出该正则表达式:(\d+)(0*),Java代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
import java.util.regex.*;

public class Main {
public static void main(String[] args) {
Pattern pattern = Pattern.compile("(\\d+)(0*)");
Matcher matcher = pattern.matcher("1230000");
if (matcher.matches()) {
System.out.println("group1=" + matcher.group(1)); // "1230000"
System.out.println("group2=" + matcher.group(2)); // ""
}
}
}

然而打印的第二个子串是空字符串""

实际上,我们期望分组匹配结果是:

input \d+ 0*
123000 “123” “000”
10100 “101” “00”
1001 “1001” “”

但实际的分组匹配结果是这样的:

input \d+ 0*
123000 “123000” “”
10100 “10100” “”
1001 “1001” “”

仔细观察上述实际匹配结果,实际上它是完全合理的,因为\d+确实可以匹配后面任意个0

这是因为正则表达式默认使用贪婪匹配:任何一个规则,它总是尽可能多地向后匹配,因此,\d+总是会把后面的0包含进来。

要让\d+尽量少匹配,让0*尽量多匹配,我们就必须让\d+使用非贪婪匹配。在规则\d+后面加个?即可表示非贪婪匹配。我们改写正则表达式如下:

1
2
3
4
5
6
7
8
9
10
11
12
import java.util.regex.*;

public class Main {
public static void main(String[] args) {
Pattern pattern = Pattern.compile("(\\d+?)(0*)");
Matcher matcher = pattern.matcher("1230000");
if (matcher.matches()) {
System.out.println("group1=" + matcher.group(1)); // "123"
System.out.println("group2=" + matcher.group(2)); // "0000"
}
}
}

因此,给定一个匹配规则,加上?后就变成了非贪婪匹配。

我们再来看这个正则表达式(\d??)(9*),注意\d?表示匹配0个或1个数字,后面第二个?表示非贪婪匹配,因此,给定字符串"9999",匹配到的两个子串分别是"""9999",因为对于\d?来说,可以匹配1个9,也可以匹配0个9,但是因为后面的?表示非贪婪匹配,它就会尽可能少的匹配,结果是匹配了0个9

小结

正则表达式匹配默认使用贪婪匹配,可以使用?表示对某一规则进行非贪婪匹配;

注意区分?的含义:\d??



分割字符串

使用正则表达式分割字符串可以实现更加灵活的功能。String.split()方法传入的正是正则表达式。我们来看下面的代码:

1
2
3
"a b c".split("\\s"); // { "a", "b", "c" }
"a b c".split("\\s"); // { "a", "b", "", "c" }
"a, b ;; c".split("[\\,\\;\\s]+"); // { "a", "b", "c" }

如果我们想让用户输入一组标签,然后把标签提取出来,因为用户的输入往往是不规范的,这时,使用合适的正则表达式,就可以消除多个空格、混合,;这些不规范的输入,直接提取出规范的字符串。

搜索字符串

使用正则表达式还可以搜索字符串,我们来看例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
import java.util.regex.*;

public class Main {
public static void main(String[] args) {
String s = "the quick brown fox jumps over the lazy dog.";
Pattern p = Pattern.compile("\\wo\\w");
Matcher m = p.matcher(s);
while (m.find()) {
String sub = s.substring(m.start(), m.end());
System.out.println(sub);
}
}
}

我们获取到Matcher对象后,不需要调用matches()方法(因为匹配整个串肯定返回false),而是反复调用find()方法,在整个串中搜索能匹配上\\wo\\w规则的子串,并打印出来。这种方式比String.indexOf()要灵活得多,因为我们搜索的规则是3个字符:中间必须是o,前后两个必须是字符[A-Za-z0-9_]

替换字符串

使用正则表达式替换字符串可以直接调用String.replaceAll(),它的第一个参数是正则表达式,第二个参数是待替换的字符串。我们还是来看例子:

1
2
3
4
5
6
7
8
// regex
public class Main {
public static void main(String[] args) {
String s = "The quick\t\t brown fox jumps over the lazy dog.";
String r = s.replaceAll("\\s+", " ");
System.out.println(r); // "The quick brown fox jumps over the lazy dog."
}
}

上面的代码把不规范的连续空格分隔的句子变成了规范的句子。可见,灵活使用正则表达式可以大大降低代码量。

反向引用

如果我们要把搜索到的指定字符串按规则替换,比如前后各加一个<b>xxxx</b>,这个时候,使用replaceAll()的时候,我们传入的第二个参数可以使用$1$2来反向引用匹配到的子串。例如:

1
2
3
4
5
6
7
8
// regex
public class Main {
public static void main(String[] args) {
String s = "the quick brown fox jumps over the lazy dog.";
String r = s.replaceAll("\\s([a-z]{4})\\s", " <b>$1</b> ");
System.out.println(r);
}
}

上述代码的运行结果是:

1
the quick brown fox jumps <b>over</b> the <b>lazy</b> dog.

它实际上把任何4字符单词的前后用<b>xxxx</b>括起来。实现替换的关键就在于" <b>$1</b> ",它用匹配的分组子串([a-z]{4})替换了$1

练习

模板引擎是指,定义一个字符串作为模板:

1
Hello, ${name}! You are learning ${lang}!

其中,以${key}表示的是变量,也就是将要被替换的内容。

当传入一个Map<String, String>给模板后,需要把对应的key替换为Map的value。

例如,传入Map为:

1
2
3
4
{
"name": "Bob",
"lang": "Java"
}

然后,${name}被替换为Map对应的值Bob${lang}被替换为Map对应的值Java,最终输出的结果为:

1
Hello, Bob! You are learning Java!

请编写一个简单的模板引擎,利用正则表达式实现这个功能。

提示:参考Matcher.appendReplacement()方法。

下载练习

小结

使用正则表达式可以:

  • 分割字符串:String.split()
  • 搜索子串:Matcher.find()
  • 替换字符串:String.replaceAll()

留言與分享

JAVA-单元测试

分類 编程语言, Java

什么是单元测试呢?单元测试就是针对最小的功能单元编写测试代码。Java程序最小的功能单元是方法,因此,对Java程序进行单元测试就是针对单个Java方法的测试。

单元测试有什么好处呢?在学习单元测试前,我们可以先了解一下测试驱动开发。

所谓测试驱动开发,是指先编写接口,紧接着编写测试。编写完测试后,我们才开始真正编写实现代码。在编写实现代码的过程中,一边写,一边测,什么时候测试全部通过了,那就表示编写的实现完成了:

1
2
3
4
5
6
7
8
9
10
11
12
13
    编写接口


编写测试


┌─▶ 编写实现
│ │
│ N ▼
└── 运行测试
│ Y

任务完成

这就是传说中的……

tdd

当然,这是一种理想情况。大部分情况是我们已经编写了实现代码,需要对已有的代码进行测试。

我们先通过一个示例来看如何编写测试。假定我们编写了一个计算阶乘的类,它只有一个静态方法来计算阶乘:

代码如下:

1
2
3
4
5
6
7
8
9
public class Factorial {
public static long fact(long n) {
long r = 1;
for (long i = 1; i <= n; i++) {
r = r * i;
}
return r;
}
}

要测试这个方法,一个很自然的想法是编写一个main()方法,然后运行一些测试代码:

1
2
3
4
5
6
7
8
9
public class Test {
public static void main(String[] args) {
if (fact(10) == 3628800) {
System.out.println("pass");
} else {
System.out.println("fail");
}
}
}

这样我们就可以通过运行main()方法来运行测试代码。

不过,使用main()方法测试有很多缺点:

一是只能有一个main()方法,不能把测试代码分离,二是没有打印出测试结果和期望结果,例如,expected: 3628800, but actual: 123456,三是很难编写一组通用的测试代码。

因此,我们需要一种测试框架,帮助我们编写测试。

JUnit

JUnit是一个开源的Java语言的单元测试框架,专门针对Java设计,使用最广泛。JUnit是事实上的单元测试的标准框架,任何Java开发者都应当学习并使用JUnit编写单元测试。

使用JUnit编写单元测试的好处在于,我们可以非常简单地组织测试代码,并随时运行它们,JUnit就会给出成功的测试和失败的测试,还可以生成测试报告,不仅包含测试的成功率,还可以统计测试的代码覆盖率,即被测试的代码本身有多少经过了测试。对于高质量的代码来说,测试覆盖率应该在80%以上。

此外,几乎所有的IDE工具都集成了JUnit,这样我们就可以直接在IDE中编写并运行JUnit测试。JUnit目前最新版本是5。

以Eclipse为例,当我们已经编写了一个Factorial.java文件后,我们想对其进行测试,需要编写一个对应的FactorialTest.java文件,以Test为后缀是一个惯例,并分别将其放入srctest目录中。最后,在Project - Properties - Java Build Path - Libraries中添加JUnit 5的库:

junit-lib

整个项目结构如下:

junit-test-structure

我们来看一下FactorialTest.java的内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package com.itranswarp.learnjava;

import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.Test;

public class FactorialTest {

@Test
void testFact() {
assertEquals(1, Factorial.fact(1));
assertEquals(2, Factorial.fact(2));
assertEquals(6, Factorial.fact(3));
assertEquals(3628800, Factorial.fact(10));
assertEquals(2432902008176640000L, Factorial.fact(20));
}
}

核心测试方法testFact()加上了@Test注解,这是JUnit要求的,它会把带有@Test的方法识别为测试方法。在测试方法内部,我们用assertEquals(1, Factorial.fact(1))表示,期望Factorial.fact(1)返回1assertEquals(expected, actual)是最常用的测试方法,它在Assertion类中定义。Assertion还定义了其他断言方法,例如:

  • assertTrue(): 期待结果为true
  • assertFalse(): 期待结果为false
  • assertNotNull(): 期待结果为非null
  • assertArrayEquals(): 期待结果为数组并与期望数组每个元素的值均相等

运行单元测试非常简单。选中FactorialTest.java文件,点击Run - Run As - JUnit Test,Eclipse会自动运行这个JUnit测试,并显示结果:

junit-test-ok

如果测试结果与预期不符,assertEquals()会抛出异常,我们就会得到一个测试失败的结果:

junit-test-failed

在Failure Trace中,JUnit会告诉我们详细的错误结果:

1
2
3
4
5
6
7
8
9
org.opentest4j.AssertionFailedError: expected: <3628800> but was: <362880>
at org.junit.jupiter.api.AssertionUtils.fail(AssertionUtils.java:55)
at org.junit.jupiter.api.AssertEquals.failNotEqual(AssertEquals.java:195)
at org.junit.jupiter.api.AssertEquals.assertEquals(AssertEquals.java:168)
at org.junit.jupiter.api.AssertEquals.assertEquals(AssertEquals.java:163)
at org.junit.jupiter.api.Assertions.assertEquals(Assertions.java:611)
at com.itranswarp.learnjava.FactorialTest.testFact(FactorialTest.java:14)
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
at ...

第一行的失败信息的意思是期待结果3628800但是实际返回是362880,此时,我们要么修正实现代码,要么修正测试代码,直到测试通过为止。

使用浮点数时,由于浮点数无法精确地进行比较,因此,我们需要调用assertEquals(double expected, double actual, double delta)这个重载方法,指定一个误差值:

1
assertEquals(0.1, Math.abs(1 - 9 / 10.0), 0.0000001);

单元测试的好处

单元测试可以确保单个方法按照正确预期运行,如果修改了某个方法的代码,只需确保其对应的单元测试通过,即可认为改动正确。此外,测试代码本身就可以作为示例代码,用来演示如何调用该方法。

使用JUnit进行单元测试,我们可以使用断言(Assertion)来测试期望结果,可以方便地组织和运行测试,并方便地查看测试结果。此外,JUnit既可以直接在IDE中运行,也可以方便地集成到Maven这些自动化工具中运行。

在编写单元测试的时候,我们要遵循一定的规范:

一是单元测试代码本身必须非常简单,能一下看明白,决不能再为测试代码编写测试;

二是每个单元测试应当互相独立,不依赖运行的顺序;

三是测试时不但要覆盖常用测试用例,还要特别注意测试边界条件,例如输入为0null,空字符串""等情况。

练习

使用JUnit编写测试代码。

下载练习

小结

JUnit是一个单元测试框架,专门用于运行我们编写的单元测试:

一个JUnit测试包含若干@Test方法,并使用Assertions进行断言,注意浮点数assertEquals()要指定delta

在一个单元测试中,我们经常编写多个@Test方法,来分组、分类对目标代码进行测试。

在测试的时候,我们经常遇到一个对象需要初始化,测试完可能还需要清理的情况。如果每个@Test方法都写一遍这样的重复代码,显然比较麻烦。

JUnit提供了编写测试前准备、测试后清理的固定代码,我们称之为Fixture。

我们来看一个具体的Calculator的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Calculator {
private long n = 0;

public long add(long x) {
n = n + x;
return n;
}

public long sub(long x) {
n = n - x;
return n;
}
}

这个类的功能很简单,但是测试的时候,我们要先初始化对象,我们不必在每个测试方法中都写上初始化代码,而是通过@BeforeEach来初始化,通过@AfterEach来清理资源:

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
public class CalculatorTest {

Calculator calculator;

@BeforeEach
public void setUp() {
this.calculator = new Calculator();
}

@AfterEach
public void tearDown() {
this.calculator = null;
}

@Test
void testAdd() {
assertEquals(100, this.calculator.add(100));
assertEquals(150, this.calculator.add(50));
assertEquals(130, this.calculator.add(-20));
}

@Test
void testSub() {
assertEquals(-100, this.calculator.sub(100));
assertEquals(-150, this.calculator.sub(50));
assertEquals(-130, this.calculator.sub(-20));
}
}

CalculatorTest测试中,有两个标记为@BeforeEach@AfterEach的方法,它们会在运行每个@Test方法前后自动运行。

上面的测试代码在JUnit中运行顺序如下:

1
2
3
4
5
6
for (Method testMethod : findTestMethods(CalculatorTest.class)) {
var test = new CalculatorTest(); // 创建Test实例
invokeBeforeEach(test);
invokeTestMethod(test, testMethod);
invokeAfterEach(test);
}

可见,@BeforeEach@AfterEach会“环绕”在每个@Test方法前后。

还有一些资源初始化和清理可能更加繁琐,而且会耗费较长的时间,例如初始化数据库。JUnit还提供了@BeforeAll@AfterAll,它们在运行所有@Test前后运行,顺序如下:

1
2
3
4
5
6
7
8
invokeBeforeAll(CalculatorTest.class);
for (Method testMethod : findTestMethods(CalculatorTest.class)) {
var test = new CalculatorTest(); // 创建Test实例
invokeBeforeEach(test);
invokeTestMethod(test, testMethod);
invokeAfterEach(test);
}
invokeAfterAll(CalculatorTest.class);

因为@BeforeAll@AfterAll在所有@Test方法运行前后仅运行一次,因此,它们只能初始化静态变量,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class DatabaseTest {
static Database db;

@BeforeAll
public static void initDatabase() {
db = createDb(...);
}

@AfterAll
public static void dropDatabase() {
...
}
}

事实上,@BeforeAll@AfterAll也只能标注在静态方法上。

因此,我们总结出编写Fixture的套路如下:

  1. 对于实例变量,在@BeforeEach中初始化,在@AfterEach中清理,它们在各个@Test方法中互不影响,因为是不同的实例;
  2. 对于静态变量,在@BeforeAll中初始化,在@AfterAll中清理,它们在各个@Test方法中均是唯一实例,会影响各个@Test方法。

大多数情况下,使用@BeforeEach@AfterEach就足够了。只有某些测试资源初始化耗费时间太长,以至于我们不得不尽量“复用”时才会用到@BeforeAll@AfterAll

最后,注意到每次运行一个@Test方法前,JUnit首先创建一个XxxTest实例,因此,每个@Test方法内部的成员变量都是独立的,不能也无法把成员变量的状态从一个@Test方法带到另一个@Test方法。

练习

使用Fixture编写单元测试。

下载练习

小结

编写Fixture是指针对每个@Test方法,编写@BeforeEach方法用于初始化测试资源,编写@AfterEach用于清理测试资源;

必要时,可以编写@BeforeAll@AfterAll,使用静态变量来初始化耗时的资源,并且在所有@Test方法的运行前后仅执行一次。

异常测试

在Java程序中,异常处理是非常重要的。

我们自己编写的方法,也经常抛出各种异常。对于可能抛出的异常进行测试,本身就是测试的重要环节。

因此,在编写JUnit测试的时候,除了正常的输入输出,我们还要特别针对可能导致异常的情况进行测试。

我们仍然用Factorial举例:

1
2
3
4
5
6
7
8
9
10
11
12
public class Factorial {
public static long fact(long n) {
if (n < 0) {
throw new IllegalArgumentException();
}
long r = 1;
for (long i = 1; i <= n; i++) {
r = r * i;
}
return r;
}
}

在方法入口,我们增加了对参数n的检查,如果为负数,则直接抛出IllegalArgumentException

现在,我们希望对异常进行测试。在JUnit测试中,我们可以编写一个@Test方法专门测试异常:

1
2
3
4
5
6
7
8
9
@Test
void testNegative() {
assertThrows(IllegalArgumentException.class, new Executable() {
@Override
public void execute() throws Throwable {
Factorial.fact(-1);
}
});
}

JUnit提供assertThrows()来期望捕获一个指定的异常。第二个参数Executable封装了我们要执行的会产生异常的代码。当我们执行Factorial.fact(-1)时,必定抛出IllegalArgumentExceptionassertThrows()在捕获到指定异常时表示通过测试,未捕获到异常,或者捕获到的异常类型不对,均表示测试失败。

有些童鞋会觉得编写一个Executable的匿名类太繁琐了。实际上,Java 8开始引入了函数式编程,所有单方法接口都可以简写如下:

1
2
3
4
5
6
@Test
void testNegative() {
assertThrows(IllegalArgumentException.class, () -> {
Factorial.fact(-1);
});
}

上述奇怪的->语法就是函数式接口的实现代码,我们会在后面详细介绍。现在,我们只需要通过这种固定的代码编写能抛出异常的语句即可。

练习

观察Factorial.fact()方法,注意到由于long型整数有范围限制,当我们传入参数21时,得到的结果是-4249290049419214848,而不是期望的51090942171709440000,因此,当传入参数大于20时,Factorial.fact()方法应当抛出ArithmeticException。请编写测试并修改实现代码,确保测试通过。

下载练习

小结

测试异常可以使用assertThrows(),期待捕获到指定类型的异常;

对可能发生的每种类型的异常都必须进行测试。



在运行测试的时候,有些时候,我们需要排出某些@Test方法,不要让它运行,这时,我们就可以给它标记一个@Disabled

1
2
3
4
5
@Disabled
@Test
void testBug101() {
// 这个测试不会运行
}

为什么我们不直接注释掉@Test,而是要加一个@Disabled?这是因为注释掉@Test,JUnit就不知道这是个测试方法,而加上@Disabled,JUnit仍然识别出这是个测试方法,只是暂时不运行。它会在测试结果中显示:

1
Tests run: 68, Failures: 2, Errors: 0, Skipped: 5

类似@Disabled这种注解就称为条件测试(Conditional Test),JUnit根据不同的条件注解,决定是否运行当前的@Test方法。

我们来看一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
public class Config {
public String getConfigFile(String filename) {
String os = System.getProperty("os.name").toLowerCase();
if (os.contains("win")) {
return "C:\\" + filename;
}
if (os.contains("mac") || os.contains("linux") || os.contains("unix")) {
return "/usr/local/" + filename;
}
throw new UnsupportedOperationException();
}
}

我们想要测试getConfigFile()这个方法,但是在Windows上跑,和在Linux上跑的代码路径不同,因此,针对两个系统的测试方法,其中一个只能在Windows上跑,另一个只能在Mac/Linux上跑:

1
2
3
4
5
6
7
8
9
@Test
void testWindows() {
assertEquals("C:\\test.ini", config.getConfigFile("test.ini"));
}

@Test
void testLinuxAndMac() {
assertEquals("/usr/local/test.cfg", config.getConfigFile("test.cfg"));
}

因此,我们给上述两个测试方法分别加上条件如下:

1
2
3
4
5
6
7
8
9
10
11
@Test
@EnabledOnOs(OS.WINDOWS)
void testWindows() {
assertEquals("C:\\test.ini", config.getConfigFile("test.ini"));
}

@Test
@EnabledOnOs({ OS.LINUX, OS.MAC })
void testLinuxAndMac() {
assertEquals("/usr/local/test.cfg", config.getConfigFile("test.cfg"));
}

@EnableOnOs就是一个条件测试判断。

我们来看一些常用的条件测试:

不在Windows平台执行的测试,可以加上@DisabledOnOs(OS.WINDOWS)

1
2
3
4
5
@Test
@DisabledOnOs(OS.WINDOWS)
void testOnNonWindowsOs() {
// TODO: this test is disabled on windows
}

只能在Java 9或更高版本执行的测试,可以加上@DisabledOnJre(JRE.JAVA_8)

1
2
3
4
5
@Test
@DisabledOnJre(JRE.JAVA_8)
void testOnJava9OrAbove() {
// TODO: this test is disabled on java 8
}

只能在64位操作系统上执行的测试,可以用@EnabledIfSystemProperty判断:

1
2
3
4
5
@Test
@EnabledIfSystemProperty(named = "os.arch", matches = ".*64.*")
void testOnlyOn64bitSystem() {
// TODO: this test is only run on 64 bit system
}

需要传入环境变量DEBUG=true才能执行的测试,可以用@EnabledIfEnvironmentVariable

1
2
3
4
5
@Test
@EnabledIfEnvironmentVariable(named = "DEBUG", matches = "true")
void testOnlyOnDebugMode() {
// TODO: this test is only run on DEBUG=true
}

当我们在JUnit中运行所有测试的时候,JUnit会给出执行的结果。在IDE中,我们能很容易地看到没有执行的测试:

junit-conditional-test

带有⊘标记的测试方法表示没有执行。

练习

在JUnit测试中使用条件测试。

下载练习

小结

条件测试是根据某些注解在运行期让JUnit自动忽略某些测试。

如果待测试的输入和输出是一组数据,可以把测试数据组织起来,用不同的测试数据调用相同的测试方法,这就是参数化测试。

参数化测试和普通测试稍微不同的地方在于,一个测试方法需要接收至少一个参数,然后,传入一组参数反复运行。

JUnit提供了一个@ParameterizedTest注解,用来进行参数化测试。

假设我们想对Math.abs()进行测试,先用一组正数进行测试:

1
2
3
4
5
@ParameterizedTest
@ValueSource(ints = { 0, 1, 5, 100 })
void testAbs(int x) {
assertEquals(x, Math.abs(x));
}

再用一组负数进行测试:

1
2
3
4
5
@ParameterizedTest
@ValueSource(ints = { -1, -5, -100 })
void testAbsNegative(int x) {
assertEquals(-x, Math.abs(x));
}

注意到参数化测试的注解是@ParameterizedTest,而不是普通的@Test

实际的测试场景往往没有这么简单。假设我们自己编写了一个StringUtils.capitalize()方法,它会把字符串的第一个字母变为大写,后续字母变为小写:

1
2
3
4
5
6
7
8
public class StringUtils {
public static String capitalize(String s) {
if (s.length() == 0) {
return s;
}
return Character.toUpperCase(s.charAt(0)) + s.substring(1).toLowerCase();
}
}

要用参数化测试的方法来测试,我们不但要给出输入,还要给出预期输出。因此,测试方法至少需要接收两个参数:

1
2
3
4
@ParameterizedTest
void testCapitalize(String input, String result) {
assertEquals(result, StringUtils.capitalize(input));
}

现在问题来了:参数如何传入?

最简单的方法是通过@MethodSource注解,它允许我们编写一个同名的静态方法来提供测试参数:

1
2
3
4
5
6
7
8
9
10
11
12
@ParameterizedTest
@MethodSource
void testCapitalize(String input, String result) {
assertEquals(result, StringUtils.capitalize(input));
}

static List<Arguments> testCapitalize() {
return List.of( // arguments:
Arguments.of("abc", "Abc"), //
Arguments.of("APPLE", "Apple"), //
Arguments.of("gooD", "Good"));
}

上面的代码很容易理解:静态方法testCapitalize()返回了一组测试参数,每个参数都包含两个String,正好作为测试方法的两个参数传入。

提示

如果静态方法和测试方法的名称不同,@MethodSource也允许指定方法名。但使用默认同名方法最方便。

另一种传入测试参数的方法是使用@CsvSource,它的每一个字符串表示一行,一行包含的若干参数用,分隔,因此,上述测试又可以改写如下:

1
2
3
4
5
@ParameterizedTest
@CsvSource({ "abc, Abc", "APPLE, Apple", "gooD, Good" })
void testCapitalize(String input, String result) {
assertEquals(result, StringUtils.capitalize(input));
}

如果有成百上千的测试输入,那么,直接写@CsvSource就很不方便。这个时候,我们可以把测试数据提到一个独立的CSV文件中,然后标注上@CsvFileSource

1
2
3
4
5
@ParameterizedTest
@CsvFileSource(resources = { "/test-capitalize.csv" })
void testCapitalizeUsingCsvFile(String input, String result) {
assertEquals(result, StringUtils.capitalize(input));
}

JUnit只在classpath中查找指定的CSV文件,因此,test-capitalize.csv这个文件要放到test目录下,内容如下:

1
2
3
4
apple, Apple
HELLO, Hello
JUnit, Junit
reSource, Resource

练习

StringUtils进行参数化测试。

下载练习

小结

使用参数化测试,可以提供一组测试数据,对一个测试方法反复测试;

参数既可以在测试代码中写死,也可以通过@CsvFileSource放到外部的CSV文件中。

留言與分享

JAVA-日期与时间

分類 编程语言, Java

在计算机中,我们经常需要处理日期和时间。

这是日期:

  • 2019-11-20
  • 2020-1-1

这是时间:

  • 12:30:59
  • 2020-1-1 20:21:59

日期是指某一天,它不是连续变化的,而是应该被看成离散的。

而时间有两种概念,一种是不带日期的时间,例如,12:30:59。另一种是带日期的时间,例如,2020-1-1 20:21:59,只有这种带日期的时间能唯一确定某个时刻,不带日期的时间是无法确定一个唯一时刻的。

本地时间

当我们说当前时刻是2019年11月20日早上8:15的时候,我们说的实际上是本地时间。在国内就是北京时间。在这个时刻,如果地球上不同地方的人们同时看一眼手表,他们各自的本地时间是不同的:

localtime

所以,不同的时区,在同一时刻,本地时间是不同的。全球一共分为24个时区,伦敦所在的时区称为标准时区,其他时区按东/西偏移的小时区分,北京所在的时区是东八区。

时区

因为光靠本地时间还无法唯一确定一个准确的时刻,所以我们还需要给本地时间加上一个时区。时区有好几种表示方式。

一种是以GMT或者UTC加时区偏移表示,例如:GMT+08:00或者UTC+08:00表示东八区。

GMTUTC可以认为基本是等价的,只是UTC使用更精确的原子钟计时,每隔几年会有一个闰秒,我们在开发程序的时候可以忽略两者的误差,因为计算机的时钟在联网的时候会自动与时间服务器同步时间。

另一种是缩写,例如,CST表示China Standard Time,也就是中国标准时间。但是CST也可以表示美国中部时间Central Standard Time USA,因此,缩写容易产生混淆,我们尽量不要使用缩写。

最后一种是以洲/城市表示,例如,Asia/Shanghai,表示上海所在地的时区。特别注意城市名称不是任意的城市,而是由国际标准组织规定的城市。

因为时区的存在,东八区的2019年11月20日早上8:15,和西五区的2019年11月19日晚上19:15,他们的时刻是相同的:

timezone

时刻相同的意思就是,分别在两个时区的两个人,如果在这一刻通电话,他们各自报出自己手表上的时间,虽然本地时间是不同的,但是这两个时间表示的时刻是相同的。

夏令时

时区还不是最复杂的,更复杂的是夏令时。所谓夏令时,就是夏天开始的时候,把时间往后拨1小时,夏天结束的时候,再把时间往前拨1小时。我们国家实行过一段时间夏令时,1992年就废除了,但是矫情的美国人到现在还在使用,所以时间换算更加复杂。

daynight-saving

因为涉及到夏令时,相同的时区,如果表示的方式不同,转换出的时间是不同的。我们举个栗子:

对于2019-11-20和2019-6-20两个日期来说,假设北京人在纽约:

  • 如果以GMT或者UTC作为时区,无论日期是多少,时间都是19:00
  • 如果以国家/城市表示,例如America/NewYork,虽然纽约也在西五区,但是,因为夏令时的存在,在不同的日期,GMT时间和纽约时间可能是不一样的:
时区 2019-11-20 2019-6-20
GMT-05:00 19:00 19:00
UTC-05:00 19:00 19:00
America/New_York 19:00 20:00

实行夏令时的不同地区,进入和退出夏令时的时间很可能是不同的。同一个地区,根据历史上是否实行过夏令时,标准时间在不同年份换算成当地时间也是不同的。因此,计算夏令时,没有统一的公式,必须按照一组给定的规则来算,并且,该规则要定期更新。

注意

计算夏令时请使用标准库提供的相关类,不要试图自己计算夏令时。

本地化

在计算机中,通常使用Locale表示一个国家或地区的日期、时间、数字、货币等格式。Locale语言_国家的字母缩写构成,例如,zh_CN表示中文+中国,en_US表示英文+美国。语言使用小写,国家使用大写。

对于日期来说,不同的Locale,例如,中国和美国的表示方式如下:

  • zh_CN:2016-11-30
  • en_US:11/30/2016

计算机用Locale在日期、时间、货币和字符串之间进行转换。一个电商网站会根据用户所在的Locale对用户显示如下:

中国用户 美国用户
购买价格 12000.00 12,000.00
购买日期 2016-11-30 11/30/2016

小结

在编写日期和时间的程序前,我们要准确理解日期、时间和时刻的概念;

由于存在本地时间,我们需要理解时区的概念,并且必须牢记由于夏令时的存在,同一地区用GMT/UTC和城市表示的时区可能导致时间不同;

计算机通过Locale来针对当地用户习惯格式化日期、时间、数字、货币等。

在计算机中,应该如何表示日期和时间呢?

我们经常看到的日期和时间表示方式如下:

  • 2019-11-20 0:15:00 GMT+00:00
  • 2019年11月20日8:15:00
  • 11/19/2019 19:15:00 America/New_York

如果直接以字符串的形式存储,那么不同的格式,不同的语言会让表示方式非常繁琐。

在理解日期和时间的表示方式之前,我们先要理解数据的存储和展示。

当我们定义一个整型变量并赋值时:

1
int n = 123400;

编译器会把上述字符串(程序源码就是一个字符串)编译成字节码。在程序的运行期,变量n指向的内存实际上是一个4字节区域:

1
2
3
┌──┬──┬──┬──┐
│00│01│e2│08│
└──┴──┴──┴──┘

注意到计算机内存除了二进制的0/1外没有其他任何格式。上述十六进制是为了简化表示。

当我们用System.out.println(n)打印这个整数的时候,实际上println()这个方法在内部把int类型转换成String类型,然后打印出字符串123400

类似的,我们也可以以十六进制的形式打印这个整数,或者,如果n表示一个价格,我们就以$123,400.00的形式来打印它:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import java.text.*;
import java.util.*;

public class Main {
public static void main(String[] args) {
int n = 123400;
// 123400
System.out.println(n);
// 1e208
System.out.println(Integer.toHexString(n));
// $123,400.00
System.out.println(NumberFormat.getCurrencyInstance(Locale.US).format(n));
}
}

可见,整数123400是数据的存储格式,它的存储格式非常简单。而我们打印的各种各样的字符串,则是数据的展示格式。展示格式有多种形式,但本质上它就是一个转换方法:

1
String toDisplay(int n) { ... }

理解了数据的存储和展示,我们回头看看以下几种日期和时间:

  • 2019-11-20 0:15:01 GMT+00:00
  • 2019年11月20日8:15:01
  • 11/19/2019 19:15:01 America/New_York

它们实际上是数据的展示格式,分别按英国时区、中国时区、纽约时区对同一个时刻进行展示。而这个“同一个时刻”在计算机中存储的本质上只是一个整数,我们称它为Epoch Time

Epoch Time是计算从1970年1月1日零点(格林威治时区/GMT+00:00)到现在所经历的秒数,例如:

1574208900表示从1970年1月1日零点GMT时区到该时刻一共经历了1574208900秒,换算成伦敦、北京和纽约时间分别是:

1
2
3
1574208900 = 北京时间2019-11-20 8:15:00
= 伦敦时间2019-11-20 0:15:00
= 纽约时间2019-11-19 19:15:00

localtime

因此,在计算机中,只需要存储一个整数1574208900表示某一时刻。当需要显示为某一地区的当地时间时,我们就把它格式化为一个字符串:

1
String displayDateTime(int n, String timezone) { ... }

Epoch Time又称为时间戳,在不同的编程语言中,会有几种存储方式:

  • 以秒为单位的整数:1574208900,缺点是精度只能到秒;
  • 以毫秒为单位的整数:1574208900123,最后3位表示毫秒数;
  • 以秒为单位的浮点数:1574208900.123,小数点后面表示零点几秒。

它们之间转换非常简单。而在Java程序中,时间戳通常是用long表示的毫秒数,即:

1
long t = 1574208900123L;

转换成北京时间就是2019-11-20T8:15:00.123。要获取当前时间戳,可以使用System.currentTimeMillis(),这是Java程序获取时间戳最常用的方法。

标准库API

我们再来看一下Java标准库提供的API。Java标准库有两套处理日期和时间的API:

  • 一套定义在java.util这个包里面,主要包括DateCalendarTimeZone这几个类;
  • 一套新的API是在Java 8引入的,定义在java.time这个包里面,主要包括LocalDateTimeZonedDateTimeZoneId等。

为什么会有新旧两套API呢?因为历史遗留原因,旧的API存在很多问题,所以引入了新的API。

那么我们能不能跳过旧的API直接用新的API呢?如果涉及到遗留代码就不行,因为很多遗留代码仍然使用旧的API,所以目前仍然需要对旧的API有一定了解,很多时候还需要在新旧两种对象之间进行转换。

本节我们快速讲解旧API的常用类型和方法。

Date

java.util.Date是用于表示一个日期和时间的对象,注意与java.sql.Date区分,后者用在数据库中。如果观察Date的源码,可以发现它实际上存储了一个long类型的以毫秒表示的时间戳:

1
2
3
4
5
6
public class Date implements Serializable, Cloneable, Comparable<Date> {

private transient long fastTime;

...
}

我们来看Date的基本用法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import java.util.*;

public class Main {
public static void main(String[] args) {
// 获取当前时间:
Date date = new Date();
System.out.println(date.getYear() + 1900); // 必须加上1900
System.out.println(date.getMonth() + 1); // 0~11,必须加上1
System.out.println(date.getDate()); // 1~31,不能加1
// 转换为String:
System.out.println(date.toString());
// 转换为GMT时区:
System.out.println(date.toGMTString());
// 转换为本地时区:
System.out.println(date.toLocaleString());
}
}

注意getYear()返回的年份必须加上1900getMonth()返回的月份是0~11分别表示1~12月,所以要加1,而getDate()返回的日期范围是1~31,又不能加1。

打印本地时区表示的日期和时间时,不同的计算机可能会有不同的结果。如果我们想要针对用户的偏好精确地控制日期和时间的格式,就可以使用SimpleDateFormat对一个Date进行转换。它用预定义的字符串表示格式化:

  • yyyy:年
  • MM:月
  • dd: 日
  • HH: 小时
  • mm: 分钟
  • ss: 秒

我们来看如何以自定义的格式输出:

1
2
3
4
5
6
7
8
9
10
11
import java.text.*;
import java.util.*;

public class Main {
public static void main(String[] args) {
// 获取当前时间:
Date date = new Date();
var sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
System.out.println(sdf.format(date));
}
}

Java的格式化预定义了许多不同的格式,我们以MMME为例:

1
2
3
4
5
6
7
8
9
10
11
import java.text.*;
import java.util.*;

public class Main {
public static void main(String[] args) {
// 获取当前时间:
Date date = new Date();
var sdf = new SimpleDateFormat("E MMM dd, yyyy");
System.out.println(sdf.format(date));
}
}

上述代码在不同的语言环境会打印出类似Sun Sep 15, 2019这样的日期。可以从JDK文档查看详细的格式说明。一般来说,字母越长,输出越长。以M为例,假设当前月份是9月:

  • M:输出9
  • MM:输出09
  • MMM:输出Sep
  • MMMM:输出September

Date对象有几个严重的问题:它不能转换时区,除了toGMTString()可以按GMT+0:00输出外,Date总是以当前计算机系统的默认时区为基础进行输出。此外,我们也很难对日期和时间进行加减,计算两个日期相差多少天,计算某个月第一个星期一的日期等。

Calendar

Calendar可以用于获取并设置年、月、日、时、分、秒,它和Date比,主要多了一个可以做简单的日期和时间运算的功能。

我们来看Calendar的基本用法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import java.util.*;

public class Main {
public static void main(String[] args) {
// 获取当前时间:
Calendar c = Calendar.getInstance();
int y = c.get(Calendar.YEAR);
int m = 1 + c.get(Calendar.MONTH);
int d = c.get(Calendar.DAY_OF_MONTH);
int w = c.get(Calendar.DAY_OF_WEEK);
int hh = c.get(Calendar.HOUR_OF_DAY);
int mm = c.get(Calendar.MINUTE);
int ss = c.get(Calendar.SECOND);
int ms = c.get(Calendar.MILLISECOND);
System.out.println(y + "-" + m + "-" + d + " " + w + " " + hh + ":" + mm + ":" + ss + "." + ms);
}
}

注意到Calendar获取年月日这些信息变成了get(int field),返回的年份不必转换,返回的月份仍然要加1,返回的星期要特别注意,1~7分别表示周日,周一,……,周六。

Calendar只有一种方式获取,即Calendar.getInstance(),而且一获取到就是当前时间。如果我们想给它设置成特定的一个日期和时间,就必须先清除所有字段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import java.text.*;
import java.util.*;

public class Main {
public static void main(String[] args) {
// 当前时间:
Calendar c = Calendar.getInstance();
// 清除所有:
c.clear();
// 设置2019年:
c.set(Calendar.YEAR, 2019);
// 设置9月:注意8表示9月:
c.set(Calendar.MONTH, 8);
// 设置2日:
c.set(Calendar.DATE, 2);
// 设置时间:
c.set(Calendar.HOUR_OF_DAY, 21);
c.set(Calendar.MINUTE, 22);
c.set(Calendar.SECOND, 23);
System.out.println(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(c.getTime()));
// 2019-09-02 21:22:23
}
}

利用Calendar.getTime()可以将一个Calendar对象转换成Date对象,然后就可以用SimpleDateFormat进行格式化了。

TimeZone

CalendarDate相比,它提供了时区转换的功能。时区用TimeZone对象表示:

1
2
3
4
5
6
7
8
9
10
11
12
import java.util.*;

public class Main {
public static void main(String[] args) {
TimeZone tzDefault = TimeZone.getDefault(); // 当前时区
TimeZone tzGMT9 = TimeZone.getTimeZone("GMT+09:00"); // GMT+9:00时区
TimeZone tzNY = TimeZone.getTimeZone("America/New_York"); // 纽约时区
System.out.println(tzDefault.getID()); // Asia/Shanghai
System.out.println(tzGMT9.getID()); // GMT+09:00
System.out.println(tzNY.getID()); // America/New_York
}
}

时区的唯一标识是以字符串表示的ID,我们获取指定TimeZone对象也是以这个ID为参数获取,GMT+09:00Asia/Shanghai都是有效的时区ID。要列出系统支持的所有ID,请使用TimeZone.getAvailableIDs()

有了时区,我们就可以对指定时间进行转换。例如,下面的例子演示了如何将北京时间2019-11-20 8:15:00转换为纽约时间:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import java.text.*;
import java.util.*;

public class Main {
public static void main(String[] args) {
// 当前时间:
Calendar c = Calendar.getInstance();
// 清除所有:
c.clear();
// 设置为北京时区:
c.setTimeZone(TimeZone.getTimeZone("Asia/Shanghai"));
// 设置年月日时分秒:
c.set(2019, 10 /* 11月 */, 20, 8, 15, 0);
// 显示时间:
var sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
sdf.setTimeZone(TimeZone.getTimeZone("America/New_York"));
System.out.println(sdf.format(c.getTime()));
// 2019-11-19 19:15:00
}
}

可见,利用Calendar进行时区转换的步骤是:

  1. 清除所有字段;
  2. 设定指定时区;
  3. 设定日期和时间;
  4. 创建SimpleDateFormat并设定目标时区;
  5. 格式化获取的Date对象(注意Date对象无时区信息,时区信息存储在SimpleDateFormat中)。

因此,本质上时区转换只能通过SimpleDateFormat在显示的时候完成。

Calendar也可以对日期和时间进行简单的加减:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import java.text.*;
import java.util.*;

public class Main {
public static void main(String[] args) {
// 当前时间:
Calendar c = Calendar.getInstance();
// 清除所有:
c.clear();
// 设置年月日时分秒:
c.set(2019, 10 /* 11月 */, 20, 8, 15, 0);
// 加5天并减去2小时:
c.add(Calendar.DAY_OF_MONTH, 5);
c.add(Calendar.HOUR_OF_DAY, -2);
// 显示时间:
var sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
Date d = c.getTime();
System.out.println(sdf.format(d));
// 2019-11-25 6:15:00
}
}

小结

计算机表示的时间是以整数表示的时间戳存储的,即Epoch Time,Java使用long型来表示以毫秒为单位的时间戳,通过System.currentTimeMillis()获取当前时间戳。

Java有两套日期和时间的API:

  • 旧的Date、Calendar和TimeZone;
  • 新的LocalDateTime、ZonedDateTime、ZoneId等。

分别位于java.utiljava.time包中。

从Java 8开始,java.time包提供了新的日期和时间API,主要涉及的类型有:

  • 本地日期和时间:LocalDateTimeLocalDateLocalTime
  • 带时区的日期和时间:ZonedDateTime
  • 时刻:Instant
  • 时区:ZoneIdZoneOffset
  • 时间间隔:Duration

以及一套新的用于取代SimpleDateFormat的格式化类型DateTimeFormatter

和旧的API相比,新API严格区分了时刻、本地日期、本地时间和带时区的日期时间,并且,对日期和时间进行运算更加方便。

此外,新API修正了旧API不合理的常量设计:

  • Month的范围用1~12表示1月到12月;
  • Week的范围用1~7表示周一到周日。

最后,新API的类型几乎全部是不变类型(和String类似),可以放心使用不必担心被修改。

LocalDateTime

我们首先来看最常用的LocalDateTime,它表示一个本地日期和时间:

1
2
3
4
5
6
7
8
9
10
11
12
import java.time.*;

public class Main {
public static void main(String[] args) {
LocalDate d = LocalDate.now(); // 当前日期
LocalTime t = LocalTime.now(); // 当前时间
LocalDateTime dt = LocalDateTime.now(); // 当前日期和时间
System.out.println(d); // 严格按照ISO 8601格式打印
System.out.println(t); // 严格按照ISO 8601格式打印
System.out.println(dt); // 严格按照ISO 8601格式打印
}
}

本地日期和时间通过now()获取到的总是以当前默认时区返回的,和旧API不同,LocalDateTimeLocalDateLocalTime默认严格按照ISO 8601规定的日期和时间格式进行打印。

上述代码其实有一个小问题,在获取3个类型的时候,由于执行一行代码总会消耗一点时间,因此,3个类型的日期和时间很可能对不上(时间的毫秒数基本上不同)。为了保证获取到同一时刻的日期和时间,可以改写如下:

1
2
3
LocalDateTime dt = LocalDateTime.now(); // 当前日期和时间
LocalDate d = dt.toLocalDate(); // 转换到当前日期
LocalTime t = dt.toLocalTime(); // 转换到当前时间

反过来,通过指定的日期和时间创建LocalDateTime可以通过of()方法:

1
2
3
4
5
// 指定日期和时间:
LocalDate d2 = LocalDate.of(2019, 11, 30); // 2019-11-30, 注意11=11月
LocalTime t2 = LocalTime.of(15, 16, 17); // 15:16:17
LocalDateTime dt2 = LocalDateTime.of(2019, 11, 30, 15, 16, 17);
LocalDateTime dt3 = LocalDateTime.of(d2, t2);

因为严格按照ISO 8601的格式,因此,将字符串转换为LocalDateTime就可以传入标准格式:

1
2
3
LocalDateTime dt = LocalDateTime.parse("2019-11-19T15:16:17");
LocalDate d = LocalDate.parse("2019-11-19");
LocalTime t = LocalTime.parse("15:16:17");

注意ISO 8601规定的日期和时间分隔符是T。标准格式如下:

  • 日期:yyyy-MM-dd
  • 时间:HH:mm:ss
  • 带毫秒的时间:HH:mm:ss.SSS
  • 日期和时间:yyyy-MM-dd’T’HH:mm:ss
  • 带毫秒的日期和时间:yyyy-MM-dd’T’HH:mm:ss.SSS

DateTimeFormatter

如果要自定义输出的格式,或者要把一个非ISO 8601格式的字符串解析成LocalDateTime,可以使用新的DateTimeFormatter

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import java.time.*;
import java.time.format.*;

public class Main {
public static void main(String[] args) {
// 自定义格式化:
DateTimeFormatter dtf = DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm:ss");
System.out.println(dtf.format(LocalDateTime.now()));

// 用自定义格式解析:
LocalDateTime dt2 = LocalDateTime.parse("2019/11/30 15:16:17", dtf);
System.out.println(dt2);
}
}

LocalDateTime提供了对日期和时间进行加减的非常简单的链式调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import java.time.*;

public class Main {
public static void main(String[] args) {
LocalDateTime dt = LocalDateTime.of(2019, 10, 26, 20, 30, 59);
System.out.println(dt);
// 加5天减3小时:
LocalDateTime dt2 = dt.plusDays(5).minusHours(3);
System.out.println(dt2); // 2019-10-31T17:30:59
// 减1月:
LocalDateTime dt3 = dt2.minusMonths(1);
System.out.println(dt3); // 2019-09-30T17:30:59
}
}

注意到月份加减会自动调整日期,例如从2019-10-31减去1个月得到的结果是2019-09-30,因为9月没有31日。

对日期和时间进行调整则使用withXxx()方法,例如:withHour(15)会把10:11:12变为15:11:12

  • 调整年:withYear()
  • 调整月:withMonth()
  • 调整日:withDayOfMonth()
  • 调整时:withHour()
  • 调整分:withMinute()
  • 调整秒:withSecond()

示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import java.time.*;

public class Main {
public static void main(String[] args) {
LocalDateTime dt = LocalDateTime.of(2019, 10, 26, 20, 30, 59);
System.out.println(dt);
// 日期变为31日:
LocalDateTime dt2 = dt.withDayOfMonth(31);
System.out.println(dt2); // 2019-10-31T20:30:59
// 月份变为9:
LocalDateTime dt3 = dt2.withMonth(9);
System.out.println(dt3); // 2019-09-30T20:30:59
}
}

同样注意到调整月份时,会相应地调整日期,即把2019-10-31的月份调整为9时,日期也自动变为30

实际上,LocalDateTime还有一个通用的with()方法允许我们做更复杂的运算。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import java.time.*;
import java.time.temporal.*;

public class Main {
public static void main(String[] args) {
// 本月第一天0:00时刻:
LocalDateTime firstDay = LocalDate.now().withDayOfMonth(1).atStartOfDay();
System.out.println(firstDay);

// 本月最后1天:
LocalDate lastDay = LocalDate.now().with(TemporalAdjusters.lastDayOfMonth());
System.out.println(lastDay);

// 下月第1天:
LocalDate nextMonthFirstDay = LocalDate.now().with(TemporalAdjusters.firstDayOfNextMonth());
System.out.println(nextMonthFirstDay);

// 本月第1个周一:
LocalDate firstWeekday = LocalDate.now().with(TemporalAdjusters.firstInMonth(DayOfWeek.MONDAY));
System.out.println(firstWeekday);
}
}

对于计算某个月第1个周日这样的问题,新的API可以轻松完成。

要判断两个LocalDateTime的先后,可以使用isBefore()isAfter()方法,对于LocalDateLocalTime类似:

1
2
3
4
5
6
7
8
9
10
11
import java.time.*;

public class Main {
public static void main(String[] args) {
LocalDateTime now = LocalDateTime.now();
LocalDateTime target = LocalDateTime.of(2019, 11, 19, 8, 15, 0);
System.out.println(now.isBefore(target));
System.out.println(LocalDate.now().isBefore(LocalDate.of(2019, 11, 19)));
System.out.println(LocalTime.now().isAfter(LocalTime.parse("08:15:00")));
}
}

注意到LocalDateTime无法与时间戳进行转换,因为LocalDateTime没有时区,无法确定某一时刻。后面我们要介绍的ZonedDateTime相当于LocalDateTime加时区的组合,它具有时区,可以与long表示的时间戳进行转换。

Duration和Period

Duration表示两个时刻之间的时间间隔。另一个类似的Period表示两个日期之间的天数:

1
2
3
4
5
6
7
8
9
10
11
12
13
import java.time.*;

public class Main {
public static void main(String[] args) {
LocalDateTime start = LocalDateTime.of(2019, 11, 19, 8, 15, 0);
LocalDateTime end = LocalDateTime.of(2020, 1, 9, 19, 25, 30);
Duration d = Duration.between(start, end);
System.out.println(d); // PT1235H10M30S

Period p = LocalDate.of(2019, 11, 19).until(LocalDate.of(2020, 1, 9));
System.out.println(p); // P1M21D
}
}

注意到两个LocalDateTime之间的差值使用Duration表示,类似PT1235H10M30S,表示1235小时10分钟30秒。而两个LocalDate之间的差值用Period表示,类似P1M21D,表示1个月21天。

DurationPeriod的表示方法也符合ISO 8601的格式,它以P...T...的形式表示,P...T之间表示日期间隔,T后面表示时间间隔。如果是PT...的格式表示仅有时间间隔。利用ofXxx()或者parse()方法也可以直接创建Duration

1
2
Duration d1 = Duration.ofHours(10); // 10 hours
Duration d2 = Duration.parse("P1DT2H3M"); // 1 day, 2 hours, 3 minutes

有的童鞋可能发现Java 8引入的java.timeAPI。怎么和一个开源的Joda Time很像?难道JDK也开始抄袭开源了?其实正是因为开源的Joda Time设计很好,应用广泛,所以JDK团队邀请Joda Time的作者Stephen Colebourne共同设计了java.timeAPI。

小结

Java 8引入了新的日期和时间API,它们是不变类,默认按ISO 8601标准格式化和解析;

使用LocalDateTime可以非常方便地对日期和时间进行加减,或者调整日期和时间,它总是返回新对象;

使用isBefore()isAfter()可以判断日期和时间的先后;

使用DurationPeriod可以表示两个日期和时间的“区间间隔”。

LocalDateTime总是表示本地日期和时间,要表示一个带时区的日期和时间,我们就需要ZonedDateTime

可以简单地把ZonedDateTime理解成LocalDateTimeZoneIdZoneIdjava.time引入的新的时区类,注意和旧的java.util.TimeZone区别。

要创建一个ZonedDateTime对象,有以下几种方法,一种是通过now()方法返回当前时间:

1
2
3
4
5
6
7
8
9
10
import java.time.*;

public class Main {
public static void main(String[] args) {
ZonedDateTime zbj = ZonedDateTime.now(); // 默认时区
ZonedDateTime zny = ZonedDateTime.now(ZoneId.of("America/New_York")); // 用指定时区获取当前时间
System.out.println(zbj);
System.out.println(zny);
}
}

观察打印的两个ZonedDateTime,发现它们时区不同,但表示的时间都是同一时刻(毫秒数不同是执行语句时的时间差):

1
2
2019-09-15T20:58:18.786182+08:00[Asia/Shanghai]
2019-09-15T08:58:18.788860-04:00[America/New_York]

另一种方式是通过给一个LocalDateTime附加一个ZoneId,就可以变成ZonedDateTime

1
2
3
4
5
6
7
8
9
10
11
import java.time.*;

public class Main {
public static void main(String[] args) {
LocalDateTime ldt = LocalDateTime.of(2019, 9, 15, 15, 16, 17);
ZonedDateTime zbj = ldt.atZone(ZoneId.systemDefault());
ZonedDateTime zny = ldt.atZone(ZoneId.of("America/New_York"));
System.out.println(zbj);
System.out.println(zny);
}
}

以这种方式创建的ZonedDateTime,它的日期和时间与LocalDateTime相同,但附加的时区不同,因此是两个不同的时刻:

1
2
2019-09-15T15:16:17+08:00[Asia/Shanghai]
2019-09-15T15:16:17-04:00[America/New_York]

时区转换

要转换时区,首先我们需要有一个ZonedDateTime对象,然后,通过withZoneSameInstant()将关联时区转换到另一个时区,转换后日期和时间都会相应调整。

下面的代码演示了如何将北京时间转换为纽约时间:

1
2
3
4
5
6
7
8
9
10
11
12
import java.time.*;

public class Main {
public static void main(String[] args) {
// 以中国时区获取当前时间:
ZonedDateTime zbj = ZonedDateTime.now(ZoneId.of("Asia/Shanghai"));
// 转换为纽约时间:
ZonedDateTime zny = zbj.withZoneSameInstant(ZoneId.of("America/New_York"));
System.out.println(zbj);
System.out.println(zny);
}
}

要特别注意,时区转换的时候,由于夏令时的存在,不同的日期转换的结果很可能是不同的。这是北京时间9月15日的转换结果:

1
2
2019-09-15T21:05:50.187697+08:00[Asia/Shanghai]
2019-09-15T09:05:50.187697-04:00[America/New_York]

这是北京时间11月15日的转换结果:

1
2
2019-11-15T21:05:50.187697+08:00[Asia/Shanghai]
2019-11-15T08:05:50.187697-05:00[America/New_York]

两次转换后的纽约时间有1小时的夏令时时差。

注意

涉及到时区时,千万不要自己计算时差,否则难以正确处理夏令时。

有了ZonedDateTime,将其转换为本地时间就非常简单:

1
2
ZonedDateTime zdt = ...
LocalDateTime ldt = zdt.toLocalDateTime();

转换为LocalDateTime时,直接丢弃了时区信息。

练习

某航线从北京飞到纽约需要13小时20分钟,请根据北京起飞日期和时间计算到达纽约的当地日期和时间。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import java.time.*;

public class Main {
public static void main(String[] args) {
LocalDateTime departureAtBeijing = LocalDateTime.of(2019, 9, 15, 13, 0, 0);
int hours = 13;
int minutes = 20;
LocalDateTime arrivalAtNewYork = calculateArrivalAtNY(departureAtBeijing, hours, minutes);
System.out.println(departureAtBeijing + " -> " + arrivalAtNewYork);
// test:
if (!LocalDateTime.of(2019, 10, 15, 14, 20, 0)
.equals(calculateArrivalAtNY(LocalDateTime.of(2019, 10, 15, 13, 0, 0), 13, 20))) {
System.err.println("测试失败!");
} else if (!LocalDateTime.of(2019, 11, 15, 13, 20, 0)
.equals(calculateArrivalAtNY(LocalDateTime.of(2019, 11, 15, 13, 0, 0), 13, 20))) {
System.err.println("测试失败!");
}
}

static LocalDateTime calculateArrivalAtNY(LocalDateTime bj, int h, int m) {
return bj;
}
}

提示:ZonedDateTime仍然提供了plusDays()等加减操作。

下载练习

小结

ZonedDateTime是带时区的日期和时间,可用于时区转换;

ZonedDateTimeLocalDateTime可以相互转换。

DateTimeFormatter

使用旧的Date对象时,我们用SimpleDateFormat进行格式化显示。使用新的LocalDateTimeZonedDateTime时,我们要进行格式化显示,就要使用DateTimeFormatter

SimpleDateFormat不同的是,DateTimeFormatter不但是不变对象,它还是线程安全的。线程的概念我们会在后面涉及到。现在我们只需要记住:因为SimpleDateFormat不是线程安全的,使用的时候,只能在方法内部创建新的局部变量。而DateTimeFormatter可以只创建一个实例,到处引用。

创建DateTimeFormatter时,我们仍然通过传入格式化字符串实现:

1
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");

格式化字符串的使用方式与SimpleDateFormat完全一致。

另一种创建DateTimeFormatter的方法是,传入格式化字符串时,同时指定Locale

1
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("E, yyyy-MMMM-dd HH:mm", Locale.US);

这种方式可以按照Locale默认习惯格式化。我们来看实际效果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import java.time.*;
import java.time.format.*;
import java.util.Locale;

public class Main {
public static void main(String[] args) {
ZonedDateTime zdt = ZonedDateTime.now();
var formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm ZZZZ");
System.out.println(formatter.format(zdt));

var zhFormatter = DateTimeFormatter.ofPattern("yyyy MMM dd EE HH:mm", Locale.CHINA);
System.out.println(zhFormatter.format(zdt));

var usFormatter = DateTimeFormatter.ofPattern("E, MMMM/dd/yyyy HH:mm", Locale.US);
System.out.println(usFormatter.format(zdt));
}
}

在格式化字符串中,如果需要输出固定字符,可以用'xxx'表示。

运行上述代码,分别以默认方式、中国地区和美国地区对当前时间进行显示,结果如下:

1
2
3
2019-09-15T23:16 GMT+08:00
2019 9月 15 周日 23:16
Sun, September/15/2019 23:16

当我们直接调用System.out.println()对一个ZonedDateTime或者LocalDateTime实例进行打印的时候,实际上,调用的是它们的toString()方法,默认的toString()方法显示的字符串就是按照ISO 8601格式显示的,我们可以通过DateTimeFormatter预定义的几个静态变量来引用:

1
2
3
var ldt = LocalDateTime.now();
System.out.println(DateTimeFormatter.ISO_DATE.format(ldt));
System.out.println(DateTimeFormatter.ISO_DATE_TIME.format(ldt));

得到的输出和toString()类似:

1
2
2019-09-15
2019-09-15T23:16:51.56217

小结

ZonedDateTimeLocalDateTime进行格式化,需要使用DateTimeFormatter类;

DateTimeFormatter可以通过格式化字符串和Locale对日期和时间进行定制输出。



Instant

我们已经讲过,计算机存储的当前时间,本质上只是一个不断递增的整数。Java提供的System.currentTimeMillis()返回的就是以毫秒表示的当前时间戳。

这个当前时间戳在java.time中以Instant类型表示,我们用Instant.now()获取当前时间戳,效果和System.currentTimeMillis()类似:

1
2
3
4
5
6
7
8
9
import java.time.*;

public class Main {
public static void main(String[] args) {
Instant now = Instant.now();
System.out.println(now.getEpochSecond()); // 秒
System.out.println(now.toEpochMilli()); // 毫秒
}
}

打印的结果类似:

1
2
1568568760
1568568760316

实际上,Instant内部只有两个核心字段:

1
2
3
4
public final class Instant implements ... {
private final long seconds;
private final int nanos;
}

一个是以秒为单位的时间戳,一个是更精确的纳秒精度。它和System.currentTimeMillis()返回的long相比,只是多了更高精度的纳秒。

既然Instant就是时间戳,那么,给它附加上一个时区,就可以创建出ZonedDateTime

1
2
3
4
// 以指定时间戳创建Instant:
Instant ins = Instant.ofEpochSecond(1568568760);
ZonedDateTime zdt = ins.atZone(ZoneId.systemDefault());
System.out.println(zdt); // 2019-09-16T01:32:40+08:00[Asia/Shanghai]

可见,对于某一个时间戳,给它关联上指定的ZoneId,就得到了ZonedDateTime,继而可以获得了对应时区的LocalDateTime

所以,LocalDateTimeZoneIdInstantZonedDateTimelong都可以互相转换:

1
2
3
4
5
6
7
8
9
10
11
12
┌─────────────┐
│LocalDateTime│────┐
└─────────────┘ │ ┌─────────────┐
├───▶│ZonedDateTime│
┌─────────────┐ │ └─────────────┘
│ ZoneId │────┘ ▲
└─────────────┘ ┌─────────┴─────────┐
│ │
▼ ▼
┌─────────────┐ ┌─────────────┐
│ Instant │◀───▶│ long │
└─────────────┘ └─────────────┘

转换的时候,只需要留意long类型以毫秒还是秒为单位即可。

小结

Instant表示高精度时间戳,它可以和ZonedDateTime以及long互相转换。



由于Java提供了新旧两套日期和时间的API,除非涉及到遗留代码,否则我们应该坚持使用新的API。

如果需要与遗留代码打交道,如何在新旧API之间互相转换呢?

旧API转新API

如果要把旧式的DateCalendar转换为新API对象,可以通过toInstant()方法转换为Instant对象,再继续转换为ZonedDateTime

1
2
3
4
5
6
7
// Date -> Instant:
Instant ins1 = new Date().toInstant();

// Calendar -> Instant -> ZonedDateTime:
Calendar calendar = Calendar.getInstance();
Instant ins2 = calendar.toInstant();
ZonedDateTime zdt = ins2.atZone(calendar.getTimeZone().toZoneId());

从上面的代码还可以看到,旧的TimeZone提供了一个toZoneId(),可以把自己变成新的ZoneId

新API转旧API

如果要把新的ZonedDateTime转换为旧的API对象,只能借助long型时间戳做一个“中转”:

1
2
3
4
5
6
7
8
9
10
11
12
// ZonedDateTime -> long:
ZonedDateTime zdt = ZonedDateTime.now();
long ts = zdt.toEpochSecond() * 1000;

// long -> Date:
Date date = new Date(ts);

// long -> Calendar:
Calendar calendar = Calendar.getInstance();
calendar.clear();
calendar.setTimeZone(TimeZone.getTimeZone(zdt.getZone().getId()));
calendar.setTimeInMillis(zdt.toEpochSecond() * 1000);

从上面的代码还可以看到,新的ZoneId转换为旧的TimeZone,需要借助ZoneId.getId()返回的String完成。

在数据库中存储日期和时间

除了旧式的java.util.Date,我们还可以找到另一个java.sql.Date,它继承自java.util.Date,但会自动忽略所有时间相关信息。这个奇葩的设计原因要追溯到数据库的日期与时间类型。

在数据库中,也存在几种日期和时间类型:

  • DATETIME:表示日期和时间;
  • DATE:仅表示日期;
  • TIME:仅表示时间;
  • TIMESTAMP:和DATETIME类似,但是数据库会在创建或者更新记录的时候同时修改TIMESTAMP

在使用Java程序操作数据库时,我们需要把数据库类型与Java类型映射起来。下表是数据库类型与Java新旧API的映射关系:

数据库 对应Java类(旧) 对应Java类(新)
DATETIME java.util.Date LocalDateTime
DATE java.sql.Date LocalDate
TIME java.sql.Time LocalTime
TIMESTAMP java.sql.Timestamp LocalDateTime

实际上,在数据库中,我们需要存储的最常用的是时刻(Instant),因为有了时刻信息,就可以根据用户自己选择的时区,显示出正确的本地时间。所以,最好的方法是直接用长整数long表示,在数据库中存储为BIGINT类型。

通过存储一个long型时间戳,我们可以编写一个timestampToString()的方法,非常简单地为不同用户以不同的偏好来显示不同的本地时间:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import java.time.*;
import java.time.format.*;
import java.util.Locale;

public class Main {
public static void main(String[] args) {
long ts = 1574208900000L;
System.out.println(timestampToString(ts, Locale.CHINA, "Asia/Shanghai"));
System.out.println(timestampToString(ts, Locale.US, "America/New_York"));
}

static String timestampToString(long epochMilli, Locale lo, String zoneId) {
Instant ins = Instant.ofEpochMilli(epochMilli);
DateTimeFormatter f = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.SHORT);
return f.withLocale(lo).format(ZonedDateTime.ofInstant(ins, ZoneId.of(zoneId)));
}
}

对上述方法进行调用,结果如下:

1
2
2019年11月20日 上午8:15
Nov 19, 2019, 7:15 PM

小结

处理日期和时间时,尽量使用新的java.time包;

在数据库中存储时间戳时,尽量使用long型时间戳,它具有省空间,效率高,不依赖数据库的优点。

留言與分享

作者的圖片

Kein Chan

這是獨立全棧工程師Kein Chan的技術博客
分享一些技術教程,命令備忘(cheat-sheet)等


全棧工程師
資深技術顧問
數據科學家
Hit廣島觀光大使


Tokyo/Macau