Qeuroal's Blog

静幽正治

概述

地球村:现代科技缩小世界的时空距离

信件 ——> 网络编程

计算机网络

计算机网络是指将地理位置不同的具有独立功能的多台计算机机器外部设备,通过通信线路连接起来,在网络操作系统,网络管理软件及光网络通信协议(类似于方言、语言)的管理和协调下,实现资源共享和信息传递的计算机系统。

网络编程的目的

无线电台… :传播交流信息,数据交换。(通信

想要达到这个效果需要什么

  1. 如何准确的定位网络上的一台主机 :192.168.16.124:port,定位到这个计算机上的某个资源
  2. 找到了这个主机,如何传输数据呢?

javaweb: 网页编程 B/S架构

网络编程: TCP/IP

网络通信的要素

如何实现网络的通信?

通信双方的地址

  • ip (唯一(指的是公网,不是局域网))
  • port
  • 192.168.16.124:5900ip:port):就可以定位到某台计算机上的某一个应用

规则:网络通信的协议

http, ftp, smtp, tcp, udp, ….

TCP/IP参考模型:

本章目的:

小结

  • 网络编程中有两个主要的问题
    • 如何准确的定位网络上的一台或多台主机
    • 找到主机之后如何进行通信
  • 网络编程中的要素
    • IPportip
    • 网络通信协议:udp, tcp
  • 万物皆对象

IP

ip地址:InetAddress

用处

  • 唯一定位一台网络上计算机
  • 127.0.0.1(localhost): 本机
  • ip地址的分类
    • IP地址分类:IPV4/IPV6
      • IPV4: 如127.0.0.1。4个字节(32位)组成(0-255),全球42亿个(30亿都在北美,亚洲4亿,2011年就用尽了)
      • IPV6: 如 2001:0bb2:aaaa:0015:0000:00000:1aaa:1312。16字节(128位)组成,8个 无符号整数(4个字节),用的是16进制(16进制占4位)。
    • 公网(互联网使用)和私网(局域网使用)
      • 192.168.xx.xx:局域网,专门给组织内部使用
      • ABCD类地址
    • 域名:记忆 IP 问题
      • IP: www.vip.com

端口

端口表示计算机上的一个程序的进程(类似于:一栋楼代表一个 ip,门牌号代表 端口)

  • 不同的进程有不同的端口号,用来区分软件

  • 被规定:0~65535

  • TCP/UDP端口: 65535 * 2,单个协议下端口号不能冲突,不同协议下,端口可以冲突

  • 端口分类

    • 公有端口:(0~1023)最好不要用

      • HTTP: 80
      • HTTPS:43
      • FTP:21
      • SSH:22
      • Telent: 23
    • 程序注册端口:1024~49151,分配给用户或者程序,建议不要用

      • Tomcat: 8080
      • Mysql:3306
      • Oracle: 1521
    • 动态、私有:49152~65535,建议不要用

      • Idea:63342

      • 查看所有接口

        1
        2
        3
        netstat -ano # 查看所有端口
        netstat -ano|findstr "5900"# 管道流: |,查看指定的端口
        tasklist|findstr "8696" # 查看指定端口的进程

        打开任务管理器: ctrl+shift+esc

    • 代码

      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
      package com.kuangstudy.net.module4;

      import java.net.InetSocketAddress;

      /**
      * @author Qeuroal
      * @date 2021-03-21 16:15
      * @description
      * @since
      */
      public class TestInetSocketAddress {
      public static void main(String[] args) {

      InetSocketAddress inetSocketAddress = new InetSocketAddress("127.0.0.1", 8080);
      System.out.println(inetSocketAddress);
      InetSocketAddress inetSocketAddress2 = new InetSocketAddress("localhost", 8080);
      System.out.println(inetSocketAddress2);

      System.out.println(inetSocketAddress.getAddress());
      // 地址,可以更改hosts文件来更改映射
      System.out.println(inetSocketAddress.getHostName());
      // 端口
      System.out.println(inetSocketAddress.getPort());
      }
      }
    • 图片

通信协议

  • 协议: 约定,就好比我们现在说的普通话
  • 网络通信协议:针对于网络所产生的协议,如:速率,传输码率,代码结构,传输控制……
  • 问题:非常的复杂
  • 大事化小:分层
  • TCP/IP协议簇:实际上是一组协议,不止两个协议。

重要的协议:

  • TCP: 用户传输协议,类似于打电话
  • UDP: 用户数据报协议,类似于发短信

出名的协议:

  • TCP: 用户传输协议
  • IP:网络互连协议

TCP, UDP对比

  • TCP: 打电话

    • 连接:稳定

    • 连接:三次握手四次挥手

      • 三次握手:

        最少需要三次,保证稳定连接!

        A:你瞅啥?

        B:瞅你咋地?

        A:干一场!

      • 四次挥手

        A:我要走了!

        B:你真的要走了吗!

        B:你真的真的要走了吗?

        A:我真的要走了!

    • 客户端、服务端连接

    • 传输完成,释放连接,效率低

  • UDP: 发短信

    • 不连接:不稳定
    • 客户端、服务端连接:没有明确的界限
    • 不管有没有准备好,都可以发给你
    • 类似于导弹攻击
    • DDOS: 洪水攻击(饱和攻击)

TCP

客户端

  1. 连接服务器 socket
  2. 发送消息
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
package com.kuangstudy.net.module6;

import java.io.IOException;
import java.io.OutputStream;
import java.net.InetAddress;
import java.net.Socket;

