创建型模式关注点是如何创建对象,其核心思想是要把对象的创建和使用相分离,这样使得两者能相对独立地变换。

创建型模式包括:

  • 工厂方法:Factory Method
  • 抽象工厂:Abstract Factory
  • 建造者:Builder
  • 原型:Prototype
  • 单例:Singleton

定义一个用于创建对象的接口,让子类决定实例化哪一个类。Factory Method使一个类的实例化延迟到其子类。

工厂方法即Factory Method,是一种对象创建型模式。

工厂方法的目的是使得创建对象和使用对象是分离的,并且客户端总是引用抽象工厂和抽象产品:

1
2
3
4
5
6
7
8
┌─────────────┐      ┌─────────────┐
│ Product │ │ Factory │
└─────────────┘ └─────────────┘
▲ ▲
│ │
┌─────────────┐ ┌─────────────┐
│ ProductImpl │◀─ ─ ─│ FactoryImpl │
└─────────────┘ └─────────────┘

我们以具体的例子来说:假设我们希望实现一个解析字符串到NumberFactory,可以定义如下:

1
2
3
public interface NumberFactory {
Number parse(String s);
}

有了工厂接口,再编写一个工厂的实现类:

1
2
3
4
5
public class NumberFactoryImpl implements NumberFactory {
public Number parse(String s) {
return new BigDecimal(s);
}
}

而产品接口是NumberNumberFactoryImpl返回的实际产品是BigDecimal

那么客户端如何创建NumberFactoryImpl呢?通常我们会在接口Factory中定义一个静态方法getFactory()来返回真正的子类:

1
2
3
4
5
6
7
8
9
10
11
public interface NumberFactory {
// 创建方法:
Number parse(String s);

// 获取工厂实例:
static NumberFactory getFactory() {
return impl;
}

static NumberFactory impl = new NumberFactoryImpl();
}

在客户端中,我们只需要和工厂接口NumberFactory以及抽象产品Number打交道:

1
2
NumberFactory factory = NumberFactory.getFactory();
Number result = factory.parse("123.456");

调用方可以完全忽略真正的工厂NumberFactoryImpl和实际的产品BigDecimal,这样做的好处是允许创建产品的代码独立地变换,而不会影响到调用方。

有的童鞋会问:一个简单的parse()需要写这么复杂的工厂吗?实际上大多数情况下我们并不需要抽象工厂,而是通过静态方法直接返回产品,即:

1
2
3
4
5
public class NumberFactory {
public static Number parse(String s) {
return new BigDecimal(s);
}
}

这种简化的使用静态方法创建产品的方式称为静态工厂方法(Static Factory Method)。静态工厂方法广泛地应用在Java标准库中。例如:

1
Integer n = Integer.valueOf(100);

Integer既是产品又是静态工厂。它提供了静态方法valueOf()来创建Integer。那么这种方式和直接写new Integer(100)有何区别呢?我们观察valueOf()方法:

1
2
3
4
5
6
7
8
public final class Integer {
public static Integer valueOf(int i) {
if (i >= IntegerCache.low && i <= IntegerCache.high)
return IntegerCache.cache[i + (-IntegerCache.low)];
return new Integer(i);
}
...
}

它的好处在于,valueOf()内部可能会使用new创建一个新的Integer实例,但也可能直接返回一个缓存的Integer实例。对于调用方来说,没必要知道Integer创建的细节。

提示

工厂方法可以隐藏创建产品的细节,且不一定每次都会真正创建产品,完全可以返回缓存的产品,从而提升速度并减少内存消耗。

如果调用方直接使用Integer n = new Integer(100),那么就失去了使用缓存优化的可能性。

我们经常使用的另一个静态工厂方法是List.of()

1
List<String> list = List.of("A", "B", "C");

这个静态工厂方法接收可变参数,然后返回List接口。需要注意的是,调用方获取的产品总是List接口,而且并不关心它的实际类型。即使调用方知道List产品的实际类型是java.util.ImmutableCollections$ListN,也不要去强制转型为子类,因为静态工厂方法List.of()保证返回List,但也完全可以修改为返回java.util.ArrayList。这就是里氏替换原则:返回实现接口的任意子类都可以满足该方法的要求,且不影响调用方。

提示

总是引用接口而非实现类,能允许变换子类而不影响调用方,即尽可能面向抽象编程。

List.of()类似,我们使用MessageDigest时,为了创建某个摘要算法,总是使用静态工厂方法getInstance(String)

1
2
MessageDigest md5 = MessageDigest.getInstance("MD5");
MessageDigest sha1 = MessageDigest.getInstance("SHA-1");

调用方通过产品名称获得产品实例,不但调用简单,而且获得的引用仍然是MessageDigest这个抽象类。

练习

使用静态工厂方法实现一个类似20200202的整数转换为LocalDate

1
2
3
4
5
public class LocalDateFactory {
public static LocalDate fromInt(int yyyyMMdd) {
...
}
}

下载练习

小结

工厂方法是指定义工厂接口和产品接口,但如何创建实际工厂和实际产品被推迟到子类实现,从而使调用方只和抽象工厂与抽象产品打交道。

实际更常用的是更简单的静态工厂方法,它允许工厂内部对创建产品进行优化。

调用方尽量持有接口或抽象类,避免持有具体类型的子类,以便工厂方法能随时切换不同的子类返回,却不影响调用方代码。

提供一个创建一系列相关或相互依赖对象的接口,而无需指定它们具体的类。

抽象工厂模式(Abstract Factory)是一个比较复杂的创建型模式。

抽象工厂模式和工厂方法不太一样,它要解决的问题比较复杂,不但工厂是抽象的,产品是抽象的,而且有多个产品需要创建,因此,这个抽象工厂会对应到多个实际工厂,每个实际工厂负责创建多个实际产品:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
                                ┌────────┐
─ ▶│ProductA│
┌────────┐ ┌─────────┐ │ └────────┘
│ Client │─ ─▶│ Factory │─ ─
└────────┘ └─────────┘ │ ┌────────┐
▲ ─ ▶│ProductB│
┌───────┴───────┐ └────────┘
│ │
┌─────────┐ ┌─────────┐
│Factory1 │ │Factory2 │
└─────────┘ └─────────┘
│ ┌─────────┐ │ ┌─────────┐
─ ▶│ProductA1│ ─ ▶│ProductA2│
│ └─────────┘ │ └─────────┘
┌─────────┐ ┌─────────┐
└ ─▶│ProductB1│ └ ─▶│ProductB2│
└─────────┘ └─────────┘

这种模式有点类似于多个供应商负责提供一系列类型的产品。我们举个例子:

假设我们希望为用户提供一个Markdown文本转换为HTML和Word的服务,它的接口定义如下:

1
2
3
4
5
6
public interface AbstractFactory {
// 创建Html文档:
HtmlDocument createHtml(String md);
// 创建Word文档:
WordDocument createWord(String md);
}

注意到上面的抽象工厂仅仅是一个接口,没有任何代码。同样的,因为HtmlDocumentWordDocument都比较复杂,现在我们并不知道如何实现它们,所以只有接口:

1
2
3
4
5
6
7
8
9
10
// Html文档接口:
public interface HtmlDocument {
String toHtml();
void save(Path path) throws IOException;
}

// Word文档接口:
public interface WordDocument {
void save(Path path) throws IOException;
}

这样,我们就定义好了抽象工厂(AbstractFactory)以及两个抽象产品(HtmlDocumentWordDocument)。因为实现它们比较困难,我们决定让供应商来完成。

现在市场上有两家供应商:FastDoc Soft的产品便宜,并且转换速度快,而GoodDoc Soft的产品贵,但转换效果好。我们决定同时使用这两家供应商的产品,以便给免费用户和付费用户提供不同的服务。

我们先看看FastDoc Soft的产品是如何实现的。首先,FastDoc Soft必须要有实际的产品,即FastHtmlDocumentFastWordDocument

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class FastHtmlDocument implements HtmlDocument {
public String toHtml() {
...
}
public void save(Path path) throws IOException {
...
}
}

public class FastWordDocument implements WordDocument {
public void save(Path path) throws IOException {
...
}
}

然后,FastDoc Soft必须提供一个实际的工厂来生产这两种产品,即FastFactory

1
2
3
4
5
6
7
8
public class FastFactory implements AbstractFactory {
public HtmlDocument createHtml(String md) {
return new FastHtmlDocument(md);
}
public WordDocument createWord(String md) {
return new FastWordDocument(md);
}
}

这样,我们就可以使用FastDoc Soft的服务了。客户端编写代码如下:

1
2
3
4
5
6
7
8
// 创建AbstractFactory,实际类型是FastFactory:
AbstractFactory factory = new FastFactory();
// 生成Html文档:
HtmlDocument html = factory.createHtml("#Hello\nHello, world!");
html.save(Paths.get(".", "fast.html"));
// 生成Word文档:
WordDocument word = factory.createWord("#Hello\nHello, world!");
word.save(Paths.get(".", "fast.doc"));

如果我们要同时使用GoodDoc Soft的服务怎么办?因为用了抽象工厂模式,GoodDoc Soft只需要根据我们定义的抽象工厂和抽象产品接口,实现自己的实际工厂和实际产品即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 实际工厂:
public class GoodFactory implements AbstractFactory {
public HtmlDocument createHtml(String md) {
return new GoodHtmlDocument(md);
}
public WordDocument createWord(String md) {
return new GoodWordDocument(md);
}
}

// 实际产品:
public class GoodHtmlDocument implements HtmlDocument {
...
}

public class GoodWordDocument implements HtmlDocument {
...
}

客户端要使用GoodDoc Soft的服务,只需要把原来的new FastFactory()切换为new GoodFactory()即可。

注意到客户端代码除了通过new创建了FastFactoryGoodFactory外,其余代码只引用了产品接口,并未引用任何实际产品(例如,FastHtmlDocument),如果把创建工厂的代码放到AbstractFactory中,就可以连实际工厂也屏蔽了:

1
2
3
4
5
6
7
8
9
10
11
public interface AbstractFactory {
public static AbstractFactory createFactory(String name) {
if (name.equalsIgnoreCase("fast")) {
return new FastFactory();
} else if (name.equalsIgnoreCase("good")) {
return new GoodFactory();
} else {
throw new IllegalArgumentException("Invalid factory name");
}
}
}

我们来看看FastFactoryGoodFactory创建的WordDocument的实际效果:

abstract-factory

注意:出于简化代码的目的,我们只支持两种Markdown语法:以#开头的标题以及普通正文。

练习

使用Abstract Factory模式实现HtmlDocumentWordDocument

下载练习

小结

抽象工厂模式是为了让创建工厂和一组产品与使用相分离,并可以随时切换到另一个工厂以及另一组产品;

抽象工厂模式实现的关键点是定义工厂接口和产品接口,但如何实现工厂与产品本身需要留给具体的子类实现,客户端只和抽象工厂与抽象产品打交道。

将一个复杂对象的构建与它的表示分离,使得同样的构建过程可以创建不同的表示。

生成器模式(Builder)是使用多个“小型”工厂来最终创建出一个完整对象。

当我们使用Builder的时候,一般来说,是因为创建这个对象的步骤比较多,每个步骤都需要一个零部件,最终组合成一个完整的对象。

我们仍然以Markdown转HTML为例,因为直接编写一个完整的转换器比较困难,但如果针对类似下面的一行文本:

1
# this is a heading

转换成HTML就很简单:

1
<h1>this is a heading</h1>

因此,我们把Markdown转HTML看作一行一行的转换,每一行根据语法,使用不同的转换器:

  • 如果以#开头,使用HeadingBuilder转换;
  • 如果以>开头,使用QuoteBuilder转换;
  • 如果以---开头,使用HrBuilder转换;
  • 其余使用ParagraphBuilder转换。

