网络编程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
Java Web 基础：登录认证-会话技术、过滤器与拦截器、事务管理
登录认证-会话技术会话：用户打开浏览器，访问web服务器的资源，会话建立，直到有一方断开连接，会话结束。在一次会话中可以包含多次请求和响应。会话跟踪：一种维护浏览器状态的方法，服务器需要识别多次请求是否来自于同意浏览器，以便在同一次会话的多次请求间共享数据。会话跟踪方案：客户端会话跟踪技术：Cookie服务端会话跟踪技术：Session令牌技术Cookie浏览器响应头 Set-Cookie:name=value客户端请求头 Cookie:name=value优点：Http协议支持缺点：移动端App无法使用Cookie；不安全，客户可以自己禁用Cookie；不能跨域（跨域：客户端和服务端协议/ip/域名/端口不同都会跨域）；【无状态】Session底层基于Cookie优点：状态存储在服务端端的内存中，安全；【状态性，Session方案的核心优势在于“状态性”。服务器端有一个“指挥中心”，它知道哪些会话是活跃的、哪些是有效的。它可以随时吊销任何会话的访问权限。】缺点：服务器集群环境下无法直接使用Session；Cookie的缺点状态性就是，服务器有存储用户信息，用户只要一个token，无状态就是，用户自己存着信息，无论是否加密令牌技术优点：支持PC端和移动端；解决集群环境下的认证问题；减轻服务器端存储压力1.解决扩展性，实现集群和分布式【多服务端】使用令牌技术的服务器端不存储任何数据。传统Session存储在单个服务器的内存中，Session的旧解决方案：通过Session复制（性能开销大）或持久化到中央数据库（如Redis）（引入单点依赖和网络延迟）来缓解，但非根本解决。令牌的解决方案：采用无状态设计。服务器不存储会话数据，用户状态自包含在令牌中。任何服务器实例只需验证令牌有效性即可处理请求，天然支持分布式集群与水平扩展。2.解决架构耦合，实现多平台通用【多客户端】Session通常与特定的服务器端技术栈（如Java Servlet、PHP、.NET）紧密耦合。它依赖于Cookie来传递Session ID，这限制了客户端的类型。而令牌（尤其是JWT）是一个开放标准，任何语言、任何平台都能生成和验证。它不依赖Cookie，可以通过HTTP Header（如 Authorization: Bearer <token>）轻松传递，这使得它适用于：原生手机App；API网关；不同域名的前端应用（解决跨域）；第三方登录（OAuth 2.0的核心）：你可以用一个令牌授权一个第三方应用访问你的部分资源，而无需提供用户名和密码3.空间换时间，解决查询性能问题【快】传统的session，即使使用Redis，每个请求都需要根据Session ID执行一次网络查询来获取用户数据。在高并发场景下，这会给中央存储带来巨大的压力和单点故障风险。而令牌技术：用户信息（如user_id, username）直接编码在令牌里，验证成功后即可直接使用，无需查询数据库。这用更大的网络传输量（令牌体积比Session ID大）换取了更低的服务器端延迟和负载。JWT不是一个简单的“加密Cookie”，而是一种更安全、更标准、更通用的无状态凭证机制。特性加密的Cookie（自研方案）JWT令牌（标准方案）1. 安全核心保密性。依赖加密算法隐藏内容。如果密钥泄露，一切皆空。完整性与验证。依赖数字签名证明真伪。Payload可公开读取，但无法篡改。签名密钥泄露是灾难性的，但内容本身不依赖加密。2. 标准化自定义的、私有的。格式和加解密方法由开发者自己定义。开放的、标准的。遵循RFC 7519标准，有固定的结构（Header.Payload.Signature），各语言都有现成库，互通性好。3. 验证方式服务器必须解密整个Cookie才能验证其有效性。服务器只需验证签名（一个数学计算），而不需要先解密整个令牌。验证通过后，可直接解码读取Payload。4. 内容可信度服务器解密后，直接信任其中的内容。服务器验证签名后，才信任其中的内容。这明确区分了“验证”和“信任”两个步骤。5. 适用边界通常与Web域名绑定，通过Cookie机制管理。协议无关、域无关。可用于任何类型的客户端（App、桌面应用）、任何API，不受同源策略限制。过滤器与拦截器1. 过滤器示例：记录请求耗时和字符编码// 使用 @WebFilter 注解或通过 FilterRegistrationBean 配置 @Component public class LoggingFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest httpRequest = (HttpServletRequest) request; long startTime = System.currentTimeMillis(); // 1. 前置处理：设置编码 request.setCharacterEncoding("UTF-8"); response.setCharacterEncoding("UTF-8"); System.out.println("Filter: 请求URI - " + httpRequest.getRequestURI()); // 2. 放行 chain.doFilter(request, response); // 3. 后置处理：记录耗时 long endTime = System.currentTimeMillis(); System.out.println("Filter: 请求处理耗时 - " + (endTime - startTime) + "ms"); } }2. 拦截器示例：登录验证@Component public class AuthInterceptor implements HandlerInterceptor { // 可以直接注入Spring管理的Bean @Autowired private UserService userService; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 在Controller执行之前 String token = request.getHeader("Authorization"); if (token != null && userService.validateToken(token)) { // 验证通过，放行 return true; } else { // 验证失败，直接返回401未授权，不再执行Controller response.setStatus(HttpStatus.UNAUTHORIZED.value()); return false; } } @Override public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception { // 在Controller执行之后，视图渲染之前 // 可以对ModelAndView进行修改 System.out.println("Interceptor: Controller执行完毕"); } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { // 在整个请求结束之后，视图渲染完毕之后 // 常用于资源清理、异常日志记录等 if (ex != null) { System.err.println("Interceptor: 请求处理发生异常 - " + ex.getMessage()); } } } // 配置拦截器，需要实现 WebMvcConfigurer @Configuration public class WebConfig implements WebMvcConfigurer { @Autowired private AuthInterceptor authInterceptor; @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(authInterceptor) .addPathPatterns("/api/**") // 拦截/api开头的路径 .excludePathPatterns("/api/login"); // 排除登录接口 } }事务管理事务回滚@Transaction默认情况下，只有出现RuntimeException才回滚异常，rollbackFor属性用于控制出现何种异常时回滚事务。@Transaction(rollbackFor = Exception.class) @Override public void delete(Integer id) throws Exception { deptMapper.deleteById(id); if (true) throw new Exception("error!"); empMapper.deleteByDeptId(id); }事务属性-传播行为根据propagation属性确定传播行为@Transactional(propagation = Propagation.REQUIRED)REQUIRED：（默认）需要事务，有则加入，无则创建新事务REQUIRES_NEW：需要新事务，无论有无，总是创建新事务eg. 需求:解散部门时，无论是成功还是失败，都要记录操作日志。步骤:①.解散部门:删除部门、删除部门下的员工②.记录日志到数据库表中@Transactional @Override public void delete(Integer id) { try { deptMapper.deleteById(id); //删除部门 int i = 1/0; empMapper.deleteByDeptId(id); //删除部门下的员工 } finally { DeptLog log = new DeptLog(); log.setCreateTime(LocalDateTime.now()); log.setDescription("执行了解散部门操作,此次解散的是"+id+"号部门"); deptLogService.insert(log); //记录操作日志 } } @Transactional(propagation = Propagation.REQUIRES_NEW) @Override public void insert(DeptLog deptLog) { deptLogMapper.insert(deptLog); }这个insert方法如果不设置为Propagation.REQUIRES_NEW，在delete失败时执行finally时事务回滚（insert事务也加入了delete事务），导致insert也被回滚，导致记录日志失败。REQUIRED：大部分情况下都是用该传播行为即可。REQUIRES_NEW：当我们不希望事务之间相互影响时，可以使用该传播行为。比如：下订单前需要记录日志，不论订单保存成功与否，都需要保证日志记录能够记录成功。
读写分离1.简介定义：读写分离的本质将数据库的两种核心操作 “拆分” 到不同节点：写操作（INSERT/UPDATE/DELETE）：统一交给 “主库（Master）” 处理，保证数据写入的一致性；读操作（SELECT）：交给 “从库（Slave）” 处理，从库通过 “数据同步” 复制主库的内容，确保读数据的准确性。简单理解：主库负责 “改数据”，从库负责 “查数据”，就像餐厅里 “厨师（主库）只负责做菜，服务员（从库）负责传菜”，分工明确效率更高。为什么必须做读写分离？单数据库的瓶颈很明显：单库的 CPU、内存、IO 资源有限，大量读请求会占满 IO，导致写请求排队（比如用户下单 “写订单” 时，因大量 “查商品” 请求阻塞，出现 “下单失败”）；一旦单库宕机，整个业务直接瘫痪，可用性极低。而读写分离能解决这些问题：提升性能：读操作分散到多个从库，主库只处理写操作，资源不抢占，响应速度更快；提高可用性：即使一个从库宕机，其他从库仍能提供读服务，不会影响业务；扩展灵活：后续可通过增加从库数量，进一步分担读压力（比如从 1 个从库扩展到 3 个）。2.基础架构：一主一从的搭建与实现“一主一从” 是读写分离的最小可用架构——1 个主库 + 1 个从库，适合中小型 “读多写少” 业务（如初创公司的 APP、小型电商网站），配置简单、成本低，是入门读写分离的首选。在搭建主从同步前，必须先完成 “环境一致性” 配置，否则后续同步会直接失败。以 MySQL 为例，我们需要 2 台服务器（或 2 个 MySQL 实例），假设配置如下：节点角色IP 地址核心配置要求主库（Master）192.168.1.10开启 binlog 日志、指定唯一 server-id从库（Slave）192.168.1.11指定与主库不同的唯一 server-id① 修改主库配置文件（my.cnf）打开 /etc/my.cnf，添加以下内容（开启 binlog 是主从同步的核心 —— 主库通过 binlog 记录写操作，从库读取 binlog 实现同步）：[mysqld] server-id=10 # 唯一标识，不能与从库重复 log-bin=mysql-bin # 开启binlog，日志文件前缀为mysql-bin binlog-do-db=test # 只同步test数据库（可选，不写则同步所有库）修改后重启 MySQL：systemctl restart mysqld。② 主库创建同步账号从库需要通过一个 “专用账号” 连接主库并读取 binlog，因此在主库执行 SQL：-- 创建账号slave_user，允许从库IP（192.168.1.11）连接 CREATE USER 'slave_user'@'192.168.1.11' IDENTIFIED BY '123456'; -- 授予同步权限（REPLICATION SLAVE是核心权限） GRANT REPLICATION SLAVE ON *.* TO 'slave_user'@'192.168.1.11'; -- 刷新权限 FLUSH PRIVILEGES;③ 查看主库 binlog 信息记录主库当前的 binlog 文件名和位置（后续从库同步需要用到）：SHOW MASTER STATUS;执行后会得到类似结果：FilePositionBinlog_Do_DBBinlog_Ignore_DBmysql-bin.000001156test ④ 修改从库配置文件（my.cnf）打开 /etc/my.cnf，添加：[mysqld] server-id=11 # 必须与主库不同 relay-log=mysql-relay-bin # 开启中继日志（从库读取主库binlog后，先存到中继日志，再执行）重启从库 MySQL：systemctl restart mysqld。环境准备完成后，我们需要实现 “主从数据同步”，再配置 “读写路由”—— 让写操作走主库，读操作走从库。第一步：实现主从同步在从库执行以下 SQL，连接主库并开启同步：-- 停止现有同步（首次配置可忽略） STOP SLAVE; -- 连接主库：替换IP、账号、密码、binlog文件名和位置 CHANGE MASTER TO MASTER_HOST='192.168.1.10', # 主库IP MASTER_USER='slave_user', # 同步账号 MASTER_PASSWORD='123456', # 密码 MASTER_LOG_FILE='mysql-bin.000001', # 主库的binlog文件名（上文记录的File） MASTER_LOG_POS=156; # 主库的binlog位置（上文记录的Position） -- 开启同步 START SLAVE;第二步：验证同步是否成功在从库执行以下 SQL，查看同步状态：SHOW SLAVE STATUS\G; # \G表示按行显示，更清晰核心看两个参数，必须都是 Yes：Slave_IO_Running: Yes（从库 IO 线程正常，能读取主库 binlog）；Slave_SQL_Running: Yes（从库 SQL 线程正常，能执行中继日志的 SQL）。如果是 No，常见原因：主从网络不通（检查防火墙，开放 3306 端口）；同步账号密码错误；binlog 文件名或位置填错。第三步：实现读写分离主从同步成功后，数据已经能从主库同步到从库，接下来要让 “写走主库，读走从库”，有两种常见方案：方案实现逻辑优点缺点适用场景应用层拆分在代码中判断 SQL 类型：- 写操作（INSERT/UPDATE/DELETE）→ 连接主库；- 读操作（SELECT）→ 连接从库无需额外组件，成本低代码耦合度高，后续修改麻烦（比如增加从库需改代码）小型项目、快速验证场景中间件拆分部署中间件（如 MyCat、Sharding-JDBC），应用只连接中间件，由中间件自动路由解耦（应用无需关心主从），易扩展需要额外维护中间件中大型项目、生产环境应用层拆分的例子：// 主库数据源（写操作） DataSource masterDataSource = createDataSource("192.168.1.10", "root", "123456"); // 从库数据源（读操作） DataSource slaveDataSource = createDataSource("192.168.1.11", "root", "123456"); // 判断SQL类型，选择数据源 String sql = "SELECT * FROM user"; DataSource dataSource; if (sql.startsWith("SELECT")) { dataSource = slaveDataSource; // 读操作走从库 } else { dataSource = masterDataSource; // 写操作走主库 }3.进阶架构：双主双从的高可用实现“一主一从” 有个致命问题：主库单点故障—— 如果主库宕机，所有写操作（如用户注册、下单）都会失败，业务直接中断。而 “双主双从” 通过 “两个主库互为主从、两个从库分担读压力”，解决了单点故障，是企业级生产环境的首选架构。双主双从：架构原理架构组成：2 个主库（Master1、Master2）+ 2 个从库（Slave1、Slave2），核心逻辑：双主互备：Master1 和 Master2 互为主从 ——Master1 的写操作同步到 Master2，Master2 的写操作同步到 Master1，确保两个主库数据一致；从库分担读压力：Slave1 同步 Master1 的数据，Slave2 同步 Master2 的数据，所有读操作路由到 Slave1 和 Slave2；写操作负载均衡：两个主库都能处理写操作（需避免数据冲突），分摊写压力。简单说：双主双从实现了 “写操作无单点、读操作可扩展”，即使一个主库宕机，另一个主库能立刻接管，业务不中断。核心痛点：解决数据冲突双主架构中，两个主库都能写数据，最容易出现 “自增 ID 冲突”—— 比如 Master1 插入一条数据 ID=1，Master2 也插入一条数据 ID=1，同步时会因 ID 重复报错。解决方案：配置主库自增步长与起始值，让两个主库的自增 ID “不重叠”：Master1：自增步长 = 2，起始值 = 1（生成 ID：1、3、5、7...）；Master2：自增步长 = 2，起始值 = 2（生成 ID：2、4、6、8...）。配置方式（修改主库 my.cnf）：# Master1的my.cnf auto-increment-increment=2 # 步长=2 auto-increment-offset=1 # 起始值=1 # Master2的my.cnf auto-increment-increment=2 # 步长=2 auto-increment-offset=2 # 起始值=2双主双从：搭建与读写分离假设 4 个节点的 IP 和角色如下：节点角色IP 地址server-id核心配置（my.cnf）Master1192.168.1.1010开启 binlog、自增步长 2 / 起始 1Master2192.168.1.1212开启 binlog、自增步长 2 / 起始 2Slave1192.168.1.1111开启中继日志Slave2192.168.1.1313开启中继日志第一步：配置双主互同步先让 Master1 和 Master2 互为主从：Master1→Master2 同步：在 Master2 执行 CHANGE MASTER TO，连接 Master1（步骤同 “一主一从”，需先在 Master1 创建同步账号）；Master2→Master1 同步：在 Master1 执行 CHANGE MASTER TO，连接 Master2（需先在 Master2 创建同步账号）；分别在两个主库执行START SLAVE，并通过SHOW SLAVE STATUS\G验证Slave_IO_Running和Slave_SQL_Running均为 Yes。第二步：配置从库同步让 Slave1 同步 Master1，Slave2 同步 Master2：在 Slave1 执行 CHANGE MASTER TO，连接 Master1，开启同步；在 Slave2 执行 CHANGE MASTER TO，连接 Master2，开启同步；同样验证同步状态为 Yes。第三步：实现读写分离与高可用切换双主双从的读写分离必须依赖中间件（如 MyCat），核心实现两点：读写路由：写操作：轮询路由到 Master1 和 Master2（分摊写压力）；读操作：轮询路由到 Slave1 和 Slave2（分摊读压力）。高可用切换：手动切换：若 Master1 宕机，在中间件中禁用 Master1，写操作全部路由到 Master2；自动切换（生产推荐）：给双主配置Keepalived，设置一个 “虚拟 IP（VIP）”—— 应用通过 VIP 连接主库，当 Master1 宕机，Keepalived 自动将 VIP 漂移到 Master2，应用无需修改任何配置，实现 “无缝切换”。
分库分表当数据库满足以下任一条件时，就必须考虑分库分表：数据量过大：单表数据量超 1000 万（MySQL 单表最优性能区间是 100 万 - 500 万），查询时 “全表扫描” 耗时超 1 秒，甚至出现 “锁表” 导致写入阻塞；访问压力过高：单库 QPS 超 5000（MySQL 单库默认能承载的 QPS 约 2000-3000），高峰时出现 “连接耗尽”，应用无法正常访问；业务耦合严重：单库中包含 “用户、订单、商品” 等所有业务表，一次表结构变更（如订单表加字段）会影响所有业务，运维风险高。1.分库分表的核心拆分方式（1）垂直拆分：按 “业务维度” 拆拆分逻辑：把单库中不同业务的表，拆到不同的数据库中。示例：电商单库拆成 3 个独立库：用户库（user_db）：存储用户表（user）、用户地址表（user_address）；订单库（order_db）：存储订单表（order）、订单详情表（order_item）；商品库（product_db）：存储商品表（product）、商品分类表（product_category）。解决问题：降低业务耦合：用户业务变更（如加 “会员等级” 字段），不会影响订单库；分散存储压力：订单库的写入压力（每秒 1000 单），不会占用用户库的磁盘和 IO。注意：垂直拆分后，跨库查询会变复杂（如 “查用户的所有订单” 需关联 user_db 和 order_db），需依赖中间件（如 MyCat）实现 “虚拟关联”。（2）水平拆分：按 “数据维度” 拆拆分逻辑：把单张 “大表”，按某一字段（如用户 ID、时间）拆成多张 “小表”，这些小表结构完全相同，数据分散存储。示例：订单表（order）按 “用户 ID 取模” 拆成 10 张表：order_0：存储用户 ID%10=0 的数据；order_1：存储用户 ID%10=1 的数据；...order_9：存储用户 ID%10=9 的数据。解决问题：提升单表效率：原 1 亿数据的订单表，拆后每张表仅 1000 万数据，查询耗时从 1 秒降到 100ms；支持弹性扩容：后续数据量增长，可新增 order_10、order_11，无需重构表结构。注意：水平拆分后，“全局查询”（如 “查昨天所有订单总数”）需聚合所有分表数据，需中间件支持 “分布式统计”。(3)实际场景：垂直 + 水平结合大多数业务会用 “混合拆分”：先垂直拆库，再对核心表水平拆表。示例：电商系统的最终拆分方案：垂直拆库：拆成 user_db、order_db、product_db；水平拆表：对 order_db 中的 order 表，按 “用户 ID 取模” 拆成 order_0~order_9，分散到 2 台数据库服务器（每台存 5 张表）。2.分库分表中间件-MyCat分库分表后，应用不能直接连接多个后端库（否则需改代码适配路由逻辑）——MyCat就是解决这个问题的 “分布式数据库中间件”：它对外伪装成 “单台 MySQL”，接收应用请求后，自动路由到后端真实分库分表，对应用完全透明。概念定义示例（对应电商拆分场景）逻辑库（Schema）MyCat 对外暴露的 “虚拟库”，应用连接的是逻辑库，而非真实后端库逻辑库名：ecommerce（对应后端 3 个真实库）逻辑表（Table）MyCat 对外暴露的 “虚拟表”，对应后端多个 “分表”（水平拆分）或 “单表”（垂直拆分）逻辑表order：对应后端 order_db 的 order_0~order_9数据节点（DataNode）后端 “真实库 + 表” 的映射，是 MyCat 路由的 “最终目标”dn1=localhost:3306/order_db（order_db 库的节点）节点主机（DataHost）后端数据库服务器的配置，包含 IP、端口、用户名、密码，可对应多个 DataNodedataHost="db_host1"：IP=192.168.1.100，端口 33063.8种核心分片规则详解(1)范围分片核心逻辑：按分片字段的 “数值范围” 划分分片，例如按user_id划分：user_id 1~10000 → 路由到order_0；user_id 10001~20000 → 路由到order_1；user_id 20001~30000 → 路由到order_2。配置关键：在rule.xml中通过PartitionByRange算法定义范围映射，示例：<tableRule name="range-user-id"> <rule> <columns>user_id</columns> <algorithm>range-alg</algorithm> </rule> </tableRule> <function name="range-alg" class="io.mycat.route.function.PartitionByRange"> <property name="mapFile">range-map.properties</property> </function>在range-map.properties中定义范围：10000=0 # user_id≤10000 → 分片0 20000=1 # 10000<user_id≤20000 → 分片1 30000=2 # 20000<user_id≤30000 → 分片2适用场景：分片字段为 “连续增长数值”（如user_id、order_id），或 “时间字段”（如按天 / 按月拆分日志表）；优势是 “扩容方便”（新增分片只需添加更大范围，无需迁移历史数据）。注意：避免 “热点分片”（如最新时间范围的分表写入压力过大）。(3)取模分片核心逻辑：按分片字段的 “数值取模” 结果划分分片，公式为分片索引 = 字段值 % 分片数量。示例：分片数量 = 3，user_id=100→ 100%3=1 → 路由到分片 1。配置关键：通过PartitionByMod算法定义 “分片数量”，参考前文 “水平分表实操” 中的配置。适用场景：分片字段为 “均匀分布的数值”（如user_id），需 “数据均匀分散到各分表” 的场景（如用户数据、订单数据）；优势是 “数据分布均衡”，查询时能精准定位单个分片。注意：扩容时需 “迁移大量数据”（如分片数量从 3→4，原取模结果会变化），需配合 “一致性 hash” 优化。(3)一致性 Hash 算法核心逻辑：基于 “Hash 环” 实现分片：将 “分片节点”（如 order_0、order_1）的 Hash 值映射到一个 32 位的 Hash 环上；计算 “分片字段值”（如user_id=100）的 Hash 值，在 Hash 环上顺时针查找最近的分片节点，即为目标分片。（为避免 “数据倾斜”，会添加 “虚拟节点”—— 每个真实分片对应多个虚拟节点，均匀分布在 Hash 环上）配置关键：通过PartitionByConsistentHash算法配置，示例：<function name="consistent-hash" class="io.mycat.route.function.PartitionByConsistentHash"> <property name="partitionCount">3</property> <property name="virtualBucketTimes">10</property> </function>适用场景：需 “动态扩容” 的场景（如业务增长快，需频繁新增分表 / 分库）；优势是 “扩容时数据迁移量少”（新增分片仅需迁移 Hash 环上相邻的部分数据）。(3)枚举分片核心逻辑：按分片字段的 “固定枚举值” 划分分片，例如按 “省份” 拆分：province=北京/天津/河北 → 路由到order_0；province=上海/江苏/浙江 → 路由到order_1；province=广东/广西/福建 → 路由到order_2。配置关键：通过PartitionByFileMap算法，在配置文件中定义 “枚举值→分片” 映射，示例：<function name="enum-province" class="io.mycat.route.function.PartitionByFileMap"> <property name="mapFile">enum-province.properties</property> <property name="defaultNode">0</property> </function>在enum-province.properties中定义：北京=0,天津=0,河北=0 上海=1,江苏=1,浙江=1 广东=2,广西=2,福建=2适用场景：分片字段的 “枚举值固定且数量少”（如省份、用户等级、订单状态）；优势是 “查询时可精准定位分片”（如查 “北京的订单” 直接路由到 order_0）。(5)应用指定算法核心逻辑：由应用层决定数据路由到哪个分片—— 应用在执行 SQL 时，通过/*!mycat:dataNode=dn1*/语法指定目标分片，示例：-- 应用指定数据写入dn1（对应order_0） insert /*!mycat:dataNode=dn1*/ into order(id, user_id) values(4, 103);配置关键：在schema.xml中给逻辑表指定rule="sharding-by-subtable-interval"（或自定义应用指定规则），无需在rule.xml中额外配置算法。适用场景：应用层需 “自定义路由逻辑” 的场景（如按 “业务线” 拆分，A 业务线数据存分片 0，B 业务线存分片 1）；优势是 “灵活性高”，路由逻辑完全由应用控制。注意：需 “应用层与 MyCat 强耦合”，若应用多，需统一路由逻辑，否则易出错。(6)固定 Hash 算法核心逻辑：对分片字段（如user_id）做 “固定 Hash 计算”（如 CRC32 Hash），再将 Hash 结果映射到指定分片数量，公式为分片索引 = Hash(字段值) % 分片数量。与 “取模分片” 的区别：取模分片直接用字段值取模，固定 Hash 用字段的 Hash 值取模，能避免 “字段值连续导致的分片热点”。配置关键：通过PartitionByFixedHash算法配置，示例：<function name="fixed-hash" class="io.mycat.route.function.PartitionByFixedHash"> <property name="partitionCount">3</property> <property name="hashSeed">1024</property> </function>适用场景：分片字段为 “非连续数值” 或 “字符串”（如order_no），需 “数据均匀分布” 且 “避免热点” 的场景；优势是 “数据分布比普通取模更均匀”。(7)字符串 Hash 解析核心逻辑：对 “字符串类型的分片字段”（如手机号、订单号）做 Hash 处理，再映射到分片：方式 1：截取字符串的 “部分字符”（如手机号后 4 位），转成数值后取模；方式 2：对整个字符串做 Hash 计算（如 MD5），取 Hash 结果的部分数值取模。配置关键：通过PartitionByString算法配置，示例（截取手机号后 4 位取模）：<function name="str-hash-phone" class="io.mycat.route.function.PartitionByString"> <property name="length">4</property> <property name="count">3</property> <property name="hashSlice"> -4:-1 </property> </function>适用场景：分片字段为 “字符串”（如手机号、用户唯一标识uuid）；优势是 “支持非数值型分片字段”，适配更多业务场景。(8)按天 / 自然月分片核心逻辑：按 “时间字段”（如create_time）的 “天 / 月” 划分分片，自动生成对应分表名：按天分片：create_time=2024-05-01 → 路由到order_20240501；按自然月分片：create_time=2024-05-15 → 路由到order_202405。配置关键：按天分片：用PartitionByDay算法，示例：<function name="sharding-by-day" class="io.mycat.route.function.PartitionByDay"> <property name="dateFormat">yyyyMMdd</property> <property name="sBeginDate">20240501</property> </function>按自然月分片：用PartitionByMonth算法，仅需将dateFormat改为yyyyMM。适用场景：时间序列数据（如日志表、流水表、订单表），需 “按时间归档” 的场景；优势是 “查询时可按时间范围快速定位分片”（如查 “2024 年 5 月的订单” 直接路由到order_202405），且历史数据可单独迁移 / 删除。4.MyCat 管理与监控(1)MyCat 管理原理与基础命令MyCat 提供 “管理端口（默认 9066）”，用于执行管理命令（区别于服务端口 8066，后者用于接收 SQL 请求）。连接管理端口# 格式：mysql -h MyCatIP -P 9066 -u 用户名 -p 密码（用户名/密码在server.xml中配置） mysql -h 192.168.1.101 -P 9066 -u mycat_user -p123456常用管理命令命令功能描述示例show dataNode查看所有数据节点状态（是否在线）show dataNode;show schema查看所有逻辑库配置show schema;show table order查看逻辑表 order 的分片配置（分表、规则）show table order;reload config热加载配置文件（修改 schema.xml/rule.xml 后无需重启 MyCat）reload config;show @@sql查看最近执行的 SQL 语句（用于排查慢 SQL）show @@sql;show @@connection查看当前 MyCat 的连接数（避免连接耗尽）show @@connection;(2)MyCat 管理工具除了命令行，还可通过 “可视化工具” 更直观地管理 MyCat，常用工具包括：MyCat Web定位：MyCat 官方提供的可视化管理平台，支持配置管理、分片监控、SQL 统计。核心功能配置管理：在线编辑schema.xml/rule.xml，支持热加载；分片监控：查看各数据节点的连接数、SQL 执行量、错误率；慢 SQL 分析：记录执行时间超阈值的 SQL，展示路由分片信息。部署方式：需单独部署 MyCat Web 服务，关联 MyCat 的管理端口，通过浏览器访问（默认端口 8082）。第三方工具Navicat/DBeaver：通过 “MySQL 连接”（端口 8066）连接 MyCat，可视化执行 SQL、查看逻辑表结构；Prometheus+Grafana：通过 MyCat 的 “监控指标接口” 采集数据，自定义监控面板（后续 “监控” 部分详细讲）。(3)MyCat 监控实战监控的核心目标是 “提前发现问题”（如连接耗尽、分片延迟、SQL 错误率飙升），需重点监控以下指标：核心监控指标指标类别关键指标正常范围告警阈值连接状态总连接数、活跃连接数、空闲连接数活跃连接数 < 总连接数 80%活跃连接数 > 总连接数 90%SQL 执行SQL 执行成功率、慢 SQL 数量、SQL 执行耗时成功率 = 100%，慢 SQL=0成功率 <99%，慢 SQL>10 条分片状态分片路由成功率、分片延迟（SQL 转发耗时）路由成功率 = 100%路由成功率 <99%，延迟> 500ms后端数据库后端库连接数、后端库 SQL 执行错误率错误率 = 0错误率 > 0.1%基于 Prometheus+Grafana 的监控部署这是企业级常用的监控方案，步骤如下：部署 Prometheus：下载 Prometheus，配置prometheus.yml，添加 MyCat 的监控目标（MyCat 需开启 “Prometheus 指标暴露”，通过server.xml配置prometheusPort=9100）：scrape_configs: - job_name: 'mycat' static_configs: - targets: ['192.168.1.101:9100'] # MyCat的Prometheus端口部署 Grafana：下载并启动 Grafana（默认端口 3000），通过浏览器访问并登录（初始账号 admin/admin）；在 Grafana 中添加 “Prometheus 数据源”：配置数据源类型为 Prometheus，填写 Prometheus 地址（如http://192.168.1.102:9090）；导入 MyCat 监控面板：在 Grafana 中搜索 “MyCat” 相关的开源面板（如面板 ID：12345，具体可在 Grafana 官网查找），或自定义面板，添加上述核心监控指标的图表（如 “活跃连接数趋势图”“SQL 执行成功率饼图”）。配置告警规则：在 Prometheus 中定义告警规则（如alert.rules.yml），示例 “活跃连接数过高” 的告警：groups: - name: mycat_alerts rules: - alert: MyCatHighActiveConnections expr: mycat_active_connections / mycat_total_connections > 0.9 for: 5m labels: severity: warning annotations: summary: "MyCat活跃连接数过高" description: "MyCat当前活跃连接数{{ $value }}，已超过总连接数的90%，持续5分钟"在 Grafana 中配置告警渠道（如邮件、企业微信、钉钉），当指标触发阈值时，自动发送告警通知。MyCat 自带监控工具若无需复杂监控，可使用 MyCat 自带的轻量监控：mycat-monitor.sh脚本：位于 MyCat 的bin目录下，执行后可实时查看 MyCat 的连接数、SQL 执行量等基础指标；日志监控：MyCat 的日志文件（位于logs目录，如mycat.log、sql.log）记录了 SQL 执行详情、错误信息，可通过tail -f mycat.log实时查看，或结合 ELK（Elasticsearch+Logstash+Kibana）进行日志聚合分析。
主从复制当单台 MySQL 服务器无法满足业务需求（如读请求量过大、担心单点故障导致数据丢失）时，主从复制 是最基础的解决方案。其核心逻辑是：主库（Master）写入数据，从库（Slave）实时同步主库数据，实现 “数据备份”“读写分离”（主库写、从库读）“故障转移”（主库挂了，从库可切换为主库）。1.核心概念角色定义：主库（Master）：唯一的写入节点，所有数据变更（DML/DDL）均在主库执行，同时将变更记录到 二进制日志。从库（Slave）：1 个或多个读取节点，通过 “IO 线程” 从主库拉取二进制日志，通过 “SQL 线程” 执行日志中的变更，实现与主库数据一致。核心目标：数据备份：从库相当于主库的实时备份，主库故障时可从从库恢复数据。读写分离：主库负责写操作（INSERT/UPDATE/DELETE），从库负责读操作（SELECT），分摊主库压力，提升并发能力。故障转移：主库宕机后，可将其中一个从库升级为主库，保证业务连续性。2.原理主从复制的本质是 “从库模拟主库的操作”，整个过程通过 3 个线程（主库 1 个、从库 2 个）协同完成，步骤如下：主库记录二进制日志（binlog）当主库执行数据变更操作（如 INSERT）时，会先将操作记录到 二进制日志（binlog）中，同时给每条记录分配一个 “偏移量（Position）”，标记日志的位置。从库 IO 线程拉取二进制日志从库启动后，会启动一个 IO 线程，该线程会连接主库的 “二进制日志 dump 线程”（主库自动创建），并请求主库发送 “从指定偏移量开始的二进制日志”。主库的 dump 线程收到请求后，会读取二进制日志内容，发送给从库的 IO 线程；从库 IO 线程将收到的日志内容写入本地的 中继日志（Relay Log）（中继日志是临时日志，避免直接操作主库日志导致冲突）。从库 SQL 线程执行中继日志从库同时启动一个 SQL 线程，该线程会实时读取中继日志中的内容，解析出具体的 SQL 语句，并在从库上执行这些语句，最终实现从库数据与主库一致。关键结论：主从复制存在 微小延迟（IO 线程拉取日志、SQL 线程执行日志的时间差），通常在毫秒到秒级，大部分业务可接受；若需 “无延迟同步”，需使用 MySQL 8.0 后的 “GTID 复制” 或 “半同步复制”（进阶内容）。3.常见问题(1)主从延迟过大主库执行数据变更后，从库需要较长时间（如几秒、几分钟）才能查询到变更结果。常见原因从库压力过大（大量读请求占用 CPU/IO，导致 SQL 线程执行缓慢）。主库写入量过高（短时间内大量 DML 操作，二进制日志生成速度超过从库同步速度）。大事务（主库执行耗时较长的大事务，如批量插入 10 万条数据，从库需等待整个事务执行完成才能同步）。解决方案优化从库：给从库配置更高性能的硬件（CPU、SSD），或增加从库数量（通过 “一主多从” 分摊读压力）。拆分大事务：将主库的大事务拆分为多个小事务，减少单次同步耗时。启用并行复制（MySQL 5.7+）：通过 slave_parallel_workers = 4（配置 4 个并行 SQL 线程），让从库可并行执行多个事务，提升同步效率（需配合 binlog_format=ROW）。(2)主从数据不一致主库有某条数据，从库没有；或主从数据字段值不同，导致 Slave_SQL_Running: No。常见原因从库开启了写操作（未设置 read_only=ON，误在从库插入 / 修改数据）。主库执行了从库不支持的 SQL（如主库使用自定义函数，从库未创建该函数）。主从复制中断后未正确恢复（如主库日志文件被删除，从库无法找到同步起点）。解决方案紧急恢复，通过主库全量备份初始化从库（推荐使用 mysqldump 工具）主库执行备份：mysqldump -u root -p --all-databases --lock-all-tables > master_backup.sql（--lock-all-tables 确保备份期间数据不变化）。复制备份文件到从库：scp master_backup.sql root@192.168.1.101:/tmp/。从库恢复备份：mysql -u root -p < /tmp/master_backup.sql，之后重新执行 CHANGE MASTER TO 关联主库最新的日志位置。长期预防从库严格设置 read_only=ON，并限制超级权限用户的使用（避免通过 root 账号在从库写数据）。定期使用工具检查数据一致性（如 pt-table-checksum，自动对比主从表数据，发现不一致时生成修复语句）。(3)主库二进制日志丢失主库二进制日志文件被误删除（或因磁盘故障损坏），导致从库 IO 线程无法拉取日志，同步中断。解决方案若主库有全量备份：先通过备份恢复主库数据，然后重新配置主从复制（从备份对应的日志位置开始同步）。若主库无备份：需先确保主从数据一致（手动对比主从关键表数据），然后在主库执行 reset master;（重置二进制日志，生成新的 mysql-bin.000001），再在从库重新执行 CHANGE MASTER TO 关联新的日志文件和位置（注意：reset master; 会清空主库所有二进制日志，仅在无备份且数据一致时使用）。4.进阶优化基础主从复制仅能满足 “数据同步” 需求，生产环境需结合以下优化提升稳定性和可用性：(1)启用 GTID 复制（MySQL 5.6+）问题背景：传统复制依赖 “日志文件 + 偏移量” 定位同步位置，若主库日志文件丢失或切换，从库需重新手动查找同步起点，操作复杂且易出错。GTID 原理：GTID（Global Transaction ID）是全局唯一的事务 ID，每个事务在主库执行时会被分配一个 GTID，从库通过 GTID 定位同步位置，无需关注具体的日志文件和偏移量。配置方法：在主库和从库的 my.cnf 中添加以下配置，重启服务后按基础步骤配置复制即可：[mysqld] gtid_mode=ON enforce_gtid_consistency=ON # 强制 GTID 一致性，避免非 GTID 事务(2)启用半同步复制（MySQL 5.5+，需安装插件）问题背景：传统复制是 “异步复制”—— 主库执行事务后立即返回结果给客户端，不等待从库同步，若主库宕机时从库未同步完事务，会导致数据丢失。半同步复制原理：主库执行事务后，会等待至少一个从库将事务日志写入中继日志（并返回确认），再返回结果给客户端，确保 “主库宕机时，至少有一个从库拥有完整数据”，降低数据丢失风险。配置方法：主库安装并启用半同步插件：INSTALL PLUGIN rpl_semi_sync_master SONAME 'semisync_master.so'; SET GLOBAL rpl_semi_sync_master_enabled = 1; SET GLOBAL rpl_semi_sync_master_timeout = 1000; # 超时时间（1秒），若超过1秒未收到从库确认，自动降级为异步复制从库安装并启用半同步插件：INSTALL PLUGIN rpl_semi_sync_slave SONAME 'semisync_slave.so'; SET GLOBAL rpl_semi_sync_slave_enabled = 1; STOP SLAVE IO_THREAD; START SLAVE IO_THREAD; # 重启 IO 线程使插件生效
日志所有日志的配置均通过 my.cnf（Linux） 或 my.ini（Windows） 配置文件生效，修改后需 重启 MySQL 服务 才能生效（部分日志支持动态开启）。通用配置步骤找到 MySQL 配置文件：通过 mysql --help | grep my.cnf 查看默认路径（如 /etc/my.cnf）。在 [mysqld] 模块下添加日志相关配置项。重启 MySQL 服务：systemctl restart mysqld（Linux）或 “服务” 中重启（Windows）。验证日志是否生效：查看日志文件是否生成，或通过 show variables like '%日志关键字%' 确认配置。注意事项磁盘空间监控：所有日志（尤其是二进制日志、查询日志）会持续占用磁盘，需配置自动过期（如 expire_logs_days），并定期检查磁盘使用率（避免日志占满导致数据库崩溃）。权限控制：日志文件需设置 MySQL 所属权限（如 chown mysql:mysql /var/log/mysql/），避免其他用户篡改或泄露数据。按需开启：除错误日志默认开启外，二进制日志（主从 / 恢复必开）、慢查询日志（性能优化必开）建议长期开启；查询日志仅临时开启，用完即关。1.错误日志（Error Log）作用：记录 MySQL 启动 / 关闭过程中的错误、运行中 critical 级别的故障（如内存不足、索引损坏、连接失败等），是 排查 “数据库起不来”“服务崩溃” 的首要日志。默认状态：默认开启，无需额外配置（仅需确认路径即可）。核心配置（my.cnf 中添加）：[mysqld] log-error=/var/log/mysql/mysqld-error.log # 日志路径（建议放在非系统盘，避免占满根目录） log-warnings=2 # 可选：记录警告信息（1=默认，2=记录所有警告，0=关闭）查看方式：直接查看文件：tail -f /var/log/mysql/mysqld-error.log（实时跟踪最新错误）。关键场景示例：若 MySQL 启动失败，执行 systemctl status mysqld 会提示 “查看错误日志”，此时打开上述文件，搜索 ERROR 关键字即可定位原因（如 “端口被占用”“配置文件语法错误”“数据目录权限不足”）。2.二进制日志（Binary Log）作用：记录所有数据变更操作（如 INSERT/UPDATE/DELETE、CREATE TABLE 等 DML/DDL 语句），但不记录查询操作（如 SELECT）。核心用途：数据恢复：通过二进制日志恢复指定时间点的数据（配合全量备份）。主从复制：主库通过二进制日志将数据变更同步给从库（后续主从复制章节的核心依赖）。默认状态：默认关闭，需手动开启。核心配置（my.cnf 中添加）：[mysqld] server-id=1 # 必须配置：唯一标识（主从复制中主库和从库的 server-id 不能重复） log-bin=/var/log/mysql/mysql-bin # 开启二进制日志，日志文件前缀为 mysql-bin（会自动生成 mysql-bin.000001、mysql-bin.000002 等） binlog_format=ROW # 二进制日志格式（3种可选，推荐 ROW）： # - ROW：记录行数据变更（最安全，主从复制无歧义，推荐） # - STATEMENT：记录执行的 SQL 语句（可能有函数/存储过程导致主从不一致） # - MIXED：混合模式（自动切换，兼容场景） expire_logs_days=7 # 可选：日志自动过期时间（7天，避免日志占满磁盘）关键操作命令：命令作用show binary logs;查看所有二进制日志文件列表show master status;查看当前正在写入的二进制日志文件及偏移量（主从复制必用）flush logs;手动切换二进制日志（生成新文件，如从 000001 切换到 000002）purge binary logs to 'mysql-bin.000003';删除指定文件之前的所有二进制日志（避免手动删文件导致索引错乱）3.查询日志（General Query Log）作用：记录 所有发送到 MySQL 服务器的 SQL 语句（包括 SELECT 查询、连接请求等），相当于 “数据库的全量操作日记”。默认状态：默认关闭（因会记录大量无关查询，占用磁盘且影响性能，仅在排查特定问题时临时开启）。核心配置（my.cnf 中添加）：[mysqld] general_log=ON # 开启查询日志 general_log_file=/var/log/mysql/mysql-general.log # 日志路径使用场景：仅用于临时排查 “某条 SQL 是否被执行”“谁在频繁连接数据库” 等场景，问题解决后需立即关闭（避免日志膨胀）。4.慢查询日志（Slow Query Log）作用：记录 执行时间超过指定阈值的 SQL 语句（默认 10 秒），是 定位慢 SQL、优化数据库性能 的核心工具（如发现 “全表扫描”“未走索引” 的 SQL）。默认状态：默认关闭，生产环境建议开启（对性能影响极小，收益极高）。核心配置（my.cnf 中添加）：[mysqld] slow_query_log=ON # 开启慢查询日志 slow_query_log_file=/var/log/mysql/mysql-slow.log # 日志路径 long_query_time=2 # 慢查询阈值（单位：秒，建议设为 2-5 秒，根据业务调整） log_queries_not_using_indexes=ON # 可选：记录未使用索引的 SQL（即使执行时间未超过阈值，帮助发现索引缺失问题）查看与分析工具：直接查看：cat /var/log/mysql/mysql-slow.log（日志中会标注 SQL 执行时间、锁等待时间、涉及的表等）。专业工具：使用 mysqldumpslow（MySQL 自带）或 pt-query-digest（Percona 工具集，更强大）分析慢查询日志，快速定位 “最耗时的 SQL”“出现次数最多的慢 SQL”。示例（mysqldumpslow 用法）：mysqldumpslow -s t /var/log/mysql/mysql-slow.log（按执行时间（t）排序，展示前 10 条慢 SQL）。
InnoDB 引擎1.逻辑存储结构层级大小作用表空间（Tablespace）可变（GB 级）最高层逻辑容器，对应 .ibd 文件（独立表空间）或 ibdata1（系统表空间）段（Segment）动态增长每棵 B+ 树对应一个段。管理该树所有页的分配区（Extent）1MB = 64 页空间分配的基本单位。段从表空间中申请区页（Page）16KB（默认）I/O 的最小单位。B+ 树的节点（根、内节点、叶节点）都存储在页中行（Row）可变实际数据记录，存储在叶子页（聚簇索引）或索引页（二级索引）(1)表空间（Tablespace）：数据的 “大仓库”表空间是 InnoDB 存储的最高层级，负责整合所有数据文件，分为两种类型：系统表空间（ibdata1）：默认存储方式，所有数据库的表数据、索引、undo log、数据字典（表结构信息）都存放在这里。缺点是文件会无限膨胀，后期难以管理。独立表空间（.ibd 文件）：通过innodb_file_per_table=ON开启，每个表对应一个独立的.ibd文件，仅存储当前表的数据和索引。优势是删除表时可直接释放空间，便于维护（生产环境必开）。(2)段（Segment）：表空间的 “功能分区”段是表空间的子单位，按 “功能用途” 划分，核心分为三类：数据段（Clustered Index Segment）：即聚簇索引对应的段，InnoDB 中 “表即索引”，聚簇索引的叶子节点就是实际数据行，因此数据段直接存储表数据。索引段（Secondary Index Segment）：非聚簇索引（普通索引）对应的段，叶子节点存储的是 “聚簇索引键值”（而非实际数据），仅用于定位数据行。回滚段（Rollback Segment）：存储 undo log 的段，每个回滚段包含 1024 个 undo 页，用于事务回滚和 MVCC（后续重点讲）。段类型对应数据结构是否 B+ 树？说明数据段（Clustered Index Segment）聚簇索引✅ 是表数据就存在这棵 B+ 树的叶子节点中索引段（Secondary Index Segment）二级索引✅ 是每个二级索引是一棵独立的 B+ 树回滚段（Rollback Segment）Undo Log❌ 否用于存储 undo 记录，结构是 链表/日志型，非树形临时段（Temporary Segment）排序、临时表等❌ 否用于排序、GROUP BY 等操作的临时空间系统段（System Segment）数据字典、事务系统等❌ 否存储元数据，如 TRX_SYS、SYS_TABLES 等“段”是一个通用的空间管理抽象，B+ 树只是其中一种使用场景。(3)区（Extent）：段的 “最小分配单元”区是段的组成单位，固定大小为 1MB（无论页大小是多少），每个区包含 64 个连续的 “页”（因为默认页大小 16KB，64×16KB=1MB）。设计目的：避免 “频繁分配小空间”—— 如果直接按页分配，当表数据量增大时，会产生大量离散的页，导致磁盘 IO 碎片化；按区分配可保证 64 个页连续，减少随机 IO。(4)页（Page）：InnoDB 的 “最小 IO 单元”页是 InnoDB 读写数据的基本单位（类比操作系统的 “块”），默认大小 16KB（可通过innodb_page_size调整为 4K/8K/32K 等），核心页类型包括：数据页（B-tree Node）：存储表数据和索引的 B + 树节点（聚簇索引和非聚簇索引的叶子 / 非叶子节点）。undo 页（Undo Log Page）：存储 undo log 记录。重做日志页（Redo Log Page）：存储 redo log 记录。系统页（System Page）：存储数据字典等系统信息。(5)行（Row）：数据的 “最小存储单元”行是最终存储业务数据的单位，InnoDB 支持两种行格式（通过innodb_default_row_format设置）：Dynamic（默认）：适合大字段（如 VARCHAR、TEXT），大字段会被拆分到 “溢出页” 存储，行中仅保留 20 字节的指针。Compact：大字段的前 768 字节存在行中，剩余部分存溢出页，兼容性更好但空间利用率略低。此外，每行数据都会隐藏 3 个核心字段（MVCC 依赖）：DB_TRX_ID（6 字节）：最后修改该记录的事务 ID。DB_ROLL_PTR（7 字节）：指向该记录的上一个版本（undo log 中的位置）。DB_ROW_ID（6 字节）：默认不显示，当表没有主键且没有唯一索引时，InnoDB 会自动生成该字段作为聚簇索引键。2.架构-内存与磁盘InnoDB 的架构核心是 “内存缓冲 + 磁盘持久化”，通过内存减少磁盘 IO，通过磁盘保证数据不丢失。分为内存结构和磁盘结构两部分。(1)内存结构缓冲池（Buffer Pool）-直接读缓存作用：缓存磁盘上的 “数据页” 和 “索引页”，后续查询若命中缓冲池，直接从内存读取，避免磁盘 IO（磁盘 IO 速度是内存的 10 万倍以上）。缓存规则：采用 “LRU（最近最少使用）算法” 管理缓存，分为 “新生代（5/8）” 和 “老年代（3/8）”，新读取的页先放入新生代的 “midpoint” 位置，避免 “一次性全表扫描” 冲刷掉热点数据。脏页（Dirty Page）：缓冲池中被修改过，但尚未写入磁盘的数据页，后续会由后台线程异步刷新到磁盘（避免阻塞用户线程）。更改缓冲区（Change Buffer）-优化非唯一索引写入速度作用：当修改非唯一索引的记录时，若该索引页未在缓冲池中，InnoDB 不会直接去磁盘加载索引页，而是将修改操作缓存到 “更改缓冲区”，后续当该索引页被读取到缓冲池时，再合并修改（称为 “merge”）。适用场景：非唯一索引（唯一索引需要校验唯一性，必须加载索引页，无法使用更改缓冲区）。优势：减少写操作的磁盘 IO，尤其适合 “写多读少” 或 “批量插入” 场景（如订单表插入）。自适应哈希索引（Adaptive Hash Index）-加速查找作用：InnoDB 会自动分析 SQL 查询，对 “频繁命中的索引条件”（如WHERE id=100），在内存中构建哈希索引，将 B + 树的 “树查找”（O (log n)）转化为 “哈希查找”（O (1)），提升查询速度。特点：完全自动，无需人工配置，仅缓存热点查询，占用内存小（不超过缓冲池的 10%）。日志缓冲区（Log Buffer）-redo log缓冲区作用：缓存事务产生的 redo log，避免每次生成 redo log 都写入磁盘（减少磁盘 IO）。刷新规则：默认每 1 秒自动刷新到磁盘，或事务提交时（innodb_flush_log_at_trx_commit=1，生产环境必设，保证事务持久性），或当缓冲池占用超过 50% 时。(2)磁盘结构数据文件（.ibd/.ibdata1)即表空间对应的文件，存储表数据、索引、undo log（独立表空间中，undo log 默认存系统表空间，可通过innodb_undo_tablespaces拆分到独立文件）。重做日志文件（Redo Log File, ib_logfile0/ib_logfile1）作用：记录 “数据页的物理修改”（如 “页 100 的偏移量 200 处的值从 10 改为 20”），用于系统崩溃后的恢复（保证事务的持久性）。特点：固定大小（通过innodb_log_file_size设置，生产环境建议 2-4GB），循环写入（写满后覆盖旧日志）。双文件（ib_logfile0 和 ib_logfile1），交替写入，避免单文件损坏导致日志丢失。回滚日志文件（Undo Log file）作用：记录 “事务的逻辑反向操作”（如 “插入一条记录” 对应 “删除该记录”，“更新值从 10 到 20” 对应 “更新值从 20 到 10”），用于事务回滚和 MVCC。特点：undo log 会被 “purge 线程” 异步回收（当事务提交且没有其他事务依赖该 undo log 时）。系统表空间文件（ibdata1）默认存储数据字典（表结构、列信息、索引信息）、undo log、临时表空间等，建议通过参数拆分（如innodb_data_file_path设置多个文件，innodb_undo_tablespaces拆分 undo log）。3.后台线程InnoDB 通过多后台线程实现 “异步操作”，避免阻塞用户查询 / 更新线程，核心线程包括 4 类：(1)Master Thread-核心线程InnoDB 的核心线程，优先级最高，负责调度其他线程，主要工作：每 1 秒：刷新 100 个脏页到磁盘；合并 100 个更改缓冲区；检查 redo log 缓冲是否需要刷新。每 10 秒：刷新所有脏页到磁盘；合并所有更改缓冲区；回收无用的 undo log；检查表空间是否需要扩展。(2)IO Thread-处理IO负责处理磁盘 IO 的 “回调任务”，避免 Master Thread 阻塞在 IO 等待上，分为 4 类（可通过show engine innodb status查看）：read thread（4 个默认）：处理数据页 / 索引页的读取请求。write thread（4 个默认）：处理脏页、更改缓冲区、redo log 的写入请求。insert buffer thread（1 个）：处理更改缓冲区的 merge 操作。log thread（1 个）：处理 redo log 的刷新操作。(3)Purge Thread-回收undo log作用：异步回收 “已提交事务的 undo log”（当事务提交后，若没有其他事务依赖该 undo log，Purge Thread 会将其标记为可重用，释放空间）。优势：早期版本 Purge 操作由 Master Thread 负责，会导致 Master Thread 繁忙；独立后提升了并发性能（默认 1 个，可通过innodb_purge_threads调整为多个）。(4)Page Cleaner Thread-脏页刷新作用：专门负责将缓冲池中的 “脏页” 刷新到磁盘，替代了早期 Master Thread 的脏页刷新工作。优势：避免 Master Thread 因刷新脏页导致的 “用户线程阻塞”（如大量写操作导致脏页堆积时，Page Cleaner Thread 会逐步刷新，不影响用户查询）。4.InnoDB 事务原理-日志机制事务是数据库的核心能力，InnoDB 通过日志机制和锁机制保证事务的 ACID 特性（原子性 Atomicity、一致性 Consistency、隔离性 Isolation、持久性 Durability）。(1)Redo Log-保证持久性如果没有 redo log，事务提交时需要将 “缓冲池中的脏页” 直接写入磁盘（即 “刷脏页”）。但脏页是 16KB 的整页，而事务可能只修改了页中的 1 个字节，直接刷整页会导致 “IO 浪费”；且刷脏页是随机 IO（数据页在磁盘上离散存储），速度慢。redo log 的解决思路：记录 “修改的物理位置和内容”（如 “页 100 的偏移量 200，值从 10→20”），每条记录仅几十字节，且 redo log 是顺序写入（磁盘顺序 IO 速度远快于随机 IO）。WAL 机制WAL（Write-Ahead Logging）：事务提交时，先写 redo log，再写缓冲池（脏页），最后由后台线程将脏页刷到磁盘。流程如下：事务修改数据：先修改缓冲池中的数据页（生成脏页），同时生成 redo log，写入 “redo log 缓冲”。事务提交：将 redo log 缓冲中的日志 “刷新到磁盘的 redo log 文件”（即 “写日志”）。后台线程：异步将缓冲池中的脏页刷新到磁盘数据文件（即 “刷脏页”）。即使步骤 3 未完成时系统崩溃，重启后 InnoDB 会通过 redo log 恢复未刷脏页的修改，保证事务持久性。redo log 的关键参数innodb_flush_log_at_trx_commit控制事务提交时 redo log 的刷新策略（生产环境必设为 1）：1（默认推荐）：事务提交时，redo log 直接刷到磁盘（保证持久性，最安全）。0：事务提交时，redo log 仅写入操作系统缓存，不刷磁盘（性能高，但系统崩溃会丢失最后 1 秒的日志）。2：事务提交时，redo log 写入操作系统缓存并刷到磁盘（但仅刷到磁盘缓存，若磁盘掉电仍会丢失）。(2)Undo Log-保证原子性，MVCC的核心事务回滚-原子性undo log 记录事务的逻辑反向操作，当事务执行rollback时，InnoDB通过undo log撤销已执行的修改例：事务执行UPDATE t SET age=20 WHERE id=1（原 age=18），会生成 undo log：UPDATE t SET age=18 WHERE id=1，回滚时，InnoDB 执行该 undo log，将数据恢复到修改前的状态。支持MVCC多版本并发控制当多个事务并发读写时，undo log会形成版本链，让不同事务看到数据的不同版本，避免读写冲突undo log的声明周期事务开始：执行修改操作时，生成undo log，写入undo log页事务提交：undo log不会立即删除，而是标记为可回收（因为其他事务可能通过MVCC依赖此版本）回收：Purge Thread异步检查，当没有事务依赖该undo log时，将其回收，释放空间5.MVCCMVCC（Multi-Version Concurrency Control，多版本并发控制）是InnoDB实现高并发读写的核心机制，其目标是：读操作不阻塞写操作，写操作不阻塞读操作（即快照读），同时保证事务隔离性。没有MVCC时，事务隔离依赖锁：读操作加共享锁（S锁），写操作加排他锁（X锁），S锁和X锁互斥，导致读阻塞写，写阻塞读，并发性能极低。MVCC解决思路：为每条记录维护多个版本，读操作读取历史版本，写操作生成新版本。(1)MVCC的三大组成部分MVCC的实现依赖隐藏字段+undo log版本链+ReadView，三者协同工作：隐藏字段每条记录的3个隐藏字段是版本管理的基础：DB_TRX_ID，标记最后修改该记录的事务ID，事务开始时会分配全局唯一IDDB_ROLL_PTR，指向该记录的上一个版本，即对应的undo log记录，形成版本链DB_ROW_ID，仅当无主键/唯一索引时使用，不影响版本管理undo log 版本链每次对记录进行修改时，InnoDB会生成一条undo log记录，存储修改前的旧版本数据，将当前记录的DB_ROLL_PTR指向这条undo log，形成链表，更新当前记录的DB_TRX_ID为当前事务IDReadView 读视图-事务的版本可见性规则读视图是事务在执行快照读时形成的一致性规则，本质是一致性规则，用于判断版本链中哪些版本对当前事务可见ReadView包含4个核心参数：m_ids：生成ReadView时，当前所有活跃的事务ID列表，即已开始但未提交的事务min_trx_id：m_ids中最小的事务id，即当前活跃事务的最小idmax_trx_id：生成ReadView时，下一个要分配的事务ID，注意不是m_ids的最小id而是全局事务id的下一个值creator_trx_id：生成该ReadView的当前事务ID(2)ReadView：事务的版本可见性规则对于版本链中的某一版本，其DB_TRX_ID记为trx_id若trx_id == creator_trx_id：该版本是当前事务自己修改的，可见。若trx_id < min_trx_id：该版本由 “已提交的事务” 生成（因为事务 ID 递增，小于最小活跃 ID 说明事务已结束），可见。若trx_id >= max_trx_id：该版本由 “生成 ReadView 后才开始的事务” 生成（事务 ID 超过下一个分配 ID，说明事务是新启动的），不可见。若min_trx_id <= trx_id < max_trx_id：需判断trx_id是否在m_ids中：若在：事务仍活跃（未提交），版本不可见；若不在：事务已提交，版本可见。若当前版本不可见，则通过DB_ROLL_PTR追溯上一个版本，重复上述判断，直到找到可见版本或版本链结束（此时返回空结果）。条件是否可见说明trx_id < min_trx_id✅ 可见事务已结束（提交）trx_id >= max_trx_id❌ 不可见未来事务（ReadView 创建后才启动）min_trx_id <= trx_id < max_trx_id看是否在 m_ids 中： - 在 → ❌ 不可见 - 不在 → ✅ 可见在：活跃未提交；不在：已提交(3)MVCC在不同隔离级别下的差异InnoDB 的事务隔离级别中，读已提交（RC） 和可重复读（RR） 均基于 MVCC 实现 “快照读”，核心差异在于ReadView 的生成时机不同，导致 “重复读” 的效果不同。读已提交（RC）：每次快照读生成新的 ReadView规则：事务中每次执行普通SELECT（快照读）时，都会重新生成一个 ReadView。效果：同一事务中多次查询，可能看到不同的结果（因为每次 ReadView 不同，能看到 “两次查询之间提交的事务” 的修改）。可重复读（RR）：仅第一次快照读生成 ReadView规则：事务中第一次执行普通SELECT（快照读）时，生成一个 ReadView，后续所有快照读都复用这个 ReadView。效果：同一事务中多次查询，结果始终一致（因为 ReadView 不变，即使其他事务提交，也看不到其修改），实现 “可重复读”。快照读与当前读的区别需要注意的是，MVCC 仅针对 “快照读” 生效，而 “当前读” 仍需通过锁机制保证隔离性：快照读：普通SELECT（不加锁），基于 MVCC 读取历史版本，不阻塞写操作，也不被写操作阻塞。当前读：读取 “最新版本” 的操作，会加锁，阻塞写操作，也会被写操作阻塞，包括：SELECT ... FOR UPDATE（加排他锁）SELECT ... LOCK IN SHARE MODE（加共享锁）INSERT/UPDATE/DELETE（默认加排他锁）小结InnoDB 的设计是一个 “环环相扣” 的整体，我们可以用一条 “写数据” 的流程串联所有核心机制：用户执行 UPDATE：事务开始，分配事务 ID（如 trx_id=5）。内存操作：从磁盘读取目标数据页到 “缓冲池”；修改缓冲池中的数据（生成脏页），同时生成 “undo log”（记录旧版本，用于回滚和 MVCC）和 “redo log”（记录物理修改，用于持久化）；undo log 加入版本链，数据页的DB_TRX_ID更新为 5，DB_ROLL_PTR指向新生成的 undo log；redo log 写入 “redo log 缓冲”。事务提交：触发 “WAL 机制”：将 redo log 缓冲中的日志刷到磁盘的 “redo log 文件”（保证持久性）；事务标记为提交，undo log 标记为 “可回收”。后台线程工作：Page Cleaner Thread异步将缓冲池中的脏页刷到磁盘的 “.ibd 文件”；Purge Thread异步回收无事务依赖的 undo log；Master Thread每 10 秒检查脏页和 undo log，确保磁盘数据与内存一致。其他事务查询：若执行普通SELECT（快照读），生成 ReadView，通过版本链和可见性规则读取历史版本（MVCC）；若执行SELECT ... FOR UPDATE（当前读），加排他锁，读取最新版本，阻塞其他写操作。通过这套机制，InnoDB 同时实现了事务安全（ACID）、高并发（MVCC）、崩溃恢复（redo log） 三大核心目标，成为 MySQL 生产环境的首选存储引擎。