IoT 系统重复告警问题分析与解决方案 ——Redis 分布式锁实战
情景再现在铁路隐蔽工程智能监测系统中，设备传感器数据上报频率较高时，会出现同一设备的同类型告警被重复创建的问题。这导致告警记录冗余，影响系统的可靠性和用户体验。从系统日志中可以观察到：19:09:35.706 [pool-2-thread-12] DEBUG c.r.i.m.I.selectIotDeviceByDeviceSn - [debug,135] - ==> Parameters: WT6B00000147(String) 19:09:35.723 [pool-2-thread-25] INFO c.r.i.s.i.DataProcessingService - [checkThresholdAndCreateAlarm,151] - 创建新的告警记录 - 设备: WT6B00000147, 类型: 振动超限, 值: 120, 阈值: 50 19:09:35.724 [pool-2-thread-30] INFO c.r.i.s.i.DataProcessingService - [checkThresholdAndCreateAlarm,151] - 创建新的告警记录 - 设备: WT6B00000147, 类型: 振动超限, 值: 120, 阈值: 50两个不同的线程（pool-2-thread-25 和 pool-2-thread-30）同时处理同一设备数据在几乎同一时刻（1ms内）创建了相同的告警记录设备SN：WT6B00000147，告警类型：振动超限，数值：120问题分析问题根源：传感器数据通过线程池并行处理同一设备的数据可能在不同的线程中同时处理无并发控制机制原始问题代码分析 - 竞态条件：// 原始问题代码 if (!isActiveAlarmExist(device.getDeviceSn(), alarmType)) { createAlarmRecord(device.getDeviceSn(), alarmType, alarmValue, thresholdValue); }isActiveAlarmExist()：检查Redis是否存在告警keycreateAlarmRecord()：创建数据库告警记录并设置Redis key两个操作非原子性，在并发情况下产生竞态条件问题解决核心思路：先通过 Redis 抢占 “告警锁”，抢到锁再写入数据库；否则直接丢弃。选择Redis的setIfAbsent操作作为分布式锁的基础：Redis单线程模型保证原子性支持过期时间，避免死锁成熟的分布式缓存方案最终代码：/** * 尝试创建告警记录（原子操作，防重复） * * @return true表示成功创建了新告警，false表示已存在活跃告警 */ private boolean tryCreateAlarmRecord(String deviceSn, String alarmType, Long alarmValue, Long thresholdValue) { String alarmKey = "alarm:active:" + deviceSn + ":" + alarmType; try { // 使用Redis的原子操作setIfAbsent检查并设置告警状态 Boolean isNewAlarm = redisTemplate.opsForValue().setIfAbsent(alarmKey, "1", 24 * 60 * 60, TimeUnit.SECONDS); if (isNewAlarm != null && isNewAlarm) { // 创建数据库告警记录 createAlarmRecordWithRetry(deviceSn, alarmType, alarmValue, thresholdValue); } else { // 告警已存在，不创建新记录 log.info("告警已存在，跳过创建 - 设备: "+deviceSn+", 类型: "+alarmType); return false; } } catch (Exception e) { log.warning("创建告警时Redis操作失败 - 设备: "+deviceSn+", 类型: "+alarmType+", 错误: "+ e.getMessage()); // 在Redis操作失败的情况下，作为保守策略，仍然尝试创建告警记录 // 这可能导致少量重复，但保证了告警不会丢失 createAlarmRecord(deviceSn, alarmType, alarmValue, thresholdValue); return true; } } 进一步思考：Redis 与数据库的不一致如果 setIfAbsent 成功，但 createAlarmRecord 失败（数据库宕机、事务回滚等），Redis 里会残留一个“假告警”，导致后续真实告警被静默丢弃。你虽然处理了 Redis 异常，但没处理数据库写入失败时的缓存回滚。本地事务补偿伪代码：// 1. Redis 加锁（防重） boolean lockSuccess = redis.setIfAbsent(key, "1", 24, HOURS); if (!lockSuccess) { // 已存在，跳过 return; } try { // 2. 数据库写入 db.insert(alarm); // 成功：结束 } catch (Exception e) { // 3. 数据库失败：回滚 Redis redis.delete(key); }为了使系统更可靠，我们再为数据库写入添加重试机制，最多重试3次，每次延迟时间递增。最终代码实现：/** * 尝试创建告警记录（原子操作，防重复 + 数据一致性保证） * * @return true表示成功创建了新告警，false表示已存在活跃告警 */ private boolean tryCreateAlarmRecord(String deviceSn, String alarmType, Long alarmValue, Long thresholdValue) { String alarmKey = "alarm:active:" + deviceSn + ":" + alarmType; try { // 使用Redis的原子操作setIfAbsent检查并设置告警状态 Boolean isNewAlarm = redisTemplate.opsForValue().setIfAbsent(alarmKey, "1", 24 * 60 * 60, TimeUnit.SECONDS); if (isNewAlarm != null && isNewAlarm) { // 尝试创建数据库告警记录（带重试机制） boolean dbSuccess = createAlarmRecordWithRetry(deviceSn, alarmType, alarmValue, thresholdValue); if (dbSuccess) { // 数据库写入成功，确认告警创建 log.info("告警创建成功 - 设备: "+deviceSn+", 类型: "+alarmType); return true; } else { // 数据库写入失败，回滚Redis缓存以避免"假告警" rollbackAlarmCache(alarmKey, deviceSn, alarmType); return false; } } else { // 告警已存在，不创建新记录 log.info("告警已存在，跳过创建 - 设备: "+deviceSn+", 类型: "+alarmType); return false; } } catch (Exception e) { log.warning("创建告警时Redis操作失败 - 设备: "+deviceSn+", 类型: "+alarmType+", 错误: "+ e.getMessage()); // 在Redis操作失败的情况下，作为保守策略，仍然尝试创建告警记录 // 这可能导致少量重复，但保证了告警不会丢失 createAlarmRecord(deviceSn, alarmType, alarmValue, thresholdValue); return true; } } /** * 创建告警记录（带重试机制 + 数据验证） */ private boolean createAlarmRecordWithRetry(String deviceSn, String alarmType, Long alarmValue, Long thresholdValue) { final int maxRetries = 3; final long retryDelayMs = 100; for (int attempt = 1; attempt <= maxRetries; attempt++) { try { IotAlarm alarm = new IotAlarm(); alarm.setDeviceSn(deviceSn); alarm.setAlarmType(alarmType); alarm.setAlarmValue(alarmValue); alarm.setThresholdValue(thresholdValue); alarm.setAlarmTime(new Date()); alarm.setStatus(0L); iotAlarmService.insertIotAlarm(alarm); // 验证插入是否成功 if (alarm.getId() != null) { return true; // 数据库写入成功 } log.info("告警记录插入验证失败（第"+attempt+"次重试）- 设备: "+deviceSn+", 类型: "+alarmType); } catch (Exception e) { log.info("告警记录插入失败（第"+attempt+"次重试）- 设备: "+deviceSn+", 类型: "+alarmType+", 错误: "+e.getMessage()); if (attempt < maxRetries) { try { Thread.sleep(retryDelayMs * attempt); // 指数退避 } catch (InterruptedException ie) { Thread.currentThread().interrupt(); break; } } } } log.warning("告警记录插入失败，已达到最大重试次数 - 设备: "+deviceSn+", 类型: "+alarmType); return false; } /** * 回滚告警缓存（保证数据一致性） */ private void rollbackAlarmCache(String alarmKey, String deviceSn, String alarmType) { try { Boolean deleted = redisTemplate.delete(alarmKey); if (deleted != null && deleted) { log.info("已回滚告警缓存 - 设备: "+deviceSn+", 类型: "+alarmType+", Key: "+alarmKey); } else { log.warning("回滚告警缓存失败（可能已被其他线程处理）- 设备: "+deviceSn+", 类型: "+alarmType+", Key: "+alarmKey); } } catch (Exception e) { log.warning("回滚告警缓存时发生异常 - 设备: "+deviceSn+", 类型: "+alarmType+", 错误: "+e.getMessage()); } }方案效果与总结彻底解决重复告警问题高并发下性能损耗小Redis + DB 最终一致性得到保障异常降级策略保证告警不丢失适合 IoT、监控、上报类高并发场景
网络编程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