这个HtmlBuilder写出来如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class HtmlBuilder {
private HeadingBuilder headingBuilder = new HeadingBuilder();
private HrBuilder hrBuilder = new HrBuilder();
private ParagraphBuilder paragraphBuilder = new ParagraphBuilder();
private QuoteBuilder quoteBuilder = new QuoteBuilder();

public String toHtml(String markdown) {
StringBuilder buffer = new StringBuilder();
markdown.lines().forEach(line -> {
if (line.startsWith("#")) {
buffer.append(headingBuilder.buildHeading(line)).append('\n');
} else if (line.startsWith(">")) {
buffer.append(quoteBuilder.buildQuote(line)).append('\n');
} else if (line.startsWith("---")) {
buffer.append(hrBuilder.buildHr(line)).append('\n');
} else {
buffer.append(paragraphBuilder.buildParagraph(line)).append('\n');
}
});
return buffer.toString();
}
}

注意观察上述代码,HtmlBuilder并不是一次性把整个Markdown转换为HTML,而是一行一行转换,并且,它自己并不会将某一行转换为特定的HTML,而是根据特性把每一行都“委托”给一个XxxBuilder去转换,最后,把所有转换的结果组合起来,返回给客户端。

这样一来,我们只需要针对每一种类型编写不同的Builder。例如,针对以#开头的行,需要HeadingBuilder

1
2
3
4
5
6
7
8
9
10
public class HeadingBuilder {
public String buildHeading(String line) {
int n = 0;
while (line.charAt(0) == '#') {
n++;
line = line.substring(1);
}
return String.format("<h%d>%s</h%d>", n, line.strip(), n);
}
}

注意

实际解析Markdown是带有状态的,即下一行的语义可能与上一行相关。这里我们简化了语法,把每一行视为可以独立转换。

可见,使用Builder模式时,适用于创建的对象比较复杂,最好一步一步创建出“零件”,最后再装配起来。

JavaMail的MimeMessage就可以看作是一个Builder模式,只不过Builder和最终产品合二为一,都是MimeMessage

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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);

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");
// 设置邮件内容为multipart:
message.setContent(multipart);

很多时候,我们可以简化Builder模式,以链式调用的方式来创建对象。例如,我们经常编写这样的代码:

1
2
3
4
5
6
StringBuilder builder = new StringBuilder();
builder.append(secure ? "https://" : "http://")
.append("www.liaoxuefeng.com")
.append("/")
.append("?t=0");
String url = builder.toString();

由于我们经常需要构造URL字符串,可以使用Builder模式编写一个URLBuilder,调用方式如下:

1
2
3
4
5
6
String url = URLBuilder.builder() // 创建Builder
.setDomain("www.liaoxuefeng.com") // 设置domain
.setScheme("https") // 设置scheme
.setPath("/") // 设置路径
.setQuery(Map.of("a", "123", "q", "K&R")) // 设置query
.build(); // 完成build

练习

使用Builder模式实现一个URLBuilder。

下载练习

小结

Builder模式是为了创建一个复杂的对象,需要多个步骤完成创建,或者需要多个零件组装的场景,且创建过程中可以灵活调用不同的步骤或组件。

原型

用原型实例指定创建对象的种类,并且通过拷贝这些原型创建新的对象。

原型模式,即Prototype,是指创建新对象的时候,根据现有的一个原型来创建。

我们举个例子:如果我们已经有了一个String[]数组,想再创建一个一模一样的String[]数组,怎么写?

实际上创建过程很简单,就是把现有数组的元素复制到新数组。如果我们把这个创建过程封装一下,就成了原型模式。用代码实现如下:

1
2
3
4
// 原型:
String[] original = { "Apple", "Pear", "Banana" };
// 新对象:
String[] copy = Arrays.copyOf(original, original.length);

对于普通类,我们如何实现原型拷贝?Java的Object提供了一个clone()方法,它的意图就是复制一个新的对象出来,我们需要实现一个Cloneable接口来标识一个对象是“可复制”的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Student implements Cloneable {
private int id;
private String name;
private int score;

// 复制新对象并返回:
public Object clone() {
Student std = new Student();
std.id = this.id;
std.name = this.name;
std.score = this.score;
return std;
}
}

使用的时候,因为clone()的方法签名是定义在Object中,返回类型也是Object,所以要强制转型,比较麻烦:

1
2
3
4
5
6
7
8
9
Student std1 = new Student();
std1.setId(123);
std1.setName("Bob");
std1.setScore(88);
// 复制新对象:
Student std2 = (Student) std1.clone();
System.out.println(std1);
System.out.println(std2);
System.out.println(std1 == std2); // false

实际上,使用原型模式更好的方式是定义一个copy()方法,返回明确的类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Student {
private int id;
private String name;
private int score;

public Student copy() {
Student std = new Student();
std.id = this.id;
std.name = this.name;
std.score = this.score;
return std;
}
}

原型模式应用不是很广泛,因为很多实例会持有类似文件、Socket这样的资源,而这些资源是无法复制给另一个对象共享的,只有存储简单类型的“值”对象可以复制。

练习

Student类增加clone()方法。

下载练习

小结

原型模式是根据一个现有对象实例复制出一个新的实例,复制出的类型和属性与原实例相同。



保证一个类仅有一个实例,并提供一个访问它的全局访问点。

单例模式(Singleton)的目的是为了保证在一个进程中,某个类有且仅有一个实例。

因为这个类只有一个实例,因此,自然不能让调用方使用new Xyz()来创建实例了。所以,单例的构造方法必须是private,这样就防止了调用方自己创建实例,但是在类的内部,是可以用一个静态字段来引用唯一创建的实例的:

1
2
3
4
5
6
7
8
public class Singleton {
// 静态字段引用唯一实例:
private static final Singleton INSTANCE = new Singleton();

// private构造方法保证外部无法实例化:
private Singleton() {
}
}

那么问题来了,外部调用方如何获得这个唯一实例?

答案是提供一个静态方法,直接返回实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Singleton {
// 静态字段引用唯一实例:
private static final Singleton INSTANCE = new Singleton();

// 通过静态方法返回实例:
public static Singleton getInstance() {
return INSTANCE;
}

// private构造方法保证外部无法实例化:
private Singleton() {
}
}

或者直接把static变量暴露给外部:

1
2
3
4
5
6
7
8
public class Singleton {
// 静态字段引用唯一实例:
public static final Singleton INSTANCE = new Singleton();

// private构造方法保证外部无法实例化:
private Singleton() {
}
}

所以,单例模式的实现方式很简单:

  1. 只有private构造方法,确保外部无法实例化;
  2. 通过private static变量持有唯一实例,保证全局唯一性;
  3. 通过public static方法返回此唯一实例,使外部调用方能获取到实例。

Java标准库有一些类就是单例,例如Runtime这个类:

1
Runtime runtime = Runtime.getRuntime();

有些童鞋可能听说过延迟加载,即在调用方第一次调用getInstance()时才初始化全局唯一实例,类似这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Singleton {
private static Singleton INSTANCE = null;

public static Singleton getInstance() {
if (INSTANCE == null) {
INSTANCE = new Singleton();
}
return INSTANCE;
}

private Singleton() {
}
}

遗憾的是,这种写法在多线程中是错误的,在竞争条件下会创建出多个实例。必须对整个方法进行加锁:

1
2
3
4
5
6
public synchronized static Singleton getInstance() {
if (INSTANCE == null) {
INSTANCE = new Singleton();
}
return INSTANCE;
}

但加锁会严重影响并发性能。还有些童鞋听说过双重检查,类似这样:

1
2
3
4
5
6
7
8
9
10
public static Singleton getInstance() {
if (INSTANCE == null) {
synchronized (Singleton.class) {
if (INSTANCE == null) {
INSTANCE = new Singleton();
}
}
}
return INSTANCE;
}

然而,由于Java的内存模型,双重检查在这里不成立。要真正实现延迟加载,只能通过Java的ClassLoader机制完成。如果没有特殊的需求,使用Singleton模式的时候,最好不要延迟加载,这样会使代码更简单。

另一种实现Singleton的方式是利用Java的enum,因为Java保证枚举类的每个枚举都是单例,所以我们只需要编写一个只有一个枚举的类即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public enum World {
// 唯一枚举:
INSTANCE;

private String name = "world";

public String getName() {
return this.name;
}

public void setName(String name) {
this.name = name;
}
}

枚举类也完全可以像其他类那样定义自己的字段、方法,这样上面这个World类在调用方看来就可以这么用:

1
String name = World.INSTANCE.getName();

使用枚举实现Singleton还避免了第一种方式实现Singleton的一个潜在问题:即序列化和反序列化会绕过普通类的private构造方法从而创建出多个实例,而枚举类就没有这个问题。

那我们什么时候应该用Singleton呢?实际上,很多程序,尤其是Web程序,大部分服务类都应该被视作Singleton,如果全部按Singleton的写法写,会非常麻烦,所以,通常是通过约定让框架(例如Spring)来实例化这些类,保证只有一个实例,调用方自觉通过框架获取实例而不是new操作符:

1
2
3
4
@Component // 表示一个单例组件
public class MyService {
...
}

因此,除非确有必要,否则Singleton模式一般以“约定”为主,不会刻意实现它。

练习

使用两种Singleton模式实现单例。

下载练习

小结

Singleton模式是为了保证一个程序的运行期间,某个类有且只有一个全局唯一实例;

Singleton模式既可以严格实现,也可以以约定的方式把普通类视作单例。

留言與分享

JAVA-设计模式

分類 编程语言, Java

设计模式

设计模式,即Design Patterns,是指在软件设计中,被反复使用的一种代码设计经验。使用设计模式的目的是为了可重用代码,提高代码的可扩展性和可维护性。

设计模式这个术语是上个世纪90年代由Erich Gamma、Richard Helm、Raplh Johnson和Jonhn Vlissides四个人总结提炼出来的,并且写了一本Design Patterns的书。这四人也被称为四人帮(GoF)。

为什么要使用设计模式?根本原因还是软件开发要实现可维护、可扩展,就必须尽量复用代码,并且降低代码的耦合度。设计模式主要是基于OOP编程提炼的,它基于以下几个原则:

开闭原则

由Bertrand Meyer提出的开闭原则(Open Closed Principle)是指,软件应该对扩展开放,而对修改关闭。这里的意思是在增加新功能的时候,能不改代码就尽量不要改,如果只增加代码就完成了新功能,那是最好的。

里氏替换原则

里氏替换原则是Barbara Liskov提出的,这是一种面向对象的设计原则,即如果我们调用一个父类的方法可以成功,那么替换成子类调用也应该完全可以运行。

设计模式把一些常用的设计思想提炼出一个个模式,然后给每个模式命名,这样在使用的时候更方便交流。GoF把23个常用模式分为创建型模式、结构型模式和行为型模式三类,我们后续会一一讲解。

学习设计模式,关键是学习设计思想,不能简单地生搬硬套,也不能为了使用设计模式而过度设计,要合理平衡设计的复杂度和灵活性,并意识到设计模式也并不是万能的。

留言與分享

JAVA-函数式编程

分類 编程语言, Java

函数式编程

本章我们介绍Java的函数式编程。

我们先看看什么是函数。函数是一种最基本的任务,一个大型程序就是一个顶层函数调用若干底层函数,这些被调用的函数又可以调用其他函数,即大任务被一层层拆解并执行。所以函数就是面向过程的程序设计的基本单元。

Java不支持单独定义函数,但可以把静态方法视为独立的函数,把实例方法视为自带this参数的函数。

而函数式编程(请注意多了一个“式”字)——Functional Programming,虽然也可以归结到面向过程的程序设计,但其思想更接近数学计算。

我们首先要搞明白计算机(Computer)和计算(Compute)的概念。

