网络编程1.网络编程基础在学习Java网络编程之前，我们先来了解什么是计算机网络。计算机网络是指两台或更多的计算机组成的网络，在同一个网络中，任意两台计算机都可以直接通信，因为所有计算机都需要遵循同一种网络协议。那什么是互联网呢？互联网是网络的网络（Internet），即把很多计算机网络连接起来，形成一个全球统一的互联网。对某个特定的计算机网络来说，它可能使用网络协议ABC，而另一个计算机网络可能使用网络协议XYZ。如果计算机网络各自的通讯协议不统一，就没法把不同的网络连接起来形成互联网。因此，为了把计算机网络接入互联网，就必须使用TCP/IP协议。TCP/IP协议泛指互联网协议，其中最重要的两个协议是TCP协议和IP协议。只有使用TCP/IP协议的计算机才能够联入互联网，使用其他网络协议（例如NetBIOS、AppleTalk协议等）是无法联入互联网的。IP地址在互联网中，一个IP地址用于唯一标识一个网络接口（Network Interface）。一台联入互联网的计算机肯定有一个IP地址，但也可能有多个IP地址。IP地址分为IPv4和IPv6两种。IPv4采用32位地址，类似101.202.99.12，而IPv6采用128位地址，类似2001:0DA8:100A:0000:0000:1020:F2F3:1428。IPv4地址总共有232个（大约42亿），而IPv6地址则总共有2128个（大约340万亿亿亿亿），IPv4的地址目前已耗尽，而IPv6的地址是根本用不完的。IP地址又分为公网IP地址和内网IP地址。公网IP地址可以直接被访问，内网IP地址只能在内网访问。内网IP地址类似于：192.168.x.x10.x.x.x有一个特殊的IP地址，称之为本机地址，它总是127.0.0.1。IPv4地址实际上是一个32位整数。例如：1707762444 = 0x65ca630c = 65 ca 63 0c = 101.202.99.12如果一台计算机只有一个网卡，并且接入了网络，那么，它有一个本机地址127.0.0.1，还有一个IP地址，例如101.202.99.12，可以通过这个IP地址接入网络。如果一台计算机有两块网卡，那么除了本机地址，它可以有两个IP地址，可以分别接入两个网络。通常连接两个网络的设备是路由器或者交换机，它至少有两个IP地址，分别接入不同的网络，让网络之间连接起来。如果两台计算机位于同一个网络，那么他们之间可以直接通信，因为他们的IP地址前段是相同的，也就是网络号是相同的。网络号是IP地址通过子网掩码过滤后得到的。例如：某台计算机的IP是101.202.99.2，子网掩码是255.255.255.0，那么计算该计算机的网络号是：IP = 101.202.99.2 Mask = 255.255.255.0 Network = IP & Mask = 101.202.99.0每台计算机都需要正确配置IP地址和子网掩码，根据这两个就可以计算网络号，如果两台计算机计算出的网络号相同，说明两台计算机在同一个网络，可以直接通信。如果两台计算机计算出的网络号不同，那么两台计算机不在同一个网络，不能直接通信，它们之间必须通过路由器或者交换机这样的网络设备间接通信，我们把这种设备称为网关。网关的作用就是连接多个网络，负责把来自一个网络的数据包发到另一个网络，这个过程叫路由。所以，一台计算机的一个网卡会有3个关键配置：IP地址，例如：10.0.2.15子网掩码，例如：255.255.255.0网关的IP地址，例如：10.0.2.2域名因为直接记忆IP地址非常困难，所以我们通常使用域名访问某个特定的服务。域名解析服务器DNS负责把域名翻译成对应的IP，客户端再根据IP地址访问服务器。用nslookup可以查看域名对应的IP地址：$ nslookup liaoxuefeng.com Server: xxx.xxx.xxx.xxx Address: xxx.xxx.xxx.xxx#53 Non-authoritative answer: Name: liaoxuefeng.com Address: xxx.xxx.xxx.xxx有一个特殊的本机域名localhost，它对应的IP地址总是本机地址127.0.0.1。网络模型由于计算机网络从底层的传输到高层的软件设计十分复杂，要合理地设计计算机网络模型，必须采用分层模型，每一层负责处理自己的操作。OSI（Open System Interconnect）网络模型是ISO组织定义的一个计算机互联的标准模型，注意它只是一个定义，目的是为了简化网络各层的操作，提供标准接口便于实现和维护。这个模型从上到下依次是：应用层，提供应用程序之间的通信；表示层：处理数据格式，加解密等等；会话层：负责建立和维护会话；传输层：负责提供端到端的可靠传输；网络层：负责根据目标地址选择路由来传输数据；链路层和物理层负责把数据进行分片并且真正通过物理网络传输，例如，无线网、光纤等。互联网实际使用的TCP/IP模型并不是对应到OSI的7层模型，而是大致对应OSI的5层模型：OSITCP/IP应用层应用层表示层 会话层 传输层传输层网络层IP层链路层网络接口层物理层 常用协议IP协议是一个分组交换，它不保证可靠传输。而TCP协议是传输控制协议，它是面向连接的协议，支持可靠传输和双向通信。TCP协议是建立在IP协议之上的，简单地说，IP协议只负责发数据包，不保证顺序和正确性，而TCP协议负责控制数据包传输，它在传输数据之前需要先建立连接，建立连接后才能传输数据，传输完后还需要断开连接。TCP协议之所以能保证数据的可靠传输，是通过接收确认、超时重传这些机制实现的。并且，TCP协议允许双向通信，即通信双方可以同时发送和接收数据。TCP协议也是应用最广泛的协议，许多高级协议都是建立在TCP协议之上的，例如HTTP、SMTP等。UDP协议（User Datagram Protocol）是一种数据报文协议，它是无连接协议，不保证可靠传输。因为UDP协议在通信前不需要建立连接，因此它的传输效率比TCP高，而且UDP协议比TCP协议要简单得多。选择UDP协议时，传输的数据通常是能容忍丢失的，例如，一些语音视频通信的应用会选择UDP协议。2.TCP编程在开发网络应用程序的时候，我们又会遇到Socket这个概念。Socket是一个抽象概念，一个应用程序通过一个Socket来建立一个远程连接，而Socket内部通过TCP/IP协议把数据传输到网络：┌───────────┐ ┌───────────┐ │Application│ │Application│ ├───────────┤ ├───────────┤ │ Socket │ │ Socket │ ├───────────┤ ├───────────┤ │ TCP │ │ TCP │ ├───────────┤ ┌──────┐ ┌──────┐ ├───────────┤ │ IP │◀───▶│Router│◀────▶│Router│◀───▶│ IP │ └───────────┘ └──────┘ └──────┘ └───────────┘Socket、TCP和部分IP的功能都是由操作系统提供的，不同的编程语言只是提供了对操作系统调用的简单的封装。例如，Java提供的几个Socket相关的类就封装了操作系统提供的接口。为什么需要Socket进行网络通信？因为仅仅通过IP地址进行通信是不够的，同一台计算机同一时间会运行多个网络应用程序，例如浏览器、QQ、邮件客户端等。当操作系统接收到一个数据包的时候，如果只有IP地址，它没法判断应该发给哪个应用程序，所以，操作系统抽象出Socket接口，每个应用程序需要各自对应到不同的Socket，数据包才能根据Socket正确地发到对应的应用程序。一个Socket就是由IP地址和端口号（范围是0～65535）组成，可以把Socket简单理解为IP地址加端口号。端口号总是由操作系统分配，它是一个0～65535之间的数字，其中，小于1024的端口属于特权端口，需要管理员权限，大于1024的端口可以由任意用户的应用程序打开。Browser: 101.202.99.2:1201QQ: 101.202.99.2:1304Email: 101.202.99.2:15000使用Socket进行网络编程时，本质上就是两个进程之间的网络通信。其中一个进程必须充当服务器端，它会主动监听某个指定的端口，另一个进程必须充当客户端，它必须主动连接服务器的IP地址和指定端口，如果连接成功，服务器端和客户端就成功地建立了一个TCP连接，双方后续就可以随时发送和接收数据。因此，当Socket连接成功地在服务器端和客户端之间建立后：对服务器端来说，它的Socket是指定的IP地址和指定的端口号；对客户端来说，它的Socket是它所在计算机的IP地址和一个由操作系统分配的随机端口号。服务器端要使用Socket编程，我们首先要编写服务器端程序。Java标准库提供了ServerSocket来实现对指定IP和指定端口的监听。ServerSocket的典型实现代码如下：public class Server { public static void main(String[] args) throws IOException { ServerSocket ss = new ServerSocket(6666); // 监听指定端口 System.out.println("server is running..."); for (;;) { Socket sock = ss.accept(); System.out.println("connected from " + sock.getRemoteSocketAddress()); Thread t = new Handler(sock); t.start(); } } } class Handler extends Thread { Socket sock; public Handler(Socket sock) { this.sock = sock; } @Override public void run() { try (InputStream input = this.sock.getInputStream()) { try (OutputStream output = this.sock.getOutputStream()) { handle(input, output); } } catch (Exception e) { try { this.sock.close(); } catch (IOException ioe) { } System.out.println("client disconnected."); } } private void handle(InputStream input, OutputStream output) throws IOException { var writer = new BufferedWriter(new OutputStreamWriter(output, StandardCharsets.UTF_8)); var reader = new BufferedReader(new InputStreamReader(input, StandardCharsets.UTF_8)); writer.write("hello\n"); writer.flush(); for (;;) { String s = reader.readLine(); if (s.equals("bye")) { writer.write("bye\n"); writer.flush(); break; } writer.write("ok: " + s + "\n"); writer.flush(); } } }服务器端通过代码：ServerSocket ss = new ServerSocket(6666);在指定端口6666监听。这里我们没有指定IP地址，表示在计算机的所有网络接口上进行监听。如果ServerSocket监听成功，我们就使用一个无限循环来处理客户端的连接：for (;;) { Socket sock = ss.accept(); Thread t = new Handler(sock); t.start(); }注意到代码ss.accept()表示每当有新的客户端连接进来后，就返回一个Socket实例，这个Socket实例就是用来和刚连接的客户端进行通信的。由于客户端很多，要实现并发处理，我们就必须为每个新的Socket创建一个新线程来处理，这样，主线程的作用就是接收新的连接，每当收到新连接后，就创建一个新线程进行处理。我们在多线程编程的章节中介绍过线程池，这里也完全可以利用线程池来处理客户端连接，能大大提高运行效率。如果没有客户端连接进来，accept()方法会阻塞并一直等待。如果有多个客户端同时连接进来，ServerSocket会把连接扔到队列里，然后一个一个处理。对于Java程序而言，只需要通过循环不断调用accept()就可以获取新的连接。客户端相比服务器端，客户端程序就要简单很多。一个典型的客户端程序如下：public class Client { public static void main(String[] args) throws IOException { Socket sock = new Socket("localhost", 6666); // 连接指定服务器和端口 try (InputStream input = sock.getInputStream()) { try (OutputStream output = sock.getOutputStream()) { handle(input, output); } } sock.close(); System.out.println("disconnected."); } private static void handle(InputStream input, OutputStream output) throws IOException { var writer = new BufferedWriter(new OutputStreamWriter(output, StandardCharsets.UTF_8)); var reader = new BufferedReader(new InputStreamReader(input, StandardCharsets.UTF_8)); Scanner scanner = new Scanner(System.in); System.out.println("[server] " + reader.readLine()); for (;;) { System.out.print(">>> "); // 打印提示 String s = scanner.nextLine(); // 读取一行输入 writer.write(s); writer.newLine(); writer.flush(); String resp = reader.readLine(); System.out.println("<<< " + resp); if (resp.equals("bye")) { break; } } } }客户端程序通过：Socket sock = new Socket("localhost", 6666);连接到服务器端，注意上述代码的服务器地址是"localhost"，表示本机地址，端口号是6666。如果连接成功，将返回一个Socket实例，用于后续通信。Socket流当Socket连接创建成功后，无论是服务器端，还是客户端，我们都使用Socket实例进行网络通信。因为TCP是一种基于流的协议，因此，Java标准库使用InputStream和OutputStream来封装Socket的数据流，这样我们使用Socket的流，和普通IO流类似：// 用于读取网络数据: InputStream in = sock.getInputStream(); // 用于写入网络数据: OutputStream out = sock.getOutputStream();最后我们重点来看看，为什么写入网络数据时，要调用flush()方法。如果不调用flush()，我们很可能会发现，客户端和服务器都收不到数据，这并不是Java标准库的设计问题，而是我们以流的形式写入数据的时候，并不是一写入就立刻发送到网络，而是先写入内存缓冲区，直到缓冲区满了以后，才会一次性真正发送到网络，这样设计的目的是为了提高传输效率。如果缓冲区的数据很少，而我们又想强制把这些数据发送到网络，就必须调用flush()强制把缓冲区数据发送出去。3.UDP编程和TCP编程相比，UDP编程就简单得多，因为UDP没有创建连接，数据包也是一次收发一个，所以没有流的概念。在Java中使用UDP编程，仍然需要使用Socket，因为应用程序在使用UDP时必须指定网络接口（IP）和端口号。注意：UDP端口和TCP端口虽然都使用0~65535，但他们是两套独立的端口，即一个应用程序用TCP占用了端口1234，不影响另一个应用程序用UDP占用端口1234。服务器端在服务器端，使用UDP也需要监听指定的端口。Java提供了DatagramSocket来实现这个功能，代码如下：DatagramSocket ds = new DatagramSocket(6666); // 监听指定端口 for (;;) { // 无限循环 // 数据缓冲区: byte[] buffer = new byte[1024]; DatagramPacket packet = new DatagramPacket(buffer, buffer.length); ds.receive(packet); // 收取一个UDP数据包 // 收取到的数据存储在buffer中，由packet.getOffset(), packet.getLength()指定起始位置和长度 // 将其按UTF-8编码转换为String: String s = new String(packet.getData(), packet.getOffset(), packet.getLength(), StandardCharsets.UTF_8); // 发送数据: byte[] data = "ACK".getBytes(StandardCharsets.UTF_8); packet.setData(data); ds.send(packet); }服务器端首先使用如下语句在指定的端口监听UDP数据包：DatagramSocket ds = new DatagramSocket(6666);如果没有其他应用程序占据这个端口，那么监听成功，我们就使用一个无限循环来处理收到的UDP数据包：for (;;) { ... }要接收一个UDP数据包，需要准备一个byte[]缓冲区，并通过DatagramPacket实现接收：byte[] buffer = new byte[1024]; DatagramPacket packet = new DatagramPacket(buffer, buffer.length); ds.receive(packet);假设我们收取到的是一个String，那么，通过DatagramPacket返回的packet.getOffset()和packet.getLength()确定数据在缓冲区的起止位置：String s = new String(packet.getData(), packet.getOffset(), packet.getLength(), StandardCharsets.UTF_8);当服务器收到一个DatagramPacket后，通常必须立刻回复一个或多个UDP包，因为客户端地址在DatagramPacket中，每次收到的DatagramPacket可能是不同的客户端，如果不回复，客户端就收不到任何UDP包。发送UDP包也是通过DatagramPacket实现的，发送代码非常简单：byte[] data = ... packet.setData(data); ds.send(packet);客户端和服务器端相比，客户端使用UDP时，只需要直接向服务器端发送UDP包，然后接收返回的UDP包：DatagramSocket ds = new DatagramSocket(); ds.setSoTimeout(1000); ds.connect(InetAddress.getByName("localhost"), 6666); // 连接指定服务器和端口 // 发送: byte[] data = "Hello".getBytes(); DatagramPacket packet = new DatagramPacket(data, data.length); ds.send(packet); // 接收: byte[] buffer = new byte[1024]; packet = new DatagramPacket(buffer, buffer.length); ds.receive(packet); String resp = new String(packet.getData(), packet.getOffset(), packet.getLength()); ds.disconnect(); // 关闭: ds.close();客户端打开一个DatagramSocket使用以下代码：DatagramSocket ds = new DatagramSocket(); ds.setSoTimeout(1000); ds.connect(InetAddress.getByName("localhost"), 6666);客户端创建DatagramSocket实例时并不需要指定端口，而是由操作系统自动指定一个当前未使用的端口。紧接着，调用setSoTimeout(1000)设定超时1秒，意思是后续接收UDP包时，等待时间最多不会超过1秒，否则在没有收到UDP包时，客户端会无限等待下去。这一点和服务器端不一样，服务器端可以无限等待，因为它本来就被设计成长时间运行。注意到客户端的DatagramSocket还调用了一个connect()方法“连接”到指定的服务器端。不是说UDP是无连接的协议吗？为啥这里需要connect()？这个connect()方法不是真连接，它是为了在客户端的DatagramSocket实例中保存服务器端的IP和端口号，确保这个DatagramSocket实例只能往指定的地址和端口发送UDP包，不能往其他地址和端口发送。这么做不是UDP的限制，而是Java内置了安全检查。后续的收发数据和服务器端是一致的。通常来说，客户端必须先发UDP包，因为客户端不发UDP包，服务器端就根本不知道客户端的地址和端口号。如果客户端认为通信结束，就可以调用disconnect()断开连接：ds.disconnect();注意到disconnect()也不是真正地断开连接，它只是清除了客户端DatagramSocket实例记录的远程服务器地址和端口号，这样，DatagramSocket实例就可以连接另一个服务器端。如果客户端希望向两个不同的服务器发送UDP包，有两种方法：客户端可以创建两个DatagramSocket实例，用connect()连接到不同的服务器；客户端也可以不调用connect()方法，而是在创建DatagramPacket的时候指定服务器地址，这样可以用一个DatagramSocket实例发送DatagramPacket到不同的服务器。不调用connect()方法的代码如下：DatagramSocket ds = new DatagramSocket(); ds.setSoTimeout(1000); // 发送到localhost:6666: byte[] data1 = "Hello".getBytes(); var packet1 = new DatagramPacket(data1, data1.length, InetAddress.getByName("localhost"), 6666); ds.send(packet1); // 发送到localhost:8888: byte[] data2 = "Hi".getBytes(); var packet2 = new DatagramPacket(data2, data2.length, InetAddress.getByName("localhost"), 8888); ds.send(packet2); // 关闭: ds.close();发送Email、接收Email（略）4.HTTP编程什么是HTTP？HTTP就是目前使用最广泛的Web应用程序使用的基础协议，例如，浏览器访问网站，手机App访问后台服务器，都是通过HTTP协议实现的。HTTP是HyperText Transfer Protocol的缩写，翻译为超文本传输协议，它是基于TCP协议之上的一种请求-响应协议。我们来看一下浏览器请求访问某个网站时发送的HTTP请求-响应。当浏览器希望访问某个网站时，浏览器和网站服务器之间首先建立TCP连接，且服务器总是使用80端口和加密端口443，然后，浏览器向服务器发送一个HTTP请求，服务器收到后，返回一个HTTP响应，并且在响应中包含了HTML的网页内容，这样，浏览器解析HTML后就可以给用户显示网页了。一个完整的HTTP请求-响应如下： GET / HTTP/1.1 Host: www.sina.com.cn User-Agent: Mozilla/5 MSIE Accept: */* ┌────────┐ ┌─────────┐ Accept-Language: zh-CN,en │░░░░░░░░│ │O ░░░░░░░│───────────────────────────▶├────────┤ ├─────────┤◀───────────────────────────│░░░░░░░░│ │ │ HTTP/1.1 200 OK ├────────┤ │ │ Content-Type: text/html │░░░░░░░░│ └─────────┘ Content-Length: 133251 └────────┘ Browser Server Hello ...HTTP请求的格式是固定的，它由HTTP Header和HTTP Body两部分构成。第一行总是请求方法 路径 HTTP版本，例如，GET / HTTP/1.1表示使用GET请求，路径是/，版本是HTTP/1.1。后续的每一行都是固定的Header: Value格式，我们称为HTTP Header，服务器依靠某些特定的Header来识别客户端请求，例如：Host：表示请求的域名，因为一台服务器上可能有多个网站，因此有必要依靠Host来识别请求是发给哪个网站的；User-Agent：表示客户端自身标识信息，不同的浏览器有不同的标识，服务器依靠User-Agent判断客户端类型是IE还是Chrome，是Firefox还是一个Python爬虫；Accept：表示客户端能处理的HTTP响应格式，*/*表示任意格式，text/*表示任意文本，image/png表示PNG格式的图片；Accept-Language：表示客户端接收的语言，多种语言按优先级排序，服务器依靠该字段给用户返回特定语言的网页版本。如果是GET请求，那么该HTTP请求只有HTTP Header，没有HTTP Body。如果是POST请求，那么该HTTP请求带有Body，以一个空行分隔。一个典型的带Body的HTTP请求如下：POST /login HTTP/1.1 Host: www.example.com Content-Type: application/x-www-form-urlencoded Content-Length: 30 username=hello&password=123456POST请求通常要设置Content-Type表示Body的类型，Content-Length表示Body的长度，这样服务器就可以根据请求的Header和Body做出正确的响应。此外，GET请求的参数必须附加在URL上，并以URLEncode方式编码，例如：http://www.example.com/?a=1&b=K%26R，参数分别是a=1和b=K&R。因为URL的长度限制，GET请求的参数不能太多，而POST请求的参数就没有长度限制，因为POST请求的参数必须放到Body中。并且，POST请求的参数不一定是URL编码，可以按任意格式编码，只需要在Content-Type中正确设置即可。常见的发送JSON的POST请求如下：POST /login HTTP/1.1 Content-Type: application/json Content-Length: 38 {"username":"bob","password":"123456"}HTTP响应也是由Header和Body两部分组成，一个典型的HTTP响应如下：HTTP/1.1 200 OK Content-Type: text/html Content-Length: 133251 Hello 响应的第一行总是HTTP版本 响应代码 响应说明，例如，HTTP/1.1 200 OK表示版本是HTTP/1.1，响应代码是200，响应说明是OK。客户端只依赖响应代码判断HTTP响应是否成功。HTTP有固定的响应代码：1xx：表示一个提示性响应，例如101表示将切换协议，常见于WebSocket连接；2xx：表示一个成功的响应，例如200表示成功，206表示只发送了部分内容；3xx：表示一个重定向的响应，例如301表示永久重定向，303表示客户端应该按指定路径重新发送请求；4xx：表示一个因为客户端问题导致的错误响应，例如400表示因为Content-Type等各种原因导致的无效请求，404表示指定的路径不存在；5xx：表示一个因为服务器问题导致的错误响应，例如500表示服务器内部故障，503表示服务器暂时无法响应。当浏览器收到第一个HTTP响应后，它解析HTML后，又会发送一系列HTTP请求，例如，GET /logo.jpg HTTP/1.1请求一个图片，服务器响应图片请求后，会直接把二进制内容的图片发送给浏览器：HTTP/1.1 200 OK Content-Type: image/jpeg Content-Length: 18391 ????JFIFHH??XExifMM?i&??X?...(二进制的JPEG图片)因此，服务器总是被动地接收客户端的一个HTTP请求，然后响应它。客户端则根据需要发送若干个HTTP请求。对于最早期的HTTP/1.0协议，每次发送一个HTTP请求，客户端都需要先创建一个新的TCP连接，然后，收到服务器响应后，关闭这个TCP连接。由于建立TCP连接就比较耗时，因此，为了提高效率，HTTP/1.1协议允许在一个TCP连接中反复发送-响应，这样就能大大提高效率： ┌─────────┐ ┌─────────┐ │░░░░░░░░░│ │O ░░░░░░░│ ├─────────┤ ├─────────┤ │░░░░░░░░░│ │ │ ├─────────┤ │ │ │░░░░░░░░░│ └─────────┘ └─────────┘ │ request 1 │ │─────────────────────▶│ │ response 1 │ │◀─────────────────────│ │ request 2 │ │─────────────────────▶│ │ response 2 │ │◀─────────────────────│ │ request 3 │ │─────────────────────▶│ │ response 3 │ │◀─────────────────────│ ▼ ▼因为HTTP协议是一个请求-响应协议，客户端在发送了一个HTTP请求后，必须等待服务器响应后，才能发送下一个请求，这样一来，如果某个响应太慢，它就会堵住后面的请求。所以，为了进一步提速，HTTP/2.0允许客户端在没有收到响应的时候，发送多个HTTP请求，服务器返回响应的时候，不一定按顺序返回，只要双方能识别出哪个响应对应哪个请求，就可以做到并行发送和接收： ┌─────────┐ ┌─────────┐ │░░░░░░░░░│ │O ░░░░░░░│ ├─────────┤ ├─────────┤ │░░░░░░░░░│ │ │ ├─────────┤ │ │ │░░░░░░░░░│ └─────────┘ └─────────┘ │ request 1 │ │─────────────────────▶│ │ request 2 │ │─────────────────────▶│ │ response 1 │ │◀─────────────────────│ │ request 3 │ │─────────────────────▶│ │ response 3 │ │◀─────────────────────│ │ response 2 │ │◀─────────────────────│ ▼ ▼可见，HTTP/2.0进一步提高了效率。HTTP编程既然HTTP涉及到客户端和服务器端，和TCP类似，我们也需要针对客户端编程和针对服务器端编程。本节我们不讨论服务器端的HTTP编程，因为服务器端的HTTP编程本质上就是编写Web服务器，这是一个非常复杂的体系，也是JavaEE开发的核心内容，我们在后面的章节再仔细研究。本节我们只讨论作为客户端的HTTP编程。因为浏览器也是一种HTTP客户端，所以，客户端的HTTP编程，它的行为本质上和浏览器是一样的，即发送一个HTTP请求，接收服务器响应后，获得响应内容。只不过浏览器进一步把响应内容解析后渲染并展示给了用户，而我们使用Java进行HTTP客户端编程仅限于获得响应内容。我们来看一下Java如何使用HTTP客户端编程。Java标准库提供了基于HTTP的包，但是要注意，早期的JDK版本是通过HttpURLConnection访问HTTP，典型代码如下：URL url = new URL("http://www.example.com/path/to/target?a=1&b=2"); HttpURLConnection conn = (HttpURLConnection) url.openConnection(); conn.setRequestMethod("GET"); conn.setUseCaches(false); conn.setConnectTimeout(5000); // 请求超时5秒 // 设置HTTP头: conn.setRequestProperty("Accept", "*/*"); conn.setRequestProperty("User-Agent", "Mozilla/5.0 (compatible; MSIE 11; Windows NT 5.1)"); // 连接并发送HTTP请求: conn.connect(); // 判断HTTP响应是否200: if (conn.getResponseCode() != 200) { throw new RuntimeException("bad response"); } // 获取所有响应Header: Map<String, List<String>> map = conn.getHeaderFields(); for (String key : map.keySet()) { System.out.println(key + ": " + map.get(key)); } // 获取响应内容: InputStream input = conn.getInputStream(); ...上述代码编写比较繁琐，并且需要手动处理InputStream，所以用起来很麻烦。从Java 11开始，引入了新的HttpClient，它使用链式调用的API，能大大简化HTTP的处理。我们来看一下如何使用新版的HttpClient。首先需要创建一个全局HttpClient实例，因为HttpClient内部使用线程池优化多个HTTP连接，可以复用：static HttpClient httpClient = HttpClient.newBuilder().build();使用GET请求获取文本内容代码如下：import java.net.URI; import java.net.http.*; import java.net.http.HttpClient.Version; import java.time.Duration; import java.util.*; public class Main { // 全局HttpClient: static HttpClient httpClient = HttpClient.newBuilder().build(); public static void main(String[] args) throws Exception { String url = "https://www.sina.com.cn/"; HttpRequest request = HttpRequest.newBuilder(new URI(url)) // 设置Header: .header("User-Agent", "Java HttpClient").header("Accept", "*/*") // 设置超时: .timeout(Duration.ofSeconds(5)) // 设置版本: .version(Version.HTTP_2).build(); HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); // HTTP允许重复的Header，因此一个Header可对应多个Value: Map<String, List<String>> headers = response.headers().map(); for (String header : headers.keySet()) { System.out.println(header + ": " + headers.get(header).get(0)); } System.out.println(response.body().substring(0, 1024) + "..."); } }如果我们要获取图片这样的二进制内容，只需要把HttpResponse.BodyHandlers.ofString()换成HttpResponse.BodyHandlers.ofByteArray()，就可以获得一个HttpResponse<byte[]>对象。如果响应的内容很大，不希望一次性全部加载到内存，可以使用HttpResponse.BodyHandlers.ofInputStream()获取一个InputStream流。要使用POST请求，我们要准备好发送的Body数据并正确设置Content-Type：String url = "http://www.example.com/login"; String body = "username=bob&password=123456"; HttpRequest request = HttpRequest.newBuilder(new URI(url)) // 设置Header: .header("Accept", "*/*") .header("Content-Type", "application/x-www-form-urlencoded") // 设置超时: .timeout(Duration.ofSeconds(5)) // 设置版本: .version(Version.HTTP_2) // 使用POST并设置Body: .POST(BodyPublishers.ofString(body, StandardCharsets.UTF_8)).build(); HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); String s = response.body();可见发送POST数据也十分简单。5.RMI远程调用Java的RMI远程调用是指，一个JVM中的代码可以通过网络实现远程调用另一个JVM的某个方法。RMI是Remote Method Invocation的缩写。提供服务的一方我们称之为服务器，而实现远程调用的一方我们称之为客户端。我们先来实现一个最简单的RMI：服务器会提供一个WorldClock服务，允许客户端获取指定时区的时间，即允许客户端调用下面的方法：LocalDateTime getLocalDateTime(String zoneId);要实现RMI，服务器和客户端必须共享同一个接口。我们定义一个WorldClock接口，代码如下：public interface WorldClock extends Remote { LocalDateTime getLocalDateTime(String zoneId) throws RemoteException; }Java的RMI规定此接口必须派生自java.rmi.Remote，并在每个方法声明抛出RemoteException。下一步是编写服务器的实现类，因为客户端请求的调用方法getLocalDateTime()最终会通过这个实现类返回结果。实现类WorldClockService代码如下：public class WorldClockService implements WorldClock { @Override public LocalDateTime getLocalDateTime(String zoneId) throws RemoteException { return LocalDateTime.now(ZoneId.of(zoneId)).withNano(0); } }现在，服务器端的服务相关代码就编写完毕。我们需要通过Java RMI提供的一系列底层支持接口，把上面编写的服务以RMI的形式暴露在网络上，客户端才能调用：public class Server { public static void main(String[] args) throws RemoteException { System.out.println("create World clock remote service..."); // 实例化一个WorldClock: WorldClock worldClock = new WorldClockService(); // 将此服务转换为远程服务接口: WorldClock skeleton = (WorldClock) UnicastRemoteObject.exportObject(worldClock, 0); // 将RMI服务注册到1099端口: Registry registry = LocateRegistry.createRegistry(1099); // 注册此服务，服务名为"WorldClock": registry.rebind("WorldClock", skeleton); } }上述代码主要目的是通过RMI提供的相关类，将我们自己的WorldClock实例注册到RMI服务上。RMI的默认端口是1099，最后一步注册服务时通过rebind()指定服务名称为"WorldClock"。下一步我们就可以编写客户端代码。RMI要求服务器和客户端共享同一个接口，因此我们要把WorldClock.java这个接口文件复制到客户端，然后在客户端实现RMI调用：public class Client { public static void main(String[] args) throws RemoteException, NotBoundException { // 连接到服务器localhost，端口1099: Registry registry = LocateRegistry.getRegistry("localhost", 1099); // 查找名称为"WorldClock"的服务并强制转型为WorldClock接口: WorldClock worldClock = (WorldClock) registry.lookup("WorldClock"); // 正常调用接口方法: LocalDateTime now = worldClock.getLocalDateTime("Asia/Shanghai"); // 打印调用结果: System.out.println(now); } }先运行服务器，再运行客户端。从运行结果可知，因为客户端只有接口，并没有实现类，因此，客户端获得的接口方法返回值实际上是通过网络从服务器端获取的。整个过程实际上非常简单，对客户端来说，客户端持有的WorldClock接口实际上对应了一个“实现类”，它是由Registry内部动态生成的，并负责把方法调用通过网络传递到服务器端。而服务器端接收网络调用的服务并不是我们自己编写的WorldClockService，而是Registry自动生成的代码。我们把客户端的“实现类”称为stub，而服务器端的网络服务类称为skeleton，它会真正调用服务器端的WorldClockService，获取结果，然后把结果通过网络传递给客户端。整个过程由RMI底层负责实现序列化和反序列化：┌ ─ ─ ─ ─ ─ ─ ─ ─ ┐ ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐ ┌─────────────┐ ┌────────────┐ │ │ Service │ │ │ │ Service │ │ └─────────────┘ └────────────┘ │ ▲ │ │ ▲ │ │ │ │ │ │ │ │ │ ┌─────────────┐ Network ┌───────────────┐ ┌────────────┐ │ │ Client Stub ├─┼─────────┼─▶│Server Skeleton│──▶│Service Impl│ │ └─────────────┘ └───────────────┘ └────────────┘ └ ─ ─ ─ ─ ─ ─ ─ ─ ┘ └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘Java的RMI严重依赖序列化和反序列化，而这种情况下可能会造成严重的安全漏洞，因为Java的序列化和反序列化不但涉及到数据，还涉及到二进制的字节码，即使使用白名单机制也很难保证100%排除恶意构造的字节码。因此，使用RMI时，双方必须是内网互相信任的机器，不要把1099端口暴露在公网上作为对外服务。此外，Java的RMI调用机制决定了双方必须是Java程序，其他语言很难调用Java的RMI。如果要使用不同语言进行RPC调用，可以选择更通用的协议，例如gRPC。笔记所有内容摘取自廖雪峰java教程 https://liaoxuefeng.com/books/java/introduction/index.html
IO1.File对象在计算机系统中，文件是非常重要的存储方式。Java的标准库java.io提供了File对象来操作文件和目录。要构造一个File对象，需要传入文件路径：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对象时，既可以传入绝对路径，也可以传入相对路径。绝对路径是以根目录开头的完整路径，例如：File f = new File("C:\\Windows\\notepad.exe");注意Windows平台使用\作为路径分隔符，在Java字符串中需要用\\表示一个\。Linux平台使用/作为路径分隔符：File f = new File("/usr/bin/javac");传入相对路径时，相对路径前面加上当前目录就是绝对路径：// 假设当前目录是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，它和绝对路径类似，但是返回的是规范路径。什么是规范路径？我们看以下代码：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对象有一个静态变量用于表示当前平台的系统分隔符：System.out.println(File.separator); // 根据当前平台打印"\"或"/"文件和目录File对象既可以表示文件，也可以表示目录。特别要注意的是，构造一个File对象，即使传入的文件或目录不存在，代码也不会出错，因为构造一个File对象，并不会导致任何磁盘操作。只有当我们调用File对象的某些方法的时候，才真正进行磁盘操作。例如，调用isFile()，判断该File对象是否是一个已存在的文件，调用isDirectory()，判断该File对象是否是一个已存在的目录：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()删除该文件：File file = new File("/path/to/file"); if (file.createNewFile()) { // 文件创建成功: // TODO: if (file.delete()) { // 删除文件成功: } }有些时候，程序需要读写一些临时文件，File对象提供了createTempFile()来创建一个临时文件，以及deleteOnExit()在JVM退出时自动删除该文件。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()提供了一系列重载方法，可以过滤不想要的文件和目录：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("=========="); } }和文件操作类似，File对象如果表示一个目录，可以通过以下方法创建和删除目录：boolean mkdir()：创建当前File对象表示的目录；boolean mkdirs()：创建当前File对象表示的目录，并在必要时将不存在的父目录也创建出来；boolean delete()：删除当前File对象表示的目录，当前目录必须为空才能删除成功。PathJava标准库还提供了一个Path对象，它位于java.nio.file包。Path对象和File对象类似，但操作更加简单：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对象更方便。2.InputStreamInputStream就是Java标准库提供的最基本的输入流。它位于java.io这个包里。java.io包提供了所有同步IO的功能。要特别注意的一点是，InputStream并不是一个接口，而是一个抽象类，它是所有输入流的超类。这个抽象类定义的一个最重要的方法就是int read()，签名如下：public abstract int read() throws IOException;这个方法会读取输入流的下一个字节，并返回字节表示的int值（0~255）。如果已读到末尾，返回-1表示不能继续读取了。FileInputStream是InputStream的一个子类。顾名思义，FileInputStream就是从文件流中读取数据。下面的代码演示了如何完整地读取一个FileInputStream的所有字节：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错误的时候都能够正确地关闭：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语句，让编译器自动为我们关闭资源。推荐的写法如下：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[]数组的偏移量和最大填充数利用上述方法一次读取多个字节时，需要先定义一个byte[]数组作为缓冲区，read()方法会尽可能多地读取字节到缓冲区， 但不会超过缓冲区的大小。read()方法的返回值不再是字节的int值，而是返回实际读取了多少个字节。如果返回-1，表示没有更多的数据了。利用缓冲区一次读取多个字节的代码如下：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）的。它的意思是，对于下面的代码：int n; n = input.read(); // 必须等待read()方法返回才能执行下一行代码 int m = n;执行到第二行代码时，必须等read()方法返回后才能继续。因为读取IO流相比执行普通代码，速度会慢很多，因此，无法确定read()方法调用到底要花费多长时间。InputStream实现类用FileInputStream可以从文件获取输入流，这是InputStream常用的一个实现类。此外，ByteArrayInputStream可以在内存中模拟一个InputStream：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然后拼成一个字符串，可以这么写：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()的方法：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：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的任意实现类。3.OutputStream和InputStream相反，OutputStream是Java标准库提供的最基本的输出流。和InputStream类似，OutputStream也是抽象类，它是所有输出流的超类。这个抽象类定义的一个最重要的方法就是void write(int b)，签名如下：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为例，演示如何将若干个字节写入文件流：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[])来实现：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错误的时候都能够正确地关闭：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：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) { ... }语句中可以同时写出多个资源，用;隔开。例如，同时读写两个文件：// 读取input.txt，写入output.txt: try (InputStream input = new FileInputStream("input.txt"); OutputStream output = new FileOutputStream("output.txt")) { input.transferTo(output); // transferTo的作用是? }input.transferTo(output) 是 Java 9+ 中引入的一个非常方便的方法，它的作用是将输入流的所有数据高效地复制到输出流。4.Filter模式Java的IO标准库提供的InputStream根据来源可以包括：FileInputStream：从文件读取数据，是最终数据源；ServletInputStream：从HTTP请求读取数据，是最终数据源；Socket.getInputStream()：从TCP连接读取数据，是最终数据源；...如果我们要给FileInputStream添加缓冲功能，则可以从FileInputStream派生一个类：BufferedFileInputStream extends FileInputStream {}如果要给FileInputStream添加计算签名的功能，类似的，也可以从FileInputStream派生一个类：DigestFileInputStream extends FileInputStream {}如果要给FileInputStream添加加密/解密功能，还是可以从FileInputStream派生一个类：CipherFileInputStream extends FileInputStream {}如果要给FileInputStream添加缓冲和签名的功能，那么我们还需要派生BufferedDigestFileInputStream。如果要给FileInputStream添加缓冲和加解密的功能，则需要派生BufferedCipherFileInputStream。我们发现，给FileInputStream添加3种功能，至少需要3个子类。这3种功能的组合，又需要更多的子类： ┌─────────────────┐ │ FileInputStream │ └─────────────────┘ ▲ ┌───────────┬─────────┼─────────┬───────────┐ │ │ │ │ │ ┌───────────────────────┐│┌─────────────────┐│┌─────────────────────┐ │BufferedFileInputStream│││DigestInputStream│││CipherFileInputStream│ └───────────────────────┘│└─────────────────┘│└─────────────────────┘ │ │ ┌─────────────────────────────┐ ┌─────────────────────────────┐ │BufferedDigestFileInputStream│ │BufferedCipherFileInputStream│ └─────────────────────────────┘ └─────────────────────────────┘这还只是针对FileInputStream设计，如果针对另一种InputStream设计，很快会出现子类爆炸的情况。因此，直接使用继承，为各种InputStream附加更多的功能，根本无法控制代码的复杂度，很快就会失控。为了解决依赖继承会导致子类数量失控的问题，JDK首先将InputStream分为两大类：一类是直接提供数据的基础InputStream，例如：FileInputStreamByteArrayInputStreamServletInputStream...一类是提供额外附加功能的InputStream，例如：BufferedInputStreamDigestInputStreamCipherInputStream...当我们需要给一个“基础”InputStream附加各种功能时，我们先确定这个能提供数据源的InputStream，因为我们需要的数据总得来自某个地方，例如，FileInputStream，数据来源自文件：InputStream file = new FileInputStream("test.gz");紧接着，我们希望FileInputStream能提供缓冲的功能来提高读取的效率，因此我们用BufferedInputStream包装这个InputStream，得到的包装类型是BufferedInputStream，但它仍然被视为一个InputStream：InputStream buffered = new BufferedInputStream(file);最后，假设该文件已经用gzip压缩了，我们希望直接读取解压缩的内容，就可以再包装一个GZIPInputStream：InputStream gzip = new GZIPInputStream(buffered);无论我们包装多少次，得到的对象始终是InputStream，我们直接用InputStream来引用它，就可以正常读取：┌─────────────────────────┐ │GZIPInputStream │ │┌───────────────────────┐│ ││BufferedFileInputStream││ ││┌─────────────────────┐││ │││ FileInputStream │││ ││└─────────────────────┘││ │└───────────────────────┘│ └─────────────────────────┘上述这种通过一个“基础”组件再叠加各种“附加”功能组件的模式，称之为Filter模式（或者装饰器模式：Decorator）。它可以让我们通过少量的类来实现各种功能的组合： ┌─────────────┐ │ InputStream │ └─────────────┘ ▲ ▲ ┌────────────────────┐ │ │ ┌─────────────────┐ │ FileInputStream │─┤ └─│FilterInputStream│ └────────────────────┘ │ └─────────────────┘ ┌────────────────────┐ │ ▲ ┌───────────────────┐ │ByteArrayInputStream│─┤ ├─│BufferedInputStream│ └────────────────────┘ │ │ └───────────────────┘ ┌────────────────────┐ │ │ ┌───────────────────┐ │ ServletInputStream │─┘ ├─│ DataInputStream │ └────────────────────┘ │ └───────────────────┘ │ ┌───────────────────┐ └─│CheckedInputStream │ └───────────────────┘类似的，OutputStream也是以这种模式来提供各种功能： ┌─────────────┐ │OutputStream │ └─────────────┘ ▲ ▲ ┌─────────────────────┐ │ │ ┌──────────────────┐ │ FileOutputStream │─┤ └─│FilterOutputStream│ └─────────────────────┘ │ └──────────────────┘ ┌─────────────────────┐ │ ▲ ┌────────────────────┐ │ByteArrayOutputStream│─┤ ├─│BufferedOutputStream│ └─────────────────────┘ │ │ └────────────────────┘ ┌─────────────────────┐ │ │ ┌────────────────────┐ │ ServletOutputStream │─┘ ├─│ DataOutputStream │ └─────────────────────┘ │ └────────────────────┘ │ ┌────────────────────┐ └─│CheckedOutputStream │ └────────────────────┘编写FilterInputStream我们也可以自己编写FilterInputStream，以便可以把自己的FilterInputStream“叠加”到任何一个InputStream中。下面的例子演示了如何编写一个CountInputStream，它的作用是对输入的字节进行计数：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，因此不存在资源泄露。ZipInputStream是一种FilterInputStream，它可以直接读取zip包的内容：┌───────────────────┐ │ InputStream │ └───────────────────┘ ▲ │ ┌───────────────────┐ │ FilterInputStream │ └───────────────────┘ ▲ │ ┌───────────────────┐ │InflaterInputStream│ └───────────────────┘ ▲ │ ┌───────────────────┐ │ ZipInputStream │ └───────────────────┘ ▲ │ ┌───────────────────┐ │ JarInputStream │ └───────────────────┘另一个JarInputStream是从ZipInputStream派生，它增加的主要功能是直接读取jar文件里面的MANIFEST.MF文件。因为本质上jar包就是zip包，只是额外附加了一些固定的描述文件。读取zip包我们来看看ZipInputStream的基本用法。我们要创建一个ZipInputStream，通常是传入一个FileInputStream作为数据源，然后，循环调用getNextEntry()，直到返回null，表示zip流结束。一个ZipEntry表示一个压缩文件或目录，如果是压缩文件，我们就用read()方法不断读取，直到返回-1：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()结束这个文件的打包。try (ZipOutputStream zip = new ZipOutputStream(new FileOutputStream(...))) { File[] files = ... for (File file : files) { zip.putNextEntry(new ZipEntry(file.getName())); zip.write(Files.readAllBytes(file.toPath())); zip.closeEntry(); } }上面的代码没有考虑文件的目录结构。如果要实现目录层次结构，new ZipEntry(name)传入的name要用相对路径。读取classpath资源很多Java程序启动的时候，都需要读取配置文件。例如，从一个.properties文件中读取配置：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读取任意的资源文件：try (InputStream input = getClass().getResourceAsStream("/default.properties")) { // TODO: }调用getResourceAsStream()需要特别注意的一点是，如果资源文件不存在，它将返回null。因此，我们需要检查返回的InputStream是否为null，如果为null，表示资源文件在classpath中没有找到：try (InputStream input = getClass().getResourceAsStream("/default.properties")) { if (input != null) { // TODO: } }如果我们把默认的配置放到jar包中，再从外部文件系统读取一个可选的配置文件，就可以做到既有默认的配置文件，又可以让用户自己修改配置：Properties props = new Properties(); props.load(inputStreamFromClassPath("/default.properties")); props.load(inputStreamFromFile("./conf.properties"));这样读取配置文件，应用程序启动就更加灵活。5.序列化序列化是指把一个Java对象变成二进制内容，本质上就是一个byte[]数组。为什么要把Java对象序列化呢？因为序列化后可以把byte[]保存到文件中，或者把byte[]通过网络传输到远程，这样，就相当于把Java对象存储到文件或者通过网络传输出去了。有序列化，就有反序列化，即把一个二进制内容（也就是byte[]数组）变回Java对象。有了反序列化，保存到文件中的byte[]数组又可以“变回”Java对象，或者从网络上读取byte[]并把它“变回”Java对象。我们来看看如何把一个Java对象序列化。一个Java对象要能序列化，必须实现一个特殊的java.io.Serializable接口，它的定义如下：public interface Serializable { }Serializable接口没有定义任何方法，它是一个空接口。我们把这样的空接口称为“标记接口”（Marker Interface），实现了标记接口的类仅仅是给自身贴了个“标记”，并没有增加任何方法。序列化把一个Java对象变为byte[]数组，需要使用ObjectOutputStream。它负责把一个Java对象写入一个字节流：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对象：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版本：public class Person implements Serializable { private static final long serialVersionUID = 2709425275741743919L; }要特别注意反序列化的几个重要特点：反序列化时，由JVM直接构造出Java对象，不调用构造方法，构造方法内部的代码，在反序列化时根本不可能执行。如果显式定义了 serialVersionUID：// 旧版本类 public class Person implements Serializable { private static final long serialVersionUID = 2709425275741743919L; private String name; } // 新版本类（兼容性修改） public class Person implements Serializable { private static final long serialVersionUID = 2709425275741743919L; // 保持相同！ private String name; private int age; // 新增字段 }行为：UID 相同，允许反序列化新增字段在旧数据中不存在，会被设为默认值（null、0、false等）删除字段：旧数据中的该字段会被忽略即使类结构完全相同，只要 serialVersionUID 不同，也会报错。安全性因为Java的序列化机制可以导致一个实例能直接从byte[]数组创建，而不经过构造方法，因此，它存在一定的安全隐患。一个精心构造的byte[]数组被反序列化后可以执行特定的Java代码，从而导致严重的安全漏洞。实际上，Java本身提供的基于对象的序列化和反序列化机制既存在安全性问题，也存在兼容性问题。更好的序列化方法是通过JSON这样的通用数据结构来实现，只输出基本类型（包括String）的内容，而不存储任何与代码相关的信息。6.ReaderReader是Java的IO库提供的另一个输入流接口。和InputStream的区别是，InputStream是一个字节流，即以byte为单位读取，而Reader是一个字符流，即以char为单位读取：InputStreamReader字节流，以byte为单位字符流，以char为单位读取字节（-1，0~255）：int read()读取字符（-1，0~65535）：int read()读到字节数组：int read(byte[] b)读到字符数组：int read(char[] c)java.io.Reader是所有字符输入流的超类，它最主要的方法是：public int read() throws IOException;这个方法读取字符流的下一个字符，并返回字符表示的int，范围是0~65535。如果已读到末尾，返回-1。FileReaderFileReader是Reader的一个子类，它可以打开文件并获取Reader。下面的代码演示了如何完整地读取一个FileReader的所有字符：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时指定编码：Reader reader = new FileReader("src/readme.txt", StandardCharsets.UTF_8);和InputStream类似，Reader也是一种资源，需要保证出错的时候也能正确关闭，所以我们需要用try (resource)来保证Reader在无论有没有IO错误的时候都能够正确地关闭：try (Reader reader = new FileReader("src/readme.txt", StandardCharsets.UTF_8)) { // TODO }Reader还提供了一次性读取若干字符并填充到char[]数组的方法：public int read(char[] c) throws IOException它返回实际读入的字符个数，最大不超过char[]数组的长度。返回-1表示流结束。利用这个方法，我们可以先设置一个缓冲区，然后，每次尽可能地填充缓冲区：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."); } } }CharArrayReaderCharArrayReader可以在内存中模拟一个Reader，它的作用实际上是把一个char[]数组变成一个Reader，这和ByteArrayInputStream非常类似：try (Reader reader = new CharArrayReader("Hello".toCharArray())) { }StringReaderStringReader可以直接把String作为数据源，它和CharArrayReader几乎一样：try (Reader reader = new StringReader("Hello")) { }InputStreamReaderReader和InputStream有什么关系？除了特殊的CharArrayReader和StringReader，普通的Reader实际上是基于InputStream构造的，因为Reader需要从InputStream中读入字节流（byte），然后，根据编码设置，再转换为char就可以实现字符流。如果我们查看FileReader的源码，它在内部实际上持有一个FileInputStream。既然Reader本质上是一个基于InputStream的byte到char的转换器，那么，如果我们已经有一个InputStream，想把它转换为Reader，是完全可行的。InputStreamReader就是这样一个转换器，它可以把任何InputStream转换为Reader。示例代码如下：// 持有InputStream: InputStream input = new FileInputStream("src/readme.txt"); // 变换为Reader: Reader reader = new InputStreamReader(input, "UTF-8");构造InputStreamReader时，我们需要传入InputStream，还需要指定编码，就可以得到一个Reader对象。上述代码可以通过try (resource)更简洁地改写如下：try (Reader reader = new InputStreamReader(new FileInputStream("src/readme.txt"), "UTF-8")) { // TODO: }上述代码实际上就是FileReader的一种实现方式。使用try (resource)结构时，当我们关闭Reader时，它会在内部自动调用InputStream的close()方法，所以，只需要关闭最外层的Reader对象即可。提示：使用InputStreamReader，可以把一个InputStream转换成一个Reader。7.WriterReader是带编码转换器的InputStream，它把byte转换为char，而Writer就是带编码转换器的OutputStream，它把char转换为byte并输出。Writer和OutputStream的区别如下：OutputStreamWriter字节流，以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)。FileWriterFileWriter就是向文件中写入字符流的Writer。它的使用方法和FileReader类似：try (Writer writer = new FileWriter("readme.txt", StandardCharsets.UTF_8)) { writer.write('H'); // 写入单个字符 writer.write("Hello".toCharArray()); // 写入char[] writer.write("Hello"); // 写入String }CharArrayWriterCharArrayWriter可以在内存中创建一个Writer，它的作用实际上是构造一个缓冲区，可以写入char，最后得到写入的char[]数组，这和ByteArrayOutputStream非常类似：try (CharArrayWriter writer = new CharArrayWriter()) { writer.write(65); writer.write(66); writer.write(67); char[] data = writer.toCharArray(); // { 'A', 'B', 'C' } }StringWriterStringWriter也是一个基于内存的Writer，它和CharArrayWriter类似。实际上，StringWriter在内部维护了一个StringBuffer，并对外提供了Writer接口。OutputStreamWriter除了CharArrayWriter和StringWriter外，普通的Writer实际上是基于OutputStream构造的，它接收char，然后在内部自动转换成一个或多个byte，并写入OutputStream。因此，OutputStreamWriter就是一个将任意的OutputStream转换为Writer的转换器：try (Writer writer = new OutputStreamWriter(new FileOutputStream("readme.txt"), "UTF-8")) { // TODO: }上述代码实际上就是FileWriter的一种实现方式。这和上一节的InputStreamReader是一样的。8.PrintStream和PrintWriterPrintStream是一种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，表示标准输出：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。PrintWriterPrintStream最终输出的总是byte数据，而PrintWriter则是扩展了Writer接口，它的print()/println()方法最终输出的是char数据。两者的使用方法几乎是一模一样的：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()); } }9.Files工具类从Java 7开始，提供了Files这个工具类，能极大地方便我们读写文件。虽然Files是java.nio包里面的类，但他俩封装了很多读写文件的简单方法，例如，我们要把一个文件的全部内容读取为一个byte[]，可以这么写：byte[] data = Files.readAllBytes(Path.of("/path/to/file.txt"));如果是文本文件，可以把一个文件的全部内容读取为String：// 默认使用UTF-8编码读取: String content1 = Files.readString(Path.of("/path/to/file.txt")); // 可指定编码: String content2 = Files.readString(Path.of("/path", "to", "file.txt"), StandardCharsets.ISO_8859_1); // 按行读取并返回每行内容: List<String> lines = Files.readAllLines(Path.of("/path/to/file.txt"));写入文件也非常方便：// 写入二进制文件: byte[] data = ... Files.write(Path.of("/path/to/file.txt"), data); // 写入文本并指定编码: Files.writeString(Path.of("/path/to/file.txt"), "文本内容...", StandardCharsets.ISO_8859_1); // 按行写入文本: List<String> lines = ... Files.write(Path.of("/path/to/file.txt"), lines);此外，Files工具类还有copy()、delete()、exists()、move()等快捷方法操作文件和目录。最后需要特别注意的是，Files提供的读写方法，受内存限制，只能读写小文件，例如配置文件等，不可一次读入几个G的大文件。读写大型文件仍然要使用文件流，每次只读写一部分文件内容。笔记所有内容摘取自廖雪峰java教程 https://liaoxuefeng.com/books/java/introduction/index.html
面向对象编程-java核心类1.字符串字符串比较当我们想要比较两个字符串是否相同时，要特别注意，我们实际上是想比较字符串的内容是否相同。必须使用equals()方法而不能用==。我们看下面的例子：// String public class Main { public static void main(String[] args) { String s1 = "hello"; String s2 = "hello"; System.out.println(s1 == s2); System.out.println(s1.equals(s2)); } }从表面上看，两个字符串用==和equals()比较都为true，但实际上那只是Java编译器在编译期，会自动把所有相同的字符串当作一个对象放入常量池，自然s1和s2的引用就是相同的。所以，这种==比较返回true纯属巧合。换一种写法，==比较就会失败：// String public class Main { public static void main(String[] args) { String s1 = "hello"; String s2 = "HELLO".toLowerCase(); System.out.println(s1 == s2); System.out.println(s1.equals(s2)); } }结论：两个字符串比较，必须总是使用equals()方法。要忽略大小写比较，使用equalsIgnoreCase()方法。字符串编辑：去除首尾空白字符、提取子串、替换子串、分割字符串、拼接字符串使用trim()方法可以移除字符串首尾空白字符。空白字符包括空格，\t，\r，\n：" \tHello\r\n ".trim(); // "Hello"注意：trim()并没有改变字符串的内容，而是返回了一个新字符串。另一个strip()方法也可以移除字符串首尾空白字符。它和trim()不同的是，类似中文的空格字符\u3000也会被移除："\u3000Hello\u3000".strip(); // "Hello" " Hello ".stripLeading(); // "Hello " " Hello ".stripTrailing(); // " Hello"String还提供了isEmpty()和isBlank()来判断字符串是否为空和空白字符串："".isEmpty(); // true，因为字符串长度为0 " ".isEmpty(); // false，因为字符串长度不为0 " \n".isBlank(); // true，因为只包含空白字符 " Hello ".isBlank(); // false，因为包含非空白字符String类还提供了多种方法来搜索子串、提取子串。常用的方法有：// 是否包含子串: "Hello".contains("ll"); // true注意到contains()方法的参数是CharSequence而不是String，因为CharSequence是String实现的一个接口。搜索子串的更多的例子："Hello".indexOf("l"); // 2 "Hello".lastIndexOf("l"); // 3 "Hello".startsWith("He"); // true "Hello".endsWith("lo"); // true提取子串的例子："Hello".substring(2); // "llo" "Hello".substring(2, 4); "ll"要在字符串中替换子串，有两种方法。一种是根据字符或字符串替换：String s = "hello"; s.replace('l', 'w'); // "hewwo"，所有字符'l'被替换为'w' s.replace("ll", "~~"); // "he~~o"，所有子串"ll"被替换为"~~"另一种是通过正则表达式替换：String s = "A,,B;C ,D"; s.replaceAll("[\\,\\;\\s]+", ","); // "A,B,C,D"上面的代码通过正则表达式，把匹配的子串统一替换为","。关于正则表达式的用法我们会在后面详细讲解。要分割字符串，使用split()方法，并且传入的也是正则表达式：String s = "A,B,C,D"; String[] ss = s.split("\\,"); // {"A", "B", "C", "D"}拼接字符串使用静态方法join()，它用指定的字符串连接字符串数组：String[] arr = {"A", "B", "C"}; S格式化字符串字符串提供了formatted()方法和format()静态方法，可以传入其他参数，替换占位符，然后生成新的字符串：// String public class Main { public static void main(String[] args) { String s = "Hi %s, your score is %d!"; System.out.println(s.formatted("Alice", 80)); System.out.println(String.format("Hi %s, your score is %.2f!", "Bob", 59.5)); } }有几个占位符，后面就传入几个参数。参数类型要和占位符一致。我们经常用这个方法来格式化信息。常用的占位符有：%s：显示字符串；%d：显示整数；%x：显示十六进制整数；%f：显示浮点数。占位符还可以带格式，例如%.2f表示显示两位小数。如果你不确定用啥占位符，那就始终用%s，因为%s可以显示任何数据类型。要查看完整的格式化语法，请参考JDK文档。任意类型转字符串-String.valueOf()要把任意基本类型或引用类型转换为字符串，可以使用静态方法valueOf()。这是一个重载方法，编译器会根据参数自动选择合适的方法：String.valueOf(123); // "123" String.valueOf(45.67); // "45.67" String.valueOf(true); // "true" String.valueOf(new Object()); // 类似java.lang.Object@636be97c要把字符串转换为其他类型，就需要根据情况。例如，把字符串转换为int类型：int n1 = Integer.parseInt("123"); // 123 int n2 = Integer.parseInt("ff", 16); // 按十六进制转换，255把字符串转换为boolean类型：boolean b1 = Boolean.parseBoolean("true"); // true boolean b2 = Boolean.parseBoolean("FALSE"); // false要特别注意，Integer有个getInteger(String)方法，它不是将字符串转换为int，而是把该字符串对应的系统变量转换为Integer：Integer.getInteger("java.version"); // 版本号，11字符串转换为char[]-toCharArray()String和char[]类型可以互相转换，方法是：toCharArray()。char[] cs = "Hello".toCharArray(); // String -> char[] String s = new String(cs); // char[] -> String编码因为英文字符的Unicode编码高字节总是00，包含大量英文的文本会浪费空间，所以，出现了UTF-8编码，它是一种变长编码，用来把固定长度的Unicode编码变成1～4字节的变长编码。通过UTF-8编码，英文字符'A'的UTF-8编码变为0x41，正好和ASCII码一致，而中文'中'的UTF-8编码为3字节0xe4b8ad。UTF-8编码的另一个好处是容错能力强。如果传输过程中某些字符出错，不会影响后续字符，因为UTF-8编码依靠高字节位来确定一个字符究竟是几个字节，它经常用来作为传输编码。在Java中，char类型实际上就是两个字节的Unicode编码。如果我们要手动把字符串转换成其他编码，可以这样做：byte[] b1 = "Hello".getBytes(); // 按系统默认编码转换，不推荐 byte[] b2 = "Hello".getBytes("UTF-8"); // 按UTF-8编码转换 byte[] b2 = "Hello".getBytes("GBK"); // 按GBK编码转换 byte[] b3 = "Hello".getBytes(StandardCharsets.UTF_8); // 按UTF-8编码转换注意：转换编码后，就不再是char类型，而是byte类型表示的数组。如果要把已知编码的byte[]转换为String，可以这样做：byte[] b = ... String s1 = new String(b, "GBK"); // 按GBK转换 String s2 = new String(b, StandardCharsets.UTF_8); // 按UTF-8转换始终牢记：Java的String和char在内存中总是以Unicode编码表示。JDK对字符串存储的优化对于不同版本的JDK，String类在内存中有不同的优化方式。具体来说，早期JDK版本的String总是以char[]存储，它的定义如下：public final class String { private final char[] value; private final int offset; private final int count; }而较新的JDK版本的String则以byte[]存储：如果String仅包含ASCII字符，则每个byte存储一个字符，否则，每两个byte存储一个字符，这样做的目的是为了节省内存，因为大量的长度较短的String通常仅包含ASCII字符：public final class String { private final byte[] value; private final byte coder; // 0 = LATIN1, 1 = UTF16对于使用者来说，String内部的优化不影响任何已有代码，因为它的public方法签名是不变的。2.StringBuilder考察下面的循环代码：String s = ""; for (int i = 0; i < 1000; i++) { s = s + "," + i; }虽然可以直接拼接字符串，但是，在循环中，每次循环都会创建新的字符串对象，然后扔掉旧的字符串。这样，绝大部分字符串都是临时对象，不但浪费内存，还会影响GC效率。为了能高效拼接字符串，Java标准库提供了StringBuilder，它是一个可变对象，可以预分配缓冲区，这样，往StringBuilder中新增字符时，不会创建新的临时对象：StringBuilder sb = new StringBuilder(1024); for (int i = 0; i < 1000; i++) { sb.append(','); sb.append(i); } String s = sb.toString();StringBuilder还可以进行链式操作：// 链式操作 public class Main { public static void main(String[] args) { var sb = new StringBuilder(1024); sb.append("Mr ") .append("Bob") .append("!") .insert(0, "Hello, "); System.out.println(sb.toString()); } }如果我们查看StringBuilder的源码，可以发现，进行链式操作的关键是，定义的append()方法会返回this，这样，就可以不断调用自身的其他方法。仿照StringBuilder，我们也可以设计支持链式操作的类。StringBuffer，这是Java早期的一个StringBuilder的线程安全版本，它通过同步来保证多个线程操作StringBuffer也是安全的，但是同步会带来执行速度的下降。StringBuilder和StringBuffer接口完全相同，现在完全没有必要使用StringBuffer。3.StringJoinerJava标准库提供了一个StringJoiner来解决类似用分隔符拼接数组的需求。String[] names = {"Bob", "Alice", "Grace"}; var sj = new StringJoiner(", "); for (String name : names) { sj.add(name); } System.out.println(sj.toString());先new一个Stringjoiner，然后遍历数组并add()，最后打印时需要toString()。可以先给StringJoiner指定“开头”和“结尾”：var sj = new StringJoiner(", ", "Hello ", "!"); for (String name : names) { sj.add(name); }String还提供了一个静态方法join()，这个方法在内部使用了StringJoiner来拼接字符串，在不需要指定“开头”和“结尾”的时候，用String.join()更方便：String[] names = {"Bob", "Alice", "Grace"}; var s = String.join(", ", names);这个不需要toString()。4.包装类型（自动装箱拆箱）int和Integer可以互相转换：int i = 100; Integer n = Integer.valueOf(i); int x = n.intValue();Java编译器可以帮助我们自动在int和Integer之间转型：Integer n = 100; // 编译器自动使用Integer.valueOf(int) int x = n; // 编译器自动使用Integer.intValue()这种直接把int变为Integer的赋值写法，称为自动装箱（Auto Boxing），反过来，把Integer变为int的赋值写法，称为自动拆箱（Auto Unboxing）。自动装箱和自动拆箱只发生在编译阶段。装箱和拆箱会影响执行效率，且拆箱时可能发生NullPointerException；所有的包装类型都是不变类。我们查看Integer的源码可知，它的核心代码如下：public final class Integer { private final int value; }因此，一旦创建了Integer对象，该对象就是不变的。对两个Integer实例进行比较要特别注意：绝对不能用==比较，因为Integer是引用类型，必须使用equals()比较。方法1：Integer n = new Integer(100);方法2：Integer n = Integer.valueOf(100);我们把能创建“新”对象的静态方法称为静态工厂方法。Integer.valueOf()就是静态工厂方法，它尽可能地返回缓存的实例以节省内存。Integer类本身还提供了大量方法，例如，最常用的静态方法parseInt()可以把字符串解析成一个整数：int x1 = Integer.parseInt("100"); // 100 int x2 = Integer.parseInt("100", 16); // 256,因为按16进制解析Integer还可以把整数格式化为指定进制的字符串：// Integer: public class Main { public static void main(String[] args) { System.out.println(Integer.toString(100)); // "100",表示为10进制 System.out.println(Integer.toString(100, 36)); // "2s",表示为36进制 System.out.println(Integer.toHexString(100)); // "64",表示为16进制 System.out.println(Integer.toOctalString(100)); // "144",表示为8进制 System.out.println(Integer.toBinaryString(100)); // "1100100",表示为2进制 } }注意：上述方法的输出都是String，在计算机内存中，只用二进制表示，不存在十进制或十六进制的表示方法。int n = 100在内存中总是以4字节的二进制表示：┌────────┬────────┬────────┬────────┐ │00000000│00000000│00000000│01100100│ └────────┴────────┴────────┴────────┘我们经常使用的System.out.println(n);是依靠核心库自动把整数格式化为10进制输出并显示在屏幕上，使用Integer.toHexString(n)则通过核心库自动把整数格式化为16进制。这里我们注意到程序设计的一个重要原则：数据的存储和显示要分离。所有的整数和浮点数的包装类型都继承自Number。5.enum枚举类为了让编译器能自动检查某个值在枚举的集合内，并且，不同用途的枚举需要不同的类型来标记，不能混用，我们可以使用enum来定义枚举类：// enum public class Main { public static void main(String[] args) { Weekday day = Weekday.SUN; if (day == Weekday.SAT || day == Weekday.SUN) { System.out.println("Work at home!"); } else { System.out.println("Work at office!"); } } } enum Weekday { SUN, MON, TUE, WED, THU, FRI, SAT; }引用类型比较，要始终使用equals()方法，但enum类型可以例外。这是因为enum类型的每个常量在JVM中只有一个唯一实例，所以可以直接用==比较。enum定义的类型就是class，只不过它有以下几个特点：定义的enum类型总是继承自java.lang.Enum，且无法被继承；只能定义出enum的实例，而无法通过new操作符创建enum的实例；定义的每个实例都是引用类型的唯一实例；可以将enum类型用于switch语句。我们定义的Color枚举类：public enum Color { RED, GREEN, BLUE; }编译器编译出的class大概就像这样：public final class Color extends Enum { // 继承自Enum，标记为final class // **每个实例均为全局唯一**: public static final Color RED = new Color(); public static final Color GREEN = new Color(); public static final Color BLUE = new Color(); // private构造方法，确保外部无法调用new操作符: private Color() {} }name()返回常量名，例如：String s = Weekday.SUN.name(); // "SUN"ordinal()返回定义的常量的顺序，从0开始计数，例如：int n = Weekday.MON.ordinal(); // 1改变枚举常量定义的顺序就会导致ordinal()返回值发生变化。我们可以定义private的构造方法，并且，给每个枚举常量添加字段：// enum public class Main { public static void main(String[] args) { Weekday day = Weekday.SUN; if (day.dayValue == 6 || day.dayValue == 0) { System.out.println("Work at home!"); } else { System.out.println("Work at office!"); } } } enum Weekday { MON(1), TUE(2), WED(3), THU(4), FRI(5), SAT(6), SUN(0); public final int dayValue; private Weekday(int dayValue) { this.dayValue = dayValue; } }这样就无需担心顺序的变化，新增枚举常量时，也需要指定一个int值。枚举类的字段也可以是非final类型，即可以在运行期修改，但是不推荐这样做。默认情况下，对枚举常量调用toString()会返回和name()一样的字符串。但是，toString()可以被覆写，而name()则不行。我们可以给Weekday添加toString()方法：枚举类很适合用于switch语句中：public class Main { public static void main(String[] args) { Weekday day = Weekday.SUN; switch(day) { case MON: // 已经知道day是Weekday类型了，所以不用写成Weekday.MON了 case TUE: case WED: case THU: case FRI: System.out.println("Today is " + day + ". Work at office!"); break; case SAT: case SUN: System.out.println("Today is " + day + ". Work at home!"); break; default: throw new RuntimeException("cannot process " + day); } } } enum Weekday { MON, TUE, WED, THU, FRI, SAT, SUN; }6.记录类（不变类）和enum类似，我们自己不能直接从Record派生，只能通过record关键字由编译器实现继承。record Point(int x, int y) {}把上述定义改写为class，相当于以下代码：final class Point extends Record { private final int x; private final int y; public Point(int x, int y) { this.x = x; this.y = y; } public int x() { return this.x; } public int y() { return this.y; } public String toString() { return String.format("Point[x=%s, y=%s]", x, y); } public boolean equals(Object o) { ... } public int hashCode() { ... } }编译器默认按照record声明的变量顺序自动创建一个构造方法，并在方法内给字段赋值。那么问题来了，如果我们要检查参数，应该怎么办？假设Point类的x、y不允许负数，我们就得给Point的构造方法加上检查逻辑：public record Point(int x, int y) { public Point { if (x < 0 || y < 0) { throw new IllegalArgumentException(); } } }注意到方法public Point {...}被称为Compact Constructor，它的目的是让我们编写检查逻辑，编译器最终生成的构造方法如下：public final class Point extends Record { public Point(int x, int y) { // 这是我们编写的Compact Constructor: if (x < 0 || y < 0) { throw new IllegalArgumentException(); } // 这是编译器继续生成的赋值代码: this.x = x; this.y = y; } ... }作为record的Point仍然可以添加静态方法。一种常用的静态方法是of()方法，用来创建Point：public record Point(int x, int y) { public static Point of() { return new Point(0, 0); } public static Point of(int x, int y) { return new Point(x, y); } }这样我们可以写出更简洁的代码：var z = Point.of(); var p = Point.of(123, 456);这是 Java 14 提供的新特性。7.大数运算-BigInteger、BigDecimaljava.math.BigInteger就是用来表示任意大小的整数。BigInteger内部用一个int[]数组来模拟一个非常大的整数：BigInteger bi = new BigInteger("1234567890");对BigInteger做运算的时候，只能使用实例方法：BigInteger sum = i1.add(i2);BigInteger和Integer、Long一样，也是不可变类，并且也继承自Number类。和BigInteger类似，BigDecimal可以表示一个任意大小且精度完全准确的浮点数。BigDecimal bd = new BigDecimal("123.4567");BigDecimal用scale()表示小数位数，例如：BigDecimal d1 = new BigDecimal("123.45"); System.out.println(d1.scale()); // 2,两位小数通过BigDecimal的stripTrailingZeros()方法，可以将一个BigDecimal格式化为一个相等的，但去掉了末尾0的BigDecimal：BigDecimal d1 = new BigDecimal("123.4500"); BigDecimal d2 = d1.stripTrailingZeros(); System.out.println(d1.scale()); // 4 System.out.println(d2.scale()); // 2,因为去掉了00BigDecimal d3 = new BigDecimal("1234500"); BigDecimal d4 = d3.stripTrailingZeros(); System.out.println(d3.scale()); // 0 System.out.println(d4.scale()); // -2d3.scale()原始值是 1234500，没有小数点scale() = 0（小数点后的位数）d4.scale()去掉了末尾的两个零后，数字变成了 12345 × 10^2这相当于 12345 小数点向左移动了 2 位所以 scale() = -2（负数表示小数点向左移动）可以对一个BigDecimal设置它的scale，如果精度比原始值低，那么按照指定的方法进行四舍五入或者直接截断：BigDecimal d1 = new BigDecimal("123.456789"); BigDecimal d2 = d1.setScale(4, RoundingMode.HALF_UP); // 四舍五入，123.4568 BigDecimal d3 = d1.setScale(4, RoundingMode.DOWN); // 直接截断，123.4567做除法时，存在无法除尽的情况，这时，就必须指定精度以及如何进行截断：BigDecimal d1 = new BigDecimal("123.456"); BigDecimal d2 = new BigDecimal("23.456789"); BigDecimal d3 = d1.divide(d2, 10, RoundingMode.HALF_UP); // 保留10位小数并四舍五入 BigDecimal d4 = d1.divide(d2); // 报错：ArithmeticException，因为除不尽还可以对BigDecimal做除法的同时求余数：BigDecimal n = new BigDecimal("12.345"); BigDecimal m = new BigDecimal("0.12"); BigDecimal[] dr = n.divideAndRemainder(m); System.out.println(dr[0]); // 102 System.out.println(dr[1]); // 0.105在比较两个BigDecimal的值是否相等时，要特别注意，使用equals()方法不但要求两个BigDecimal的值相等，还要求它们的scale()相等：必须使用compareTo()方法来比较，它根据两个值的大小分别返回负数、正数和0，分别表示小于、大于和等于。如果查看BigDecimal的源码，可以发现，实际上一个BigDecimal是通过一个BigInteger和一个scale来表示的，即BigInteger表示一个完整的整数，而scale表示小数位数：public class BigDecimal extends Number implements Comparable<BigDecimal> { private final BigInteger intVal; private final int scale; }BigDecimal也是从Number继承的，也是不可变对象。8.常用工具类Math：数学计算HexFormat：格式化十六进制数Random：生成伪随机数SecureRandom：生成安全的随机数笔记所有内容摘取自廖雪峰java教程 https://liaoxuefeng.com/books/java/introduction/index.html
面向对象编程-面向对象编程基础1.继承--super()的注意事项我们来看一个例子：// super public class Main { public static void main(String[] args) { Student s = new Student("Xiao Ming", 12, 89); } } class Person { protected String name; protected int age; public Person(String name, int age) { this.name = name; this.age = age; } } class Student extends Person { protected int score; public Student(String name, int age, int score) { this.score = score; } }运行上面的代码，会得到一个编译错误，大意是在Student的构造方法中，无法调用Person的构造方法。这是因为在Java中，任何class的构造方法，第一行语句必须是调用父类的构造方法。如果没有明确地调用父类的构造方法，编译器会帮我们自动加一句super();所以，Student类的构造方法实际上是这样：class Student extends Person { protected int score; public Student(String name, int age, int score) { super(); // 自动调用父类的构造方法 this.score = score; } }但是，Person类并没有无参数的构造方法，因此，编译失败。解决方法是调用Person类存在的某个构造方法。例如：class Student extends Person { protected int score; public Student(String name, int age, int score) { super(name, age); // 调用父类的构造方法Person(String, int) this.score = score; } }这样就可以正常编译了！在子类的覆写方法中，如果要调用父类的被覆写的方法，可以通过super来调用。例如：class Person { protected String name; public String hello() { return "Hello, " + name; } } class Student extends Person { @Override public String hello() { // 调用父类的hello()方法: return super.hello() + "!"; } }2.继承-阻止继承final修饰符有多种作用：final修饰的方法可以阻止被覆写；final修饰的class可以阻止被继承；final修饰的field必须在创建对象时初始化，随后不可修改。从Java 15开始，允许使用sealed修饰class，并通过permits明确写出能够从该class继承的子类名称。例如，定义一个Shape类：public sealed class Shape permits Rect, Circle, Triangle { ... }上述Shape类就是一个sealed类，它只允许指定的3个类继承它。如果写：public final class Rect extends Shape {...}是没问题的，因为Rect出现在Shape的permits列表中。但是，如果定义一个Ellipse就会报错：public final class Ellipse extends Shape {...} // Compile error: class is not allowed to extend sealed class: Shape原因是Ellipse并未出现在Shape的permits列表中。这种sealed类主要用于一些框架，防止继承被滥用。sealed类在Java 15中目前是预览状态，要启用它，必须使用参数--enable-preview和--source 15。3.继承--向下转型向下转型很可能会失败。失败的时候，Java虚拟机会报ClassCastException。为了避免向下转型出错，Java提供了instanceof操作符，可以先判断一个实例究竟是不是某种类型：Person p = new Person(); System.out.println(p instanceof Person); // true System.out.println(p instanceof Student); // false Student s = new Student(); System.out.println(s instanceof Person); // true System.out.println(s instanceof Student); // true Student n = null; System.out.println(n instanceof Student); // falseinstanceof实际上判断一个变量所指向的实例是否是指定类型，或者这个类型的子类。如果一个引用变量为null，那么对任何instanceof的判断都为false。利用instanceof，在向下转型前可以先判断：Person p = new Student(); if (p instanceof Student) { // 只有判断成功才会向下转型: Student s = (Student) p; // 一定会成功 }从Java 14开始，判断instanceof后，可以直接转型为指定变量，避免再次强制转型。例如，对于以下代码：Object obj = "hello"; if (obj instanceof String) { String s = (String) obj; System.out.println(s.toUpperCase()); }可以改写如下：// instanceof variable: public class Main { public static void main(String[] args) { Object obj = "hello"; if (obj instanceof String s) { // 可以直接使用变量s: System.out.println(s.toUpperCase()); } } }这种使用instanceof的写法更加简洁。4.多态-到底什么是多态？一个实际类型为Student，引用类型为Person的变量，调用其run()方法，调用的是Person还是Student的run()方法？Person p = new Student(); p.run(); 运行一下代码就可以知道，实际上调用的方法是Student的run()方法。因此可得出结论：Java的实例方法调用是基于运行时的实际类型的动态调用，而非变量的声明类型。这个非常重要的特性在面向对象编程中称之为多态。它的英文拼写非常复杂：Polymorphic。假设我们编写这样一个方法：public void runTwice(Person p) { p.run(); p.run(); }它传入的参数类型是Person，我们是无法知道传入的参数实际类型究竟是Person，还是Student，还是Person的其他子类例如Teacher，因此，也无法确定调用的是不是Person类定义的run()方法。你无法在编译时知道实际调用哪个类的 run() 方法，只有在运行时才能确定。这就是面向对象编程中多态的强大之处。多态具有一个非常强大的功能，就是允许添加更多类型的子类实现功能扩展，却不需要修改基于父类的代码。5.多态--覆写Object方法因为所有的class最终都继承自Object，而Object定义了几个重要的方法：toString()：把instance输出为String；equals()：判断两个instance是否逻辑相等；hashCode()：计算一个instance的哈希值。在必要的情况下，我们可以覆写Object的这几个方法。重写和重载：Override和Overload不同的是，如果方法签名不同，就是Overload，Overload方法是一个新方法；如果方法签名相同，并且返回值也相同，就是Override。6.抽象类--什么是抽象类？如果父类的方法本身不需要实现任何功能，仅仅是为了定义方法签名，目的是让子类去覆写它，那么，可以把父类的方法声明为抽象方法：class Person { public abstract void run(); }把一个方法声明为abstract，表示它是一个抽象方法，本身没有实现任何方法语句。因为这个抽象方法本身是无法执行的，所以，Person类也无法被实例化。编译器会告诉我们，无法编译Person类，因为它包含抽象方法。有抽象方法的类必须是抽象类。必须把Person类本身也声明为abstract，才能正确编译它：abstract class Person { public abstract void run(); }7.接口--到底什么是接口？如果一个抽象类没有字段，所有方法全部都是抽象方法：abstract class Person { public abstract void run(); public abstract String getName(); }就可以把该抽象类改写为接口：interface。在Java中，使用interface可以声明一个接口：interface Person { void run(); String getName(); }所谓interface，就是比抽象类还要抽象的纯抽象接口，因为它连字段都不能有。因为接口定义的所有方法默认都是public abstract的，所以这两个修饰符不需要写出来（写不写效果都一样）。因为interface是一个纯抽象类，所以它不能定义实例字段。但是，interface是可以有静态字段的，并且静态字段必须为final类型：public interface Person { public static final int MALE = 1; public static final int FEMALE = 2; }实际上，因为interface的字段只能是public static final类型，所以我们可以把这些修饰符都去掉，上述代码可以简写为：public interface Person { // 编译器会自动加上public static final: int MALE = 1; int FEMALE = 2; }编译器会自动把该字段变为public static final类型。8.包作用域位于同一个包的类，可以访问包作用域的字段和方法。不用public、protected、private修饰的字段和方法就是包作用域。例如，Person类定义在hello包下面：package hello; public class Person { // 包作用域: void hello() { System.out.println("Hello!"); } }Main类也定义在hello包下面：package hello; public class Main { public static void main(String[] args) { Person p = new Person(); p.hello(); // 可以调用，因为Main和Person在同一个包 } }9.包--编译和运行假设我们创建了如下的目录结构：work ├── bin └── src └── com └── itranswarp ├── sample │ └── Main.java └── world └── Person.java其中，bin目录用于存放编译后的class文件，src目录按包结构存放Java源码，我们怎么一次性编译这些Java源码呢？首先，确保当前目录是work目录，即存放src和bin的父目录：$ ls bin src然后，编译src目录下的所有Java文件：$ javac -d ./bin src/**/*.java命令行-d指定输出的class文件存放bin目录，后面的参数src/**/*.java表示src目录下的所有.java文件，包括任意深度的子目录。注意：Windows不支持**这种搜索全部子目录的做法，所以在Windows下编译必须依次列出所有.java文件：C:\work> javac -d bin src\com\itranswarp\sample\Main.java src\com\itranswarp\world\Persion.java使用Windows的PowerShell可以利用Get-ChildItem来列出指定目录下的所有.java文件：PS C:\work> (Get-ChildItem -Path .\src -Recurse -Filter *.java).FullName C:\work\src\com\itranswarp\sample\Main.java C:\work\src\com\itranswarp\world\Person.java因此，编译命令可写为：PS C:\work> javac -d .\bin (Get-ChildItem -Path .\src -Recurse -Filter *.java).FullName如果编译无误，则javac命令没有任何输出。可以在bin目录下看到如下class文件：bin └── com └── itranswarp ├── sample │ └── Main.class └── world └── Person.class现在，我们就可以直接运行class文件了。根据当前目录的位置确定classpath，例如，当前目录仍为work，则classpath为bin或者./bin：$ java -cp bin com.itranswarp.sample.Main Hello, world!10.内部类如果一个类定义在另一个类的内部，这个类就是Inner Class：class Outer { class Inner { // 定义了一个Inner Class } }上述定义的Outer是一个普通类，而Inner是一个Inner Class，它与普通类有个最大的不同，就是Inner Class的实例不能单独存在，必须依附于一个Outer Class的实例。示例代码如下：// inner class public class Main { public static void main(String[] args) { Outer outer = new Outer("Nested"); // 实例化一个Outer Outer.Inner inner = outer.new Inner(); // 实例化一个Inner inner.hello(); } } class Outer { private String name; Outer(String name) { this.name = name; } class Inner { void hello() { System.out.println("Hello, " + Outer.this.name); } } }观察上述代码，要实例化一个Inner，我们必须首先创建一个Outer的实例，然后，调用Outer实例的new来创建Inner实例：Outer.Inner inner = outer.new Inner();这是因为Inner Class除了有一个this指向它自己，还隐含地持有一个Outer Class实例，可以用Outer.this访问这个实例。所以，实例化一个Inner Class不能脱离Outer实例。Inner Class和普通Class相比，除了能引用Outer实例外，还有一个额外的“特权”，就是可以修改Outer Class的private字段，因为Inner Class的作用域在Outer Class内部，所以能访问Outer Class的private字段和方法。在Inner Class访问Outer的属性：Outer.this.name。观察Java编译器编译后的.class文件可以发现，Outer类被编译为Outer.class，而Inner类被编译为Outer$Inner.class。11.匿名内部类// Anonymous Class public class Main { public static void main(String[] args) { Outer outer = new Outer("Nested"); outer.asyncHello(); } } class Outer { private String name; Outer(String name) { this.name = name; } void asyncHello() { Runnable r = new Runnable() { @Override public void run() { System.out.println("Hello, " + Outer.this.name); } }; new Thread(r).start(); } }观察asyncHello()方法，我们在方法内部实例化了一个Runnable。Runnable本身是接口，接口是不能实例化的，所以这里实际上是定义了一个实现了Runnable接口的匿名类，并且通过new实例化该匿名类，然后转型为Runnable。在定义匿名类的时候就必须实例化它，定义匿名类的写法如下：Runnable r = new Runnable() { // 实现必要的抽象方法... };匿名类和Inner Class一样，可以访问Outer Class的private字段和方法。之所以我们要定义匿名类，是因为在这里我们通常不关心类名，比直接定义Inner Class可以少写很多代码。观察Java编译器编译后的.class文件可以发现，Outer类被编译为Outer.class，而匿名类被编译为Outer$1.class。如果有多个匿名类，Java编译器会将每个匿名类依次命名为Outer$1、Outer$2、Outer$3……用static修饰的内部类和Inner Class有很大的不同，它不再依附于Outer的实例，而是一个完全独立的类，因此无法引用Outer.this，但它可以访问Outer的private静态字段和静态方法。笔记所有内容摘取自廖雪峰java教程 https://liaoxuefeng.com/books/java/introduction/index.html