/**
* @author Qeuroal
* @date 2021-03-21 16:53
* @description 客户端
* @since
*/
public class TestTcpClient {
public static void main(String[] args) {
Socket socket = null;
OutputStream os = null;
try {
// 1. 要知道服务器的地址
InetAddress serverIP = InetAddress.getByName("127.0.0.1");
// 2. 端口号
int port = 9999;
// 3. 创建一个socket连接
socket = new Socket(serverIP, port);
// 4. 发送消息: io流
os = socket.getOutputStream();
os.write("你好,欢迎学习网络编程".getBytes());
} catch (Exception e) {
e.printStackTrace();
} finally {
if (os != null) {
try {
os.close();
} catch (IOException e) {
e.printStackTrace();
}
}

if (socket != null) {
try {
socket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
}

服务器

  1. 建立服务的端口 ServerSocket
  2. 等待用户的连接 accept
  3. 接受用户消息
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
package com.kuangstudy.net.module6;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.ServerSocket;
import java.net.Socket;

/**
* @author Qeuroal
* @date 2021-03-21 16:53
* @description 服务端
* @since
*/
public class TestTcpSever {
public static void main(String[] args) {
ServerSocket serverSocket = null;
Socket socket = null;
InputStream is = null;
ByteArrayOutputStream baos = null;
try {
// 1. 我得有一个地址
serverSocket = new ServerSocket(9999);
while (true) {
// 2. 等待客户端连接过来
socket = serverSocket.accept();
// 3. 读取客户端的消息
is = socket.getInputStream();

// 管道流
baos = new ByteArrayOutputStream();
byte[] buffer = new byte[1024];
int len;
while ((len = is.read(buffer)) != -1) {
baos.write(buffer, 0, len);
}
System.out.println(baos.toString());
}

} catch (IOException e) {
e.printStackTrace();
} finally {
// 关闭资源
if (baos != null) {
try {
baos.close();
} catch (IOException e) {
e.printStackTrace();
}
}
if (is != null) {
try {
is.close();
} catch (IOException e) {
e.printStackTrace();
}
}
if (socket != null) {
try {
socket.close();
} catch (IOException e) {
e.printStackTrace();
}
}

if (serverSocket != null) {
try {
serverSocket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
}

文件上传

读取文件->流->传出去

服务器端

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
package com.kuangstudy.net.module7;

import java.io.File;
import java.io.FileOutputStream;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.ServerSocket;
import java.net.Socket;

/**
* @author Qeuroal
* @date 2021-03-22 16:13
* @description
* @since
*/
public class TestTcpFileServer {
public static void main(String[] args) throws Exception {
// 1. 创建服务
ServerSocket serverSocket = new ServerSocket(9000);
// 2. 监听客户端的连接
Socket socket = serverSocket.accept();// 阻塞式监听,会一直等待客户端连接
// 3. 获取输入流
InputStream is = socket.getInputStream();
// 4. 文件输出
FileOutputStream fos = new FileOutputStream(new File("receive.png"));
byte[] buffer = new byte[1024];
int len;
while ((len = is.read(buffer)) != -1) {
fos.write(buffer, 0, len);

}
// 5. 通知客户端接收完毕
OutputStream os = socket.getOutputStream();
os.write("我接收完毕了,你可以断开了".getBytes());
// 6. 关闭资源
fos.close();
is.close();
socket.close();
serverSocket.close();
}
}

客户端

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
package com.kuangstudy.net.module7;

import java.io.*;
import java.net.InetAddress;
import java.net.Socket;

/**
* @author Qeuroal
* @date 2021-03-22 16:07
* @description
* @since
*/
public class TestTcpFileClient {
public static void main(String[] args) throws Exception {
// 1. 创建一个socket连接
Socket socket = new Socket(InetAddress.getByName("127.0.0.1"), 9000);
// 2. 创建一个输出流
OutputStream os = socket.getOutputStream();
// 3. 读取文件
FileInputStream fis = new FileInputStream(new File("src/resource/xly2.png"));
// 4. 写出文件
byte[] buffer = new byte[1024];
int len;
while ((len = fis.read(buffer)) != -1) {
os.write(buffer, 0, len);
}
// 5. 通知服务器,我已经结束了
socket.shutdownOutput(); // 我已经传输完了
// 5. 确定服务器接收完毕,才能够断开连接
InputStream is = socket.getInputStream();
// String byte[]
ByteArrayOutputStream baos = new ByteArrayOutputStream();
byte[] buffer2 = new byte[1024];
int len2;
while ((len2 = is.read(buffer2)) != -1) {
baos.write(buffer2, 0, len2);
}
System.out.println(baos.toString());

// 5. 关闭资源
baos.close();
is.close();
fis.close();
os.close();
socket.close();
}
}

Tomcat

服务端

  • 自定义 S
  • Tomcat服务器 S: Java后台开发!

客户端

  • 自定义 S
  • 浏览器 B

UDP

发短信:不用连接,需要知道对方的地址


涉及到两个类:

  • DatagramPacket
  • DatagramSocket

发送端

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
package com.kuangstudy.net.module9;

import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;

/**
* @author Qeuroal
* @date 2021-03-22 17:29
* @description 不需要连接服务器
* @since
*/
public class TestUdpClient {
public static void main(String[] args) throws Exception {
// 1. 建立一个Socket
DatagramSocket socket = new DatagramSocket(); // 用来发东西的
// 2. 建个包
String msg = "你好啊,服务器!";
// 3. 发送给谁
InetAddress localhost = InetAddress.getByName("localhost");
int port = 9090;
// 数据,数据的长度起始,要发送给谁
DatagramPacket packet = new DatagramPacket(msg.getBytes(), 0, msg.getBytes().length, localhost, port);
// 4. 发送包
socket.send(packet);

// 5. 关闭
socket.close();
}
}

接收端

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
package com.kuangstudy.net.module9;

import java.net.DatagramPacket;
import java.net.DatagramSocket;

/**
* @author Qeuroal
* @date 2021-03-22 17:35
* @description 还是要等待客户端的连接
* @since
*/
public class TestUdpServer {
public static void main(String[] args) throws Exception {
// 开放端口
DatagramSocket socket = new DatagramSocket(9090);
// 接收数据包
byte[] buffer = new byte[1024];
DatagramPacket packet = new DatagramPacket(buffer, 0, buffer.length); // 接收
socket.receive(packet); // 阻塞接收
System.out.println(packet.getAddress().getHostName());
System.out.println(new String(packet.getData(), 0, packet.getLength()));

// 关闭连接
socket.close();

}
}

本质上没有服务器:因为可以互相发送,因此就没有服务器的概念

咨询

类似于:广告的客服

xxx: 你好

xxx: 你好

  • BufferedReader : 包装流包装 System.in,为了控制台读取

    1
    new BufferedReader(new InputStreamReader(System.in))

循环发送消息

1

循环接收消息

1

在线咨询

两个人都是发送方,同时也都是接收方

TalkSend

1

TalkReceive

1

TalkStudent

1

TalkTeacher

1

URL

如:https://www.baidu.com/

统一资源定位符:定位资源的,定位互联网上的某一个资源

DNS域名解析: 将 www.baidu.com ==> xxx.xx.xx.xx

组成(可以少,但不能多)

1
协议://ip地址:端口号/项目名/资源
  • URL() : 网络类,代表一个地址
    • param: String
    • url.getProtocol: 得到协议名
    • url.getHost(): 得到主机ip
    • url.getPort(): 得到端口
    • url.getPath(): 文件地址
    • url.getFile(): 得到文件全路径
    • url.getQuery: 得到参数(如:查询的名字)
    • url.openConnection(): 打开连接
    • urlConnection.getInputStream(): 得到流
    • urlConnection.disconnect(): 断开连接

下载文件

  1. 下载地址
  2. 连接到这个资源,用 HTTP 连接
  3. 下载

getResource

getResource读取的是 out 下的文件,即 classpath

  • 相对路径: 即在当前包内的路径,如: Test.class.getResource("xly2.png");, xly2.png在当前包内,或者说和运行的class在同一目录下
  • 绝对路径: 用 / 表示,代表是当前项目下,如 Test.class.getResource("/resource/xly2.png"),如上图可见 resource 的位置

new File

读取的是 目录文件,如下所示

1
new FileInputStream(new File("src/resource/xly2.png"));

GUI简介

简介

GUI核心开发技术:Swing、AWT
不流行原因:

  • 界面不美观
  • 需要 jre 环境

为什么要学习?

  1. 可以写出自己心中想要的小工具
  2. 工作时候,也可能小维护到 swing 界面,概率极小
  3. 了解MVC架构,了解监听

AWT

AWT介绍

  1. 包含了很多类和接口用于GUI编程!GUI:图形用户界面
  2. 元素:窗口,按钮,文本框
  3. java.awt

组件和容器

Frame

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
import java.awt.*;

/**
* @author QeuroIzo
* @date 2021-03-03 17:04
* @TODO GUI的第一个界面
* @since
*/
public class TestFrame {
public static void main(String[] args) {
// Frame 看源码
Frame frame = new Frame("我的第一个Java图像界面窗口");
// 设置可见性
frame.setVisible(true);
// 设置大小
frame.setSize(400, 400);
frame.setBackground(new Color(0, 255, 0));
// 设置弹出的初始位置
frame.setLocation(200, 200);
// 设置大小固定
frame.setResizable(false);

}

}

尝试封装

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
package com.kuangstudy.gui.module3;

import java.awt.*;

/**
* @author QeuroIzo
* @date 2021-03-03 17:24
* @TODO
* @since
*/
public class TestFrame2 {
public static void main(String[] args) {
MyFrame myFrame1 = new MyFrame(100, 100, 200, 200, Color.BLUE);
MyFrame myFrame2 = new MyFrame(300, 100, 200, 200, Color.YELLOW);
MyFrame myFrame3 = new MyFrame(100, 300, 200, 200, Color.RED);
MyFrame myFrame4 = new MyFrame(300, 300, 200, 200, Color.MAGENTA);

}
}

class MyFrame extends Frame {
// 可能存在多个窗口,需要一个计数器
static int id = 0;
public MyFrame(int x, int y, int w, int h, Color color) {
super("MyFrame" + (++id));
setVisible(true);
setBounds(x, y, w, h);
setBackground(color);

}
}

面板Panel

Panel 可以看成是一个空间,但是不能单独存在,需要放在 Frame

解决了窗口关闭事件

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
package com.kuangstudy.gui.module4;

import java.awt.*;
import java.awt.event.WindowAdapter;
import java.awt.event.WindowEvent;

/**
* @author QeuroIzo
* @date 2021-03-03 18:38
* @TODO
* @since
*/
public class TestPanel1 {
public static void main(String[] args) {
Frame frame = new Frame();
frame.setTitle("hello");
// 布局的概念
Panel panel = new Panel();

// 设置布局
frame.setLayout(null);
// 坐标
frame.setBounds(300, 300, 500, 500);
frame.setBackground(new Color(0, 255, 0));
// panel 设置坐标,相对位置
panel.setBounds(50, 50, 400, 400);
panel.setBackground(new Color(255, 0, 0));

// frame 添加 panel
frame.add(panel);
// 设置可见
frame.setVisible(true);

// 监听事件:监听窗口关闭事件 System.exit(0)
// 适配器模式:
frame.addWindowListener(new WindowAdapter() {
// 窗口点击关闭的时候需要做的事情
@Override
public void windowClosing(WindowEvent e) {
// 结束程序
System.exit(0);

}
});
}
}

布局管理器

  • 流式布局
  • 东西南北中
  • 表格布局

流式布局

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
package com.kuangstudy.gui.module5;

import java.awt.*;

/**
* @author QeuroIzo
* @date 2021-03-03 19:01
* @TODO 流式布局
* @since
*/
public class TestFlowLayout1 {
public static void main(String[] args) {
Frame frame = new Frame();

// 组件-按钮
Button button1 = new Button("Button1");
Button button2 = new Button("Button2");
Button button3 = new Button("Button3");

// 设置为流式布局
// frame.setLayout(new FlowLayout()); // 默认center
// frame.setLayout(new FlowLayout(FlowLayout.LEFT));
frame.setLayout(new FlowLayout(FlowLayout.RIGHT));
frame.setSize(200, 200);
// 把按钮添加上去
frame.add(button1);
frame.add(button2);
frame.add(button3);
frame.setVisible(true);
}
}

东西南北中

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
package com.kuangstudy.gui.module5;

import java.awt.*;

/**
* @author QeuroIzo
* @date 2021-03-03 21:02
* @TODO 东西南北中
* @since
*/
public class TestBorderLayout {
public static void main(String[] args) {
Frame frame = new Frame("Test");

Button east = new Button("East");
Button west = new Button("West");
Button south = new Button("South");
Button north = new Button("North");
Button center = new Button("Center");

frame.add(east, BorderLayout.EAST);
frame.add(west, BorderLayout.WEST);
frame.add(south, BorderLayout.SOUTH);
frame.add(north, BorderLayout.NORTH);
frame.add(center, BorderLayout.CENTER);

frame.setSize(200, 200);
frame.setVisible(true);



}
}

表格布局

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
package com.kuangstudy.gui.module5;

import java.awt.*;

/**
* @author QeuroIzo
* @date 2021-03-03 21:20
* @TODO
* @since
*/
public class TestGridLayout {
public static void main(String[] args) {
Frame frame = new Frame("GridLayout");

Button btn1 = new Button("btn1");
Button btn2 = new Button("btn2");
Button btn3 = new Button("btn3");
Button btn4 = new Button("btn4");
Button btn5 = new Button("btn5");
Button btn6 = new Button("btn6");

frame.setLayout(new GridLayout(3, 2));
frame.add(btn1);
frame.add(btn2);
frame.add(btn3);
frame.add(btn4);
frame.add(btn5);
frame.add(btn6);

// java函数:自动布局
frame.pack();
frame.setSize(300, 300);
frame.setVisible(true);

}
}

练习

切记直接动手

正常的方式:构思(80%) -> 代码(20%)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
package com.kuangstudy.gui.module6;

import java.awt.*;

/**
* @author QeuroIzo
* @date 2021-03-03 21:32
* @TODO
* @since
*/
public class TestDemo {
public static void main(String[] args) {
Frame frame = new Frame("表格布局测试");
frame.setLayout(new GridLayout(2, 3));

Panel panelUp = new Panel();
panelUp.setLayout(new GridLayout(2, 1));
Panel panelDown = new Panel(new GridLayout(2, 2));
Button btn1 = new Button("btn");
Button btn2 = new Button("btn");
Button btn3 = new Button("btn");
Button btn4 = new Button("btn");
Button btn5 = new Button("btn");
Button btn6 = new Button("btn");
Button btn7 = new Button("btn");
Button btn8 = new Button("btn");
Button btn9 = new Button("btn");
Button btn10 = new Button("btn");

// panelUp 添加 btn
panelUp.add(btn2);
panelUp.add(btn3);
// panelDown 添加 Btn
panelDown.add(btn6);
panelDown.add(btn7);
panelDown.add(btn8);
panelDown.add(btn9);

frame.add(btn1);
frame.add(panelUp);
frame.add(btn4);
frame.add(btn5);
frame.add(panelDown);
frame.add(btn10);
frame.pack();

frame.setBounds(300, 300, 500, 400);
frame.setBackground(Color.BLUE);
frame.setVisible(true);
}
}

总结

  1. Frame 是一个顶级窗口
  2. Panel 无法单独显示,必须添加到某个容器中
  3. 布局管理器
    1. 流式
    2. 东西南北中
    3. 表格
  4. 大小、定位、背景颜色、可见性、监听

事件监听

事件监听:当某个事情发生的时候,干什么?

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
package com.kuangstudy.gui.module7;

import java.awt.*;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.awt.event.WindowAdapter;
import java.awt.event.WindowEvent;

/**
* @author QeuroIzo
* @date 2021-03-05 15:39
* @TODO
* @since
*/
public class TestActionEvent {
public static void main(String[] args) {
// 按下按钮是,触发一些事件
Frame frame = new Frame();
// frame.
Button button = new Button("button");
// 因为 addActionListener() 需要一个 ActionListener,所以我们需要构造一个ActionListener
MyActionListener myActionListener = new MyActionListener();
button.addActionListener(myActionListener);

frame.add(button, BorderLayout.CENTER);
frame.pack();
frame.setVisible(true);
windowClose(frame);

}

/**
* 关闭窗体的事件
* @param frame Frame
*/
private static void windowClose(Frame frame) {
frame.addWindowListener(new WindowAdapter() {
@Override
public void windowClosing(WindowEvent e) {
System.exit(0);
}
});
}
}

/**
* 事件监听
*/
class MyActionListener implements ActionListener {

@Override
public void actionPerformed(ActionEvent e) {
System.out.println("aaa");
}
}

多个按钮共享一个事件

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
package com.kuangstudy.gui.module7;

import java.awt.*;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;

/**
* @author QeuroIzo
* @date 2021-03-05 16:20
* @TODO
* @since
*/
public class TestActionEvent2 {
public static void main(String[] args) {
// 两个按钮,实现同一个监听
// 开始-停止
Frame frame = new Frame("开始-停止");
Button beginButton = new Button("start");
Button stopButton = new Button("stop");

// 可以显示的定义触发会返回的命令,如果不显示定义,则会走默认的只
// 可以多个按钮只写一个监听类
stopButton.setActionCommand("button-stop");
MyMonitor myMonitor = new MyMonitor();
beginButton.addActionListener(myMonitor);
stopButton.addActionListener(myMonitor);

frame.add(beginButton, BorderLayout.NORTH);
frame.add(stopButton, BorderLayout.SOUTH);

frame.pack();
frame.setVisible(true);


}
}

class MyMonitor implements ActionListener {

@Override
public void actionPerformed(ActionEvent e) {
// e.getActionCommand() 获得按钮的信息
System.out.println("按钮被点击了:msg=>" + e.getActionCommand());

}
}

输入框 TextField、监听

main方法里面只有一行代码:启动

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
package com.kuangstudy.gui.module8;

import java.awt.*;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;

/**
* @author QeuroIzo
* @date 2021-03-05 16:32
* @TODO
* @since
*/
public class TestTextField {
public static void main(String[] args) {
// 启动
new MyFrame();
}
}

class MyFrame extends Frame {
public MyFrame() {
TextField textField = new TextField();
add(textField);
// 监听这个文本框输入的文字
MyActionListener myActionListener = new MyActionListener();
// 按下enter,就会触发这个输入框的事件
textField.addActionListener(myActionListener);
// 设置替换编码
textField.setEchoChar('*');

setVisible(true);
pack();
}
}

class MyActionListener implements ActionListener {

@Override
public void actionPerformed(ActionEvent e) {
// 获得一些资源,返回的一个对象(为什么Object可以向下转型,有的时候不是会报错吗- runtime error! ClassCastException?)
TextField field = (TextField) e.getSource();
// 获得输入框中的文本
System.out.println(field.getText());
// 设置清空
field.setText("");
}
}

简单计算器、组合+内部类回顾

oop原则:组合大于继承

继承

1
2
3
class A extends B {

}

组合

1
2
3
class A {
public B b;
}

目前代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
package com.kuangstudy.gui.module9;

import java.awt.*;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;

/**
* @author QeuroIzo
* @date 2021-03-05 18:06
* @TODO 简易计算器
* @since
*/
public class CalculateDemo {
public static void main(String[] args) {
new Calculator();
}
}

/**
* 计算器类
*/
class Calculator extends Frame {
public Calculator() {
// 三个文本框
TextField num1 = new TextField(10);
TextField num2 = new TextField(10);
TextField num3 = new TextField(20);
// 一个按钮
Button button = new Button("=");
button.addActionListener(new MyCalculatorListener(num1, num2, num3));
// 一个标签
Label label = new Label("+");

// 布局
setLayout(new FlowLayout());

// 添加组件
add(num1);
add(label);
add(num2);
add(button);
add(num3);

pack();
setVisible(true);
}
}

/**
* 监听器类
*/
class MyCalculatorListener implements ActionListener {

/**
* 获取三个变量
*/
private TextField num1, num2, num3;
public MyCalculatorListener(TextField num1, TextField num2, TextField num3) {
this.num1 = num1;
this.num2 = num2;
this.num3 = num3;
}

@Override
public void actionPerformed(ActionEvent e) {
// 1. 获得加数和被加数
int n1 = Integer.parseInt(num1.getText());
int n2 = Integer.parseInt(num2.getText());

// 2. 将这个值加法运算后,放到第三个框
num3.setText("" + (n1 + n2));
// 3. 清楚前两个框
num1.setText("");
num2.setText("");
}
}

优化代码

在一个类中调用另一个类的引用

多用组合,最好不要用继承、多态:继承,增强了代码的耦合性;多态,使代码更麻烦,理解容易出错。使用组合的方式把代码拿过来。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
package com.kuangstudy.gui.module9;

import java.awt.*;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;

/**
* @author QeuroIzo
* @date 2021-03-05 18:06
* @TODO 简易计算器
* @since
*/
public class CalculateDemo {
public static void main(String[] args) {
new Calculator().loadFrame();
}
}

/**
* 计算器类
*/
class Calculator extends Frame {

/**
* 属性
*/
public TextField num1, num2, num3;

/**
* 方法
*/
public void loadFrame() {
// 三个文本框
num1 = new TextField(10);
num2 = new TextField(10);
num3 = new TextField(20);
// 一个按钮
Button button = new Button("=");
// 一个标签
Label label = new Label("+");
button.addActionListener(new MyCalculatorListener(this));

// 布局
setLayout(new FlowLayout());

// 添加组件
add(num1);
add(label);
add(num2);
add(button);
add(num3);

pack();
setVisible(true);
}

}

/**
* 监听器类
*/
class MyCalculatorListener implements ActionListener {

/**
* 获取计算器这个对象,在一个类中组合另外一个类
*/
private Calculator calculator = null;
public MyCalculatorListener(Calculator calculator) {
this.calculator = calculator;
}

@Override
public void actionPerformed(ActionEvent e) {
// 1. 获得加数和被加数
// 2. 将这个值加法运算后,放到第三个框
// 3. 清楚前两个框

int n1 = Integer.parseInt(calculator.num1.getText());
int n2 = Integer.parseInt(calculator.num2.getText());
calculator.num3.setText("" + (n1 + n2));
calculator.num1.setText("");
calculator.num2.setText("");
}
}

完全改造为OOP写法

内部类

  • 更好的包装
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
package com.kuangstudy.gui.module9;

import java.awt.*;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;

/**
* @author QeuroIzo
* @date 2021-03-05 18:06
* @TODO 简易计算器
* @since
*/
public class CalculateDemo {
public static void main(String[] args) {
new Calculator().loadFrame();
}
}

/**
* 计算器类
*/
class Calculator extends Frame {

/**
* 属性
*/
private TextField num1, num2, num3;

/**
* 方法
*/
public void loadFrame() {
// 三个文本框
num1 = new TextField(10);
num2 = new TextField(10);
num3 = new TextField(20);
// 一个按钮
Button button = new Button("=");
// 一个标签
Label label = new Label("+");
button.addActionListener(new MyCalculatorListener());

// 布局
setLayout(new FlowLayout());

// 添加组件
add(num1);
add(label);
add(num2);
add(button);
add(num3);

pack();
setVisible(true);
}

/**
* 监听器(内部类)
* 内部类最大的好处,就是可以畅通无阻的访问外部类的属性和方法
*/
private class MyCalculatorListener implements ActionListener {

@Override
public void actionPerformed(ActionEvent e) {
// 1. 获得加数和被加数
// 2. 将这个值加法运算后,放到第三个框
// 3. 清楚前两个框

int n1 = Integer.parseInt(num1.getText());
int n2 = Integer.parseInt(num2.getText());
num3.setText("" + (n1 + n2));
num1.setText("");
num2.setText("");
}
}
}

画笔

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
package com.kuangstudy.gui.module10;

import java.awt.*;

/**
* @author QeuroIzo
* @date 2021-03-05 20:18
* @TODO
* @since
*/
public class TestPaint {
public static void main(String[] args) {
new MyPaint().loadFrame();
}
}

class MyPaint extends Frame {

public void loadFrame() {
setBounds(200, 200, 600, 500);
setVisible(true);
}
/**
* 画笔
* @param g
*/
@Override
public void paint(Graphics g) {
// super.paint(g);
// 画笔,需要有颜色,可以画画
g.setColor(Color.RED);
g.drawOval(100, 100, 100, 200);
// 实心圆
g.fillOval(100, 300, 100, 100);

g.setColor(Color.GREEN);
g.fillRect(200, 100, 100, 100);

// 养成习惯:画笔用完,将它还原到最初的颜色。
}
}

鼠标监听

目的:想要实现鼠标画画

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
package com.kuangstudy.gui.module11;

import java.awt.*;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.util.ArrayList;
import java.util.Iterator;

/**
* @author QeuroIzo
* @date 2021-03-05 21:54
* @TODO 测试鼠标监听事件
* @since
*/
public class TestMouseListener {
public static void main(String[] args) {
new MyFrame("画图");
}
}

class MyFrame extends Frame {
// 画画需要画笔,需要监听鼠标当前的位置,需要集合来存储这个点
/**
* 存鼠标点击的点
*/
private ArrayList<Point> points;
public MyFrame(String title) {
super(title);
setBounds(200, 200, 600, 500);
// 存鼠标的点
points = new ArrayList<>();
// points.add(new Point(100, 100));
points.add(new Point(0, 0));
// points.add(new Point(200, 200));
// points.add(new Point(300, 300));


// 鼠标监听器,针对这个窗口
this.addMouseListener(new MyMouseListener());

setVisible(true);
}

@Override
public void paint(Graphics g) {
// 画画需要监听鼠标的事件
Iterator iterator = points.iterator();
while (iterator.hasNext()) {
Point point = (Point) iterator.next();
g.setColor(Color.BLUE);
g.fillOval(point.x, point.y, 100, 100);
}
}

/**
* 添加一个点到界面上
*/
public void addPaint(Point point) {
points.add(point);
}

/**
* 适配器模式
*/
private class MyMouseListener extends MouseAdapter {
// 只需要鼠标按下、弹起、按住不放

@Override
public void mouseClicked(MouseEvent e) {
MyFrame myFrame = (MyFrame) e.getSource();
// 点击的时候,就会在界面上产生一个点!
// 这个点就是鼠标的点
myFrame.addPaint(new Point(e.getX(), e.getY()));

// 每次点击鼠标都需要重画一遍(《=》刷新),每秒刷新 30帧或60帧
myFrame.repaint();
}
}
}

代码的思维导图,下图:

窗口监听

关掉某个窗口:即隐藏这个窗口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
package com.kuangstudy.gui.module12;

import java.awt.*;
import java.awt.event.WindowAdapter;
import java.awt.event.WindowEvent;

/**
* @author Qeuroal
* @date 2021-03-09 16:28
* @description
* @since
*/
public class TestWindowListener {
public static void main(String[] args) {
new WindowFrame();
}
}

class WindowFrame extends Frame {
public WindowFrame() {
setVisible(true);
setBounds(200, 300, 300, 400);
setBackground(Color.RED);
// addWindowListener(new MyWindowListener());
//最好使用匿名内部类
this.addWindowListener(new WindowAdapter() {
// 监听不到
// @Override
// public void windowOpened(WindowEvent e) {
// System.out.println("windowOpened");
// }

/**
* 关闭窗口
* @param e
*/
@Override
public void windowClosing(WindowEvent e) {
System.out.println("windowClosing");
System.exit(0);
}

// 监听不到
// @Override
// public void windowClosed(WindowEvent e) {
// System.out.println("windowClosed");
// }

/**
* 激活窗口
* @param e
*/
@Override
public void windowActivated(WindowEvent e) {
// 获取事件所作用的对象,(获得事件监听的对象)即你所与该事件绑定的控件,例如你点击了按钮,那么得到的source就是按钮对象了
WindowFrame source = (WindowFrame) e.getSource();
source.setTitle("被激活了");
System.out.println("windowActivated");
}

/**
* 未被激活窗口,即切出去了
* @param e
*/
@Override
public void windowDeactivated(WindowEvent e) {
System.out.println("windowDeactivated");
}
});
}

/** 成员内部类
class MyWindowListener extends WindowAdapter {
@Override
public void windowClosing(WindowEvent e) {
// 隐藏窗口,通过按钮隐藏窗口
setVisible(false);
// 正常退出:0,非正常退出:1
System.exit(0);
}
}
*/
}

键盘监听

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
package com.kuangstudy.gui.module13;

import java.awt.*;
import java.awt.event.KeyAdapter;
import java.awt.event.KeyEvent;

/**
* @author Qeuroal
* @date 2021-03-09 17:04
* @description
* @since
*/
public class TestKeyListener {
public static void main(String[] args) {
new KeyFrame();
}
}

class KeyFrame extends Frame {
public KeyFrame() {
setBounds(300, 400, 300, 400);
setVisible(true);

this.addKeyListener(new KeyAdapter() {
/**
* 键盘按下
* @param e
*/
@Override
public void keyPressed(KeyEvent e) {
// 获得键盘下的键是哪个,当前键盘的码
int keyCode = e.getKeyCode();
// 不需要记录这个数值,直接使用静态属性 VK_xxx
System.out.println(keyCode);
if (keyCode == KeyEvent.VK_UP) {
System.out.println("你按下了上键");
}
// 根据按下的不同操作,产生不同结果
}
});
}
}

Swing

AWT 是底层,Swing 是给封装了

窗口、面板

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
package com.kuangstudy.gui.module14;

import javax.swing.*;
import java.awt.*;

/**
* @author Qeuroal
* @date 2021-03-09 17:17
* @description
* @since
*/
public class TestJFrame {
/**
* 初始化
*/
public void init() {
// 顶级窗口
JFrame jf = new JFrame("这是一个JFrame窗口");
jf.setVisible(true);
jf.setBounds(100, 100, 400, 300);

// 设置文字: Jlabel
JLabel label = new JLabel("欢迎来到JAVA GUI");

jf.add(label);

// 容器:需要实例化,JFrame本身也是一个容器,需要实例化
Container contentPane = jf.getContentPane();
contentPane.setBackground(Color.RED);

// 关闭事件
jf.setDefaultCloseOperation(WindowConstants.EXIT_ON_CLOSE);
}
public static void main(String[] args) {
// 建立一个窗口
new TestJFrame().init();
}
}

标签居中

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
package com.kuangstudy.gui.module14;

import javax.swing.*;
import java.awt.*;

/**
* @author Qeuroal
* @date 2021-03-09 17:32
* @description
* @since
*/
public class TestJFrame2 {
public static void main(String[] args) {
new MyJFrame2().init();
}
}

class MyJFrame2 extends JFrame {
public void init() {
this.setVisible(true);
setBounds(300, 300, 400, 300);
// 设置文字: Jlabel
JLabel label = new JLabel("欢迎来到JAVA GUI");
// add(label) 和 this.add(label) 一样
add(label);
//设置水平对齐
label.setHorizontalAlignment(SwingConstants.CENTER);
// 获得一个容器
Container contentPane = this.getContentPane();
contentPane.setBackground(Color.RED);
}
}

弹窗

JDialog,用来被弹出,默认就有关闭事件

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
package com.kuangstudy.gui.module15;

import javax.swing.*;
import java.awt.*;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;

/**
* @author Qeuroal
* @date 2021-03-09 20:46
* @description 主窗口
* @since
*/
public class TestDialog extends JFrame {
public TestDialog() {
this.setVisible(true);
this.setBounds(400, 400, 400, 300);
this.setDefaultCloseOperation(WindowConstants.EXIT_ON_CLOSE);

// JFrame放东西:容器
Container contentPane = this.getContentPane();
// 绝对布局
contentPane.setLayout(null);

// 按钮
JButton jButton = new JButton("点击弹出一个对话框");
jButton.setBounds(30, 30, 200, 50);

// 点击这个按钮的时候,弹出一个弹窗
jButton.addActionListener(new ActionListener() {
@Override
public void actionPerformed(ActionEvent e) {
// 弹窗
new MyDialogDemo();
}
});
contentPane.add(jButton);
}

public static void main(String[] args) {
new TestDialog();
}
}

/**
* 弹窗的窗口
*/
class MyDialogDemo extends JDialog{
public MyDialogDemo() {
this.setVisible(true);
this.setBounds(300, 300, 300, 200);
// this.setDefaultCloseOperation(WindowConstants.EXIT_ON_CLOSE);

Container contentPane = this.getContentPane();
contentPane.setLayout(null);

contentPane.add(new Label("学Swing"));
}
}

标签

label

  • 创建
1
new JLabel("xxx")
  • 实例
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
package com.kuangstudy.gui.module16;

import javax.swing.*;
import java.awt.*;

/**
* @author Qeuroal
* @date 2021-03-09 21:49
* @description 图标是一个接口,需要实现类,Frame继承
* @since
*/
public class TestIcon extends JFrame implements Icon {

public static void main(String[] args) {
// 首先生成TestIcon实例,通过这个实例再去生成新的TestIcon实例
new TestIcon().init();
}

private int width;
private int height;

public TestIcon() {}

public TestIcon(int width, int height) {
this.width = width;
this.height = height;
}

public void init() {
TestIcon testIcon = new TestIcon(30, 30);
// 图标放在标签上,也可以放在按钮上
JLabel iconTest = new JLabel("iconTest", testIcon, SwingConstants.CENTER);
Container contentPane = getContentPane();
contentPane.add(iconTest);
setVisible(true);
setDefaultCloseOperation(WindowConstants.EXIT_ON_CLOSE);
}

@Override
public void paintIcon(Component c, Graphics g, int x, int y) {
g.fillOval(x,y,width,height);
}

@Override
public int getIconWidth() {
return width;
}

@Override
public int getIconHeight() {
return height;
}
}

Icon

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
package com.kuangstudy.gui.module16;

import javax.swing.*;
import java.awt.*;
import java.net.URL;

/**
* @author Qeuroal
* @date 2021-03-09 22:14
* @description
* @since
*/
public class TestImageIcon extends JFrame {
public TestImageIcon() {
JLabel imageIconLabel = new JLabel("ImageIcon");
// 获取图片的地址
System.out.println(TestImageIcon.class);
URL resourceURL = TestImageIcon.class.getResource("/resource/xly2.png");
// 命名不要冲突了
ImageIcon imageIcon = new ImageIcon(resourceURL);

imageIconLabel.setIcon(imageIcon);
imageIconLabel.setHorizontalAlignment(SwingConstants.CENTER);

Container container = getContentPane();
container.add(imageIconLabel);

setVisible(true);
setDefaultCloseOperation(WindowConstants.EXIT_ON_CLOSE);
setBounds(100, 100, 300, 300);

}


public static void main(String[] args) {
new TestImageIcon();
}
}

面板

JPanel

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
package com.kuangstudy.gui.module17;

import javax.swing.*;
import java.awt.*;

/**
* @author Qeuroal
* @date 2021-03-09 22:36
* @description
* @since
*/
public class TestJPanel extends JFrame {
public TestJPanel() {
Container container = getContentPane();
//后面参数的意思是间距
container.setLayout(new GridLayout(2, 1, 10, 10));

JPanel panel1 = new JPanel(new GridLayout(1, 3));
JPanel panel2 = new JPanel(new GridLayout(1, 2));
JPanel panel3 = new JPanel(new GridLayout(2, 2));

panel1.add(new JButton("1"));
panel1.add(new JButton("1"));
panel1.add(new JButton("1"));
panel2.add(new JButton("2"));
panel2.add(new JButton("2"));
panel3.add(new JButton("3"));
panel3.add(new JButton("3"));
panel3.add(new JButton("3"));
panel3.add(new JButton("3"));

container.add(panel1);
container.add(panel2);
container.add(panel3);

setVisible(true);
setDefaultCloseOperation(WindowConstants.EXIT_ON_CLOSE);
setBounds(300, 300, 400, 300);
}

public static void main(String[] args) {
new TestJPanel();
}
}

JScrollPanel

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
package com.kuangstudy.gui.module17;

import javax.swing.*;
import java.awt.*;

/**
* @author Qeuroal
* @date 2021-03-09 22:45
* @description
* @since
*/
public class TestJScrollPanel extends JFrame {
public static void main(String[] args) {
new TestJScrollPanel();
}

public TestJScrollPanel() {
Container container = getContentPane();

// 文本域
JTextArea jTextArea = new JTextArea(20, 50);
jTextArea.setText("请输入文本");

// Scroll面板
JScrollPane jScrollPane = new JScrollPane(jTextArea);
container.add(jScrollPane);

setVisible(true);
setDefaultCloseOperation(WindowConstants.EXIT_ON_CLOSE);
setBounds(300, 300, 400, 30);
}
}

按钮

图片按钮

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
package com.kuangstudy.gui.module18;

import javax.swing.*;
import java.awt.*;
import java.net.URL;

/**
* @author Qeuroal
* @date 2021-03-15 22:34
* @description
* @since
*/
public class TestButton extends JFrame {

public TestButton() {
Container container = this.getContentPane();
// 将一个图片变为图标
URL resource = TestButton.class.getResource("/resource/xly2.png");
Icon imageIcon = new ImageIcon(resource);

// 把图标放在按钮上
JButton btn = new JButton();
btn.setIcon(imageIcon);
btn.setToolTipText("图片按钮");

// add
container.add(btn);
this.setVisible(true);
this.setDefaultCloseOperation(WindowConstants.EXIT_ON_CLOSE);
this.setBounds(300, 300, 400, 300);
}

public static void main(String[] args) {
new TestButton();
}
}

单选按钮

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
package com.kuangstudy.gui.module18;

import javax.swing.*;
import java.awt.*;
import java.net.URL;

/**
* @author Qeuroal
* @date 2021-03-15 22:42
* @description
* @since
*/
public class TestButton2 extends JFrame {

public TestButton2() {
Container container = this.getContentPane();
// 将一个图片变为图标
URL resource = TestButton.class.getResource("/resource/xly2.png");
Icon imageIcon = new ImageIcon(resource);

// 单选框
JRadioButton jRadioButton1 = new JRadioButton("JRadioButton1");
JRadioButton jRadioButton2 = new JRadioButton("JRadioButton2");
JRadioButton jRadioButton3 = new JRadioButton("JRadioButton3");

// 由于单选框只能选个一个,所以:分组,一个组中只能选一个
ButtonGroup buttonGroup = new ButtonGroup();
buttonGroup.add(jRadioButton1);
buttonGroup.add(jRadioButton2);
buttonGroup.add(jRadioButton3);

container.add(jRadioButton1, BorderLayout.CENTER);
container.add(jRadioButton2, BorderLayout.NORTH);
container.add(jRadioButton3, BorderLayout.SOUTH);

this.setVisible(true);
this.setDefaultCloseOperation(WindowConstants.EXIT_ON_CLOSE);
this.setBounds(300, 300, 400, 300);
}

public static void main(String[] args) {
new TestButton2();
}
}

复选按钮

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
package com.kuangstudy.gui.module18;

import javax.swing.*;
import java.awt.*;
import java.net.URL;

/**
* @author Qeuroal
* @date 2021-03-15 22:48
* @description
* @since
*/
public class TestButton3 extends JFrame {

public TestButton3() {
Container container = this.getContentPane();
// 将一个图片变为图标
URL resource = TestButton.class.getResource("/resource/xly2.png");
Icon imageIcon = new ImageIcon(resource);

// 多选框
JCheckBox jCheckBox1 = new JCheckBox("jCheckBox1");
JCheckBox jCheckBox2 = new JCheckBox("jCheckBox2");

container.add(jCheckBox1, BorderLayout.NORTH);
container.add(jCheckBox2, BorderLayout.SOUTH);

this.setVisible(true);
this.setDefaultCloseOperation(WindowConstants.EXIT_ON_CLOSE);
this.setBounds(300, 300, 400, 300);
}

public static void main(String[] args) {
new TestButton3();
}
}

列表

下拉框

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
package com.kuangstudy.gui.module19;

import javax.swing.*;
import java.awt.*;

/**
* @author Qeuroal
* @date 2021-03-15 22:53
* @description
* @since
*/
public class TestCombobox extends JFrame {
public TestCombobox() {
super("TestCombobox");
Container container = this.getContentPane();

JComboBox status = new JComboBox();
status.addItem(null);
status.addItem("正在热播");
status.addItem("已下架");
status.addItem("即将上映");

container.add(status);

setVisible(true);
setBounds(300, 300, 500, 300);
setDefaultCloseOperation(WindowConstants.EXIT_ON_CLOSE);
}

public static void main(String[] args) {
new TestCombobox();
}
}

列表框

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
package com.kuangstudy.gui.module19;

import javax.swing.*;
import java.awt.*;

/**
* @author Qeuroal
* @date 2021-03-15 23:03
* @description
* @since
*/
public class TestCombobox2 extends JFrame {
public TestCombobox2() {
super("TestCombobox");
Container container = this.getContentPane();

// 生成列表的内容
String[] contents = {"1", "2", "3"};

JList jList = new JList(contents);
container.add(jList);

setVisible(true);
setBounds(300, 300, 500, 300);
setDefaultCloseOperation(WindowConstants.EXIT_ON_CLOSE);
}

public static void main(String[] args) {
new TestCombobox2();
}
}
  • 应用场景
    • 下拉框:选择地区,或者一些单个选项
    • 列表框:展示信息,一般是动态扩容

文本框

文本框

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
package com.kuangstudy.gui.module20;

import javax.swing.*;
import java.awt.*;

/**
* @author Qeuroal
* @date 2021-03-15 23:11
* @description
* @since
*/
public class TestText extends JFrame {
public TestText() {
super("TestCombobox");
Container container = this.getContentPane();
container.setLayout(null);

JTextField jTextField1 = new JTextField("hello");
JTextField jTextField2 = new JTextField("world", 20);

// 东西南北中布局,会自动填充满
container.add(jTextField1, BorderLayout.NORTH);
container.add(jTextField2, BorderLayout.SOUTH);


setVisible(true);
setBounds(300, 300, 500, 300);
setDefaultCloseOperation(WindowConstants.EXIT_ON_CLOSE);
}

public static void main(String[] args) {
new TestText();
}
}

密码框

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
package com.kuangstudy.gui.module20;

import javax.swing.*;
import java.awt.*;

/**
* @author Qeuroal
* @date 2021-03-15 23:14
* @description
* @since
*/
public class TestText2 extends JFrame {
public TestText2() {
super("TestCombobox");
Container container = this.getContentPane();

// 默认 ····
JPasswordField jPasswordField = new JPasswordField();
// 手动设置 ***
jPasswordField.setEchoChar('*');

container.add(jPasswordField);

setVisible(true);
setBounds(300, 300, 500, 300);
setDefaultCloseOperation(WindowConstants.EXIT_ON_CLOSE);
}

public static void main(String[] args) {
new TestText2();
}
}

文本域

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
package com.kuangstudy.gui.module17;

import javax.swing.*;
import java.awt.*;

/**
* @author Qeuroal
* @date 2021-03-09 22:45
* @description
* @since
*/
public class TestJScrollPanel extends JFrame {
public static void main(String[] args) {
new TestJScrollPanel();
}

public TestJScrollPanel() {
Container container = getContentPane();

// 文本域
JTextArea jTextArea = new JTextArea(20, 50);
jTextArea.setText("请输入文本");

// Scroll面板
JScrollPane jScrollPane = new JScrollPane(jTextArea);
container.add(jScrollPane);

setVisible(true);
setDefaultCloseOperation(WindowConstants.EXIT_ON_CLOSE);
setBounds(300, 300, 400, 30);
}
}

游戏实践:贪吃蛇

如果时间片足够小,就是动画:一秒30帧(人眼就是动画了)

连起来是动画,拆开就是静态的图片。如:动漫,1秒24张画

键盘监听

定时器 Timer

代码

StartGame

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
package com.kuangstudy.gui.snake;

import javax.swing.*;

/**
* @author Qeuroal
* @date 2021-03-15 23:29
* @description 游戏的主启动类
* @since
*/
public class StartGame {
public static void main(String[] args) {
JFrame frame = new JFrame();

// 是算出来的,不能被拉伸,否则就会变形了
frame.setBounds(200, 100, 900, 720);
// 窗口大小不可变
frame.setResizable(false);
frame.setDefaultCloseOperation(WindowConstants.EXIT_ON_CLOSE);

// 整车游戏界面都在面板上
frame.add(new GamePanel());

frame.setVisible(true);
}
}

GamePanel

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
package com.kuangstudy.gui.snake;

import javax.swing.*;
import java.awt.*;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.awt.event.KeyEvent;
import java.awt.event.KeyListener;
import java.util.Random;

/**
* @author Qeuroal
* @date 2021-03-15 23:32
* @description 游戏的面板
* @since
*/
public class GamePanel extends JPanel implements KeyListener, ActionListener {

/**
* 定义蛇的数据结构
*/
// 蛇的长度
int length;
// 蛇的x坐标 25*25
int[] snakeX = new int[100];
// 蛇的Y坐标 25*25
int[] snakeY = new int[100];
// 初始方向
String fx;
// 游戏当前状态:开始,停止
boolean isStart= false;
// 食物的坐标
int foodX;
int foodY;
Random random = new Random();
// 积分
int score;
// 游戏失败状态
boolean isFail = false;
// 定时器:ms为单位,监听this这个对象。100毫秒执行一次。
Timer timer = new Timer(100, this);
/**
* 构造器
*/
public GamePanel() {
init();
// 获得焦点事件
this.setFocusable(true);
// 获取键盘事件
this.addKeyListener(this);
// 游戏一开始定时器就启动
timer.start();
}


/**
* 初始化方法
*/
public void init() {
length = 3;
// 脑袋的坐标
snakeX[0] = 100; snakeY[0] = 100;
// 第一个身体的坐标
snakeX[1] = 75; snakeY[1] = 100;
// 第二个身体的坐标
snakeX[2] = 50; snakeY[2] = 100;
fx = "R";
// 把食物随机放在界面上
foodX = 25 + 25 * random.nextInt(34);
foodY = 75 + 25 * random.nextInt(24);
// 积分
score = 0;
}


/**
* 绘制面板,我们游戏中的所有东西,都是用这个笔来画
* @param g
*/
@Override
protected void paintComponent(Graphics g) {
// 清屏
super.paintComponent(g);
setBackground(Color.WHITE);
// 绘制静态面板,头部广告栏画上去
Data.header.paintIcon(this, g, 25, 11);
// 默认的游戏界面
g.fillRect(25, 75, 850, 600);

// 画积分
g.setColor(Color.WHITE);
g.setFont(new Font("微软雅黑", Font.BOLD, 15));
g.drawString("长度: " + length,750, 35 );
g.drawString("分数: " + score, 750, 50);

// 画食物
Data.food.paintIcon(this, g, foodX, foodY);

// 把小蛇画上去
if (fx.equals("R")) {
Data.right.paintIcon(this, g, snakeX[0], snakeY[0]);
} else if (fx.equals("L")) {
Data.left.paintIcon(this, g, snakeX[0], snakeY[0]);
} else if (fx.equals("U")) {
Data.up.paintIcon(this, g, snakeX[0], snakeY[0]);
} else if (fx.equals("D")) {
Data.down.paintIcon(this, g, snakeX[0], snakeY[0]);
}
for (int i = 1; i < length; i++) {
Data.body.paintIcon(this, g, snakeX[i], snakeY[i]);
}

// 游戏状态
if (isStart == false) {
g.setColor(Color.WHITE);
// 设置字体
g.setFont(new Font("微软雅黑", Font.BOLD, 40));
g.drawString("按下空格开始游戏", 300, 300);
}

if (isFail) {
g.setColor(Color.RED);
// 设置字体
g.setFont(new Font("微软雅黑", Font.BOLD, 40));
g.drawString("失败,按下空格重新开始游戏", 300, 300);
}
}



/**
* 键盘监听事件
* @param e
*/
@Override
public void keyPressed(KeyEvent e) {
// 获得键盘按键是哪一个
int keyCode = e.getKeyCode();
// 如果按下的是空格键
if (keyCode == KeyEvent.VK_SPACE) {
if (isFail) {
// 重新开始
isFail = false;
init();
} else {
isStart = !isStart;
}
repaint();

}
// 小蛇移动
if (keyCode == KeyEvent.VK_UP) {
fx = "U";
} else if (keyCode == KeyEvent.VK_DOWN) {
fx = "D";
} else if (keyCode == KeyEvent.VK_LEFT) {
fx = "L";
} else if (keyCode == KeyEvent.VK_RIGHT) {
fx = "R";
}
}

@Override
public void keyReleased(KeyEvent e) {
}
@Override
public void keyTyped(KeyEvent e) {
}

/**
* 事件监听——需要通过固定事件来刷新:10次/1s
* @param e
*/
@Override
public void actionPerformed(ActionEvent e) {
// 如果游戏是开始状态,就让小蛇动起来
if (isStart && isFail == false) {
// 吃食物
if (snakeX[0] == foodX && snakeY[0] == foodY) {
// 长度+1
length++;
// 分数+10
score += 10;
// 重新生成食物
foodX = 25 + 25 * random.nextInt(34);
foodY = 75 + 25 * random.nextInt(24);
}

// 移动:后一节移到前一节的位置
for (int i = length - 1; i > 0; i--) {
snakeX[i] = snakeX[i - 1];
snakeY[i] = snakeY[i - 1];
}
// 走向
if (fx.equals("R")) {
snakeX[0] += 25;
// 边界判断
if (snakeX[0] > 850) {
snakeX[0] = 25;
}
} else if (fx.equals("L")){
snakeX[0] -= 25;
if (snakeX[0] < 25) {
snakeX[0] = 850;
}
} else if (fx.equals("U")){
snakeY[0] -= 25;
if (snakeY[0] < 75) {
snakeY[0] = 650;
}
} else if (fx.equals("D")){
snakeY[0] += 25;
if (snakeY[0] > 650) {
snakeY[0] = 75;
}
}

// 失败判定:撞到自己就算失败
for (int i = 1; i < length; i++) {
if (snakeX[0] == snakeX[i] && snakeY[0] == snakeY[i]) {
isFail = true;
}
}

// 重画页面
repaint();
}
// 定时器开始
timer.start();
}
}

总结

补充

C/S:客户端+服务器 (主流:C++)

B/S:浏览器+服务器 (主流:Java)

IO

IO是指Input/Output,即输入和输出。以内存为中心:

  • Input指从外部读入数据到内存,例如,把文件从磁盘读取到内存,从网络读取数据到内存等等。
  • Output指把数据从内存输出到外部,例如,把数据从内存写入到文件,把数据从内存输出到网络等等。

为什么要把数据读到内存才能处理这些数据?因为代码是在内存中运行的,数据也必须读到内存,最终的表示方式无非是byte数组,字符串等,都必须存放在内存里。
从Java代码来看,输入实际上就是从外部,例如,硬盘上的某个文件,把内容读到内存,并且以Java提供的某种数据类型表示,例如,byte[],String,这样,后续代码才能处理这些数据。
因为内存有“易失性”的特点,所以必须把处理后的数据以某种方式输出,例如,写入到文件。Output实际上就是把Java表示的数据格式,例如,byte[],String等输出到某个地方。
IO流是一种顺序读写数据的模式,它的特点是单向流动。数据类似自来水一样在水管中流动,所以我们把它称为IO流。

InputStream / OutputStream

IO流以byte(字节)为最小单位,因此也称为字节流。例如,我们要从磁盘读入一个文件,包含6个字节,就相当于读入了6个字节的数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
╔════════════╗
║ Memory ║
╚════════════╝

│0x48
│0x65
│0x6c
│0x6c
│0x6f
│0x21
╔═══════════╗
║ Hard Disk ║
╚═══════════╝

这6个字节是按顺序读入的,所以是输入字节流。
反过来,我们把6个字节从内存写入磁盘文件,就是输出字节流:
1
2
3
4
5
6
7
8
9
10
11
12
13
╔════════════╗
║ Memory ║
╚════════════╝
│0x21
│0x6f
│0x6c
│0x6c
│0x65
│0x48

╔═══════════╗
║ Hard Disk ║
╚═══════════╝

在Java中,InputStream代表输入字节流,OuputStream代表输出字节流,这是最基本的两种IO流。

Reader / Writer

如果我们需要读写的是字符,并且字符不全是单字节表示的ASCII字符,那么,按照char来读写显然更方便,这种流称为字符流。
Java提供了Reader和Writer表示字符流,字符流传输的最小数据单位是char。
例如,我们把char[]数组Hi你好这4个字符用Writer字符流写入文件,并且使用UTF-8编码,得到的最终文件内容是8个字节,英文字符H和i各占一个字节,中文字符你好各占3个字节:

1
2
3
4
0x48
0x69
0xe4bda0
0xe5a5bd

反过来,我们用Reader读取以UTF-8编码的这8个字节,会从Reader中得到Hi你好这4个字符。
因此,Reader和Writer本质上是一个能自动编解码的InputStream和OutputStream。
使用Reader,数据源虽然是字节,但我们读入的数据都是char类型的字符,原因是Reader内部把读入的byte做了解码,转换成了char。使用InputStream,我们读入的数据和原始二进制数据一模一样,是byte[]数组,但是我们可以自己把二进制byte[]数组按照某种编码转换为字符串。究竟使用Reader还是InputStream,要取决于具体的使用场景。如果数据源不是文本,就只能使用InputStream,如果数据源是文本,使用Reader更方便一些。Writer和OutputStream是类似的。

同步和异步

同步IO是指,读写IO时代码必须等待数据返回后才继续执行后续代码,它的优点是代码编写简单,缺点是CPU执行效率低。
而异步IO是指,读写IO时仅发出请求,然后立刻执行后续代码,它的优点是CPU执行效率高,缺点是代码编写复杂。
Java标准库的包java.io提供了同步IO,而java.nio则是异步IO。上面我们讨论的InputStream、OutputStream、Reader和Writer都是同步IO的抽象类,对应的具体实现类,以文件为例,有FileInputStream、FileOutputStream、FileReader和FileWriter。
本节我们只讨论Java的同步IO,即输入/输出流的IO模型。

小结

  • IO流是一种流式的数据输入/输出模型:
    • 二进制数据以byte为最小单位在InputStream/OutputStream中单向流动;
    • 字符数据以char为最小单位在Reader/Writer中单向流动。
  • Java标准库的java.io包提供了同步IO功能:
    • 字节流接口:InputStream/OutputStream;
    • 字符流接口:Reader/Writer。

File对象

在计算机系统中,文件是非常重要的存储方式。Java的标准库java.io提供了File对象来操作文件和目录。
要构造一个File对象,需要传入文件路径:

1
2
3
4
5
6
7
import java.io.*;
public class Main {
public static void main(String[] args) {
File f = new File("C:\\Windows\\notepad.exe");
System.out.println(f);
}
}

构造File对象时,既可以传入绝对路径,也可以传入相对路径。绝对路径是以根目录开头的完整路径,例如:
1
File f = new File("C:\\Windows\\notepad.exe");

注意Windows平台使用\作为路径分隔符,在Java字符串中需要用\表示一个\。Linux平台使用/作为路径分隔符:
1
File f = new File("/usr/bin/javac");

传入相对路径时,相对路径前面加上当前目录就是绝对路径:
1
2
3
4
// 假设当前目录是C:\Docs
File f1 = new File("sub\\javac"); // 绝对路径是C:\Docs\sub\javac
File f3 = new File(".\\sub\\javac"); // 绝对路径是C:\Docs\sub\javac
File f3 = new File("..\\sub\\javac"); // 绝对路径是C:\sub\javac

可以用.表示当前目录,..表示上级目录。
File对象有3种形式表示的路径,一种是getPath(),返回构造方法传入的路径,一种是getAbsolutePath(),返回绝对路径,一种是getCanonicalPath,它和绝对路径类似,但是返回的是规范路径。
什么是规范路径?我们看以下代码:
1
2
3
4
5
6
7
8
9
import java.io.*;
public class Main {
public static void main(String[] args) throws IOException {
File f = new File("..");
System.out.println(f.getPath());
System.out.println(f.getAbsolutePath());
System.out.println(f.getCanonicalPath());
}
}

绝对路径可以表示成C:\Windows\System32\..\notepad.exe,而规范路径就是把...转换成标准的绝对路径后的路径:C:\Windows\notepad.exe
因为Windows和Linux的路径分隔符不同,File对象有一个静态变量用于表示当前平台的系统分隔符:
1
System.out.println(File.separator); // 根据当前平台打印"\"或"/"

文件和目录

File对象既可以表示文件,也可以表示目录。特别要注意的是,构造一个File对象,即使传入的文件或目录不存在,代码也不会出错,因为构造一个File对象,并不会导致任何磁盘操作。只有当我们调用File对象的某些方法的时候,才真正进行磁盘操作。

例如,调用isFile(),判断该File对象是否是一个已存在的文件,调用isDirectory(),判断该File对象是否是一个已存在的目录:

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

public class Main {
public static void main(String[] args) throws IOException {
File f1 = new File("C:\\Windows");
File f2 = new File("C:\\Windows\\notepad.exe");
File f3 = new File("C:\\Windows\\nothing");
System.out.println(f1.isFile());
System.out.println(f1.isDirectory());
System.out.println(f2.isFile());
System.out.println(f2.isDirectory());
System.out.println(f3.isFile());
System.out.println(f3.isDirectory());
}
}

用File对象获取到一个文件时,还可以进一步判断文件的权限和大小:

  • boolean canRead():是否可读;
  • boolean canWrite():是否可写;
  • boolean canExecute():是否可执行;
  • long length():文件字节大小。

对目录而言,是否可执行表示能否列出它包含的文件和子目录。

创建和删除文件

当File对象表示一个文件时,可以通过createNewFile()创建一个新文件,用delete()删除该文件:

1
2
3
4
5
6
7
8
File file = new File("/path/to/file");
if (file.createNewFile()) {
// 文件创建成功:
// TODO:
if (file.delete()) {
// 删除文件成功:
}
}

有些时候,程序需要读写一些临时文件,File对象提供了createTempFile()来创建一个临时文件,以及deleteOnExit()在JVM退出时自动删除该文件。
1
2
3
4
5
6
7
8
9
10
import java.io.*;

public class Main {
public static void main(String[] args) throws IOException {
File f = File.createTempFile("tmp-", ".txt"); // 提供临时文件的前缀和后缀
f.deleteOnExit(); // JVM退出时自动删除
System.out.println(f.isFile());
System.out.println(f.getAbsolutePath());
}
}

遍历文件和目录

当File对象表示一个目录时,可以使用list()和listFiles()列出目录下的文件和子目录名。listFiles()提供了一系列重载方法,可以过滤不想要的文件和目录:

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
import java.io.*;

public class Main {
public static void main(String[] args) throws IOException {
File f = new File("C:\\Windows");
File[] fs1 = f.listFiles(); // 列出所有文件和子目录
printFiles(fs1);
File[] fs2 = f.listFiles(new FilenameFilter() { // 仅列出.exe文件
public boolean accept(File dir, String name) {
return name.endsWith(".exe"); // 返回true表示接受该文件
}
});
printFiles(fs2);
}

static void printFiles(File[] files) {
System.out.println("==========");
if (files != null) {
for (File f : files) {
System.out.println(f);
}
}
System.out.println("==========");
}
}

list() : 获取该目录下的所有文件
listFiles(): 获取该目录下所有文件的绝对路径

和文件操作类似,File对象如果表示一个目录,可以通过以下方法创建和删除目录:

  • boolean mkdir():创建当前File对象表示的目录;
  • boolean mkdirs():创建当前File对象表示的目录,并在必要时将不存在的父目录也创建出来;
  • boolean delete():删除当前File对象表示的目录,当前目录必须为空才能删除成功。

Path

Java标准库还提供了一个Path对象,它位于java.nio.file包。Path对象和File对象类似,但操作更加简单:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import java.io.*;
import java.nio.file.*;

public class Main {
public static void main(String[] args) throws IOException {
Path p1 = Paths.get(".", "project", "study"); // 构造一个Path对象
System.out.println(p1);
Path p2 = p1.toAbsolutePath(); // 转换为绝对路径
System.out.println(p2);
Path p3 = p2.normalize(); // 转换为规范路径
System.out.println(p3);
File f = p3.toFile(); // 转换为File对象
System.out.println(f);
for (Path p : Paths.get("..").toAbsolutePath()) { // 可以直接遍历Path
System.out.println(" " + p);
}
}
}

如果需要对目录进行复杂的拼接、遍历等操作,使用Path对象更方便。

小结

Java标准库的java.io.File对象表示一个文件或者目录:

  • 创建File对象本身不涉及IO操作;
  • 可以获取路径/绝对路径/规范路径:getPath()/getAbsolutePath()/getCanonicalPath();
  • 可以获取目录的文件和子目录:list()/listFiles();
  • 可以创建或删除文件和目录。

InputStream

InputStream就是Java标准库提供的最基本的输入流。它位于java.io这个包里。java.io包提供了所有同步IO的功能。
要特别注意的一点是,InputStream并不是一个接口,而是一个抽象类,它是所有输入流的超类。这个抽象类定义的一个最重要的方法就是int read(),签名如下:

1
public abstract int read() throws IOException;

这个方法会读取输入流的下一个字节,并返回字节表示的int值(0~255)。如果已读到末尾,返回-1表示不能继续读取了。
FileInputStream是InputStream的一个子类。顾名思义,FileInputStream就是从文件流中读取数据。下面的代码演示了如何完整地读取一个FileInputStream的所有字节:
1
2
3
4
5
6
7
8
9
10
11
12
public void readFile() throws IOException {
// 创建一个FileInputStream对象:
InputStream input = new FileInputStream("src/readme.txt");
for (;;) {
int n = input.read(); // 反复调用read()方法,直到返回-1
if (n == -1) {
break;
}
System.out.println(n); // 打印byte的值
}
input.close(); // 关闭流
}

在计算机中,类似文件、网络端口这些资源,都是由操作系统统一管理的。应用程序在运行的过程中,如果打开了一个文件进行读写,完成后要及时地关闭,以便让操作系统把资源释放掉,否则,应用程序占用的资源会越来越多,不但白白占用内存,还会影响其他应用程序的运行。
InputStream和OutputStream都是通过close()方法来关闭流。关闭流就会释放对应的底层资源。
我们还要注意到在读取或写入IO流的过程中,可能会发生错误,例如,文件不存在导致无法读取,没有写权限导致写入失败,等等,这些底层错误由Java虚拟机自动封装成IOException异常并抛出。因此,所有与IO操作相关的代码都必须正确处理IOException。
仔细观察上面的代码,会发现一个潜在的问题:如果读取过程中发生了IO错误,InputStream就没法正确地关闭,资源也就没法及时释放。
因此,我们需要用try … finally来保证InputStream在无论是否发生IO错误的时候都能够正确地关闭:
1
2
3
4
5
6
7
8
9
10
11
12
public void readFile() throws IOException {
InputStream input = null;
try {
input = new FileInputStream("src/readme.txt");
int n;
while ((n = input.read()) != -1) { // 利用while同时读取并判断
System.out.println(n);
}
} finally {
if (input != null) { input.close(); }
}
}

用try … finally来编写上述代码会感觉比较复杂,更好的写法是利用Java 7引入的新的try(resource)的语法,只需要编写try语句,让编译器自动为我们关闭资源。推荐的写法如下:
1
2
3
4
5
6
7
8
public void readFile() throws IOException {
try (InputStream input = new FileInputStream("src/readme.txt")) {
int n;
while ((n = input.read()) != -1) {
System.out.println(n);
}
} // 编译器在此自动为我们写入finally并调用close()
}

实际上,编译器并不会特别地为InputStream加上自动关闭。编译器只看try(resource = ...)中的对象是否实现了java.lang.AutoCloseable接口,如果实现了,就自动加上finally语句并调用close()方法。InputStream和OutputStream都实现了这个接口,因此,都可以用在try(resource)中。

缓冲

在读取流的时候,一次读取一个字节并不是最高效的方法。很多流支持一次性读取多个字节到缓冲区,对于文件和网络流来说,利用缓冲区一次性读取多个字节效率往往要高很多。InputStream提供了两个重载方法来支持读取多个字节:

  • int read(byte[] b):读取若干字节并填充到byte[]数组,返回读取的字节数
  • int read(byte[] b, int off, int len):指定byte[]数组的偏移量和最大填充数

    off:在数组b在其中写入数据的起始位置的偏移;
    len: 要读取的字节数。
    要满足 off+len <= b的大小
    缓冲相当于多次执行 input.read(),因此在循环中,如果在下标小于偏移量的数组,是不读取的。

利用上述方法一次读取多个字节时,需要先定义一个byte[]数组作为缓冲区,read()方法会尽可能多地读取字节到缓冲区, 但不会超过缓冲区的大小。read()方法的返回值不再是字节的int值,而是返回实际读取了多少个字节。如果返回-1,表示没有更多的数据了。
利用缓冲区一次读取多个字节的代码如下:

1
2
3
4
5
6
7
8
9
10
public void readFile() throws IOException {
try (InputStream input = new FileInputStream("src/readme.txt")) {
// 定义1000个字节大小的缓冲区:
byte[] buffer = new byte[1000];
int n;
while ((n = input.read(buffer)) != -1) { // 读取到缓冲区
System.out.println("read " + n + " bytes.");
}
}
}

阻塞

在调用InputStream的read()方法读取数据时,我们说read()方法是阻塞(Blocking)的。它的意思是,对于下面的代码:

1
2
3
int n;
n = input.read(); // 必须等待read()方法返回才能执行下一行代码
int m = n;

执行到第二行代码时,必须等read()方法返回后才能继续。因为读取IO流相比执行普通代码,速度会慢很多,因此,无法确定read()方法调用到底要花费多长时间。

InputStream实现类

用FileInputStream可以从文件获取输入流,这是InputStream常用的一个实现类。此外,ByteArrayInputStream可以在内存中模拟一个InputStream:

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

public class Main {
public static void main(String[] args) throws IOException {
byte[] data = { 72, 101, 108, 108, 111, 33 };
try (InputStream input = new ByteArrayInputStream(data)) {
int n;
while ((n = input.read()) != -1) {
System.out.println((char)n);
}
}
}
}

ByteArrayInputStream实际上是把一个byte[]数组在内存中变成一个InputStream,虽然实际应用不多,但测试的时候,可以用它来构造一个InputStream。
举个栗子:我们想从文件中读取所有字节,并转换成char然后拼成一个字符串,可以这么写:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Main {
public static void main(String[] args) throws IOException {
String s;
try (InputStream input = new FileInputStream("C:\\test\\README.txt")) {
int n;
StringBuilder sb = new StringBuilder();
while ((n = input.read()) != -1) {
sb.append((char) n);
}
s = sb.toString();
}
System.out.println(s);
}
}

要测试上面的程序,就真的需要在本地硬盘上放一个真实的文本文件。如果我们把代码稍微改造一下,提取一个readAsString()的方法:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class Main {
public static void main(String[] args) throws IOException {
String s;
try (InputStream input = new FileInputStream("C:\\test\\README.txt")) {
s = readAsString(input);
}
System.out.println(s);
}

public static String readAsString(InputStream input) throws IOException {
int n;
StringBuilder sb = new StringBuilder();
while ((n = input.read()) != -1) {
sb.append((char) n);
}
return sb.toString();
}
}

对这个String readAsString(InputStream input)方法进行测试就相当简单,因为不一定要传入一个真的FileInputStream:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import java.io.*;
public class Main {
public static void main(String[] args) throws IOException {
byte[] data = { 72, 101, 108, 108, 111, 33 };
try (InputStream input = new ByteArrayInputStream(data)) {
String s = readAsString(input);
System.out.println(s);
}
}

public static String readAsString(InputStream input) throws IOException {
int n;
StringBuilder sb = new StringBuilder();
while ((n = input.read()) != -1) {
sb.append((char) n);
}
return sb.toString();
}
}

这就是面向抽象编程原则的应用:接受InputStream抽象类型,而不是具体的FileInputStream类型,从而使得代码可以处理InputStream的任意实现类。

小结

  • Java标准库的java.io.InputStream定义了所有输入流的超类:
    • FileInputStream实现了文件流输入;
    • ByteArrayInputStream在内存中模拟一个字节流输入。
  • 总是使用try(resource)来保证InputStream正确关闭。

OutputStream

和InputStream相反,OutputStream是Java标准库提供的最基本的输出流。
和InputStream类似,OutputStream也是抽象类,它是所有输出流的超类。这个抽象类定义的一个最重要的方法就是void write(int b),签名如下:

1
public abstract void write(int b) throws IOException;

这个方法会写入一个字节到输出流。要注意的是,虽然传入的是int参数,但只会写入一个字节,即只写入int最低8位表示字节的部分(相当于b & 0xff)。
和InputStream类似,OutputStream也提供了close()方法关闭输出流,以便释放系统资源。要特别注意:OutputStream还提供了一个flush()方法,它的目的是将缓冲区的内容真正输出到目的地。
为什么要有flush()?因为向磁盘、网络写入数据的时候,出于效率的考虑,操作系统并不是输出一个字节就立刻写入到文件或者发送到网络,而是把输出的字节先放到内存的一个缓冲区里(本质上就是一个byte[]数组),等到缓冲区写满了,再一次性写入文件或者网络。对于很多IO设备来说,一次写一个字节和一次写1000个字节,花费的时间几乎是完全一样的,所以OutputStream有个flush()方法,能强制把缓冲区内容输出。
通常情况下,我们不需要调用这个flush()方法,因为缓冲区写满了OutputStream会自动调用它,并且,在调用close()方法关闭OutputStream之前,也会自动调用flush()方法。
但是,在某些情况下,我们必须手动调用flush()方法。举个栗子:
小明正在开发一款在线聊天软件,当用户输入一句话后,就通过OutputStream的write()方法写入网络流。小明测试的时候发现,发送方输入后,接收方根本收不到任何信息,怎么肥四?
原因就在于写入网络流是先写入内存缓冲区,等缓冲区满了才会一次性发送到网络。如果缓冲区大小是4K,则发送方要敲几千个字符后,操作系统才会把缓冲区的内容发送出去,这个时候,接收方会一次性收到大量消息。
解决办法就是每输入一句话后,立刻调用flush(),不管当前缓冲区是否已满,强迫操作系统把缓冲区的内容立刻发送出去。
实际上,InputStream也有缓冲区。例如,从FileInputStream读取一个字节时,操作系统往往会一次性读取若干字节到缓冲区,并维护一个指针指向未读的缓冲区。然后,每次我们调用int read()读取下一个字节时,可以直接返回缓冲区的下一个字节,避免每次读一个字节都导致IO操作。当缓冲区全部读完后继续调用read(),则会触发操作系统的下一次读取并再次填满缓冲区。

FileOutputStream

我们以FileOutputStream为例,演示如何将若干个字节写入文件流:

1
2
3
4
5
6
7
8
9
public void writeFile() throws IOException {
OutputStream output = new FileOutputStream("out/readme.txt");
output.write(72); // H
output.write(101); // e
output.write(108); // l
output.write(108); // l
output.write(111); // o
output.close();
}

每次写入一个字节非常麻烦,更常见的方法是一次性写入若干字节。这时,可以用OutputStream提供的重载方法void write(byte[])来实现:
1
2
3
4
5
public void writeFile() throws IOException {
OutputStream output = new FileOutputStream("out/readme.txt");
output.write("Hello".getBytes("UTF-8")); // Hello
output.close();
}

和InputStream一样,上述代码没有考虑到在发生异常的情况下如何正确地关闭资源。写入过程也会经常发生IO错误,例如,磁盘已满,无权限写入等等。我们需要用try(resource)来保证OutputStream在无论是否发生IO错误的时候都能够正确地关闭:
1
2
3
4
5
public void writeFile() throws IOException {
try (OutputStream output = new FileOutputStream("out/readme.txt")) {
output.write("Hello".getBytes("UTF-8")); // Hello
} // 编译器在此自动为我们写入finally并调用close()
}

阻塞

和InputStream一样,OutputStream的write()方法也是阻塞的。

OutputStream实现类

用FileOutputStream可以从文件获取输出流,这是OutputStream常用的一个实现类。此外,ByteArrayOutputStream可以在内存中模拟一个OutputStream:

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

public class Main {
public static void main(String[] args) throws IOException {
byte[] data;
try (ByteArrayOutputStream output = new ByteArrayOutputStream()) {
output.write("Hello ".getBytes("UTF-8"));
output.write("world!".getBytes("UTF-8"));
data = output.toByteArray();
}
System.out.println(new String(data, "UTF-8"));
}
}

ByteArrayOutputStream实际上是把一个byte[]数组在内存中变成一个OutputStream,虽然实际应用不多,但测试的时候,可以用它来构造一个OutputStream。
同时操作多个AutoCloseable资源时,在try(resource) { ... }语句中可以同时写出多个资源,用;隔开。例如,同时读写两个文件:
1
2
3
4
5
6
// 读取input.txt,写入output.txt:
try (InputStream input = new FileInputStream("input.txt");
OutputStream output = new FileOutputStream("output.txt"))
{
input.transferTo(output); // transferTo的作用是?
}

小结

  • Java标准库的java.io.OutputStream定义了所有输出流的超类:
    • FileOutputStream实现了文件流输出;
    • ByteArrayOutputStream在内存中模拟一个字节流输出。
  • 某些情况下需要手动调用OutputStream的flush()方法来强制输出缓冲区。
  • 总是使用try(resource)来保证OutputStream正确关闭。

Filter模式

Java的IO标准库提供的InputStream根据来源可以包括:

  • FileInputStream:从文件读取数据,是最终数据源;
  • ServletInputStream:从HTTP请求读取数据,是最终数据源;
  • Socket.getInputStream():从TCP连接读取数据,是最终数据源;

如果我们要给FileInputStream添加缓冲功能,则可以从FileInputStream派生一个类:

1
BufferedFileInputStream extends FileInputStream

如果要给FileInputStream添加计算签名的功能,类似的,也可以从FileInputStream派生一个类:
1
DigestFileInputStream extends FileInputStream

如果要给FileInputStream添加加密/解密功能,还是可以从FileInputStream派生一个类:
1
CipherFileInputStream extends FileInputStream

如果要给FileInputStream添加缓冲和签名的功能,那么我们还需要派生BufferedDigestFileInputStream。如果要给FileInputStream添加缓冲和加解密的功能,则需要派生BufferedCipherFileInputStream。
我们发现,给FileInputStream添加3种功能,至少需要3个子类。这3种功能的组合,又需要更多的子类:
1
2
3
4
5
6
7
8
9
10
11
12
13
                         ┌─────────────────┐
│ FileInputStream │
└─────────────────┘

┌───────────┬─────────┼─────────┬───────────┐
│ │ │ │ │
┌───────────────────────┐│┌─────────────────┐│┌─────────────────────┐
│BufferedFileInputStream│││DigestInputStream│││CipherFileInputStream│
└───────────────────────┘│└─────────────────┘│└─────────────────────┘
│ │
┌─────────────────────────────┐ ┌─────────────────────────────┐
│BufferedDigestFileInputStream│ │BufferedCipherFileInputStream│
└─────────────────────────────┘ └─────────────────────────────┘

这还只是针对FileInputStream设计,如果针对另一种InputStream设计,很快会出现子类爆炸的情况。
因此,直接使用继承,为各种InputStream附加更多的功能,根本无法控制代码的复杂度,很快就会失控。
为了解决依赖继承会导致子类数量失控的问题,JDK首先将InputStream分为两大类:

一类是直接提供数据的基础InputStream,例如:

  • FileInputStream
  • ByteArrayInputStream
  • ServletInputStream

一类是提供额外附加功能的InputStream,例如:

  • BufferedInputStream
  • DigestInputStream
  • CipherInputStream

当我们需要给一个“基础”InputStream附加各种功能时,我们先确定这个能提供数据源的InputStream,因为我们需要的数据总得来自某个地方,例如,FileInputStream,数据来源自文件:

1
InputStream file = new FileInputStream("test.gz");

紧接着,我们希望FileInputStream能提供缓冲的功能来提高读取的效率,因此我们用BufferedInputStream包装这个InputStream,得到的包装类型是BufferedInputStream,但它仍然被视为一个InputStream:
1
InputStream buffered = new BufferedInputStream(file);

无论我们包装多少次,得到的对象始终是InputStream,我们直接用InputStream来引用它,就可以正常读取:
1
2
3
4
5
6
7
8
9
┌─────────────────────────┐
│GZIPInputStream │
│┌───────────────────────┐│
││BufferedFileInputStream││
││┌─────────────────────┐││
│││ FileInputStream │││
││└─────────────────────┘││
│└───────────────────────┘│
└─────────────────────────┘

上述这种通过一个“基础”组件再叠加各种“附加”功能组件的模式,称之为Filter模式(或者装饰器模式:Decorator)。它可以让我们通过少量的类来实现各种功能的组合:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
                  ┌─────────────┐
│ InputStream │
└─────────────┘
▲ ▲
┌────────────────────┐ │ │ ┌─────────────────┐
│ FileInputStream │─┤ └─│FilterInputStream│
└────────────────────┘ │ └─────────────────┘
┌────────────────────┐ │ ▲ ┌───────────────────┐
│ByteArrayInputStream│─┤ ├─│BufferedInputStream│
└────────────────────┘ │ │ └───────────────────┘
┌────────────────────┐ │ │ ┌───────────────────┐
│ ServletInputStream │─┘ ├─│ DataInputStream │
└────────────────────┘ │ └───────────────────┘
│ ┌───────────────────┐
└─│CheckedInputStream │
└───────────────────┘

类似的,OutputStream也是以这种模式来提供各种功能:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
                  ┌─────────────┐
│OutputStream │
└─────────────┘
▲ ▲
┌─────────────────────┐ │ │ ┌──────────────────┐
│ FileOutputStream │─┤ └─│FilterOutputStream│
└─────────────────────┘ │ └──────────────────┘
┌─────────────────────┐ │ ▲ ┌────────────────────┐
│ByteArrayOutputStream│─┤ ├─│BufferedOutputStream│
└─────────────────────┘ │ │ └────────────────────┘
┌─────────────────────┐ │ │ ┌────────────────────┐
│ ServletOutputStream │─┘ ├─│ DataOutputStream │
└─────────────────────┘ │ └────────────────────┘
│ ┌────────────────────┐
└─│CheckedOutputStream │
└────────────────────┘

编写FilterInputStream

我们也可以自己编写FilterInputStream,以便可以把自己的FilterInputStream“叠加”到任何一个InputStream中。
下面的例子演示了如何编写一个CountInputStream,它的作用是对输入的字节进行计数:

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
import java.io.*;

public class Main {
public static void main(String[] args) throws IOException {
byte[] data = "hello, world!".getBytes("UTF-8");
try (CountInputStream input = new CountInputStream(new ByteArrayInputStream(data))) {
int n;
while ((n = input.read()) != -1) {
System.out.println((char)n);
}
System.out.println("Total read " + input.getBytesRead() + " bytes");
}
}
}

class CountInputStream extends FilterInputStream {
private int count = 0;

CountInputStream(InputStream in) {
super(in);
}

public int getBytesRead() {
return this.count;
}

public int read() throws IOException {
int n = in.read();
if (n != -1) {
this.count ++;
}
return n;
}

public int read(byte[] b, int off, int len) throws IOException {
int n = in.read(b, off, len);
if (n != -1) {
this.count += n;
}
return n;
}
}

注意到在叠加多个FilterInputStream,我们只需要持有最外层的InputStream,并且,当最外层的InputStream关闭时(在try(resource)块的结束处自动关闭),内层的InputStream的close()方法也会被自动调用,并最终调用到最核心的“基础”InputStream,因此不存在资源泄露。

小结

  • Java的IO标准库使用Filter模式为InputStream和OutputStream增加功能:
    • 可以把一个InputStream和任意个FilterInputStream组合;
    • 可以把一个OutputStream和任意个FilterOutputStream组合。
  • Filter模式可以在运行期动态增加功能(又称Decorator模式)。

操作Zip

ZipInputStream是一种FilterInputStream,它可以直接读取zip包的内容:

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


┌───────────────────┐
│ FilterInputStream │
└───────────────────┘


┌───────────────────┐
│InflaterInputStream│
└───────────────────┘


┌───────────────────┐
│ ZipInputStream │
└───────────────────┘


┌───────────────────┐
│ JarInputStream │
└───────────────────┘

另一个JarInputStream是从ZipInputStream派生,它增加的主要功能是直接读取jar文件里面的MANIFEST.MF文件。因为本质上jar包就是zip包,只是额外附加了一些固定的描述文件。

读取zip包

我们来看看ZipInputStream的基本用法。
我们要创建一个ZipInputStream,通常是传入一个FileInputStream作为数据源,然后,循环调用getNextEntry(),直到返回null,表示zip流结束。
一个ZipEntry表示一个压缩文件或目录,如果是压缩文件,我们就用read()方法不断读取,直到返回-1:

1
2
3
4
5
6
7
8
9
10
11
try (ZipInputStream zip = new ZipInputStream(new FileInputStream(...))) {
ZipEntry entry = null;
while ((entry = zip.getNextEntry()) != null) {
String name = entry.getName();
if (!entry.isDirectory()) {
int n;
while ((n = zip.read()) != -1) {
...
}
}
}

写入zip包

ZipOutputStream是一种FilterOutputStream,它可以直接写入内容到zip包。我们要先创建一个ZipOutputStream,通常是包装一个FileOutputStream,然后,每写入一个文件前,先调用putNextEntry(),然后用write()写入byte[]数据,写入完毕后调用closeEntry()结束这个文件的打包:

1
2
3
4
5
6
7
8
try (ZipOutputStream zip = new ZipOutputStream(new FileOutputStream(...))) {
File[] files = ...
for (File file : files) {
zip.putNextEntry(new ZipEntry(file.getName()));
zip.write(getFileDataAsBytes(file));
zip.closeEntry();
}
}

上面的代码没有考虑文件的目录结构。如果要实现目录层次结构,new ZipEntry(name)传入的name要用相对路径。

小结

  • ZipInputStream可以读取zip格式的流,ZipOutputStream可以把多份数据写入zip包;
  • 配合FileInputStream和FileOutputStream就可以读写zip文件。

读取classpath资源

很多Java程序启动的时候,都需要读取配置文件。例如,从一个.properties文件中读取配置:

1
2
3
4
String conf = "C:\\conf\\default.properties";
try (InputStream input = new FileInputStream(conf)) {
// TODO:
}

这段代码要正常执行,必须在C盘创建conf目录,然后在目录里创建default.properties文件。但是,在Linux系统上,路径和Windows的又不一样。
因此,从磁盘的固定目录读取配置文件,不是一个好的办法。
有没有路径无关的读取文件的方式呢?
我们知道,Java存放.class的目录或jar包也可以包含任意其他类型的文件,例如:

  • 配置文件,例如.properties;
  • 图片文件,例如.jpg;
  • 文本文件,例如.txt,.csv;
  • ……

从classpath读取文件就可以避免不同环境下文件路径不一致的问题:如果我们把default.properties文件放到classpath中,就不用关心它的实际存放路径。
在classpath中的资源文件,路径总是以/开头,我们先获取当前的Class对象,然后调用getResourceAsStream()就可以直接从classpath读取任意的资源文件:

1
2
3
try (InputStream input = getClass().getResourceAsStream("/default.properties")) {
// TODO:
}

调用getResourceAsStream()需要特别注意的一点是,如果资源文件不存在,它将返回null。因此,我们需要检查返回的InputStream是否为null,如果为null,表示资源文件在classpath中没有找到:
1
2
3
4
5
try (InputStream input = getClass().getResourceAsStream("/default.properties")) {
if (input != null) {
// TODO:
}
}

如果我们把默认的配置放到jar包中,再从外部文件系统读取一个可选的配置文件,就可以做到既有默认的配置文件,又可以让用户自己修改配置:
1
2
3
Properties props = new Properties();
props.load(inputStreamFromClassPath("/default.properties"));
props.load(inputStreamFromFile("./conf.properties"));

这样读取配置文件,应用程序启动就更加灵活。

小结

  • 把资源存储在classpath中可以避免文件路径依赖;
  • Class对象的getResourceAsStream()可以从classpath中读取指定资源;
  • 根据classpath读取资源时,需要检查返回的InputStream是否为null。

序列化

序列化是指把一个Java对象变成二进制内容,本质上就是一个byte[]数组。
为什么要把Java对象序列化呢?因为序列化后可以把byte[]保存到文件中,或者把byte[]通过网络传输到远程,这样,就相当于把Java对象存储到文件或者通过网络传输出去了。
有序列化,就有反序列化,即把一个二进制内容(也就是byte[]数组)变回Java对象。有了反序列化,保存到文件中的byte[]数组又可以“变回”Java对象,或者从网络上读取byte[]并把它“变回”Java对象。
我们来看看如何把一个Java对象序列化。
一个Java对象要能序列化,必须实现一个特殊的java.io.Serializable接口,它的定义如下:

1
2
public interface Serializable {
}

Serializable接口没有定义任何方法,它是一个空接口。我们把这样的空接口称为“标记接口”(Marker Interface),实现了标记接口的类仅仅是给自身贴了个“标记”,并没有增加任何方法。

序列化

把一个Java对象变为byte[]数组,需要使用ObjectOutputStream。它负责把一个Java对象写入一个字节流:

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

public class Main {
public static void main(String[] args) throws IOException {
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
try (ObjectOutputStream output = new ObjectOutputStream(buffer)) {
// 写入int:
output.writeInt(12345);
// 写入String:
output.writeUTF("Hello");
// 写入Object:
output.writeObject(Double.valueOf(123.456));
}
System.out.println(Arrays.toString(buffer.toByteArray()));
}
}

ObjectOutputStream既可以写入基本类型,如int,boolean,也可以写入String(以UTF-8编码),还可以写入实现了Serializable接口的Object。
因为写入Object时需要大量的类型信息,所以写入的内容很大。

反序列化

和ObjectOutputStream相反,ObjectInputStream负责从一个字节流读取Java对象:

1
2
3
4
5
try (ObjectInputStream input = new ObjectInputStream(...)) {
int n = input.readInt();
String s = input.readUTF();
Double d = (Double) input.readObject();
}

除了能读取基本类型和String类型外,调用readObject()可以直接返回一个Object对象。要把它变成一个特定类型,必须强制转型。
readObject()可能抛出的异常有:

  • ClassNotFoundException:没有找到对应的Class;
  • InvalidClassException:Class不匹配。

对于ClassNotFoundException,这种情况常见于一台电脑上的Java程序把一个Java对象,例如,Person对象序列化以后,通过网络传给另一台电脑上的另一个Java程序,但是这台电脑的Java程序并没有定义Person类,所以无法反序列化。
对于InvalidClassException,这种情况常见于序列化的Person对象定义了一个int类型的age字段,但是反序列化时,Person类定义的age字段被改成了long类型,所以导致class不兼容。
为了避免这种class定义变动导致的不兼容,Java的序列化允许class定义一个特殊的serialVersionUID静态变量,用于标识Java类的序列化“版本”,通常可以由IDE自动生成。如果增加或修改了字段,可以改变serialVersionUID的值,这样就能自动阻止不匹配的class版本:

1
2
3
public class Person implements Serializable {
private static final long serialVersionUID = 2709425275741743919L;
}

要特别注意反序列化的几个重要特点:
反序列化时,由JVM直接构造出Java对象,不调用构造方法,构造方法内部的代码,在反序列化时根本不可能执行。

安全性

因为Java的序列化机制可以导致一个实例能直接从byte[]数组创建,而不经过构造方法,因此,它存在一定的安全隐患。一个精心构造的byte[]数组被反序列化后可以执行特定的Java代码,从而导致严重的安全漏洞。
实际上,Java本身提供的基于对象的序列化和反序列化机制既存在安全性问题,也存在兼容性问题。更好的序列化方法是通过JSON这样的通用数据结构来实现,只输出基本类型(包括String)的内容,而不存储任何与代码相关的信息。

小结

  • 可序列化的Java对象必须实现java.io.Serializable接口,类似Serializable这样的空接口被称为“标记接口”(Marker Interface);
  • 反序列化时不调用构造方法,可设置serialVersionUID作为版本号(非必需);
  • Java的序列化机制仅适用于Java,如果需要与其它语言交换数据,必须使用通用的序列化方法,例如JSON。

Reader

Reader是Java的IO库提供的另一个输入流接口。和InputStream的区别是,InputStream是一个字节流,即以byte为单位读取,而Reader是一个字符流,即以char为单位读取:

InputStream Reader
字节流,以byte为单位 字符流,以char为单位
读取字节(-1,0~255)int read() 读取字符(-1,0~65535)int read()
读到字节数组:int read(byte[] b) 读到字符数组:int read(char[] c)

java.io.Reader是所有字符输入流的超类,它最主要的方法是:

1
public int read() throws IOException;

这个方法读取字符流的下一个字符,并返回字符表示的int,范围是0~65535。如果已读到末尾,返回-1。

FileReader

FileReader是Reader的一个子类,它可以打开文件并获取Reader。下面的代码演示了如何完整地读取一个FileReader的所有字符:

1
2
3
4
5
6
7
8
9
10
11
12
public void readFile() throws IOException {
// 创建一个FileReader对象:
Reader reader = new FileReader("src/readme.txt"); // 字符编码是???
for (;;) {
int n = reader.read(); // 反复调用read()方法,直到返回-1
if (n == -1) {
break;
}
System.out.println((char)n); // 打印char
}
reader.close(); // 关闭流
}

如果我们读取一个纯ASCII编码的文本文件,上述代码工作是没有问题的。但如果文件中包含中文,就会出现乱码,因为FileReader默认的编码与系统相关,例如,Windows系统的默认编码可能是GBK,打开一个UTF-8编码的文本文件就会出现乱码。
要避免乱码问题,我们需要在创建FileReader时指定编码:
1
Reader reader = new FileReader("src/readme.txt", StandardCharsets.UTF_8);

和InputStream类似,Reader也是一种资源,需要保证出错的时候也能正确关闭,所以我们需要用try (resource)来保证Reader在无论有没有IO错误的时候都能够正确地关闭:
1
2
3
try (Reader reader = new FileReader("src/readme.txt", StandardCharsets.UTF_8) {
// TODO
}

Reader还提供了一次性读取若干字符并填充到char[]数组的方法:
1
public int read(char[] c) throws IOException

它返回实际读入的字符个数,最大不超过char[]数组的长度。返回-1表示流结束。
利用这个方法,我们可以先设置一个缓冲区,然后,每次尽可能地填充缓冲区:
1
2
3
4
5
6
7
8
9
public void readFile() throws IOException {
try (Reader reader = new FileReader("src/readme.txt", StandardCharsets.UTF_8)) {
char[] buffer = new char[1000];
int n;
while ((n = reader.read(buffer)) != -1) {
System.out.println("read " + n + " chars.");
}
}
}

CharArrayReader

CharArrayReader可以在内存中模拟一个Reader,它的作用实际上是把一个char[]数组变成一个Reader,这和ByteArrayInputStream非常类似:

1
2
try (Reader reader = new CharArrayReader("Hello".toCharArray())) {
}

StringReader

StringReader可以直接把String作为数据源,它和CharArrayReader几乎一样:

1
2
try (Reader reader = new StringReader("Hello")) {
}

InputStreamReader

Reader和InputStream有什么关系?
除了特殊的CharArrayReader和StringReader,普通的Reader实际上是基于InputStream构造的,因为Reader需要从InputStream中读入字节流(byte),然后,根据编码设置,再转换为char就可以实现字符流。如果我们查看FileReader的源码,它在内部实际上持有一个FileInputStream。
既然Reader本质上是一个基于InputStream的byte到char的转换器,那么,如果我们已经有一个InputStream,想把它转换为Reader,是完全可行的。InputStreamReader就是这样一个转换器,它可以把任何InputStream转换为Reader。示例代码如下:

1
2
3
4
// 持有InputStream:
InputStream input = new FileInputStream("src/readme.txt");
// 变换为Reader:
Reader reader = new InputStreamReader(input, "UTF-8");

构造InputStreamReader时,我们需要传入InputStream,还需要指定编码,就可以得到一个Reader对象。上述代码可以通过try (resource)更简洁地改写如下:
1
2
3
try (Reader reader = new InputStreamReader(new FileInputStream("src/readme.txt"), "UTF-8")) {
// TODO:
}

上述代码实际上就是FileReader的一种实现方式。
使用try (resource)结构时,当我们关闭Reader时,它会在内部自动调用InputStream的close()方法,所以,只需要关闭最外层的Reader对象即可。

使用InputStreamReader,可以把一个InputStream转换成一个Reader。

小结

  • Reader定义了所有字符输入流的超类:
    • FileReader实现了文件字符流输入,使用时需要指定编码;
    • CharArrayReader和StringReader可以在内存中模拟一个字符流输入。
  • Reader是基于InputStream构造的:可以通过InputStreamReader在指定编码的同时将任何InputStream转换为Reader。
  • 总是使用try (resource)保证Reader正确关闭。

Writer

Reader是带编码转换器的InputStream,它把byte转换为char,而Writer就是带编码转换器的OutputStream,它把char转换为byte并输出。
Writer和OutputStream的区别如下:

OutputStream Writer
字节流,以byte为单位 字符流,以char为单位
写入字节(0~255):void write(int b) 写入字符(0~65535):void write(int c)
写入字节数组:void write(byte[] b) 写入字符数组:void write(char[] c)
无对应方法 写入String:void write(String s)

Writer是所有字符输出流的超类,它提供的方法主要有:

  • 写入一个字符(0~65535):void write(int c);
  • 写入字符数组的所有字符:void write(char[] c);
  • 写入String表示的所有字符:void write(String s)。

FileWriter

FileWriter就是向文件中写入字符流的Writer。它的使用方法和FileReader类似:

1
2
3
4
5
try (Writer writer = new FileWriter("readme.txt", StandardCharsets.UTF_8)) {
writer.write('H'); // 写入单个字符
writer.write("Hello".toCharArray()); // 写入char[]
writer.write("Hello"); // 写入String
}

CharArrayWriter

CharArrayWriter可以在内存中创建一个Writer,它的作用实际上是构造一个缓冲区,可以写入char,最后得到写入的char[]数组,这和ByteArrayOutputStream非常类似:

1
2
3
4
5
6
try (CharArrayWriter writer = new CharArrayWriter()) {
writer.write(65);
writer.write(66);
writer.write(67);
char[] data = writer.toCharArray(); // { 'A', 'B', 'C' }
}

StringWriter

StringWriter也是一个基于内存的Writer,它和CharArrayWriter类似。实际上,StringWriter在内部维护了一个StringBuffer,并对外提供了Writer接口。

OutputStreamWriter

除了CharArrayWriter和StringWriter外,普通的Writer实际上是基于OutputStream构造的,它接收char,然后在内部自动转换成一个或多个byte,并写入OutputStream。因此,OutputStreamWriter就是一个将任意的OutputStream转换为Writer的转换器:

1
2
3
try (Writer writer = new OutputStreamWriter(new FileOutputStream("readme.txt"), "UTF-8")) {
// TODO:
}

上述代码实际上就是FileWriter的一种实现方式。这和上一节的InputStreamReader是一样的。

小结

  • Writer定义了所有字符输出流的超类:
    • FileWriter实现了文件字符流输出;
    • CharArrayWriter和StringWriter在内存中模拟一个字符流输出。
  • 使用try (resource)保证Writer正确关闭。
  • Writer是基于OutputStream构造的,可以通过OutputStreamWriter将OutputStream转换为Writer,转换时需要指定编码。

PrintStream和PrintWriter

PrintStream

PrintStream是一种FilterOutputStream,它在OutputStream的接口上,额外提供了一些写入各种数据类型的方法:

  • 写入int:print(int)
  • 写入boolean:print(boolean)
  • 写入String:print(String)
  • 写入Object:print(Object),实际上相当于print(object.toString())

以及对应的一组println()方法,它会自动加上换行符。
我们经常使用的System.out.println()实际上就是使用PrintStream打印各种数据。其中,System.out是系统默认提供的PrintStream,表示标准输出:

1
2
3
System.out.print(12345); // 输出12345
System.out.print(new Object()); // 输出类似java.lang.Object@3c7a835a
System.out.println("Hello"); // 输出Hello并换行

System.err是系统默认提供的标准错误输出。
PrintStream和OutputStream相比,除了添加了一组print()/println()方法,可以打印各种数据类型,比较方便外,它还有一个额外的优点,就是不会抛出IOException,这样我们在编写代码的时候,就不必捕获IOException。

PrintWriter

PrintStream最终输出的总是byte数据,而PrintWriter则是扩展了Writer接口,它的print()/println()方法最终输出的是char数据。两者的使用方法几乎是一模一样的:

1
2
3
4
5
6
7
8
9
10
11
12
import java.io.*;
public class Main {
public static void main(String[] args) {
StringWriter buffer = new StringWriter();
try (PrintWriter pw = new PrintWriter(buffer)) {
pw.println("Hello");
pw.println(12345);
pw.println(true);
}
System.out.println(buffer.toString());
}
}

小结

  • PrintStream是一种能接收各种数据类型的输出,打印数据时比较方便:
    • System.out是标准输出;
    • System.err是标准错误输出。
  • PrintWriter是基于Writer的输出。

使用Files

从Java 7开始,提供了Files和Paths这两个工具类,能极大地方便我们读写文件。
虽然Files和Paths是java.nio包里面的类,但他俩封装了很多读写文件的简单方法,例如,我们要把一个文件的全部内容读取为一个byte[],可以这么写:

1
byte[] data = Files.readAllBytes(Paths.get("/path/to/file.txt"));

如果是文本文件,可以把一个文件的全部内容读取为String:
1
2
3
4
5
6
// 默认使用UTF-8编码读取:
String content1 = Files.readString(Paths.get("/path/to/file.txt"));
// 可指定编码:
String content2 = Files.readString(Paths.get("/path/to/file.txt"), StandardCharsets.ISO_8859_1);
// 按行读取并返回每行内容:
List<String> lines = Files.readAllLines(Paths.get("/path/to/file.txt"));

写入文件也非常方便:
1
2
3
4
5
6
7
8
// 写入二进制文件:
byte[] data = ...
Files.write(Paths.get("/path/to/file.txt"), data);
// 写入文本并指定编码:
Files.writeString(Paths.get("/path/to/file.txt"), "文本内容...", StandardCharsets.ISO_8859_1);
// 按行写入文本:
List<String> lines = ...
Files.write(Paths.get("/path/to/file.txt"), lines);

此外,Files工具类还有copy()、delete()、exists()、move()等快捷方法操作文件和目录。
最后需要特别注意的是,Files提供的读写方法,受内存限制,只能读写小文件,例如配置文件等,不可一次读入几个G的大文件。读写大型文件仍然要使用文件流,每次只读写一部分文件内容。

小结

  • 对于简单的小文件读写操作,可以使用Files工具类简化代码。

length(), length, size()

  • length() 方法是针对字符串来说的,要求一个字符串的长度就要用到它的length()方法;
  • length 属性是针对 Java 中的数组来说的,要求数组的长度可以用其 length 属性;
  • size() 方法是针对泛型集合说的, 如果想看这个泛型有多少个元素, 就调用此方法来查看!

例子

1
2
import java.util.ArrayList;
import java.util.List;

public class Main {

  public static void main(String[] args) {
     String array[] = { "First", "Second", "Third" };
     String a = "HelloWorld";
     List<String> list = new ArrayList<String>();
     list.add(a);
     System.out.println("数组array的长度为" + array.length);
     System.out.println("字符串a的长度为" + a.length());
     System.out.println("list中元素个数为" + list.size());

  }

}

输出

数组array的长度为3
字符串a的长度为10
list中元素个数为1

安装

注意:解压压缩包到本地磁盘(解压目录不要有中文、空格)

目录讲解

  • bin:可执行的脚本命令
  • conf:配置文件
  • lib:maven项目运行需要的jar包

maven的好处

maven项目找jar包的过程

maven好处如何实现

maven的两大核心

依赖管理:对jar包管理过程
项目构建:项目在编码完成后,对项目进行编译、测试、打包、部署等一系列操作都通过 命令 实现

实例

通过maven命令将web项目发布到tomcat:

1
mvn tomcat:run

maven安装、配置本地仓库

maven程序安装前提:maven程序java开发,它的运行依赖:jdk。

maven的下载安装

jdk需要有:JAVE_HOME

  1. 官网下载
  2. 解压到本地磁盘(解压目录不要有中文、空格)
  3. 配置环境变量
    1. MAVEN_HOME: bin目录上一级
    2. MAVEN_HOME 环境变量配置到 path 环境变量中: %MAVEN_HOME%\bin
  4. 测试
    1
    mvn -v

配置本地仓库

在本地磁盘上存储各种各样的jar包

仓库类型

配置本地仓库

  1. 找到jar包仓库压缩包(配套文件中的 bos_repository.zip
  2. 解压到本地磁盘
  3. 配置本地仓库:让maven程序知道仓库在哪(maven-conf-settings.xml)
    1
    <localRepository>[改为自己的本地仓库目录]</localRepository>

maven标准目录结构

对项目文件进行细分:

  • src: 项目源码
  • pom.xml: project object module(maven项目核心配置文件)
  • target: src项目源码编译完成存到target文件夹中(不属于maven项目标准目录结构)

maven常用命令

  • clean: 清理

    将项目根目录target目录清理

  • compile: 编译

    将项目中 .java文件编译为 .class文件

  • test: 单元测试

    将项目根目录下 src/test/java 目录下的单元测试类都会执行
    单元测试类名有要求: XxxxTest.java

  • package: 打包
    web project — war包
    java project – jar包

    将项目打包,导报项目根目录下target目录

  • install: 安装

    解决本地多个项目公用一个jar包
    打包到本地仓库

maven项目的生命周期

在maven中存在“三套”生命周期,每一套生命周期相互独立,互不影响。

  1. CleanLifeCycle: 清理生命周期
    • clean
  2. defaultLifeCycle: 默认生命周期
    • compile
    • test
    • package
    • install
    • deploy
  3. siteLifeCycle: 站点生命周期
    • site

在一套生命周期内,执行后面的命令前面的命令会自动执行。

创建maven项目

  • Group Id: 公司名称(公司域名倒写)
  • Artifact Id: 项目名称
  • Version: 版本:
    • SNAPSHOT: 测试版本
    • RELEASES: 正式版本(发行版本)
  • Packaging: 打包方式
    • jar: java project
    • war: web project
    • pom: 父工程

依赖范围

依赖范围 对于编译classpath有效 对于测试classpath有效 对于运行classpath有效 例子
compile Y Y Y spring-core
test - Y - Junit
provided Y Y - servlet-api
runtime - Y Y JDBC驱动
system Y Y - 本地的,maven仓库之外的类库

添加依赖范围:默认compile
privided: 运行部署到tomcat不再需要

如果将 servlet-api.jar 设置为 compile,打包后包含 servlet-api.jar,war包部署到tomcat跟tomcat中存在的 servlet-api.jar 冲突,导致运行失败。

总结:如果使用到tomcat自带的jar包,一定要将项目依赖范围设置为:provided

简介

Collection

Java 标准库自带的 java.util 包提供了集合类:Collection,它是除 Map 外所有其他集合类的根接口。Java 的 java.util 包主要提供了以下三种类型的集合:

  • List:一种有序列表的集合,例如,按索引排列的 Student 的 List;
  • Set:一种保证没有重复元素的集合,例如,所有无重复名称的 Student 的 Set;
  • Map:一种通过键值(key-value)查找的映射表集合,例如,根据 Student 的 name 查找对应 Student 的 Map。

Java 集合的设计有几个特点:一是实现了接口和实现类相分离,例如,有序表的接口是 List,具体的实现类有 ArrayList,LinkedList 等,二是支持泛型,我们可以限制在一个集合中只能放入同一种数据类型的元素,例如:

1
List<String> list = new ArrayList<>(); // 只能放入 String 类型

最后,Java 访问集合总是通过统一的方式——迭代器(Iterator)来实现,它最明显的好处在于无需知道集合内部元素是按什么方式存储的。

由于 Java 的集合设计非常久远,中间经历过大规模改进,我们要注意到有一小部分集合类是遗留类,不应该继续使用:

  • Hashtable:一种线程安全的 Map 实现;
  • Vector:一种线程安全的 List 实现;
  • Stack:基于 Vector 实现的 LIFO 的栈。

还有一小部分接口是遗留接口,也不应该继续使用:

  • Enumeration:已被 Iterator 取代。

小结

  • Java 的集合类定义在 java.util 包中,支持泛型,主要提供了 3 种集合类,包括 List,Set 和 Map。Java 集合使用统一的 Iterator 遍历,尽量不要使用遗留接口。

List

List 是最基础的一种集合:它是一种有序列表。
List 的行为和数组几乎完全相同:List 内部按照放入元素的先后顺序存放,每个元素都可以通过索引确定自己的位置,List 的索引和数组一样,从 0 开始。
数组和 List 类似,也是有序结构,如果我们使用数组,在添加和删除元素的时候,会非常不方便。

在实际应用中,需要增删元素的有序列表,我们使用最多的是 ArrayList。实际上,ArrayList 在内部使用了数组来存储所有元素。例如,一个 ArrayList 拥有 5 个元素,实际数组大小为 6(即有一个空位)
当添加一个元素并指定索引到 ArrayList 时,ArrayList 自动移动需要移动的元素;
然后,往内部指定索引的数组位置添加一个元素,然后把 size 加 1;
继续添加元素,但是数组已满,没有空闲位置的时候,ArrayList 先创建一个更大的新数组,然后把旧数组的所有元素复制到新数组,紧接着用新数组取代旧数组;
现在,新数组就有了空位,可以继续添加一个元素到数组末尾,同时 size 加 1;
可见,ArrayList 把添加和删除的操作封装起来,让我们操作 List 类似于操作数组,却不用关心内部元素如何移动。

List 接口

  • 在末尾添加一个元素:boolean add(E e)
  • 在指定索引添加一个元素:boolean add(int index, E e)
  • 删除指定索引的元素:int remove(int index)
  • 删除某个元素:int remove(Object e)
  • 获取指定索引的元素:E get(int index)
  • 获取链表大小(包含元素的个数):int size()

但是,实现 List 接口并非只能通过数组(即 ArrayList 的实现方式)来实现,另一种 LinkedList 通过 “链表” 也实现了 List 接口。在 LinkedList 中,它的内部每个元素都指向下一个元素:

1
2
3
        ┌───┬───┐   ┌───┬───┐   ┌───┬───┐   ┌───┬───┐
HEAD ──>│ A │ ●─┼──>│ B │ ●─┼──>│ C │ ●─┼──>│ D │ │
└───┴───┘ └───┴───┘ └───┴───┘ └───┴───┘

我们来比较一下 ArrayList 和 LinkedList:

ArrayList LinkedList
获取指定元素 速度很快 需要从头开始查找元素
添加元素到末尾 速度很快 速度很快
在指定位置添加 / 删除 需要移动元素 不需要移动元素
内存占用 较大
通常情况下,我们总是优先使用 ArrayList。

List 的特点

使用 List 时,我们要关注 List 接口的规范。List 接口允许我们添加重复的元素,即 List 内部的元素可以重复:

1
2
3
4
5
6
7
8
9
public class Main {
public static void main(String[] args) {
List<String> list = new ArrayList<String>();
list.add("apple"); // size=1
list.add("pear"); // size=2
list.add("apple"); // 允许重复添加元素,size=3
System.out.println(list.size());
}
}

List 还允许添加 null:
1
2
3
4
5
6
7
8
9
10
public class Main {
public static void main(String[] args) {
List<String> list = new ArrayList<>();
list.add("apple"); // size=1
list.add(null); // size=2
list.add("pear"); // size=3
String second = list.get(1); // null
System.out.println(second);
}
}

创建 List

除了使用 ArrayList 和 LinkedList,我们还可以通过 List 接口提供的 of() 方法,根据给定元素快速创建 List:
除了使用 ArrayList 和 LinkedList,我们还可以通过 List 接口提供的 of() 方法,根据给定元素快速创建 List:

1
List<Integer> list = List.of(1, 2, 5);

但是 List.of() 方法不接受 null 值,如果传入 null,会抛出 NullPointerException 异常。

遍历List

和数组类型,我们要遍历一个 List,完全可以用 for 循环根据索引配合 get(int) 方法遍历:

1
2
3
4
5
6
7
8
9
public class Main {
public static void main(String[] args) {
List<String> list = List.of("apple", "pear", "banana");
for (int i=0; i<list.size(); i++) {
String s = list.get(i);
System.out.println(s);
}
}
}

但这种方式并不推荐,一是代码复杂,二是因为get(int)方法只有ArrayList的实现是高效的,换成LinkedList后,索引越大,访问速度越慢。
所以我们要始终坚持使用迭代器Iterator来访问List。Iterator本身也是一个对象,但它是由List的实例调用iterator()方法的时候创建的。Iterator对象知道如何遍历一个List,并且不同的List类型,返回的Iterator对象实现也是不同的,但总是具有最高的访问效率。
Iterator对象有两个方法:boolean hasNext()判断是否有下一个元素,E next()返回下一个元素。因此,使用Iterator遍历List代码如下:
1
2
3
4
5
6
7
8
9
public class Main {
public static void main(String[] args) {
List<String> list = List.of("apple", "pear", "banana");
for (Iterator<String> it = list.iterator(); it.hasNext(); ) {
String s = it.next();
System.out.println(s);
}
}
}

有童鞋可能觉得使用 Iterator 访问 List 的代码比使用索引更复杂。但是,要记住,通过 Iterator 遍历 List 永远是最高效的方式。并且,由于 Iterator 遍历是如此常用,所以,Java 的 for each 循环本身就可以帮我们使用 Iterator 遍历。把上面的代码再改写如下:
1
2
3
4
5
6
7
8
public class Main {
public static void main(String[] args) {
List<String> list = List.of("apple", "pear", "banana");
for (String s : list) {
System.out.println(s);
}
}
}

上述代码就是我们编写遍历 List 的常见代码。

实际上,只要实现了 Iterable 接口的集合类都可以直接用 for each 循环来遍历,Java 编译器本身并不知道如何遍历集合对象,但它会自动把 for each 循环变成 Iterator 的调用,原因就在于 Iterable 接口定义了一个 Iterator<E> iterator() 方法,强迫集合类必须返回一个 Iterator 实例。

Note: Java的 for - each 遍历不是只读的。

List和Array转换

把List变为Array有三种方法,第一种是调用toArray()方法直接返回一个Object[]数组:

1
2
3
4
5
6
7
8
9
public class Main {
public static void main(String[] args) {
List<String> list = List.of("apple", "pear", "banana");
Object[] array = list.toArray();
for (Object s : array) {
System.out.println(s);
}
}
}

这种方法会丢失类型信息,所以实际应用很少。

第二种方式是给 toArray(T[]) 传入一个类型相同的 Array,List 内部自动把元素复制到传入的 Array 中:

1
2
3
4
5
6
7
8
9
public class Main {
public static void main(String[] args) {
List<Integer> list = List.of(12, 34, 56);
Integer[] array = list.toArray(new Integer[3]);
for (Integer n : array) {
System.out.println(n);
}
}
}

注意到这个 toArray(T[]) 方法的泛型参数 <T> 并不是 List 接口定义的泛型参数 <E>,所以,我们实际上可以传入其他类型的数组,例如我们传入 Number 类型的数组,返回的仍然是 Number 类型:
注意到这个 toArray(T[]) 方法的泛型参数 <T> 并不是 List 接口定义的泛型参数 <E>,所以,我们实际上可以传入其他类型的数组,例如我们传入 Number 类型的数组,返回的仍然是 Number 类型:
1
2
3
4
5
6
7
8
9
public class Main {
public static void main(String[] args) {
List<Integer> list = List.of(12, 34, 56);
Number[] array = list.toArray(new Number[3]);
for (Number n : array) {
System.out.println(n);
}
}
}

但是,如果我们传入类型不匹配的数组,例如,String[]类型的数组,由于 List 的元素是 Integer,所以无法放入 String 数组,这个方法会抛出 ArrayStoreException
如果我们传入的数组大小和 List 实际的元素个数不一致怎么办?根据 List 接口的文档,我们可以知道:
如果传入的数组不够大,那么 List 内部会创建一个新的刚好够大的数组,填充后返回;如果传入的数组比 List 元素还要多,那么填充完元素后,剩下的数组元素一律填充 null。
实际上,最常用的是传入一个 “恰好” 大小的数组:
1
Integer[] array = list.toArray(new Integer[list.size()]);

最后一种更简洁的写法是通过 List 接口定义的 T[] toArray(IntFunction<T[]> generator) 方法:
1
Integer[] array = list.toArray(Integer[]::new);

这种函数式写法我们会在后续讲到。
反过来,把Array变为List就简单多了,通过List.of(T…)方法最简单:
1
2
Integer[] array = { 1, 2, 3 };
List<Integer> list = List.of(array);

对于 JDK 11 之前的版本,可以使用 Arrays.asList(T...) 方法把数组转换成 List。
要注意的是,返回的 List 不一定就是 ArrayList 或者 LinkedList,因为 List 只是一个接口,如果我们调用 List.of(),它返回的是一个只读 List
1
2
3
4
5
6
public class Main {
public static void main(String[] args) {
List<Integer> list = List.of(12, 34, 56);
list.add(999); // UnsupportedOperationException
}
}

对只读 List 调用 add()remove() 方法会抛出 UnsupportedOperationException

小结

  • List是按索引顺序访问的长度可变的有序表,优先使用ArrayList而不是LinkedList;
  • 可以直接使用for each遍历List;
  • List可以和Array相互转换。

编写equal方法

List 还提供了 boolean contains(Object o) 方法来判断 List 是否包含某个指定元素。此外,int indexOf(Object o) 方法可以返回某个元素的索引,如果元素不存在,就返回 -1

1
2
3
4
5
6
7
8
9
public class Main {
public static void main(String[] args) {
List<String> list = List.of("A", "B", "C");
System.out.println(list.contains("C")); // true
System.out.println(list.contains("X")); // false
System.out.println(list.indexOf("C")); // 2
System.out.println(list.indexOf("X")); // -1
}
}

这里我们注意一个问题,我们往List中添加的"C"和调用contains("C")传入的"C"是不是同一个实例?

如果这两个"C"不是同一个实例,这段代码是否还能得到正确的结果?我们可以改写一下代码测试一下:

1
2
3
4
5
6
7
8
import java.util.List;
public class Main {
public static void main(String[] args) {
List<String> list = List.of("A", "B", "C");
System.out.println(list.contains(new String("C"))); // true or false?
System.out.println(list.indexOf(new String("C"))); // 2 or -1?
}
}

因为我们传入的是new String("C"),所以一定是不同的实例。结果仍然符合预期,这是为什么呢?

因为List内部并不是通过==判断两个元素是否相等,而是使用equals()方法判断两个元素是否相等,例如contains()方法可以实现如下:

1
2
3
4
5
6
7
8
9
10
11
public class ArrayList {
Object[] elementData;
public boolean contains(Object o) {
for (int i = 0; i < size; i++) {
if (o.equals(elementData[i])) {
return true;
}
}
return false;
}
}

因此,要正确使用List的contains()indexOf()这些方法,放入的实例必须正确覆写equals()方法,否则,放进去的实例,查找不到。我们之所以能正常放入String、Integer这些对象,是因为Java标准库定义的这些类已经正确实现了equals()方法。

我们以Person对象为例,测试一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import java.util.List;
public class Main {
public static void main(String[] args) {
List<Person> list = List.of(
new Person("Xiao Ming"),
new Person("Xiao Hong"),
new Person("Bob")
);
System.out.println(list.contains(new Person("Bob"))); // false
}
}

class Person {
String name;
public Person(String name) {
this.name = name;
}
}

不出意外,虽然放入了new Person("Bob"),但是用另一个new Person("Bob")查询不到,原因就是Person类没有覆写equals()方法。

编写equals

JAVA当中所有的类都是继承于Object这个超类的,在Object类中定义了一个equals的方法,equals的源码是这样写的:

1
2
3
4
5
public boolean equals(Object obj) {
//this - s1
//obj - s2
return (this == obj);
}

可以看到,这个方法的初始默认行为是比较对象的内存地址值,一般来说,意义不大。所以,在一些类库当中这个方法被重写了,如String、Integer、Date。在这些类当中equals有其自身的实现(一般都是用来比较对象的成员变量值是否相同),而不再是比较类在堆内存中的存放地址了。

如何正确编写equals()方法?equals()方法要求我们必须满足以下条件:

  • 自反性(Reflexive):对于非 null 的 x 来说,x.equals(x) 必须返回 true;
  • 对称性(Symmetric):对于非 null 的 x 和 y 来说,如果 x.equals(y) 为 true,则 y.equals(x) 也必须为 true;
  • 传递性(Transitive):对于非 null 的 x、y 和 z 来说,如果 x.equals(y) 为 true,y.equals(z) 也为 true,那么 x.equals(z) 也必须为 true;
  • 一致性(Consistent):对于非 null 的 x 和 y 来说,只要 x 和 y 状态不变,则 x.equals(y) 总是一致地返回 true 或者 false;
  • 对 null 的比较:即 x.equals(null) 永远返回 false。

上述规则看上去似乎非常复杂,但其实代码实现equals()方法是很简单的,我们以Person类为例:

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

首先,我们要定义“相等”的逻辑含义。对于Person类,如果name相等,并且age相等,我们就认为两个Person实例相等。

因此,编写equals()方法如下:

1
2
3
4
5
6
7
public boolean equals(Object o) {
if (o instanceof Person) {
Person p = (Person) o;
return this.name.equals(p.name) && this.age == p.age;
}
return false;
}

对于引用字段比较,我们使用equals(),对于基本类型字段的比较,我们使用==
如果this.name为null,那么equals()方法会报错,因此,需要继续改写如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public boolean equals(Object o) {
if (o instanceof Person) {
Person p = (Person) o;
boolean nameEquals = false;
if (this.name == null && p.name == null) {
nameEquals = true;
}
if (this.name != null) {
nameEquals = this.name.equals(p.name);
}
return nameEquals && this.age == p.age;
}
return false;
}

因此,我们总结一下equals()方法的正确编写方法:

  1. 先确定实例“相等”的逻辑,即哪些字段相等,就认为实例相等;
  2. instanceof判断传入的待比较的Object是不是当前类型,如果是,继续比较,否则,返回false;
  3. 对引用类型用Objects.equals()比较,对基本类型直接用==比较。

使用Objects.equals()比较两个引用类型是否相等的目的是省去了判断null的麻烦。两个引用类型都是null时它们也是相等的。
如果不调用List的contains()indexOf()这些方法,那么放入的元素就不需要实现equals()方法。

小结

  • 在List中查找元素时,List的实现类通过元素的equals()方法比较两个元素是否相等,因此,放入的元素必须正确覆写equals()方法,Java标准库提供的String、Integer等已经覆写了equals()方法;
  • 编写equals()方法可借助Objects.equals()判断。
  • 如果不在List中查找元素,就不必覆写equals()方法。

使用Map

我们知道,List是一种顺序列表,如果有一个存储学生Student实例的List,要在List中根据name查找某个指定的Student的分数,应该怎么办?

最简单的方法是遍历List并判断name是否相等,然后返回指定元素:

1
2
3
4
5
6
7
8
9
List<Student> list = ...
Student target = null;
for (Student s : list) {
if ("Xiao Ming".equals(s.name)) {
target = s;
break;
}
}
System.out.println(target.score);

这种需求其实非常常见,即通过一个键去查询对应的值。使用List来实现存在效率非常低的问题,因为平均需要扫描一半的元素才能确定,而Map这种键值(key-value)映射表的数据结构,作用就是能高效通过key快速查找value(元素)。

用Map来实现根据name查询某个Student的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import java.util.HashMap;
import java.util.Map;

public class Main {
public static void main(String[] args) {
Student s = new Student("Xiao Ming", 99);
Map<String, Student> map = new HashMap<>();
map.put("Xiao Ming", s); // 将"Xiao Ming"和Student实例映射并关联
Student target = map.get("Xiao Ming"); // 通过key查找并返回映射的Student实例
System.out.println(target == s); // true,同一个实例
System.out.println(target.score); // 99
Student another = map.get("Bob"); // 通过另一个key查找
System.out.println(another); // 未找到返回null
}
}

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

通过上述代码可知:Map<K, V>是一种键-值映射表,当我们调用put(K key, V value)方法时,就把key和value做了映射并放入Map。当我们调用V get(K key)时,就可以通过key获取到对应的value。如果key不存在,则返回null。和List类似,Map也是一个接口,最常用的实现类是HashMap
如果只是想查询某个key是否存在,可以调用boolean containsKey(K key)方法。
如果我们在存储Map映射关系的时候,对同一个key调用两次put()方法,分别放入不同的value,会有什么问题呢?例如:
1
2
3
4
5
6
7
8
9
10
11
12
13
import java.util.HashMap;
import java.util.Map;

public class Main {
public static void main(String[] args) {
Map<String, Integer> map = new HashMap<>();
map.put("apple", 123);
map.put("pear", 456);
System.out.println(map.get("apple")); // 123
map.put("apple", 789); // 再次放入apple作为key,但value变为789
System.out.println(map.get("apple")); // 789
}
}

重复放入key-value并不会有任何问题,但是一个key只能关联一个value。在上面的代码中,一开始我们把key对象”apple”映射到Integer对象123,然后再次调用put()方法把”apple”映射到789,这时,原来关联的value对象123就被“冲掉”了。实际上,put()方法的签名是V put(K key, V value),如果放入的key已经存在,put()方法会返回被删除的旧的value,否则,返回null。

始终牢记:Map中不存在重复的key,因为放入相同的key,只会把原有的key-value对应的value给替换掉。

此外,在一个Map中,虽然key不能重复,但value是可以重复的:

1
2
3
Map<String, Integer> map = new HashMap<>();
map.put("apple", 123);
map.put("pear", 123); // ok

遍历Map

对Map来说,要遍历key可以使用for each循环遍历Map实例的keySet()方法返回的Set集合,它包含不重复的key的集合:

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

public class Main {
public static void main(String[] args) {
Map<String, Integer> map = new HashMap<>();
map.put("apple", 123);
map.put("pear", 456);
map.put("banana", 789);
for (String key : map.keySet()) {
Integer value = map.get(key);
System.out.println(key + " = " + value);
}
}
}

同时遍历key和value可以使用for each循环遍历Map对象的entrySet()集合,它包含每一个key-value映射:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import java.util.HashMap;
import java.util.Map;

public class Main {
public static void main(String[] args) {
Map<String, Integer> map = new HashMap<>();
map.put("apple", 123);
map.put("pear", 456);
map.put("banana", 789);
for (Map.Entry<String, Integer> entry : map.entrySet()) {
String key = entry.getKey();
Integer value = entry.getValue();
System.out.println(key + " = " + value);
}
}
}

Map和List不同的是,Map存储的是key-value的映射关系,并且,它不保证顺序。在遍历的时候,遍历的顺序既不一定是put()时放入的key的顺序,也不一定是key的排序顺序。使用Map时,任何依赖顺序的逻辑都是不可靠的。以HashMap为例,假设我们放入”A”,”B”,”C”这3个key,遍历的时候,每个key会保证被遍历一次且仅遍历一次,但顺序完全没有保证,甚至对于不同的JDK版本,相同的代码遍历的输出顺序都是不同的!

遍历Map时,不可假设输出的key是有序的!

小结

  • Map是一种映射表,可以通过key快速查找value。
  • 可以通过for each遍历keySet(),也可以通过for each遍历entrySet(),直接获取key-value。
  • 最常用的一种Map实现是HashMap。

编写equals和hashCode

Map是一种键-值(key-value)映射表,可以通过key快速查找对应的value。
以HashMap为例,观察下面的代码:

1
2
3
4
5
6
7
Map<String, Person> map = new HashMap<>();
map.put("a", new Person("Xiao Ming"));
map.put("b", new Person("Xiao Hong"));
map.put("c", new Person("Xiao Jun"));

map.get("a"); // Person("Xiao Ming")
map.get("x"); // null

HashMap之所以能根据key直接拿到value,原因是它内部通过空间换时间的方法,用一个大数组存储所有value,并根据key直接计算出value应该存储在哪个索引:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
  ┌───┐
0 │ │
├───┤
1 │ ●─┼───> Person("Xiao Ming")
├───┤
2 │ │
├───┤
3 │ │
├───┤
4 │ │
├───┤
5 │ ●─┼───> Person("Xiao Hong")
├───┤
6 │ ●─┼───> Person("Xiao Jun")
├───┤
7 │ │
└───┘

如果key的值为”a”,计算得到的索引总是1,因此返回value为Person(“Xiao Ming”),如果key的值为”b”,计算得到的索引总是5,因此返回value为Person(“Xiao Hong”),这样,就不必遍历整个数组,即可直接读取key对应的value。
当我们使用key存取value的时候,就会引出一个问题:
我们放入Map的key是字符串”a”,但是,当我们获取Map的value时,传入的变量不一定就是放入的那个key对象。
换句话讲,两个key应该是内容相同,但不一定是同一个对象。测试代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import java.util.HashMap;
import java.util.Map;
public class Main {
public static void main(String[] args) {
String key1 = "a";
Map<String, Integer> map = new HashMap<>();
map.put(key1, 123);

String key2 = new String("a");
map.get(key2); // 123

System.out.println(key1 == key2); // false
System.out.println(key1.equals(key2)); // true
}
}

因为在Map的内部,对key做比较是通过equals()实现的,这一点和List查找元素需要正确覆写equals()是一样的,即正确使用Map必须保证:作为key的对象必须正确覆写equals()方法。
我们经常使用String作为key,因为String已经正确覆写了equals()方法。但如果我们放入的key是一个自己写的类,就必须保证正确覆写了equals()方法。
我们再思考一下HashMap为什么能通过key直接计算出value存储的索引。相同的key对象(使用equals()判断时返回true)必须要计算出相同的索引,否则,相同的key每次取出的value就不一定对。
通过key计算索引的方式就是调用key对象的hashCode()方法,它返回一个int整数。HashMap正是通过这个方法直接定位key对应的value的索引,继而直接返回value。
因此,正确使用Map必须保证:

  1. 作为key的对象必须正确覆写equals()方法,相等的两个key实例调用equals()必须返回true;
  2. 作为key的对象还必须正确覆写hashCode()方法,且hashCode()方法要严格遵循以下规范:
    • 如果两个对象相等,则两个对象的hashCode()必须相等;
    • 如果两个对象不相等,则两个对象的hashCode()尽量不要相等。

即对应两个实例a和b:

  • 如果a和b相等,那么a.equals(b)一定为true,则a.hashCode()必须等于b.hashCode();
  • 如果a和b不相等,那么a.equals(b)一定为false,则a.hashCode()和b.hashCode()尽量不要相等。

上述第一条规范是正确性,必须保证实现,否则HashMap不能正常工作。
而第二条如果尽量满足,则可以保证查询效率,因为不同的对象,如果返回相同的hashCode(),会造成Map内部存储冲突,使存取的效率下降。
正确编写equals()的方法我们已经在编写equals方法一节中讲过了。

在正确实现equals()的基础上,我们还需要正确实现hashCode(),即上述3个字段分别相同的实例,hashCode()返回的int必须相同:

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

@Override
int hashCode() {
int h = 0;
h = 31 * h + firstName.hashCode();
h = 31 * h + lastName.hashCode();
h = 31 * h + age;
return h;
}
}

注意到String类已经正确实现了hashCode()方法,我们在计算Person的hashCode()时,反复使用31*h,这样做的目的是为了尽量把不同的Person实例的hashCode()均匀分布到整个int范围。
和实现equals()方法遇到的问题类似,如果firstName或lastName为null,上述代码工作起来就会抛NullPointerException。为了解决这个问题,我们在计算hashCode()的时候,经常借助Objects.hash()来计算:
1
2
3
int hashCode() {
return Objects.hash(firstName, lastName, age);
}

所以,编写equals()和hashCode()遵循的原则是:
equals()用到的用于比较的每一个字段,都必须在hashCode()中用于计算;equals()中没有使用到的字段,绝不可放在hashCode()中计算。
另外注意,对于放入HashMap的value对象,没有任何要求。

延伸阅读

既然HashMap内部使用了数组,通过计算key的hashCode()直接定位value所在的索引,那么第一个问题来了:hashCode()返回的int范围高达±21亿,先不考虑负数,HashMap内部使用的数组得有多大?
实际上HashMap初始化时默认的数组大小只有16,任何key,无论它的hashCode()有多大,都可以简单地通过:

1
int index = key.hashCode() & 0xf; // 0xf = 15

把索引确定在0~15,即永远不会超出数组范围,上述算法只是一种最简单的实现。

第二个问题:如果添加超过16个key-value到HashMap,数组不够用了怎么办?
添加超过一定数量的key-value时,HashMap会在内部自动扩容,每次扩容一倍,即长度为16的数组扩展为长度32,相应地,需要重新确定hashCode()计算的索引位置。例如,对长度为32的数组计算hashCode()对应的索引,计算方式要改为:

1
int index = key.hashCode() & 0x1f; // 0x1f = 31

由于扩容会导致重新分布已有的key-value,所以,频繁扩容对HashMap的性能影响很大。如果我们确定要使用一个容量为10000个key-value的HashMap,更好的方式是创建HashMap时就指定容量:
1
Map<String, Integer> map = new HashMap<>(10000);

虽然指定容量是10000,但HashMap内部的数组长度总是2n,因此,实际数组长度被初始化为比10000大的16384($2^{14}$)。

最后一个问题:如果不同的两个key,例如”a”和”b”,它们的hashCode()恰好是相同的(这种情况是完全可能的,因为不相等的两个实例,只要求hashCode()尽量不相等),那么,当我们放入:

1
2
map.put("a", new Person("Xiao Ming"));
map.put("b", new Person("Xiao Hong"));

时,由于计算出的数组索引相同,后面放入的”Xiao Hong”会不会把”Xiao Ming”覆盖了?
当然不会!使用Map的时候,只要key不相同,它们映射的value就互不干扰。但是,在HashMap内部,确实可能存在不同的key,映射到相同的hashCode(),即相同的数组索引上,肿么办?
我们就假设”a”和”b”这两个key最终计算出的索引都是5,那么,在HashMap的数组中,实际存储的不是一个Person实例,而是一个List,它包含两个Entry,一个是”a”的映射,一个是”b”的映射:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
  ┌───┐
0 │ │
├───┤
1 │ │
├───┤
2 │ │
├───┤
3 │ │
├───┤
4 │ │
├───┤
5 │ ●─┼───> List<Entry<String, Person>>
├───┤
6 │ │
├───┤
7 │ │
└───┘

在查找的时候,例如:
1
Person p = map.get("a");

HashMap内部通过”a”找到的实际上是List<Entry<String, Person>>,它还需要遍历这个List,并找到一个Entry,它的key字段是”a”,才能返回对应的Person实例。
我们把不同的key具有相同的hashCode()的情况称之为哈希冲突。在冲突的时候,一种最简单的解决办法是用List存储hashCode()相同的key-value。显然,如果冲突的概率越大,这个List就越长,Map的get()方法效率就越低,这就是为什么要尽量满足条件二:
如果两个对象不相等,则两个对象的hashCode()尽量不要相等。
hashCode()方法编写得越好,HashMap工作的效率就越高。

小结

  • 要正确使用HashMap,作为key的类必须正确覆写equals()和hashCode()方法;
  • 一个类如果覆写了equals(),就必须覆写hashCode(),并且覆写规则是:
    • 如果equals()返回true,则hashCode()返回值必须相等;
    • 如果equals()返回false,则hashCode()返回值尽量不要相等。
  • 实现hashCode()方法可以通过Objects.hashCode()辅助方法实现。

使用EnumMap

因为HashMap是一种通过对key计算hashCode(),通过空间换时间的方式,直接定位到value所在的内部数组的索引,因此,查找效率非常高。
如果作为key的对象是enum类型,那么,还可以使用Java集合库提供的一种EnumMap,它在内部以一个非常紧凑的数组存储value,并且根据enum类型的key直接定位到内部数组的索引,并不需要计算hashCode(),不但效率最高,而且没有额外的空间浪费。
我们以DayOfWeek这个枚举类型为例,为它做一个“翻译”功能:

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

public class Main {
public static void main(String[] args) {
Map<DayOfWeek, String> map = new EnumMap<>(DayOfWeek.class);
map.put(DayOfWeek.MONDAY, "星期一");
map.put(DayOfWeek.TUESDAY, "星期二");
map.put(DayOfWeek.WEDNESDAY, "星期三");
map.put(DayOfWeek.THURSDAY, "星期四");
map.put(DayOfWeek.FRIDAY, "星期五");
map.put(DayOfWeek.SATURDAY, "星期六");
map.put(DayOfWeek.SUNDAY, "星期日");
System.out.println(map);
System.out.println(map.get(DayOfWeek.MONDAY));
}
}

使用EnumMap的时候,我们总是用Map接口来引用它,因此,实际上把HashMap和EnumMap互换,在客户端看来没有任何区别。

小结

  • 如果Map的key是enum类型,推荐使用EnumMap,既保证速度,也不浪费空间。
  • 使用EnumMap的时候,根据面向抽象编程的原则,应持有Map接口。

使用TreeMap

我们已经知道,HashMap是一种以空间换时间的映射表,它的实现原理决定了内部的Key是无序的,即遍历HashMap的Key时,其顺序是不可预测的(但每个Key都会遍历一次且仅遍历一次)。
还有一种Map,它在内部会对Key进行排序,这种Map就是SortedMap。注意到SortedMap是接口,它的实现类是TreeMap。

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

┌────┴─────┐
│ │
┌───────┐ ┌─────────┐
│HashMap│ │SortedMap│
└───────┘ └─────────┘


┌─────────┐
│ TreeMap │
└─────────┘

SortedMap保证遍历时以Key的顺序来进行排序。例如,放入的Key是”apple”、”pear”、”orange”,遍历的顺序一定是”apple”、”orange”、”pear”,因为String默认按字母排序:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
import java.util.*;

public class Main {
public static void main(String[] args) {
Map<String, Integer> map = new TreeMap<>();
map.put("orange", 1);
map.put("apple", 2);
map.put("pear", 3);
for (String key : map.keySet()) {
System.out.println(key);
}
// apple, orange, pear
}
}

使用TreeMap时,放入的Key必须实现Comparable接口。String、Integer这些类已经实现了Comparable接口,因此可以直接作为Key使用。作为Value的对象则没有任何要求。
如果作为Key的class没有实现Comparable接口,那么,必须在创建TreeMap时同时指定一个自定义排序算法:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import java.util.*;
public class Main {
public static void main(String[] args) {
Map<Person, Integer> map = new TreeMap<>(new Comparator<Person>() {
public int compare(Person p1, Person p2) {
return p1.name.compareTo(p2.name);
}
});
map.put(new Person("Tom"), 1);
map.put(new Person("Bob"), 2);
map.put(new Person("Lily"), 3);
for (Person key : map.keySet()) {
System.out.println(key);
}
// {Person: Bob}, {Person: Lily}, {Person: Tom}
System.out.println(map.get(new Person("Bob"))); // 2
}
}

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

注意到Comparator接口要求实现一个比较方法,它负责比较传入的两个元素a和b,如果a<b,则返回负数,通常是-1,如果a==b,则返回0,如果a>b,则返回正数,通常是1。TreeMap内部根据比较结果对Key进行排序。
从上述代码执行结果可知,打印的Key确实是按照Comparator定义的顺序排序的。如果要根据Key查找Value,我们可以传入一个new Person(“Bob”)作为Key,它会返回对应的Integer值2。
另外,注意到Person类并未覆写equals()和hashCode(),**因为TreeMap不使用equals()和hashCode()**。
我们来看一个稍微复杂的例子:这次我们定义了Student类,并用分数score进行排序,高分在前:
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
import java.util.*;
public class Main {
public static void main(String[] args) {
Map<Student, Integer> map = new TreeMap<>(new Comparator<Student>() {
public int compare(Student p1, Student p2) {
return p1.score > p2.score ? -1 : 1;
}
});
map.put(new Student("Tom", 77), 1);
map.put(new Student("Bob", 66), 2);
map.put(new Student("Lily", 99), 3);
for (Student key : map.keySet()) {
System.out.println(key);
}
System.out.println(map.get(new Student("Bob", 66))); // null?
}
}

class Student {
public String name;
public int score;
Student(String name, int score) {
this.name = name;
this.score = score;
}
public String toString() {
return String.format("{%s: score=%d}", name, score);
}
}

在for循环中,我们确实得到了正确的顺序。但是,且慢!根据相同的Key:new Student("Bob", 66)进行查找时,结果为null!
这是怎么肥四?难道TreeMap有问题?遇到TreeMap工作不正常时,我们首先回顾Java编程基本规则:出现问题,不要怀疑Java标准库,要从自身代码找原因。
在这个例子中,TreeMap出现问题,原因其实出在这个Comparator上:
1
2
3
public int compare(Student p1, Student p2) {
return p1.score > p2.score ? -1 : 1;
}

p1.scorep2.score不相等的时候,它的返回值是正确的,但是,在p1.scorep2.score相等的时候,它并没有返回0!这就是为什么TreeMap工作不正常的原因:TreeMap在比较两个Key是否相等时,依赖Key的compareTo()方法或者Comparator.compare()方法。在两个Key相等时,必须返回0。因此,修改代码如下:
1
2
3
4
5
6
public int compare(Student p1, Student p2) {
if (p1.score == p2.score) {
return 0;
}
return p1.score > p2.score ? -1 : 1;
}

或者直接借助Integer.compare(int, int)也可以返回正确的比较结果。

小结

  • SortedMap在遍历时严格按照Key的顺序遍历,最常用的实现类是TreeMap;
  • 作为SortedMap的Key必须实现Comparable接口,或者传入Comparator;
  • 要严格按照compare()规范实现比较逻辑,否则,TreeMap将不能正常工作。

使用Properties

在编写应用程序的时候,经常需要读写配置文件。例如,用户的设置:

1
2
3
4
# 上次最后打开的文件:
last_open_file=/data/hello.txt
# 自动保存文件的时间间隔:
auto_save_interval=60

配置文件的特点是,它的Key-Value一般都是String-String类型的,因此我们完全可以用Map<String, String>来表示它。
因为配置文件非常常用,所以Java集合库提供了一个Properties来表示一组“配置”。由于历史遗留原因,Properties内部本质上是一个Hashtable,但我们只需要用到Properties自身关于读写配置的接口。

读取配置文件

用Properties读取配置文件非常简单。Java默认配置文件以.properties为扩展名,每行以key=value表示,以#课开头的是注释。以下是一个典型的配置文件:

1
2
3
4
# setting.properties

last_open_file=/data/hello.txt
auto_save_interval=60

可以从文件系统读取这个.properties文件:
1
2
3
4
5
6
String f = "setting.properties";
Properties props = new Properties();
props.load(new java.io.FileInputStream(f));

String filepath = props.getProperty("last_open_file");
String interval = props.getProperty("auto_save_interval", "120");

可见,用Properties读取配置文件,一共有三步:

  1. 创建Properties实例;
  2. 调用load()读取文件;
  3. 调用getProperty()获取配置。

调用getProperty()获取配置时,如果key不存在,将返回null。我们还可以提供一个默认值,这样,当key不存在的时候,就返回默认值。
也可以从classpath读取.properties文件,因为load(InputStream)方法接收一个InputStream实例,表示一个字节流,它不一定是文件流,也可以是从jar包中读取的资源流:

1
2
Properties props = new Properties();
props.load(getClass().getResourceAsStream("/common/setting.properties"));

试试从内存读取一个字节流:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import java.io.*;
import java.util.Properties;

public class Main {
public static void main(String[] args) throws IOException {
String settings = "# test" + "\n" + "course=Java" + "\n" + "last_open_date=2019-08-07T12:35:01";
ByteArrayInputStream input = new ByteArrayInputStream(settings.getBytes("UTF-8"));
Properties props = new Properties();
props.load(input);

System.out.println("course: " + props.getProperty("course"));
System.out.println("last_open_date: " + props.getProperty("last_open_date"));
System.out.println("last_open_file: " + props.getProperty("last_open_file"));
System.out.println("auto_save: " + props.getProperty("auto_save", "60"));
}
}

如果有多个.properties文件,可以反复调用load()读取,后读取的key-value会覆盖已读取的key-value:
1
2
3
Properties props = new Properties();
props.load(getClass().getResourceAsStream("/common/setting.properties"));
props.load(new FileInputStream("C:\\conf\\setting.properties"));

上面的代码演示了Properties的一个常用用法:可以把默认配置文件放到classpath中,然后,根据机器的环境编写另一个配置文件,覆盖某些默认的配置。
Properties设计的目的是存储String类型的key-value,但Properties实际上是从Hashtable派生的,它的设计实际上是有问题的,但是为了保持兼容性,现在已经没法修改了。除了getProperty()和setProperty()方法外,还有从Hashtable继承下来的get()和put()方法,这些方法的参数签名是Object,我们在使用Properties的时候,不要去调用这些从Hashtable继承下来的方法。

写入配置文件

如果通过setProperty()修改了Properties实例,可以把配置写入文件,以便下次启动时获得最新配置。写入配置文件使用store()方法:

1
2
3
4
Properties props = new Properties();
props.setProperty("url", "http://www.liaoxuefeng.com");
props.setProperty("language", "Java");
props.store(new FileOutputStream("C:\\conf\\setting.properties"), "这是写入的properties注释");

编码

早期版本的Java规定.properties文件编码是ASCII编码(ISO8859-1),如果涉及到中文就必须用name=\u4e2d\u6587来表示,非常别扭。从JDK9开始,Java的.properties文件可以使用UTF-8编码了。
不过,需要注意的是,由于load(InputStream)默认总是以ASCII编码读取字节流,所以会导致读到乱码。我们需要用另一个重载方法load(Reader)读取:

1
2
Properties props = new Properties();
props.load(new FileReader("settings.properties", StandardCharsets.UTF_8));

就可以正常读取中文。InputStream和Reader的区别是一个是字节流,一个是字符流。字符流在内存中已经以char类型表示了,不涉及编码问题。

小结

  • Java集合库提供的Properties用于读写配置文件.properties。.properties文件可以使用UTF-8编码。
  • 可以从文件系统、classpath或其他任何地方读取.properties文件。
  • 读写Properties时,注意仅使用getProperty()和setProperty()方法,不要调用继承而来的get()和put()等方法。

使用Set

我们知道,Map用于存储key-value的映射,对于充当key的对象,是不能重复的,并且,不但需要正确覆写equals()方法,还要正确覆写hashCode()方法。
如果我们只需要存储不重复的key,并不需要存储映射的value,那么就可以使用Set。
Set用于存储不重复的元素集合,它主要提供以下几个方法:

  • 将元素添加进Set<E>boolean add(E e)
  • 将元素从Set<E>删除:boolean remove(Object e)
  • 判断是否包含元素:boolean contains(Object e)

我们来看几个简单的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
import java.util.*;
public class Main {
public static void main(String[] args) {
Set<String> set = new HashSet<>();
System.out.println(set.add("abc")); // true
System.out.println(set.add("xyz")); // true
System.out.println(set.add("xyz")); // false,添加失败,因为元素已存在
System.out.println(set.contains("xyz")); // true,元素存在
System.out.println(set.contains("XYZ")); // false,元素不存在
System.out.println(set.remove("hello")); // false,删除失败,因为元素不存在
System.out.println(set.size()); // 2,一共两个元素
}
}

Set实际上相当于只存储key、不存储value的Map。我们经常用Set用于去除重复元素。
因为放入Set的元素和Map的key类似,都要正确实现equals()和hashCode()方法,否则该元素无法正确地放入Set。
最常用的Set实现类是HashSet,实际上,HashSet仅仅是对HashMap的一个简单封装,它的核心代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class HashSet<E> implements Set<E> {
// 持有一个HashMap:
private HashMap<E, Object> map = new HashMap<>();

// 放入HashMap的value:
private static final Object PRESENT = new Object();

public boolean add(E e) {
return map.put(e, PRESENT) == null;
}

public boolean contains(Object o) {
return map.containsKey(o);
}

public boolean remove(Object o) {
return map.remove(o) == PRESENT;
}
}

Set接口并不保证有序,而SortedSet接口则保证元素是有序的:

  • HashSet是无序的,因为它实现了Set接口,并没有实现SortedSet接口;
  • TreeSet是有序的,因为它实现了SortedSet接口。

用一张图表示:

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

┌────┴─────┐
│ │
┌───────┐ ┌─────────┐
│HashSet│ │SortedSet│
└───────┘ └─────────┘


┌─────────┐
│ TreeSet │
└─────────┘

我们来看HashSet的输出:
1
2
3
4
5
6
7
8
9
10
11
12
13
import java.util.*;
public class Main {
public static void main(String[] args) {
Set<String> set = new HashSet<>();
set.add("apple");
set.add("banana");
set.add("pear");
set.add("orange");
for (String s : set) {
System.out.println(s);
}
}
}

注意输出的顺序既不是添加的顺序,也不是String排序的顺序,在不同版本的JDK中,这个顺序也可能是不同的。
把HashSet换成TreeSet,在遍历TreeSet时,输出就是有序的,这个顺序是元素的排序顺序:
1
2
3
4
5
6
7
8
9
10
11
12
13
import java.util.*;
public class Main {
public static void main(String[] args) {
Set<String> set = new TreeSet<>();
set.add("apple");
set.add("banana");
set.add("pear");
set.add("orange");
for (String s : set) {
System.out.println(s);
}
}
}

使用TreeSet和使用TreeMap的要求一样,添加的元素必须正确实现Comparable接口,如果没有实现Comparable接口,那么创建TreeSet时必须传入一个Comparator对象。

小结

  • Set用于存储不重复的元素集合:
    • 放入HashSet的元素与作为HashMap的key要求相同;
    • 放入TreeSet的元素与作为TreeMap的Key要求相同;
  • 利用Set可以去除重复元素;
  • 遍历SortedSet按照元素的排序顺序遍历,也可以自定义排序算法。

使用Queue

队列(Queue)是一种经常使用的集合。Queue实际上是实现了一个先进先出(FIFO:First In First Out)的有序表。它和List的区别在于,List可以在任意位置添加和删除元素,而Queue只有两个操作:

  • 把元素添加到队列末尾;
  • 从队列头部取出元素。

例如:超市的收银台就是一个队列

在Java的标准库中,队列接口Queue定义了以下几个方法:

  • int size():获取队列长度;
  • boolean add(E)/boolean offer(E):添加元素到队尾;
  • E remove()/E poll():获取队首元素并从队列中删除;
  • E element()/E peek():获取队首元素但并不从队列中删除。

对于具体的实现类,有的Queue有最大队列长度限制,有的Queue没有。注意到添加、删除和获取队列元素总是有两个方法,这是因为在添加或获取元素失败时,这两个方法的行为是不同的。我们用一个表格总结如下:

throw Exception 返回false或null
添加元素到队尾 add(E e) boolean offer(E e)
取队首元素并删除 E remove() E poll()
取队首元素但不删除 E element() E peek()
举个栗子,假设我们有一个队列,对它做一个添加操作,如果调用add()方法,当添加失败时(可能超过了队列的容量),它会抛出异常:
1
2
3
4
5
6
7
Queue<String> q = ...
try {
q.add("Apple");
System.out.println("添加成功");
} catch(IllegalStateException e) {
System.out.println("添加失败");
}
如果我们调用offer()方法来添加元素,当添加失败时,它不会抛异常,而是返回false:
1
2
3
4
5
6
Queue<String> q = ...
if (q.offer("Apple")) {
System.out.println("添加成功");
} else {
System.out.println("添加失败");
}
当我们需要从Queue中取出队首元素时,如果当前Queue是一个空队列,调用remove()方法,它会抛出异常:
1
2
3
4
5
6
7
Queue<String> q = ...
try {
String s = q.remove();
System.out.println("获取成功");
} catch(IllegalStateException e) {
System.out.println("获取失败");
}
如果我们调用poll()方法来取出队首元素,当获取失败时,它不会抛异常,而是返回null:
1
2
3
4
5
6
7
Queue<String> q = ...
String s = q.poll();
if (s != null) {
System.out.println("获取成功");
} else {
System.out.println("获取失败");
}
因此,两套方法可以根据需要来选择使用。
注意:不要把null添加到队列中,否则poll()方法返回null时,很难确定是取到了null元素还是队列为空。
接下来我们以poll()和peek()为例来说说“获取并删除”与“获取但不删除”的区别。对于Queue来说,每次调用poll(),都会获取队首元素,并且获取到的元素已经从队列中被删除了:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import java.util.LinkedList;
import java.util.Queue;

public class Main {
public static void main(String[] args) {
Queue<String> q = new LinkedList<>();
// 添加3个元素到队列:
q.offer("apple");
q.offer("pear");
q.offer("banana");
// 从队列取出元素:
System.out.println(q.poll()); // apple
System.out.println(q.poll()); // pear
System.out.println(q.poll()); // banana
System.out.println(q.poll()); // null,因为队列是空的
}
}
如果用peek(),因为获取队首元素时,并不会从队列中删除这个元素,所以可以反复获取:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import java.util.LinkedList;
import java.util.Queue;

public class Main {
public static void main(String[] args) {
Queue<String> q = new LinkedList<>();
// 添加3个元素到队列:
q.offer("apple");
q.offer("pear");
q.offer("banana");
// 队首永远都是apple,因为peek()不会删除它:
System.out.println(q.peek()); // apple
System.out.println(q.peek()); // apple
System.out.println(q.peek()); // apple
}
}
从上面的代码中,我们还可以发现,LinkedList即实现了List接口,又实现了Queue接口,但是,在使用的时候,如果我们把它当作List,就获取List的引用,如果我们把它当作Queue,就获取Queue的引用:
1
2
3
4
// 这是一个List:
List<String> list = new LinkedList<>();
// 这是一个Queue:
Queue<String> queue = new LinkedList<>();
始终按照面向抽象编程的原则编写代码,可以大大提高代码的质量。

小结

  • 队列Queue实现了一个先进先出(FIFO)的数据结构:
    • 通过add()/offer()方法将元素添加到队尾;
    • 通过remove()/poll()从队首获取元素并删除;
    • 通过element()/peek()从队首获取元素但不删除。
  • 要避免把null添加到队列。

使用PriorityQueue

我们知道,Queue是一个先进先出(FIFO)的队列。
在银行柜台办业务时,我们假设只有一个柜台在办理业务,但是办理业务的人很多,怎么办?
可以每个人先取一个号,例如:A1、A2、A3……然后,按照号码顺序依次办理,实际上这就是一个Queue。
如果这时来了一个VIP客户,他的号码是V1,虽然当前排队的是A10、A11、A12……但是柜台下一个呼叫的客户号码却是V1。
这个时候,我们发现,要实现“VIP插队”的业务,用Queue就不行了,因为Queue会严格按FIFO的原则取出队首元素。我们需要的是优先队列:PriorityQueue。
PriorityQueue和Queue的区别在于,它的出队顺序与元素的优先级有关,对PriorityQueue调用remove()或poll()方法,返回的总是优先级最高的元素。
要使用PriorityQueue,我们就必须给每个元素定义“优先级”。我们以实际代码为例,先看看PriorityQueue的行为:

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

public class Main {
public static void main(String[] args) {
Queue<String> q = new PriorityQueue<>();
// 添加3个元素到队列:
q.offer("apple");
q.offer("pear");
q.offer("banana");
System.out.println(q.poll()); // apple
System.out.println(q.poll()); // banana
System.out.println(q.poll()); // pear
System.out.println(q.poll()); // null,因为队列为空
}
}

我们放入的顺序是”apple”、”pear”、”banana”,但是取出的顺序却是”apple”、”banana”、”pear”,这是因为从字符串的排序看,”apple”排在最前面,”pear”排在最后面。
因此,放入PriorityQueue的元素,必须实现Comparable接口,PriorityQueue会根据元素的排序顺序决定出队的优先级。
如果我们要放入的元素并没有实现Comparable接口怎么办?PriorityQueue允许我们提供一个Comparator对象来判断两个元素的顺序。我们以银行排队业务为例,实现一个PriorityQueue:
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) {
Queue<User> q = new PriorityQueue<>(new UserComparator());
// 添加3个元素到队列:
q.offer(new User("Bob", "A1"));
q.offer(new User("Alice", "A2"));
q.offer(new User("Boss", "V1"));
System.out.println(q.poll()); // Boss/V1
System.out.println(q.poll()); // Bob/A1
System.out.println(q.poll()); // Alice/A2
System.out.println(q.poll()); // null,因为队列为空
}
}

class UserComparator implements Comparator<User> {
public int compare(User u1, User u2) {
if (u1.number.charAt(0) == u2.number.charAt(0)) {
// 如果两人的号都是A开头或者都是V开头,比较号的大小:
return u1.number.compareTo(u2.number);
}
if (u1.number.charAt(0) == 'V') {
// u1的号码是V开头,优先级高:
return -1;
} else {
return 1;
}
}
}

实现PriorityQueue的关键在于提供的UserComparator对象,它负责比较两个元素的大小(较小的在前)。UserComparator总是把V开头的号码优先返回,只有在开头相同的时候,才比较号码大小。
上面的UserComparator的比较逻辑其实还是有问题的,它会把A10排在A2的前面,请尝试修复该错误。

小结

  • PriorityQueue实现了一个优先队列:从队首获取元素时,总是获取优先级最高的元素。
  • PriorityQueue默认按元素比较的顺序排序(必须实现Comparable接口),也可以通过Comparator自定义排序算法(元素就不必实现Comparable接口)。

使用Deque

我们知道,Queue是队列,只能一头进,另一头出。
如果把条件放松一下,允许两头都进,两头都出,这种队列叫双端队列(Double Ended Queue),学名Deque。
Java集合提供了接口Deque来实现一个双端队列,它的功能是:

  • 既可以添加到队尾,也可以添加到队首;
  • 既可以从队首获取,又可以从队尾获取。
    我们来比较一下Queue和Deque出队和入队的方法:
Queue Deque
添加元素到队尾 add(E e) / offer(E e) addLast(E e) / offerLast(E e)
取队首元素并删除 E remove() / E poll() E removeFirst() / E pollFirst()
取队首元素但不删除 E element() / E peek() E getFirst() / E peekFirst()
添加元素到队首 addFirst(E e) / offerFirst(E e)
取队尾元素并删除 E removeLast() / E pollLast()
取队尾元素但不删除 E getLast() / E peekLast()

对于添加元素到队尾的操作,Queue提供了add()/offer()方法,而Deque提供了addLast()/offerLast()方法。添加元素到对首、取队尾元素的操作在Queue中不存在,在Deque中由addFirst()/removeLast()等方法提供。
注意到Deque接口实际上扩展自Queue:

1
2
3
public interface Deque<E> extends Queue<E> {
...
}

因此,Queue提供的add()/offer()方法在Deque中也可以使用,但是,使用Deque,最好不要调用offer(),而是调用offerLast():
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import java.util.Deque;
import java.util.LinkedList;

public class Main {
public static void main(String[] args) {
Deque<String> deque = new LinkedList<>();
deque.offerLast("A"); // A
deque.offerLast("B"); // A <- B
deque.offerFirst("C"); // C <- A <- B
System.out.println(deque.pollFirst()); // C, 剩下A <- B
System.out.println(deque.pollLast()); // B, 剩下A
System.out.println(deque.pollFirst()); // A
System.out.println(deque.pollFirst()); // null
}
}

如果直接写deque.offer(),我们就需要思考,offer()实际上是offerLast(),我们明确地写上offerLast(),不需要思考就能一眼看出这是添加到队尾。
因此,使用Deque,推荐总是明确调用offerLast()/offerFirst()或者pollFirst()/pollLast()方法。
Deque是一个接口,它的实现类有ArrayDeque和LinkedList。
我们发现LinkedList真是一个全能选手,它即是List,又是Queue,还是Deque。但是我们在使用的时候,总是用特定的接口来引用它,这是因为持有接口说明代码的抽象层次更高,而且接口本身定义的方法代表了特定的用途。
1
2
3
4
5
6
// 不推荐的写法:
LinkedList<String> d1 = new LinkedList<>();
d1.offerLast("z");
// 推荐的写法:
Deque<String> d2 = new LinkedList<>();
d2.offerLast("z");

可见面向抽象编程的一个原则就是:尽量持有接口,而不是具体的实现类。

小结

Deque实现了一个双端队列(Double Ended Queue),它可以:

  • 将元素添加到队尾或队首:addLast()/offerLast()/addFirst()/offerFirst();
  • 从队首/队尾获取元素并删除:removeFirst()/pollFirst()/removeLast()/pollLast();
  • 从队首/队尾获取元素但不删除:getFirst()/peekFirst()/getLast()/peekLast();
  • 总是调用xxxFirst()/xxxLast()以便与Queue的方法区分开;
  • 避免把null添加到队列。

使用Stack

栈(Stack)是一种后进先出(LIFO:Last In First Out)的数据结构。
什么是LIFO呢?我们先回顾一下Queue的特点FIFO:

1
2
3
4
5
           ────────────────────────
(\(\ (\(\ (\(\ (\(\ (\(\
(='.') ─> (='.') (='.') (='.') ─> (='.')
O(_")") O(_")") O(_")") O(_")") O(_")")
────────────────────────

所谓FIFO,是最先进队列的元素一定最早出队列,而LIFO是最后进Stack的元素一定最早出Stack。如何做到这一点呢?只需要把队列的一端封死:
1
2
3
4
5
           ───────────────────────────────┐
(\(\ (\(\ (\(\ (\(\ (\(\ │
(='.') <─> (='.') (='.') (='.') (='.')│
O(_")") O(_")") O(_")") O(_")") O(_")")│
───────────────────────────────┘

因此,Stack是这样一种数据结构:只能不断地往Stack中压入(push)元素,最后进去的必须最早弹出(pop)来:
Stack只有入栈和出栈的操作:

  • 把元素压栈:push(E);
  • 把栈顶的元素“弹出”:pop(E);
  • 取栈顶元素但不弹出:peek(E)。

在Java中,我们用Deque可以实现Stack的功能:

  • 把元素压栈:push(E)/addFirst(E);
  • 把栈顶的元素“弹出”:pop(E)/removeFirst();
  • 取栈顶元素但不弹出:peek(E)/peekFirst()。

为什么Java的集合类没有单独的Stack接口呢?因为有个遗留类名字就叫Stack,出于兼容性考虑,所以没办法创建Stack接口,只能用Deque接口来“模拟”一个Stack了。
当我们把Deque作为Stack使用时,注意只调用push()/pop()/peek()方法,不要调用addFirst()/removeFirst()/peekFirst()方法,这样代码更加清晰。

Stack的作用

Stack在计算机中使用非常广泛,JVM在处理Java方法调用的时候就会通过栈这种数据结构维护方法调用的层次。例如:

1
2
3
4
5
6
7
8
9
10
11
static void main(String[] args) {
foo(123);
}

static String foo(x) {
return "F-" + bar(x + 1);
}

static int bar(int x) {
return x << 2;
}

JVM会创建方法调用栈,每调用一个方法时,先将参数压栈,然后执行对应的方法;当方法返回时,返回值压栈,调用方法通过出栈操作获得方法返回值。
因为方法调用栈有容量限制,嵌套调用过多会造成栈溢出,即引发StackOverflowError:
1
2
3
4
5
6
7
8
9
public class Main {
public static void main(String[] args) {
increase(1);
}

static int increase(int x) {
return increase(x) + 1;
}
}

我们再来看一个Stack的用途:对整数进行进制的转换就可以利用栈。
例如,我们要把一个int整数12500转换为十六进制表示的字符串。

计算中缀表达式

小结

  • 栈(Stack)是一种后进先出(LIFO)的数据结构,操作栈的元素的方法有:
    • 把元素压栈:push(E);
    • 把栈顶的元素“弹出”:pop(E);
    • 取栈顶元素但不弹出:peek(E)。
  • 在Java中,我们用Deque可以实现Stack的功能,注意只调用push()/pop()/peek()方法,避免调用Deque的其他方法。
  • 最后,不要使用遗留类Stack。

使用Iterator

Java的集合类都可以使用for each循环,List、Set和Queue会迭代每个元素,Map会迭代每个key。以List为例:

1
2
3
4
List<String> list = List.of("Apple", "Orange", "Pear");
for (String s : list) {
System.out.println(s);
}

实际上,Java编译器并不知道如何遍历List。上述代码能够编译通过,只是因为编译器把for each循环通过Iterator改写为了普通的for循环:
1
2
3
4
for (Iterator<String> it = list.iterator(); it.hasNext(); ) {
String s = it.next();
System.out.println(s);
}

我们把这种通过Iterator对象遍历集合的模式称为迭代器。
使用迭代器的好处在于,调用方总是以统一的方式遍历各种集合类型,而不必关系它们内部的存储结构。
例如,我们虽然知道ArrayList在内部是以数组形式存储元素,并且,它还提供了get(int)方法。虽然我们可以用for循环遍历:
1
2
3
for (int i=0; i<list.size(); i++) {
Object value = list.get(i);
}

但是这样一来,调用方就必须知道集合的内部存储结构。并且,如果把ArrayList换成LinkedList,get(int)方法耗时会随着index的增加而增加。如果把ArrayList换成Set,上述代码就无法编译,因为Set内部没有索引。
用Iterator遍历就没有上述问题,因为Iterator对象是集合对象自己在内部创建的,它自己知道如何高效遍历内部的数据集合,调用方则获得了统一的代码,编译器才能把标准的for each循环自动转换为Iterator遍历。
如果我们自己编写了一个集合类,想要使用for each循环,只需满足以下条件:

  • 集合类实现Iterable接口,该接口要求返回一个Iterator对象;
  • 用Iterator对象迭代集合内部数据。

这里的关键在于,集合类通过调用iterator()方法,返回一个Iterator对象,这个对象必须自己知道如何遍历该集合。
一个简单的Iterator示例如下,它总是以倒序遍历集合:

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

public class Main {
public static void main(String[] args) {
ReverseList<String> rlist = new ReverseList<>();
rlist.add("Apple");
rlist.add("Orange");
rlist.add("Pear");
for (String s : rlist) {
System.out.println(s);
}
}
}

class ReverseList<T> implements Iterable<T> {

private List<T> list = new ArrayList<>();

public void add(T t) {
list.add(t);
}

@Override
public Iterator<T> iterator() {
return new ReverseIterator(list.size());
}

class ReverseIterator implements Iterator<T> {
int index;

ReverseIterator(int index) {
this.index = index;
}

@Override
public boolean hasNext() {
return index > 0;
}

@Override
public T next() {
index--;
return ReverseList.this.list.get(index);
}
}
}


虽然ReverseList和ReverseIterator的实现类稍微比较复杂,但是,注意到这是底层集合库,只需编写一次。而调用方则完全按for each循环编写代码,根本不需要知道集合内部的存储逻辑和遍历逻辑。
在编写Iterator的时候,我们通常可以用一个内部类来实现Iterator接口,这个内部类可以直接访问对应的外部类的所有字段和方法。例如,上述代码中,内部类ReverseIterator可以用ReverseList.this获得当前外部类的this引用,然后,通过这个this引用就可以访问ReverseList的所有字段和方法。

小结

  • Iterator是一种抽象的数据访问模型。使用Iterator模式进行迭代的好处有:
    • 对任何集合都采用同一种访问模型;
    • 调用者对集合内部结构一无所知;
    • 集合类返回的Iterator对象知道如何迭代。
  • Java提供了标准的迭代器模型,即集合类实现java.util.Iterable接口,返回java.util.Iterator实例。

使用Collections

Collections是JDK提供的工具类,同样位于java.util包中。它提供了一系列静态方法,能更方便地操作各种集合。

注意Collections结尾多了一个s,不是Collection!
我们一般看方法名和参数就可以确认Collections提供的该方法的功能。例如,对于以下静态方法:

1
public static boolean addAll(Collection<? super T> c, T... elements) { ... }

addAll()方法可以给一个Collection类型的集合添加若干元素。因为方法签名是Collection,所以我们可以传入List,Set等各种集合类型。

创建空集合

Collections提供了一系列方法来创建空集合:

  • 创建空List:List<T> emptyList()
  • 创建空Map:Map<K, V> emptyMap()
  • 创建空Set:Set<T> emptySet()

要注意到返回的空集合是不可变集合,无法向其中添加或删除元素。
此外,也可以用各个集合接口提供的of(T…)方法创建空集合。例如,以下创建空List的两个方法是等价的:

1
2
List<String> list1 = List.of();
List<String> list2 = Collections.emptyList();

创建单元素集合

Collections提供了一系列方法来创建一个单元素集合:

  • 创建一个元素的List:List<T> singletonList(T o)
  • 创建一个元素的Map:Map<K, V> singletonMap(K key, V value)
  • 创建一个元素的Set:Set<T> singleton(T o)

要注意到返回的单元素集合也是不可变集合,无法向其中添加或删除元素。
此外,也可以用各个集合接口提供的of(T…)方法创建单元素集合。例如,以下创建单元素List的两个方法是等价的:

1
2
List<String> list1 = List.of("apple");
List<String> list2 = Collections.singletonList("apple");

实际上,使用List.of(T...)更方便,因为它既可以创建空集合,也可以创建单元素集合,还可以创建任意个元素的集合:
1
2
3
4
List<String> list1 = List.of(); // empty list
List<String> list2 = List.of("apple"); // 1 element
List<String> list3 = List.of("apple", "pear"); // 2 elements
List<String> list4 = List.of("apple", "pear", "orange"); // 3 elements

排序

Collections可以对List进行排序。因为排序会直接修改List元素的位置,因此必须传入可变List:

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

public class Main {
public static void main(String[] args) {
List<String> list = new ArrayList<>();
list.add("apple");
list.add("pear");
list.add("orange");
// 排序前:
System.out.println(list);
Collections.sort(list);
// 排序后:
System.out.println(list);
}
}

洗牌

Collections提供了洗牌算法,即传入一个有序的List,可以随机打乱List内部元素的顺序,效果相当于让计算机洗牌:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import java.util.*;
public class Main {
public static void main(String[] args) {
List<Integer> list = new ArrayList<>();
for (int i=0; i<10; i++) {
list.add(i);
}
// 洗牌前:
System.out.println(list);
Collections.shuffle(list);
// 洗牌后:
System.out.println(list);
}
}

不可变集合

Collections还提供了一组方法把可变集合封装成不可变集合:

  • 封装成不可变List:List<T> unmodifiableList(List<? extends T> list)
  • 封装成不可变Set:Set<T> unmodifiableSet(Set<? extends T> set)
  • 封装成不可变Map:Map<K, V> unmodifiableMap(Map<? extends K, ? extends V> m)

这种封装实际上是通过创建一个代理对象,拦截掉所有修改方法实现的。我们来看看效果:

1
2
3
4
5
6
7
8
9
10
public class Main {
public static void main(String[] args) {
List<String> mutable = new ArrayList<>();
mutable.add("apple");
mutable.add("pear");
// 变为不可变集合:
List<String> immutable = Collections.unmodifiableList(mutable);
immutable.add("orange"); // UnsupportedOperationException!
}
}

然而,继续对原始的可变List进行增删是可以的,并且,会直接影响到封装后的“不可变”List:
1
2
3
4
5
6
7
8
9
10
11
12
import java.util.*;
public class Main {
public static void main(String[] args) {
List<String> mutable = new ArrayList<>();
mutable.add("apple");
mutable.add("pear");
// 变为不可变集合:
List<String> immutable = Collections.unmodifiableList(mutable);
mutable.add("orange");
System.out.println(immutable);
}
}

因此,如果我们希望把一个可变List封装成不可变List,那么,返回不可变List后,最好立刻扔掉可变List的引用,这样可以保证后续操作不会意外改变原始对象,从而造成“不可变”List变化了:
1
2
3
4
5
6
7
8
9
10
11
12
13
import java.util.*;
public class Main {
public static void main(String[] args) {
List<String> mutable = new ArrayList<>();
mutable.add("apple");
mutable.add("pear");
// 变为不可变集合:
List<String> immutable = Collections.unmodifiableList(mutable);
// 立刻扔掉mutable的引用:
mutable = null;
System.out.println(immutable);
}
}

线程安全集合

Collections还提供了一组方法,可以把线程不安全的集合变为线程安全的集合:

  • 变为线程安全的List:List<T> synchronizedList(List<T> list)
  • 变为线程安全的Set:Set<T> synchronizedSet(Set<T> s)
  • 变为线程安全的Map:Map<K,V> synchronizedMap(Map<K,V> m)

多线程的概念我们会在后面讲。因为从Java 5开始,引入了更高效的并发集合类,所以上述这几个同步方法已经没有什么用了。

小结

Collections类提供了一组工具方法来方便使用集合类:

  • 创建空集合;
  • 创建单元素集合;
  • 创建不可变集合;
  • 排序/洗牌等操作。

概述

在讲解什么是泛型之前,我们先观察 Java 标准库提供的 ArrayList,它可以看作 “可变长度” 的数组,因为用起来比数组更方便。
实际上 ArrayList 内部就是一个 Object[]数组,配合存储一个当前分配的长度,就可以充当“可变数组”:

1
2
3
4
5
6
7
public class ArrayList {
private Object[] array;
private int size;
public void add(Object e) {...}
public void remove(int index) {...}
public Object get(int index) {...}
}

如果用上述 ArrayList 存储 String 类型,会有这么几个缺点:

  • 需要强制转型;
  • 不方便,易出错。

例如,代码必须这么写:

1
2
3
4
ArrayList list = new ArrayList();
list.add("Hello");
// 获取到Object,必须强制转型为String:
String first = (String) list.get(0);

很容易出现ClassCastException,因为容易“误转型”:
1
2
3
list.add(new Integer(123));
// ERROR: ClassCastException:
String second = (String) list.get(1);

要解决上述问题,我们可以为String单独编写一种ArrayList:
1
2
3
4
5
6
7
public class StringArrayList {
private String[] array;
private int size;
public void add(String e) {...}
public void remove(int index) {...}
public String get(int index) {...}
}

这样一来,存入的必须是String,取出的也一定是String,不需要强制转型,因为编译器会强制检查放入的类型:
1
2
3
4
5
StringArrayList list = new StringArrayList();
list.add("Hello");
String first = list.get(0);
// 编译错误: 不允许放入非String类型:
list.add(new Integer(123));

问题暂时解决。
然而,新的问题是,如果要存储Integer,还需要为Integer单独编写一种ArrayList:
1
2
3
4
5
6
7
public class IntegerArrayList {
private Integer[] array;
private int size;
public void add(Integer e) {...}
public void remove(int index) {...}
public Integer get(int index) {...}
}

实际上,还需要为其他所有class单独编写一种ArrayList:
- LongArrayList
- DoubleArrayList
- PersonArrayList
- …

这是不可能的,JDK的class就有上千个,而且它还不知道其他人编写的class。
为了解决新的问题,我们必须把ArrayList变成一种模板:ArrayList<T>,代码如下:

1
2
3
4
5
6
7
public class ArrayList<T> {
private T[] array;
private int size;
public void add(T e) {...}
public void remove(int index) {...}
public T get(int index) {...}
}

T可以是任何class。这样一来,我们就实现了:编写一次模版,可以创建任意类型的ArrayList:
1
2
3
4
5
6
// 创建可以存储String的ArrayList:
ArrayList<String> strList = new ArrayList<String>();
// 创建可以存储Float的ArrayList:
ArrayList<Float> floatList = new ArrayList<Float>();
// 创建可以存储Person的ArrayList:
ArrayList<Person> personList = new ArrayList<Person>();

因此,泛型就是定义一种模板,例如ArrayList<T>,然后在代码中为用到的类创建对应的ArrayList<类型>:
1
ArrayList<String> strList = new ArrayList<String>();

由编译器针对类型作检查:
1
2
3
4
strList.add("hello"); // OK
String s = strList.get(0); // OK
strList.add(new Integer(123)); // compile error!
Integer n = strList.get(0); // compile error!

这样一来,既实现了编写一次,万能匹配,又通过编译器保证了类型安全:这就是泛型。

向上转型

在 Java 标准库中的 ArrayList<T> 实现了 List<T> 接口,它可以向上转型为 List<T>

1
2
3
4
public class ArrayList<T> implements List<T> {
...
}
List<String> list = new ArrayList<String>();

即类型 ArrayList<T> 可以向上转型为 List<T>
要特别注意:不能把 ArrayList<Integer> 向上转型为 ArrayList<Number> 或 List
这是为什么呢?假设 ArrayList<Integer> 可以向上转型为 ArrayList,观察一下代码:
1
2
3
4
5
6
7
8
9
10
// 创建ArrayList<Integer>类型:
ArrayList<Integer> integerList = new ArrayList<Integer>();
// 添加一个Integer:
integerList.add(new Integer(123));
// “向上转型”为ArrayList<Number>:
ArrayList<Number> numberList = integerList;
// 添加一个Float,因为Float也是Number:
numberList.add(new Float(12.34));
// 从ArrayList<Integer>获取索引为1的元素(即添加的Float):
Integer n = integerList.get(1); // ClassCastException!

我们把一个 ArrayList<Integer> 转型为 ArrayList<Number> 类型后,这个 ArrayList<Number> 就可以接受 Float 类型,因为 Float 是 Number 的子类。但是,ArrayList<Number> 实际上和 ArrayList<Integer> 是同一个对象,也就是 ArrayList<Integer> 类型,它不可能接受 Float 类型, 所以在获取 Integer 的时候将产生 ClassCastException。
实际上,编译器为了避免这种错误,根本就不允许把 ArrayList<Integer> 转型为 ArrayList<Number>

ArrayList<Integer>ArrayList<Number> 两者完全没有继承关系。

小结

  • 泛型就是编写模板代码来适应任意类型;
  • 泛型的好处是使用时不必对类型进行强制转换,它通过编译器对类型进行检查;
  • 注意泛型的继承关系:可以把 ArrayList<Integer> 向上转型为 List<Integer>(T 不能变!),但不能把 ArrayList<Integer> 向上转型为 ArrayList<Number>(T 不能变成父类)。

使用泛型

使用 ArrayList 时,如果不定义泛型类型时,泛型类型实际上就是 Object

1
2
3
4
5
6
// 编译器警告:
List list = new ArrayList();
list.add("Hello");
list.add("World");
String first = (String) list.get(0);
String second = (String) list.get(1);

此时,只能把 <T> 当作 Object 使用,没有发挥泛型的优势。
当我们定义泛型类型 <String> 后,List<T> 的泛型接口变为强类型 List<String>
1
2
3
4
5
6
7
// 无编译器警告:
List<String> list = new ArrayList<String>();
list.add("Hello");
list.add("World");
// 无强制转型:
String first = list.get(0);
String second = list.get(1);

当我们定义泛型类型<Number>后,List<T>的泛型接口变为强类型List<Number>
1
2
3
4
5
List<Number> list = new ArrayList<Number>();
list.add(new Integer(123)); // add添加的是一个Number的实例,并且使用了向上转型
list.add(new Double(12.34));
Number first = list.get(0);
Number second = list.get(1);

编译器如果能自动推断出泛型类型,就可以省略后面的泛型类型。例如,对于下面的代码:
1
List<Number> list = new ArrayList<Number>();

编译器看到泛型类型List就可以自动推断出后面的ArrayList的泛型类型必须是ArrayList,因此,可以把代码简写为:
1
2
// 可以省略后面的Number,编译器可以自动推断泛型类型:
List<Number> list = new ArrayList<>();

泛型接口

除了 ArrayList<T> 使用了泛型,还可以在接口中使用泛型。例如,Arrays.sort(Object[]) 可以对任意数组进行排序,但待排序的元素必须实现 Comparable<T> 这个泛型接口:

1
2
3
4
5
6
7
8
public interface Comparable<T> {
/**
* 返回负数: 当前实例比参数o小
* 返回0: 当前实例与参数o相等
* 返回正数: 当前实例比参数o大
*/
int compareTo(T o);
}

可以直接对String数组进行排序:
1
2
3
4
5
6
7
8
9
import java.util.Arrays;

public class Main {
public static void main(String[] args) {
String[] ss = new String[] { "Orange", "Apple", "Pear" };
Arrays.sort(ss);
System.out.println(Arrays.toString(ss));
}
}

这是因为 String 本身已经实现了 Comparable<String> 接口。如果换成我们自定义的 Person 类型试试:
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
import java.util.Arrays;

public class Main {
public static void main(String[] args) {
Person[] ps = new Person[] {
new Person("Bob", 61),
new Person("Alice", 88),
new Person("Lily", 75),
};
Arrays.sort(ps);
System.out.println(Arrays.toString(ps));
}
}

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

运行程序,我们会得到ClassCastException,即无法将Person转型为Comparable。我们修改代码,让Person实现Comparable<T>接口:
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
import java.util.Arrays;

public class Main {
public static void main(String[] args) {
Person[] ps = new Person[] {
new Person("Bob", 61),
new Person("Alice", 88),
new Person("Lily", 75),
};
Arrays.sort(ps);
System.out.println(Arrays.toString(ps));
}
}
class Person implements Comparable<Person> {
String name;
int score;
Person(String name, int score) {
this.name = name;
this.score = score;
}
public int compareTo(Person other) {
return this.name.compareTo(other.name);
}
public String toString() {
return this.name + "," + this.score;
}
}

运行上述代码,可以正确实现按name进行排序。
也可以修改比较逻辑,例如,按score从高到低排序。请自行修改测试。

小结

  • 使用泛型时,把泛型参数<T>替换为需要的class类型,例如:ArrayList<String>ArrayList<Number>等;
  • 可以省略编译器能自动推断出的类型,例如:List<String> list = new ArrayList<>();
  • 不指定泛型参数类型时,编译器会给出警告,且只能将<T>视为Object类型;
  • 可以在接口中定义泛型类型,实现此接口的类必须实现正确的泛型类型。

编写泛型

编写泛型类比普通类要复杂。通常来说,泛型类一般用在集合类中,例如ArrayList<T>,我们很少需要编写泛型类。
如果我们确实需要编写一个泛型类,那么,应该如何编写它?
可以按照以下步骤来编写一个泛型类。
首先,按照某种类型,例如:String,来编写类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Pair {
private String first;
private String last;
public Pair(String first, String last) {
this.first = first;
this.last = last;
}
public String getFirst() {
return first;
}
public String getLast() {
return last;
}
}

然后,标记所有的特定类型,这里是String:
最后,把特定类型String替换为T,并申明<T>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Pair<T> {
private T first;
private T last;
public Pair(T first, T last) {
this.first = first;
this.last = last;
}
public T getFirst() {
return first;
}
public T getLast() {
return last;
}
}

熟练后即可直接从T开始编写。

静态方法

编写泛型类时,要特别注意,泛型类型 <T> 不能用于静态方法。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Pair<T> {
private T first;
private T last;
public Pair(T first, T last) {
this.first = first;
this.last = last;
}
public T getFirst() { ... }
public T getLast() { ... }

// 对静态方法使用<T>:
public static Pair<T> create(T first, T last) {
return new Pair<T>(first, last);
}
}

上述代码会导致编译错误,我们无法在静态方法create()的方法参数和返回类型上使用泛型类型T
有些同学在网上搜索发现,可以在static修饰符后面加一个<T>,编译就能通过:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Pair<T> {
private T first;
private T last;
public Pair(T first, T last) {
this.first = first;
this.last = last;
}
public T getFirst() { ... }
public T getLast() { ... }

// 可以编译通过:
public static <T> Pair<T> create(T first, T last) {
return new Pair<T>(first, last);
}
}

但实际上,这个<T>Pair<T>类型的<T>已经没有任何关系了。
对于静态方法,我们可以单独改写为“泛型”方法,只需要使用另一个类型即可。对于上面的create()静态方法,我们应该把它改为另一种泛型类型,例如,<K>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Pair<T> {
private T first;
private T last;
public Pair(T first, T last) {
this.first = first;
this.last = last;
}
public T getFirst() { ... }
public T getLast() { ... }

// 静态泛型方法应该使用其他类型区分:
public static <K> Pair<K> create(K first, K last) {
return new Pair<K>(first, last);
}
}

这样才能清楚地将静态方法的泛型类型和实例类型的泛型类型区分开。

多个泛型类型

泛型还可以定义多种类型。例如,我们希望Pair不总是存储两个类型一样的对象,就可以使用类型<T, K>

1
2
3
4
5
6
7
8
9
10
public class Pair<T, K> {
private T first;
private K last;
public Pair(T first, K last) {
this.first = first;
this.last = last;
}
public T getFirst() { ... }
public K getLast() { ... }
}

使用的时候,需要指出两种类型:
1
Pair<String, Integer> p = new Pair<>("test", 123);

Java标准库的Map<K, V>就是使用两种泛型类型的例子。它对Key使用一种类型,对Value使用另一种类型。

小结

  • 编写泛型时,需要定义泛型类型<T>
  • 静态方法不能引用泛型类型<T>,必须定义其他类型(例如<K>)来实现静态泛型方法;
  • 泛型可以同时定义多种类型,例如Map<K, V>

擦拭法

泛型是一种类似”模板代码“的技术,不同语言的泛型实现方式不一定相同。
Java语言的泛型实现方式是擦拭法(Type Erasure)。
所谓擦拭法是指,虚拟机对泛型其实一无所知,所有的工作都是编译器做的。
例如,我们编写了一个泛型类Pair,这是编译器看到的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Pair<T> {
private T first;
private T last;
public Pair(T first, T last) {
this.first = first;
this.last = last;
}
public T getFirst() {
return first;
}
public T getLast() {
return last;
}
}

而虚拟机根本不知道泛型。这是虚拟机执行的代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Pair {
private Object first;
private Object last;
public Pair(Object first, Object last) {
this.first = first;
this.last = last;
}
public Object getFirst() {
return first;
}
public Object getLast() {
return last;
}
}

因此,Java使用擦拭法实现泛型,导致了:

  • 编译器把类型<T>视为Object;
  • 编译器根据<T>实现安全的强制转型。

使用泛型的时候,我们编写的代码也是编译器看到的代码:

1
2
3
Pair<String> p = new Pair<>("Hello", "world");
String first = p.getFirst();
String last = p.getLast();

而虚拟机执行的代码并没有泛型:
1
2
3
Pair p = new Pair("Hello", "world");
String first = (String) p.getFirst();
String last = (String) p.getLast();

所以,Java的泛型是由编译器在编译时实行的,编译器内部永远把所有类型T视为Object处理,但是,在需要转型的时候,编译器会根据T的类型自动为我们实行安全地强制转型。

了解了Java泛型的实现方式——擦拭法,我们就知道了Java泛型的局限:

局限一:<T>不能是基本类型,例如int,因为实际类型是Object,Object类型无法持有基本类型:

1
Pair<int> p = new Pair<>(1, 2); // compile error!

局限二:无法取得带泛型的Class。观察以下代码:
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
public class Main {
public static void main(String[] args) {
Pair<String> p1 = new Pair<>("Hello", "world");
Pair<Integer> p2 = new Pair<>(123, 456);
Class c1 = p1.getClass();
Class c2 = p2.getClass();
System.out.println(c1==c2); // true
System.out.println(c1==Pair.class); // true

}
}

class Pair<T> {
private T first;
private T last;
public Pair(T first, T last) {
this.first = first;
this.last = last;
}
public T getFirst() {
return first;
}
public T getLast() {
return last;
}
}

因为 T 是 Object,我们对 Pair<String>Pair<Integer> 类型获取 Class 时,获取到的是同一个 Class,也就是 Pair 类的 Class。
换句话说,所有泛型实例,无论 T 的类型是什么,getClass() 返回同一个 Class 实例,因为编译后它们全部都是 Pair<Object>

局限三:无法判断带泛型的类型:

1
2
3
4
Pair<Integer> p = new Pair<>(123, 456);
// Compile error:
if (p instanceof Pair<String>) {
}

原因和前面一样,并不存在Pair<String>.class,而是只有唯一的Pair.class

局限四:不能实例化T类型:

1
2
3
4
5
6
7
8
9
public class Pair<T> {
private T first;
private T last;
public Pair() {
// Compile error:
first = new T();
last = new T();
}
}

上述代码无法通过编译,因为构造方法的两行语句:
1
2
first = new T();
last = new T();

擦拭后实际上变成了:
1
2
first = new Object();
last = new Object();

这样一来,创建 new Pair<String>() 和创建 new Pair<Integer>() 就全部成了 Object,显然编译器要阻止这种类型不对的代码。
要实例化 T 类型,我们必须借助额外的 Class<T> 参数:
1
2
3
4
5
6
7
8
public class Pair<T> {
private T first;
private T last;
public Pair(Class<T> clazz) {
first = clazz.newInstance();
last = clazz.newInstance();
}
}

上述代码借助Class参数并通过反射来实例化T类型,使用的时候,也必须传入Class。例如:
1
Pair<String> pair = new Pair<>(String.class);

因为传入了Class的实例,所以我们借助String.class就可以实例化String类型。

不恰当的覆写方法

有些时候,一个看似正确定义的方法会无法通过编译。例如:

1
2
3
4
5
public class Pair<T> {
public boolean equals(T t) {
return this == t;
}
}

这是因为,定义的equals(T t)方法实际上会被擦拭成equals(Object t),而这个方法是继承自Object的,编译器会阻止一个实际上会变成覆写的泛型方法定义。

换个方法名,避开与Object.equals(Object)的冲突就可以成功编译:

1
2
3
4
5
public class Pair<T> {
public boolean same(T t) {
return this == t;
}
}

泛型继承

一个类可以继承自一个泛型类。例如:父类的类型是Pair<Integer>,子类的类型是IntPair,可以这么继承:

1
2
public class IntPair extends Pair<Integer> {
}

使用的时候,因为子类IntPair并没有泛型类型,所以,正常使用即可:
1
IntPair ip = new IntPair(1, 2);

前面讲了,我们无法获取 Pair<T> 的 T 类型,即给定一个变量 Pair<Integer> p,无法从 p 中获取到 Integer 类型。

但是,在父类是泛型类型的情况下,编译器就必须把类型 T(对 IntPair 来说,也就是 Integer 类型)保存到子类的 class 文件中,不然编译器就不知道 IntPair 只能存取 Integer 这种类型。

在继承了泛型类型的情况下,子类可以获取父类的泛型类型。例如:IntPair 可以获取到父类的泛型类型 Integer。获取父类的泛型类型代码比较复杂:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;

public class Main {
public static void main(String[] args) {
Class<IntPair> clazz = IntPair.class;
Type t = clazz.getGenericSuperclass();
if (t instanceof ParameterizedType) {
ParameterizedType pt = (ParameterizedType) t;
Type[] types = pt.getActualTypeArguments(); // 可能有多个泛型类型
Type firstType = types[0]; // 取第一个泛型类型
Class<?> typeClass = (Class<?>) firstType;
System.out.println(typeClass); // Integer
}

}
}

class Pair<T> {
private T first;
private T last;
public Pair(T first, T last) {
this.first = first;
this.last = last;
}
public T getFirst() {
return first;
}
public T getLast() {
return last;
}
}

class IntPair extends Pair<Integer> {
public IntPair(Integer first, Integer last) {
super(first, last);
}
}

因为Java引入了泛型,所以,只用Class来标识类型已经不够了。实际上,Java的类型系统结构如下:
1
2
3
4
5
6
7
8
9
10
                     ┌────┐
│Type│
└────┘


┌────────────┬────────┴─────────┬───────────────┐
│ │ │ │
┌─────┐┌─────────────────┐┌────────────────┐┌────────────┐
│Class││ParameterizedType││GenericArrayType││WildcardType│
└─────┘└─────────────────┘└────────────────┘└────────────┘

小结

  • Java的泛型是采用擦拭法实现的;
  • 擦拭法决定了泛型<T>
    • 不能是基本类型,例如:int;
    • 不能获取带泛型类型的Class,例如:Pair<String>.class
    • 不能判断带泛型类型的类型,例如:x instanceof Pair<String>
    • 不能实例化T类型,例如:new T()
    • 泛型方法要防止重复定义方法,例如:public boolean equals(T obj)
  • 子类可以获取父类的泛型类型<T>

extends通配符

我们前面已经讲到了泛型的继承关系:Pair<Integer> 不是 Pair<Number> 的子类。

假设我们定义了 Pair<T>

1
public class Pair<T> { ... }

然后,我们又针对 Pair<Number> 类型写了一个静态方法,它接收的参数类型是 Pair<Number>
1
2
3
4
5
6
7
public class PairHelper {
static int add(Pair<Number> p) {
Number first = p.getFirst();
Number last = p.getLast();
return first.intValue() + last.intValue();
}
}

上述代码是可以正常编译的。使用的时候,我们传入:
1
int sum = PairHelper.add(new Pair<Number>(1, 2));

注意:传入的类型是Pair<Number>,实际参数类型是(Integer, Integer)。

既然实际参数是Integer类型,试试传入Pair<Integer>

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
public class Main {
public static void main(String[] args) {
Pair<Integer> p = new Pair<>(123, 456);
int n = add(p);
System.out.println(n);
}

static int add(Pair<Number> p) {
Number first = p.getFirst();
Number last = p.getLast();
return first.intValue() + last.intValue();
}

}

class Pair<T> {
private T first;
private T last;
public Pair(T first, T last) {
this.first = first;
this.last = last;
}
public T getFirst() {
return first;
}
public T getLast() {
return last;
}
}

直接运行,会得到一个编译错误:
1
incompatible types: Pair<Integer> cannot be converted to Pair<Number>

原因很明显,因为Pair<Integer>不是Pair<Number>的子类,因此,add(Pair<Number>)不接受参数类型Pair<Integer>
但是从add()方法的代码可知,传入Pair<Integer>是完全符合内部代码的类型规范,因为语句:
1
2
Number first = p.getFirst();
Number last = p.getLast();

实际类型是Integer,引用类型是Number,没有问题。问题在于方法参数类型定死了只能传入Pair<Number>

有没有办法使得方法参数接受Pair<Integer>?办法是有的,这就是使用Pair<? extends Number>使得方法接收所有泛型类型为Number或Number子类的Pair类型。我们把代码改写如下:

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
public class Main {
public static void main(String[] args) {
Pair<Integer> p = new Pair<>(123, 456);
int n = add(p);
System.out.println(n);
}

static int add(Pair<? extends Number> p) {
Number first = p.getFirst();
Number last = p.getLast();
return first.intValue() + last.intValue();
}

}

class Pair<T> {
private T first;
private T last;
public Pair(T first, T last) {
this.first = first;
this.last = last;
}
public T getFirst() {
return first;
}
public T getLast() {
return last;
}
}

这样一来,给方法传入 Pair<Integer> 类型时,它符合参数 Pair<? extends Number> 类型。这种使用 <? extends Number > 的泛型定义称之为上界通配符(Upper Bounds Wildcards),即把泛型类型 T 的上界限定在 Number 了。
除了可以传入 Pair<Integer> 类型,我们还可以传入 Pair<Double> 类型,Pair<BigDecimal> 类型等等,因为 Double 和 BigDecimal 都是 Number 的子类。
如果我们考察对 Pair<? extends Number> 类型调用 getFirst() 方法,实际的方法签名变成了:
1
<? extends Number> getFirst();   

即返回值是Number或Number的子类,因此,可以安全赋值给Number类型的变量:
1
Number x = p.getFirst();

然后,我们不可预测实际类型就是Integer,例如,下面的代码是无法通过编译的:
1
Integer x = p.getFirst();

这是因为实际的返回类型可能是Integer,也可能是Double或者其他类型,编译器只能确定类型一定是Number的子类(包括Number类型本身),但具体类型无法确定。
我们再来考察一下Pair<T>的set方法:
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) {
Pair<Integer> p = new Pair<>(123, 456);
int n = add(p);
System.out.println(n);
}

static int add(Pair<? extends Number> p) {
Number first = p.getFirst();
Number last = p.getLast();
p.setFirst(new Integer(first.intValue() + 100));
p.setLast(new Integer(last.intValue() + 100));
return p.getFirst().intValue() + p.getFirst().intValue();
}

}

class Pair<T> {
private T first;
private T last;

public Pair(T first, T last) {
this.first = first;
this.last = last;
}

public T getFirst() {
return first;
}
public T getLast() {
return last;
}
public void setFirst(T first) {
this.first = first;
}
public void setLast(T last) {
this.last = last;
}
}

不出意外,我们会得到一个编译错误:
1
2
3
incompatible types: Integer cannot be converted to CAP#1
where CAP#1 is a fresh type-variable:
CAP#1 extends Number from capture of ? extends Number

编译错误发生在p.setFirst()传入的参数是Integer类型。有些童鞋会问了,既然p的定义是Pair<? extends Number>,那么setFirst(? extends Number)为什么不能传入Integer?
原因还在于擦拭法。如果我们传入的p是Pair<Double>,显然它满足参数定义Pair<? extends Number>,然而,Pair<Double>的setFirst()显然无法接受Integer类型。
这就是<? extends Number>通配符的一个重要限制:方法参数签名setFirst(? extends Number)无法传递任何Number的子类型给setFirst(? extends Number)
这里唯一的例外是可以给方法参数传入null:
1
2
p.setFirst(null); // ok, 但是后面会抛出NullPointerException
p.getFirst().intValue(); // NullPointerException

extends通配符的作用

如果我们考察Java标准库的java.util.List<T>接口,它实现的是一个类似“可变数组”的列表,主要功能包括:

1
2
3
4
5
6
public interface List<T> {
int size(); // 获取个数
T get(int index); // 根据索引获取指定元素
void add(T t); // 添加一个新元素
void remove(T t); // 删除一个已有元素
}

现在,让我们定义一个方法来处理列表的每个元素:
1
2
3
4
5
6
7
8
int sumOfList(List<? extends Integer> list) {
int sum = 0;
for (int i=0; i<list.size(); i++) {
Integer n = list.get(i);
sum = sum + n;
}
return sum;
}

为什么我们定义的方法参数类型是List<? extends Integer>而不是List<Integer>?从方法内部代码看,传入List<? extends Integer>或者List<Integer>是完全一样的,但是,注意到List<? extends Integer>的限制:

  • 允许调用get()方法获取Integer的引用;
  • 不允许调用set(? extends Integer)方法并传入任何Integer的引用(null除外)。

因此,方法参数类型List<? extends Integer>表明了该方法内部只会读取List的元素,不会修改List的元素(因为无法调用add(? extends Integer)、remove(? extends Integer)这些方法。换句话说,这是一个对参数List<? extends Integer>进行只读的方法(恶意调用set(null)除外)。

使用extends限定T类型

在定义泛型类型Pair的时候,也可以使用extends通配符来限定T的类型:

1
public class Pair<T extends Number> { ... }

现在,我们只能定义:
1
2
3
Pair<Number> p1 = null;
Pair<Integer> p2 = new Pair<>(1, 2);
Pair<Double> p3 = null

因为Number、Integer和Double都符合<T extends Number>
非Number类型将无法通过编译:
1
2
Pair<String> p1 = null; // compile error!
Pair<Object> p2 = null; // compile error!

因为String、Object都不符合<T extends Number>,因为它们不是Number类型或Number的子类。

小结

  • 使用类似<? extends Number>通配符作为方法参数时表示:
    • 方法内部可以调用获取Number引用的方法,例如:Number n = obj.getFirst();
    • 方法内部无法调用传入Number引用的方法(null除外),例如:obj.setFirst(Number n);

即一句话总结:使用extends通配符表示可以读,不能写。

  • 使用类似定义泛型类时表示:
    • 泛型类型限定为Number以及Number的子类。

super通配符

我们前面已经讲到了泛型的继承关系:Pair<Integer>不是Pair<Number>的子类。
考察下面的set方法:

1
2
3
4
void set(Pair<Integer> p, Integer first, Integer last) {
p.setFirst(first);
p.setLast(last);
}

传入 Pair<Integer> 是允许的,但是传入 Pair<Number> 是不允许的。
和 extends 通配符相反,这次,我们希望接受 Pair<Integer> 类型,以及 Pair<Number>Pair<Object>,因为 Number 和 Object 是 Integer 的父类,setFirst(Number) 和 setFirst(Object) 实际上允许接受 Integer 类型。
我们使用super通配符来改写这个方法:
1
2
3
4
void set(Pair<? super Integer> p, Integer first, Integer last) {
p.setFirst(first);
p.setLast(last);
}

注意到Pair<? super Integer>表示,方法参数接受所有泛型类型为Integer或Integer父类的Pair类型。
下面的代码可以被正常编译:
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) {
Pair<Number> p1 = new Pair<>(12.3, 4.56);
Pair<Integer> p2 = new Pair<>(123, 456);
setSame(p1, 100);
setSame(p2, 200);
System.out.println(p1.getFirst() + ", " + p1.getLast());
System.out.println(p2.getFirst() + ", " + p2.getLast());
}

static void setSame(Pair<? super Integer> p, Integer n) {
p.setFirst(n);
p.setLast(n);
}

}

class Pair<T> {
private T first;
private T last;

public Pair(T first, T last) {
this.first = first;
this.last = last;
}

public T getFirst() {
return first;
}
public T getLast() {
return last;
}
public void setFirst(T first) {
this.first = first;
}
public void setLast(T last) {
this.last = last;
}
}

考察 Pair<? super Integer>的setFirst()方法,它的方法签名实际上是:
1
void setFirst(? super Integer);

因此,可以安全地传入Integer类型。
再考察Pair<? super Integer>的getFirst()方法,它的方法签名实际上是:
1
? super Integer getFirst();

这里注意到我们无法使用Integer类型来接收getFirst()的返回值,即下面的语句将无法通过编译:
1
Integer x = p.getFirst();

因为如果传入的实际类型是Pair<Number>,编译器无法将Number类型转型为Integer。
注意:虽然Number是一个抽象类,我们无法直接实例化它。但是,即便Number不是抽象类,这里仍然无法通过编译。此外,传入Pair<Object>类型时,编译器也无法将Object类型转型为Integer。
唯一可以接收getFirst()方法返回值的是Object类型:
1
Object obj = p.getFirst();

因此,使用<? super Integer>通配符表示:

  • 允许调用set(? super Integer)方法传入Integer的引用;
  • 不允许调用get()方法获得Integer的引用。

唯一例外是可以获取Object的引用:Object o = p.getFirst()。
换句话说,使用<? super Integer>通配符作为方法参数,表示方法内部代码对于参数只能写,不能读。

对比extends和super通配符

我们再回顾一下extends通配符。作为方法参数,<? extends T>类型和<? super T>类型的区别在于:

  • <? extends T>允许调用读方法T get()获取T的引用,但不允许调用写方法set(T)传入T的引用(传入null除外);
  • <? super T>允许调用写方法set(T)传入T的引用,但不允许调用读方法T get()获取T的引用(获取Object除外)。

一个是允许读不允许写,另一个是允许写不允许读。
先记住上面的结论,我们来看Java标准库的Collections类定义的copy()方法:

1
2
3
4
5
6
7
8
9
public class Collections {
// 把src的每个元素复制到dest中:
public static <T> void copy(List<? super T> dest, List<? extends T> src) {
for (int i=0; i<src.size(); i++) {
T t = src.get(i);
dest.add(t);
}
}
}

它的作用是把一个List的每个元素依次添加到另一个List中。它的第一个参数是List<? super T>,表示目标List,第二个参数List<? extends T>,表示要复制的List。我们可以简单地用for循环实现复制。在for循环中,我们可以看到,对于类型<? extends T>的变量src,我们可以安全地获取类型T的引用,而对于类型<? super T>的变量dest,我们可以安全地传入T的引用。
这个copy()方法的定义就完美地展示了extends和super的意图:

  • copy()方法内部不会读取dest,因为不能调用dest.get()来获取T的引用;
  • copy()方法内部也不会修改src,因为不能调用src.add(T)。

这是由编译器检查来实现的。如果在方法代码中意外修改了src,或者意外读取了dest,就会导致一个编译错误:

1
2
3
4
5
6
7
8
public class Collections {
// 把src的每个元素复制到dest中:
public static <T> void copy(List<? super T> dest, List<? extends T> src) {
...
T t = dest.get(0); // compile error!
src.add(t); // compile error!
}
}

这个copy()方法的另一个好处是可以安全地把一个List<Integer>添加到List<Number>,但是无法反过来添加:
1
2
3
4
5
6
7
// copy List<Integer> to List<Number> ok:
List<Number> numList = ...;
List<Integer> intList = ...;
Collections.copy(numList, intList);

// ERROR: cannot copy List<Number> to List<Integer>:
Collections.copy(intList, numList);

而这些都是通过super和extends通配符,并由编译器强制检查来实现的。

PECS原则

何时使用extends,何时使用super?为了便于记忆,我们可以用PECS原则:Producer Extends Consumer Super
即:如果需要返回T,它是生产者(Producer),要使用extends通配符;如果需要写入T,它是消费者(Consumer),要使用super通配符。
还是以Collections的copy()方法为例:

1
2
3
4
5
6
7
8
public class Collections {
public static <T> void copy(List<? super T> dest, List<? extends T> src) {
for (int i=0; i<src.size(); i++) {
T t = src.get(i); // src是producer
dest.add(t); // dest是consumer
}
}
}

需要返回T的src是生产者,因此声明为List<? extends T>,需要写入T的dest是消费者,因此声明为List<? super T>

无限定通配符

我们已经讨论了<? extends T><? super T>作为方法参数的作用。实际上,Java的泛型还允许使用无限定通配符(Unbounded Wildcard Type),即只定义一个?:

1
2
3
void sample(Pair<?> p) {

}

因为<?>通配符既没有extends,也没有super,因此:

  • 不允许调用set(T)方法并传入引用(null除外);
  • 不允许调用T get()方法并获取T引用(只能获取Object引用)。

换句话说,既不能读,也不能写,那只能做一些null判断:

1
2
3
static boolean isNull(Pair<?> p) {
return p.getFirst() == null || p.getLast() == null;
}

大多数情况下,可以引入泛型参数<T>消除<?>通配符:
1
2
3
static <T> boolean isNull(Pair<T> p) {
return p.getFirst() == null || p.getLast() == null;
}

<?>通配符有一个独特的特点,就是:Pair<?>是所有Pair<T>的超类:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public class Main {
public static void main(String[] args) {
Pair<Integer> p = new Pair<>(123, 456);
Pair<?> p2 = p; // 安全地向上转型
System.out.println(p2.getFirst() + ", " + p2.getLast());
}

}

class Pair<T> {
private T first;
private T last;

public Pair(T first, T last) {
this.first = first;
this.last = last;
}

public T getFirst() {
return first;
}
public T getLast() {
return last;
}
public void setFirst(T first) {
this.first = first;
}
public void setLast(T last) {
this.last = last;
}
}

上述代码是可以正常编译运行的,因为Pair<Integer>Pair<?>的子类,可以安全地向上转型。

小结

  • 使用类似<? super Integer>通配符作为方法参数时表示:
    • 方法内部可以调用传入Integer引用的方法,例如:obj.setFirst(Integer n);;
    • 方法内部无法调用获取Integer引用的方法(Object除外),例如:Integer n = obj.getFirst();。

即使用super通配符表示只能写不能读。

  • 使用extends和super通配符要遵循PECS原则。
  • 无限定通配符<?>很少使用,可以用<T>替换,同时它是所有<T>类型的超类。

Java 的异常

在语言层面上提供一个异常处理机制。
Java 内置了一套异常处理机制,总是使用异常来表示错误。
异常是一种 class,因此它本身带有类型信息。异常可以在任何地方抛出,但只需要在上层捕获,这样就和方法调用分离了:

1
2
3
4
5
6
7
8
9
10
11
12
try {
String s = processFile(“C:\\test.txt”);
// ok:
} catch (FileNotFoundException e) {
// file not found:
} catch (SecurityException e) {
// no read permission:
} catch (IOException e) {
// io error:
} catch (Exception e) {
// other error:
}

Java 规定

  • 必须捕获的异常,包括 Exception 及其子类,但不包括 RuntimeException 及其子类,这种类型的异常称为 Checked Exception。
  • 不需要捕获的异常,包括 Error 及其子类,RuntimeException 及其子类。

    Note: 编译器对 RuntimeException 及其子类不做强制捕获要求,不是指应用程序本身不应该捕获并处理 RuntimeException。是否需要捕获,具体问题具体分析。

捕获异常

捕获异常使用 try…catch 语句,把可能发生异常的代码放到 try {…} 中,然后使用 catch 捕获对应的 Exception 及其子类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Main {
public static void main(String[] args) {
byte[] bs = toGBK("中文");
System.out.println(Arrays.toString(bs));
}

static byte[] toGBK(String s) {
try {
// 用指定编码转换 String 为 byte[]:
return s.getBytes("GBK");
} catch (UnsupportedEncodingException e) {
// 如果系统不支持 GBK 编码,会捕获到 UnsupportedEncodingException:
System.out.println(e); // 打印异常信息
return s.getBytes(); // 尝试使用用默认编码
}
}
}

如果我们不捕获 UnsupportedEncodingException,会出现编译失败的问题
编译器会报错,错误信息类似:unreported exception UnsupportedEncodingException; must be caught or declared to be thrown,并且准确地指出需要捕获的语句是 return s.getBytes(“GBK”);。意思是说,像 UnsupportedEncodingException 这样的 Checked Exception,必须被捕获。
这是因为 String.getBytes(String) 方法定义是:
1
2
3
public byte[] getBytes(String charsetName) throws UnsupportedEncodingException {
...
}

在方法定义的时候,使用 throws Xxx 表示该方法可能抛出的异常类型。调用方在调用的时候,必须强制捕获这些异常,否则编译器会报错。
在 toGBK() 方法中,因为调用了 String.getBytes(String) 方法,就必须捕获 UnsupportedEncodingException。我们也可以不捕获它,而是在方法定义处用 throws 表示 toGBK() 方法可能会抛出 UnsupportedEncodingException,就可以让 toGBK() 方法通过编译器检查:
1
2
3
4
5
6
7
8
9
10
public class Main {
public static void main(String[] args) {
byte[] bs = toGBK("中文");
System.out.println(Arrays.toString(bs));
}

static byte[] toGBK(String s) throws UnsupportedEncodingException {
return s.getBytes("GBK");
}
}

上述代码仍然会得到编译错误,但这一次,编译器提示的不是调用 return s.getBytes(“GBK”); 的问题,而是 byte[] bs = toGBK(“中文”);。因为在 main() 方法中,调用 toGBK(),没有捕获它声明的可能抛出的 UnsupportedEncodingException。
修复方法是在 main() 方法中捕获异常并处理:
1
2
3
4
5
6
7
8
9
10
11
public class Main {
public static void main(String[] args) throws Exception {
byte[] bs = toGBK("中文");
System.out.println(Arrays.toString(bs));
}

static byte[] toGBK(String s) throws UnsupportedEncodingException {
// 用指定编码转换 String 为 byte[]:
return s.getBytes("GBK");
}
}

因为 main() 方法声明了可能抛出 Exception,也就声明了可能抛出所有的 Exception,因此在内部就无需捕获了。代价就是一旦发生异常,程序会立刻退出。
还有一些童鞋喜欢在 toGBK() 内部 “消化” 异常:
1
2
3
4
5
6
7
static byte[] toGBK(String s) {
try {
return s.getBytes("GBK");
} catch (UnsupportedEncodingException e) {
// 什么也不干
}
return null;

这种捕获后不处理的方式是非常不好的,即使真的什么也做不了,也要先把异常记录下来:
1
2
3
4
5
6
7
8
static byte[] toGBK(String s) {
try {
return s.getBytes("GBK");
} catch (UnsupportedEncodingException e) {
// 先记下来再说:
e.printStackTrace();
}
return null;

所有异常都可以调用 printStackTrace() 方法打印异常栈,这是一个简单有用的快速打印异常的方法。

常见 RuntimeException 异常

  • NullPointerException:见的最多了,其实很简单,一般都是在 null 对象上调用方法了。
  • NumberFormatException:继承 IllegalArgumentException,字符串转换为数字时出现。比如 int i= Integer.parseInt(“ab3”);
  • ArrayIndexOutOfBoundsException: 数组越界。比如 int[] a=new int[3]; int b=a[3];
  • StringIndexOutOfBoundsException:字符串越界。比如 String s=”hello”; char c=s.chatAt(6);
  • ClassCastException: 类型转换错误。比如 Object obj=new Object(); String s=(String)obj;
  • UnsupportedOperationException: 该操作不被支持。如果我们希望不支持这个方法,可以抛出这个异常。既然不支持还要这个干吗?有可能子类中不想支持父类中有的方法,可以直接抛出这个异常。
  • ArithmeticException:算术错误,典型的就是 0 作为除数的时候。
  • IllegalArgumentException:非法参数,在把字符串转换成数字的时候经常出现的一个异常,我们可以在自己的程序中好好利用这个异常

小结

  • Java 使用异常来表示错误,并通过 try … catch 捕获异常;
  • Java 的异常是 class,并且从 Throwable 继承;
  • Error 是无需捕获的严重错误,Exception 是应该捕获的可处理的错误;
  • RuntimeException 无需强制捕获,非 RuntimeException(Checked Exception)需强制捕获,或者用 throws 声明;
  • 不推荐捕获了异常但不进行任何处理。

捕获异常

在 Java 中,凡是可能抛出异常的语句,都可以用 try … catch 捕获。把可能发生异常的语句放在 try {…} 中,然后使用 catch 捕获对应的 Exception 及其子类。

多 catch 语句

可以使用多个 catch 语句,每个 catch 分别捕获对应的 Exception 及其子类。JVM 在捕获到异常后,会从上到下匹配 catch 语句,匹配到某个 catch 后,执行 catch 代码块,然后不再继续匹配。
简单地说就是:多个 catch 语句只有一个能被执行。例如:

1
2
3
4
5
6
7
8
9
10
11
public static void main(String[] args) {
try {
process1();
process2();
process3();
} catch (IOException e) {
System.out.println(e);
} catch (NumberFormatException e) {
System.out.println(e);
}
}

存在多个 catch 的时候,catch 的顺序非常重要:子类必须写在前面。例如:
1
2
3
4
5
6
7
8
9
10
11
public static void main(String[] args) {
try {
process1();
process2();
process3();
} catch (IOException e) {
System.out.println("IO error");
} catch (UnsupportedEncodingException e) { // 永远捕获不到
System.out.println("Bad encoding");
}
}

对于上面的代码,UnsupportedEncodingException 异常是永远捕获不到的,因为它是 IOException 的子类。当抛出 UnsupportedEncodingException 异常时,会被 catch (IOException e) { … } 捕获并执行。
因此,正确的写法是把子类放到前面:
1
2
3
4
5
6
7
8
9
10
11
public static void main(String[] args) {
try {
process1();
process2();
process3();
} catch (UnsupportedEncodingException e) {
System.out.println("Bad encoding");
} catch (IOException e) {
System.out.println("IO error");
}
}

finally 语句

无论是否有异常发生,如果我们都希望执行一些语句,例如清理工作,怎么写?

可以把执行语句写若干遍:正常执行的放到 try 中,每个 catch 再写一遍。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public static void main(String[] args) {
try {
process1();
process2();
process3();
System.out.println("END");
} catch (UnsupportedEncodingException e) {
System.out.println("Bad encoding");
System.out.println("END");
} catch (IOException e) {
System.out.println("IO error");
System.out.println("END");
}
}

上述代码无论是否发生异常,都会执行 System.out.println(“END”); 这条语句。
Java 的 try … catch 机制还提供了 finally 语句,finally 语句块保证有无错误都会执行。上述代码可以改写如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
public static void main(String[] args) {
try {
process1();
process2();
process3();
} catch (UnsupportedEncodingException e) {
System.out.println("Bad encoding");
} catch (IOException e) {
System.out.println("IO error");
} finally {
System.out.println("END");
}
}

注意 finally 有几个特点:

  • finally 语句不是必须的,可写可不写;
  • finally 总是最后执行。

如果没有发生异常,就正常执行 try {…} 语句块,然后执行 finally。如果发生了异常,就中断执行 try { … } 语句块,然后跳转执行匹配的 catch 语句块,最后执行 finally。
可见,finally 是用来保证一些代码必须执行的。
某些情况下,可以没有 catch,只使用 try … finally 结构。例如:

1
2
3
4
5
6
7
void process(String file) throws IOException {
try {
...
} finally {
System.out.println("END");
}
}

因为方法声明了可能抛出的异常,所以可以不写 catch。

写上 try-finally 即便抛出异常,也会执行 finally 块的语句,而 try 后面的语句则不会执行了
有 try-catch 和 方法抛出异常的区别:try-catch 捕获异常后会继续执行后面的语句,相当于是个桥梁路还是通着的,如果是方法抛出,那么则不会执行后面的语句了,相当于桥断了。

捕获多种异常

1
2
3
4
5
6
7
8
9
10
11
12
13
public static void main(String[] args) {
try {
process1();
process2();
process3();
} catch (IOException e) {
System.out.println("Bad input");
} catch (NumberFormatException e) {
System.out.println("Bad input");
} catch (Exception e) {
System.out.println("Unknown error");
}
}

因为处理 IOException 和 NumberFormatException 的代码是相同的,所以我们可以把它两用 | 合并到一起:

1
2
3
4
5
6
7
8
9
10
11
public static void main(String[] args) {
try {
process1();
process2();
process3();
} catch (IOException | NumberFormatException e) { // IOException 或 NumberFormatException
System.out.println("Bad input");
} catch (Exception e) {
System.out.println("Unknown error");
}
}

小结

  • 使用 try … catch … finally 时:
  • 多个 catch 语句的匹配顺序非常重要,子类必须放在前面;
  • finally 语句保证了有无异常都会执行,它是可选的;
  • 一个 catch 语句也可以匹配多个非继承关系的异常。

抛出异常

异常的传播

当某个方法抛出了异常时,如果当前方法没有捕获异常,异常就会被抛到上层调用方法,直到遇到某个 try … catch 被捕获为止:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Main {
public static void main(String[] args) {
try {
process1();
} catch (Exception e) {
e.printStackTrace();
}
}

static void process1() {
process2();
}

static void process2() {
Integer.parseInt(null); // 会抛出 NumberFormatException
}
}

通过 printStackTrace() 可以打印出方法的调用栈,类似:
1
2
3
4
5
6
java.lang.NumberFormatException: null
at java.lang.Integer.parseInt(Integer.java:542)
at java.lang.Integer.parseInt(Integer.java:615)
at class3.module3.Test1.process2(Test1.java:23)
at class3.module3.Test1.process1(Test1.java:19)
at class3.module3.Test1.main(Test1.java:12)

printStackTrace() 对于调试错误非常有用,上述信息表示:NumberFormatException 是在 java.lang.Integer.parseInt 方法中被抛出的,从下往上看,调用层次依次是:

  1. main() 调用 process1();
  2. process1() 调用 process2();
  3. process2() 调用 Integer.parseInt(String);
  4. Integer.parseInt(String) 调用 Integer.parseInt(String, int)。

查看 Integer.java 源码可知,抛出异常的方法代码如下:

1
2
3
4
5
6
public static int parseInt(String s, int radix) throws NumberFormatException {
if (s == null) {
throw new NumberFormatException("null");
}
...
}

并且,每层调用均给出了源代码的行号,可直接定位。

抛出异常

当发生错误时,例如,用户输入了非法的字符,我们就可以抛出异常。
如何抛出异常?参考 Integer.parseInt() 方法,抛出异常分两步:

  1. 创建某个 Exception 的实例;
  2. 用 throw 语句抛出。

下面是一个例子:

1
2
3
4
5
6
void process2(String s) {
if (s==null) {
NullPointerException e = new NullPointerException();
throw e;
}
}

实际上,绝大部分抛出异常的代码都会合并写成一行:
1
2
3
4
5
void process2(String s) {
if (s==null) {
throw new NullPointerException();
}
}

如果一个方法捕获了某个异常后,又在 catch 子句中抛出新的异常,就相当于把抛出的异常类型 “转换” 了:
1
2
3
4
5
6
7
8
9
10
11
12
13
void process1(String s) {
try {
process2();
} catch (NullPointerException e) {
throw new IllegalArgumentException();
}
}

void process2(String s) {
if (s==null) {
throw new NullPointerException();
}
}

当 process2() 抛出 NullPointerException 后,被 process1() 捕获,然后抛出 IllegalArgumentException()。
如果在 main() 中捕获 IllegalArgumentException,我们看看打印的异常栈:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class Main {
public static void main(String[] args) {
try {
process1();
} catch (Exception e) {
e.printStackTrace();
}
}

static void process1() {
try {
process2();
} catch (NullPointerException e) {
throw new IllegalArgumentException();
}
}

static void process2() {
throw new NullPointerException();
}
}

打印出的异常栈类似:
1
2
3
java.lang.IllegalArgumentException
at Main.process1(Main.java:15)
at Main.main(Main.java:5)

这说明新的异常丢失了原始异常信息,我们已经看不到原始异常 NullPointerException 的信息了。
为了能追踪到完整的异常栈,在构造异常的时候,把原始的 Exception 实例传进去,新的 Exception 就可以持有原始 Exception 信息。对上述代码改进如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class Main {
public static void main(String[] args) {
try {
process1();
} catch (Exception e) {
e.printStackTrace();
}
}

static void process1() {
try {
process2();
} catch (NullPointerException e) {
throw new IllegalArgumentException(e);
}
}

static void process2() {
throw new NullPointerException();
}
}

运行上述代码,打印出的异常栈类似:
1
2
3
4
5
6
java.lang.IllegalArgumentException: java.lang.NullPointerException
at Main.process1(Main.java:15)
at Main.main(Main.java:5)
Caused by: java.lang.NullPointerException
at Main.process2(Main.java:20)
at Main.process1(Main.java:13)

注意到 Caused by: Xxx,说明捕获的 IllegalArgumentException 并不是造成问题的根源,根源在于 NullPointerException,是在 Main.process2() 方法抛出的。
在代码中获取原始异常可以使用 Throwable.getCause() 方法。如果返回 null,说明已经是 “根异常” 了。
有了完整的异常栈的信息,我们才能快速定位并修复代码的问题。

捕获到异常并再次抛出时,一定要留住原始异常,否则很难定位第一案发现场!
如果我们在 try 或者 catch 语句块中抛出异常,finally 语句是否会执行?例如:

1
2
3
4
5
6
7
8
9
10
11
12
public class Main {
public static void main(String[] args) {
try {
Integer.parseInt("abc");
} catch (Exception e) {
System.out.println("catched");
throw new RuntimeException(e);
} finally {
System.out.println("finally");
}
}
}

上述代码执行结果如下:
1
2
3
4
5
6
catched
finally
Exception in thread "main" java.lang.RuntimeException: java.lang.NumberFormatException: For input string: "abc"
at Main.main(Main.java:8)
Caused by: java.lang.NumberFormatException: For input string: "abc"
at ...

第一行打印了 catched,说明进入了 catch 语句块。第二行打印了 finally,说明执行了 finally 语句块。
因此,在 catch 中抛出异常,不会影响 finally 的执行。JVM 会先执行 finally,然后抛出异常。

异常屏蔽

如果在执行 finally 语句时抛出异常,那么,catch 语句的异常还能否继续抛出?例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Main {
public static void main(String[] args) {
try {
Integer.parseInt("abc");
} catch (Exception e) {
System.out.println("catched");
throw new RuntimeException(e);
} finally {
System.out.println("finally");
throw new IllegalArgumentException();
}
}
}

执行上述代码,发现异常信息如下:
1
2
3
4
catched
finally
Exception in thread "main" java.lang.IllegalArgumentException
at Main.main(Main.java:11)

这说明 finally 抛出异常后,原来在 catch 中准备抛出的异常就 “消失” 了,因为只能抛出一个异常。没有被抛出的异常称为 “被屏蔽” 的异常(Suppressed Exception)。
在极少数的情况下,我们需要获知所有的异常。如何保存所有的异常信息?方法是先用 origin 变量保存原始异常,然后调用 Throwable.addSuppressed(),把原始异常添加进来,最后在 finally 抛出:
1
2
3
4
catched
finally
Exception in thread "main" java.lang.IllegalArgumentException
at Main.main(Main.java:11)

这说明 finally 抛出异常后,原来在 catch 中准备抛出的异常就 “消失” 了,因为只能抛出一个异常。没有被抛出的异常称为 “被屏蔽” 的异常(Suppressed Exception)。
在极少数的情况下,我们需要获知所有的异常。如何保存所有的异常信息?方法是先用 origin 变量保存原始异常,然后调用 Throwable.addSuppressed(),把原始异常添加进来,最后在 finally 抛出:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Main {
public static void main(String[] args) throws Exception {
Exception origin = null;
try {
System.out.println(Integer.parseInt("abc"));
} catch (Exception e) {
origin = e;
throw e;
} finally {
Exception e = new IllegalArgumentException();
if (origin != null) {
e.addSuppressed(origin);
}
throw e;
}
}
}

当 catch 和 finally 都抛出了异常时,虽然 catch 的异常被屏蔽了,但是,finally 抛出的异常仍然包含了它:
1
2
3
4
5
6
7
Exception in thread "main" java.lang.IllegalArgumentException
at Main.main(Main.java:11)
Suppressed: java.lang.NumberFormatException: For input string: "abc"
at java.base/java.lang.NumberFormatException.forInputString(NumberFormatException.java:65)
at java.base/java.lang.Integer.parseInt(Integer.java:652)
at java.base/java.lang.Integer.parseInt(Integer.java:770)
at Main.main(Main.java:6)

通过 Throwable.getSuppressed() 可以获取所有的 Suppressed Exception。
绝大多数情况下,在 finally 中不要抛出异常。因此,我们通常不需要关心 Suppressed Exception。

小结

  • 调用 printStackTrace() 可以打印异常的传播栈,对于调试非常有用;
  • 捕获异常并再次抛出新的异常时,应该持有原始异常信息;
  • 通常不要在 finally 中抛出异常。如果在 finally 中抛出异常,应该原始异常加入到原有异常中。调用方可通过 Throwable.getSuppressed() 获取所有添加的 Suppressed Exception。
  • 在 java 的异常类体系中, Error 和 RuntimeException 是非检查型异常,其他的都是检查型异常。
  • 所有方法都可以在不声明 throws 的情况下抛出 RuntimeException 及其子类;不可以在不声明的情况下抛出非 RuntimeException

自定义异常

Java 标准库定义的常用异常包括:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
Exception

├─ RuntimeException
│ │
│ ├─ NullPointerException
│ │
│ ├─ IndexOutOfBoundsException
│ │
│ ├─ SecurityException
│ │
│ └─ IllegalArgumentException
│ │
│ └─ NumberFormatException

├─ IOException
│ │
│ ├─ UnsupportedCharsetException
│ │
│ ├─ FileNotFoundException
│ │
│ └─ SocketException

├─ ParseException

├─ GeneralSecurityException

├─ SQLException

└─ TimeoutException

当我们在代码中需要抛出异常时,尽量使用 JDK 已定义的异常类型。例如,参数检查不合法,应该抛出 IllegalArgumentException:
1
2
3
4
5
static void process1(int age) {
if (age <= 0) {
throw new IllegalArgumentException();
}
}

在一个大型项目中,可以自定义新的异常类型,但是,保持一个合理的异常继承体系是非常重要的。
一个常见的做法是自定义一个 BaseException 作为 “根异常”,然后,派生出各种业务类型的异常。
BaseException 需要从一个适合的 Exception 派生,通常建议从 RuntimeException 派生:
1
2
3
public class BaseException extends RuntimeException {

}

其他业务类型的异常就可以从 BaseException 派生:
1
2
3
4
5
6
7
public class UserNotFoundException extends BaseException {
}

public class LoginFailedException extends BaseException {
}

...

自定义的 BaseException 应该提供多个构造方法:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class BaseException extends RuntimeException {
public BaseException() {
super();
}

public BaseException(String message, Throwable cause) {
super(message, cause);
}

public BaseException(String message) {
super(message);
}

public BaseException(Throwable cause) {
super(cause);
}
}

上述构造方法实际上都是原样照抄 RuntimeException。这样,抛出异常的时候,就可以选择合适的构造方法。通过 IDE 可以根据父类快速生成子类的构造方法。

小结

  • 抛出异常时,尽量复用 JDK 已定义的异常类型;
  • 自定义异常体系时,推荐从 RuntimeException 派生 “根异常”,再派生出业务异常;
  • 自定义异常时,应该提供多种构造方法。

NullPointerException

NullPointerException 即空指针异常,俗称 NPE。如果一个对象为 null,调用其方法或访问其字段就会产生 NullPointerException,这个异常通常是由 JVM 抛出的,例如:

1
2
3
4
5
public class Main {public static void main(String[] args) {
String s = null;
System.out.println(s.toLowerCase());
}
}

指针这个概念实际上源自 C 语言,Java 语言中并无指针。我们定义的变量实际上是引用,Null Pointer 更确切地说是 Null Reference,不过两者区别不大。

处理 NullPointerException

如果遇到 NullPointerException,我们应该如何处理?首先,必须明确,NullPointerException 是一种代码逻辑错误,遇到 NullPointerException,遵循原则是早暴露,早修复,严禁使用 catch 来隐藏这种编码错误:

1
2
3
4
5
6
// 错误示例: 捕获 NullPointerException
try {
transferMoney(from, to, amount);
} catch (NullPointerException e) {

}

好的编码习惯可以极大地降低 NullPointerException 的产生,例如:
成员变量在定义时初始化:
1
2
3
public class Person {
private String name = "";
}

使用空字符串 "" 而不是默认的 null 可避免很多 NullPointerException,编写业务逻辑时,用空字符串 "" 表示未填写比 null 安全得多。

返回空字符串 ""、空数组而不是 null

1
2
3
4
5
6
7
public String[] readLinesFromFile(String file) {
if (getFileSize(file) == 0) {
// 返回空数组而不是null:
return new String[0];
}
...
}

这样可以使得调用方无需检查结果是否为 null。

如果调用方一定要根据 null 判断,比如返回 null 表示文件不存在,那么考虑返回 Optional<T>

1
2
3
4
5
6
public Optional<String> readFromFile(String file) {
if (!fileExist(file)) {
return Optional.empty();
}
...
}

这样调用方必须通过Optional.isPresent()判断是否有结果。

定位NullPointerException

如果产生了NullPointerException,例如,调用a.b.c.x()时产生了NullPointerException,原因可能是:

  • a是null;
  • a.b是null;
  • a.b.c是null;

确定到底是哪个对象是null以前只能打印这样的日志:
System.out.println(a);
System.out.println(a.b);
System.out.println(a.b.c);
可以在 NullPointerException 的详细信息中看到类似 ... because "<local1>.address.city" is null,意思是 city 字段为 null,这样我们就能快速定位问题所在。
这种增强的 NullPointerException 详细信息是 Java 14 新增的功能,但默认是关闭的,我们可以给 JVM 添加一个 - XX:+ShowCodeDetailsInExceptionMessages 参数启用它:

1
java -XX:+ShowCodeDetailsInExceptionMessages Main.java

小结

  • NullPointerException是Java代码常见的逻辑错误,应当早暴露,早修复;
  • 可以启用Java 14的增强异常信息来查看NullPointerException的详细错误信息。

断言

断言(Assertion)是一种调试程序的方式。在 Java 中,使用 assert 关键字来实现断言。
我们先看一个例子:

1
2
3
4
5
public static void main(String[] args) {
double x = Math.abs(-123.45);
assert x >= 0;
System.out.println(x);
}

语句 assert x >= 0; 即为断言,断言条件 x >= 0 预期为 true。如果计算结果为 false,则断言失败,抛出 AssertionError。
使用 assert 语句时,还可以添加一个可选的断言消息:
1
assert x >= 0 : "x must >= 0";

这样,断言失败的时候,AssertionError 会带上消息 x must >= 0,更加便于调试。
Java 断言的特点是:断言失败时会抛出 AssertionError,导致程序结束退出。因此,断言不能用于可恢复的程序错误,只应该用于开发和测试阶段。
对于可恢复的程序错误,不应该使用断言。例如:
1
2
3
void sort(int[] arr) {
assert arr != null;
}

应该抛出异常并在上层捕获:
1
2
3
4
5
void sort(int[] arr) {
if (x == null) {
throw new IllegalArgumentException("array cannot be null");
}
}

当我们在程序中使用 assert 时,例如,一个简单的断言:
1
2
3
4
5
6
7
public class Main {
public static void main(String[] args) {
int x = -1;
assert x > 0;
System.out.println(x);
}
}

断言 x 必须大于 0,实际上 x 为 - 1,断言肯定失败。执行上述代码,发现程序并未抛出 AssertionError,而是正常打印了 x 的值。
这是怎么肥四?为什么 assert 语句不起作用?
这是因为 JVM 默认关闭断言指令,即遇到 assert 语句就自动忽略了,不执行。
要执行 assert 语句,必须给 Java 虚拟机传递 -enableassertions(可简写为 -ea)参数启用断言。所以,上述程序必须在命令行下运行才有效果:
1
2
3
$ java -ea Main.java
Exception in thread "main" java.lang.AssertionError
at Main.main(Main.java:5)

还可以有选择地对特定地类启用断言,命令行参数是:-ea:com.itranswarp.sample.Main,表示只对 com.itranswarp.sample.Main 这个类启用断言。

或者对特定地包启用断言,命令行参数是:-ea:com.itranswarp.sample...(注意结尾有 3 个.),表示对 com.itranswarp.sample 这个包启动断言。

实际开发中,很少使用断言。更好的方法是编写单元测试,后续我们会讲解 JUnit 的使用。

小结

  • 断言是一种调试方式,断言失败会抛出AssertionError,只能在开发和测试阶段启用断言;
  • 对可恢复的错误不能使用断言,而应该抛出异常;
  • 断言很少被使用,更好的方法是编写单元测试。

JDK Logging

在编写程序的过程中,发现程序运行结果与预期不符,怎么办?当然是用 System.out.println() 打印出执行过程中的某些变量,观察每一步的结果与代码逻辑是否符合,然后有针对性地修改代码。
代码改好了怎么办?当然是删除没有用的 System.out.println() 语句了。
如果改代码又改出问题怎么办?再加上 System.out.println()
反复这么搞几次,很快大家就发现使用 System.out.println() 非常麻烦。
怎么办?

解决方法是使用日志。
那什么是日志?日志就是 Logging,它的目的是为了取代 System.out.println()
输出日志,而不是用 System.out.println(),有以下几个好处:

  • 可以设置输出样式,避免自己每次都写”ERROR: “ + var;
  • 可以设置输出级别,禁止某些级别输出。例如,只输出错误日志;
  • 可以被重定向到文件,这样可以在程序运行结束后查看日志;
  • 可以按包名控制日志级别,只输出某些包打的日志;
  • 可以……

使用日志

因为Java标准库内置了日志包java.util.logging,我们可以直接用。先看一个简单的例子:

1
2
3
4
5
6
7
8
9
10
11
12
import java.util.logging.Level;
import java.util.logging.Logger;

public class Hello {
public static void main(String[] args) {
Logger logger = Logger.getGlobal();
logger.info("start process...");
logger.warning("memory is running out...");
logger.fine("ignored.");
logger.severe("process will be terminated...");
}
}

输出
1
2
3
4
5
6
Jan 15, 2021 5:57:27 AM Hello main
INFO: start process...
Jan 15, 2021 5:57:27 AM Hello main
WARNING: memory is running out...
Jan 15, 2021 5:57:27 AM Hello main
SEVERE: process will be terminated...

对比可见,使用日志最大的好处是,它自动打印了时间、调用类、调用方法等很多有用的信息。
再仔细观察发现,4 条日志,只打印了 3 条,logger.fine() 没有打印。这是因为,日志的输出可以设定级别。JDK 的 Logging 定义了 7 个日志级别,从严重到普通:

  • SEVERE
  • WARNING
  • INFO
  • CONFIG
  • FINE
  • FINER
  • FINEST

因为默认级别是 INFO,因此,INFO 级别以下的日志,不会被打印出来。使用日志级别的好处在于,调整级别,就可以屏蔽掉很多调试相关的日志输出。
使用 Java 标准库内置的 Logging 有以下局限:
Logging 系统在 JVM 启动时读取配置文件并完成初始化,一旦开始运行 main() 方法,就无法修改配置;
配置不太方便,需要在 JVM 启动时传递参数 -Djava.util.logging.config.file=<config-file-name>
因此,Java 标准库内置的 Logging 使用并不是非常广泛。更方便的日志系统我们稍后介绍。

小结

  • 日志是为了替代System.out.println(),可以定义格式,重定向到文件等;
  • 日志可以存档,便于追踪问题;
  • 日志记录可以按级别分类,便于打开或关闭某些级别;
  • 可以根据配置文件调整日志,无需修改代码;
  • Java标准库提供了java.util.logging来实现日志功能。

Commons Logging

和Java标准库提供的日志不同,Commons Logging是一个第三方日志库,它是由Apache创建的日志模块。
Commons Logging的特色是,它可以挂接不同的日志系统,并通过配置文件指定挂接的日志系统。默认情况下,Commons Loggin自动搜索并使用Log4j(Log4j是另一个流行的日志系统),如果没有找到Log4j,再使用JDK Logging。
使用Commons Logging只需要和两个类打交道,并且只有两步:
第一步,通过LogFactory获取Log类的实例; 第二步,使用Log实例的方法打日志。
示例代码如下:

1
2
3
4
5
6
7
8
9
10
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;

public class Main {
public static void main(String[] args) {
Log log = LogFactory.getLog(Main.class);
log.info("start...");
log.warn("end.");
}
}

运行上述代码,肯定会得到编译错误,类似error: package org.apache.commons.logging does not exist(找不到org.apache.commons.logging这个包)。因为Commons Logging是一个第三方提供的库,所以,必须先把它下载下来。下载后,解压,找到commons-logging-1.2.jar这个文件,再把Java源码Main.java放到一个目录下,例如work目录:
1
2
3
4
5
work

├─ commons-logging-1.2.jar

└─ Main.java

然后用javac编译Main.java,编译的时候要指定classpath,不然编译器找不到我们引用的org.apache.commons.logging包。编译命令如下:
1
javac -cp commons-logging-1.2.jar Main.java

如果编译成功,那么当前目录下就会多出一个Main.class文件:
1
2
3
4
5
6
7
work

├─ commons-logging-1.2.jar

├─ Main.java

└─ Main.class

现在可以执行这个Main.class,使用java命令,也必须指定classpath,命令如下:
1
java -cp .;commons-logging-1.2.jar Main

注意到传入的classpath有两部分:一个是.,一个是commons-logging-1.2.jar,用;分割。.表示当前目录,如果没有这个.,JVM不会在当前目录搜索Main.class,就会报错。

如果在Linux或macOS下运行,注意classpath的分隔符不是;,而是:
运行结果如下:

1
2
3
4
Mar 02, 2019 7:15:31 PM Main main
INFO: start...
Mar 02, 2019 7:15:31 PM Main main
WARNING: end.

Commons Logging定义了6个日志级别:

  • FATAL
  • ERROR
  • WARNING
  • INFO
  • DEBUG
  • TRACE
    默认级别是INFO。
    使用Commons Logging时,如果在静态方法中引用Log,通常直接定义一个静态类型变量:
    1
    2
    3
    4
    5
    6
    7
    8
    // 在静态方法中引用Log:
    public class Main {
    static final Log log = LogFactory.getLog(Main.class);

    static void foo() {
    log.info("foo");
    }
    }
    在实例方法中引用Log,通常定义一个实例变量:
    1
    2
    3
    4
    5
    6
    7
    8
    // 在实例方法中引用Log:
    public class Person {
    protected final Log log = LogFactory.getLog(getClass());

    void foo() {
    log.info("foo");
    }
    }
    注意到实例变量 log 的获取方式是 LogFactory.getLog(getClass()),虽然也可以用 LogFactory.getLog(Person.class),但是前一种方式有个非常大的好处,就是子类可以直接使用该 log 实例。例如:
    1
    2
    3
    4
    5
    6
    // 在子类中使用父类实例化的log:
    public class Student extends Person {
    void bar() {
    log.info("bar");
    }
    }
    由于 Java 类的动态特性,子类获取的 log 字段实际上相当于 LogFactory.getLog(Student.class),但却是从父类继承而来,并且无需改动代码。
    此外,Commons Logging的日志方法,例如info(),除了标准的info(String)外,还提供了一个非常有用的重载方法:info(String, Throwable),这使得记录异常更加简单:
    1
    2
    3
    4
    5
    try {
    ...
    } catch (Exception e) {
    log.error("got exception!", e);
    }

小结

  • Commons Logging是使用最广泛的日志模块;
  • Commons Logging的API非常简单;
  • Commons Logging可以自动检测并使用其他日志模块。

Log4j

前面介绍了Commons Logging,可以作为“日志接口”来使用。而真正的“日志实现”可以使用Log4j。
Log4j是一种非常流行的日志框架,最新版本是2.x。
Log4j是一个组件化设计的日志系统,它的架构大致如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
log.info("User signed in.");

│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
├──>│ Appender │───>│ Filter │───>│ Layout │───>│ Console │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘

│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
├──>│ Appender │───>│ Filter │───>│ Layout │───>│ File │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘

│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
└──>│ Appender │───>│ Filter │───>│ Layout │───>│ Socket │
└──────────┘ └──────────┘ └──────────┘ └──────────┘

当我们使用Log4j输出一条日志时,Log4j自动通过不同的Appender把同一条日志输出到不同的目的地。例如:

  • console:输出到屏幕;
  • file:输出到文件;
  • socket:通过网络输出到远程计算机;
  • jdbc:输出到数据库

在输出日志的过程中,通过Filter来过滤哪些log需要被输出,哪些log不需要被输出。例如,仅输出ERROR级别的日志。
最后,通过Layout来格式化日志信息,例如,自动添加日期、时间、方法名称等信息。
上述结构虽然复杂,但我们在实际使用的时候,并不需要关心Log4j的API,而是通过配置文件来配置它。
以XML配置为例,使用Log4j的时候,我们把一个log4j2.xml的文件放到classpath下就可以让Log4j读取配置文件并按照我们的配置来输出日志。下面是一个配置文件的例子:

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
<?xml version="1.0" encoding="UTF-8"?>
<Configuration>
<Properties>
<!-- 定义日志格式 -->
<Property name="log.pattern">%d{MM-dd HH:mm:ss.SSS} [%t] %-5level %logger{36}%n%msg%n%n</Property>
<!-- 定义文件名变量 -->
<Property name="file.err.filename">log/err.log</Property>
<Property name="file.err.pattern">log/err.%i.log.gz</Property>
</Properties>
<!-- 定义Appender,即目的地 -->
<Appenders>
<!-- 定义输出到屏幕 -->
<Console name="console" target="SYSTEM_OUT">
<!-- 日志格式引用上面定义的log.pattern -->
<PatternLayout pattern="${log.pattern}" />
</Console>
<!-- 定义输出到文件,文件名引用上面定义的file.err.filename -->
<RollingFile name="err" bufferedIO="true" fileName="${file.err.filename}" filePattern="${file.err.pattern}">
<PatternLayout pattern="${log.pattern}" />
<Policies>
<!-- 根据文件大小自动切割日志 -->
<SizeBasedTriggeringPolicy size="1 MB" />
</Policies>
<!-- 保留最近10份 -->
<DefaultRolloverStrategy max="10" />
</RollingFile>
</Appenders>
<Loggers>
<Root level="info">
<!-- 对info级别的日志,输出到console -->
<AppenderRef ref="console" level="info" />
<!-- 对error级别的日志,输出到err,即上面定义的RollingFile -->
<AppenderRef ref="err" level="error" />
</Root>
</Loggers>
</Configuration>

虽然配置Log4j比较繁琐,但一旦配置完成,使用起来就非常方便。对上面的配置文件,凡是INFO级别的日志,会自动输出到屏幕,而ERROR级别的日志,不但会输出到屏幕,还会同时输出到文件。并且,一旦日志文件达到指定大小(1MB),Log4j就会自动切割新的日志文件,并最多保留10份。
有了配置文件还不够,因为Log4j也是一个第三方库,我们需要从这里下载Log4j,解压后,把以下3个jar包放到classpath中:

  • log4j-api-2.x.jar
  • log4j-core-2.x.jar
  • log4j-jcl-2.x.jar

因为Commons Logging会自动发现并使用Log4j,所以,把上一节下载的commons-logging-1.2.jar也放到classpath中。
要打印日志,只需要按Commons Logging的写法写,不需要改动任何代码,就可以得到Log4j的日志输出,类似:

1
2
03-03 12:09:45.880 [main] INFO  com.itranswarp.learnjava.Main
Start process...

最佳实践

在开发阶段,始终使用Commons Logging接口来写入日志,并且开发阶段无需引入Log4j。如果需要把日志写入文件, 只需要把正确的配置文件和Log4j相关的jar包放入classpath,就可以自动把日志切换成使用Log4j写入,无需修改任何代码。

小结

通过Commons Logging实现日志,不需要修改代码即可使用Log4j;
使用Log4j只需要把log4j2.xml和相关jar放入classpath;
如果要更换Log4j,只需要移除log4j2.xml和相关jar;
只有扩展Log4j时,才需要引用Log4j的接口(例如,将日志加密写入数据库的功能,需要自己开发)。

0%