在计算机的层次上,CPU执行的是加减乘除的指令代码,以及各种条件判断和跳转指令,所以,汇编语言是最贴近计算机的语言。

而计算则指数学意义上的计算,越是抽象的计算,离计算机硬件越远。

对应到编程语言,就是越低级的语言,越贴近计算机,抽象程度低,执行效率高,比如C语言;越高级的语言,越贴近计算,抽象程度高,执行效率低,比如Lisp语言。

函数式编程就是一种抽象程度很高的编程范式,纯粹的函数式编程语言编写的函数没有变量,因此,任意一个函数,只要输入是确定的,输出就是确定的,这种纯函数我们称之为没有副作用。而允许使用变量的程序设计语言,由于函数内部的变量状态不确定,同样的输入,可能得到不同的输出,因此,这种函数是有副作用的。

函数式编程的一个特点就是,允许把函数本身作为参数传入另一个函数,还允许返回一个函数!

函数式编程最早是数学家阿隆佐·邱奇研究的一套函数变换逻辑,又称Lambda Calculus(λ-Calculus),所以也经常把函数式编程称为Lambda计算。

Java平台从Java 8开始,支持函数式编程。

lambda



在了解Lambda之前,我们先回顾一下Java的方法。

Java的方法分为实例方法,例如Integer定义的equals()方法:

1
2
3
4
5
public final class Integer {
boolean equals(Object o) {
...
}
}

以及静态方法,例如Integer定义的parseInt()方法:

1
2
3
4
5
public final class Integer {
public static int parseInt(String s) {
...
}
}

无论是实例方法,还是静态方法,本质上都相当于过程式语言的函数。例如C函数:

1
char* strcpy(char* dest, char* src)

只不过Java的实例方法隐含地传入了一个this变量,即实例方法总是有一个隐含参数this

函数式编程(Functional Programming)是把函数作为基本运算单元,函数可以作为变量,可以接收函数,还可以返回函数。历史上研究函数式编程的理论是Lambda演算,所以我们经常把支持函数式编程的编码风格称为Lambda表达式。

Lambda表达式

在Java程序中,我们经常遇到一大堆单方法接口,即一个接口只定义了一个方法:

  • Comparator
  • Runnable
  • Callable

Comparator为例,我们想要调用Arrays.sort()时,可以传入一个Comparator实例,以匿名类方式编写如下:

1
2
3
4
5
6
String[] array = ...
Arrays.sort(array, new Comparator<String>() {
public int compare(String s1, String s2) {
return s1.compareTo(s2);
}
});

上述写法非常繁琐。从Java 8开始,我们可以用Lambda表达式替换单方法接口。改写上述代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
// Lambda
import java.util.Arrays;

public class Main {
public static void main(String[] args) {
String[] array = new String[] { "Apple", "Orange", "Banana", "Lemon" };
Arrays.sort(array, (s1, s2) -> {
return s1.compareTo(s2);
});
System.out.println(String.join(", ", array));
}
}

观察Lambda表达式的写法,它只需要写出方法定义:

1
2
3
(s1, s2) -> {
return s1.compareTo(s2);
}

其中,参数是(s1, s2),参数类型可以省略,因为编译器可以自动推断出String类型。-> { ... }表示方法体,所有代码写在内部即可。Lambda表达式没有class定义,因此写法非常简洁。

如果只有一行return xxx的代码,完全可以用更简单的写法:

1
Arrays.sort(array, (s1, s2) -> s1.compareTo(s2));

返回值的类型也是由编译器自动推断的,这里推断出的返回值是int,因此,只要返回int,编译器就不会报错。

FunctionalInterface

我们把只定义了单方法的接口称之为FunctionalInterface,用注解@FunctionalInterface标记。例如,Callable接口:

1
2
3
4
@FunctionalInterface
public interface Callable<V> {
V call() throws Exception;
}

再来看Comparator接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@FunctionalInterface
public interface Comparator<T> {

int compare(T o1, T o2);

boolean equals(Object obj);

default Comparator<T> reversed() {
return Collections.reverseOrder(this);
}

default Comparator<T> thenComparing(Comparator<? super T> other) {
...
}
...
}

虽然Comparator接口有很多方法,但只有一个抽象方法int compare(T o1, T o2),其他的方法都是default方法或static方法。另外注意到boolean equals(Object obj)Object定义的方法,不算在接口方法内。因此,Comparator也是一个FunctionalInterface

练习

使用Lambda表达式实现忽略大小写排序。

下载练习

小结

单方法接口被称为FunctionalInterface

接收FunctionalInterface作为参数的时候,可以把实例化的匿名类改写为Lambda表达式,能大大简化代码。

Lambda表达式的参数和返回值均可由编译器自动推断。

使用Lambda表达式,我们就可以不必编写FunctionalInterface接口的实现类,从而简化代码:

1
2
3
Arrays.sort(array, (s1, s2) -> {
return s1.compareTo(s2);
});

实际上,除了Lambda表达式,我们还可以直接传入方法引用。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
import java.util.Arrays;

public class Main {
public static void main(String[] args) {
String[] array = new String[] { "Apple", "Orange", "Banana", "Lemon" };
Arrays.sort(array, Main::cmp);
System.out.println(String.join(", ", array));
}

static int cmp(String s1, String s2) {
return s1.compareTo(s2);
}
}

上述代码在Arrays.sort()中直接传入了静态方法cmp的引用,用Main::cmp表示。

因此,所谓方法引用,是指如果某个方法签名和接口恰好一致,就可以直接传入方法引用。

因为Comparator<String>接口定义的方法是int compare(String, String),和静态方法int cmp(String, String)相比,除了方法名外,方法参数一致,返回类型相同,因此,我们说两者的方法签名一致,可以直接把方法名作为Lambda表达式传入:

1
Arrays.sort(array, Main::cmp);

注意:在这里,方法签名只看参数类型和返回类型,不看方法名称,也不看类的继承关系。

我们再看看如何引用实例方法。如果我们把代码改写如下:

1
2
3
4
5
6
7
8
9
import java.util.Arrays;

public class Main {
public static void main(String[] args) {
String[] array = new String[] { "Apple", "Orange", "Banana", "Lemon" };
Arrays.sort(array, String::compareTo);
System.out.println(String.join(", ", array));
}
}

不但可以编译通过,而且运行结果也是一样的,这说明String.compareTo()方法也符合Lambda定义。

观察String.compareTo()的方法定义:

1
2
3
4
5
public final class String {
public int compareTo(String o) {
...
}
}

这个方法的签名只有一个参数,为什么和int Comparator<String>.compare(String, String)能匹配呢?

因为实例方法有一个隐含的this参数,String类的compareTo()方法在实际调用的时候,第一个隐含参数总是传入this,相当于静态方法:

1
public static int compareTo(String this, String o);

所以,String.compareTo()方法也可作为方法引用传入。

构造方法引用

除了可以引用静态方法和实例方法,我们还可以引用构造方法。

我们来看一个例子:如果要把一个List<String>转换为List<Person>,应该怎么办?

1
2
3
4
5
6
7
8
9
class Person {
String name;
public Person(String name) {
this.name = name;
}
}

List<String> names = List.of("Bob", "Alice", "Tim");
List<Person> persons = ???

传统的做法是先定义一个ArrayList<Person>,然后用for循环填充这个List

1
2
3
4
5
List<String> names = List.of("Bob", "Alice", "Tim");
List<Person> persons = new ArrayList<>();
for (String name : names) {
persons.add(new Person(name));
}

要更简单地实现StringPerson的转换,我们可以引用Person的构造方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 引用构造方法
import java.util.*;
import java.util.stream.*;

public class Main {
public static void main(String[] args) {
List<String> names = List.of("Bob", "Alice", "Tim");
List<Person> persons = names.stream().map(Person::new).collect(Collectors.toList());
System.out.println(persons);
}
}

class Person {
String name;
public Person(String name) {
this.name = name;
}
public String toString() {
return "Person:" + this.name;
}
}

后面我们会讲到Streammap()方法。现在我们看到,这里的map()需要传入的FunctionalInterface的定义是:

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

把泛型对应上就是方法签名Person apply(String),即传入参数String,返回类型Person。而Person类的构造方法恰好满足这个条件,因为构造方法的参数是String,而构造方法虽然没有return语句,但它会隐式地返回this实例,类型就是Person,因此,此处可以引用构造方法。构造方法的引用写法是类名::new,因此,此处传入Person::new

练习

使用方法引用实现忽略大小写排序。

下载练习

小结

FunctionalInterface允许传入:

  • 接口的实现类(传统写法,代码较繁琐);
  • Lambda表达式(只需列出参数名,由编译器推断类型);
  • 符合方法签名的静态方法;
  • 符合方法签名的实例方法(实例类型被看做第一个参数类型);
  • 符合方法签名的构造方法(实例类型被看做返回类型)。

FunctionalInterface不强制继承关系,不需要方法名称相同,只要求方法参数(类型和数量)与方法返回类型相同,即认为方法签名相同。

Java从8开始,不但引入了Lambda表达式,还引入了一个全新的流式API:Stream API。它位于java.util.stream包中。

划重点:这个Stream不同于java.ioInputStreamOutputStream,它代表的是任意Java对象的序列。两者对比如下:

java.io java.util.stream
存储 顺序读写的bytechar 顺序输出的任意Java对象实例
用途 序列化至文件或网络 内存计算/业务逻辑

有同学会问:一个顺序输出的Java对象序列,不就是一个List容器吗?

再次划重点:这个StreamList也不一样,List存储的每个元素都是已经存储在内存中的某个Java对象,而Stream输出的元素可能并没有预先存储在内存中,而是实时计算出来的。

换句话说,List的用途是操作一组已存在的Java对象,而Stream实现的是惰性计算,两者对比如下:

java.util.List java.util.stream
元素 已分配并存储在内存 可能未分配,实时计算
用途 操作一组已存在的Java对象 惰性计算

Stream看上去有点不好理解,但我们举个例子就明白了。

如果我们要表示一个全体自然数的集合,显然,用List是不可能写出来的,因为自然数是无限的,内存再大也没法放到List中:

1
List<BigInteger> list = ??? // 全体自然数?

但是,用Stream可以做到。写法如下:

1
Stream<BigInteger> naturals = createNaturalStream(); // 全体自然数

我们先不考虑createNaturalStream()这个方法是如何实现的,我们看看如何使用这个Stream

首先,我们可以对每个自然数做一个平方,这样我们就把这个Stream转换成了另一个Stream

1
2
Stream<BigInteger> naturals = createNaturalStream(); // 全体自然数
Stream<BigInteger> streamNxN = naturals.map(n -> n.multiply(n)); // 全体自然数的平方

因为这个streamNxN也有无限多个元素,要打印它,必须首先把无限多个元素变成有限个元素,可以用limit()方法截取前100个元素,最后用forEach()处理每个元素,这样,我们就打印出了前100个自然数的平方:

1
2
3
4
Stream<BigInteger> naturals = createNaturalStream();
naturals.map(n -> n.multiply(n)) // 1, 4, 9, 16, 25...
.limit(100)
.forEach(System.out::println);

我们总结一下Stream的特点:它可以“存储”有限个或无限个元素。这里的存储打了个引号,是因为元素有可能已经全部存储在内存中,也有可能是根据需要实时计算出来的。

Stream的另一个特点是,一个Stream可以轻易地转换为另一个Stream,而不是修改原Stream本身。

最后,真正的计算通常发生在最后结果的获取,也就是惰性计算。

1
2
3
4
Stream<BigInteger> naturals = createNaturalStream(); // 不计算
Stream<BigInteger> s2 = naturals.map(n -> n.multiply(n)); // 不计算
Stream<BigInteger> s3 = s2.limit(100); // 不计算
s3.forEach(System.out::println); // 计算

