Qeuroal's Blog

静幽正治

同步方法及同步块

同步方法

1
2
3
public synchronized void method(int args) {
...
}

synchronized方法控制对“对象”的访问,每个对象对应一把锁,每个synchronized方法都必须获得该方法的对象的多才能执行,否则线程会阻塞,方法一旦执行,就独占该锁,知道该方法返回才释放锁,后面被阻塞的线程才能获得这个锁,继续执行

同步块

1
2
3
synchronized (Obj) {
...
}

Obj 称之为 同步监视器:

  • Obj可以是任何对象,但是推荐使用共享资源作为同步监视器
  • 同步方法中无需置顶同步监视器,因为同步方法的同步监视器就是 this,就是这个对象本身,或者是 class

同步监视器的执行过程:

  1. 第一个线程访问,锁定同步监视器,执行其中代码
  2. 第二个线程访问,发现同步监视器被锁定,无法访问
  3. 第一个线程访问完毕,解锁同步监视器
  4. 第二个线程访问,发现同步监视器没有锁,然后锁定并访问

总结:锁的对象就是变化的量:需要增删改的对象

死锁

简单定义:多个线程互相抱着对方需要的资源才能运行,然后形成僵持

多个线程各自占有一些共享资源,并且互相等待其他线程占有的资源才能运行,而导致两个或多个线程都在等待对方释放资源,都停止执行的情形。某一个同步块同时拥有“两个以上对象的锁”时,就可能会发生“死锁”问题。

产生死锁的四个必要条件

  1. 互斥条件:一个资源每次只能被一个进程使用
  2. 请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放
  3. 不剥夺条件:进程已获得的资源,在未使用完之前,不能强行剥夺
  4. 循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系。

Runnable 线程创建

1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Main {
public static void main(String[] args) {
MyRunnable myRunnable = new MyRunnable()
Thread thread = new Thread(myRunnable);
thread.start(); // 启动新线程
}
}

class MyRunnable implements Runnable {
@Override
public void run() {
System.out.println("start new thread!");
}
}

2

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Main {
public static void main(String[] args) {
MyRunnable myRunnable = new MyRunnable()
new Thread(myRunnable).start(); // 启动新线程
}
}

class MyRunnable implements Runnable {
@Override
public void run() {
System.out.println("start new thread!");
}
}

3

1
2
3
4
Thread t = new Thread(() -> {
System.out.println("start new thread!");
});
t.start(); // 启动新线程

4

1
2
3
new Thread(()-> {
System.out.println("start new thread!");
}).start();

Note

要想给每个实例不一样的参数:用构造函数

Thread 创建线程

1
2
3
4
5
6
7
8
9
10
11
12
13
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!");
}
}

Maven 介绍

在了解 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
<project ...>
<modelVersion>4.0.0</modelVersion>
<groupId>com.itranswarp.learnjava</groupId>
<artifactId>hello</artifactId>
<version>1.0</version>
<packaging>jar</packaging>
<properties>
...
</properties>
<dependencies>
<dependency>
<groupId>commons-logging</groupId>
<artifactId>commons-logging</artifactId>
<version>1.2</version>
</dependency>
</dependencies>
</project>

其中,groupId 类似于 Java 的包名,通常是公司或组织名称,artifactId 类似于 Java 的类名,通常是项目名称,再加上 version,一个 Maven 工程就是由 groupIdartifactIdversion 作为唯一标识。我们在引用其他第三方库的时候,也是通过这 3 个变量确定。例如,依赖 commons-logging
1
2
3
4
5
<dependency>
<groupId>commons-logging</groupId>
<artifactId>commons-logging</artifactId>
<version>1.2</version>
</dependency>

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

安装Maven

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

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

Windows可以把%M2_HOME%\bin添加到系统Path变量中。
然后,打开命令行窗口,输入mvn -version,应该看到Maven的版本信息:
1
2
3
4
5
Apache Maven 3.6.3 (cecedd343002696d0abb50b32b541b8a6ba2883f)
Maven home: D:\maven\bin\..
Java version: 1.8.0_271, vendor: Oracle Corporation, runtime: D:\Program Files\Java\jdk1.8.0_271\jre
Default locale: zh_CN, platform encoding: GBK
os name: "windows 10", version: "10.0", arch: "amd64", family: "windows"

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

小结

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

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

依赖管理

如果我们的项目依赖第三方的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自动把abc和xyz都加入了我们的项目依赖,不需要我们自己去研究abc是否需要依赖xyz。
因此,Maven的第一个作用就是解决依赖管理。我们声明了自己的项目需要abc,Maven会自动导入abc的jar包,再判断出abc需要xyz,又会自动导入xyz的jar包,这样,最终我们的项目会依赖abc和xyz两个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>javax.servlet</groupId>
<artifactId>javax.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镜像

除了可以从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配置文件(个人是在安装目录-conf-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/public</url>
</mirror>
</mirrors>
</settings>

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

搜索第三方组件

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

命令行编译

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

1
mvn clean packages

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

小结

  • 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
  • package

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

  • validate
  • 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
  • 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相当于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通过自定义插件可以执行项目构建时需要的额外功能,使用自定义插件必须在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
mutiple-project
├── 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代码。编写parent的pom.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 logginglog4j这些第三方模块,就是第三方模块的开发者自己把编译好的jar包发布到Maven的中央仓库中。

私有仓库

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

本地仓库

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

小结

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

  • 可以通过继承在parentpom.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 -N io.takari:maven:0.7.6:wrapper

它会自动使用最新版本的Maven。注意0.7.6是Maven Wrapper的版本。最新的Maven Wrapper版本可以去官方网站查看。
如果要指定使用的Maven版本,使用下面的安装命令指定版本,例如3.3.3:
1
mvn -N io.takari:maven:0.7.6:wrapper -Dmaven=3.3.3

安装后,查看项目结构:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
my-project
├── .mvn
│ └── wrapper
│ ├── MavenWrapperDownloader.java
│ ├── maven-wrapper.jar
│ └── 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

多线程基础

现代操作系统(Windows,macOS,Linux)都可以执行多任务。多任务就是同时运行多个任务,例如: IEQQ网易云音乐
CPU执行代码都是一条一条顺序执行的,但是,即使是单核cpu,也可以同时运行多个任务。因为操作系统执行多任务实际上就是让CPU对多个任务轮流交替执行。
例如,假设我们有语文、数学、英语3门作业要做,每个作业需要30分钟。我们把这3门作业看成是3个任务,可以做1分钟语文作业,再做1分钟数学作业,再做1分钟英语作业。
这样轮流做下去,在某些人眼里看来,做作业的速度就非常快,看上去就像同时在做3门作业一样。
类似的,操作系统轮流让多个任务交替执行,例如,让浏览器执行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. 多进程模式(每个进程只有一个线程):
    1
    2
    3
    4
    5
    6
    ┌──────────┐ ┌──────────┐ ┌──────────┐
    │Process │ │Process │ │Process │
    │┌────────┐│ │┌────────┐│ │┌────────┐│
    ││ Thread ││ ││ Thread ││ ││ Thread ││
    │└────────┘│ │└────────┘│ │└────────┘│
    └──────────┘ └──────────┘ └──────────┘
  2. 多线程模式(一个进程有多个线程):
    1
    2
    3
    4
    5
    6
    7
    8
    9
    ┌────────────────────┐
    │Process │
    │┌────────┐┌────────┐│
    ││ Thread ││ Thread ││
    │└────────┘└────────┘│
    │┌────────┐┌────────┐│
    ││ Thread ││ Thread ││
    │└────────┘└────────┘│
    └────────────────────┘
  3. 多进程+多线程模式(复杂度最高):
    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
public class Main {
public static void main(String[] args) {
Thread t = new Thread();
t.start(); // 启动新线程
}
}

但是这个线程启动后实际上什么也不做就立刻结束了。我们希望新线程能执行指定的代码,有以下几种方法:

  1. 方法一:从Thread派生一个自定义类,然后覆写run()方法:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    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!");
    }
    }

    输出

    1
    start new thread!

    执行上述代码,注意到 start() 方法会在内部自动调用实例的 run() 方法。

    三步走:

    1. 继承 Thread
    2. 重写 run() 方法
    3. 调用 start() 开启线程
  2. 方法二:创建 Thread 实例时,传入一个 Runnable 实例:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    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!");
    }
    }

    输出

    1
    start new thread!

    三步走:

    1. 实现 Runbable 接口
    2. 重写 run() 方法
    3. 执行进程需要丢入 Runnable接口实现类,调用 start() 方法
  3. 方法三:用 Java8 引入的 lambda 语法进一步简写为:

    1
    2
    3
    4
    5
    6
    7
    8
    public class Main {
    public static void main(String[] args) {
    Thread t = new Thread(() -> {
    System.out.println("start new thread!");
    });
    t.start(); // 启动新线程
    }
    }

    使用 lambda简化,其实 lambda 也相当于实现类

使用线程执行的打印语句,和直接在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..."); // main
Thread t = new Thread() { // main
public void run() {
System.out.println("thread run...");
System.out.println("thread end.");
}
};
t.start(); // main
System.out.println("main end..."); // main
}
}

我们用 // main 表示主线程,也就是 main线程 main线程 执行的代码有 4 行,首先打印 main start,然后创建 Thread 对象,紧接着调用 start() 启动新线程。当 start() 方法被调用时,JVM 就创建了一个新线程,我们通过实例变量 t 来表示这个新线程对象,并开始执行。
接着,main 线程继续执行打印 main end 语句,而 t 线程在 main 线程执行的同时会并发执行,打印 thread run 和 thread 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
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

获取优先级
1
Thread.getPriority()

Note:

  • 优先级高的线程被操作系统调度的优先级较高,操作系统对高优先级线程可能调度更频繁,但我们决不能通过设置优先级来确保高优先级的线程一定会先执行。
  • 先设置优先级,再启动

小结

  • Java 用 Thread 对象表示一个线程,通过调用 start() 启动一个新线程;
  • 一个线程对象只能调用一次 start() 方法;
  • 线程的执行代码写在 run() 方法中;
  • 线程调度由操作系统决定,程序本身无法决定调度顺序;
  • Thread.sleep() 可以把当前线程暂停一段时间;
  • 线程开启不一定立即执行,由 CPU 调度执行
  • 推荐使用 Runnable 接口,因为 Java 单继承的局限性

静态代理模式

  1. 真实对象(目标对象)和代理对象都要实现同一个接口
  2. 代理对象要代理真实角色

好处:

  • 代理对象可以做很多真实对象做不了的事情
  • 真实对象专注做自己的事情

Lambda表达式

  • 函数式接口定义: 任何接口,如果只包含唯一一个抽象方法,那么它就是一个函数式接口
  • 对于函数式接口,我们可以通过 lambda 表达式来创建该接口的对象。

小结

  • lambda 表达式只有一行代码的情况下才能简化为一行,如果有多行就用代码块包裹
  • 前提是:接口是函数式接口
  • 多个参数也可以去掉参数类型,若去掉就都去掉,必须加上括号

线程的状态

线程方法

方法 说明
setPriority(int new Priority) 更改线程的优先级
static void sleep(long millis) 在置顶的毫秒数内让当前正在执行的线程休眠
void join() 等待该线程终止
static void yield() 暂停当前正在执行的线程对象,并执行其他的线程
void interrupt 中断线程,别用这个方式
boolean isAlive() 测试线层是否处于活动状态

停止线程

  • 不推荐使用JDK提供的 stop()destroy()方法。【已废弃】
  • 推荐线程自己停止下来—> 利用次数,不建议死循环
  • 建议使用一个标志位进行终止遍历,当 flag==false,则终止线程运行

线程休眠

  • sleep(时间) 置顶当前线程阻塞的毫秒数;
  • sleep 存在 异常 InterruptedException;
  • sleep 时间达到后线程进入就去状态;
  • sleep 可以模拟网络延时,倒计时等;
  • 每一个对象都有一个锁,sleep不会释放锁;

问题

模拟网络延时:放大问题的发生性

线程礼让

  • 礼让线程:让当期那正在执行的线程暂停,但不阻塞
  • 让线程从运行状态转为就绪状态
  • 让CPU重新调度,礼让不一定成功!看CPU心情

线程强制执行(join)

  • join 合并线程,待此线程执行完成后,再执行其他线程
  • 可以想象成插队
  • 记住:插的队的是对当前的线程插队,对其他线程并没有影响,只有插队的线程执行完成后,被插队的线程才可以执行

线程状态观测

Thread.State
在 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 │
└─────────────┘

当线程启动后,它可以在 Runnable、Blocked、Waiting 和 Timed Waiting 这几个状态之间切换,直到最后变成 Terminated 状态,线程终止。

线程终止的原因有:

  • 线程正常终止:run() 方法执行到 return 语句返回;
  • 线程意外终止:run() 方法因为未捕获的异常导致线程终止;
  • 对某个线程的 Thread 实例调用 stop() 方法强制终止(强烈不推荐使用)。

一个线程还可以等待另一个线程直到其运行结束。例如,main 线程在启动 t 线程后,可以通过 t.join() 等待 t 线程结束后再继续运行:

1
2
3
4
5
6
7
8
9
10
11
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.join();
System.out.println("end");
}
}

当 main 线程对线程对象 t 调用 join() 方法时,主线程将等待变量 t 表示的线程运行结束,即 join 就是指等待该线程结束,然后才继续往下执行自身线程。所以,上述代码打印顺序可以肯定是 main 线程先打印 start,t 线程再打印 hello,main 线程最后再打印 end。
如果 t 线程已经结束,对实例 t 调用 join() 会立刻返回。此外,join(long) 的重载方法也可以指定一个等待时间,超过等待时间后就不再继续等待。

小结

  • Java线程对象Thread的状态包括:New、Runnable、Blocked、Waiting、Timed Waiting和Terminated;
  • 通过对另一个线程对象调用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
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) {
System.out.println("InterruptedException");
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
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); // 默认是false:表示是用户线程,正常的线程都是用户线程
t.start();

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

线程同步

简单定义: 多个线程操作同一个资源
当多个线程同时运行时,线程的调度由操作系统决定,程序本身无法决定。因此,任何一个线程都有可能在任何指令处被操作系统暂停,然后在某个时间段后继续执行。
这个时候,有个单线程模型下不存在的问题就来了:如果多个线程同时读写共享变量,会出现数据不一致的问题。
我们来看一个例子:

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 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,但是,每次运行,结果实际上都是不一样的。
这是因为对变量进行读取和写入时,结果要正确,必须保证是原子操作。原子操作是指不能被中断的一个或一系列操作。

为何会不安全:由于CPU会使线程进入阻塞状态,使修改资源的操作中断了,但是在这个时候,其他线程开始运行,也修改了这个资源,因此,会造成数据不一致。

例如,对于语句:

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
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:

  • 找出修改共享变量的线程代码块;
  • 选择一个共享实例作为锁;
  • 使用 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
    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
    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 += 1 和 Counter.teacherCount += 1,现在无法并发执行了,执行效率大大降低。实际上,需要同步的线程可以分成两组:AddStudentThread 和 DecStudentThread,AddTeacherThread 和 DecTeacherThread,组之间不存在竞争,因此,应该使用两个不同的锁,即:
    AddStudentThread和DecStudentThread使用lockStudent锁:
    1
    2
    3
    synchronized(Counter.lockStudent) {
    ...
    }
    AddTeacherThread和DecTeacherThread使用lockTeacher锁:
    1
    2
    3
    synchronized(Counter.lockTeacher) {
    ...
    }
    这样才能最大化地提高执行效率。

不需要synchronized的操作

JVM规范定义了几种原子操作:

  • 基本类型(long和double除外)赋值,例如:int n = m;
  • 引用类型赋值,例如:List list = anotherList。

long 和 double 是 64 位数据,JVM 没有明确规定 64 位赋值操作是不是一个原子操作,不过在 x64 平台的 JVM 是把 long 和 double 的赋值作为原子操作实现的。
单条原子操作的语句不需要同步。例如:

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 Pair {
int first;
int last;
public void set(int first, int last) {
synchronized(this) {
this.first = first;
this.last = last;
}
}
}

有些时候,通过一些巧妙的转换,可以把非原子操作变为原子操作。例如,上述代码如果改造成:
1
2
3
4
5
6
7
class Pair {
int[] pair;
public void set(int first, int last) {
int[] ps = new int[] { first, last };
this.pair = ps;
}
}

就不再需要同步,因为this.pair = ps是引用赋值的原子操作。而语句:
1
int[] ps = new int[] { first, last };

这里的ps是方法内部定义的局部变量,每个线程都会有各自的局部变量,互不影响,并且互不可见,并不需要同步。