惰性计算的特点是:一个Stream转换为另一个Stream时,实际上只存储了转换规则,并没有任何计算发生。

例如,创建一个全体自然数的Stream,不会进行计算,把它转换为上述s2这个Stream,也不会进行计算。再把s2这个无限Stream转换为s3这个有限的Stream,也不会进行计算。只有最后,调用forEach确实需要Stream输出的元素时,才进行计算。我们通常把Stream的操作写成链式操作,代码更简洁:

1
2
3
4
createNaturalStream()
.map(n -> n.multiply(n))
.limit(100)
.forEach(System.out::println);

因此,Stream API的基本用法就是:创建一个Stream,然后做若干次转换,最后调用一个求值方法获取真正计算的结果:

1
2
3
4
5
int result = createNaturalStream() // 创建Stream
.filter(n -> n % 2 == 0) // 任意个转换
.map(n -> n * n) // 任意个转换
.limit(100) // 任意个转换
.sum(); // 最终计算结果

小结

Stream API的特点是:

  • Stream API提供了一套新的流式处理的抽象序列;
  • Stream API支持函数式编程和链式操作;
  • Stream可以表示无限序列,并且大多数情况下是惰性求值的。

要使用Stream,就必须先创建它。创建Stream有很多种方法,我们来一一介绍。

Stream.of()

创建Stream最简单的方式是直接用Stream.of()静态方法,传入可变参数即创建了一个能输出确定元素的Stream

1
2
3
4
5
6
7
8
9
10
import java.util.stream.Stream;

public class Main {
public static void main(String[] args) {
Stream<String> stream = Stream.of("A", "B", "C", "D");
// forEach()方法相当于内部循环调用,
// 可传入符合Consumer接口的void accept(T t)的方法引用:
stream.forEach(System.out::println);
}
}

虽然这种方式基本上没啥实质性用途,但测试的时候很方便。

基于数组或Collection

第二种创建Stream的方法是基于一个数组或者Collection,这样该Stream输出的元素就是数组或者Collection持有的元素:

1
2
3
4
5
6
7
8
9
10
11
import java.util.*;
import java.util.stream.*;

public class Main {
public static void main(String[] args) {
Stream<String> stream1 = Arrays.stream(new String[] { "A", "B", "C" });
Stream<String> stream2 = List.of("X", "Y", "Z").stream();
stream1.forEach(System.out::println);
stream2.forEach(System.out::println);
}
}

把数组变成Stream使用Arrays.stream()方法。对于CollectionListSetQueue等),直接调用stream()方法就可以获得Stream

上述创建Stream的方法都是把一个现有的序列变为Stream,它的元素是固定的。

基于Supplier

创建Stream还可以通过Stream.generate()方法,它需要传入一个Supplier对象:

1
Stream<String> s = Stream.generate(Supplier<String> sp);

基于Supplier创建的Stream会不断调用Supplier.get()方法来不断产生下一个元素,这种Stream保存的不是元素,而是算法,它可以用来表示无限序列。

例如,我们编写一个能不断生成自然数的Supplier,它的代码非常简单,每次调用get()方法,就生成下一个自然数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import java.util.function.*;
import java.util.stream.*;

public class Main {
public static void main(String[] args) {
Stream<Integer> natual = Stream.generate(new NatualSupplier());
// 注意:无限序列必须先变成有限序列再打印:
natual.limit(20).forEach(System.out::println);
}
}

class NatualSupplier implements Supplier<Integer> {
int n = 0;
public Integer get() {
n++;
return n;
}
}

上述代码我们用一个Supplier<Integer>模拟了一个无限序列(当然受int范围限制不是真的无限大)。如果用List表示,即便在int范围内,也会占用巨大的内存,而Stream几乎不占用空间,因为每个元素都是实时计算出来的,用的时候再算。

对于无限序列,如果直接调用forEach()或者count()这些最终求值操作,会进入死循环,因为永远无法计算完这个序列,所以正确的方法是先把无限序列变成有限序列,例如,用limit()方法可以截取前面若干个元素,这样就变成了一个有限序列,对这个有限序列调用forEach()或者count()操作就没有问题。

其他方法

创建Stream的第三种方法是通过一些API提供的接口,直接获得Stream

例如,Files类的lines()方法可以把一个文件变成一个Stream,每个元素代表文件的一行内容:

1
2
3
try (Stream<String> lines = Files.lines(Paths.get("/path/to/file.txt"))) {
...
}

此方法对于按行遍历文本文件十分有用。

另外,正则表达式的Pattern对象有一个splitAsStream()方法,可以直接把一个长字符串分割成Stream序列而不是数组:

1
2
3
Pattern p = Pattern.compile("\\s+");
Stream<String> s = p.splitAsStream("The quick brown fox jumps over the lazy dog");
s.forEach(System.out::println);

基本类型

因为Java的泛型不支持基本类型,所以我们无法用Stream<int>这样的类型,会发生编译错误。为了保存int,只能使用Stream<Integer>,但这样会产生频繁的装箱、拆箱操作。为了提高效率,Java标准库提供了IntStreamLongStreamDoubleStream这三种使用基本类型的Stream,它们的使用方法和泛型Stream没有大的区别,设计这三个Stream的目的是提高运行效率:

1
2
3
4
// 将int[]数组变为IntStream:
IntStream is = Arrays.stream(new int[] { 1, 2, 3 });
// 将Stream<String>转换为LongStream:
LongStream ls = List.of("1", "2", "3").stream().mapToLong(Long::parseLong);

练习

编写一个能输出斐波拉契数列(Fibonacci)的LongStream

1
1, 1, 2, 3, 5, 8, 13, 21, 34, ...

下载练习

小结

创建Stream的方法有 :

  • 通过指定元素、指定数组、指定Collection创建Stream
  • 通过Supplier创建Stream,可以是无限序列;
  • 通过其他类的相关方法创建。

基本类型的StreamIntStreamLongStreamDoubleStream

使用map

Stream.map()Stream最常用的一个转换方法,它把一个Stream转换为另一个Stream

所谓map操作,就是把一种操作运算,映射到一个序列的每一个元素上。例如,对x计算它的平方,可以使用函数f(x) = x * x。我们把这个函数映射到一个序列1,2,3,4,5上,就得到了另一个序列1,4,9,16,25:

1
2
3
4
5
6
7
8
9
10
11
12
13
            f(x) = x * x


┌───┬───┬───┬───┼───┬───┬───┬───┐
│ │ │ │ │ │ │ │ │
▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼

[ 1 2 3 4 5 6 7 8 9 ]

│ │ │ │ │ │ │ │ │
▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼

[ 1 4 9 16 25 36 49 64 81 ]

可见,map操作,把一个Stream的每个元素一一对应到应用了目标函数的结果上。

1
2
Stream<Integer> s = Stream.of(1, 2, 3, 4, 5);
Stream<Integer> s2 = s.map(n -> n * n);

如果我们查看Stream的源码,会发现map()方法接收的对象是Function接口对象,它定义了一个apply()方法,负责把一个T类型转换成R类型:

1
<R> Stream<R> map(Function<? super T, ? extends R> mapper);

其中,Function的定义是:

1
2
3
4
5
@FunctionalInterface
public interface Function<T, R> {
// 将T类型转换为R:
R apply(T t);
}

利用map(),不但能完成数学计算,对于字符串操作,以及任何Java对象都是非常有用的。例如:

1
2
3
4
5
6
7
8
9
10
11
12
import java.util.*;
import java.util.stream.*;

public class Main {
public static void main(String[] args) {
List.of(" Apple ", " pear ", " ORANGE", " BaNaNa ")
.stream()
.map(String::trim) // 去空格
.map(String::toLowerCase) // 变小写
.forEach(System.out::println); // 打印
}
}

通过若干步map转换,可以写出逻辑简单、清晰的代码。

练习

使用map()把一组String转换为LocalDate并打印。

下载练习

小结

map()方法用于将一个Stream的每个元素映射成另一个元素并转换成一个新的Stream

可以将一种元素类型转换成另一种元素类型。



使用filter

Stream.filter()Stream的另一个常用转换方法。

所谓filter()操作,就是对一个Stream的所有元素一一进行测试,不满足条件的就被“滤掉”了,剩下的满足条件的元素就构成了一个新的Stream

例如,我们对1,2,3,4,5这个Stream调用filter(),传入的测试函数f(x) = x % 2 != 0用来判断元素是否是奇数,这样就过滤掉偶数,只剩下奇数,因此我们得到了另一个序列1,3,5:

1
2
3
4
5
6
7
8
9
10
11
12
13
            f(x) = x % 2 != 0


┌───┬───┬───┬───┼───┬───┬───┬───┐
│ │ │ │ │ │ │ │ │
▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼

[ 1 2 3 4 5 6 7 8 9 ]

│ X │ X │ X │ X │
▼ ▼ ▼ ▼ ▼

[ 1 3 5 7 9 ]

用IntStream写出上述逻辑,代码如下:

1
2
3
4
5
6
7
8
9
import java.util.stream.IntStream;

public class Main {
public static void main(String[] args) {
IntStream.of(1, 2, 3, 4, 5, 6, 7, 8, 9)
.filter(n -> n % 2 != 0)
.forEach(System.out::println);
}
}

从结果可知,经过filter()后生成的Stream元素可能变少。

filter()方法接收的对象是Predicate接口对象,它定义了一个test()方法,负责判断元素是否符合条件:

1
2
3
4
5
@FunctionalInterface
public interface Predicate<T> {
// 判断元素t是否符合条件:
boolean test(T t);
}

filter()除了常用于数值外,也可应用于任何Java对象。例如,从一组给定的LocalDate中过滤掉工作日,以便得到休息日:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import java.time.*;
import java.util.function.*;
import java.util.stream.*;

public class Main {
public static void main(String[] args) {
Stream.generate(new LocalDateSupplier())
.limit(31)
.filter(ldt -> ldt.getDayOfWeek() == DayOfWeek.SATURDAY || ldt.getDayOfWeek() == DayOfWeek.SUNDAY)
.forEach(System.out::println);
}
}

class LocalDateSupplier implements Supplier<LocalDate> {
LocalDate start = LocalDate.of(2020, 1, 1);
int n = -1;
public LocalDate get() {
n++;
return start.plusDays(n);
}
}

练习

请使用filter()过滤出成绩及格的同学,并打印出名字。

下载练习

小结

使用filter()方法可以对一个Stream的每个元素进行测试,通过测试的元素被过滤后生成一个新的Stream



map()filter()都是Stream的转换方法,而Stream.reduce()则是Stream的一个聚合方法,它可以把一个Stream的所有元素按照聚合函数聚合成一个结果。

我们来看一个简单的聚合方法:

1
2
3
4
5
6
7
8
import java.util.stream.*;

public class Main {
public static void main(String[] args) {
int sum = Stream.of(1, 2, 3, 4, 5, 6, 7, 8, 9).reduce(0, (acc, n) -> acc + n);
System.out.println(sum); // 45
}
}

reduce()方法传入的对象是BinaryOperator接口,它定义了一个apply()方法,负责把上次累加的结果和本次的元素 进行运算,并返回累加的结果:

1
2
3
4
5
@FunctionalInterface
public interface BinaryOperator<T> {
// Bi操作:两个输入,一个输出
T apply(T t, T u);
}

上述代码看上去不好理解,但我们用for循环改写一下,就容易理解了:

1
2
3
4
5
Stream<Integer> stream = ...
int sum = 0;
for (n : stream) {
sum = (sum, n) -> sum + n;
}

可见,reduce()操作首先初始化结果为指定值(这里是0),紧接着,reduce()对每个元素依次调用(acc, n) -> acc + n,其中,acc是上次计算的结果:

1
2
3
4
5
6
7
8
9
10
11
// 计算过程:
acc = 0 // 初始化为指定值
acc = acc + n = 0 + 1 = 1 // n = 1
acc = acc + n = 1 + 2 = 3 // n = 2
acc = acc + n = 3 + 3 = 6 // n = 3
acc = acc + n = 6 + 4 = 10 // n = 4
acc = acc + n = 10 + 5 = 15 // n = 5
acc = acc + n = 15 + 6 = 21 // n = 6
acc = acc + n = 21 + 7 = 28 // n = 7
acc = acc + n = 28 + 8 = 36 // n = 8
acc = acc + n = 36 + 9 = 45 // n = 9

因此,实际上这个reduce()操作是一个求和。

如果去掉初始值,我们会得到一个Optional<Integer>

1
2
3
4
Optional<Integer> opt = stream.reduce((acc, n) -> acc + n);
if (opt.isPresent()) {
System.out.println(opt.get());
}

这是因为Stream的元素有可能是0个,这样就没法调用reduce()的聚合函数了,因此返回Optional对象,需要进一步判断结果是否存在。

利用reduce(),我们可以把求和改成求积,代码也十分简单:

1
2
3
4
5
6
7
8
import java.util.stream.*;

public class Main {
public static void main(String[] args) {
int s = Stream.of(1, 2, 3, 4, 5, 6, 7, 8, 9).reduce(1, (acc, n) -> acc * n);
System.out.println(s); // 362880
}
}

注意:计算求积时,初始值必须设置为1

除了可以对数值进行累积计算外,灵活运用reduce()也可以对Java对象进行操作。下面的代码演示了如何将配置文件的每一行配置通过map()reduce()操作聚合成一个Map<String, String>

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.util.*;

public class Main {
public static void main(String[] args) {
// 按行读取配置文件:
List<String> props = List.of("profile=native", "debug=true", "logging=warn", "interval=500");
Map<String, String> map = props.stream()
// 把k=v转换为Map[k]=v:
.map(kv -> {
String[] ss = kv.split("\\=", 2);
return Map.of(ss[0], ss[1]);
})
// 把所有Map聚合到一个Map:
.reduce(new HashMap<String, String>(), (m, kv) -> {
m.putAll(kv);
return m;
});
// 打印结果:
map.forEach((k, v) -> {
System.out.println(k + " = " + v);
});
}
}

小结

reduce()方法将一个Stream的每个元素依次作用于BinaryOperator,并将结果合并。

reduce()是聚合方法,聚合方法会立刻对Stream进行计算。

我们介绍了Stream的几个常见操作:map()filter()reduce()。这些操作对Stream来说可以分为两类,一类是转换操作,即把一个Stream转换为另一个Stream,例如map()filter(),另一类是聚合操作,即对Stream的每个元素进行计算,得到一个确定的结果,例如reduce()

区分这两种操作是非常重要的,因为对于Stream来说,对其进行转换操作并不会触发任何计算!我们可以做个实验:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import java.util.function.Supplier; 
import java.util.stream.Stream;

public class Main {
public static void main(String[] args) {
Stream<Long> s1 = Stream.generate(new NatualSupplier());
Stream<Long> s2 = s1.map(n -> n * n);
Stream<Long> s3 = s2.map(n -> n - 1);
System.out.println(s3); // java.util.stream.ReferencePipeline$3@49476842
}
}

class NatualSupplier implements Supplier<Long> {
long n = 0;
public Long get() {
n++;
return n;
}
}

因为s1是一个Long类型的序列,它的元素高达922亿亿个,但执行上述代码,既不会有任何内存增长,也不会有任何计算,因为转换操作只是保存了转换规则,无论我们对一个Stream转换多少次,都不会有任何实际计算发生。

而聚合操作则不一样,聚合操作会立刻促使Stream输出它的每一个元素,并依次纳入计算,以获得最终结果。所以,对一个Stream进行聚合操作,会触发一系列连锁反应:

1
2
3
4
5
Stream<Long> s1 = Stream.generate(new NatualSupplier());
Stream<Long> s2 = s1.map(n -> n * n);
Stream<Long> s3 = s2.map(n -> n - 1);
Stream<Long> s4 = s3.limit(10);
s4.reduce(0, (acc, n) -> acc + n);

我们对s4进行reduce()聚合计算,会不断请求s4输出它的每一个元素。因为s4的上游是s3,它又会向s3请求元素,导致s3s2请求元素,s2s1请求元素,最终,s1Supplier实例中请求到真正的元素,并经过一系列转换,最终被reduce()聚合出结果。

可见,聚合操作是真正需要从Stream请求数据的,对一个Stream做聚合计算后,结果就不是一个Stream,而是一个其他的Java对象。

输出为List

reduce()只是一种聚合操作,如果我们希望把Stream的元素保存到集合,例如List,因为List的元素是确定的Java对象,因此,把Stream变为List不是一个转换操作,而是一个聚合操作,它会强制Stream输出每个元素。

下面的代码演示了如何将一组String先过滤掉空字符串,然后把非空字符串保存到List中:

1
2
3
4
5
6
7
8
9
10
import java.util.*;
import java.util.stream.*;

public class Main {
public static void main(String[] args) {
Stream<String> stream = Stream.of("Apple", "", null, "Pear", " ", "Orange");
List<String> list = stream.filter(s -> s != null && !s.isBlank()).collect(Collectors.toList());
System.out.println(list);
}
}

Stream的每个元素收集到List的方法是调用collect()并传入Collectors.toList()对象,它实际上是一个Collector实例,通过类似reduce()的操作,把每个元素添加到一个收集器中(实际上是ArrayList)。

类似的,collect(Collectors.toSet())可以把Stream的每个元素收集到Set中。

输出为数组

把Stream的元素输出为数组和输出为List类似,我们只需要调用toArray()方法,并传入数组的“构造方法”:

1
2
List<String> list = List.of("Apple", "Banana", "Orange");
String[] array = list.stream().toArray(String[]::new);

注意到传入的“构造方法”是String[]::new,它的签名实际上是IntFunction<String[]>定义的String[] apply(int),即传入int参数,获得String[]数组的返回值。

输出为Map

如果我们要把Stream的元素收集到Map中,就稍微麻烦一点。因为对于每个元素,添加到Map时需要key和value,因此,我们要指定两个映射函数,分别把元素映射为key和value:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import java.util.*;
import java.util.stream.*;

public class Main {
public static void main(String[] args) {
Stream<String> stream = Stream.of("APPL:Apple", "MSFT:Microsoft");
Map<String, String> map = stream
.collect(Collectors.toMap(
// 把元素s映射为key:
s -> s.substring(0, s.indexOf(':')),
// 把元素s映射为value:
s -> s.substring(s.indexOf(':') + 1)));
System.out.println(map);
}
}

分组输出

Stream还有一个强大的分组功能,可以按组输出。我们看下面的例子:

1
2
3
4
5
6
7
8
9
10
11
import java.util.*;
import java.util.stream.*;

public class Main {
public static void main(String[] args) {
List<String> list = List.of("Apple", "Banana", "Blackberry", "Coconut", "Avocado", "Cherry", "Apricots");
Map<String, List<String>> groups = list.stream()
.collect(Collectors.groupingBy(s -> s.substring(0, 1), Collectors.toList()));
System.out.println(groups);
}
}

分组输出使用Collectors.groupingBy(),它需要提供两个函数:一个是分组的key,这里使用s -> s.substring(0, 1),表示只要首字母相同的String分到一组,第二个是分组的value,这里直接使用Collectors.toList(),表示输出为List,上述代码运行结果如下:

1
2
3
4
5
{
A=[Apple, Avocado, Apricots],
B=[Banana, Blackberry],
C=[Coconut, Cherry]
}

可见,结果一共有3组,按"A""B""C"分组,每一组都是一个List

假设有这样一个Student类,包含学生姓名、班级和成绩:

1
2
3
4
5
6
class Student {
int gradeId; // 年级
int classId; // 班级
String name; // 名字
int score; // 分数
}

如果我们有一个Stream<Student>,利用分组输出,可以非常简单地按年级或班级把Student归类。

小结

Stream可以输出为集合:

Stream通过collect()方法可以方便地输出为ListSetMap,还可以分组输出。

我们把Stream提供的操作分为两类:转换操作和聚合操作。除了前面介绍的常用操作外,Stream还提供了一系列非常有用的方法。

排序

Stream的元素进行排序十分简单,只需调用sorted()方法:

1
2
3
4
5
6
7
8
9
10
11
12
import java.util.*;
import java.util.stream.*;

public class Main {
public static void main(String[] args) {
List<String> list = List.of("Orange", "apple", "Banana")
.stream()
.sorted()
.collect(Collectors.toList());
System.out.println(list);
}
}

此方法要求Stream的每个元素必须实现Comparable接口。如果要自定义排序,传入指定的Comparator即可:

1
2
3
4
List<String> list = List.of("Orange", "apple", "Banana")
.stream()
.sorted(String::compareToIgnoreCase)
.collect(Collectors.toList());

注意sorted()只是一个转换操作,它会返回一个新的Stream

去重

对一个Stream的元素进行去重,没必要先转换为Set,可以直接用distinct()

1
2
3
4
List.of("A", "B", "A", "C", "B", "D")
.stream()
.distinct()
.collect(Collectors.toList()); // [A, B, C, D]

截取

截取操作常用于把一个无限的Stream转换成有限的Streamskip()用于跳过当前Stream的前N个元素,limit()用于截取当前Stream最多前N个元素:

1
2
3
4
5
List.of("A", "B", "C", "D", "E", "F")
.stream()
.skip(2) // 跳过A, B
.limit(3) // 截取C, D, E
.collect(Collectors.toList()); // [C, D, E]

截取操作也是一个转换操作,将返回新的Stream

合并

将两个Stream合并为一个Stream可以使用Stream的静态方法concat()

1
2
3
4
5
Stream<String> s1 = List.of("A", "B", "C").stream();
Stream<String> s2 = List.of("D", "E").stream();
// 合并:
Stream<String> s = Stream.concat(s1, s2);
System.out.println(s.collect(Collectors.toList())); // [A, B, C, D, E]

flatMap

如果Stream的元素是集合:

1
2
3
4
Stream<List<Integer>> s = Stream.of(
Arrays.asList(1, 2, 3),
Arrays.asList(4, 5, 6),
Arrays.asList(7, 8, 9));

而我们希望把上述Stream转换为Stream<Integer>,就可以使用flatMap()

1
Stream<Integer> i = s.flatMap(list -> list.stream());

因此,所谓flatMap(),是指把Stream的每个元素(这里是List)映射为Stream,然后合并成一个新的Stream

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

│flatMap(List -> Stream)



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

并行

通常情况下,对Stream的元素进行处理是单线程的,即一个一个元素进行处理。但是很多时候,我们希望可以并行处理Stream的元素,因为在元素数量非常大的情况,并行处理可以大大加快处理速度。

把一个普通Stream转换为可以并行处理的Stream非常简单,只需要用parallel()进行转换:

1
2
3
4
Stream<String> s = ...
String[] result = s.parallel() // 变成一个可以并行处理的Stream
.sorted() // 可以进行并行排序
.toArray(String[]::new);

经过parallel()转换后的Stream只要可能,就会对后续操作进行并行处理。我们不需要编写任何多线程代码就可以享受到并行处理带来的执行效率的提升。

其他聚合方法

除了reduce()collect()外,Stream还有一些常用的聚合方法:

  • count():用于返回元素个数;
  • max(Comparator<? super T> cp):找出最大元素;
  • min(Comparator<? super T> cp):找出最小元素。

针对IntStreamLongStreamDoubleStream,还额外提供了以下聚合方法:

  • sum():对所有元素求和;
  • average():对所有元素求平均数。

还有一些方法,用来测试Stream的元素是否满足以下条件:

  • boolean allMatch(Predicate<? super T>):测试是否所有元素均满足测试条件;
  • boolean anyMatch(Predicate<? super T>):测试是否至少有一个元素满足测试条件。