小结

  • 多线程同时读写共享变量时,会造成逻辑错误,因此需要通过 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也是线程安全的。
还有一些不变类,例如String,Integer,LocalDate,它们的所有成员变量都是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) {
...
}
}
}

我们再考察Counter的get()方法:
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;
}
}

字符串和编码

String

Java 中,String 是一个引用类型,它本身也是一个 class。但是,Java 编译器对 String 有特殊处理,即可以直接用 “…” 来表示一个字符串:

1
String s1 = "Hello!";

实际上字符串在 String 内部是通过一个 char[] 数组表示的,因此,按下面的写法也是可以的:
1
String s2 = new String(new char[] {'H', 'e', 'l', 'l', 'o', '!'});

因为 String 太常用了,所以 Java 提供了 "..." 这种字符串字面量表示方法。

Java 字符串的一个重要特点就是字符串不可变。这种不可变性是通过内部的 private final char[] 字段,以及没有任何修改 char[] 的方法实现的。看下例:

1
2
3
4
5
6
7
8
public class Main {
public static void main(String[] args) {
String s = "Hello";
System.out.println(s);
s = s.toUpperCase();
System.out.println(s);
}
}

输出

1
2
Hello
HELLO

个人认为:字符串内容没有改变,因为字符串是引用类型,而 s.toUpperCase() 是创建了一个新的字符串,再用 s 指向它。只是原来的字符串无法通过 s 访问罢了。

字符串比较

当我们想要比较两个字符串是否相同时,要特别注意,我们实际上是想比较字符串的内容是否相同。必须使用 equals() 方法而不能用 ==
看下例:

1
2
3
4
5
6
7
8
public class Main {
public static void main(String[] args) {
String s1 = "hello";
String s2 = "hello";
System.out.println(s1 == s2);
System.out.println(s1.equals(s2));
}
}

输出
1
2
true
true

从表面上看,两个字符串用 ==equals() 比较都为 true,但实际上那只是 Java 编译器在编译期,会自动把所有相同的字符串当作一个对象放入常量池,自然 s1s2 的引用就是相同的。
所以,这种 == 比较返回 true 纯属巧合。换一种写法,== 比较就会失败:
1
2
3
4
5
6
7
8
public class Main {
public static void main(String[] args) {
String s1 = "hello";
String s2 = "HELLO".toLowerCase(); // 因为在编译期间,只有 hello 和 HELLO 两个字符串
System.out.println(s1 == s2);
System.out.println(s1.equals(s2));
}
}

输出
1
2
false
true

结论:两个字符串比较,必须总是使用 equals() 方法。要忽略大小写比较,使用 equalsIgnoreCase() 方法。String 类还提供了多种方法来搜索子串、提取子串。常用的方法有:

子串、提取子串

  1. 是否包含子串

    1
    2
    // 是否包含子串:
    "Hello".contains("ll"); // true

    注意到 contains() 方法的参数是 CharSequence 而不是 String,因为 CharSequenceString 的父类。

  2. 搜索子串的更多的例子:

    1
    2
    3
    4
    "Hello".indexOf("l"); // 2
    "Hello".lastIndexOf("l"); // 3
    "Hello".startsWith("He"); // true
    "Hello".endsWith("lo"); // true
  3. 提取子串

    1
    2
    "Hello".substring(2); // "llo"
    "Hello".substring(2, 4); // "ll"

注意索引号是从 0 开始的

去除首尾空白字符

使用 trim() 方法可以移除字符串首尾空白字符。空白字符包括 空格\t\r\n

1
"  \tHello\r\n ".trim(); // "Hello"

注意trim() 并没有改变字符串的内容,而是返回了一个新字符串。
另一个 strip() 方法也可以移除字符串首尾空白字符。它和 trim() 不同的是,类似中文的空格字符 \u3000 也会被移除:

1
2
3
"\u3000Hello\u3000".strip(); // "Hello"
" Hello ".stripLeading(); // "Hello "
" Hello ".stripTrailing(); // " Hello"

String 还提供了 isEmpty()isBlank() 来判断字符串是否为空和空白字符串:
1
2
3
4
"".isEmpty(); // true,因为字符串长度为0
" ".isEmpty(); // false,因为字符串长度不为0
" \n".isBlank(); // true,因为只包含空白字符
" Hello ".isBlank(); // false,因为包含非空白字符

替换子串

要在字符串中替换子串,有两种方法。一种是根据字符或字符串替换:

1
2
3
String s = "hello";
s.replace('l', 'w'); // "hewwo",所有字符'l'被替换为'w'
s.replace("ll", "~~"); // "he~~o",所有子串"ll"被替换为"~

另一种是通过正则表达式替换:
1
2
String s = "A,,B;C ,D";
s.replaceAll("[\\,\\;\\s]+", ","); // "A,B,C,D"

分割字符串

要分割字符串,使用 split() 方法,并且传入的也是正则表达式:

1
2
String s = "A,B,C,D";
String[] ss = s.split("\\,"); // {"A", "B", "C", "D"}

拼接字符串

拼接字符串使用静态方法 join(),它用指定的字符串连接字符串数组:

1
2
String[] arr = {"A", "B", "C"};
String s = String.join("***", arr); // "A***B***C"

格式化字符串

字符串提供了 formatted() 方法和 format() 静态方法,可以传入其他参数,替换占位符,然后生成新的字符串:

1
2
3
4
5
6
7
public class Main {
public static void main(String[] args) {
String s = "Hi %s, your score is %d!";
System.out.println(s.formatted("Alice", 80));
System.out.println(String.format("Hi %s, your score is %.2f!", "Bob", 59.5));
}
}

有几个占位符,后面就传入几个参数。参数类型要和占位符一致。我们经常用这个方法来格式化信息。常用的占位符有:
- %s:显示字符串;
- %d:显示整数;
- %x:显示十六进制整数;
- %f:显示浮点数。
占位符还可以带格式,例如 %.2f 表示显示两位小数。如果你不确定用啥占位符,那就始终用 %s,因为 %s 可以显示任何数据类型。要查看完整的格式化语法,请参考 JDK文档

类型转换

要把任意基本类型或引用类型转换为字符串,可以使用静态方法 valueOf()。这是一个重载方法,编译器会根据参数自动选择合适的方法:

1
2
3
4
String.valueOf(123); // "123"
String.valueOf(45.67); // "45.67"
String.valueOf(true); // "true"
String.valueOf(new Object()); // 类似java.lang.Object@636be97c

要把字符串转换为其他类型,就需要根据情况。例如,把字符串转换为 int 类型:
1
2
int n1 = Integer.parseInt("123"); // 123
int n2 = Integer.parseInt("ff", 16); // 按十六进制转换,255

把字符串转换为 boolean 类型:
1
2
boolean b1 = Boolean.parseBoolean("true"); // true
boolean b2 = Boolean.parseBoolean("FALSE"); // false

要特别注意,Integer 有个 getInteger(String) 方法,它不是将字符串转换为 int,而是把该字符串对应的系统变量转换为 Integer
1
Integer.getInteger("java.version"); // 版本号,11

转换为char[]

Stringchar[] 类型可以互相转换,方法是:

1
2
char[] cs = "Hello".toCharArray(); // String -> char[]
String s = new String(cs); // char[] -> String

如果修改了 char[] 数组,String 并不会改变:
1
2
3
4
5
6
7
8
9
public class Main {
public static void main(String[] args) {
char[] cs = "Hello".toCharArray();
String s = new String(cs);
System.out.println(s);
cs[0] = 'X';
System.out.println(s);
}
}

这是因为通过 new String(char[]) 创建新的 String 实例时,它并不会直接引用传入的 char[] 数组,而是会复制一份,所以,修改外部的 char[] 数组不会影响 String 实例内部的 char[] 数组,因为这是两个不同的数组。
String 的不变性设计可以看出,如果传入的对象有可能改变,我们需要复制而不是直接引用。

字符编码

StringBuilder

Java 编译器对 String 做了特殊处理,使得我们可以直接用 + 拼接字符串。

1
2
3
4
String s = "";
for (int i = 0; i < 1000; i++) {
s = s + "," + i;
}

虽然可以直接拼接字符串,但是,在循环中,每次循环都会创建新的字符串对象,然后扔掉旧的字符串。这样,绝大部分字符串都是临时对象,不但浪费内存,还会影响 GC 效率。
为了能高效拼接字符串,Java 标准库提供了 StringBuilder,它是一个可变对象,可以预分配缓冲区,这样,往 StringBuilder 中新增字符时,不会创建新的临时对象:
1
2
3
4
5
6
StringBuilder sb = new StringBuilder(1024);
for (int i = 0; i < 1000; i++) {
sb.append(',');
sb.append(i);
}
String s = sb.toString();

StringBuilder 还可以进行链式操作:
1
2
3
4
5
6
7
8
9
10
public class Main {
public static void main(String[] args) {
var sb = new StringBuilder(1024);
sb.append("Mr ")
.append("Bob")
.append("!")
.insert(0, "Hello, ");
System.out.println(sb.toString());
}
}

如果我们查看 StringBuilder 的源码,可以发现,进行链式操作的关键是,定义的 append() 方法会返回 this,这样,就可以不断调用自身的其他方法。

仿照 StringBuilder,我们也可以设计支持链式操作的类。例如,一个可以不断增加的计数器:

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) {
Adder adder = new Adder();
adder.add(3)
.add(5)
.inc()
.add(10);
System.out.println(adder.value());
}
}

class Adder {
private int sum = 0;

public Adder add(int n) {
sum += n;
return this;
}

public Adder inc() {
sum ++;
return this;
}

public int value() {
return sum;
}
}

注意:对于普通的字符串 + 操作,并不需要我们将其改写为 StringBuilder,因为 Java 编译器在编译时就自动把多个连续的 + 操作编码为 StringConcatFactory 的操作。在运行期,StringConcatFactory 会自动把字符串连接操作优化为数组复制或者 StringBuilder 操作。

你可能还听说过 StringBuffer,这是 Java 早期的一个 StringBuilder 的线程安全版本,它通过同步来保证多个线程操作 StringBuffer 也是安全的,但是同步会带来执行速度的下降。

StringBuilderStringBuffer 接口完全相同,现在完全没有必要使用 StringBuffer

StringJoiner

要高效拼接字符串,应该使用 StringBuilder
很多时候,我们拼接的字符串像这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Main {
public static void main(String[] args) {
String[] names = {"Bob", "Alice", "Grace"};
var sb = new StringBuilder();
sb.append("Hello ");
for (String name : names) {
sb.append(name).append(", ");
}
// 注意去掉最后的", ":
sb.delete(sb.length() - 2, sb.length());
sb.append("!");
System.out.println(sb.toString());
}
}

类似用分隔符拼接数组的需求很常见,所以 Java 标准库还提供了一个 StringJoiner 来干这个事:
1
2
3
4
5
6
7
8
9
10
11
12
import java.util.StringJoiner;

public class Main {
public static void main(String[] args) {
String[] names = {"Bob", "Alice", "Grace"};
var sj = new StringJoiner(", ");
for (String name : names) {
sj.add(name);
}
System.out.println(sj.toString());
}
}

慢着!用 StringJoiner 的结果少了前面的 "Hello" 和结尾的 "!" 遇到这种情况,需要给 StringJoiner 指定 “开头” 和“结尾”:
1
2
3
4
5
6
7
8
9
10
public class Main {
public static void main(String[] args) {
String[] names = {"Bob", "Alice", "Grace"};
var sj = new StringJoiner(", ", "Hello ", "!");
for (String name : names) {
sj.add(name);
}
System.out.println(sj.toString());
}
}

String.join()

String 还提供了一个静态方法 join(),这个方法在内部使用了 StringJoiner 来拼接字符串,在不需要指定 “开头” 和“结尾”的时候,用 String.join() 更方便:

1
2
String[] names = {"Bob", "Alice", "Grace"};
var s = String.join(", ", names);

包装类型

我们已经知道,Java的数据类型分两种:

  • 基本类型:byteshortintlongbooleanfloatdoublechar
  • 引用类型:所有 classinterface 类型

引用类型可以赋值为 null,表示空,但基本类型不能赋值为 null

1
2
String s = null;
int n = null; // compile error!

那么,如何把一个基本类型视为对象(引用类型)?

比如,想要把 int 基本类型变成一个引用类型,我们可以定义一个 Integer 类,它只包含一个实例字段 int,这样,Integer 类就可以视为 int 的包装类(Wrapper Class)

1
2
3
4
5
6
7
8
9
10
11
public class Integer {
private int value;

public Integer(int value) {
this.value = value;
}

public int intValue() {
return this.value;
}
}

定义好了 Integer 类,我们就可以把 intInteger 互相转换:
1
2
3
Integer n = null;
Integer n2 = new Integer(99);
int n3 = n2.intValue();

实际上,因为包装类型非常有用,Java 核心库为每种基本类型都提供了对应的包装类型:

基本类型 对应的引用类型
boolean java.lang.Boolean
byte java.lang.Byte
short java.lang.Short
int java.lang.Integer
long java.lang.Long
float java.lang.Float
double java.lang.Double
char java.lang.Character
我们可以直接使用,并不需要自己去定义:
1
2
3
4
5
6
7
8
9
10
11
12
public class Main {
public static void main(String[] args) {
int i = 100;
// 通过new操作符创建Integer实例(不推荐使用,会有编译警告):
Integer n1 = new Integer(i);
// 通过静态方法valueOf(int)创建Integer实例:
Integer n2 = Integer.valueOf(i);
// 通过静态方法valueOf(String)创建Integer实例:
Integer n3 = Integer.valueOf("100");
System.out.println(n3.intValue());
}
}

Auto Boxing

因为int和Integer可以互相转换:

1
2
3
int i = 100;
Integer n = Integer.valueOf(i);
int x = n.intValue();

所以,Java 编译器可以帮助我们自动在 intInteger 之间转型:
1
2
Integer n = 100; // 编译器自动使用Integer.valueOf(int)
int x = n; // 编译器自动使用Integer.intValue()

这种直接把 int 变为 Integer 的赋值写法,称为自动装箱(Auto Boxing),反过来,把 Integer 变为 int 的赋值写法,称为自动拆箱(Auto Unboxing)。

注意:自动装箱和自动拆箱只发生在编译阶段,目的是为了少写代码。

装箱和拆箱会影响代码的执行效率,因为编译后的 class 代码是严格区分基本类型和引用类型的。并且,自动拆箱执行时可能会报 NullPointerException

1
2
3
4
5
6
public class Main {
public static void main(String[] args) {
Integer n = null;
int i = n;
}
}

不变类

所有的包装类型都是不变类。我们查看 Integer 的源码可知,它的核心代码如下:

1
2
3
public final class Integer {
private final int value;
}

因此,一旦创建了 Integer 对象,该对象就是不变的。
对两个 Integer 实例进行比较要特别注意:绝对不能用 == 比较,因为 Integer 是引用类型,必须使用 equals() 比较:
1
2
3
4
5
6
7
8
9
10
11
12
public class Main {
public static void main(String[] args) {
Integer x = 127;
Integer y = 127;
Integer m = 99999;
Integer n = 99999;
System.out.println("x == y: " + (x==y)); // true
System.out.println("m == n: " + (m==n)); // false
System.out.println("x.equals(y): " + x.equals(y)); // true
System.out.println("m.equals(n): " + m.equals(n)); // true
}
}

仔细观察结果的童鞋可以发现,== 比较,较小的两个相同的 Integer 返回 true,较大的两个相同的 Integer 返回 false,这是因为 Integer 是不变类,编译器把 Integer x = 127; 自动变为 Integer x = Integer.valueOf(127);,为了节省内存,Integer.valueOf()对于较小的数,始终返回相同的实例,因此,== 比较 “恰好” 为 true,但我们绝不能因为 Java 标准库的 Integer 内部有缓存优化就用 == 比较,必须用 equals()方法比较两个 Integer。

按照语义编程,而不是针对特定的底层实现去“优化”。

因为 Integer.valueOf() 可能始终返回同一个 Integer 实例,因此,在我们自己创建 Integer 的时候,以下两种方法:

  • 方法 1:Integer n = new Integer(100);
  • 方法 2:Integer n = Integer.valueOf(100);

方法 2 更好,因为方法 1 总是创建新的 Integer 实例,方法 2 把内部优化留给 Integer 的实现者去做,即使在当前版本没有优化,也有可能在下一个版本进行优化。

我们把能创建 “新” 对象的静态方法称为静态工厂方法。Integer.valueOf()就是静态工厂方法,它尽可能地返回缓存的实例以节省内存。

创建新对象时,优先选用静态工厂方法而不是 new 操作符

如果我们考察 Byte.valueOf() 方法的源码,可以看到,标准库返回的 Byte 实例全部是缓存实例,但调用者并不关心静态工厂方法以何种方式创建新实例还是直接返回缓存的实例。

进制转换

Integer 类本身还提供了大量方法,例如,最常用的静态方法 parseInt() 可以把字符串解析成一个整数:

1
2
int x1 = Integer.parseInt("100"); // 100
int x2 = Integer.parseInt("100", 16); // 256,因为按16进制解析

Integer还可以把整数格式化为指定进制的字符串:
1
2
3
4
5
6
7
8
9
public class Main {
public static void main(String[] args) {
System.out.println(Integer.toString(100)); // "100",表示为10进制
System.out.println(Integer.toString(100, 36)); // "2s",表示为36进制
System.out.println(Integer.toHexString(100)); // "64",表示为16进制
System.out.println(Integer.toOctalString(100)); // "144",表示为8进制
System.out.println(Integer.toBinaryString(100)); // "1100100",表示为2进制
}
}

注意:上述方法的输出都是String,在计算机内存中,只用二进制表示,不存在十进制或十六进制的表示方法。int n = 100在内存中总是以4字节的二进制表示:

1
00000000 00000000 00000000 01100100 

我们经常使用的 System.out.println(n); 是依靠核心库自动把整数格式化为 10 进制输出并显示在屏幕上,使用 Integer.toHexString(n) 则通过核心库自动把整数格式化为 16 进制。

这里我们注意到程序设计的一个重要原则:数据的存储和显示要分离

Java 的包装类型还定义了一些有用的静态变量

1
2
3
4
5
6
7
8
9
// boolean只有两个值true/false,其包装类型只需要引用Boolean提供的静态字段:
Boolean t = Boolean.TRUE;
Boolean f = Boolean.FALSE;
// int可表示的最大/最小值:
int max = Integer.MAX_VALUE; // 2147483647
int min = Integer.MIN_VALUE; // -2147483648
// long类型占用的bit和byte数量:
int sizeOfLong = Long.SIZE; // 64 (bits)
int bytesOfLong = Long.BYTES; // 8 (bytes)

最后,所有的整数和浮点数的包装类型都继承自 Number,因此,可以非常方便地直接通过包装类型获取各种基本类型:
1
2
3
4
5
6
7
8
// 向上转型为Number:
Number num = new Integer(999);
// 获取byte, int, long, float, double:
byte b = num.byteValue();
int n = num.intValue();
long ln = num.longValue();
float f = num.floatValue();
double d = num.doubleValue();

处理无符号整型

在 Java 中,并没有无符号整型(Unsigned)的基本数据类型。byteshortintlong 都是带符号整型,最高位是符号位。而 C 语言则提供了 CPU 支持的全部数据类型,包括无符号整型。无符号整型和有符号整型的转换在 Java 中就需要借助包装类型的静态方法完成。

例如,byte 是有符号整型,范围是 -128~+127,但如果把 byte 看作无符号整型,它的范围就是 0~255。我们把一个负的 byte 按无符号整型转换为 int

1
2
3
4
5
6
7
8
public class Main {
public static void main(String[] args) {
byte x = -1;
byte y = 127;
System.out.println(Byte.toUnsignedInt(x)); // 255
System.out.println(Byte.toUnsignedInt(y)); // 127
}
}

因为 byte 的 -1 的二进制表示是 11111111,以无符号整型转换后的 int 就是 255。

类似的,可以把一个 shortunsigned 转换为 int,把一个 intunsigned 转换为 long

小结

  • Java 核心库提供的包装类型可以把基本类型包装为 class
  • 自动装箱和自动拆箱都是在编译期完成的(JDK>=1.5)
  • 装箱和拆箱会影响执行效率,且拆箱时可能发生 NullPointerException
  • 包装类型的比较必须使用 equals()
  • 整数和浮点数的包装类型都继承自 Number
  • 包装类型提供了大量实用方法。

JavaBean

在 Java 中,有很多 class 的定义都符合这样的规范:

  • 若干 private 实例字段;
  • 通过 public 方法来读写实例字段。

例如:

1
2
3
4
5
6
7
8
9
10
public class Person {
private String name;
private int age;

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

public int getAge() { return this.age; }
public void setAge(int age) { this.age = age; }
}

如果读写方法符合以下这种命名规范:
1
2
3
4
// 读方法:
public Type getXyz()
// 写方法:
public void setXyz(Type value)

那么这种 class 被称为 JavaBean
上面的字段是 xyz,那么读写方法名分别以 get 和 set 开头,并且后接大写字母开头的字段名 Xyz,因此两个读写方法名分别是 getXyz() 和 setXyz()。
boolean 字段比较特殊,它的读方法一般命名为 isXyz():
1
2
3
4
// 读方法:
public boolean isChild()
// 写方法:
public void setChild(boolean value)

我们通常把一组对应的读方法(getter)和写方法(setter)称为属性(property)。例如,name属性

  • 对应的读方法是String getName()
  • 对应的写方法是setName(String)

只有getter的属性称为只读属性(read-only),例如,定义一个age只读属性:

  • 对应的读方法是int getAge()
  • 无对应的写方法setAge(int)

类似的,只有setter的属性称为只写属性(write-only)。
很明显,只读属性很常见,只写属性不常见。
属性只需要定义getter和setter方法,不一定需要对应的字段。例如,child只读属性定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Person {
private String name;
private int age;

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

public int getAge() { return this.age; }
public void setAge(int age) { this.age = age; }

public boolean isChild() {
return age <= 6;
}
}

枚举 JavaBean 属性

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
public class Main {
public static void main(String[] args) throws Exception {
BeanInfo info = Introspector.getBeanInfo(Person.class);
for (PropertyDescriptor pd : info.getPropertyDescriptors()) {
System.out.println(pd.getName());
System.out.println(" " + pd.getReadMethod());
System.out.println(" " + pd.getWriteMethod());
}
}
}

class Person {
private String name;
private int age;

public String getName() {
return name;
}

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

public int getAge() {
return age;
}

public void setAge(int age) {
this.age = age;
}
public boolean isChild() {
return age < 6;
}
}

输出

1
2
3
4
5
6
7
8
9
10
11
12
age
public int Person.getAge()
public void Person.setAge(int)
child
public boolean Person.isChild()
null
class
public final native java.lang.Class java.lang.Object.getClass()
null
name
public java.lang.String Person.getName()
public void Person.setName(java.lang.String)

枚举类

在Java中,我们可以通过static final来定义常量。例如,我们希望定义周一到周日这7个常量,可以用7个不同的int表示:

1
2
3
4
5
6
7
8
9
public class Weekday {
public static final int SUN = 0;
public static final int MON = 1;
public static final int TUE = 2;
public static final int WED = 3;
public static final int THU = 4;
public static final int FRI = 5;
public static final int SAT = 6;
}

使用常量的时候,可以这么引用:
1
2
3
if (day == Weekday.SAT || day == Weekday.SUN) {
// TODO: work at home
}

也可以把常量定义为字符串类型,例如,定义3种颜色的常量:
1
2
3
4
5
public class Color {
public static final String RED = "r";
public static final String GREEN = "g";
public static final String BLUE = "b";
}

使用常量的时候,可以这么引用:
1
2
3
4
String color = ...
if (Color.RED.equals(color)) {
// TODO:
}

无论是int常量还是String常量,使用这些常量来表示一组枚举值的时候,有一个严重的问题就是,编译器无法检查每个值的合理性。例如:
1
2
3
4
5
if (weekday == 6 || weekday == 7) {
if (tasks == Weekday.MON) {
// TODO:
}
}

上述代码编译和运行均不会报错,但存在两个问题:

  • 注意到Weekday定义的常量范围是0~6,并不包含7,编译器无法检查不在枚举中的int值;
  • 定义的常量仍可与其他变量比较,但其用途并非是枚举星期值。

enum

为了让编译器能自动检查某个值在枚举的集合内,并且,不同用途的枚举需要不同的类型来标记,不能混用,我们可以使用 enum 来定义枚举类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Main {
public static void main(String[] args) {
Weekday day = Weekday.SUN;
if (day == Weekday.SAT || day == Weekday.SUN) {
System.out.println("Work at home!");
} else {
System.out.println("Work at office!");
}
}
}

enum Weekday {
SUN, MON, TUE, WED, THU, FRI, SAT;
}

注意到定义枚举类是通过关键字enum实现的,我们只需依次列出枚举的常量名。
和int定义的常量相比,使用enum定义枚举有如下好处:
首先,enum常量本身带有类型信息,即Weekday.SUN类型是Weekday,编译器会自动检查出类型错误。例如,下面的语句不可能编译通过:
1
2
3
int day = 1;
if (day == Weekday.SUN) { // Compile error: bad operand types for binary operator '=='
}

其次,不可能引用到非枚举的值,因为无法通过编译。
最后,不同类型的枚举不能互相比较或者赋值,因为类型不符。例如,不能给一个Weekday枚举类型的变量赋值为Color枚举类型的值:
1
2
Weekday x = Weekday.SUN; // ok!
Weekday y = Color.RED; // Compile error: incompatible types

这就使得编译器可以在编译期自动检查出所有可能的潜在错误。

enum 比较

使用 enum 定义的枚举类是一种引用类型。前面我们讲到,引用类型比较,要使用 equals() 方法,如果使用 == 比较,它比较的是两个引用类型的变量是否是同一个对象。因此,引用类型比较,要始终使用 equals() 方法,但 enum 类型可以例外。
这是因为 enum 类型的每个常量在 JVM 中只有一个唯一实例,所以可以直接用 == 比较:

1
2
3
4
if (day == Weekday.FRI) { // ok!
}
if (day.equals(Weekday.SUN)) { // ok, but more code!
}

enum 类型

通过enum定义的枚举类,和其他的class有什么区别?
答案是没有任何区别。enum定义的类型就是class,只不过它有以下几个特点:
定义的enum类型总是继承自java.lang.Enum,且无法被继承;

  • 只能定义出enum的实例,而无法通过new操作符创建enum的实例;
  • 定义的每个实例都是引用类型的唯一实例;
  • 可以将enum类型用于switch语句。

例如,我们定义的Color枚举类:

1
2
3
public enum Color {
RED, GREEN, BLUE;
}

编译器编译出的class大概就像这样:
1
2
3
4
5
6
7
8
public final class Color extends Enum { // 继承自Enum,标记为final class
// 每个实例均为全局唯一:
public static final Color RED = new Color();
public static final Color GREEN = new Color();
public static final Color BLUE = new Color();
// private构造方法,确保外部无法调用new操作符:
private Color() {}
}

所以,编译后的 enum 类和普通 class 并没有任何区别。但是我们自己无法按定义普通 class 那样来定义 enum,必须使用 enum 关键字,这是 Java 语法规定的。

因为 enum 是一个 class,每个枚举的值都是 class 实例,因此,这些实例有一些方法:

name()

返回常量名,例如:

1
String s = Weekday.SUN.name(); // "SUN"

ordinal()

返回定义的常量的顺序,从0开始计数,例如:

1
int n = Weekday.MON.ordinal(); // 1

改变枚举常量定义的顺序就会导致ordinal()返回值发生变化。例如:

1
2
3
public enum Weekday {
SUN, MON, TUE, WED, THU, FRI, SAT;
}


1
2
3
public enum Weekday {
MON, TUE, WED, THU, FRI, SAT, SUN;
}

的 ordinal 就是不同的。如果在代码中编写了类似 if(x.ordinal()==1) 这样的语句,就要保证 enum 的枚举顺序不能变。新增的常量必须放在最后。
Weekday的枚举常量如果要和int转换,使用ordinal()不是非常方便?比如这样写:
1
2
String task = Weekday.MON.ordinal() + "/ppt";
saveToFile(task);

但是,如果不小心修改了枚举的顺序,编译器是无法检查出这种逻辑错误的。要编写健壮的代码,就不要依靠 ordinal() 的返回值。因为 enum 本身是 class,所以我们可以定义 private 的构造方法,并且,给每个枚举常量添加字段:
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) {
Weekday day = Weekday.SUN;
if (day.dayValue == 6 || day.dayValue == 0) {
System.out.println("Work at home!");
} else {
System.out.println("Work at office!");
}
}
}

enum Weekday {
MON(1), TUE(2), WED(3), THU(4), FRI(5), SAT(6), SUN(0);

public final int dayValue;

private Weekday(int dayValue) {
this.dayValue = dayValue;
}
}

这样就无需担心顺序的变化,新增枚举常量时,也需要指定一个int值。

枚举类的字段也可以是非final类型,即可以在运行期修改,但是不推荐这样做!

默认情况下,对枚举常量调用toString()会返回和name()一样的字符串。但是,toString()可以被覆写,而name()则不行。我们可以给Weekday添加toString()方法:

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 Main {
public static void main(String[] args) {
Weekday day = Weekday.SUN;
if (day.dayValue == 6 || day.dayValue == 0) {
System.out.println("Today is " + day + ". Work at home!");
} else {
System.out.println("Today is " + day + ". Work at office!");
}
}
}

enum Weekday {
MON(1, "星期一"), TUE(2, "星期二"), WED(3, "星期三"), THU(4, "星期四"), FRI(5, "星期五"), SAT(6, "星期六"), SUN(0, "星期日");

public final int dayValue;
private final String chinese;

private Weekday(int dayValue, String chinese) {
this.dayValue = dayValue;
this.chinese = chinese;
}

@Override
public String toString() {
return this.chinese;
}
}

覆写toString()的目的是在输出时更有可读性。
判断枚举常量的名字,要始终使用name()方法,绝不能调用toString()!

switch

最后,枚举类可以应用在switch语句中。因为枚举类天生具有类型信息和有限个枚举常量,所以比int、String类型更适合用在switch语句中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class Main {
public static void main(String[] args) {
Weekday day = Weekday.SUN;
switch(day) {
case MON:
case TUE:
case WED:
case THU:
case FRI:
System.out.println("Today is " + day + ". Work at office!");
break;
case SAT:
case SUN:
System.out.println("Today is " + day + ". Work at home!");
break;
default:
throw new RuntimeException("cannot process " + day);
}
}
}

enum Weekday {
MON, TUE, WED, THU, FRI, SAT, SUN;
}

  1. case语句中只能写枚举类定义的变量名称,不能加类名。
  2. 加上default语句,可以在漏写某个枚举常量时自动报错,从而及时发现错误。

小结

  • Java 使用 enum 定义枚举类型,它被编译器编译为 final class Xxx extends Enum {…};
  • 通过 name() 获取常量定义的字符串,注意不要使用 toString()
  • 通过 ordinal() 返回常量定义的顺序(无实质意义);
  • 可以为 enum 编写构造方法、字段和方法
  • enum 的构造方法要声明为 private,字段强烈建议声明为 final
  • enum 适合用在 switch 语句中。

BigInteger

在 Java 中,由 CPU 原生提供的整型最大范围是 64 位 long 型整数。使用 long 型整数可以直接通过 CPU 指令进行计算,速度非常快。
如果我们使用的整数范围超过了 long 型怎么办?这个时候,就只能用软件来模拟一个大整数。java.math.BigInteger 就是用来表示任意大小的整数。BigInteger 内部用一个 int[] 数组来模拟一个非常大的整数:

1
2
BigInteger bi = new BigInteger("1234567890");
System.out.println(bi.pow(5)); // 2867971860299718107233761438093672048294900000

BigInteger 做运算的时候,只能使用实例方法,例如,加法运算:
1
2
3
BigInteger i1 = new BigInteger("1234567890");
BigInteger i2 = new BigInteger("12345678901234567890");
BigInteger sum = i1.add(i2); // 12345678902469135780

long 型整数运算比,BigInteger 不会有范围限制,但缺点是速度比较慢。

也可以把BigInteger转换成long型:

1
2
3
BigInteger i = new BigInteger("123456789000");
System.out.println(i.longValue()); // 123456789000
System.out.println(i.multiply(i).longValueExact()); // java.lang.ArithmeticException: BigInteger out of long range

使用 longValueExact() 方法时,如果超出了 long 型的范围,会抛出 ArithmeticException
BigIntegerIntegerLong 一样,也是不可变类,并且也继承自 Number 类。因为 Number 定义了转换为基本类型的几个方法:

  • 转换为 bytebyteValue()
  • 转换为 shortshortValue()
  • 转换为 intintValue()
  • 转换为 longlongValue()
  • 转换为 floatfloatValue()
  • 转换为 doubledoubleValue()