最后一个常用的方法是forEach(),它可以循环处理Stream的每个元素,我们经常传入System.out::println来打印Stream的元素:

1
2
3
4
Stream<String> s = ...
s.forEach(str -> {
System.out.println("Hello, " + str);
});

小结

Stream提供的常用操作有:

转换操作:map()filter()sorted()distinct()

合并操作:concat()flatMap()

并行处理:parallel()

聚合操作:reduce()collect()count()max()min()sum()average()

其他操作:allMatch(), anyMatch(), forEach()

留言與分享

JAVA-JDBC编程

分類 编程语言, Java

在介绍JDBC之前,我们先简单介绍一下关系数据库。

程序运行的时候,数据都是在内存中的。当程序终止的时候,通常都需要将数据保存到磁盘上,无论是保存到本地磁盘,还是通过网络保存到服务器上,最终都会将数据写入磁盘文件。

而如何定义数据的存储格式就是一个大问题。如果我们自己来定义存储格式,比如保存一个班级所有学生的成绩单:

名字 成绩
Michael 99
Bob 85
Bart 59
Lisa 87

你可以用一个文本文件保存,一行保存一个学生,用,隔开:

1
2
3
4
Michael,99
Bob,85
Bart,59
Lisa,87

你还可以用JSON格式保存,也是文本文件:

1
2
3
4
5
6
[
{"name":"Michael","score":99},
{"name":"Bob","score":85},
{"name":"Bart","score":59},
{"name":"Lisa","score":87}
]

你还可以定义各种保存格式,但是问题来了:

存储和读取需要自己实现,JSON还是标准,自己定义的格式就各式各样了;

不能做快速查询,只有把数据全部读到内存中才能自己遍历,但有时候数据的大小远远超过了内存(比如蓝光电影,40GB的数据),根本无法全部读入内存。

为了便于程序保存和读取数据,而且,能直接通过条件快速查询到指定的数据,就出现了数据库(Database)这种专门用于集中存储和查询的软件。

数据库软件诞生的历史非常久远,早在1950年数据库就诞生了。经历了网状数据库,层次数据库,我们现在广泛使用的关系数据库是20世纪70年代基于关系模型的基础上诞生的。

关系模型有一套复杂的数学理论,但是从概念上是十分容易理解的。举个学校的例子:

假设某个XX省YY市ZZ县第一实验小学有3个年级,要表示出这3个年级,可以在Excel中用一个表格画出来:

grade

每个年级又有若干个班级,要把所有班级表示出来,可以在Excel中再画一个表格:

class

这两个表格有个映射关系,就是根据Grade_ID可以在班级表中查找到对应的所有班级:

grade-classes

也就是Grade表的每一行对应Class表的多行,在关系数据库中,这种基于表(Table)的一对多的关系就是关系数据库的基础。

根据某个年级的ID就可以查找所有班级的行,这种查询语句在关系数据库中称为SQL语句,可以写成:

1
SELECT * FROM classes WHERE grade_id = '1';

结果也是一个表:

grade_id class_id name
1 11 一年级一班
1 12 一年级二班
1 13 一年级三班

类似的,Class表的一行记录又可以关联到Student表的多行记录:

class-students

由于本教程不涉及到关系数据库的详细内容,如果你想从零学习关系数据库和基本的SQL语句,请参考SQL课程

NoSQL

你也许还听说过NoSQL数据库,很多NoSQL宣传其速度和规模远远超过关系数据库,所以很多同学觉得有了NoSQL是否就不需要SQL了呢?千万不要被他们忽悠了,连SQL都不明白怎么可能搞明白NoSQL呢?

数据库类别

既然我们要使用关系数据库,就必须选择一个关系数据库。目前广泛使用的关系数据库也就这么几种:

付费的商用数据库:

  • Oracle,典型的高富帅;
  • SQL Server,微软自家产品,Windows定制专款;
  • DB2,IBM的产品,听起来挺高端;
  • Sybase,曾经跟微软是好基友,后来关系破裂,现在家境惨淡。

这些数据库都是不开源而且付费的,最大的好处是花了钱出了问题可以找厂家解决,不过在Web的世界里,常常需要部署成千上万的数据库服务器,当然不能把大把大把的银子扔给厂家,所以,无论是Google、Facebook,还是国内的BAT,无一例外都选择了免费的开源数据库:

  • MySQL,大家都在用,一般错不了;
  • PostgreSQL,学术气息有点重,其实挺不错,但知名度没有MySQL高;
  • sqlite,嵌入式数据库,适合桌面和移动应用。

作为一个Java工程师,选择哪个免费数据库呢?当然是MySQL。因为MySQL普及率最高,出了错,可以很容易找到解决方法。而且,围绕MySQL有一大堆监控和运维的工具,安装和使用很方便。

安装MySQL

为了能继续后面的学习,你需要从MySQL官方网站下载并安装MySQL Community Server,这个版本是免费的,其他高级版本是要收钱的(请放心,收钱的功能我们用不上)。MySQL是跨平台的,选择对应的平台下载安装文件,安装即可。

安装时,MySQL会提示输入root用户的口令,请务必记清楚。如果怕记不住,就把口令设置为password

在Windows上,安装时请选择UTF-8编码,以便正确地处理中文。

在Mac或Linux上,需要编辑MySQL的配置文件,把数据库默认的编码全部改为UTF-8。MySQL的配置文件默认存放在/etc/my.cnf或者/etc/mysql/my.cnf

1
2
3
4
5
6
7
[client]
default-character-set = utf8

[mysqld]
default-storage-engine = INNODB
character-set-server = utf8
collation-server = utf8_general_ci

重启MySQL后,可以通过MySQL的客户端命令行检查编码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
$ mysql -u root -p
Enter password:
Welcome to the MySQL monitor...
...

mysql> show variables like '%char%';
+--------------------------+--------------------------------------------------------+
| Variable_name | Value |
+--------------------------+--------------------------------------------------------+
| character_set_client | utf8 |
| character_set_connection | utf8 |
| character_set_database | utf8 |
| character_set_filesystem | binary |
| character_set_results | utf8 |
| character_set_server | utf8 |
| character_set_system | utf8 |
| character_sets_dir | /usr/local/mysql-5.1.65-osx10.6-x86_64/share/charsets/ |
+--------------------------+--------------------------------------------------------+
8 rows in set (0.00 sec)

看到utf8字样就表示编码设置正确。

:如果MySQL的版本≥5.5.3,可以把编码设置为utf8mb4utf8mb4utf8完全兼容,但它支持最新的Unicode标准,可以显示emoji字符。

JDBC

什么是JDBC?JDBC是Java DataBase Connectivity的缩写,它是Java程序访问数据库的标准接口。

使用Java程序访问数据库时,Java代码并不是直接通过TCP连接去访问数据库,而是通过JDBC接口来访问,而JDBC接口则通过JDBC驱动来实现真正对数据库的访问。

例如,我们在Java代码中如果要访问MySQL,那么必须编写代码操作JDBC接口。注意到JDBC接口是Java标准库自带的,所以可以直接编译。而具体的JDBC驱动是由数据库厂商提供的,例如,MySQL的JDBC驱动由Oracle提供。因此,访问某个具体的数据库,我们只需要引入该厂商提供的JDBC驱动,就可以通过JDBC接口来访问,这样保证了Java程序编写的是一套数据库访问代码,却可以访问各种不同的数据库,因为他们都提供了标准的JDBC驱动:

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

│ ┌───────────────┐ │
│ Java App │
│ └───────────────┘ │

│ ▼ │
┌───────────────┐
│ │JDBC Interface │◀─┼─── JDK
└───────────────┘
│ │ │

│ ┌───────────────┐ │
│ JDBC Driver │◀───── Vendor
│ └───────────────┘ │

└ ─ ─ ─ ─ ─│─ ─ ─ ─ ─ ┘

┌───────────────┐
│ Database │
└───────────────┘

从代码来看,Java标准库自带的JDBC接口其实就是定义了一组接口,而某个具体的JDBC驱动其实就是实现了这些接口的类:

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

│ ┌───────────────┐ │
│ Java App │
│ └───────────────┘ │

│ ▼ │
┌───────────────┐
│ │JDBC Interface │◀─┼─── JDK
└───────────────┘
│ │ │

│ ┌───────────────┐ │
│ MySQL Driver │◀───── Oracle
│ └───────────────┘ │

└ ─ ─ ─ ─ ─│─ ─ ─ ─ ─ ┘

┌───────────────┐
│ MySQL │
└───────────────┘

实际上,一个MySQL的JDBC的驱动就是一个jar包,它本身也是纯Java编写的。我们自己编写的代码只需要引用Java标准库提供的java.sql包下面的相关接口,由此再间接地通过MySQL驱动的jar包通过网络访问MySQL服务器,所有复杂的网络通讯都被封装到JDBC驱动中,因此,Java程序本身只需要引入一个MySQL驱动的jar包就可以正常访问MySQL服务器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐
┌───────────────┐
│ │ App.class │ │
└───────────────┘
│ │ │

│ ┌───────────────┐ │
│ java.sql.* │
│ └───────────────┘ │

│ ▼ │
┌───────────────┐ TCP ┌───────────────┐
│ │ mysql-xxx.jar │──┼────────▶│ MySQL │
└───────────────┘ └───────────────┘
└ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘
JVM

小结

使用JDBC的好处是:

  • 各数据库厂商使用相同的接口,Java代码不需要针对不同数据库分别开发;
  • Java程序编译期仅依赖java.sql包,不依赖具体数据库的jar包;
  • 可随时替换底层数据库,访问数据库的Java代码基本不变。

前面我们讲了Java程序要通过JDBC接口来查询数据库。JDBC是一套接口规范,它在哪呢?就在Java的标准库java.sql里放着,不过这里面大部分都是接口。接口并不能直接实例化,而是必须实例化对应的实现类,然后通过接口引用这个实例。那么问题来了:JDBC接口的实现类在哪?

因为JDBC接口并不知道我们要使用哪个数据库,所以,用哪个数据库,我们就去使用哪个数据库的“实现类”,我们把某个数据库实现了JDBC接口的jar包称为JDBC驱动。

因为我们选择了MySQL 5.x作为数据库,所以我们首先得找一个MySQL的JDBC驱动。所谓JDBC驱动,其实就是一个第三方jar包,我们直接添加一个Maven依赖就可以了:

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

注意到这里添加依赖的scoperuntime,因为编译Java程序并不需要MySQL的这个jar包,只有在运行期才需要使用。如果把runtime改成compile,虽然也能正常编译,但是在IDE里写程序的时候,会多出来一大堆类似com.mysql.jdbc.Connection这样的类,非常容易与Java标准库的JDBC接口混淆,所以坚决不要设置为compile

有了驱动,我们还要确保MySQL在本机正常运行,并且还需要准备一点数据。这里我们用一个脚本创建数据库和表,然后插入一些数据:

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
-- 创建数据库learjdbc:
DROP DATABASE IF EXISTS learnjdbc;
CREATE DATABASE learnjdbc;

-- 创建登录用户learn/口令learnpassword
CREATE USER IF NOT EXISTS learn@'%' IDENTIFIED BY 'learnpassword';
GRANT ALL PRIVILEGES ON learnjdbc.* TO learn@'%' WITH GRANT OPTION;
FLUSH PRIVILEGES;

-- 创建表students:
USE learnjdbc;
CREATE TABLE students (
id BIGINT AUTO_INCREMENT NOT NULL,
name VARCHAR(50) NOT NULL,
gender TINYINT(1) NOT NULL,
grade INT NOT NULL,
score INT NOT NULL,
PRIMARY KEY(id)
) Engine=INNODB DEFAULT CHARSET=UTF8;