因此,通过上述方法,可以把 BigInteger 转换成基本类型。如果 BigInteger 表示的范围超过了基本类型的范围,转换时将丢失高位信息,即结果不一定是准确的。如果需要准确地转换成基本类型,可以使用 intValueExact()longValueExact() 等方法,在转换时如果超出范围,将直接抛出 ArithmeticException 异常。

如果BigInteger的值甚至超过了float的最大范围(3.4x1038),那么返回的float是什么呢?

1
2
3
4
5
6
7
8
9
import java.math.BigInteger;

public class Main {
public static void main(String[] args) {
BigInteger n = new BigInteger("999999").pow(99);
float f = n.floatValue();
System.out.println(f);
}
}

小结

  • BigInteger 用于表示任意大小的整数;
  • BigInteger 是不变类,并且继承自 Number
  • BigInteger 转换成基本类型时可使用 longValueExact() 等方法保证结果准确。

BigDecimal

BigInteger 类似,BigDecimal 可以表示一个任意大小且精度完全准确的浮点数。

1
2
BigDecimal bd = new BigDecimal("123.4567");
System.out.println(bd.multiply(bd)); // 15241.55677489

BigDecimalscale() 表示小数位数,例如:
1
2
3
4
5
6
BigDecimal d1 = new BigDecimal("123.45");
BigDecimal d2 = new BigDecimal("123.4500");
BigDecimal d3 = new BigDecimal("1234500");
System.out.println(d1.scale()); // 2,两位小数
System.out.println(d2.scale()); // 4
System.out.println(d3.scale()); // 0

通过 BigDecimalstripTrailingZeros() 方法,可以将一个 BigDecimal 格式化为一个相等的,但去掉了末尾 0BigDecimal
1
2
3
4
5
6
7
8
9
BigDecimal d1 = new BigDecimal("123.4500");
BigDecimal d2 = d1.stripTrailingZeros();
System.out.println(d1.scale()); // 4
System.out.println(d2.scale()); // 2,因为去掉了00

BigDecimal d3 = new BigDecimal("1234500");
BigDecimal d4 = d3.stripTrailingZeros();
System.out.println(d3.scale()); // 0
System.out.println(d4.scale()); // -2

如果一个 BigDecimalscale() 返回负数,例如,-2,表示这个数是个整数,并且末尾有 20
可以对一个 BigDecimal 设置它的 scale,如果精度比原始值低,那么按照指定的方法进行四舍五入或者直接截断:
1
2
3
4
5
6
7
8
9
10
11
12
import java.math.BigDecimal;
import java.math.RoundingMode;

public class Main {
public static void main(String[] args) {
BigDecimal d1 = new BigDecimal("123.456789");
BigDecimal d2 = d1.setScale(4, RoundingMode.HALF_UP); // 四舍五入,123.4568
BigDecimal d3 = d1.setScale(4, RoundingMode.DOWN); // 直接截断,123.4567
System.out.println(d2);
System.out.println(d3);
}
}

BigDecimal 做加、减、乘时,精度不会丢失,但是做除法时,存在无法除尽的情况,这时,就必须指定精度以及如何进行截断:
1
2
3
4
BigDecimal d1 = new BigDecimal("123.456");
BigDecimal d2 = new BigDecimal("23.456789");
BigDecimal d3 = d1.divide(d2, 10, RoundingMode.HALF_UP); // 保留10位小数并四舍五入
BigDecimal d4 = d1.divide(d2); // 报错:ArithmeticException,因为除不尽

还可以对 BigDecimal 做除法的同时求余数:
1
2
3
4
5
6
7
8
9
10
11
import java.math.BigDecimal;

public class Main {
public static void main(String[] args) {
BigDecimal n = new BigDecimal("12.345");
BigDecimal m = new BigDecimal("0.12");
BigDecimal[] dr = n.divideAndRemainder(m);
System.out.println(dr[0]); // 102
System.out.println(dr[1]); // 0.105
}
}

调用 divideAndRemainder() 方法时,返回的数组包含两个 BigDecimal,分别是商和余数,其中商总是整数,余数不会大于除数。我们可以利用这个方法判断两个 BigDecimal 是否是整数倍数:
1
2
3
4
5
6
BigDecimal n = new BigDecimal("12.75");
BigDecimal m = new BigDecimal("0.15");
BigDecimal[] dr = n.divideAndRemainder(m);
if (dr[1].signum() == 0) {
// n是m的整数倍
}

比较BigDecimal

在比较两个 BigDecimal 的值是否相等时,要特别注意,使用 equals() 方法不但要求两个 BigDecimal 的值相等,还要求它们的 scale() 相等:

1
2
3
4
5
BigDecimal d1 = new BigDecimal("123.456");
BigDecimal d2 = new BigDecimal("123.45600");
System.out.println(d1.equals(d2)); // false,因为scale不同
System.out.println(d1.equals(d2.stripTrailingZeros())); // true,因为d2去除尾部0后scale变为2
System.out.println(d1.compareTo(d2)); // 0

必须使用 compareTo() 方法来比较,它根据两个值的大小分别返回负数、正数和 0,分别表示小于、大于和等于。

总是使用 compareTo() 比较两个 BigDecimal 的值,不要使用 equals()

如果查看 BigDecimal 的源码,可以发现,实际上一个 BigDecimal 是通过一个 BigInteger 和一个 scale 来表示的,即 BigInteger 表示一个完整的整数,而 scale 表示小数位数:

1
2
3
4
public class BigDecimal extends Number implements Comparable<BigDecimal> {
private final BigInteger intVal;
private final int scale;
}

BigDecimal 也是从 Number 继承的,也是不可变对象。

小结

  • BigDecimal用于表示精确的小数,常用于财务计算;
  • 比较BigDecimal的值是否相等,必须使用compareTo()而不能使用equals()。

常用工具类

Java的核心库提供了大量的现成的类供我们使用。本节我们介绍几个常用的工具类。

Math

顾名思义,Math类就是用来进行数学计算的,它提供了大量的静态方法来便于我们实现数学计算:

求绝对值:

1
2
Math.abs(-100); // 100
Math.abs(-7.8); // 7.8

取最大或最小值:

1
2
Math.max(100, 99); // 100
Math.min(1.2, 2.3); // 1.2

计算 $x^y$ 次方:

1
Math.pow(2, 10); // 2的10次方=1024

计算 $\sqrt{x}$:

1
Math.sqrt(2); // 1.414...

计算 $e^x$ 次方:

1
Math.exp(2); // 7.389...

计算以 e 为底的对数:

1
Math.log(4); // 1.386...

计算以 10 为底的对数:

1
Math.log10(100); // 2

三角函数:

1
2
3
4
5
Math.sin(3.14); // 0.00159...
Math.cos(3.14); // -0.9999...
Math.tan(3.14); // -0.0015...
Math.asin(1.0); // 1.57079...
Math.acos(1.0); // 0.0

Math 还提供了几个数学常量:

1
2
3
double pi = Math.PI; // 3.14159...
double e = Math.E; // 2.7182818...
Math.sin(Math.PI / 6); // sin(π/6) = 0.5

生成一个随机数 x,x 的范围是 0 <= x < 1

1
Math.random(); // 0.53907...每次都不一样

如果我们要生成一个区间在 [MIN, MAX) 的随机数,可以借助 Math.random() 实现,计算如下:

1
2
3
4
5
6
7
8
9
10
11
12
// 区间在[MIN, MAX)的随机数
public class Main {
public static void main(String[] args) {
double x = Math.random(); // x的范围是[0,1)
double min = 10;
double max = 50;
double y = x * (max - min) + min; // y的范围是[10,50)
long n = (long) y; // n的范围是[10,50)的整数
System.out.println(y);
System.out.println(n);
}
}

Java 标准库还提供了一个 StrictMath,它提供了和 Math 几乎一模一样的方法。这两个类的区别在于,由于浮点数计算存在误差,不同的平台(例如 x86 和 ARM)计算的结果可能不一致(指误差不同),因此,StrictMath 保证所有平台计算结果都是完全相同的,而 Math 会尽量针对平台优化计算速度,所以,绝大多数情况下,使用 Math 就足够了。

Random

Random 用来创建伪随机数。所谓伪随机数,是指只要给定一个初始的种子,产生的随机数序列是完全一样的。

要生成一个随机数,可以使用 nextInt()nextLong()nextFloat()nextDouble()

1
2
3
4
5
6
Random r = new Random();
r.nextInt(); // 2071575453,每次都不一样
r.nextInt(10); // 5,生成一个[0,10)之间的int
r.nextLong(); // 8811649292570369305,每次都不一样
r.nextFloat(); // 0.54335...生成一个[0,1)之间的float
r.nextDouble(); // 0.3716...生成一个[0,1)之间的double

每次运行程序,生成的随机数都是不同的,没看出伪随机数的特性来。
这是因为我们创建Random实例时,如果不给定种子,就使用系统当前时间戳作为种子,因此每次运行时,种子不同,得到的伪随机数序列就不同。
如果我们在创建Random实例时指定一个种子,就会得到完全确定的随机数序列:
1
2
3
4
5
6
7
8
9
10
11
import java.util.Random;

public class Main {
public static void main(String[] args) {
Random r = new Random(12345);
for (int i = 0; i < 10; i++) {
System.out.println(r.nextInt(100));
}
// 51, 80, 41, 28, 55...
}
}

前面我们使用的Math.random()实际上内部调用了Random类,所以它也是伪随机数,只是我们无法指定种子。

SecureRandom

有伪随机数,就有真随机数。实际上真正的真随机数只能通过量子力学原理来获取,而我们想要的是一个不可预测的安全的随机数,SecureRandom 就是用来创建安全的随机数的:

1
2
SecureRandom sr = new SecureRandom();
System.out.println(sr.nextInt(100));

SecureRandom 无法指定种子,它使用 RNG(random number generator)算法。JDK 的 SecureRandom 实际上有多种不同的底层实现,有的使用安全随机种子加上伪随机数算法来产生安全的随机数,有的使用真正的随机数生成器。实际使用的时候,可以优先获取高强度的安全随机数生成器,如果没有提供,再使用普通等级的安全随机数生成器:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import java.util.Arrays;
import java.security.SecureRandom;
import java.security.NoSuchAlgorithmException;

public class Main {
public static void main(String[] args) {
SecureRandom sr = null;
try {
sr = SecureRandom.getInstanceStrong(); // 获取高强度安全随机数生成器
} catch (NoSuchAlgorithmException e) {
sr = new SecureRandom(); // 获取普通的安全随机数生成器
}
byte[] buffer = new byte[16];
sr.nextBytes(buffer); // 用安全随机数填充buffer
System.out.println(Arrays.toString(buffer));
}
}

SecureRandom 的安全性是通过操作系统提供的安全的随机种子来生成随机数。这个种子是通过 CPU 的热噪声、读写磁盘的字节、网络流量等各种随机事件产生的 “熵”。

在密码学中,安全的随机数非常重要。如果使用不安全的伪随机数,所有加密体系都将被攻破。因此,时刻牢记必须使用 SecureRandom 来产生安全的随机数。

需要使用安全随机数的时候,必须使用 SecureRandom,绝不能使用 Random

小结

Java提供的常用工具类有:

  • Math:数学计算
  • Random:生成伪随机数
  • SecureRandom:生成安全的随机数

面向对象基础

例子

1
2
3
4
class Person {
public String name;
public int age;
}

一个 class 可以包含多个字段(field),字段用来描述一个类的特征。上面的 Person 类,我们定义了两个字段,一个是 String 类型的字段,命名为 name,一个是 int 类型的字段,命名为 age。因此,通过 class,把一组数据汇集到一个对象上,实现了数据封装。

字段就是 C++ 中的成员属性

创建实例

1
Person ming = new Person();

实例化

1
2
3
Role role1 = new Role(); // 在堆空间里面分配了一个空间,把空间的地址赋给了 role1,用哈希码来表示在虚拟机里面的内存地址
Role role2; // 声明了一个 Role 类型的变量,叫 role2(可以把 Role 看成自己定义的数据类型),但是还没有空间
role2 = new Role(); // role2 一定要初始化,对象在运行时,一定要分配空间

类和对象的关系

  • 类是一个抽象的概念,仅仅是模板,比如说:演员、总统
  • 对象是一个你能够看得到、摸得着的具体实体
  • 类的定义者和类的使用者是不一样的。越往下越具体,越往上越抽象。

基本步骤

  1. 发现类
  2. 找出属性(名词)
  3. 找出行为(动词)
  4. 数据抽象:是数据和处理方法的结合

小结

  • 在 OOP 中,class 和 instance 是 “模版” 和 “实例” 的关系;
  • 类就是对象的模板(蓝图)
  • 定义 class 就是定义了一种数据类型,对应的 instance 是这种数据类型的实例;
  • class 定义的 field,在每个 instance 都会拥有各自的 field,且互不干扰;
  • 通过 new 操作符创建新的 instance,然后用变量指向它,即可通过变量来引用这个 instance;
  • 访问实例字段的方法是变量名. 字段名;

类图

  1. 直观、容易理解
  2. 参考工具:
    • StarUML
    • Astah
      • 号代表 public,- 号代表 private
    • 属性名在前,后面跟冒号和类型名
    • 方法名在前,后面跟冒号和返回值类型
    • 如果有参数,参数的类型写法同上
    • Astah

方法

把 field 从 public 改成 private,外部代码不能访问这些 field,以我们需要使用方法(method)来让外部代码可以间接修改 field:

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
public class Main {
public static void main(String[] args) {
Person ming = new Person();
ming.setName("Xiao Ming"); // 设置 name
ming.setAge(12); // 设置 age
System.out.println(ming.getName() + "," + ming.getAge());
}
}

class Person {
private String name;
private int age;

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

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

public int getAge() {
return this.age;
}

public void setAge(int age) {
if (age < 0 || age> 100) {
throw new IllegalArgumentException("invalid age value");
}
this.age = age;
}
}

虽然外部代码不能直接修改 private 字段,但是,外部代码可以调用方法 setName() 和 setAge() 来间接修改 private 字段。在方法内部,我们就有机会检查参数对不对。比如,setAge() 就会检查传入的参数,参数超出了范围,直接报错。这样,外部代码就没有任何机会把 age 设置成不合理的值。
同样,外部代码不能直接读取 private 字段,但可以通过 getName() 和 getAge() 间接获取 private 字段的值。
所以,一个类通过定义方法,就可以给外部代码暴露一些操作的接口,同时,内部自己保证逻辑一致性。

调用方法的语法是实例变量 . 方法名 (参数);。一个方法调用就是一个语句,所以不要忘了在末尾加 ;。例如:ming.setName("Xiao Ming");

定义方法

1
2
3
4
修饰符 方法返回类型 方法名 (方法参数列表) {
若干方法语句;
return 方法返回值;
}

private 方法

有 public 方法,自然就有 private 方法。和 private 字段一样,private 方法不允许外部调用,定义 private 方法的理由是内部方法是可以调用 private 方法的。

this 变量

在方法内部,可以使用一个隐含的变量 this,它始终指向当前实例。因此,通过 this.field 就可以访问当前实例的字段。如果没有命名冲突,可以省略 this,但是如果有局部变量和字段重名,那么局部变量优先级更高,就必须加上 this

方法参数

方法可以包含 0 个或任意个参数。方法参数用于接收传递给方法的变量值。调用方法时,必须严格按照参数的定义一一传递。(同 C++)

可变参数

可变参数用 类型... 定义,可变参数相当于数组类型:

1
2
class Group {
private String[] names;

  public void setNames(String... names) {
     this.names = names;
  }

}

1
2
3
4
5
6
7
> 上面的 setNames() 就定义了一个可变参数。调用时,可以这么写:
```java
Group g = new Group();
g.setNames("Xiao Ming", "Xiao Hong", "Xiao Jun"); // 传入 3 个 String
g.setNames("Xiao Ming", "Xiao Hong"); // 传入 2 个 String
g.setNames("Xiao Ming"); // 传入 1 个 String
g.setNames(); // 传入 0 个 String

完全可以把可变参数改写为 String[] 类型:

1
2
class Group {
private String[] names;

  public void setNames(String[] names) {
     this.names = names;
  }

}

1
2
3
4
> 但是,调用方需要自己先构造 String[],比较麻烦。例如:
```java
Group g = new Group();
g.setNames(new String[] {"Xiao Ming", "Xiao Hong", "Xiao Jun"}); // 传入 1 个 String[]

另一个问题是,调用方可以传入 null:

1
2
Group g = new Group();
g.setNames(null);