-- 插入初始数据:
INSERT INTO students (name, gender, grade, score) VALUES ('小明', 1, 1, 88);
INSERT INTO students (name, gender, grade, score) VALUES ('小红', 1, 1, 95);
INSERT INTO students (name, gender, grade, score) VALUES ('小军', 0, 1, 93);
INSERT INTO students (name, gender, grade, score) VALUES ('小白', 0, 1, 100);
INSERT INTO students (name, gender, grade, score) VALUES ('小牛', 1, 2, 96);
INSERT INTO students (name, gender, grade, score) VALUES ('小兵', 1, 2, 99);
INSERT INTO students (name, gender, grade, score) VALUES ('小强', 0, 2, 86);
INSERT INTO students (name, gender, grade, score) VALUES ('小乔', 0, 2, 79);
INSERT INTO students (name, gender, grade, score) VALUES ('小青', 1, 3, 85);
INSERT INTO students (name, gender, grade, score) VALUES ('小王', 1, 3, 90);
INSERT INTO students (name, gender, grade, score) VALUES ('小林', 0, 3, 91);
INSERT INTO students (name, gender, grade, score) VALUES ('小贝', 0, 3, 77);

在控制台输入mysql -u root -p,输入root口令后以root身份,把上述SQL贴到控制台执行一遍就行。如果你运行的是最新版MySQL 8.x,需要调整一下CREATE USER语句。

JDBC连接

使用JDBC时,我们先了解什么是Connection。Connection代表一个JDBC连接,它相当于Java程序到数据库的连接(通常是TCP连接)。打开一个Connection时,需要准备URL、用户名和口令,才能成功连接到数据库。

URL是由数据库厂商指定的格式,例如,MySQL的URL是:

1
jdbc:mysql://<hostname>:<port>/<db>?key1=value1&key2=value2

假设数据库运行在本机localhost,端口使用标准的3306,数据库名称是learnjdbc,那么URL如下:

1
jdbc:mysql://localhost:3306/learnjdbc?useSSL=false&characterEncoding=utf8

后面的两个参数表示不使用SSL加密,使用UTF-8作为字符编码(注意MySQL的UTF-8是utf8)。

要获取数据库连接,使用如下代码:

1
2
3
4
5
6
7
8
9
// JDBC连接的URL, 不同数据库有不同的格式:
String JDBC_URL = "jdbc:mysql://localhost:3306/test";
String JDBC_USER = "root";
String JDBC_PASSWORD = "password";
// 获取连接:
Connection conn = DriverManager.getConnection(JDBC_URL, JDBC_USER, JDBC_PASSWORD);
// TODO: 访问数据库...
// 关闭连接:
conn.close();

核心代码是DriverManager提供的静态方法getConnection()DriverManager会自动扫描classpath,找到所有的JDBC驱动,然后根据我们传入的URL自动挑选一个合适的驱动。

因为JDBC连接是一种昂贵的资源,所以使用后要及时释放。使用try (resource)来自动释放JDBC连接是一个好方法:

1
2
3
try (Connection conn = DriverManager.getConnection(JDBC_URL, JDBC_USER, JDBC_PASSWORD)) {
...
}

JDBC查询

获取到JDBC连接后,下一步我们就可以查询数据库了。查询数据库分以下几步:

第一步,通过Connection提供的createStatement()方法创建一个Statement对象,用于执行一个查询;

第二步,执行Statement对象提供的executeQuery("SELECT * FROM students")并传入SQL语句,执行查询并获得返回的结果集,使用ResultSet来引用这个结果集;

第三步,反复调用ResultSetnext()方法并读取每一行结果。

完整查询代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
try (Connection conn = DriverManager.getConnection(JDBC_URL, JDBC_USER, JDBC_PASSWORD)) {
try (Statement stmt = conn.createStatement()) {
try (ResultSet rs = stmt.executeQuery("SELECT id, grade, name, gender FROM students WHERE gender=1")) {
while (rs.next()) {
long id = rs.getLong(1); // 注意:索引从1开始
long grade = rs.getLong(2);
String name = rs.getString(3);
int gender = rs.getInt(4);
}
}
}
}

注意要点:

StatementResultSet都是需要关闭的资源,因此嵌套使用try (resource)确保及时关闭;

rs.next()用于判断是否有下一行记录,如果有,将自动把当前行移动到下一行(一开始获得ResultSet时当前行不是第一行);

ResultSet获取列时,索引从1开始而不是0

必须根据SELECT的列的对应位置来调用getLong(1)getString(2)这些方法,否则对应位置的数据类型不对,将报错。

SQL注入

使用Statement拼字符串非常容易引发SQL注入的问题,这是因为SQL参数往往是从方法参数传入的。

我们来看一个例子:假设用户登录的验证方法如下:

1
2
3
4
5
User login(String name, String pass) {
...
stmt.executeQuery("SELECT * FROM user WHERE login='" + name + "' AND pass='" + pass + "'");
...
}

其中,参数namepass通常都是Web页面输入后由程序接收到的。

如果用户的输入是程序期待的值,就可以拼出正确的SQL。例如:name = "bob",pass = "1234"

1
SELECT * FROM user WHERE login='bob' AND pass='1234'

但是,如果用户的输入是一个精心构造的字符串,就可以拼出意想不到的SQL,这个SQL也是正确的,但它查询的条件不是程序设计的意图。例如:name = "bob' OR pass=", pass = " OR pass='"

1
SELECT * FROM user WHERE login='bob' OR pass=' AND pass=' OR pass=''

这个SQL语句执行的时候,根本不用判断口令是否正确,这样一来,登录就形同虚设。

要避免SQL注入攻击,一个办法是针对所有字符串参数进行转义,但是转义很麻烦,而且需要在任何使用SQL的地方增加转义代码。

还有一个办法就是使用PreparedStatement。使用PreparedStatement可以完全避免SQL注入的问题,因为PreparedStatement始终使用?作为占位符,并且把数据连同SQL本身传给数据库,这样可以保证每次传给数据库的SQL语句是相同的,只是占位符的数据不同,还能高效利用数据库本身对查询的缓存。上述登录SQL如果用PreparedStatement可以改写如下:

1
2
3
4
5
6
7
8
User login(String name, String pass) {
...
String sql = "SELECT * FROM user WHERE login=? AND pass=?";
PreparedStatement ps = conn.prepareStatement(sql);
ps.setObject(1, name);
ps.setObject(2, pass);
...
}

所以,PreparedStatementStatement更安全,而且更快。

注意

使用Java对数据库进行操作时,必须使用PreparedStatement,严禁任何通过参数拼字符串的代码!

我们把上面使用Statement的代码改为使用PreparedStatement

1
2
3
4
5
6
7
8
9
10
11
12
13
14
try (Connection conn = DriverManager.getConnection(JDBC_URL, JDBC_USER, JDBC_PASSWORD)) {
try (PreparedStatement ps = conn.prepareStatement("SELECT id, grade, name, gender FROM students WHERE gender=? AND grade=?")) {
ps.setObject(1, "M"); // 注意:索引从1开始
ps.setObject(2, 3);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
long id = rs.getLong("id");
long grade = rs.getLong("grade");
String name = rs.getString("name");
String gender = rs.getString("gender");
}
}
}
}

使用PreparedStatementStatement稍有不同,必须首先调用setObject()设置每个占位符?的值,最后获取的仍然是ResultSet对象。

另外注意到从结果集读取列时,使用String类型的列名比索引要易读,而且不易出错。

注意到JDBC查询的返回值总是ResultSet,即使我们写这样的聚合查询SELECT SUM(score) FROM ...,也需要按结果集读取:

1
2
3
4
ResultSet rs = ...
if (rs.next()) {
double sum = rs.getDouble(1);
}

数据类型

有的童鞋可能注意到了,使用JDBC的时候,我们需要在Java数据类型和SQL数据类型之间进行转换。JDBC在java.sql.Types定义了一组常量来表示如何映射SQL数据类型,但是平时我们使用的类型通常也就以下几种:

SQL数据类型 Java数据类型
BIT, BOOL boolean
INTEGER int
BIGINT long
REAL float
FLOAT, DOUBLE double
CHAR, VARCHAR String
DECIMAL BigDecimal
DATE java.sql.Date, LocalDate
TIME java.sql.Time, LocalTime

注意:只有最新的JDBC驱动才支持LocalDateLocalTime

练习

使用JDBC查询数据库。

下载练习

小结

JDBC接口的Connection代表一个JDBC连接;

使用JDBC查询时,总是使用PreparedStatement进行查询而不是Statement

查询结果总是ResultSet,即使使用聚合查询也不例外。

数据库操作总结起来就四个字:增删改查,行话叫CRUD:Create,Retrieve,Update和Delete。

查就是查询,我们已经讲过了,就是使用PreparedStatement进行各种SELECT,然后处理结果集。现在我们来看看如何使用JDBC进行增删改。

插入

插入操作是INSERT,即插入一条新记录。通过JDBC进行插入,本质上也是用PreparedStatement执行一条SQL语句,不过最后执行的不是executeQuery(),而是executeUpdate()。示例代码如下:

1
2
3
4
5
6
7
8
9
10
try (Connection conn = DriverManager.getConnection(JDBC_URL, JDBC_USER, JDBC_PASSWORD)) {
try (PreparedStatement ps = conn.prepareStatement(
"INSERT INTO students (id, grade, name, gender) VALUES (?,?,?,?)")) {
ps.setObject(1, 999); // 注意:索引从1开始
ps.setObject(2, 1); // grade
ps.setObject(3, "Bob"); // name
ps.setObject(4, "M"); // gender
int n = ps.executeUpdate(); // 1
}
}

设置参数与查询是一样的,有几个?占位符就必须设置对应的参数。虽然Statement也可以执行插入操作,但我们仍然要严格遵循绝不能手动拼SQL字符串的原则,以避免安全漏洞。

当成功执行executeUpdate()后,返回值是int,表示插入的记录数量。此处总是1,因为只插入了一条记录。

插入并获取主键

如果数据库的表设置了自增主键,那么在执行INSERT语句时,并不需要指定主键,数据库会自动分配主键。对于使用自增主键的程序,有个额外的步骤,就是如何获取插入后的自增主键的值。

要获取自增主键,不能先插入,再查询。因为两条SQL执行期间可能有别的程序也插入了同一个表。获取自增主键的正确写法是在创建PreparedStatement的时候,指定一个RETURN_GENERATED_KEYS标志位,表示JDBC驱动必须返回插入的自增主键。示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
try (Connection conn = DriverManager.getConnection(JDBC_URL, JDBC_USER, JDBC_PASSWORD)) {
try (PreparedStatement ps = conn.prepareStatement(
"INSERT INTO students (grade, name, gender) VALUES (?,?,?)",
Statement.RETURN_GENERATED_KEYS)) {
ps.setObject(1, 1); // grade
ps.setObject(2, "Bob"); // name
ps.setObject(3, "M"); // gender
int n = ps.executeUpdate(); // 1
try (ResultSet rs = ps.getGeneratedKeys()) {
if (rs.next()) {
long id = rs.getLong(1); // 注意:索引从1开始
}
}
}
}

观察上述代码,有两点注意事项:

一是调用prepareStatement()时,第二个参数必须传入常量Statement.RETURN_GENERATED_KEYS,否则JDBC驱动不会返回自增主键;

二是执行executeUpdate()方法后,必须调用getGeneratedKeys()获取一个ResultSet对象,这个对象包含了数据库自动生成的主键的值,读取该对象的每一行来获取自增主键的值。如果一次插入多条记录,那么这个ResultSet对象就会有多行返回值。如果插入时有多列自增,那么ResultSet对象的每一行都会对应多个自增值(自增列不一定必须是主键)。

更新

更新操作是UPDATE语句,它可以一次更新若干列的记录。更新操作和插入操作在JDBC代码的层面上实际上没有区别,除了SQL语句不同:

1
2
3
4
5
6
7
try (Connection conn = DriverManager.getConnection(JDBC_URL, JDBC_USER, JDBC_PASSWORD)) {
try (PreparedStatement ps = conn.prepareStatement("UPDATE students SET name=? WHERE id=?")) {
ps.setObject(1, "Bob"); // 注意:索引从1开始
ps.setObject(2, 999);
int n = ps.executeUpdate(); // 返回更新的行数
}
}

executeUpdate()返回数据库实际更新的行数。返回结果可能是正数,也可能是0(表示没有任何记录更新)。

删除

删除操作是DELETE语句,它可以一次删除若干行。和更新一样,除了SQL语句不同外,JDBC代码都是相同的:

1
2
3
4
5
6
try (Connection conn = DriverManager.getConnection(JDBC_URL, JDBC_USER, JDBC_PASSWORD)) {
try (PreparedStatement ps = conn.prepareStatement("DELETE FROM students WHERE id=?")) {
ps.setObject(1, 999); // 注意:索引从1开始
int n = ps.executeUpdate(); // 删除的行数
}
}

练习

使用JDBC更新数据库。

下载练习

小结

使用JDBC执行INSERTUPDATEDELETE都可视为更新操作;

更新操作使用PreparedStatementexecuteUpdate()进行,返回受影响的行数。

JDBC事务

数据库事务(Transaction)是由若干个SQL语句构成的一个操作序列,有点类似于Java的synchronized同步。数据库系统保证在一个事务中的所有SQL要么全部执行成功,要么全部不执行,即数据库事务具有ACID特性:

  • Atomicity:原子性
  • Consistency:一致性
  • Isolation:隔离性
  • Durability:持久性

数据库事务可以并发执行,而数据库系统从效率考虑,对事务定义了不同的隔离级别。SQL标准定义了4种隔离级别,分别对应可能出现的数据不一致的情况:

Isolation Level 脏读(Dirty Read) 不可重复读(Non Repeatable Read) 幻读(Phantom Read)
Read Uncommitted Yes Yes Yes
Read Committed - Yes Yes
Repeatable Read - - Yes
Serializable - - -

对应用程序来说,数据库事务非常重要,很多运行着关键任务的应用程序,都必须依赖数据库事务保证程序的结果正常。

举个例子:假设小明准备给小红支付100,两人在数据库中的记录主键分别是123456,那么用两条SQL语句操作如下:

1
2
UPDATE accounts SET balance = balance - 100 WHERE id = 123 AND balance >= 100;
UPDATE accounts SET balance = balance + 100 WHERE id = 456;

这两条语句必须以事务方式执行才能保证业务的正确性,因为一旦第一条SQL执行成功而第二条SQL失败的话,系统的钱就会凭空减少100,而有了事务,要么这笔转账成功,要么转账失败,双方账户的钱都不变。

这里我们不讨论详细的SQL事务,如果对SQL事务不熟悉,请参考SQL事务

要在JDBC中执行事务,本质上就是如何把多条SQL包裹在一个数据库事务中执行。我们来看JDBC的事务代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Connection conn = openConnection();
try {
// 关闭自动提交:
conn.setAutoCommit(false);
// 执行多条SQL语句:
insert(); update(); delete();
// 提交事务:
conn.commit();
} catch (SQLException e) {
// 回滚事务:
conn.rollback();
} finally {
conn.setAutoCommit(true);
conn.close();
}

其中,开启事务的关键代码是conn.setAutoCommit(false),表示关闭自动提交。提交事务的代码在执行完指定的若干条SQL语句后,调用conn.commit()。要注意事务不是总能成功,如果事务提交失败,会抛出SQL异常(也可能在执行SQL语句的时候就抛出了),此时我们必须捕获并调用conn.rollback()回滚事务。最后,在finally中通过conn.setAutoCommit(true)Connection对象的状态恢复到初始值。

实际上,默认情况下,我们获取到Connection连接后,总是处于“自动提交”模式,也就是每执行一条SQL都是作为事务自动执行的,这也是为什么前面几节我们的更新操作总能成功的原因:因为默认有这种“隐式事务”。只要关闭了ConnectionautoCommit,那么就可以在一个事务中执行多条语句,事务以commit()方法结束。

如果要设定事务的隔离级别,可以使用如下代码:

1
2
// 设定隔离级别为READ COMMITTED:
conn.setTransactionIsolation(Connection.TRANSACTION_READ_COMMITTED);

如果没有调用上述方法,那么会使用数据库的默认隔离级别。MySQL的默认隔离级别是REPEATABLE_READ

练习

使用数据库事务。

下载练习

小结

数据库事务(Transaction)具有ACID特性:

  • Atomicity:原子性
  • Consistency:一致性
  • Isolation:隔离性
  • Durability:持久性

JDBC提供了事务的支持,使用Connection可以开启、提交或回滚事务。



使用JDBC操作数据库的时候,经常会执行一些批量操作。

例如,一次性给会员增加可用优惠券若干,我们可以执行以下SQL代码:

1
2
3
4
5
INSERT INTO coupons (user_id, type, expires) VALUES (123, 'DISCOUNT', '2030-12-31');
INSERT INTO coupons (user_id, type, expires) VALUES (234, 'DISCOUNT', '2030-12-31');
INSERT INTO coupons (user_id, type, expires) VALUES (345, 'DISCOUNT', '2030-12-31');
INSERT INTO coupons (user_id, type, expires) VALUES (456, 'DISCOUNT', '2030-12-31');
...

实际上执行JDBC时,因为只有占位符参数不同,所以SQL实际上是一样的:

1
2
3
4
5
6
7
for (var params : paramsList) {
PreparedStatement ps = conn.preparedStatement("INSERT INTO coupons (user_id, type, expires) VALUES (?,?,?)");
ps.setLong(params.get(0));
ps.setString(params.get(1));
ps.setString(params.get(2));
ps.executeUpdate();
}

类似的还有,给每个员工薪水增加10%~30%:

1
UPDATE employees SET salary = salary * ? WHERE id = ?

通过一个循环来执行每个PreparedStatement虽然可行,但是性能很低。SQL数据库对SQL语句相同,但只有参数不同的若干语句可以作为batch执行,即批量执行,这种操作有特别优化,速度远远快于循环执行每个SQL。

在JDBC代码中,我们可以利用SQL数据库的这一特性,把同一个SQL但参数不同的若干次操作合并为一个batch执行。我们以批量插入为例,示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
try (PreparedStatement ps = conn.prepareStatement("INSERT INTO students (name, gender, grade, score) VALUES (?, ?, ?, ?)")) {
// 对同一个PreparedStatement反复设置参数并调用addBatch():
for (Student s : students) {
ps.setString(1, s.name);
ps.setBoolean(2, s.gender);
ps.setInt(3, s.grade);
ps.setInt(4, s.score);
ps.addBatch(); // 添加到batch
}
// 执行batch:
int[] ns = ps.executeBatch();
for (int n : ns) {
System.out.println(n + " inserted."); // batch中每个SQL执行的结果数量
}
}

执行batch和执行一个SQL不同点在于,需要对同一个PreparedStatement反复设置参数并调用addBatch(),这样就相当于给一个SQL加上了多组参数,相当于变成了“多行”SQL。

第二个不同点是调用的不是executeUpdate(),而是executeBatch(),因为我们设置了多组参数,相应地,返回结果也是多个int值,因此返回类型是int[],循环int[]数组即可获取每组参数执行后影响的结果数量。

练习

使用Batch操作。

下载练习

小结

使用JDBC的batch操作会大大提高执行效率,对内容相同,参数不同的SQL,要优先考虑batch操作。

我们在讲多线程的时候说过,创建线程是一个昂贵的操作,如果有大量的小任务需要执行,并且频繁地创建和销毁线程,实际上会消耗大量的系统资源,往往创建和消耗线程所耗费的时间比执行任务的时间还长,所以,为了提高效率,可以用线程池。

类似的,在执行JDBC的增删改查的操作时,如果每一次操作都来一次打开连接,操作,关闭连接,那么创建和销毁JDBC连接的开销就太大了。为了避免频繁地创建和销毁JDBC连接,我们可以通过连接池(Connection Pool)复用已经创建好的连接。

JDBC连接池有一个标准的接口javax.sql.DataSource,注意这个类位于Java标准库中,但仅仅是接口。要使用JDBC连接池,我们必须选择一个JDBC连接池的实现。常用的JDBC连接池有:

  • HikariCP
  • C3P0
  • BoneCP
  • Druid

目前使用最广泛的是HikariCP。我们以HikariCP为例,要使用JDBC连接池,先添加HikariCP的依赖如下:

  • com.zaxxer:HikariCP:2.7.1

紧接着,我们需要创建一个DataSource实例,这个实例就是连接池:

1
2
3
4
5
6
7
8
HikariConfig config = new HikariConfig();
config.setJdbcUrl("jdbc:mysql://localhost:3306/test");
config.setUsername("root");
config.setPassword("password");
config.addDataSourceProperty("connectionTimeout", "1000"); // 连接超时:1秒
config.addDataSourceProperty("idleTimeout", "60000"); // 空闲超时:60秒
config.addDataSourceProperty("maximumPoolSize", "10"); // 最大连接数:10
DataSource ds = new HikariDataSource(config);

注意创建DataSource也是一个非常昂贵的操作,所以通常DataSource实例总是作为一个全局变量存储,并贯穿整个应用程序的生命周期。

有了连接池以后,我们如何使用它呢?和前面的代码类似,只是获取Connection时,把DriverManage.getConnection()改为ds.getConnection()

1
2
3
try (Connection conn = ds.getConnection()) { // 在此获取连接
...
} // 在此“关闭”连接

通过连接池获取连接时,并不需要指定JDBC的相关URL、用户名、口令等信息,因为这些信息已经存储在连接池内部了(创建HikariDataSource时传入的HikariConfig持有这些信息)。一开始,连接池内部并没有连接,所以,第一次调用ds.getConnection(),会迫使连接池内部先创建一个Connection,再返回给客户端使用。当我们调用conn.close()方法时(在try(resource){...}结束处),不是真正“关闭”连接,而是释放到连接池中,以便下次获取连接时能直接返回。

因此,连接池内部维护了若干个Connection实例,如果调用ds.getConnection(),就选择一个空闲连接,并标记它为“正在使用”然后返回,如果对Connection调用close(),那么就把连接再次标记为“空闲”从而等待下次调用。这样一来,我们就通过连接池维护了少量连接,但可以频繁地执行大量的SQL语句。

通常连接池提供了大量的参数可以配置,例如,维护的最小、最大活动连接数,指定一个连接在空闲一段时间后自动关闭等,需要根据应用程序的负载合理地配置这些参数。此外,大多数连接池都提供了详细的实时状态以便进行监控。

练习

使用JDBC连接池。

下载练习

小结

数据库连接池是一种复用Connection的组件,它可以避免反复创建新连接,提高JDBC代码的运行效率;

可以配置连接池的详细参数并监控连接池。

留言與分享

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

在守护线程中,编写代码要注意:守护线程不能持有任何需要关闭的资源,例如打开文件等,因为虚拟机退出时,守护线程没有任何机会来关闭文件,这会导致数据丢失。

练习

使用守护线程。

下载练习

小结

守护线程是为其他线程服务的线程;

所有非守护线程都执行完毕后,虚拟机退出,守护线程随之结束;

守护线程不能持有需要关闭的资源(如打开文件等)。



留言與分享

作者的圖片

Kein Chan

這是獨立全棧工程師Kein Chan的技術博客
分享一些技術教程,命令備忘(cheat-sheet)等


全棧工程師
資深技術顧問
數據科學家
Hit廣島觀光大使


Tokyo/Macau