而可变参数可以保证无法传入 null,因为传入 0 个参数时,接收到的实际值是一个空数组而不是 null。

参数绑定

传参的问题
基本类型参数的传递,是调用方值的复制。双方各自的后续修改,互不影响。
引用类型的传递:将地址进行复制,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class Main {
public static void main(String[] args) {
Person p = new Person();
String[] fullname = new String[] { "Homer", "Simpson" };
p.setName(fullname); // 传入 fullname 数组
System.out.println(p.getName()); // "Homer Simpson"
fullname[0] = "Bart"; // fullname 数组的第一个元素修改为 "Bart"
System.out.println(p.getName()); // "Bart Simpson"
}
}

class Person {
private String[] name;

public String getName() {
return this.name[0] + " " + this.name[1];
}

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

Note: 因为传入的是字符串数组,因此将 fullname 数组的地址复制,传给了 p.name,因此 p.namefullname 指向的是同一个字符串数组,所以这两个同时变化,但是变化的时候还是遵循引用的原则:Homer 仍然存在,只是无法通过 fullname[0] 进行访问罢了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class Main {
public static void main(String[] args) {
Person p = new Person();
String bob = "Bob";
p.setName(bob); // 传入 bob 变量
System.out.println(p.getName()); // "Bob"
bob = "Alice"; // bob 改名为 Alice
System.out.println(p.getName()); // "Bob"
}
}

class Person {
private String name;

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

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

Note: 和上一个传入引用参数一样,复制的是地址,传入之后 bobp.name 指向了同一块内存,只不过 bob 改变之后会重新指向新的内存,所以这两个变量指向的内存就不一样了。

Note:字符串和数组都是引用类型。

构造方法

  1. 由于构造方法是如此特殊,所以构造方法的名称就是类名。构造方法的参数没有限制,在方法内部,也可以编写任意语句。但是,和普通方法相比,构造方法没有返回值(也没有 void),调用构造方法,必须用 new 操作符。(具体意义和 C++ 一样)
  2. 没有在构造方法中初始化字段时,引用类型的字段默认是 null,数值类型的字段用默认值,int 类型默认值是 0,布尔类型默认值是 false:
  3. 可以对字段直接进行初始化:
1
2
3
4
5
6
7
8
9
class Person {
private String name = "Unamed";
private int age = 10;

public Person(String name, int age) {
this.name = name;
this.age = age;
}
}

构造方法的代码由于后运行,最终由构造方法的代码确定,即便已经直接将字段初始化了。

方法重载

> 同 C++

继承

Student 类包含了 Person 类已有的字段和方法,只是多出了一个 score 字段和相应的 getScore()、setScore() 方法。
继承是面向对象编程中非常强大的一种机制,它首先可以复用代码。当我们让 Student 从 Person 继承时,Student 就获得了 Person 的所有功能,我们只需要为 Student 编写新增的功能。

实现

Java 使用 extends 关键字来实现继承:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Person {
private String name;
private int age;

public String getName() {...}
public void setName(String name) {...}
public int getAge() {...}
public void setAge(int age) {...}
}

class Student extends Person {
// 不要重复 name 和 age 字段 / 方法,
// 只需要定义新增 score 字段 / 方法:
private int score;

public int getScore() { …}
public void setScore(int score) { … }
}

通过继承,Student 只需要编写额外的功能,不再需要重复代码。

Note: 子类自动获得了父类的所有字段,严禁定义与父类重名的字段!

在 OOP 的术语中,我们把 Person 称为 超类(super class) 父类(parent class) 基类(base class),把 Student 称为 子类(subclass) 扩展类(extended class)

继承树

在 Java 中,没有明确写 extends 的类,编译器会自动加上 extends Object。所以,任何类,除了 Object,都会继承自某个类。
Java 只允许一个 class 继承自一个类,因此,一个类有且仅有一个父类。只有 Object 特殊,它没有父类。

例如:

protected

继承有个特点,就是子类无法访问父类的 private 字段或者 private 方法。
为了让子类可以访问父类的字段,我们需要把 private 改为 protected。用 protected 修饰的字段可以被子类访问: 因此,protected 关键字可以把字段和方法的访问权限控制在继承树内部,一个 protected 字段和方法可以被其子类,以及子类的子类所访问。

super

super 关键字表示父类(超类)。子类引用父类的字段时,可以用 super.fieldName。例如:

1
2
3
4
5
class Student extends Person {
public String hello() {
return "Hello," + super.name;
}
}

实际上,这里使用 super.name,或者 this.name,或者 name,效果都是一样的。编译器会自动定位到父类的 name 字段。
但是,在某些时候,就必须使用 super。我们来看一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class Main {
public static void main(String[] args) {
Student s = new Student("Xiao Ming", 12, 89);
}
}

class Person {
protected String name;
protected int age;

public Person(String name, int age) {
this.name = name;
this.age = age;
}
}

class Student extends Person {
protected int score;

public Student(String name, int age, int score) {
this.score = score;
}
}

运行上面的代码,会得到一个 编译错误,大意是在 Student 的构造方法中,无法调用 Person 的构造方法。

这是因为在 Java 中,任何 class 的构造方法,**第一行语句必须是调用父类的构造方法。如果没有明确地调用父类的构造方法,编译器会帮我们自动加一句 super();**,所以,Student 类的构造方法实际上是这样:

1
2
3
4
5
6
7
8
class Student extends Person {
protected int score;

public Student(String name, int age, int score) {
super(); // 自动调用父类的构造方法
this.score = score;
}
}

但是,Person 类并没有无参数的构造方法,因此,编译失败。

解决方法是调用 Person 类存在的某个构造方法。例如:

1
2
3
4
5
6
7
8
class Student extends Person {
protected int score;

public Student(String name, int age, int score) {
super(name, age); // 调用父类的构造方法 Person(String, int)
this.score = score;
}
}

如果父类没有默认的构造方法,子类就必须显式调用 super() 并给出参数以便让编译器定位到父类的一个合适的构造方法。

这里还顺带引出了另一个问题:即子类不会继承任何父类的构造方法。子类默认的构造方法是编译器自动生成的,不是继承的。

阻止继承

只要某个 class 没有 final 修饰符,那么任何类都可以从该 class 继承。
从 Java 15 开始,允许使用 sealed 修饰 class,并通过 permits 明确写出能够从该 class 继承的子类名称。

1
2
3
public sealed class Shape permits Rect, Circle, Triangle {
...
}

上述 Shape 类就是一个 sealed 类,它只允许指定的 3 个类 (Rect, Circle, Triangle) 继承它,否则就会报错。
这种 sealed 类主要用于一些框架,防止继承被滥用。sealed 类在 Java 15 中目前是预览状态,要启用它,必须使用参数 --enable-preview--source 15

向上转型:一个子类类型安全地变为父类类型的赋值

向上转型实际上是把一个子类型安全地变为更加抽象的父类型

1
2
3
4
Student s = new Student();
Person p = s; // upcasting, ok
Object o1 = p; // upcasting, ok
Object o2 = s; // upcasting, ok

p 也只能使用 Person 类中有的字段和方法,不能使用 Student 添加的字段和方法

向下转型:把一个父类类型强制转型为子类类型

不能把父类变为子类,因为子类功能比父类多,多的功能无法凭空变出来。

instanceof 实际上判断一个变量所指向的实例是否是指定类型,或者这个类型的子类。如果一个引用变量为 null,那么对任何 instanceof 的判断都为 false

1
2
3
4
5
Person p = new Student();
if (p instanceof Student) {
// 只有判断成功才会向下转型:
Student s = (Student) p; // 一定会成功
}

从 Java 14 开始,判断 instanceof 后,可以直接转型为指定变量,避免再次强制转型。

1
2
3
4
5
6
7
8
9
public class Main {
public static void main(String[] args) {
Object obj = "hello";
if (obj instanceof String s) {
// 可以直接使用变量 s:
System.out.println(s.toUpperCase());
}
}
}

等价于

1
2
3
4
5
Object obj = "hello";
if (obj instanceof String) {
String s = (String) obj;
System.out.println(s.toUpperCase());
}

组合和继承

1
2
3
4
5
class Book {
protected String name;
public String getName() {...}
public void setName(String name) {...}
}

这个 Book 类也有 name 字段,那么,我们能不能让 Student 继承自 Book 呢?不可以
从逻辑上讲,这是不合理的,Student 不应该从 Book 继承,而应该从 Person 继承。
究其原因,是因为 Student 是 Person 的一种,它们是 is 关系,而 Student 并不是 Book。实际上 Student 和 Book 的关系是 has 关系。
具有 has 关系不应该使用继承,而是使用组合,即 Student 可以持有一个 Book 实例:

1
2
3
4
class Student extends Person {
protected Book book;
protected int score;
}

继承是 is 关系,组合是 has 关系。

多态

补充

方法签名由方法名称和一个参数列表(方法的参数的顺序和类型)组成。

Note:方法签名不包括方法的返回类型。不包括返回值和访问修饰符。

覆写(重写、覆盖)

  1. 在继承关系中,子类如果定义了一个与父类方法签名完全相同的方法,被称为覆写(Override)。

  2. 重写(Override)和重载(Overload)
    Override 和 Overload 不同的是,如果方法签名如果不同,就是 Overload,Overload 方法是一个新方法;如果方法签名相同,并且返回值也相同,就是 Override

    重写: 前提是继承,两个方法的方法签名相同。至于修饰符,范围相同或者比父类的范围大即可。

    要是重写的话,也不是必须加 @override,写 @override,这个表示编辑器会给你去父类检查重写的对不对,不写就自己检查呗。

    重写是覆盖,就是子类的替换了父类的,正常用方法,调的是子类的,所以重写完还是一个方法
    重载是写多个同名方法,但可以给的参数不同,功能类似,所以正常调哪个方法,要看参数怎么写的,跟继承无关,区别是重载后变成多个方法。

  3. 注意

    1. 父类:如果是 private,那么子类就不存在重写,只是新建了个方法。但是要是 protected 的话,子类就可以重写。@override 是可以 OK 的。子类是 protected 或者 public,不可以是 private(经测试 private 类型是错误的,直接报错)。
    2. 在修饰符条件 OK 的情况下,发现如果可继承的要重写的方法要是返回类型不同,编辑器也是会报错的。说是重写的两个方法的返回类型冲突、不相容的,矛盾的返回类型。

引例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class Main {
public static void main(String[] args) {
Person p = new Student();
p.run(); // 打印 Student.run
}
}

class Person {
public void run() {
System.out.println("Person.run");
}
}

class Student extends Person {
@Override
public void run() {
System.out.println("Student.run");
}
}

Java 的实例方法调用是基于运行时的实际类型的动态调用,而非变量的声明类型。
这个非常重要的特性在面向对象编程中称之为多态。它的英文拼写非常复杂:Polymorphic。

多态定义

针对某个类型的方法调用,其真正执行的方法取决于运行时期实际类型的方法。

1
2
3
public void runTwice(Person p) {
p.run();
}

它传入的参数类型是 Person,我们是无法知道传入的参数实际类型究竟是 Person,还是 Student,还是 Person 的其他子类,因此,也无法确定调用的是不是 Person 类定义的 run() 方法。
所以,多态的特性就是,运行期才能动态决定调用的子类方法。对某个类型调用某个方法,执行的实际方法可能是某个子类的覆写方法。

多态的用处

我们要编写一个报税的财务软件,对于一个人的所有收入进行报税

1
2
3
4
5
6
7
8
9
10
public class Main {
public static void main(String[] args) {
// 给一个有普通收入、工资收入和享受国务院特殊津贴的小伙伴算税:
Income[] incomes = new Income[] {
new Income(3000),
new Salary(7500),
new StateCouncilSpecialAllowance(15000)
};
System.out.println(totalTax(incomes));
}

  public static double totalTax(Income... incomes) {
     double total = 0;
     for (Income income: incomes) {
           total = total + income.getTax();
     }
     return total;
  }

}

class Income {
protected double income;

  public Income(double income) {
     this.income = income;
  }

  public double getTax() {
     return income * 0.1; // 税率 10%
  }

}

class Salary extends Income {
public Salary(double income) {
super(income);
}

  @Override
  public double getTax() {
     if (income <= 5000) {
           return 0;
     }
     return (income - 5000) * 0.2;
  }

}

class StateCouncilSpecialAllowance extends Income {
public StateCouncilSpecialAllowance(double income) {
super(income);
}

  @Override
  public double getTax() {
     return 0;
  }

}

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

> 观察 totalTax() 方法:利用多态,totalTax() 方法只需要和 Income 打交道,它完全不需要知道 Salary 和 StateCouncilSpecialAllowance 的存在,就可以正确计算出总的税。如果我们要新增一种稿费收入,只需要从 Income 派生,然后正确覆写 getTax() 方法就可以。把新的类型传入 totalTax(),不需要修改任何代码。

> *** 可见,多态具有一个非常强大的功能,就是允许添加更多类型的子类实现功能扩展,却不需要修改基于父类的代码。***


## 调用 super
```java
class Person {
protected String name;
public String hello() {
return "Hello," + name;
}
}

Student extends Person {
@Override
public String hello() {
// 调用父类的 hello() 方法:
return super.hello() + "!";
}
}

final

  1. 继承可以允许子类覆写父类的方法。如果一个父类不允许子类对它的某个方法进行覆写,可以把该方法标记为 final。用 final 修饰的方法不能被 Override:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    class Person {
    protected String name;
    public final String hello() {
    return "Hello," + name;
    }
    }

    Student extends Person {
    // compile error: 不允许覆写
    @Override
    public String hello() {
    }
    }
  2. 如果一个类不希望任何其他类继承自它,那么可以把这个类本身标记为 final。用 final 修饰的类不能被继承:
    1
    2
    3
    4
    5
    6
    7
    final class Person {
    protected String name;
    }

    // compile error: 不允许继承自 Person
    Student extends Person {
    }
  3. 对于一个类的实例字段,同样可以用 final 修饰。用 final 修饰的字段在初始化后不能被修改。
    1
    2
    3
    class Person {
    public final String name = "Unamed";
    }
    对 final 字段重新赋值会报错:
    1
    2
    Person p = new Person();
    p.name = "New Name"; // compile error!
    这样也不可以:
    1
    2
    3
    4
    5
    6
    7
    public class Person {
    public final String name = "Unamed";

    public TestDemo1(String name) {
    this.name = name;
    }
    }
    最常用的方法:
    1
    2
    3
    4
    5
    6
    class Person {
    public final String name;
    public Person(String name) {
    this.name = name;
    }
    }

    因为可以保证实例一旦创建,其 final 字段就不可修改:

抽象类

引入

因为可以保证实例一旦创建,其 final 字段就不可修改:

1
2
3
class Person {
public void run() { …}
}

class Student extends Person {
@Override
public void run() { …}
}

class Teacher extends Person {
@Override
public void run() { …}
}

1
2
3
4
5
6
> 从 Person 类派生的 Student 和 Teacher 都可以覆写 run() 方法。
> 如果父类 Person 的 run() 方法没有实际意义,能否去掉方法的执行语句?
```java
class Person {
public void run(); // Compile Error!
}

答案是不行,会导致编译错误,因为定义方法的时候,必须实现方法的语句。
而且不可以去掉父类的 run() 方法,因为如果去掉了父类的 run() 方法,就会失去多态的特性

解决办法:如果父类的方法本身不需要实现任何功能,仅仅是为了定义方法签名,目的是让子类去覆写它,那么,可以把父类的方法声明为抽象方法:

1
2
3
class Person {
public abstract void run();
}

把一个方法声明为 abstract,表示它是一个抽象方法,本身没有实现任何方法语句。因为这个抽象方法本身是无法执行的,所以,Person 类也无法被实例化。编译器会告诉我们,无法编译 Person 类,因为它包含抽象方法,必须把 Person 类本身也声明为 abstract,才能正确编译它:
1
2
3
abstract class Person {
public abstract void run();
}

个人理解

1
2
3
abstract class Person {
public abstract void run();
}

等价于

1
2
3
class Person {
public void run() {}
}

区别在于:
前者不可以实例化,后者可以实例化

定义

如果一个 class 定义了方法,但没有具体执行代码,这个方法就是抽象方法,抽象方法用 abstract 修饰。
因为无法执行抽象方法,因此这个类也必须申明为抽象类(abstract class)。

使用 abstract 修饰的类就是抽象类。我们无法实例化一个抽象类:

1
Person p = new Person(); // 编译错误

抽象类的用途

因为抽象类本身被设计成只能用于被继承,因此,抽象类可以强迫子类实现其定义的抽象方法,否则编译会报错。因此,抽象方法实际上相当于定义了 “规范”。

例如,Person 类定义了抽象方法 run(),那么,在实现子类 Student 的时候,就必须覆写 run() 方法:

面向抽象编程

当我们定义了抽象类 Person,以及具体的 Student、Teacher 子类的时候,我们可以通过抽象类 Person 类型去引用具体的子类的实例:

1
2
Person s = new Student();
Person t = new Teacher();

这种引用抽象类的好处在于,我们对其进行方法调用,并不关心 Person 类型变量的具体子类型:
1
2
3
// 不关心 Person 变量的具体子类型:
s.run();
t.run();

同样的代码,如果引用的是一个新的子类,我们仍然不关心具体类型:
1
2
3
// 同样不关心新的子类是如何实现 run() 方法的:
Person e = new Employee();
e.run();

这种尽量引用 ** 高层类型 **(相当于父类),避免引用 ** 实际子类型 **(相当于子类)的方式,称之为面向抽象编程。

面向抽象编程的本质就是:

  • 上层代码只定义规范(例如:abstract class Person);
  • 不需要子类就可以实现业务逻辑(正常编译);
  • 具体的业务逻辑由不同的子类实现,调用者并不关心(个人理解:就是调用者只需要知道抽象类有什么字段和方法以及功能,不关心具体怎么实现的,可以直接调用,具体实现对调用者透明)。

小结

  • 通过 abstract 定义的方法是抽象方法,它只有定义,没有实现。抽象方法定义了子类必须实现的接口规范;
  • 定义了抽象方法的 class 必须被定义为抽象类,从抽象类继承的子类必须实现抽象方法;
  • 如果不实现抽象方法,则该子类仍是一个抽象类;
  • 面向抽象编程使得调用者只关心抽象方法的定义,不关心子类的具体实现。

接口

引入

在抽象类中,抽象方法本质上是定义接口规范:即规定高层类的接口,从而保证所有子类都有相同的接口实现,这样,多态就能发挥出威力。

如果一个抽象类没有字段,所有方法全部都是抽象方法:

1
2
3
4
abstract class Person {
public abstract void run();
public abstract String getName();
}

就可以把该抽象类改写为接口: interface
1
2
3
4
interface Person {
void run();
String getName();
}

定义

所谓 interface,就是比抽象类还要抽象的纯抽象接口,因为它连字段都不能有。因为接口定义的所有方法默认都是 public abstract 的,所以这两个修饰符不需要写出来(写不写效果都一样)。

当一个具体的 class 去实现一个 interface 时,需要使用 implements 关键字。举个例子:

1
2
class Student implements Person {
private String name;

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

  @Override
  public void run() {
     System.out.println(this.name + "run");
  }

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

}

1
2
3
4
5
> 在 Java 中,一个类只能继承自另一个类,不能从多个类继承。但是,一个类可以实现多个 interface,例如:
```java
class Student implements Person, Hello { // 实现了两个 interface
...
}

术语

Java 的接口特指 interface 的定义,表示一个接口类型和一组方法签名,而编程接口泛指接口规范,如方法签名,数据格式,网络协议等。

抽象类和接口的对比如下:

abstract class interface
继承 只能 extends 一个 class 可以 implements 多个 interface
字段 可以定义实例字段 不能定义实例字段
抽象方法 可以定义抽象方法 可以定义抽象方法
非抽象方法 可以定义非抽象方法 可以定义 default 方法

接口继承

一个 interface 可以继承自另一个 interface。interface 继承自 interface 使用 extends,它相当于扩展了接口的方法。例如:

1
2
3
4
5
6
7
8
9
10
interface Hello {
void hello();
}


interface Person extends Hello {
void run();
String getName();
}

此时,Person 接口继承自 Hello 接口,因此,Person 接口现在实际上有 3 个抽象方法签名,其中一个来自继承的 Hello 接口。

继承关系

合理设计 interface 和 abstract class 的继承关系,可以充分复用代码。一般来说,公共逻辑适合放在 abstract class 中,具体逻辑放到各个子类,而接口层次代表抽象程度。可以参考 Java 的集合类定义的一组接口、抽象类以及具体子类的继承关系:

在使用的时候,实例化的对象永远只能是某个具体的子类,但总是通过接口去引用它,因为接口比抽象类更抽象:
1
2
3
List list = new ArrayList(); // 用 List 接口引用具体子类的实例
Collection coll = list; // 向上转型为 Collection 接口
Iterable it = coll; // 向上转型为 Iterable 接口

default 方法

在接口中,可以定义 default 方法。例如,把 Person 接口的 run() 方法改为 default 方法:

1
2
3
4
5
6
public class Main {
public static void main(String[] args) {
Person p = new Student("Xiao Ming");
p.run();
}
}

interface Person {
String getName();
default void run() {
System.out.println(getName() + “run”);
}
}

class Student implements Person {
private String name;

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

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

}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
   > 实现类可以不必覆写 default 方法。default 方法的目的是,当我们需要给接口新增一个方法时,会涉及到修改全部子类。如果新增的是 default 方法,那么子类就不必全部修改,只需要在需要覆写的地方去覆写新增方法。
> default 方法和抽象类的普通方法是有所不同的。因为 interface 没有字段,default 方法无法访问字段,而抽象类的普通方法可以访问实例字段。

## 小结
- Java 的接口(interface)定义了纯抽象规范,一个类可以实现多个接口;
- 接口也是数据类型,适用于向上转型和向下转型;
- 接口的所有方法都是抽象方法,接口不能定义实例字段;
- 接口可以定义 default 方法(JDK>=1.8)。


# 静态字段和静态方法

## 静态字段
> 在一个 class 中定义的字段,我们称之为实例字段。实例字段的特点是,每个实例都有独立的字段,各个实例的同名字段互不影响。

> 还有一种字段,是用 static 修饰的字段,称为静态字段:static field。
> 实例字段在每个实例中都有自己的一个独立 “空间”,但是静态字段只有一个共享 “空间”,所有实例都会共享该字段。举个例子:
```java
class Person {
public String name;
public int age;
// 定义静态字段 number:
public static int number;
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class Main {
public static void main(String[] args) {
Person ming = new Person("Xiao Ming", 12);
Person hong = new Person("Xiao Hong", 15);
ming.number = 88;
System.out.println(hong.number);
hong.number = 99;
System.out.println(ming.number);
}
}

class Person {
public String name;
public int age;

public static int number;

public Person(String name, int age) {
this.name = name;
this.age = age;
}
}

输出

1
2
88
99

对于静态字段,无论修改哪个实例的静态字段,效果都是一样的:所有实例的静态字段都被修改了,原因是静态字段并不属于实例:

虽然实例可以访问静态字段,但是它们指向的其实都是 Person class 的静态字段。所以,所有实例共享一个静态字段。
因此,不推荐用 实例变量. 静态字段 去访问静态字段,因为在 Java 程序中,实例对象并没有静态字段。在代码中,实例对象能访问静态字段只是因为编译器可以根据实例类型自动转换为类名. 静态字段来访问静态对象。

推荐用类名来访问静态字段。可以把静态字段理解为描述 class 本身的字段(非实例字段)。对于上面的代码,更好的写法是:

1
2
Person.number = 99;
System.out.println(Person.number);

静态方法

有静态字段,就有静态方法。用 static 修饰的方法称为静态方法。(废话)
调用实例方法必须通过一个实例变量,而调用静态方法则不需要实例变量,通过类名就可以调用。静态方法类似其它编程语言的函数。例如:

1
2
3
4
5
6
public class Main {
public static void main(String[] args) {
Person.setNumber(99);
System.out.println(Person.number);
}
}

class Person {
public static int number;

  public static void setNumber(int value) {
     number = value;
  }

}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
   > 因为静态方法属于 class 而不属于实例,因此,静态方法内部,无法访问 this 变量,也无法访问实例字段,它只能访问静态字段。
> 通过实例变量也可以调用静态方法,但这只是编译器自动帮我们把实例改写成类名而已。
> 通常情况下,通过实例变量访问静态字段和静态方法,会得到一个编译警告。

> 静态方法经常用于工具类,例如:
- `Arrays.sort()`
- `Math.random()`
> 静态方法也经常用于辅助方法。注意到 Java 程序的入口 main() 也是静态方法。

## 接口的静态字段
> 因为 interface 是一个纯抽象类,所以它不能定义实例字段。但是,interface 是可以有静态字段的,并且静态字段必须为 final 类型:
```java
public interface Person {
public static final int MALE = 1;
public static final int FEMALE = 2;
}

实际上,因为 interface 的字段只能是 public static final 类型,所以我们可以把这些修饰符都去掉,上述代码可以简写为:

1
2
3
4
5
public interface Person {
// 编译器会自动加上 public statc final:
int MALE = 1;
int FEMALE = 2;
}

编译器会自动把该字段变为 public static final 类型。

小结

  • 静态字段属于所有实例 “共享” 的字段,实际上是属于 class 的字段;
  • 调用静态方法不需要实例,无法访问 this,但可以访问静态字段和其他静态方法;
  • 静态方法常用于工具类和辅助方法。

定义

在前面的代码中,我们把类和接口命名为 PersonStudentHello 等简单名字。

在现实中,如果小明写了一个 Person 类,小红也写了一个 Person 类,现在,小白既想用小明的 Person,也想用小红的 Person,怎么办?

如果小军写了一个 Arrays 类,恰好 JDK 也自带了一个 Arrays 类,如何解决类名冲突?

在 Java 中,我们使用 package 来解决名字冲突。

Java 定义了一种名字空间,称之为包:package。一个类总是属于某个包,类名(比如 Person)只是一个简写,真正的完整类名是包名. 类名。

例如:

小明的 Person 类存放在包 ming 下面,因此,完整类名是 ming.Person

小红的 Person 类存放在包 hong 下面,因此,完整类名是 hong.Person

小军的 Arrays 类存放在包 mr.jun 下面,因此,完整类名是 mr.jun.Arrays

JDK 的 Arrays 类存放在包 java.util 下面,因此,完整类名是 java.util.Arrays

在定义 class 的时候,我们需要在第一行声明这个 class 属于哪个包。

小明的 Person.java 文件

1
package ming; // 申明包名 ming

public class Person {

}

1
2
3
4
5
6
7
8

> 小军的 `Arrays.java` 文件
```java
package mr.jun; // 申明包名 mr.jun

public class Arrays {

}

在 Java 虚拟机执行的时候,JVM 只看完整类名,因此,只要包名不同,类就不同。

包可以是多层结构,用 . 隔开。例如:java.util

要特别注意:包没有父子关系。java.utiljava.util.zip 是不同的包,两者没有任何继承关系。

没有定义包名的 class,它使用的是默认包,非常容易引起名字冲突,因此,不推荐不写包名的做法。

我们还需要按照包结构把上面的 Java 文件组织起来。假设以 package_sample 作为根目录,src 作为源码目录,那么所有文件结构就是:

1
2
3
4
5
6
7
8
9
package_sample
└─ src
├─ hong
│ └─ Person.java
│ ming
│ └─ Person.java
└─ mr
└─ jun
└─ Arrays.java

即所有 Java 文件对应的目录层次要和包的层次一致。

编译后的 .class 文件也需要按照包结构存放。如果使用 IDE,把编译后的 .class 文件放到 bin 目录下,那么,编译的文件结构就是:

1
2
3
4
5
6
7
8
9
package_sample
└─ bin
├─ hong
│ └─ Person.class
│ ming
│ └─ Person.class
└─ mr
└─ jun
└─ Arrays.class

编译的命令相对比较复杂,我们需要在 src 目录下执行 javac 命令:

1
javac -d ../bin ming/Person.java hong/Person.java mr/jun/Arrays.java

在 IDE 中,会自动根据包结构编译所有 Java 源码,所以不必担心使用命令行编译的复杂命令。

包作用域

位于同一个包的类,可以访问包作用域的字段和方法。不用 public、protected、private 修饰的字段和方法就是包作用域。例如,Person 类定义在 hello 包下面:

1
package hello;

public class Person {
// 包作用域:
void hello() {
System.out.println(“Hello!”);
}
}

1
2
3
4
5
6
7
8
9
10
> Main 类也定义在 hello 包下面:
```java
package hello;

public class Main {
public static void main(String[] args) {
Person p = new Person();
p.hello(); // 可以调用,因为 Main 和 Person 在同一个包
}
}

import

在一个 class 中,我们总会引用其他的 class。例如,小明的 ming.Person 类,如果要引用小军的 mr.jun.Arrays 类,他有三种写法:

第一种,直接写出完整类名,例如:

1
2
// Person.java
package ming;

public class Person {
public void run() {
mr.jun.Arrays arrays = new mr.jun.Arrays();
}
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
> 很显然,每次写完整类名比较痛苦。
> 因此,第二种写法是用 import 语句,导入小军的 Arrays,然后写简单类名:
```java
// Person.java
package ming;

// 导入完整类名:
import mr.jun.Arrays;

public class Person {
public void run() {
Arrays arrays = new Arrays();
}
}

在写 import 的时候,可以使用 *,表示把这个包下面的所有 class 都导入进来(但不包括子包的 class):

1
2
// Person.java
package ming;

// 导入 mr.jun 包的所有 class:
import mr.jun.*;

public class Person {
public void run() {
Arrays arrays = new Arrays();
}
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
> 我们一般不推荐这种写法,因为在导入了多个包后,很难看出 Arrays 类属于哪个包。

> 还有一种 import static 的语法,它可以导入可以导入一个类的静态字段和静态方法:
```java
package main;

// 导入 System 类的所有静态字段和静态方法:
import static java.lang.System.*;

public class Main {
public static void main(String[] args) {
// 相当于调用 System.out.println(…)
out.println("Hello, world!");
}
}

import static 很少使用。

Java 编译器最终编译出的 .class 文件只使用完整类名,因此,在代码中,当编译器遇到一个 class 名称时:

  • 如果是完整类名,就直接根据完整类名查找这个 class;
  • 如果是简单类名,按下面的顺序依次查找:
    • 查找当前 package 是否存在这个 class;
    • 查找 import 的包是否包含这个 class;
    • 查找 java.lang 包是否包含这个 class。

如果按照上面的规则还无法确定类名,则编译报错。

编写 class 的时候,编译器会自动帮我们做两个 import 动作:

  • 默认自动 import 当前 package 的其他 class;
  • 默认自动 import java.lang.*

** 注意 **:

  • 自动导入的是 java.lang 包,但类似 java.lang.reflect 这些包仍需要手动导入。
  • 如果有两个 class 名称相同,例如,mr.jun.Arrays 和 java.util.Arrays,那么只能 import 其中一个,另一个必须写完整类名。

最佳实践

为了避免名字冲突,我们需要确定唯一的包名。推荐的做法是使用倒置的域名来确保唯一性。例如:

  • org.apache
  • org.apache.commons.log
  • com.liaoxuefeng.sample

    子包就可以根据功能自行命名。

要注意不要和 java.lang 包的类重名,即自己的类不要使用这些名字:

  • String
  • System
  • Runtime
  • ...

要注意也不要和 JDK 常用类重名:

  • java.util.List
  • java.text.Format
  • java.math.BigInteger
  • ...

小结

  • Java 内建的 package 机制是为了避免 class 命名冲突;
  • JDK 的核心类使用 java.lang 包,编译器会自动导入;
  • JDK 的其它常用类定义在 java.util.*,java.math.*,java.text.*,……;
  • 包名推荐使用倒置的域名,例如 org.apache。

作用域

public、protected、private 这些修饰符。在 Java 中,这些修饰符可以用来限定访问作用域。

public

定义为 public 的 class、interface 可以被其他任何类访问:

1
package abc;

public class Hello {
public void hi() {
}
}

1
2
3
4
5
6
7
8
9
10
11
> 上面的 Hello 是 public,因此,可以被其他包的类访问:

```java
package xyz;

class Main {
void foo() {
// Main 可以访问 Hello
Hello h = new Hello();
}
}


定义为 public 的 field、method 可以被其他类访问,前提是首先有访问 class 的权限:

1
package abc;

public class Hello {
public void hi() {
}
}

1
2
3
4
5
6
7
8
9
10
> 上面的 hi() 方法是 public,可以被其他类调用,前提是首先要能访问 Hello 类:
```java
package xyz;

class Main {
void foo() {
Hello h = new Hello();
h.hi();
}
}

private

定义为 private 的 field、method 无法被其他类访问:

1
package abc;

public class Hello {
// 不能被其他类调用:
private void hi() {
}

  public void hello() {
     this.hi();
  }

}

1
2
3
4
5
6
7
8
9
10
11
12
> ** 实际上,确切地说,private 访问权限被限定在 class 的内部,而且与方法声明顺序无关。推荐把 private 方法放到后面,因为 public 方法定义了类对外提供的功能,阅读代码的时候,应该先关注 public 方法:**
```java
package abc;

public class Hello {
public void hello() {
this.hi();
}

private void hi() {
}
}

由于 Java 支持嵌套类,如果一个类内部还定义了嵌套类,那么,嵌套类拥有访问 private 的权限:

1
2
3
4
5
public class Main {
public static void main(String[] args) {
Inner i = new Inner();
i.hi();
}

  // private 方法:
  private static void hello() {
     System.out.println("private hello!");
  }

  // 静态内部类:
  static class Inner {
     public void hi() {
           Main.hello();
     }
  }

}

1
2
3
4
5
6
7
8
9
10
11
12
   > 定义在一个 `class` 内部的 `class` 称为嵌套类(nested class),Java 支持好几种嵌套类。经过自己测试: `static` 不能没有

## protected
> `protected` 作用于继承关系。定义为 `protected` 的字段和方法可以被子类访问,以及子类的子类:
```java
package abc;

public class Hello {
// protected 方法:
protected void hi() {
}
}

上面的 protected 方法可以被继承的类访问:

1
package xyz;

class Main extends Hello {
void foo() {
// 可以访问 protected 方法:
hi();
}
}

1
2
3
4
5
6
7
8
9
10
11

## package
> 包作用域是指一个类允许访问同一个 `package` 的没有 `public`、`private` 修饰的 `class`,以及没有 `public`、`protected`、`private` 修饰的字段和方法。但是不允许包之外的类访问。
```java
package abc;
// package 权限的类:
class Hello {
// package 权限的方法:
void hi() {
}
}

只要在同一个包,就可以访问 package 权限的 class、field 和 method:

1
package abc;

class Main {
void foo() {
// 可以访问 package 权限的类:
Hello h = new Hello();
// 可以调用 package 权限的方法:
h.hi();
}
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
   > 注意,包名必须完全一致,包没有父子关系,`com.apache` 和 `com.apache.abc` 是不同的包

## 局部变量
> 在方法内部定义的变量称为局部变量,局部变量作用域从变量声明处开始到对应的块结束。方法参数也是局部变量。
```java
package abc;

public class Hello {
void hi(String name) { // ①
String s = name.toLowerCase(); // ②
int len = s.length(); // ③
if (len < 10) { // ④
int p = 10 - len; // ⑤
for (int i=0; i<10; i++) { // ⑥
System.out.println(); // ⑦
} // ⑧
} // ⑨
} // ⑩
}

我们观察上面的 hi() 方法代码:

  • 方法参数 name 是局部变量,它的作用域是整个方法,即 ①~⑩
  • 变量 s 的作用域是定义处到方法结束,即 ②~⑩
  • 变量 len 的作用域是定义处到方法结束,即 ③~⑩
  • 变量 p 的作用域是定义处到 if 块结束,即 ⑤~⑨
  • 变量 i 的作用域是 for 循环,即 ⑥~⑧

final

final 与访问权限不冲突,它有很多作用。

  1. final 修饰 class 可以阻止被继承:
    1
    package abc;

// 无法被继承:
public final class Hello {
private int n = 0;
protected void hi(int t) {
long i = t;
}
}

1
2
3
4
5
6
7
8
9
10

> 2. 用 `final` 修饰 `method` 可以阻止被子类覆写:
```java
package abc;

public class Hello {
// 无法被覆写:
protected final void hi() {
}
}

  1. final 修饰 field 可以阻止被重新赋值:
    1
    package abc;

public class Hello {
private final int n = 0;
protected void hi() {
this.n = 1; // error!
}
}

1
2
3
4
5
6
7
8
9
10

> 4. 用 final 修饰局部变量可以阻止被重新赋值:
```java
package abc;

public class Hello {
protected void hi(final int t) {
t = 1; // error!
}
}

最佳实践

  • 如果不确定是否需要 public,就不声明为 public,即尽可能少地暴露对外的字段和方法。
  • 把方法定义为 package 权限有助于测试,因为测试类和被测试类只要位于同一个 package,测试代码就可以访问被测试类的 package 权限方法。
  • 一个. java 文件只能包含一个 public 类,但可以包含多个非 public 类。如果有 public 类,文件名必须和 public 类的名字相同。

小结

  • Java 内建的访问权限包括 publicprotectedprivatepackage 权限;

  • Java 在方法内部定义的变量是局部变量,局部变量的作用域从变量声明开始,到一个块结束;

  • final 修饰符不是访问权限,它可以修饰 classfieldmethod

  • 一个 .java 文件只能包含一个 public 类,但可以包含多个非 public 类。

  • 关于修饰符的问题:

    • public:包内、包外(需要 import)都可以访问
    • private:只有类的内部才可以访问
    • protected:只有类、子类、子类的子类可以访问
    • 没有修饰符:只有包的内部能访问,包外不可以访问

内部类

通常情况下,我们把不同的类组织在不同的包下面,对于一个包下面的类来说,它们是在同一层次,没有父子关系:

1
2
3
4
5
java.lang
├── Math
├── Runnable
├── String
└── ...

还有一种类,它被定义在另一个类的内部,所以称为内部类(Nested Class)。Java 的内部类分为好几种,通常情况用得不多,但也需要了解它们是如何使用的。

Inner Class

如果一个类定义在另一个类的内部,这个类就是 Inner Class

1
2
3
4
5
class Outer {
class Inner {
// 定义了一个 Inner Class
}
}

上述定义的 Outer 是一个普通类,而 Inner 是一个 Inner Class,它与普通类有个最大的不同,就是 Inner Class 的实例不能单独存在,必须依附于一个 Outer Class 的实例。示例代码如下:
1
2
3
4
5
6
7
8
public class Main {
public static void main(String[] args) {
Outer outer = new Outer("Nested"); // 实例化一个 Outer
Outer.Inner inner = outer.new Inner(); // 实例化一个 Inner
inner.hello();
System.out.println(outer.age);
}
}

class Outer {
private String name;
public int age;

  Outer(String name) {
     this.name = name;
  }

  class Inner {
     void hello() {
           System.out.println("Hello," + Outer.this.name);
           Outer.this.age =100; // 或者 age = 10;
     }
  }

}

1
2
3
> 观察上述代码,要实例化一个 Inner,我们必须首先创建一个 Outer 的实例,然后,调用 Outer 实例的 new 来创建 Inner 实例:
```java
Outer.Inner inner = outer.new Inner();

这是因为 Inner Class 除了有一个 this 指向它自己,还隐含地持有一个 Outer Class 实例,可以用 Outer.this 访问这个实例。所以,实例化一个 Inner Class 不能脱离 Outer 实例。
Inner Class 和普通 Class 相比,除了能引用 Outer 实例外,还有一个额外的 “特权”,就是可以修改 Outer Class 的 private 字段,因为 Inner Class 的作用域在 Outer Class 内部,所以能访问 Outer Class 的 private 字段和方法。

观察 Java 编译器编译后的. class 文件可以发现,Outer 类被编译为 Outer.class,而 Inner 类被编译为 Outer$Inner.class。

Anonymous Class (?)

还有一种定义 Inner Class 的方法,它不需要在 Outer Class 中明确地定义这个 Class,而是在方法内部,通过匿名类(Anonymous Class)来定义。示例代码如下:

1
2
3
4
5
6
public class Main {
public static void main(String[] args) {
Outer outer = new Outer("Nested");
outer.asyncHello();
}
}

class Outer {
private String name;

  Outer(String name) {
     this.name = name;
  }

  void asyncHello() {
     Runnable r = new Runnable() {
           @Override
           public void run() {
              System.out.println("Hello," + Outer.this.name);
           }
     };
     new Thread(r).start();
  }

}

1
2
3
4
5
> 观察 `asyncHello()` 方法,我们在方法内部实例化了一个 `Runnable`。`Runnable` 本身是接口,接口是不能实例化的,所以这里实际上是定义了一个实现了 `Runnable` 接口的匿名类,并且通过 new 实例化该匿名类,然后转型为 `Runnable`。在定义匿名类的时候就必须实例化它,定义匿名类的写法如下:
```java
Runnable r = new Runnable() {
// 实现必要的抽象方法...
};

匿名类和 Inner Class 一样,可以访问 Outer Class 的 private 字段和方法。之所以我们要定义匿名类,是因为在这里我们通常不关心类名,比直接定义 Inner Class 可以少写很多代码。
观察 Java 编译器编译后的. class 文件可以发现,Outer 类被编译为 Outer.class,而匿名类被编译为 Outer1.class。如果有多个匿名类,Java 编译器会将每个匿名类依次命名为 Outer1、Outer2、Outer3……

除了接口外,匿名类也完全可以继承自普通类。观察以下代码:

1
import java.util.HashMap;

public class Main {
public static void main(String[] args) {
HashMap<String, String> map1 = new HashMap<>();
HashMap<String, String> map2 = new HashMap<>() {}; // 匿名类!
HashMap<String, String> map3 = new HashMap<>() {
{
put(“A”, “1”);
put(“B”, “2”);
}
};
System.out.println(map3.get(“A”));
}
}

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
   > map1 是一个普通的 HashMap 实例,但 map2 是一个匿名类实例,只是该匿名类继承自 HashMap。map3 也是一个继承自 HashMap 的匿名类实例,并且添加了 static 代码块来初始化数据。观察编译输出可发现 Main$1.class 和 Main$2.class 两个匿名类文件。

## Static Nested Class
> 最后一种内部类和 Inner Class 类似,但是使用 static 修饰,称为静态内部类(Static Nested Class):
```java
public class Main {
public static void main(String[] args) {
Outer.StaticNested sn = new Outer.StaticNested();
sn.hello();
}
}

class Outer {
private static String NAME = "OUTER";

private String name;

Outer(String name) {
this.name = name;
}

static class StaticNested {
void hello() {
System.out.println("Hello," + Outer.NAME);
}
}
}

用 static 修饰的内部类和 Inner Class 有很大的不同,它不再依附于 Outer 的实例,而是一个完全独立的类,因此无法引用 Outer.this,但它可以访问 Outer 的 private 静态字段和静态方法。如果把 StaticNested 移到 Outer 之外,就失去了访问 private 的权限。

但是在实例化 静态内部类 的时候,也会实例化一个 Outer

小结

Java 的内部类可分为 Inner Class、Anonymous Class 和 Static Nested Class 三种:

  • Inner Class 和 Anonymous Class 本质上是相同的,都必须依附于 Outer Class 的实例,即隐含地持有 Outer.this 实例,并拥有 Outer Class 的 private 访问权限;
  • Static Nested Class 是独立类,但拥有 Outer Class 的 private 访问权限。

补充

内部类就是在一个类的内部再定义一个类。比如:A类中定义一个B类,那么B类相对于A类来说就称为内部类,而A类相对于B类来说就是外部类了。

classpath 和 jar

classpath

在 Java 中,我们经常听到 classpath 这个东西。网上有很多关于 “如何设置 classpath” 的文章,但大部分设置都不靠谱。到底什么是 classpath
classpathJVM 用到的一个环境变量,它用来指示 JVM 如何搜索 class

因为 Java 是编译型语言,源码文件是 .java,而编译后的 .class 文件才是真正可以被 JVM 执行的字节码。因此,JVM 需要知道,如果要加载一个 abc.xyz.Hello 的类,应该去哪搜索对应的 Hello.class 文件。
所以,classpath 就是一组目录的集合,它设置的搜索路径与操作系统相关。例如,在 Windows 系统上,用 ; 分隔,带空格的目录用 "" 括起来,可能长这样:

1
C:\work\project1\bin;C:\shared;"D:\My Documents\project1\bin"

在 Linux 系统上,用: 分隔,可能长这样:
1
/usr/shared:/usr/local/bin:/home/liaoxuefeng/bin

现在我们假设 classpath.;C:\work\project1\bin;C:\shared,当 JVM 在加载 abc.xyz.Hello 这个类时,会依次查找:

  • <当前目录>\abc\xyz\Hello.class
  • C:\work\project1\bin\abc\xyz\Hello.class
  • C:\shared\abc\xyz\Hello.class

    注意到 . 代表当前目录。如果 JVM 在某个路径下找到了对应的 class 文件,就不再往后继续搜索。如果所有路径下都没有找到,就报错。

classpath 的设定方法有两种:

  • 在系统环境变量中设置 classpath 环境变量,不推荐;
  • 在启动 JVM 时设置 classpath 变量,推荐。

我们强烈不推荐在系统环境变量中设置 classpath,那样会污染整个系统环境。在启动 JVM 时设置 classpath 才是推荐的做法。实际上就是给 java 命令传入 -classpath-cp 参数:

1
java -classpath .;C:\work\project1\bin;C:\shared abc.xyz.Hello

或者使用 -cp 的简写:
1
java -cp .;C:\work\project1\bin;C:\shared abc.xyz.Hello

没有设置系统环境变量,也没有传入 -cp 参数,那么 JVM 默认的 classpath 为.,即当前目录:
1
java abc.xyz.Hello

上述命令告诉 JVM 只在当前目录搜索 Hello.class

在 IDE 中运行 Java 程序,IDE 自动传入的 - cp 参数是当前工程的 bin 目录和引入的 jar 包。
通常,我们在自己编写的 class 中,会引用 Java 核心库的 class,例如,StringArrayList 等。这些 class 应该上哪去找?
有很多 “如何设置 classpath” 的文章会告诉你把 JVM 自带的 rt.jar 放入 classpath,但事实上,根本不需要告诉 JVM 如何去 Java 核心库查找 class,JVM 怎么可能笨到连自己的核心库在哪都不知道?

Note: 不要把任何 Java 核心库添加到 classpath 中!JVM 根本不依赖 classpath 加载核心库!

更好的做法是,不要设置 classpath!默认的当前目录. 对于绝大多数情况都够用了。
假设我们有一个编译后的 Hello.class,它的包名是 com.example,当前目录是 C:\work,那么,目录结构必须如下:

1
2
3
4
C:\work
└─ com
└─ example
└─ Hello.class

运行这个 Hello.class 必须在当前目录下使用如下命令:
1
C:\work> java -cp . com.example.Hello

JVM 根据 classpath 设置的. 在当前目录下查找 com.example.Hello,即实际搜索文件必须位于 com/example/Hello.class。如果指定的. class 文件不存在,或者目录结构和包名对不上,均会报错。

jar 包

如果有很多 .class 文件,散落在各层目录中,肯定不便于管理。如果能把目录打一个包,变成一个文件,就方便多了。
jar 包就是用来干这个事的,它可以把 package 组织的目录层级,以及各个目录下的所有文件(包括. class 文件和其他文件)都打成一个 jar 文件,这样一来,无论是备份,还是发给客户,就简单多了。
jar 包实际上就是一个 zip 格式的压缩文件,而 jar 包相当于目录。如果我们要执行一个 jar 包的 class,就可以把 jar 包放到 classpath 中:

1
java -cp ./hello.jar abc.xyz.Hello

这样 JVM 会自动在 hello.jar 文件里去搜索某个类。

那么问题来了:如何创建 jar 包?
因为 jar 包就是 zip 包,所以,直接在资源管理器中,找到正确的目录,点击右键,在弹出的快捷菜单中选择 “发送到”,“压缩 (zipped) 文件夹”,就制作了一个 zip 文件。然后,把后缀从 .zip 改为 .jar,一个 jar 包就创建成功。

假设编译输出的目录结构是这样:

1
2
3
4
5
6
7
8
9
package_sample
└─ bin
├─ hong
│ └─ Person.class
│ ming
│ └─ Person.class
└─ mr
└─ jun
└─ Arrays.class

这里需要特别注意的是,jar 包里的第一层目录,不能是 bin,而应该是 hongmingmr。如果在 Windows 的资源管理器中看,应该长这样:
1
2
3
4
5
6
7
8
9
10
package_sample
└─ bin
└─ hello.zip
├─ hong
│ └─ Person.class
│ ming
│ └─ Person.class
└─ mr
└─ jun
└─ Arrays.class

如果长这样:
1
2
3
4
5
6
7
8
9
10
11
package_sample
└─ bin
└─ hello.zip
└─ bin
├─ hong
│ └─ Person.class
│ ming
│ └─ Person.class
└─ mr
└─ jun
└─ Arrays.class

说明打包打得有问题,JVM 仍然无法从 jar 包中查找正确的 class,原因是 hong.Person 必须按 hong/Person.class 存放,而不是 bin/hong/Person.class

jar 包还可以包含一个特殊的 /META-INF/MANIFEST.MF 文件,MANIFEST.MF 是纯文本,可以指定 Main-Class 和其它信息。JVM 会自动读取这个 MANIFEST.MF 文件,如果存在 Main-Class,我们就不必在命令行指定启动的类名,而是用更方便的命令:

1
java -jar hello.jar

jar 包还可以包含其它 jar 包,这个时候,就需要在 MANIFEST.MF 文件里配置 classpath 了。
在大型项目中,不可能手动编写 MANIFEST.MF 文件,再手动创建 zip 包。Java 社区提供了大量的开源构建工具,例如 Maven,可以非常方便地创建 jar 包。

小结

  • JVM 通过环境变量 classpath 决定搜索 class 的路径和顺序;
  • 不推荐设置系统环境变量 classpath,始终建议通过 - cp 命令传入;
  • jar 包相当于目录,可以包含很多 .class 文件,方便下载和使用;
  • MANIFEST.MF 文件可以提供 jar 包的信息,如 Main-Class,这样可以直接运行 jar 包。

补充

  • idea 软件中 .class 在根目录的 out -> production 文件下
  • 个人理解 jar 包 : 就是将目录下的类打包,供其他人使用的,可以直接调用的压缩包文件

示例

我的目录

1
2
3
4
5
6
7
8
9
IdeaTestProjects
└─class2
└─unit10
├─test1
│ └─Main.class
│ └─Person.class

└─test2
└─TestMain.class

将文件夹 IdeaTestProjects 打包 zip 文件,再将后缀名改为 jar, 然后运行如下命令
1
java -cp IdeaTestProjects.jar class2.unit10.test1.Main

输出结果为:
1
2
3
4
name: Qeuro
age: 23
name: qeuro
age: 23

模块

一个 class 类是一个 java 文件,后缀名为 .java,编译之后生成 .class 文件,而这个 .class 文件才真正可以被 JVM 执行的字节码。

定义

从 Java 9 开始,JDK 又引入了模块(Module)。
什么是模块?这要从 Java 9 之前的版本说起。
我们知道,.class 文件是 JVM 看到的最小可执行文件,而一个大型程序需要编写很多 Class,并生成一堆 .class 文件,很不便于管理,所以,jar 文件就是 .class 文件的容器。
Java 9 之前,一个大型 Java 程序会生成自己的 jar 文件,同时引用依赖的第三方 jar 文件,而 JVM 自带的 Java 标准库,实际上也是以 jar 文件形式存放的,这个文件叫 rt.jar,一共有 60 多 M。
如果是自己开发的程序,除了一个自己的 app.jar 以外,还需要一堆第三方的 jar 包,运行一个 Java 程序,一般来说,命令行写这样:

1
java -cp app.jar:a.jar:b.jar:c.jar com.liaoxuefeng.sample.Main

Note: JVM 自带的标准库 rt.jar 不要写到 classpath 中,写了反而会干扰 JVM 的正常运行。

如果漏写了某个运行时需要用到的 jar,那么在运行期极有可能抛出 ClassNotFoundException
所以,jar 只是用于存放 class 的容器,它并不关心 class 之间的依赖。

从 Java 9 开始引入的模块,主要是为了解决 “依赖” 这个问题。如果 a.jar 必须依赖另一个 b.jar 才能运行,那我们应该给 a.jar 加点说明啥的,让程序在编译和运行的时候能自动定位到 b.jar,这种自带 “依赖关系” 的 class 容器就是模块。
为了表明 Java 模块化的决心,从 Java 9 开始,原有的 Java 标准库已经由一个单一巨大的 rt.jar 分拆成了几十个模块,这些模块以 .jmod 扩展名标识,可以在 $JAVA_HOME/jmods 目录下找到它们:
- java.base.jmod
- java.compiler.jmod
- java.datatransfer.jmod
- java.desktop.jmod
- ...

这些 .jmod 文件每一个都是一个模块,模块名就是文件名。例如:模块 java.base 对应的文件就是 java.base.jmod。模块之间的依赖关系已经被写入到模块内的 module-info.class 文件了。所有的模块都直接或间接地依赖 java.base 模块,只有 java.base 模块不依赖任何模块,它可以被看作是 “根模块”,好比所有的类都是从 Object 直接或间接继承而来。
把一堆 class 封装为 jar 仅仅是一个打包的过程,而把一堆 class 封装为模块则不但需要打包,还需要写入依赖关系,并且还可以包含二进制代码(通常是 JNI 扩展)。此外,模块支持多版本,即在同一个模块中可以为不同的 JVM 提供不同的版本。

编写模块

如何编写模块呢?还是以具体的例子来说。首先,创建模块和原有的创建 Java 项目是完全一样的,以 oop-module 工程为例,它的目录结构如下:

1
2
3
4
5
6
7
8
9
10
oop-module
├── bin
├── build.sh
└── src
├── com
│ └── itranswarp
│ └── sample
│ ├── Greeting.java
│ └── Main.java
└── module-info.java

其中,bin 目录存放编译后的 .class 文件,src 目录存放源码,按包名的目录结构存放,仅仅在 src 目录下多了一个 module-info.java 这个文件,这就是模块的描述文件。在这个模块中,它长这样:
1
2
3
4
module hello.world {
requires java.base; // 可不写,任何模块都会自动引入 java.base
requires java.xml;
}

其中,module 是关键字,后面的 hello.world 是模块的名称,它的命名规范与包一致。花括号的 requires xxx; 表示这个模块需要引用的其他模块名。除了 java.base 可以被自动引入外,这里我们引入了一个 java.xml 的模块。

当我们使用模块声明了依赖关系后,才能使用引入的模块。例如,Main.java 代码如下:

1
package com.itranswarp.sample;

// 必须引入 java.xml 模块后才能使用其中的类:
import javax.xml.XMLConstants;

public class Main {
public static void main(String[] args) {
Greeting g = new Greeting();
System.out.println(g.hello(XMLConstants.XML_NS_PREFIX));
}
}

1
2
3
4
> 如果把 `requires java.xml;` 从 `module-info.java` 中去掉,编译将报错。可见,模块的重要作用就是声明依赖关系。

> 下面,我们用 JDK 提供的命令行工具来编译并创建模块。
> 首先,我们把工作目录切换到 `oop-module`,在当前目录下编译所有的 `.java` 文件,并存放到 `bin` 目录下,命令如下:

javac -d bin src/module-info.java src/com/itranswarp/sample/*.java
1
> 如果编译成功,现在项目结构如下:

oop-module
├── bin
│ ├── com
│ │ └── itranswarp
│ │ └── sample
│ │ ├── Greeting.class
│ │ └── Main.class
│ └── module-info.class
└── src
├── com
│ └── itranswarp
│ └── sample
│ ├── Greeting.java
│ └── Main.java
└── module-info.java
1
2
3
> 注意到 `src` 目录下的 `module-info.java` 被编译到 `bin` 目录下的 `module-info.class`。

> 下一步,我们需要把 `bin` 目录下的所有 `class` 文件先打包成 `jar`,在打包的时候,注意传入 `--main-class` 参数,让这个 `jar` 包能自己定位 `main` 方法所在的类:

jar –create –file hello.jar –main-class com.itranswarp.sample.Main -C bin .
1
> 现在我们就在当前目录下得到了 `hello.jar` 这个 `jar` 包,它和普通 `jar` 包并无区别,可以直接使用命令 `java -jar hello.jar` 来运行它。但是我们的目标是创建模块,所以,继续使用 `JDK` 自带的 jmod 命令把一个 `jar` 包转换成模块:

jmod create –class-path hello.jar hello.jmod
1
2
3
4
   > 于是,在当前目录下我们又得到了 `hello.jmod` 这个模块文件,这就是最后打包出来的传说中的模块!

## 运行模块
> 要运行一个 jar,我们使用 `java -jar xxx.jar` 命令。要运行一个模块,我们只需要指定模块名。试试:

java –module-path hello.jmod –module hello.world
1
> 结果是一个错误:

Error occurred during initialization of boot layer
java.lang.module.FindException: JMOD format not supported at execution time: hello.jmod
1
> 原因是 `.jmod` 不能被放入 `--module-path` 中。换成 `.jar` 就没问题了:

$ java –module-path hello.jar –module hello.world
Hello, xml!
1
2
3
4
5
6
   > 那我们辛辛苦苦创建的 hello.jmod 有什么用?** 答案是我们可以用它来打包 JRE。**

## 打包 JRE
前面讲了,为了支持模块化,Java 9 首先带头把自己的一个巨大无比的 rt.jar 拆成了几十个. jmod 模块,原因就是,运行 Java 程序的时候,实际上我们用到的 JDK 模块,并没有那么多。不需要的模块,完全可以删除。
过去发布一个 Java 应用程序,要运行它,必须下载一个完整的 JRE,再运行 jar 包。而完整的 JRE 块头很大,有 `100+M`。怎么给 JRE 瘦身呢?
现在,JRE 自身的标准库已经分拆成了模块,只需要带上程序用到的模块,其他的模块就可以被裁剪掉。怎么裁剪 JRE 呢?并不是说把系统安装的 JRE 给删掉部分模块,而是 “复制” 一份 JRE,但只带上用到的模块。为此,JDK 提供了 `jlink` 命令来干这件事。命令如下:

jlink –module-path hello.jmod –add-modules java.base,java.xml,hello.world –output jre/
1
2
我们在 `--module-path` 参数指定了我们自己的模块 `hello.jmod`,然后,在 `--add-modules` 参数中指定了我们用到的 3 个模块 `java.base`、`java.xml` 和 `hello.world`,用 `,` 分隔。最后,在 `--output` 参数指定输出目录。
现在,在当前目录下,我们可以找到 jre 目录,这是一个完整的并且带有我们自己 `hello.jmod` 模块的 JRE。试试直接运行这个 JRE:

$ jre/bin/java –module hello.world
Hello, xml!
1
2
3
4
5
6
7
   要分发我们自己的 `Java` 应用程序,只需要把这个 `jre` 目录打个包给对方发过去,对方直接运行上述命令即可,既不用下载安装 `JDK`,也不用知道如何配置我们自己的模块,极大地方便了分发和部署。

## 访问权限
前面我们讲过,`Java` 的 `class` 访问权限分为 `public`、`protected`、`private` 和默认的包访问权限。引入模块后,这些访问权限的规则就要稍微做些调整。
确切地说,`class` 的这些访问权限只在一个模块内有效,模块和模块之间,例如,`a` 模块要访问 `b` 模块的某个 `class`,必要条件是 `b` 模块明确地导出了可以访问的包。

举个例子:我们编写的模块 `hello.world` 用到了模块 `java.xml` 的一个类 `javax.xml.XMLConstants`,我们之所以能直接使用这个类,是因为模块 `java.xml` 的 `module-info.java` 中声明了若干导出:

module java.xml {
exports java.xml;
exports javax.xml.catalog;
exports javax.xml.datatype;

}
1
只有它声明的导出的包,外部代码才被允许访问。换句话说,如果外部代码想要访问我们的 `hello.world` 模块中的 `com.itranswarp.sample.Greeting` 类,我们必须将其导出:

module hello.world {
exports com.itranswarp.sample;

  requires java.base;
  requires java.xml;

}

1
因此,模块进一步隔离了代码的访问权限。

常用命令

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@attention           注意
@author 作者
@bug 缺陷,链接到所有缺陷汇总的缺陷列表
@brief 简要注释
@code 代码块开始,与 “endcode” 成对使用
@endcode 代码块结束,与 “code” 成对使用
@details 详细注释
@date 日期
@file <文件名> 文件参考,用在文件注释中
@param 参数,用在函数注释中
@return 返回,用在函数注释中
@todo TODO,链接到所有 TODO 汇总的 TODO 列表
@version 版本
@warning 警告

Vscode 用户自定义片段

个人设置

1
2
3
4
5
6
7
8
9
10
11
"cppDocuNote": {
"prefix": "docuNote",
"body": [
"/**",
"* $1",
"* @param: $2",
"* @return: $3",
"*/$0",
],
"description": "Documentation notes"
}

实例

1
2
3
4
5
6
7
/**
*〈一句话功能简述〉
*〈功能详细描述〉
* @param [参数 1] [参数 1 说明]
* @param [参数 2] [参数 2 说明]
* @return [返回类型说明]
*/

  1. 程序设计返回结果

    1. Accepted: 通过
    2. Wrong Answer: 答案错误
    3. Runtime Error: 表示程序因为非法访问或未处理异常而结束。
    4. Memory Limit Exceeded: 表示程序因为使用的内存超过规定的内存限制。
    5. Presentation Error: 表示虽然程序输出的答案是对的,但是换行或空格等不符合输出格式要求
    6. Time Limit Exceed: 超时
    7. Output Limit Exceeded: 表示程序输出了过多的内容。
    8. Compile Error: 表示所提交的源代码没能通过编译,不符合语法规定。
    9. System Error, Validator Error: 表示系统发生错误无法正常判题。
    10. Segmentation Fault: 段错误:访问的内存超过了系统所给这个程序的内存空间
  2. 状态

    例子:找最短路径的问题中,状态仅仅是目前所在位置的坐标。

    当状态更加复杂是,就需要封装成一个类来表示转态了。

  3. 状态转移、转移的方式

    例子:同1中的例子,转移的方式为4个方向移动

    又如:八连通($P_{32}$),8个方向共对应了8种状态转移。

  4. next_permutation 函数使用

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    #include <iostream>
    #include <algorithm>
    using namespace std;
    int main()
    {
    int a[4] = {1, 5, 4, 3}, i;
    sort(a, a + 4); //在使用全排列函数之前,一定要先将数组中的数排序哦(不排序好像也可以)
    do {
    for (i = 0; i < 4; i++) cout << a[i] << " ";
    cout << endl;
    } while (next_permutation(a, a + 3));

    for (i = 0; i < 4; i++) cout << a[i] << " ";
    return 0;
    }

    使用 next_permutation 后,原数组的不变。

  5. 贪心算法

    贪心算法就是遵循某种规则,不断贪心地选取当期那最优策略的算法设计方法。

    如果我们不慎重地选择一个正确的规则,就会得到错误的算法。

    如果问题能够用贪心算法来求解的话,那么它通常是非常高效的。

    1. 区间问题:在可选工作中,每次都选择结束时间最早的工作。
    2. 字典序比较类型的问题经常用得上贪心法
  6. 动态规划

    1. 记忆化搜索

      在需要剪枝的情况下,可能会把各种参数都写在函数上,但是这种情况下会让记忆化搜索难以实现,需要注意

    2. def

      一步步按顺序求出问题的解的方法。

  7. memset 初始化数组

    语法: memset(<数组名>, <初始化的值>, <数组的大小>)

    虽然 memset 按照1字节为单位对内存进行填充,-1的每一位二进制位都为1,所以不可以像0一样用 memset 进行初始化。通过使用 Memset 可以快速地对高维数组等进行初始化,但是需要注意无法初始化成1之类的数值。

  8. 移位运算

    1. $1 << n = 2^n$
    2. $n << 1 = 2n$
    3. $n >> 1 = \lfloor{\frac{n}{2.0}}\rfloor$
  9. $a^b$ 算法

    求a的b次方对p取模的值,其中 $1 \leq a, b, p \leq 10^9$

    1
    2
    3
    4
    5
    6
    7
    8
    int power(int a, int b, int p) {
    int ans = 1 % p;
    for (; b; b >>= 1) {
    if (b & 1) ans = 1ll *ans * a % p;
    a = 1ll * a * a % p;
    }
    return ans;
    }
  10. $a * b % p$

    求a乘b对p取模的值,其中 $1 \leq a, b, p \leq 10^{18}$

    1. 方法一
      1
      2
      3
      4
      5
      6
      7
      8
      long long mul(long long a, long long b, long long p) {
      long long ans = 0;
      for (; b; b >>= 1) {
      if (b & 1) ans = (ans + a) % p;
      a = a * 2 % p;
      }
      return ans;
      }
    2. 方法二 ($P_6$)
      1
      2
      3
      4
      long long mul(long long a, long long b, long long p) {

      }

  11. 二进制状态压缩

    操作 运算
    取出整数n在二进制表示下的第k位 (n >> k) & 1
    取出整数n在二进制表示下的第0 ~ k - 1位(后k位) n & ((1 << k) - 1)
    把整数n在二进制表示下的第k位取反 n xor (1 << k)
    对整数n在二进制表示下的第k位赋值为1 n | (1<<k)
    对整数n在二进制表示下的第k为赋值为0 n & (~(1 << k))
0%