admin 管理员组文章数量: 887021
第一章 计算机基础
- 走进0与1的世界
- 计算机就是晶体管、电路板组装起来的电子设备,无论是图形图像的渲染、网络远程共享,还是大数据计算,归根到底都是 0 与 1 的信号处理。信息存储和逻辑计算的元数据只能是 0 与 1,但它们在不同介质里的物理表现方式却是不太一样的,如三极管的通电与断电、CPU 的低电平与高电平、磁盘的电荷左右方向。明确了 0 与 1 的物理表现方式后,设定基数为 2, 进位规则是”逢二进一“,借位规则是”借一当二“,所以称为二进制
- 假设有 8 条电路,每条电路有低电平和高电平两种状态。根据数学排列,有 8 个 2 相乘,能够表示 256 种信号。假设表示区间为 0~255,那么 32 条电路能够表示的最大数为 2 的 32 次方 减 1 ,平时所有的 32 位机器就能够处理字长位 32 位的电路信号
- 如何表示负数呢,8 条电路时最左侧的一条表示正负,0 表示正数,1 表示负数,不参与数值表示。8 条电路的最大值为 01111111 即 127。表示范围因有正负之分而改变为 -128~127。二进制整数最终都是以补码形式出现的。正数的补码与原码、反码是一样的,负数的补码是反码加一的结果。在各类编程语言中,均规定了不同数组类型的表示范围,有相应的最大值和最小值。例如一个计算需要 9 条电路来表示,但用 8 条电路来表示是就会出现溢出
- 一条电路线在计算机中被称为 1 位,即 1 个 bit,简写为 b。8 个 bit 组成一个单位,称为一个字节,即 1 个 Byte,简写为 B,1024 个 Byte 简写为 KB,1024 个 KB 简写为 MB,1024 个 MB 简写为 GB
- 浮点数
- 计算机定义了两种小数,分别为定点数和浮点数
- 定点数的小数点位置是固定的,在确定字长的系统中一旦指定小数点的位置后,它的整数部分和小数部分也随之确定
- 浮点数是采用科学计算法来表示的,由符号位、有效数字、指数三部分组成
float a = 1f; float b = 0.9f; float f = a-b; // 输出 // 0.100000024
- 科学计数法
- 浮点数是计算机用来表示小数的一种数据类型,在数学中采用科学计数法来近似表示一个极大或极小且位数较多的数
- 科学计数法的有效数字为从第 1 个非零数字开始的全部数字,指数决定小数点的位置,符号表示该数的正与负。十进制科学计数法要求有效数字的整数部分必须在 [1,9]区间内
- 浮点数表示
- 略
- 计算机定义了两种小数,分别为定点数和浮点数
- 字符集与乱码
- CPU与内存
- CPU (Central Processing Unit)是一块超大规模的集成电路板,是计算机的核心部件,承载着计算机的主要运算和控制功能,是计算机指令的最终解释模块和执行模块。硬件包括基板、核心、针脚,基板用来固定核心和针脚,针脚通过基板上的基座连接电路信号
- 控制器:由控制单元、指令译码器、指令寄存器组成。控制单元是 CPU 的大脑,由时序控制和指令控制等组成;指令译码器是在控制单元的协调下完成指令读取、分析并交由运算器执行等操作;指令寄存器是存储指令集,当前流行的指令集包括 X86、SSE、MMX等。控制器有点像一个变成语言的编译器,输入 0 与 1 的源码流,通过译码和控制单元对存储设备的数据进行读取,运算完成后,保存回寄存器,甚至内存
- 运算器:运算器的核心是算数逻辑运算单元,即ALU,能够执行算术运算或逻辑运算等各种命令,运算单元会从寄存器中提取或存储数据。相对控制单元来说,运算器是受控的执行部件。任何编程语言诸如 a+b 的算术运算,无论字节码指令还是汇编指令,最后一定会以 0 与 1 的组合流方式在部件内完成最终计算,并保存到寄存器,最后送出 CPU。平时理解的栈与堆,在 CPU 眼里都是内存
- 寄存器:最著名的寄存器是 CPU 的高速缓存 L1、L2,缓存容量是在组装计算机时必问的两个 CPU 性能问题之一
- 存储单元都有一个十六进制的编号,在 32 位机器上是 0x 开始的 8 位数字编号,就是内存存储单元的地址,相当于门牌号。以 C 和 C++ 为代表的编程语言可以直接操作内存地址,进行分配和释放。而以 Java 为代表的编程语言,内存就交给 JVM 进行自动分配与释放,这个过程被称为垃圾回收机制
- TCP/IP
- 网络协议
- 计算机诞生后,从单机模式应用发展到多台计算机连接起来,形成计算机网络,是信息共享、多机协作、大规模计算等称为现实。计算机网络需要解决的第一个问题是如何无障碍地发送和接收数据。而这个发送和接收数据地过程需要相应地协议来支撑,按互相可以理解的方式进行数据的打包与解包,使不同厂商的设备在不同类型的操作系统上实现顺畅的网络通信
- TCP/IP(Transmission Control Protocol/Internet Protocol)中文译为传输控制协议/因特网互联协议,这个大家族里的其他知名协议还有 HTTP、HTTPS、FTP、SMTP、UDP、ARP、PPP、IEEE802.x 等,TCP/IP 是当前流行的网络传输协议框架,从严格意义上讲它是一个协议族,因为 TCP、IP 是其中最为核心的协议,所以就把该协议族称为 TCP/IP,而另一个是耳熟能详的 ISO/OSI 的七层传输协议,其中 OSI(Open System Interconnection) 的出发点是想设计出计算机世界通用的网络通信基本框架,已被淘汰
- 链路层:单个 0、1 是没有意义的,链路层以字节为单位把 0 与 1 进行分组,定义数据帧,写入源和目标机器的物理地址、数据、校验位来传输数据
- MAC 地址长 6 个字节共 48 位,通常使用十六进制数表示。使用 ifconfig -a 命令即可看到 MAC 地址。如图 f4:5c:89。即前 24 位由管理机构统一分配,后 24 位由厂商自己分配,保证网卡全球唯一。网卡就像是家庭地址一样,是计算机世界范围内的唯一标识
- 网络层:根据 IP 定义网络地址,区分网络。子网内根据地址解析协议(ARP)进行 MAC 寻址,子网外进行路由转发数据包,这个数据包即 IP 数据包
- 传输层:数据包通过网络层发送到目标计算机后,应用程序在传输层定义逻辑端口,确认数据后,将数据包交给应用程序,实现端口到端口间通信。最典型的传输层协议时 UDP 和 TCP。UDP 只是在 IP 数据包上增加端口等部分信息,是面向无连接的,是不可靠传输,多用于视频通话、电话会议等(即使少一帧数据也无妨)。与之相反,TCP 是面向连接的。所谓面向连接,是一种端到端间通过失败重传机制建立的可靠数据传输方式
- 应用层:传输层的数据到达应用程序时,以某种统一规定的协议格式解读数据。比如:E-mail 在各个公司的程序界面、操作、管理方式都不一样,但是都能够读取邮件内容,是因为 SMTP 协议就像传统的书信格式一样,按规定填写邮编及收信人信息
- 总结:程序在发送消息时,应用层按既定的协议打包数据,随后由传输层加上双方的端口号,由网络层加上双方的 IP 地址,由链路层加上双方的 MAC 地址,并将数据拆分成数据帧,经过多个路由器和网关后,到达目标机器。就是按“端口 -> IP地址 -> MAC地址”这样的路径进行数据的封装和发送,解包的时候进行反操作即可
- IP 协议
- IP 是面向无连接、无状态的,没有额外的机制保证发送的包是否有序到达。IP 首先规定出 IP 地址格式,该地址相当于在逻辑意义上进行了网段的划分,给每台计算机额外设置了一个唯一的详细地址。在全世界范围内,不可能通过广播的方式,从数以万计的计算机里找到目标 MAC 地址的计算机而不超时。在数据投递时就需要对地址进行分层管理
- IP 地址属于网络层,主要功能在 WLAN 内进行路由寻址,选择最佳路由。IP 报文格式共 32 位 4 个字节,通常用十进制数来表示。IP 地址的掩码 0xffffff00 表示 255.255.255.0,掩码相同,则在同一个子网内
- 协议结构比较简单,TTL 即数据包可经过的最多路由总数,TTL 初始值由源主机设置后,数据包在传输过程中每经过一个路由器 TTL 值减 1,当该字段位 0 时,数据包被丢弃,并发送 ICMP 报文通知源主机,以防止源主机无休止地发送报文。ICMP(Internet Control Message Protcol),它是检测传输网络是否通畅、主机是否可达、路由是否可用等网络运行状态的协议。ICMP 虽然并不传输用户数据,但对评估网络健康状态非常重要,常用的 ping、tracert 命令就是基于 ICMP 检测网络状态的有力工具。TTL 右侧挂载协议标识表示 IP 数据包里放置的子数据包协议类型,如 6 代表 TCP、17 代表 UDP 等
- IP 报文在互联网上传输时,可能要经历多个物理网络,才能从源主机到达目标主机。比如在手机上给某个 PC 端的朋友发送一个信息,经过无线网的 IEE802.1x 认证,转到光纤通信,然后进入内部企业网 802.3,并最终到达目标 PC。由于不同的硬件和物理特性不同,对数据帧的最大长度都有不同的限制,这个最大长度被称为最大传输单元,即 MTU(Maximum Transmission Unit)。那么在不同的物理网之间就可能需要对 IP 报文进行分片,这个工作通常由路由器负责完成
- IP 是 TCP/IP 的基石,几乎所有其他协议都建立在 IP 所提供的服务基础上进行传输,其中包括在实际应用中用户传输稳定有序数据得 TCP
- TCP 建立连接
- TCP 传输控制协议(Transmission Control Propocol),是一种面向连接、确保数据在端到端间可靠传输的协议。面向连接是指在发送数据前,需要先建立一条虚拟的链路,然后让数据在这条链路上“流动”完成传输。为了确保数据的可靠传输,不仅需要对发出的每个字节进行编号确认,校验每一个数据包的有效性,在出现超时情况时进行重传,还需要通过实现滑动窗口和拥塞控制等机制,避免网络状况恶化而最终影响数据传输的极端情况。每个 TCP 数据包是封装在 IP 包中的,每个 IP 头的后面紧跟的是 TCP 头
- 协议第一行的两个端口各占两个字节,分别表示了源机器和目标机器的端口号。这两个端口号与 IP 报头中的源 IP 地址和目标 IP 地址所组成的四元组可唯一标识一条 TCP 连接。由于 TCP 是面向连接的,因此有服务端和客户端之分。需要服务端先在相应的端口上进行监听,准备好接收客户端发起的建立连接请求。当客户端发起第一个请求连接的 TCP 包时,目标机器端口就是服务端所监听的端口号。比如一些由国际组织定义的广为人知端口号-代表 HTTP 服务的 80 端口、代表 SSH 服务的 22 端口、代表 HTTPS 服务的 443 端口。可以通过 netstat 命令列出在机器上已建立的连接信息,其中包含唯一标识一条连接的四元组,以及各连接的状态等内容。如图红框代码表端口号
- 协议第二行和第三行是序列号,各占 4 个字节。前者是指所发送数据包中数据部分的第一个字节的序号,后者是指期望收到来自对方的下一个数据包中数据部分第一个字节的序号
- 由于 TCP 报头中存在一些扩展字段,所以需要通过长度为 4 个 bit 的头部长度字段表示 TCP 报头的大小,这样接收方才能准确地计算出包中数据部分的开始位置
- TCP 的 FLAG 位由 6 个 bit 组成,分别代表 ACK、SYN、FIN、URG、PSH、RST,都以置 1 表示有效。SYN(Synchronize Sequence Numbers)用作建立连接时的同步信号;ACK(Acknowledgement)用于对收到的数据进行确认,所确认的数据由确认序列号表示;FIN(Finish)表示后面没有数据需要发送,通常意味着所建立的连接需要关闭了
- TCP 正常情况下通过三次握手建立连接,三次握手指的是建立连接的三个步骤
- A 机器发出一个数据包并将 SYN 置 1,表示希望建立连接,这个包中的序列号假设是 x
- B 机器收到 A 机器发过来的数据包后,通过 SYN 得知这是一个建立连接的请求,于是发送一个响应包并将 SYN 和 ACK 标记都置 1,假设这个包中的序列号是 y,而确认序列号必须是 x+1,表示收到了 A 发过来的 SYN,在 TCP 中,SYN 被当作数据部分的一个字节
- A 收到 B 的响应包后需要确认,确认包中将 ACK 置 1,并将确认序号设置位 y+1,表示收到了来自 B 的 SYN
- 三次握手的目的
- 三次握手有两个目的,信息对等和防止超时
- 信息对等
- 防止超时。TTL 网络报文的生存时间往往都会超过 TCP 请求超时时间,如果两次握手就可以创建连接,传输数据并释放连接后,第一个超时的连接请求才到达 B 机器的话,B 机器会以为 A 创建新连接的请求,然后确认同意创建连接。因为 A 机器的状态不是 SYN_SENT,所以直接丢弃了 B 的确认数据,以致最后只是 B 机器方面创建连接完毕。如果是三次握手,则 B 机器收到连接请求后,同样会向 A 机器确认同意创建连接,但因为 A 机器不是 SYN_SENT 状态,所以会直接丢弃,B 机器由于长时间没有收到确认信息,最后超时导致连接创建失败,因为不会出现脏连接
- 从编程的角度,TCP 连接的建立时通过文件描述符(File Descriptor,fd)完成的,通过创建套接字获得一个 fd,然后服务端和客户端需要基于所获得的 fd 调用不同的函数分别进入监听状态和发起连接请求。由于 fd 的数量将决定服务端进程所能建立连接的数量,对于大规模分布式服务来说,当 fd 不足时就会出现 “open too many fils” 错误而使得无法建立更多的连接。为此,需要注意调整服务端进程和操作系统所支持的最大文件句柄数
- 通过使用 ulimit -n 命令来查看单个进程可以打开文件句柄的数量,如果想查看当前系统各个进程产生了多少句柄,可以使用如下句柄:
lsof -n | awk '{print $2} '| sort |uniq -c |sort -nr|more
- 左侧列是句柄数,右侧列是进程号。lsof 命令用于查看当前系统所打开 fd 的数量,在 Linux 系统中,很多资源都是以 fd 的形式进行读写的,除了提到的文件和 TCP 连接,UDP 数据报、输入输出设备等都被抽象成了 fd
- 想知道具体的 PID 对应的具体应用程序是什么,使用如下命令即可
ps -ax | grep 32764
- TCP 在协议层面支持 Keep Alive 功能,即隔段时间通过向对方发送数据表示连接处于健康状态。不少服务将确保连接健康的行为放到了应用层,通过定期发送心跳包检查连接的健康度。一旦心跳包出现异常不仅会主动关闭连接,还会回收与连接相关的其他用户提供服务的资源,确保系统资源最大限度地被有效利用
- 通过使用 ulimit -n 命令来查看单个进程可以打开文件句柄的数量,如果想查看当前系统各个进程产生了多少句柄,可以使用如下句柄:
- TCP 断开连接
- TCP 是全双工通信,双方都能作为数据的发送发和接收方,但 TCP 连接也会有断开的时候。所谓相爱容易分守难,建立连接只有三次,而挥手断开则需要四次。A 机器想要关闭连接,则待本方数据发送完毕后,传递 FIN 信号给 B 机器。B 机器应答 ACK,告诉 A 机器可以断开,但是需要等 B 机器处理完数据,再主动给 A 机器发送 FIN 信号。这时,A 机器处于半关闭状态(FIN_WAIT_2),无法再发送新的数据。B 机器做好连接关闭前的准备工作,发送 FIN 给 A 机器,此时 B 机器也进入半关闭状态(CLOSE_WAIT)。A 机器发送针对 B 机器 FIN 的 ACK 后,进入 TIME_WAIT 状态,经过 2MSL(Maximum Segment Lifetime)后,没有收到 B 机器传来的报文,则确定 B 机器已经收到 A 机器最后发送的 ACK 指令,此时 TCP 连接正式释放
- 通过抓包分析,如图所示红色箭头表示 B 机器已经清理好现场,并发送 FIN+ACK。注意,B 机器主动发送的两次 ACK 应答的都是 81,第一次进入 CLOSE_WAIT 状态,第二次应答进入 LAST_ACK 状态,表示可以断开连接,在绿色箭头处,A 机器应答的就是 Seq=81
- TIME_WAIT 和 CLOSE_WAIT 分别表示主动关闭和被动关闭产生的阶段性状态,如果线上服务器大量出现这两种状态,就会加重机器负载,也会影响有效连接的创建
- TIME_WAIT:主动要求关闭的机器表示收到了对方的 FIN 报文,并发送出了 ACK 报文,进入 TIME_WAIT 状态,等 2MSL 后即可进入到 CLOSED 状态。如果 FIN_WAIT_1 状态下,同时收到带 FIN 标志和 ACK 标志的报文时,可以直接进入 TIME_WAIT 状态,而无须经过 FIN_WAIT_2 状态
- CLOSE_WAIT:被动要求关闭的机器收到对方请求关闭连接的 FIN 报文,在第一次 ACK 应答后,马上进入 CLOSE_WAIT 状态。这种状态其实表示在等待关闭,并且通知应用程序发送剩余数据,处理现场信息,关闭相关资源
- 在 TIME_WAIT 等待的 2MSL 是报文在网络上生存的最长时间,超过阈值便将报文丢弃。一般来说,MSL 大于 TTL 衰减至 0 的时间。在 RFC793 中规定 MSL 为 2 分钟。但是在当前的高速网络中,2 分钟的等待时间会造成资源的极大浪费,在高并发服务器上通常会使用更小的值,既然 TIME_WAIT 貌似百害无一利为何不直接关闭,而进入 CLOSED 状态呢?
- 第一,确认被动关闭方能够顺利进入 CLOSED 状态。 假如最后一个 ACK 由于网络原因导致无法到达 B 机器,处于 LAST_ACK 的 B 机器通常“自信”地以为对方没有收到自己的 FIN+ACK 报文,所以会重发。A 机器收到第二次的 FIN+ACK 报文,会重发一次 ACK,并且重新计时。如果 A 机器收到 B 机器的 FIN+ACK 报文后,发送一个 ACK 给 B 机器,就“自私”地立马进入 CLOSED 状态,可能会导致 B 机器无法确保收到最后的 ACK 指令,也无法进入 CLOSED 状态,这是 A 机器不负责任的表现
- 第二,防止失效请求。这样做是为了防止已失效连接的请求数据包与正常连接的请求数据包混淆而发生异常
- 因为 TIME_WAIT 状态无法真正释放句柄资源,在此期间,Socket 中使用的本地端口在默认情况下不能再被使用。该限制对客户端机器来说无所谓,但对于高并发服务器来说,会极大地限制有效连接的常见数量,所以,建议将高并发服务器 TIME_WAIT 超时时间调小
- 在服务器上通过变更 /etc/sysctl.conf 文件来i需改该省略值(秒)
net.ipv4.tcp_fin_timeout=30(建议小于30秒为宜)
- 修改完成过后执行 /sbin/sysctl -p 让参数生效即可,可以通过如下命令查看各连接状态的计数情况
netstat -n | awk '/^tcp/ {++S[$NF]} END {for(a in S) print a,S[a]}'
- 在 sysctl.conf 中还有其他连接参数也用来不断地调优服务器 TCP 连接能力,以提升服务器的有效利用率。TIME_WAIT 是挥手四次断开连接的尾声,如果此状态连接过多,则可以通过优化服务器参数得到解决。如果不是对方连接的异常,一般不会出现无法关闭的情况。但是 CLOSE_WAIT 过多很可能是程序自身的问题,比如在对方关闭连接后,程序没有检测到,或者忘记自己关闭连接。通过命令,查看未关闭的连接
netstat -ant |grep -i "443" |grep CLOSE_WAIT|wc -l
- 网络协议
- 连接池
- 在实际业务中,假如数据库配置的 MAX 是 100,一个请求 10 ms,则最大能够处理 10000 QPS,增大连接数,有可能会超过单台服务器的正常负载能力,另外,连接数的创建是受到服务器操作系统的 fd (文件描述符) 数量限制的。创建更多的活跃连接,就需要消耗更多的 fd,系统默认单进程可同时拥有 1024 个 fd,该值虽然可以适当调整,但如果无限制地增加,会导致服务器在 fd 的维护和切换上消耗过多的精力从而降低应用吞吐量
- 一般可以把连接池的最大连接数设置在 30 个左右,理论上还可以设置更大值,但是 DBA 一般不会允许,因为往往只有出现了慢 SQL,才需要使用更多连接数,这时候通常需要优化应用层逻辑或者创建数据库索引,而不是一味地采用加大连接数这种治标不治本的做法
- 从经验上看,在数据库层面的请求应答时间必须在 100ms 以内,秒级的 SQL 查询通常存在巨大的性能提升空间
- 建立高效且合适的索引
- 排查连接资源未显示关闭的情形
- 合并短的请求
- 合理拆分多个表 join 的 SQL,若是超过三个表则禁止 join。三表 join 的数据量由于笛卡儿积操作会呈几何级数增加,所以不推荐
- 使用临时表
- 应用层优化
- 改用其他数据库。针对不同的业务场景选择不同的数据库
- 信息安全
- 黑客与安全
- 互联网公司都需要建立一套完整的信息安全体系,遵顼 CIA 原则,即保密性(Confidentiality)、完整性(Integrity)、可用性(Availability)
- SQL 注入
- SQL 注入是注入式攻击中常见的类型,SQL 注入式攻击时未将代码与数据进行严格的隔离,导致在读取用户数据的时候,错误地把数据作为代码的一部分执行,从而导致一些安全问题
- SQL 注入的防范
- 过滤用户输入参数中的特数字符,从而降低被SQL 注入的风险
- 禁止通字符串拼接的 SQL 语句,严格使用参数绑定传入的 SQL 参数
- 合理使用数据库访问框架提供的防注入机制
- XSS与CSRF
- XSS:跨站脚本攻击,即 Cross-Site Scripting,为了不和前端开发中层叠样式表(CSS)的名字冲突,简称 XSS。XSS 是指黑客通过技术手段,向正常用户请求的 HTML 页面中插入恶意脚本,从而可以执行任意脚本。XSS 主要分为反射型 XSS、存储型 XSS 和 DOM 型 XSS
- CSRF:跨站请求伪造,即 Cross-Site Request Forgery,简称 CSRF,也被称为 One-click Attack,即在用户并不知情的情况下,冒充用户发起请求,在当前已经登陆的 Web 应用程序上执行恶意操作,如恶意发帖、修改密码、发邮件等
- CSRF 有别于 XSS,从攻击效果上,两者有重合的地方
- XSS 是在正常用户请求的 HTML 页面中执行了黑客提供的恶意代码;CSRF 是黑客直接盗用用户浏览器中的登录信息,冒充用户去执行黑客指定的操作
- XSS 问题是出在用户数据没有过滤、转义;CSRF 问题是出在 HTTP 接口没有防范不受信任的调用
- 防范 CSRF 漏洞主要有以下方式
- CSRF Token 验证,利用浏览器的同源限制,在 HTTP 接口执行前验证页面或者 Cookie 中设置的 Token,只有验证通过才继续执行请求
- 人机交互,比如使用短信校验等
- HTTPS
- HTTPS 的全称是 HTTP over SSL,简单的理解就是在之前的 HTTP 传输上增加了 SSL 协议的加密能力。SSL 协议工作于传输层与应用层之间,为应用提供数据的加密传输
- RSA 它将密码革命性的分成公钥和私钥,由于公钥和私钥不相同,所以称为非对称加密。私钥是用来对公钥加密的信息进行解密的,是需要严格保密的,公钥是对信息进行加密,任何人都可以知道,包括黑客
- 非对称加密的安全性是基于大质数分解的困难性,在非对称的加密中公钥和私钥是一对大质数函数。计算两个大质数的乘积是简单的,但是这个过程的你逆运算是非常困难的。在 RSA 的算法中,从一个公钥和密文中解密出明文的难度等同于分解两个大指数的难度。因此在实际传输中,可以把公钥发送给对方。一方发送信息时,使用另一方的公钥进行加密生成密文,收到密文的一方在使用私钥进行解密,这样就相对安全了
- 非对称加密并不是完美的,它有个明显的缺点就是加密和解密耗时长,只适合对少量数据进行处理。我们担心对称加密中的密钥安全问题,那么将密钥的传输使用非对称加密就完美的解决了问题,实际上,HTTPS 也是使用这一种方式来建立安全的 SSL 连接的。整个过程如下:
- 甲告诉乙,使用 RSA 算法进行加密。乙说,好的
- 甲和乙分别根据 RSA 生成一对密钥,互相发送公钥
- 甲使用乙的公钥给乙加密报文信息
- 乙收到信息,并用自己的密钥进行解密
- 乙使用同样方式给甲发送信息,甲使用相同方式进行解密
- 整个过程看似无懈可击,但如果甲的送信使者中被拦截,然后拦截者自己生成一对密钥,然后冒充甲的使者到乙家,把自己的公钥给乙。这样乙会把信息都给中间的拦截者。这时就需要一个具有公信力的组织来证明身份。CA (Certificate Authority)就是颁发 HTTPS 证书的组织。HTTPS 是当前网站的主流文本传输协议,在基于 HTTPS 进行连接时,就需要数字证书。如图所示,可以看到协议版本、签名方案、签发的组织是 GlobalSign,这个证书的有效期至 2018 年 10 月 31 日
- 访问一个 HTTPS 的网站的大致流程如下
- 浏览器向服务器发送请求,请求中包括浏览器支持的协议,并附带一个随机数
- 服务器收到请求后,选择某种非对称加密算法,把数字证书签名公钥、身份信息发送给浏览器,同时也附带一个随机数
- 浏览器收到后,验证证书的真实性,用服务器的公钥发送握手信息给服务器
- 服务器解密后,使用之前的随机数计算出一个对称加密的密钥,以此作为加密信息并发送
- 后续所有的信息发送都是以对称加密方式进行
- 传输层安全协议(Transport Layer Security,TLS),TLS 可以理解为 SSL 协议 3.0 版本的升级,所以 TLS 的 1.0 版本也被标识为 SSL3.1 版本。对于大的协议栈来说,SSL 和 TLS 并没有什么大的区别,因此在 Wireshark 里,分层依然使用的是安全套接字层(SSL)标识
- 在整个 HTTPS 的传输协议中,主要分为两个部分:首先是 HTTPS 的握手,然后是数据的传输。前者是建立一个 HTTPS 的通道,并确定连接使用的加密套件及数据传输使用的密钥。而后者主要使用密钥对数据加密并传输
- 整个连接的过程
- 第一,客户端发送了一个 Client Hello 协议的请求:在 Client Hello 中最重要的信息是 Cihper Suites 字段,这里客户端会告诉服务端自己支持哪些加密的套件,比如在此次 SSL 连接中,客户端支持的加密套件协议如下
- 第二,服务端在收到客户端发来的 Client Hello 的请求后,会返回一系列的协议数据,并以一个没有数据内容的 Server Hello Done 作为结束。这些协议数据有的是单独发送,有的则这合并发送
- Server Hello 协议。主要告知客户端后续协议中要使用的 TLS 协议版本,这个版本主要和客户端与服务端支持的最高版本有关。比如本次确认后续的 TLS 协议版本是 TLSv1.2,并为本次连接分配一个会话 ID (Session ID)。此外,还会确认后续采用的加密套件(Cipher Suite),这里确认使用的加密套件为 TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256。该加密套件的基本含义为:使用非对称加密协议加密(RSA)进行对称协议加密(AES)密钥的加密,并使用对称加密协议(AES)进行信息的加密
- Certificate 协议。主要传输服务端的证书内容
- Server Key Exchange。如果在 Certifivate 协议中未给出客户端足够的信息,则会在 Server Key Exchange 进行补充。比如在本次连接中 Certificate 未给出证书的公钥(Public Key),这个公钥的信息将会通过 Server Key Exchange 发送给客户端
- Certificate Request。这个协议是一个可选项,当服务端需要对客户端进行证书验证的时候,才会想客户端发送一个证书请求(Certificate Request)
- 最后以 Server Hello Done 作为结束信息,告知客户端整个 Server hello 过程结束
- 第三,客户端在收到服务端的握手信息后,根据服务端的请求,也会发送一系列的协议
- Certificate。它是可选项。因为上文中服务端发送了 Certificate Request 需要对客户端进行证书验证,所以客户端要发送自己的证书信息
- Client Key Exchange。它与上文中的 Server Key Exchange 类似,是对客户端 Certificate 信息的补充,在本次请求中同样是补充了客户端证书的公钥信息
- Certification Verity。对服务端发送的证书信息进行确认
- Change Cipher Specc。该协议不同于其他握手协议(Handshake Protocol),而是作为一个独立协议告知服务端,客户端已经接收之前服务端确认的加密套件,并会在后续通信中使用该加密套件进行加密
- Encrypted Handshake Message。用于客户端给服务端加密套件加密一般 Finish 的数据,用以验证这条建立起来的加解密通道的正确性
- 第四,服务端在接收客户端的确认信息及验证信息后,会对客户端发送的数据进行确认
- Change Cipher Spec。通过使用私钥对客户端发送的数据进行解密,并告知后续将协商好的加密套件进行加密传输数据
- Encrypted Handshake Message。与客户端的操作相同,发送一段 Finish 的加密数据验证加密通道的正确性
- 最后,如果客户端和服务端都确认加解密无误后,各自按照之前约定的 Session Secret 对 Application Data 进行加密传输
- 黑客与安全
- 编程语言的发展
第二章 面向对象
- OOP理念
- OOP目标
- 可维护性、可重用性、可扩展性
- 面向过程与面向对象
- 面向过程是让计算机有步骤地顺次做一件事,是一种过程化的叙事思维。面向过程的结构相对松散,强调如何流程化地解决问题
- 面向对象是提出一种计算机世界里解决复杂软件工程的方法论,拆解问题复杂度,从人类思维角度提出解决问题的步骤和方案。面向对象的思维更加内聚,强调高内聚、低耦合,先抽象模型,定义共性行为,再解决实际问题
- 面向对象的特性
- 面向对象有三大特性:封装、继承、多态。本书明确将“抽象”作为面向对象的特性之一
- 封装是一种对象功能内聚的表现形式,使模块之间的聚合度变低,更具维护性
- 继承使子类能够继承父类,获得父类的部分属性和行为,使模块更有复用性
- 多态使模块在复用性基础上更加有扩展性,使运行期更有想象空间
- 抽象分为归纳和演绎。归纳是从具体到本质,从个性到共性,将一类对象的共同特征进行归一化的逻辑思维过程;演绎是从本质到具体,从共性到个性,逐步形象化的过程
- Object类
- 我是谁:getClass() 说明本质上是谁,而toString()是当前职位的名片
- 我从哪里来:Object() 构造方法是生产对象的基本步骤,clone() 是繁殖对象的另一种方式
- 我到哪里去:finalize() 是在对象销毁时触发的方法
- 世界是否因你而不同:hashCode() 和 equals() 就是判断与其他元素是否相同的一组方法
- 与他人如何协调:wait() 和 notify() 是对象间通信与协作的一组方法
- OOP目标
- 初识 Java
- 类
-
类的定义
- 类的定义由访问级别、类型、类名、是否抽象、是否静态、泛型标识、继承或实现关键字、父类或接口名称等组成,类的访问级别有 public 和无访问控制符,类型分为 class、interface、enum
- Java 类主要由两部分组成:成员和方法。在定义类时,推荐首先定义变量,然后定义方法。由于公有方法是类的调用者和维护者最关心的方法,因此最好首屏展示;保护方法虽然只被子类关心,但也有可能是模板设计模式的核心方法,因此重要性仅次于公有方法;而私有方法对外来说是一个黑盒,因此不需要被特别关注,最后是 getter/setter 方法,虽然他们是公共方法,一般不包含业务逻辑,所以放在最后
-
接口与抽象类
- 定义类的过程就是抽象和封装的过程,而接口与抽象类则是对实体类进行更高层次的抽象,仅定义公共行为和特征。接口与抽象类的共同点是都不能被实例化,但可以定义引用变量指向实例对象
- 抽象类在被继承时体现的是 is-a 关系,即抽象类是对同类事物相对具体的抽象,通常包含抽象方法、实体方法、属性变量。如果一个抽象类只有一个抽象方法,他就等同于一个接口,它体现的是里氏替换原则;接口在被实现时体现的是 can-do 关系,即实现类有能力去实现并执行接口种定义的行为,它体现的是接口隔离原则
- 抽象类是模板式设计,接口时契约式设计
-
内部类
- 在一个 .java 源文件中,只能定一个类名与文件名完全一致的公开类,使用 public class 关键字来修饰。但在面向对象语言中,任何一个类都可以在内部定义另外一个类,前者为外部类,后者为内部类。内部类本身就是类的一个属性,与其他属性定义方式一致。例如,属性字段 private static String str,由访问控制符、是否静态、类型、变量名组成,而内部类 private static class Inner{},这样来定义的,类型为 class、enum 甚至 interface。内部类可以是静态和非静态的。具体分类为四种
- 静态内部类:static class StaticInnerClass{};
- 成员内部类:private class InstanceInnerClass{};
- 局部内部类:定义在方法或表达式内部
- 匿名内部类:(new Thread(){}).start()
public class OuterClass{ // 成员内部类 private class InstanceInnerClass{} // 静态内部类 static class StaticInnerClass{} public static void main(String[] args){ // 两个匿名内部类 分别对应 OuterClass$1 和 OuterClass$2 (new Thread() {}).start(); (new Thread() {}).start(); // 两个方法内部类 分别对应 OuterClass$1MethodClass1 和 OuterClass$1MethodClass2 class MethodClass1{} class MethodClass2{} } } ```![在这里插入图片描述](https://img-blog.csdnimg.cn/20210218143954704.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L2xiaF9wYW9wYW8=,size_16,color_FFFFFF,t_70#pic_center) * 无论时什么类型的内部类,都会编译成一个独立的 .class 文件。外部类与内部类之间使用 $ 符号分隔,匿名内部类使用数字进行编号,而方法内部类,在类名前还有一个编号来识别时哪个方法。静态内部类时最常用的内部表现形式,外部可以使用 OuterClass.StaticInnerClass 直接访问,类加载与外部类在同一个阶段进行,在 JDK 源码中,定义包内可见静态内部类的方式很常见,这样的好处是: * 作用域不会扩散到包外 * 可以通过“外部类.内部类”的方式直接访问 * 内部类可以访问外部类中所有静态属性和方法
- 在一个 .java 源文件中,只能定一个类名与文件名完全一致的公开类,使用 public class 关键字来修饰。但在面向对象语言中,任何一个类都可以在内部定义另外一个类,前者为外部类,后者为内部类。内部类本身就是类的一个属性,与其他属性定义方式一致。例如,属性字段 private static String str,由访问控制符、是否静态、类型、变量名组成,而内部类 private static class Inner{},这样来定义的,类型为 class、enum 甚至 interface。内部类可以是静态和非静态的。具体分类为四种
-
访问权限控制
- 面向对象的核心思想之一是封装,只把有限的方法和成员公开,这也是迪米特法则的内在要求,使外部调用方对方法体内的实现细节知道的尽可能少,至于实现封装的方式,需要使用某些关键字来限制类外部对类属性和方法的随意访问
- 访问权限控制符
- public:可以修饰外部类、属性、方法,表示公开的、无限制的,是访问限制最松的一级,被其修饰的类、方法不仅可以被包内访问,还可以跨类、挎包访问,真是允许跨工程访问
- protected:只能修饰属性和方法,表示受保护的、有限制的,被其修饰的属性和方法被包内及包外子类访问。注意,即使并非继承关系,protected 属性和方法在同一包内也是可见的
- 无:即无任何访问权限控制符,千万不要说成是 default,它并非访问权限控制符的关键字,另外,在 JDK8 接口中引入 default 默认方法实现,更容易混淆两者释义。无访问权限控制符仅对包内可见。虽然无访问权限控制符还可以修饰外部类,但定义外部类极少使用无控制符的方式,要么定义为内部类,功能内聚,要么定义为公开类,即 public class,包外可以实例化
- private: 只修饰属性、方法、内部类。表示“私有的”,是访问限制最严格的一级,被其修饰的属性或方法只能在该类内部访问,子类、包内均不能访问,更不允许跨包访问
- 访问权限控制符的使用
- 如果不允许外部直接通过 new 创建对象,构造方法必须是 private
- 工具类不允许有 public 或 default 构造方法
- 类非 static 成员变量并且与子类共享,必须是 protected
- 类非 static 成员变量并且仅在本类使用,必须是 private
- 类 static 成员变量如果仅在本类使用,必须是 private
- 若是 static 成员变量,必须考虑是否为 final
- 类成员方法只供类内部调用,必须是 private
- 类成员方法只对继承类公开,那么限制为 protected
-
this和super
- this 和 super 在很多情况下是可以省略的
- 本类方法调用本类属性
- 本类方法调用另一个本类方法
- 子类构造器隐含调用 super()
- 任何类在创建之初,都有一个默认的空构造方法,它是 super() 的一条默认通路,构造方法的参数列表决定了调用通路的选择;如果子类指定调用父类的某个构造方法,super 就会不断往上溯源;如果没有指定则默认使用 super()。如果父类没有提供默认的构造方法,子类在继承时就会编译错误。如果父类坚持不提供默认的无参构造器,必须在本类的无参构造方法中使用 super() 方法调用父类的有参构造方法,如 public Son(){super(123);}
- 一个示例变量可以通过 this. 赋值另一个实例变量;另一个实例方法可以通过 this. 调用另一个实例方法;甚至一个构造方法都可以通过 this. 调用另一个构造方法。如果 this 和 super 指代构造方法,则必须位于方法体的第一行。换句话说,在一个构造方法中,this 和 super 只能出现一个,且只能出现一次,否则在实例化对象时,会因子类调用到多个父类构造方法而造成混乱
- 由于 this 与 super 都在实例化阶段调用,所以不能在静态方法和静态代码块中使用 this 和 super 关键字。this 还可以指代当前对象,比如在同步代码块 synchronized(this){…} 中,super 并不具备此能力,但 super 也有自己的特异功能,在子类覆盖父类方法时,可以使用 super 调用父类同名的实例方法
- this 和 super 在很多情况下是可以省略的
-
类关系
- 类与类之间的关系可以包含如下 5 种类型
- 继承(extends is-a)
- 实现(implements can-do)
- 组合(类是成员变量 contains-a):类关系种的组合是一种完全绑定的关系,所有成员共同完成一件使命,它们的生命周期是一样的。组合体现的是非常强的整体与部分的关系,同生同死,部分不能再整体之间共享
- 聚合(类是成员变量 has-a):聚合是一种可以拆分的整体与部分的关系,是非常松散的暂时组合,部分可以被拆分出来的另一个整体
- 依赖(import类 use-a):除组合和聚合外的类与类之间的关系,这个类只要 import,那就是依赖关系
- 类与类之间的关系可以包含如下 5 种类型
-
序列化
- 序列化概念
- 内存中的数据对象只有转换为二进制流才可以进行数据持久化和网络传输。将数据对象转换为二进制流的过程称为对象的序列化(Serialization)。反之,将二进制流恢复为数据对象的过程称为反序列化(Seserialization)。序列化需要保留充分的信息以恢复数据对象,但是为了节约存储空间和网络带宽,序列化后的二进制流又要尽可能小。序列化常见的场景是 RPC 框架的数据传输
- 序列化分类
- Java 原生序列化
- Java 类通过实现 Serializable 接口来实现该类对象的序列化,这个接口非常特殊,没有任何方法,只起标识作用。Java 序列化保留了对象类的元数据(如类、成员变量、继承类信息等),以及对象数据等,兼容性最好,但不支持跨语言,而且性能一般。实现 Serializable 接口的类建议设置 serialVersionUID 字段值,如果不设置,那么每次运行时。编译器会根据类的内部实现,包括类名、接口名、方法和属性等来自动生成 serialVersionUID。如果类的源代码有修改,那么重新编译后 serialVersionUID 的取值可能会发生变化,因此实现 Serialzable 接口的类一定要显式的定义 serialVersionUID 属性值。修改类时需要根据兼容性决定是否修改 serialVersionUID值:
- 如果是兼容升级,请不要修改 serialVersionUID 字段,避免反序列失败
- 如果是不兼容升级,需要修改 serialVersionUID 值,避免反序列化混乱
- 使用 Java 原生序列化需注意,Java 反序列化不会调用类的无参构造器,而是调用 native 方法将成员变量赋值为对应类型的初始值。基于性能考虑,不推荐使用 Java 原生序列化
- Java 类通过实现 Serializable 接口来实现该类对象的序列化,这个接口非常特殊,没有任何方法,只起标识作用。Java 序列化保留了对象类的元数据(如类、成员变量、继承类信息等),以及对象数据等,兼容性最好,但不支持跨语言,而且性能一般。实现 Serializable 接口的类建议设置 serialVersionUID 字段值,如果不设置,那么每次运行时。编译器会根据类的内部实现,包括类名、接口名、方法和属性等来自动生成 serialVersionUID。如果类的源代码有修改,那么重新编译后 serialVersionUID 的取值可能会发生变化,因此实现 Serialzable 接口的类一定要显式的定义 serialVersionUID 属性值。修改类时需要根据兼容性决定是否修改 serialVersionUID值:
- Hession 序列化
- Hessian序列化是一种支持动态类型、跨语言、基于对象的传输的网络协议。Java 对象序列化的二进制流可以被其他语言(如c++、Python)反序列化。Hession 协议具有如下特性:
- 自描述序列化类型。不依赖外部描述文件或接口定义,用一个字节表示常用基础类型,极大缩短二进制流
- 语言无关,支持脚本语言
- 协议简单,比 Java 原生序列化高效
- 相比 Hessian 1.0,Hessian 2.0 中增加了压缩编码,其序列化二进制流大小是 Java 序列化的 50%,序列化耗时是 Java 序列化的 30%,反序列化耗时是 Java 反序列化的 20%
- Hessian 会把复杂对象的所有属性存储在一个 Map 中进行序列化,所以在父类、子类存在同名成员变量的情况下,Hessian 序列化时,先序列化子类,然后序列化父类,因此反序列化结果会导致子类同名成员变量被父类的值覆盖
- Hessian序列化是一种支持动态类型、跨语言、基于对象的传输的网络协议。Java 对象序列化的二进制流可以被其他语言(如c++、Python)反序列化。Hession 协议具有如下特性:
- JSON 序列化
- JSON 是一种轻量级的数据交换格式。JSON 序列化就是将数据对象转换为 JSON 字符串。在序列化过程中抛弃了类型信息,所以反序列化时只有提供类型信息才能准确地反序列化
- Java 原生序列化
- 序列化过程中的注意点
- 总会有一些数据称为黑客的攻击点,所以有些对象的敏感属性不需要进行序列化传输,可以加 transient 字段,避免把此属性信息转化为序列化的二进制流。如果一定要传递对象的敏感属性,可以使用对称与非对称加密方式独立传输,再使用某个方法把属性还原到对象中
- 序列化概念
-
方法
- 方法签名
- 方法签名包括方法名称和参数列表,是 JVM 标识方法的唯一索引,不包括返回值,更不包括访问权限控制符、异常类型等。假如返回值可以是方法签名的一部分,仅从代码可读性角度来考虑,如下实例中,类型推断的 var 到底接受到的是 1.0d 还是 1L?从静态阅读的角度,根本无从知道它调用的是哪个方法
long f(){ return 1L; } double f(){ return 1.0d; } var a = f();
- 方法签名包括方法名称和参数列表,是 JVM 标识方法的唯一索引,不包括返回值,更不包括访问权限控制符、异常类型等。假如返回值可以是方法签名的一部分,仅从代码可读性角度来考虑,如下实例中,类型推断的 var 到底接受到的是 1.0d 还是 1L?从静态阅读的角度,根本无从知道它调用的是哪个方法
- 参数
- 参数又叫 parameter,在代码注释中用 @param 表示参数类型。参数在方法中,属于方法签名的一部分,包括参数类型和参数个数,多个参数用逗号相隔,在代码风格中,约定每个逗号都必须要有一个空格,不管是形参,还是实参。形参是在方法定义阶段,而实参是在方法调用阶段,先来看看实参传递给形参的过程:
public class ParamPassing{ private static int intStatic = 222; private static String stringStatic = "old stirng"; private static StringBuilder stringBuilderStatic = new StringBuilder("old stringBuilder"); public static void main(String[] args){ // 实参调用 method(intStatic); method(stringStatic); method(stringBuilderStatic, stringBuilderStatic); // 输出依然是222(第一处) System.out.println(intStatic); method(); // 无参方法调用之后,反而修改为888(第二处) System.out.println(intStatic); // 输出依然是:old string System.out.println(stringStatic); // 输出结果参考下方分析 System.out.println(stringBuilderStatic); } // A方法 public static void method(int intStatic){ intStatic = 777; } // B 方法 public static void method(){ intStatic = 888; } // C 方法 public static void method(String stringStatic){ // String 是immutable 对象,String没有提供任何方法用于修改对象 stringStatic = "new string"; } // D 方法 public static void method(StringBuilder stringBuilderStatic1, StringBuiler stringBuilderStatic2){ // 直接使用参数引用修改对象(第三处) stringBuilderStatic1.append(".method.First-"); stringBuilderStatic2.append("method.second-"); // 引用重新赋值 stringBuilderStatic1 = new StringBuilder("new stringBuilder"); stringBuilderStatic1.append("new methods`s append"); } }
- 解析
- 第一二处:有参的 A 方法字节码如图所示,参数是局部变量,拷贝静态变量的 777,并存入虚拟机中的局部变量表的第一个小格子内。虽然在方法内部的 intStatic 与静态变量同名,但是因为作用域就近原则,它是局部变量的参数,所有的操作与静态变量是无关的。而无参的 B 方法字节码先把本地赋值的 888 压入虚拟机栈的操作栈中,然后给静态变量 intStatic 赋值
// 第一处 1. SIPUSH 777 2. ISTORE 0 3. RETURN // 第二处 1. SIPUSH 888 2. PUTSTATIC ParamPassing.intStatic: 3. RETURN
- 第三处:注意上述字节码中的两个 ALOAD 0,是把静态变量的引用赋值给虚拟机栈的栈帧中的局部变量表,然后 ALOAD 操作是把两个对象引用变量压入操作栈的栈顶,注意,这两个引用都指向了静态引用变量执行的 new StringBuilder(“old stringBuilder”) 对象在 method(stringBuilderStatic, stringBuilderStatic) 的执行结果后的值,其中红绿字符串分别是两次 append 的结果:old stringBuilder.method.first-method.second-。在 D 方法中,new 出来一个新的 StringBuilder 对象,赋值为 stringBuilderStatic1。注意,这是一个新的局部引用变量,使用 ASTORE 命令对局部变量表的第一个位置的引用变量值进行了覆盖,然后再重新进行 ALOAD 到操作栈顶,所以后续对于 stringBuilderStatic1 的 append 操作,与类的静态引用变量 stringBuilderStatic 没有任何关系
public static method(Ljava/lang/StringBuilder;Ljava/lang/StringBuilder;)v L0 ALOAD 0 LDC ".method.first" INVOKEVIRTUAL java/lang/StringBuilder.append (Ljava/lang/String;) Ljava/lang/StringBuilder; POP L1 ALOAD 1 LDC "method.second" INVOKEVIRTUAL java/lang/StringBuilder.append (Ljava/lang/String;) Ljava/lang/StringBuilder; POP L2 NEW java/lang/StringBUilder DUP LDC "new stringBuilder" INVOKESPECIAL java/lang/StringBuilder.<init> (Ljava/lang/String;) V ASTORE 0 L3 ALOAD 0 LDC "new method's append" INVOKEVIRTUAL java/lang/StringBuilder.append (Ljava/lang/String;) Ljava/lang/StringBUilder; POP RETURN
- 所以,无论是对基本数据类型,还是引用变量,Java 中的参数传递都是值复制的过程,对于引用变量,复制指向对象的首地址,双方都可以通过自己的引用变量修改指向对象的相关属性
- 第一二处:有参的 A 方法字节码如图所示,参数是局部变量,拷贝静态变量的 777,并存入虚拟机中的局部变量表的第一个小格子内。虽然在方法内部的 intStatic 与静态变量同名,但是因为作用域就近原则,它是局部变量的参数,所有的操作与静态变量是无关的。而无参的 B 方法字节码先把本地赋值的 888 压入虚拟机栈的操作栈中,然后给静态变量 intStatic 赋值
- 解析
- 可变参数
- 可变参数是在 jdk5 版本中引入的,主要为了解决当时反射机制和 printf 方法问题,适用于不确定参数个数的场景。可变参数通过"参数参数类型…"的方式定义
- 参数预处理
- 入参保护
- 入参保护本质上是对服务提供方的保护,常见于批量接口,如批量修改个数较多导致服务器宕机等
- 参数校验
- 基于防御式变成理念,在方法内,无论是对方法调用方传入参数的理性不信任还是对参数有效值的校验都是有必要的
- 需要进行参数校验的场景
- 调用频度低的方法
- 执行时间开销很大的接口
- 需要极高稳定性和可用性的接口
- 对外提供的开放接口
- 敏感权限入口
- 不需要进行参数校验的场景
- 极有可能被循环调用的方法。(需要在方法里注明外部参数校验)
- 底层调用频度高的方法
- 声明成 private 只会被自己代码调用的方法
- 入参保护
- 参数又叫 parameter,在代码注释中用 @param 表示参数类型。参数在方法中,属于方法签名的一部分,包括参数类型和参数个数,多个参数用逗号相隔,在代码风格中,约定每个逗号都必须要有一个空格,不管是形参,还是实参。形参是在方法定义阶段,而实参是在方法调用阶段,先来看看实参传递给形参的过程:
- 构造方法
- 介绍
- 构造方法(Constructor)是方法名与类名相同的特殊方法,在新建对象时调用,可以通过不同的构造方法实现不同方式的对象初始化
- 特征
- 构造方法名称必须与类名相同
- 构造方法是没有返回类型的,即使是 void 也不能有。他返回对象的地址,并赋值给引用变量
- 构造方法不能被继承,不能被覆写,不能被直接调用。调用的途径有三种:一是通过 new 关键字;二是在子类的构造方法中通过 super 调用父类的构造方法;三是通过反射的方式获取并使用
- 类定义时提供了默认的无参构造器方法。但是如果显式定义了有参构造器方法,则此无参构造方法就会被覆盖,如果依然想要拥有,就需要显式定义
- 构造方法可以私有。外部无法使用私有构造方法创建对象
- 使用
- 接口中不能定义构造方法,在抽象类中可以定义
- 枚举类中,可以定义构造方法,但不能加 public 修饰,默认是 private。是绝对的单例,不允许外部以创建对象的方式生成枚举对象
- 一个类可以有多个参数不同的构造方法,称为构造方法的重载
- 构造方法的使命就是在构造对象时进行传参操作,所以不应该在构造方法中引入业务逻辑。单一职责对构造方法是适用的。如果一个对象在生产中,需要完成初始化上下游对象、分配内存、执行静态方法、赋值句柄等繁重的工作,其中某个步骤出错,导致没有完成对象初始化,再寄希望与业务逻辑部分来处理异常就是不受控制的事情了,所以推荐将初始化业务逻辑方法某个方法中,例如 init(),当对象确认完成所有初始化工作之后再显式调用
- 示例
class Son extends Parent { static { System.out.println("Son 静态代码块"); } Son() { System.out.println("Son 构造方法"); } public static void main(String[] args) { new Son(); new Son(); } } class Parent { static { System.out.println("Parent 静态代码"); } public Parent() { System.out.println("Parent 构造方法");} } // 输出 // Parent 静态代码块 // Son 静态代码块 // Parent 构造方法 // Son 构造方法 // Parent 构造方法 // Son 构造方法
- 介绍
- 类内方法
- 介绍
- 在面向过程的语言中,几乎所有方法都是全局静态方法,在引入面向对象理念后,某些方法才归属于具体对象,即类内方法。构造方法无论是有形、无形、私有、公有,在一个类中必然是存在的。除构造方法外,类中还有三类方法:实例方法、静态方法、静态代码块
- 实例方法:又称为非静态方法。实例方法比较简单,它必须依附于某个实际对象,并可以通过引用变量调用其方法。类内部各个实例方法之间可以相互调用,也可以直接读写类内变量,但是不包含 this。当 .class 字节码文件加载之后,实例方法并不会被分配方法入口地址,只有在对象创建之后才会被分配。实例方法可以调用静态变量和静态方法,当从外部创建对象后,应尽量使用"类名.静态方法"来调用,而不是对象名,一来为编译器减负,二来提升代码可读性
- 静态方法:又称类方法,当类加载后,即分配了响应的内存空间。通常静态方法都是用于定义工具类的方法的,静态方法如果使用了可修改的对象,那么在并发时会存在线程安全问题。需要注意的是:一静态方法中不能使用实例成员变量和实例方法;二静态方法不能使用 super 和 this 关键字,这两个关键字指代的都是需要被创建出来的对象。
- 静态代码块:在代码的执行方法体中,非静态代码块和静态代码块比较特殊。非静态代码块又称为局部代码块,实际不推荐的处理方式。而静态代码块在类加载的时候就被调用且只调用一次。静态代码块是先于构造方法执行的特殊代码块。今天太代码块不能存在于任何方法体内,包括类静态方法和属性变量
public class StaticCode { // prior 必须定义在last 前边,否则编译出错:illegal forword reference static String prior = "done"; // 以此调用 f() 的结果,三目运算符为 true,执行 g(),最后赋值成功 static String last = f() ? g():prior; public static boolean f(){ return true; } public static String g(){ return "hello world"; } static { // 静态代码块可以访问静态变量和静态方法 System.out.println(last); g(); } }
- 介绍
- getter与setter
- 介绍
- 在实例方法中有一类特殊的方法,即 getter 与 setter 方法,它们一般不包含任何业务逻辑,仅仅是类成员属性提供读取和修改的方法
- 优势
- 满足面向对象语言封装的特性。尽可能将类中的属性定义为 private,针对属性的访问和修改需要使用相应的 getter 与 setter 方法,而不是直接对 public 的属性进行读取和修改
- 有利于统一控制。在反射中尤为明显
- POJO 类
- 常见的 POJO 类包括 DO(Domain Object)、BO(Business Object)、DTO(Data Transfer Object)、VO(View Object)、AO(Application OBject)
- POJO 作为数据载体,通常用于数据传输,不应该包含任何业务逻辑
- 介绍
- 同步与异步
* - 覆写
- 介绍
- 多态中的 override。如果父类定义的方法达不到子类的期望,那么子类可以重新实现方法覆盖父类的实现。因为有些子类是延迟加载的,甚至是网络加载的,所以最终的实现需要在运行期判断,这就是所谓的动态绑定。动态绑定是多态性得以实现的重要因素,元空间有一个方法表保存着每个可以实例化类的方法信息, 可以通过方法表快速地激活实例方法。如果某个类覆写了父类的某个方法,则 JVM 方法表中的方法指向引用会指向子类的实现处。代码通常是用这样的方式来调用子类的方法,通常这也被称作向上转型
- 条件
- 访问权限不能变小
- 返回类型能够向上转型称为父类的返回类型
- 异常也要能向上转型成为父类的异常
- 方法名、参数类型及个数必须严格一致
- 介绍
- 方法签名
-
重载
- 介绍
- 在同一个类中,如果多个方法有相同的名字、不同的参数,即成为重载。在编译器的眼里,方法名称 + 参数类型 + 参数个数组成一个唯一键,称为方法签名,JVM 通过这个唯一键决定调用哪个重载的方法。注意,方法返回值并非是这个组合体中的一员,所以使用重载机制时,不能有两个方法名称完全相同,参数类型和个数也相同但是返回类型不同的方法
public class SameMethodSignature { public void methodForOverload () {} // 编译出错,返回值并不是方法签名的一部分 public final int methodForOverload () { return 7; } // 编译出错,访问控制符也不是方法签名的一部分 private void methodForOverload () {} // 编译出错,静态标识符也不是方法签名的一部分 public static void methodForOverload () {} // 编译出错,final标识符也不是方法签名的一部分 private final void methodForOverload () {} }
- 在同一个类中,如果多个方法有相同的名字、不同的参数,即成为重载。在编译器的眼里,方法名称 + 参数类型 + 参数个数组成一个唯一键,称为方法签名,JVM 通过这个唯一键决定调用哪个重载的方法。注意,方法返回值并非是这个组合体中的一员,所以使用重载机制时,不能有两个方法名称完全相同,参数类型和个数也相同但是返回类型不同的方法
- 重载的优先顺序(从上到下优先级依次降低)
- 精确匹配
- 如果是基本数据类型,自动转换成更大表示范围的基本类型
- 通过自动拆箱与装箱
- 通过子类向上转型继承路线依次匹�����
- 通过可变参数匹配
- 注意
- 下列方式也是可以编译通过的
public void methodForOverload(int paraml, Integer param2) {} public void methodForOverload(Integer param3, int param4) {} // 若调用 methodForOverload(13, 14) 会抛异常
- 下列方式也是可以编译通过的
- 介绍
-
泛型
- 介绍
- 泛型的本质是类型参数化,解决不确定具体对象类型的问题
- 应用
- 泛型可以定义在类、接口、方法中,编译器通过识别尖括号和尖括号内的字母来解析泛型。在泛型定义时,约定成俗的符号包括:E 代表 Element,用于集合中的元素;T 代表 the Type of object,表示某个类;K 代表 Key、V 代表 Value,用于键值对元素
public class GenericDefinitionDemo<T> { static <String, T, Alibaba> String get(String string, Alibaba alibaba){ return string; } public static void main(String[] args){ Integer first = 222; Long second = 333L; Integer result = get(first,second); } }
- 尖括号里的每个元素都指代一种未知类型,Sting 并不是 java.lang.String,而仅仅是一个代号。类名后定义的泛型 和 get() 前定义的 是两个指代,完全不同,互不影响
- 尖括号的位置非常讲究,必须在类名之后或方法返回值之前
- 泛型在定义处只具备执行 Object() 方法的能力。因此想在 get() 内部执行 string.longValue()+aliaba.intValue() 是做不到的,此时泛型只能调用 Object 类中的方法,如 toString()
- 对于编译之后的字节码指令,其实没有这些方法签名,充分说明反省只是一种编写代码时的语法检查。在使用泛型元素时,会执行强制类型转换
INVOKESTATIC com/alibaba/easy/coding/generic/GenericDefinitionDemo.get (Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; CHECKCAST java/lang/Integer
- 类型擦除
- CHECKCAST 指令在运行时会检查对象实例的类型是否匹配,如果不匹配,则抛出运行时异常 ClassCastException。与 C++ 根据模板类生成不同的类的方式不同,Java 使用的是类型擦除的方式。编译后 get() 的参数是两个 Object 返回值也是 Object,尖括号里很多内容消失了,参数中也没有 String 和 Alibaba 两个类型。数据返回给 Integer result 时,进行了类型强制转化。因此,泛型就是在编译期增加了一道检查而己,目的是促使程序员在使用泛型时安全放置和使用数据
- 泛型可以定义在类、接口、方法中,编译器通过识别尖括号和尖括号内的字母来解析泛型。在泛型定义时,约定成俗的符号包括:E 代表 Element,用于集合中的元素;T 代表 the Type of object,表示某个类;K 代表 Key、V 代表 Value,用于键值对元素
- 优势
- 类型安全。放置的什么取出的就是什么
- 提升可读性。从编码阶段就显示地知道泛型集合、泛型方法等处理地对象类型是什么
- 代码重用。泛型合并了同类型的处理代码,使代码重用度变高
- 介绍
-
数据类型
- 基本数据类型
- 基本数据类型是指不可再分的原子数据类型,内存中直接存储此类型的值,通过内存地址即可直接访问到数据,并且此内存区域只能存放这种类型的值。它们不具备对象的特征,没有属性和行为。Java 的 9 中基本数据类型包括 boolean、byte、char、short、int、long、float、double和refvar。前八中数据类型表示生活中的真假、字符、整数和小数,最后一种 refvar 是面向对象世界中的引用变量,也叫引用句柄
- 默认值
- 虽然默认值都与 0 有关,但是它们之间是存在区别的。比如,boolean 的默认值以 0 表示 false,JVM 并没有针对 Boolean 数据类型进行赋值的专用字节码指令,boolean flag = false 就是用 ICONST_0,即常数 0 来进行赋值;byte 的默认值以一个字节的 0 表示,在默认值的表示上使用了了强制类型转化, float 的默认值以单精度浮点数 0.0f 表示,浮点的 0.0 使用后缀 f 和 d 区别标识;char 的默认值只能是单引号的’\u0000’ 表示 NUL,注意不是 null,它就是一个空的不可见字符,在码表中是第一个,其码值 0 ,与 ‘\n’ 换行之类的不可见控制符的理解角度是一样的。注意,不可以用双引号方式对 char 进行赋值,那是字符串的表示方式。在代码中直接出现的没有任何上下文的 0 和 0.0 分别默认为 int 和 double 类型,可以使用 JDK10 的类型推断证明 var a=0; Long b=a:代码编译出错,因为在自动装箱时,默认是 int 类型,自动装箱为 Integer,无法转化为 Long 类型
- 符号
- 所有数值类型都是有符号的,因为浮点数无法表示零值,所以表示范围分为两个区间:正数区间和负数区间,float 和 double 的最小值与最大值均指正数区间,它们对应的包装类并没有缓存任何数值
- 引用数据类型
- 引用数据类型分为引用变量本身和引用指向的对象。本书把引用变量称为 refvar,而把引用指向的实际对象简称为 refobj
- refvar是基本的数据类型,它的默认值是 null,存储 refobj 的首地址,可以直接使用双等号 == 进行等值判断,而平时使用的 refvar.hashCode() 返回的值,只是对象的某种哈希计算,可能与地址有关,与 refvar 本身存储的内存单元地址是两回事。作为一个引用变量,不管它是指向包装类、集合类、字符串类还是自定义类,refvar 均占用 4B 空间,对于 refobj 来说,无论多么小的对象,最小占用的存储空间是 12B(用于存储基本信息,称为对象头),但由于存储空间分配必须是 8B 的倍数,所以初始分配的空间至少是 16B。一个 refvar 之多存储一个 refobj 的首地址,一个 refobj 可以被多个 refvar 存储下它的首地址,即一个堆内对象可以被多个 refvar 引用指向。如果 refobj 没有被任何 refvar 指向,那么它迟早会被垃圾回收,而 refvar 的内存释放,与其他基本数据类型。由于 refobj 对象的基础大小是 12B,再加上 int 是 4B,所以 Integer 实例对象占用 16B,按此推算 Double 对象占用的存储容量是 24B
- 对象结构
- 对象头(Object Header)
- 对象头占用 12 个字节,存储内容包括对象标记(markOop)和类元信息(klassOop)。对象标记存储对象本身运行时的数据,如哈希码、GC标识、锁信息、线程关联信息等,这部分数据在 64 位 JVM 上占用 8 个字节,称为 “Mark Word”。为了存储更多的状态信息,对象标记的存储是非固定的(与JVM实现有关),类元信息存储的是对象指向它的类元数据(即Klass)的首地址,占用 4 个字节,与 refvar 一样
- 实例数据(Instance Data)
- 存储本类对象的实例成员变量和所有可见的父类成员变量,如 Integer 的实例成员只有一个 private int value,占用 4 个字节,所以加上对象头为 16 个字节;再如,上述示例代码的 RefObjDemo 对象大小为 48 个字节,一个子类 RefObjSon 继承 RefObjDemo,即使子类内部是空的,new RefObjSon 的对象也占用 48 个字节
- 对齐填充(Padding)
- 对象的存储空间分配单元是 8 个字节,如果一个占用大小为 16 个字节的对象,增加一个成员变量 byte 类型,此时需要占用 17 个字节,但是也会分配 24 个字节进行对齐填充操作
- 对象头(Object Header)
- 包装类型
- 推荐所有包装类对象之间值的比较,全部使用 equals() 方法
- 只有 Integer 可以修改缓存范围,-XX:AutoBoxCacheMax=xxx
- 推荐
- 所有的 POJO 类型属性必须使用包装数据类型
- RPC 方法的返回值和参数必须使用包装数据类型
- 所有局部变量推荐使用基本数据类
- 字符串
- 介绍
- 字符串相关类型主要有三种,String、StringBuilder、StringBuffer
- String 是只读字符串,典型的 immutable 对象,对它的任何改代,其实都是创建一个新对象,再引用指向该对象。String 对象赋值操作后,会在常量池中进行缓存,如果下次再申请创建对象时,缓存中已经存在,则直接返回响应引用给创建者
- StringBuffer 可以在原对象上进行修改,是线程安全的。JDK5 引入的 StringBuilder 与 StringBuffer 均继承自 AbstractStringBuilder,两个子类的很多方法都是通过"super.方法()"的方式调用抽象父类中的方法,此抽象类在内部与 String 一样,也是以字符数组的形式存储字符串的
- StringBuilder 是非线程安全的,但效率高于 StringBuffer
- 使用
- 在非基本数据类在对象中,String 是仅支持直接相加操作的对象,但在循环体内,字符串的连接方式应该是 StringBuilder 的 append 方法进行扩展
- 介绍
- 基本数据类型
-
第三章 代码风格
- 命名规约
- 命名约定
- 命名符合本语言特性
- 命名体现代码元素特征
- 命名最好望文知义
- 常量
- 常量是在作用域内保持不变的值,一般用 final 关键字进行修饰,根据作用域区分,分为全局常量、类内常量、局部常量。全局常量是指类的公开静态属性,使用 public static final 修饰;类内常量是私有静态属性,使用 private static final 修饰;局部常量分为方法常量和参数常量,前者是在方法或代码块内定义的常量,后者是在定义形式参数时,增加 final 标识,表示此参数不能被修改。全局常量和类内常量时最主要的常量表现形式,他们的命名方式比较特殊,采用字母全部大写、单词之间加下划线的方式。而局部常量采用小驼峰形式即可
- 变量
- 变量是在程序中一切通过分配内存并赋值的量,分为不可变量(常量)和可变变量。狭义来说,变量仅指在程序运行过程中可以改变其值的量,包括成员变量和局部可变变量等
- 一般情况下,变量的命名需要满足小驼峰格式,命名体现业务含义即可。存在一种特殊情况,在定义类成员变量时,特别是在 POJO 类中,针对布尔类型的变量,命名不要加 is 前缀,否则部分框架解析会引起序列化错误
- 代码展示风格
- 缩进
- 推荐使用 4 个空格缩进,禁止使用 Tab 键(不同编辑器对 Tab 解析不一致,空格在编辑器之间是兼容的)
- 空格
- 任何二目、三目运算符的左右两边都必须加一个空格
- 注释的双斜线与注释内容之间有且仅有一个空格
- 方法参数在定义和传入时,多个参数逗号后边必须加空格
- 没有必要增加若干空格使变量的赋值等号与上一行对应位置的等号对齐
- 如果是大括号内为空,则简洁地写成 {} 即可,大括号中间无须换行和空格
- 左右小括号与括号内部的相邻字符之间不要出现括号
- 左括号前需要加空格
- 空行
- 在方法定义之后
- 属性定义与方法之间
- 不同逻辑
- 不同语义
- 不同业务的代码
- 缩进
- 换行与高度
- 换行:约定单行字符数不超过 120 个,超出需要换行
- 第二行相对第一行缩进 4 个空格。从第三行开始,不再继续缩进
- 运算符与下文一起换行
- 方法调用中的多个参数需要换行时,在逗号后换行
- 在括号前不要换行
- 方法行数限制
- 约定单个方法的总行数不超过 80 行
- 换行:约定单行字符数不超过 120 个,超出需要换行
- 控制语句
- 在 if、else、for、while、do-while 等语句中必须使用大括号
- 在条件表达式中不允许有赋值操作,也不允许在判断表达式中出现复杂的逻辑组合
- 多层嵌套不得超过 3 层
- 避免采用去反逻辑运算符
- 命名约定
- 代码注释
- 注释三要素
- Nothing is strange
- Less is more
- Advance with the times
- 注释格式
- Javadoc 规范
- 枚举必须使用注释
- 简单注释
- 单行注释
- 多行注释
- Javadoc 规范
- 注释三要素
第四章 走进 JVM
- 字节码
- 介绍
- 在不同的时代,不同的厂商,机器指令组成的集合是不同的。但毕竟 CPU 是底层基础硬件,指令集通常以扩展兼容的方式向前不断演进。而机器码是离 CPU 指令集最近的编码,是 CPU 可以直接解读的指令,因此机器码肯定是与底层硬件系统耦合的。那 JAVA 是如何实现跨平台的?计算机工程领域的任何问题都可以通过增加一个中间层来解决,于是字节码(Bytecode)应运而生。Java 所有的指令有 200 个左右,一个字节(8位)可以存 256 种不同的指令信息,这样的字节称为字节码(Bytecode)
- 在代码的执行过程中,JVM 将字节码解释执行,屏蔽对底层操作系统的依赖;JVM 也可以将字节码编译执行,如果是热点代码,会通过 JIT 动态地编译为机器码,提高执行效率
- 字节码指令
- 如图所示,十六进制表示的二进制流通常是一个操作指令。起始的 4 个字节非常特殊,即绿色框的 cafe babe 是 Gosling 定义的一个魔法书,意思为 Coffee Baby,其十进制值为 3405691582。它的作用是:标志该文件是一个 Java 类文件,如果没有识别到该标志,说明该文件不是 Java 类文件或者文件已受损,无法进行加载。而红色框代表当前版本号,0X37 的十进制为 55,是 JDK11 的内部版本号
- 由于字节码的纯数字比较难记,JVM 在字节码上设计了一整套操作码住记符,常见的字节码主要指令为:
- 加载或存储指令:在某个栈帧中,通过指令操作数据在虚拟机栈的局部变量表和操作栈之间来回传输
- 将局部变量加载到操作栈中
- ILOAD:将 int 类型的局部变量压入栈
- ALOAD:将对象引用的局部变量压入栈
- 从操作栈顶存储到局部变量表
- ISTORE
- ASTORE
- 将厂里加载到操作栈顶,这是极为高频使用的指令
- ICONST:加载的是 -1~5 的数(ICONST与BIPUSH的加载界限)
- BIPUSH:即 Byte Immediate PUSH,加载 -128~127 之间的数
- SOPUSH:即 Short Immediate PUSH,加载 -32768~32767 之间的数
- LDC:即 Load COnstant,在 -2147483648~2147483647 或者是字符串时,JVM 采用 LDC 指令压入栈中
- 将局部变量加载到操作栈中
- 运算指令:对两个操作栈帧上的值进行运算,并把结果写入操作栈顶
- IADD
- IMUL
- 类型转换指令:显式转换两种不同的数值类型
- I2L
- D2F
- 对象创建与访问指令:根据类进行对象的创建、初始化、方法调用相关指令
- 创建对象指令
- NEW
- NEWARRAY
- 访问属性指令
- GETFIELD
- PUTFIELD
- GETSTATIC
- 检查实例类型指令
- INSTANCEOF
- CHECKCAST
- 创建对象指令
- 操作栈管理指令:直接控制操作栈的指令
- 出栈操作
- POP:一个元素出栈
- POP2:两个元素出栈
- 复制栈顶元素并压入栈
- DUP
- 出栈操作
- 方法调用与返回指令
- INVOKEVIRTUAL:调用对象的实例方法
- INVOKESPECIAL: 调用实例初始化方法、私有方法、父类方法等
- INVOKESTATIC:调用类静态方法
- RETURN:返回 VOID 类型
- 同步指令
- JVM 使用方法结构中的 ACC_SYNCHRONIZED 表示同步方法,指令集中有 MONTORENTEDR 和 MONITOEREXIT 支持 synchronized 语义
- 加载或存储指令:在某个栈帧中,通过指令操作数据在虚拟机栈的局部变量表和操作栈之间来回传输
- 除字节码之另外,还包含了一些额外信息。例如,LINENUMBER 存储了字节码与源码行号的对应关系,方便调试的时候正确地定位到代码地所在行;LOCALVARIABLE 存储当前方法中使用到地局部变量表
- 编译
- 词法解析时通过空格分析出单词、操作符、控制符等信息,将其形成 token 信息流,传递给语法解析器
- 语法解析器时,把词法解析得到的 token 信息流按照 Java 语法规则组装成一颗语法树
- 在语义分析阶段,需要检查关键字的使用是否合理、类型是否匹配、作用域是否正确等
- 语义分析完成后,生成字节码
- 加载
- 字节码必须通过类加载过程加载到 JVM 环境后才可以执行。执行有三种模式:第一,解释执行;第二,JIT 编译执行;第三,JIT 编译与解释混合执行(主流JVM默认执行模式)
- 混合执行模式的优势在于解释器在启动时先解释执行,省去编译时间。随着时间推进,JVM 通过热点代码统计分析,识别高频的方法调用、循环体、公共模块等,基于强大的 JIT 动态编译技术,将热点代码转化为机器码,直接交给 CPU 执行。JIT 的作用是将 Java 字节码动态地编译成可以直接发送给处理器指令执行的机器码
- 注意解释执行与编译执行在线上环境微妙的辩证关系,机器在热机状态可以承受的负载要大于冷机状态(刚启动时),如果以热机状态时的流量进行切流,可能使处于冷机状态的服务器因无法承受流量而假死,在生产环境发布中,以分批的方式进行发布,根据机器数量划分成多个批次,每个批次的机器数之多占到整个集群的 1/8
- 介绍
- 类加载
- 介绍
-
在冯·诺伊曼定义的计算机模型中,任何程序都需要加载到内存才能与 CPU 进行交流,字节码.class 文件需要加载到内存中在可以实例化类。而 ClassLoader 就是提前加载 .class 类文件到内存中,在加载类时,使用的是 Parents Delegation Model,译为双亲委派模式,意译的话叫做"溯源委派加载模型"更为贴切
-
Java 的类加载器是一个运行时核心基础模块,主要在启动之初进行类的 Load、Link 和 Init,即加载、链接、初始化
- Load阶段:读取类文件产生二进制流,并转化为特定的数据结构,初步校验 cafe babe 魔法数、常量池、文件长度、是否有父类等,然后创建对应类的 java.lang.Class 实例
- Link阶段:Linkd 阶段包含验证、准备、解析三个阶段。验证时更详细的校验,比如 final 是否合规、类型是否正确、静态变量是否合理等;准备阶段是为静态变量分配默认内存,并设计默认值,解析类和方法确保类与类之间的相互引用正确性,完成内存结构布局
- Init阶段:执行类构造器 方法,如果赋值运算是通过其他类的静态方法来完成的,则马上解析另外一个类,在虚拟机栈中执行完毕后通过返回值进行赋值
-
类加载是一个将 .class 字节码文件实例化成 Class 对象并进行相关初始化的过程。在这个过程中,JVM 会初始化继承树上还没被初始化过的所有父类,并且会执行这个链路上所有未执行过的静态代码块、静态变量赋值语句等。某些类在使用时,也可以按需由类加载器进行加载
-
全小写的 class 是关键字,用来定义类,而首字母大写的 Class,它是所有 class 的类
public class ClassTest { // 数组类型有一个魔法属性:length来获取数组长度 private static int[] array = new int[3]; private static int length = array.length; // 任何小写class定义的类,也有一个魔法属性:class,来获取此类的大写 Class 类对象 private static Class<One> one = One.class; private static Class<Another> another = Another.class; public static void main(String[] args) throws Exception{ // 通过newInstance方法创建One 和Another的类对象(第一处) One oneObject = one.newInstance(); oneObject.call(); Another anotherObject = another.newInstance(); anotherObject.speak(); // 通过one这个大写的Class对象,获取私有成员属性对象Field(第二处) Field privateFieldInOne = one.getDeclaredField("inner"); // 设置私有对象可以访问和修改(第三处) privateFiledInOne.setAccessible(true); privateFiledInOne.set(oneObject, "world changed."); // 成功修改类的私有属性inner变量值未word changed。 System.out.println(oneObject.getInner()); } } class one{ private String inner = "time flier."; public void call(){ System.out.println("hello world."); } public String getInner(){ return inner; } } class Annother{ public void speak(){ System.out.println("easy coding."); } } // 输出 // hello world. // easy coding. // world changed.
-
- 类加载器分类
- 类加载器中最高一层的是 Bootstrap,它是在 JVM 启动时创建的,通常由与操作系统相关的本地代码实现,是最根据的类加载器,负责装载最核心的 Java 类,比如 Object、System、String等;第二层是在 JDK9 版本中,称为 Platform ClassLoader,即平台类加载器,用以加载一些扩展的系统类,比如 XML、加密、压缩相关的功能类等,JDK9 之前的加载器是 Extension ClassLoader;第三层是 Appliation ClassLoader 的应用类加载器,主要是加载用户定义的 CLASSPATH 路径下的类。第二、三类加载器为 Java 语言实现,用户也可以自定义类加载器
ClassLoader c = TestWhoLoad.class.getClassLoader(); ClassLoader c1 = c.getParent(); // 由于bootstrap 是通过 C/C++ 实现的,并不存在于 JVM 体系内,所以输出为 null ClassLoader c2 = c1.getParent(); // 输出 jdk8 // sun.misc.Launcher\$AppClassLoader@14dad5dc // sun.misc.launcher\$ExtClassLoader@6e0be858 // null
- 低层次的当前类加载器不能覆盖更高层次类加载器已经加载的类。如果低层次的类加载器想加载一个未知类,会一次往上询问是否存在,只有父类都不存在时才可以加载
- 通过如下代码可以查看 BootStrap 所有已经加载的类库
URL[] urls = sun.misc.Launcher.getBootstrapClassPath().getURLs(); for(java.net.URL url : urls){ System.out.println(url.toExternalForm()); }
- Boostrap 加载的路径可以追加,不建议修改或删除原来加载路径。在 JVM 中增加如下参数,则能通过 Class.forName 正常读取到指定类,说明此参数可以增加 Boostrap 的类加载路径
-Xbootclasspath/a:/Users/yangguangbao/book/easyCoding/byJdk11/src
- Boostrap 加载的路径可以追加,不建议修改或删除原来加载路径。在 JVM 中增加如下参数,则能通过 Class.forName 正常读取到指定类,说明此参数可以增加 Boostrap 的类加载路径
- 如果想在启动时观察加载了哪个 jar 包中的哪个类,可以增加 -XX:+TraceClassLoading 参数,此参数在解决类冲突时非常实用
- 类加载器中最高一层的是 Bootstrap,它是在 JVM 启动时创建的,通常由与操作系统相关的本地代码实现,是最根据的类加载器,负责装载最核心的 Java 类,比如 Object、System、String等;第二层是在 JDK9 版本中,称为 Platform ClassLoader,即平台类加载器,用以加载一些扩展的系统类,比如 XML、加密、压缩相关的功能类等,JDK9 之前的加载器是 Extension ClassLoader;第三层是 Appliation ClassLoader 的应用类加载器,主要是加载用户定义的 CLASSPATH 路径下的类。第二、三类加载器为 Java 语言实现,用户也可以自定义类加载器
- 需要自定义类加载器的地方
- 隔离加载类:例如某些中间件以应用的模块隔离,把类加载到不同的环境
- 修改类加载方式:指定某些类在某个时间点进行按需动态加载
- 扩展加载源:从数据库、网络甚至电视机机顶盒加载
- 防止源码泄露:Java代码容易呗编译和篡改,可以进行编译加密。那么类加载器也需要自定义,还原加密的字节码
- 实现自定义加载器
public class CustomerClassLoader extends ClassLoader { @Override protected Class<?> findClass(String name) throws ClassNotFoundException{ try{ byte[] result = getClassFormCustomerPath(name); if (result == null ) { throw new FileNotFoundException(); } else { return defineClass(name, result, 0, result.length); } } catch (Exception e){ e.printStackTrace(); } throws new ClassNotFoundException(name); } private byte[] getClassFromCustomerPath(String name){ // 从自定义路径中加载指定类 } } public static void main(String[] args){ CustomerClassLoader customerClassLoader = new CustomerClassLoader(); try{ Class<?> clazz = Class.forName("one", true, customerClassLoader); Object obj = clazz.newInstance(); System.out.println(obj.getClass().getClassLoader()); } catch(Exception e){ e.printStackTrace(); } } // 输出 // classloader.CustomerClassLoader@5e481248
- 介绍
- 内存布局
- 介绍
- 内存是非常重要的系统资源,是硬盘和 CPU 的中间仓库及桥梁,承载着操作系统和应用程序的实时运行。JVM 内存布局规定了 Java 在运行过程中内存申请、分配、管理的策略,保证了 JVM 的高效稳定运行
- Heap(堆区)
- Heap 是 OOM 故障最主要的发源地,它存储着几乎所有的实例对象,堆由垃圾收集器自动回收,堆区由各自子线程共享使用。堆内存的空间既可以固定大小,也可以在运行时动态地调整,通过如下参数设定初始值和最大值,比如 -Xms256M -Xmx1024M,其中-x表示它是 JVM 运行参数,ms 是 menmory start 的简称,mx 是 memory max 的简称,分别代表最小堆容量和最大堆容量。但是在通常情况下,服务器在运行过程中,堆空间不断地扩容与回缩,势必造成不必要地系统压力,所以在线上生产环境中, JVM 的 Xms 和 Xms 设置成一样大小,避免在 GC 后调整堆大小时带来的额外压力
- 堆分成两大块:新生代和老年代。对象产生之初在新生代,步入暮年时进入老年代,但是在老年代也接纳在新生代无法容纳的超大对象。新生代 = 1 个 Eden 区 + 2 个 Survivor 区。绝大部分对象在 Eden 区生成,当当 Eden 区填满的时候,会触发 Young Garbage Collection,即 YGC。垃圾回收的时候,在 Eden 区实现清楚策略,没有被引用的对象则直接回收。依然存活的对象会被移送到 Survivor 区。Survivor 区分为 S0 和 S1 两块内存空间,每次 YGC 的时候,它们将存活的对象复制到未使用的那块空间,然后将当前正在使用的空间完全清楚,交换两块空间的使用状态。如果 YGC 要移送的对象大于 Survivor 区容量的上限,则直接移交给老年代。每个对象都有一个计数器,每次 YGC 都会加 1。-XX:MaxTenuringThreshold 参数能配置计数器的值到达某个阈值的时候,对象从新生代晋升到老年代。该值默认是 15,可以在 Survivor 区交换 14 次后今生老年代
- 如果 Survivor 区无法放下,或者超大对象的阈值超过上限,则尝试在老年代进行分配,如果老年代也无法放下,则会触发 Full Garbage Collertion,即FGC。如果依然无法放下,则抛出 OOM。堆内存出现 OOM 的概率是所有内存耗尽异常中最高的。出错时的堆内存信息堆解决问题非常有帮助,所以给 JVM 设置运行参数 -XX:+HeapDumpOnOutOfMemoryError,让 JVM 遇到 OOM 异常时能输出堆内信息
- Metaspace(元空间)
- 本书采用 JDK11 版本,JVM 则为 Hotspot。早在 JDK8 版本中,元空间的前身 Perm 区已经被淘汰。在 JDK7 及之前的版本中,只有 Hotspot 才有 Perm 区,译为永久代,它在启动时固定大小,很难进行调优,并且 FGC 时会移动类元信息,在某些场景下,如果动态加载类过多,容易产生 Perm 区的 OOM(“Exception in thread ‘double client x.x connector’ java.lang.OutOfMemoryError:PermGenapce”),为了解决该问题,需要设定运行参数 -XX:MaxPermSize=1280m。JDK8 使用元空间替代永久代,在 JDK8 及以上版本中,设定 MaxPermSize 参数,JVM 在启动时并不会报错,但是会提示:Java HotSpot 64Bit Server VM warning:ignoring option MaxPermSize=2560m;support was removed in 8.0
- 区别于永久代,元空间在本地内存中分配。在 JDK8 里,Perm 区中所有内容字符串常量移至堆内存,其他内容包括类元信息、字段、静态属性、方法、常量等都移动到元空间
- JVM Stack(虚拟机栈)
- 栈(Stack)是一个先进后出的数据结构,相对于基于寄存器的运行环境来说,JVM 是基于栈结构的运行环境,栈结构移植性更好,可控性更强。JVM 中的虚拟机栈是描述 Java 方法执行的内存区域,它是线程私有的。栈中的元素用于支持虚拟机栈进行方法调用,每个方法从开始调用到执行完成的过程,就是栈帧从入栈到出栈的过程。在活动线程中,只有位于栈顶的栈才是有效的,称为当前栈帧。正在执行的方法称为当前方法,栈帧是方法运行的基本机构,在执行引擎运行时,所有指令都只能针对当前栈帧进行操作。而 StackOverflowError 表示请求的栈溢出,导致内存耗尽,通常出现在递归方法中。JVM 能够横扫千军,虚拟机栈就是它的心腹大将,当前方法的栈帧,都是正在战斗的战场,其中的操作栈是参与战斗的士兵
- 虚拟机栈通过压栈和出栈的方式,堆每个方法对应的活动栈帧进行运算处理,犯法正常执行结束,肯定会跳转到另一个栈帧上。在执行的过程中,如果出现异常,会进行异常回溯,返回地址通过异常处理表确定,栈帧在整个 JVM 体系中的地位颇高,包括局部变量表、操作栈、动态链接、方法返回地址等
- 局部变量表
- 局部变量表是存放方法参数和局部变量的区域。相对于类属性变量的准备阶段和初始化阶段来说,局部变量没有准备阶段,必须显式初始化。如果是非静态方法,则在 index[0] 位置上存储的是方法所属对象的实例引用,随后存储的是参数和局部变量。字节码指令中的 STORE 指令就是将操作栈中计算完成的局部变量写回局部变量表的存储空间内
- 操作栈
- 操作栈是一个初始状态为空的桶式结构栈。在方法执行过程中,会有各种指令往栈中写入和提取信息。 JVM 的执行引擎是基于栈的执行引擎,其中的栈指的就是操作栈。字节码指令集的定义都是基于栈类型的,栈的深度在方法元信息的 stack 属性中,下面用一段简单的代码说明操作栈与局部变量表的交互:
public int simpleMethod(){ int x = 13; int y = 14; int z = x + y; return z; }
- 字节码操作顺序
public simpleMethod(); descriptor: ()I flags: ACC_PUBLIC Code: stack=2, loacls=4, args_Size=1 // 最大栈深度为2,局部变量个数为4 BIPUSH 13 // 常量13压入操作栈 ISTORE_1 // 并保存到局部变量表的slot_1中(第一处) BIPUSH 14 // 常量14压入操作栈,注意是BIPUSH ISTORE_2 // 并保存到局部变量表的slot_2中 ILOAD_1 // 把局部变量表的slot_1元素(int x)压入操作栈 ILOAD_2 // 把局部变量表的slot_2元素(int y)压入操作栈 IADD // 把上方的两个数都取出来,在CPU里加一下,并压回操作栈的栈顶 ISTORE_3 // 把栈顶的结果存储到变量表的slot_3中 ILOAD_3 IRETURN // 返回栈顶元素
- 操作栈是一个初始状态为空的桶式结构栈。在方法执行过程中,会有各种指令往栈中写入和提取信息。 JVM 的执行引擎是基于栈的执行引擎,其中的栈指的就是操作栈。字节码指令集的定义都是基于栈类型的,栈的深度在方法元信息的 stack 属性中,下面用一段简单的代码说明操作栈与局部变量表的交互:
- 动态连接
- 每个栈帧中包含一个在常量池中对当前方法的引用,目的至支持方法调用过程的动态连接
- 方法返回地址
- 方法执行时有两种退出情况:第一,正常退出,即正常执行到任何方法的返回字节码指令,如 RETURN、IRETURN、ARETURN等;第二,异常退出。无论何种退出情况,都将返回至方法当前被调用的位置,方法退出的过程相当于弹出当前栈帧,退出有三种可能的方式:(1)返回值压入上层调用栈帧;(2)异常信息抛给能够处理的栈帧;(3)PC 计数器指向方法调用后的下一条指令
- Native Method Stack(本地方法栈)
- 本地方法栈在 JVM 内存布局中,也是线程对象私有的,但是虚拟机栈"主内",而本地方法栈"主外"。这个"内外"是针对 JVM 来说的,本地方法栈为 Native 方法服务。线程开始调用本地方法时,会进入一个不再受 JVM 约束的世界。本地方法可以通过 JNI (Java Native Interface)来访问虚拟机运行时的数据区,甚至可以调用寄存器,具有和 JVM 相同的能力和权限。当大量本地方法出现时,必然削弱 JVM 对系统的控制力,因为它的出错都比较黑盒。对于内存不足的情况,本地方法栈还是会抛出 native heap OutOfMemory。此外最著名的本地方法应该时 System.currentTimeMillis()
- Program Counter Register(程序计数寄存器)
- 每个线程在创建后,都会产生自己的程序计数器和栈帧,程序计数器用来存放执行指令的偏移量和行号指示器等,线程执行或恢复都要依赖程序计数器。程序计数器在各个线程之间互不影响,此区域也不会发生内存溢出异常
- 总结
- 从线程共享的角度看,堆和元空间是线程共享的,虚拟机栈、本地方法栈、程序计数器都是线程内部私有的
- 局部变量表
- 介绍
- 对象实例化
- 介绍
- Java 是面向对象的静态强类型语言,声明并创建对象的代码很常见,很具某个类声明一个引用变量指向被创建的对象,并使用此引用变量操作该对象
- 实例化对象的过程
- 从最简单的 Obejct ref = new Object(); 代码进行分析,利用 javap -verbose -p 命令查看对象创建的字节码
stack=2,locals=1,args_size=0 NEW java/lang/Object BUP INVOKESPECIAL java/lang/Object.<init> ()V ASTORE_1 LocalVariableTable: Start Length Slot Name Signature 8 1 0 ref Ljava/lang/Object;
- NEW:如果找不到 Class 对象,则进行类加载。加载成功后,则在堆中分配内存,Object 开始到本类路径上的所有属性值都要分配内存。分配完毕之后,进行零值初始化。在分配过程中,注意引用是占据存储空间的,它是一个变量,占用 4 个字节。这个指令完毕后,将指向实例对象的引用变量压入虚拟机栈顶
- DUP:在栈顶复制该引用变量,这时的栈顶有两个指向堆内实例对象的引用变量。如果 方法有参数,还需要把参数压入操作栈中。两个引用变量的目的不同,其中压至底下的引用用于赋值,或者保存到局部变量表,另一个栈顶的引用变量作为句柄调用相关方法
- INVOKESPECIAL:调用对象实例方法,通过栈顶的引用变量调用 方法。 是类初始化时执行的方法,而 是对象初始化时执行的方法
- 前面所示是从字节码的角度看待对象的创建过程,现在从执行步骤的角度来分析
- 确认类元信息是否存在。当 JVM 接收到 new 指令时,首先在 metaspace 内检查需要创建的类元信息是否存在。若不存在,那么在双亲委派模式下,使用当前类加载器以 ClassLoader+包名+类名为 Key 进行查找对应的 .class 文件。如果没有找到文件,则抛出 ClassNotFoundException 异常,如果找到,则进行类加载,并生成对应的 Class 类对象
- 分配对象内存。首先计算对象占用空间大小,如果实例成员变量是引用变量,仅分配引用变量空间即可,即 4 个字节大小,接着在堆中划分一块内存给新对象。在分配内存空间时,需要进行同步操作,比如采用 CAS (Compare And Swap)失败重试、区域加锁等方式保证分配操作的原子性
- 设定默认值。成员变量值都需要设定为默认值,即各种不同形式的零值
- 设置对象头。设置新对象的哈希码、GC信息、锁信息、对象所属的类元信息等。这个过程的具体设置方式取决于 JVM 实现
- 执行 init 方法。初始化成员变量,执行实例化代码块,调用类的构造方法,并把堆内对象的首地址赋值给引用变量
- 从最简单的 Obejct ref = new Object(); 代码进行分析,利用 javap -verbose -p 命令查看对象创建的字节码
- 介绍
- 垃圾回收
- 介绍
- Java 会对内存进行自动分配与回收管理,使上层业务更加安全,方便地使用内存实现程序逻辑。在不同的 Java 实现及不同的回收机制中,堆内存的划分方式是不一样的。这里简要介绍垃圾回收( Garbage Collection, GC )。垃圾回收的主要目的是清除不再使用的对象,自动释放内存
- GC Roots
- 如果一个对象与 GC Roots 之间没有直接或间接的引用关系,比如某个失去任何引用的对象,或者两个互相环岛状循环引用的对象等,判决这些对象“死缓”,是可以被回收的。什么对象可以作为 GC Roots ?比如类静态属性中引用的对象、常量引用的对象、虚拟机栈中引用的对象、本地方法栈中引用的对象等
- 垃圾回收算法
- 最基础的为"标记-清除算法",该算法会从每个 GC Roots 出发,依次标记有引用关系的对象,最后将没有被标记的对象清除。但是这种算法会带来大量的空间碎片,导致需要分配-个较大连续空间时容易触发 FGC。为了解决这个问题,又提出了"标记一整理算法",该算法类似计算机的碰盘整理,首先会从 GC Roots 发标记存活的对象,然后将存活对象整理到内存空间的一端,形成连续的已使用空间,最后把已使用空间之外的部分全部清理掉,这样就不会产生空间碎片的问题。"Mark-Copy"算法为了能够并行地标记和整理将空间分为两块,每次只激活其中一块,垃圾回收时只需把存活的对象复制到另一块未激活空间上,将未激活空间标记为己激活,将己激活空间标记为未激活,然后清除原空间中的原对象。堆内存空间分为较大的 Eden 和两块较小 Survivor,每次只使用 Eden 和 Survivor 区的一块。这种情形下的 “Mark-Copy” 减少了内存空间的浪费。"Mark-Copy"现作为主流的 YGC 算法进行新生代的垃圾回收
- 垃圾回收器
- 垃圾回收器是实现垃圾回收算法并应用在 JVM 环境中的内存管理模块
- Serial回收器
- Serial 回收器是一个主要应用于 YGC 的垃圾回收器,采用串行单线程的方式完 GC 任务,其中“ Stop The World ”简称 STW ,即垃圾回收的某个阶段会暂停整个应用程序的执行。 FGC 的时间相对较长,频繁 FGC 会严重影响应用程序的性能
- CMS回收器
- CMS 回收器是回收停顿时间比较短、目前比较常用的垃圾回收器。它通过初始标记(Initial Mark)、并发标记(Concurrent Mark)、重新标记(Remark)、并发清除(Concurrent Sweep)四个步骤完成垃圾回收工作。第1、3步的初始标记和重新标记阶段依然会引发 STW ,而第2、4步的并发标记和并发清除两个阶段可以和应用程序并发执行,也是比较耗时的操作,但并不影响应用程序的正常执行。由于 CMS 采用的是"标记一清除算法",因此产生大量的空间碎片。为了解决这个问题, CMS 可以通过配置 XX :+UseCMSCompactAtFullCollection 参数,强制 JVM FGC 完成后对老年代进行压缩,执行一次空间碎片整理,但是空间碎片整理阶段也会引发 STW 。为了减少 STW 次数, CMS 还可以通过配置 一XX:+CMSFul!GCsBeforeCompaction=n 参数,在执行了 n 次 FGC 后,JVM 再在老年代执行空间碎片整理
- G1回收器
- Hotspot 在 JDK7 推出了新一代 G1 (Garbage-First Garbage Collector) 垃圾回收,通过 -XX:+UseG1GC 参数启用。和 CMS 相比,G1具备压缩功能,能避免碎片问题,G1 的暂停时间更加可控
- G1 将 Java 堆空间分割成了若干相同大小的区域,即 region,包括 Eden、Survivor、Old、Humongous 四种类型。其中,Humongous 是特殊的 Old 类型,专门放置大型对象。这样的划分方式意昧着不需要一个连续的内存空间管理对象。G1 将空间分为多个区域,优先回收垃圾最多的区域。G1 采用的是"Mark-Copy",有非常好的空间整合能力,不会产生大量的空间碎片。 G1 的一大优势在于可预测的停顿时间,能够尽可能快地在指定时间内完成垃圾回收任务。在 JDK11 中,已经将 G1 设为默认垃圾回收器,通过 jstat 命令可以查看垃圾回收情况,在 YGC 时 S0/S1 并不会交换
- S0/S1 的功能由 G1 中的 survivor region 来承载。通过 GC 日志可以观察到完整的垃圾回收过程如下,其中就有 surivor regions 的区域从 0 个到 1 个
- 红色标识的为 G1 中的四种 region 都处于 Heap 中。G1 执行时使用 worker 并发执行,在初始标记时,还是会触发 STW 如第一步所示的 Pause
- 介绍
第五章 异常与日志
- 处理异常需要解决的问题
- 哪里发生异常?
- 谁来处理异常?
- 如何处理异常?
- 异常的分类
- JDK 中定义了一套完整的异常机制,所有异常都是 Throwable 的子类,分为 Error(致命异常)和 Exception(非致命异常)。Error 是一种非常特殊的异常类型,它出现标志的系统发生不可控的错误,例如 StackOverflowError、OutofMemoryError。针对此类错误程序无法处理,只能人工介入。Exception 又分为 checked 异常(受检异常)和 unchecked 异常(非受检异常)
- checked 异常时需要在代码中显式处理的异常,unchecked 异常时运行时异常,它们都继承自 RuntimeException,不需要程序进行显式的捕捉和处理
- unchecked 异常又可以分为三类:
- 可预测异常(Predicted Exception):常见的可预测异常包括 IndexOutOfBoundsException、NullPointerException 等,需要做好预判并处理
- 需捕捉异常(Caution Exception):例如远程访问的 DubboTimeoutException,此类异常必须显示处理
- 可透出异常(Ignored Exception):框架或系统产生的会自行处理的异常,例如 404 等
- try代码块
- try-catch-finally 是处理程序异常的三部曲。当存在 try 时,可以只有 catch 代码块,也可以只有 finally 代码块,就是不能单独只有 try 这个光杆司令
- try 代码块:监视代码执行过程,一旦发现异常则直接跳转至 catch,如果没有 catch,则直接跳转至 finally
- catch 代码块:可选执行代码块,如果没有任何异常不会执行,如果发现异常则进行处理或向上抛出
- finally 代码块:必选执行的代码块,不管是否又异常产生,即使发生 OOM 异常也会执行,通常用户处理善后清理工作
- 如果 finally 代码块没有执行,那么有三种可能
- 没有进入 try 代码块
- 进入 try 代码块,但是代码运行中出现了死循环或死锁状态
- 进入 try 代码块,但是执行了 System.exit() 操作
- finally 是在 return 表达式运行后执行的,此时要 return 的结果已经被封存起来了,待 finally 代码块执行结束后将之前暂存的结果返回
public static int finallyNotWork() { int temp = 10000; try { throw new Exception(); } catch (Exception e) { return ++temp ; }finally { temp = 99999; } } // 输出 // 10001
- 相比在 finally 代码块中赋值,更加危险的是 finally 块中使用 return 操作,这样的代码会使返回值变得非常不可控
public class TryCatchFinally{ static int x = 1; static int y = 10; static int z = 100; public static void main(String[] args){ int value = finallyReturn(); System.out.println("Value=" + value); Systme.out.println("x=" + x); Systme.out.println("y=" + y); Systme.out.println("z=" + z); } public static int finallyReturn(){ try{ // ... return ++x; }catch(Excepton e){ return ++y; }finally{ return ++z; } } } // 输出 // value=101 // x=2 // y=10 // z=101
- 以上执行结果说明
- 最后的 return 的动作是由 finally 代码块中的 return ++z 完成的,所以方法返回的结果是 101
- 语句 return ++x 中的 ++x 被成功执行,所以运行结果是 x=2
- 如果有异常抛出,那么运行结果将会是 y=11,x=1
- 以上执行结果说明
- try 代码块与锁
- Lock、ThreadLocal、InputStream等这些需要进行强制释放和清除的对象都得在 finally 代码块中进行显式的清理,避免产生内存泄露,或者资源消耗
Lock lock = new XxxLock(); preDo(); try{ // 无论加锁是否成功,unlock 都会执行 lock.lock(); doSomething(); }finally{ lock.unlock(); }
- Lock、ThreadLocal、InputStream等这些需要进行强制释放和清除的对象都得在 finally 代码块中进行显式的清理,避免产生内存泄露,或者资源消耗
- 如果 finally 代码块没有执行,那么有三种可能
- 异常的抛与接
- 对外提供的开放接口使用错误码
- 公司内部跨应用远程服务调用优先考虑使用 Result 对象来封装错误码、错误描述信息
- 应用内部则推荐直接抛出异常信息
- 日志
- 日志规范
- 推荐的日志文件命名方式为:appName_logType_LogName.log
- 日志级别
- DEBUG 级别日志:记录对调试程序有帮助的信息
- INFO 级别日志:记录程序运行现场,虽然此处并未发生错误,但对排查其他错误具有指导意义
- WARN 级别日志:记录程序运行现场,但是更偏向于此处有出现潜在错误的可能
- ERROR 级别日志:记录程序发生了错误,需要被关注。但当前错误没有影响系统的继续进行
- FATAL 级别日志:记录程序运行的验证错误,并且会导致系统中断
- 日志级别的使用
- 预先判断日志级别
- 避免无效日志打印:生产环境禁止输出 DEBUG 日志而有选择地输出 INFO 日志
- 区别对待错误日志:一些业务是可以通过引导重试恢复正常地,可以使用 WARN,而需要人工介入的使用 ERROR。所以 ERROR 级别只记录系统逻辑错误、异常或违反重要的业务规则,其他错误都可以归为 WARN 级别
- 保证记录内容完整:记录异常时,一定要输出异常堆栈,例如 logger.error(“xxx”+e.getMessage(),e)
- 日志规范
- 日志框架
- 日志框架分为三大部分,包括日志门面、日志适配器、日志库。利用门面设计模式,即 Facade 来进行解耦
- 门面模式
- 门面设计模式是面向对象设计模式中的一种,日志框架采用的就是这种模式,类似 JDBC 的设计理念。它只提供一套接口规范,自身不负责日志功能的实现,目的是让使用者不需要关注底层具体是哪个日志库来负责日志打印及具体的使用细节等。目前用得最为广泛的日志门面有两种:slf4j 和 commons-logging
- 日志库
- 它具体实现了日志的相关功能,主流的日志库有三个,分别是 log4j、log-jdk、logback。最早 Java 要想记录日志只能通过 System.out 或 System.error 来完成,非常不方便。log4j 就是为了解决这一问题而提出的,它是最早诞生的日志库。接着 JDK 也在 1.4 版本引入了一个日志库 java.util.logging.Logger,简称 log-jdk。这样市面上就出现两种功能的实现,开发者在使用时需要关注所使用的日志库的具体细节。 logback 是最晚出现的,它与 log4j 出自同一个作者,是 log4j 的升级版且本身就实现了 slf4j 的接口
- 日志适配器
- 日志门面适配器:因为 slf4j 规范是后来出的,在此之前的日志库是没有实现 slf4j 的接口的,例如 log4j,所以在工程里要使用 slf4j+log4j 的模式,就额外需要一个适配器(slf4j-log4j)来解决接口不兼容的问题
- 日志库适配器:在一些老的工程里,一开始为了开发简单而直接使用了日志库 API 来完成日志的打印,但随着时间推移想从原来直接调用日志库的模式改为业界标准的门面模式,就需要一个适配器来完成从旧日志库的 API 到 slf4j 的路由
- 日志的使用
- 新项目:则推荐使用 slf4j+logback 模式
<dependency> <groupid>org.slf4]</groupid> <artifactid>slf4j-api</artifactid> <version>${slf4j-api.version}</version> </dependency> <dependency> <groupid>ch.qos.logback</groupid> <artifactid>logback-classic</artifactid> <version>${logback-classic.version}</version> </dependency> <dependency> <groupid>ch.qos.logback</groupid> <artifactid>logback-core</artifactid> <version>${logback-core.version}</version> </dependency>
- 老项目:如果是老工程,则需要根据所使用的日志库来确定门面适配器,通常情况下老工程使用的都是 log4j 因此以 log4j 日志库为例
<dependency> <groupid>org.slf4j</groupid> <artifactid>slf4j-api</artifactid> <version>${slf4j-api.version}</version> </dependency> <dependency> <groupid>org.slf4j</groupid> <artifactid>slf4j-log4j12</artfactid> <version>${slf4j-log4j12.version}</version> </dependency> <dependency> <groupid>log4j</groupid> <artifactid>log4j</artifactid> <version>${log4j.version}</version> </dependency>
- 如果老代码中直接使用了 log4j 日志库提供的接口来打印曰志,则还需要引人日志库适配器
<dependency> <groupid>org.slf4j</groupid> <artifactid>log4j-over-slf4j</artifactid> <version>${log4j-over-slf4j.version}</version> </dependency>
- logger 被定义为 static 变量,是因为这个 logger 与当前类绑定 避免每次new 一个新对象,造成资源浪费,甚至引发 OutOfMernoryError 问题
- 在使用 slf4j + 日志库模式时,要防止日志库冲突,一旦发生则可能会出现日志打印功能失效的问题
- 新项目:则推荐使用 slf4j+logback 模式
第六章 数据结构与集合
- 数据结构
- 数据结构的定义
- 数据结构是指逻辑意义上的数据组织方式及其相应的处理方式
- 什么是逻辑意义
- 数据结构的抽象表达非常丰富,而实际物理存储的方式相对单一。二叉树在说盘中的存储真的是树形排列吗?并非如此。树的存储可能是基于物理上的顺序存储方式,可以理解为一个格子一个格子连续地放
- 什么是数据组织方式
- 逻辑意义上的组织方式有很多,比如树、图、队列、哈希等。树可以是二叉树、三叉树、B+树等,图可以是有向图或无向图,队列是先进先出的线性结构;哈希是根据某种算法直接定位的数据组织方式
- 什么是数据处理方式
- 在既定的数据组织方式上,以某种特定的算法实现数据的增加、删除、修改、查找和遍历。不同的数据处理方式往往存在着非常大的性能差异
- 数据结构的分类
- 线性结构:0 至 1 个直接前继和直接后继。当线性结构非空时,有唯-的首元素和尾元素,除两者外,所有的元素都有唯一的直接前继和直接后继。线性结构包括顺序表、链表、栈、队列等,其中栈和队列是访问受限的结构。栈是后进先出,即 Last In, First-Out ,简称 LIFO;队列是先进先出,即 First-In First-Out,简称 FIFO
- 树结构:0 至 1 个直接前继和 0 至 n 个直接后继(n大于或等于2)。树是一种非常重要的有层次的非线性数据结构,像自然界的树一样。由于树结构比较稳定和均衡,在计算机领域中得到广泛应用
- 图结构:0 至 N 个直接前继和直接后继(n大于或等于2)。图结构包括简单图、多重图、有向图和无向图等
- 哈希结构:没有直接前继和直接后继。哈希结构通过某种特定的哈希函数将索引与存储的值关联起来,它是一种查找效率非常高的数据结构
- 数据处理的性能
- 空间复杂度
- 时间复杂度
- 从最好到最坏的常用算法复杂度排序如下:常数级O(1)、对数级O(logn)、线性级O(n)、线性对数级O(nlogn)、平方级O(n2)、立方级O(n3)、指数级O(2n)等
- 数据结构的定义
- 集合框架图
- Java 中的集合是用于存储对象的工具类容器,它实现了常用的数据结构,提供了一系列公开的方法用于增加、删除、修改、查找和遍历数据,降低日常开发成本
- 集合框架主要分为两类:第一类是按照单个元素存储的 Collection,在继承树中 Set 和 List 都实现了 Collection 接口;第二类是按照 Key-Value 存储的 Map
- 在集合框架图中,红色代表接口,蓝色代表抽象类,绿色代表并发包中的类,灰色代表早期线程安全的类(基本已经弃用)
- List集合
- List 集合是线性数据结构的主要实现,集合元素通常存在明确的上一个和下一个元素,也存在明确的第一个元素和最后一个元素
- 该体系最常用的是 ArrayList 和 LinkedList 两个集合类
- ArrayList 是容量可以改变的非线程安全集合。内部实现使用数组进行存储,集合扩容时会创建更大的数组空间,把原有数据复制到新数组中。ArrayList 支持对元素的快速随机访问,但是插入与删除时速度通常很慢,因为这个过程很有可能需要移动其他元素
- LinkedList 的本质是双向链表。与 ArrayList 相比 LinkedList 的插入和删除速度更快,但是随机访问速度则很慢。测试表明,对于 10 万条的数据,与 ArrayList 相比,随机提取元素时存在数百倍的差距。除继承 abstractList 抽象类外, LinkedList 还实现了另一个接口 Deque ,即 double-ended queue。这个接口同时具有队列和栈的性质。LinkedList 包含 3 个重要的成员 first、first、last。size 是双向链表中节点的个数。first 和 last 分别指向第一个和最后一个节点的引用。 LinkedList 的优点在于可以将零散的内存单元通过附加引用的方式关联起来,形成按链路顺序查找的线性结构,内存利用率较高
- Queue (队列)
- Queue 是一种先进先出的数据结构,队列是一种特殊的线性表,它只允许在表的一端进行获取操作,在表的另一端进行插入操作。当队列中没有元素时,称为空队列。自从 BlockingQueue (阻塞队列)问世以来,队列的地位得到极大的提升,在各种高并发编程场景中,由于其本身 FIFO 的特性和阻塞操作的特点,经常被作为 Buffer (数据缓冲区)使用
- Map
- Map 集合是以 Key-Value 键值对作为存储元素实现的哈希结构, Key 按某种哈希函数计算后是唯一的, Value 则是可以重复的。Map 类提供三种 Collection 视图,在集合框架图中, Map 指向 Collection 的箭头仅表示两个类之间的依赖关系 可以使用 keySet()查看所有的 町,使用 values ()查看所有的 Value ,使用 ent Set()查看所有的键值对。最早用于存储键值对的 Has table 因为 能瓶颈已经被淘汰,而如今广泛使用的 HashMa 线程是不安全的。 ConcurrentHashMap 是线程安全的,在 JDK8 进行了锁的大幅度优化,体现出不错的性能。在多线程并发场景中,优先推荐使ConcurrentHashMap ,而不是 HashMap TreeMap Key 有序的 Map 类集合
- Set 集合
- Set 是不允许出现重复元素的集合类型。 Set 体系最常用的是 HashSet、TreeSet 和 LinkedHashSet 三个集合类。 HashSet 从源码分析是使用 HashMap 来实现的,只是 Value 固定为一个静态对象,使用 Key 保证集合元素的唯一性,但它不保证集合元素的顺序。TreeSet 也是如此,从源码分析是使用 TreeMap 来实现的,底层为树结构,在添加新元素到集合中时,按照某种比较规则将其插入合适的位置,保证插入后的集合仍然是有序的。LinkedHashSet 继承自 HashSet,具有 HashSet 的优点,内部使用链表维护了元素插入顺序
- 集合初始化
- 集合初始化通常进行分配容量、设置特定参数等相关工作
- ArrayList
public class ArrayList<E> extends AbstractList<E> implements List<E>, RandomAccess, Cloneable, java.io.Serializable { private static final int DEFAULT_CAPACITY = 10; // 空表的表示方法 private static final Object[] EMPTY_ELEMENTDATA = {}; transient Object[] elementData; private int size; public ArrayList(int initialCapacity) { if (initialCapacity > 0) { // 值大于0时,根据构造方法的参数值,忠实地创建一个多大的数组 this.elementData = new Object[initialCapacity]; } else if (initialCapacity == 0) { this.elementData = EMPTY_ELEMENTDATA; } } // 公开的 add 方法调用此内部私有方法 private void add(E e, Object[] elementData, int s) { // 当前数组能否容纳size+1的数组,如果不够则调用grow来扩展 if (s == elementData.length) elementData = grow(); elementData[s] = e; size = s + 1; } private Object[] grow() { return grow(size + 1); } // 扩容的最小要求,必须满足容纳刚才的元素个数+1,注意,newCapacity() // 方法才是扩容的重点 private Object[] grow(int minCapacity) { return elementData = Arrays.copyof(elementData, newCapacity(minCapacity)); } private int newCapacity(int minCapacity) { // 防止扩容1.5倍后超过int的表示范围(第一处) int oldCapacity = elementData.length; // JDK6之前扩容50%或50%-1,但是取ceil,而之后的版本取floor(第二处) int newCapacity = oldCapacity + (oldCapacity >> 1); if (newCapacity- minCapacity <=0) { if (elementData == DEFAULTCAPACITY_EMPTY_ ELEMENTDATA) // 无参数构造方法,会在此时分配默认10的容量 return Math.max(DEFAULT_CAPACITY, minCapacity); if (minCapacity < 0 ) throw new OutOfMemoryError(); return minCapacity; } return (newCapacity - MAX_ARRAY_SIZE <= 0)? newCapacity:hugeCapacity(minCapacity); } }
- 第一处说明:正数带符号右移的值肯定是正值,所以 oldCapatity+(oldCapacity>>1) 的结果可能超过 int 可以表示的最大值,反而有可能比 minCapacity 更小,则返回值为 (size+1) 的 minCapacity
- 第二处说明:如果原始容量是 13,当新添加一个元素时,根据程序中的算法得出 13 的二进制数为 1101,随后右移 1 位操作后得到二进制数 110, 即十进制数 6。最终扩容的大小计算结果为 oldCapacity+(oldCapacity>>1)=13+6=19。使用位运算主要是基于计算效率的考虑。在JDK7 之前的公式,扩容计算方式和结果为 oldCapacity3/2+1=133/2+1=20
- HashMap
- HashMap 扩容是需要重建 hash 表非常影响性能。在 HashMap 中有两个比较重要的参数,CapaCity 和 Load Factory,其中 Capacity 决定了存储容量的大小,默认为 16;而 Load Factor 决定了填充比例,一般使用默认的 0.75。基于这两个参数的乘积,HashMap 内部用 threshold 变量表示 HashMap 中能放入的元素个数。HashMap 容量并不会在 new 的时候分配,而是在第一次 put 的时候完成创建的
public v put(K key, V value) { if (table == EMPTY_TABLE) { inflateTable(threshold); } // ...省略代码 } // 第一次put时,调用如下方法,初始化table private void inflateTable(int toSize) { // 找到大于参数值且最接近2的幂值,假如输入参数是27则返回32 int capacity = roundUpToPowerOf2{toSize}; // threshold 在不超过限制最大值的前提下等于 capacity * loadFactor threshold = (int)Math.min(capacity * loadFactory, MAXIMUM_CAPACITY + 1); table = new Entry[capacity]; initHashSeedAsNeeded(capacity); }
- 为了提高运算速度,设定 HashMap 容量大小为 2 的 n 次方。如果初始化 HashMap 的时候通过构造器指定了 initialCapacity,则会计算出比 initialCapacity 大的 2 的幂存入 threshold,在第一次 pull 时会按照这个 2 的幂初始化数组大小,此后每次扩容都是增加 2 倍
- 数组与集合
- 数组是一种顺序表,在各种高级语言中,它是组织和处理数据的一种常见方式,我们可以使用索引下标进行快速定位并获取指定位置的元素。数组的下标从 0 开始,源于 BCPL 语言,它将指针设置在 0 的位置,用数组下标作为直接偏移量进行计算,为什么下标不从 1 开始呢?如果这样,计算偏移量就要使用当前下标减 1 的操作。加减法对 CPU 来说是一种双数运算,在数组下标使用频率极高的场景下,这种运算十分耗时
- 在 Java 体系中,数组用以存储同一类型的对象,一旦分配内存后无法扩容
- Arrays 是针对数组对象进行操作的工具类,包括数组的排序、查找、对比、拷贝等操作
- 集合与泛型
- 示例
public class ListNoGeneric { public static void main(String[] args){ // 第一段:泛型出现之前的集合定义方式 List a1 = new ArrayList(); a1.add(new Object()); a1.add(new Integer(111)); a1.add(new String("hello a1a1")); // 第二段:把a1引用赋值给a2,注意a2与a1的区别是增加了泛型限制<Object> List<Object> a2 = a1; a2.add(new Object()); a2.add(new Integer(222)); a2.add(new String("hello a2a2")); // 第三段:把a1引用赋值给a3,注意a3与a1的区别是增加了泛型<Integer> List<Integer> a3 = a1; a3.add(new Integer(333)); a3.add(new Object()); a3.add(new String("hello a3a3")); // 第四段:找a1引用赋值给a4,a1与a4的区别是增加了通配符 List<?> a4 = a1; // 允许删除和清楚元素 a1.remove(0); a4.clear(); // 编译出错,不允许增加任何元素 a4.add(new Object()); } }
- 问题代码
JSONObject jsonObject = JSONObject.fromObject("{\"level\":[\"3\"]}"); List<Integer> intList = new ArrayList<Integer>(10); if (jsonObject != null) { initList.addAll(jsonObject.getJSONArray("level")); int amount = 0; for (Integer t:intList) { // 抛出 ClassCastException异常:String cannot be cast to Integer if (condition) { amount = amount + t; } } } // addAll的定义 public boolean addAll(Collection<? extends E> c){...} // JSONArray的定义 public final class JSONArray extends AbstractJSON implements JSON, List{}
- JSONArray 实现了 List,是非泛型集合,可以赋值给任何泛型限制的集合。编译可以通过,但是运行时报错
- 泛型
- <? extend T> 可以赋值给任何 T 及 T 子类的集合,上界为 T,取出来的类型带有泛型限制,向上强制转型为 T。null 可以表示任何类型,所以除 null 外,任何元素都不得添加进 <? extends T> 集合内
- <? super T> 可以赋值给任何 T 及 T 的父类集合,下界为 T
- extends 的场景是 put 功能受限,而 super 的场景是 get 功能受限
// 用动物的猫科与加菲猫的继承关系说明extends与super在集合中的意义 public class AnimalCatCarfield { public static void main(String[] args){ // 第一段:声明三个依次继承的类的集合:Object>动物>猫>加菲猫 List<Animal> animal = new ArrayList<Animal>(); List<Cat> cat = new ArrayList<Cat>(); List<Garfield> garfield = new ArrayList<Garfield>(); animal.add(new Animal()); cat.add(new Cat()); garfield.add(new Garfield()); // 第二段:测试赋值操作 // 下行编译出错,只能赋值Cat或Cat子类的集合 List<? extends Cat> extendsCatFromAnimal = animal; List<? super Cat> superCatFromAnimal = animal; List<? extends Cat> extendsCatFromCat = cat; List<? super Cat> superCatFromCat = cat; List<? extends Cat> extendsCatFromGarfield = garfield; // 下行编译出错,只能赋值Cat或Cat父类的集合 List<? super Cat> superCatFromGarfield = garfield; // 第三段:测试add方法 // 下面三行中所有的 <? extends T>都无法进行add操作,编译均出错 extendsCatFromCat.add(new Animal()); extendsCatFromCat.add(new Cat()); extendsCatFromCat.add(new Garfield()); // 下行编译出错,只能添加Cat或Cat子类的集合 superCatFromCat.add(new Animal()); superCatFromCat.add(new Cat()); superCatFromCat.add(new Garfield()); // 第四段:测试get方法 // 所有的super操作都能返回元素,但是泛型丢失,只能返回Object对象 // 以下extends操作能返回元素 Object catExtends2 = extendsCatFromCat.get(0); Cat catExtends1 = extendsCatFromCat.get(0); // 下行编译出错,虽然Cat集合从Garfield赋值而来,但类型擦除后是不知道的 Garfield garfield1 = extendsCatFromCat.get(0); } }
- 第一段:声明三个泛型集合,可以理解为三个不同的笼子,List 住的是动物,List 住的是猫,List 住的是加菲猫。Garfield 继承于 Cat,Cat 继承自 Animal
- 第二段:以 Cat 类为核心,因为它有父类也有子类。定义类型限定集合,分别为 List<? extends Cat> 和 List<? super Cat>。在理解这两个概念时,暂时不要引人上界和下界,专注于代码本身就好。把 List 对象赋值给两者都是可以的。但是把 List 赋值给 List 时会编译出错。因为能赋值给 <? extends Cat> 的类型,只有 Cat 自己和它的子类。尽管它是类型安全的,但依然有泛型信息,因而从笼子里取出来的必然是只猫,而 List 里边有可能住着毒蛇、鲤鱼、蝙蝠等其他动物。List 赋值给 List<? super Cat> 时,也会编译报错。因为能赋值给 <? super Cat> 的类型,只有 Cat 自己和它的父类
- 第三段:所有的 List<? extends T> 都会编译出错,无法进行 add 操作,这是因为 null 外,任何元素都不能被添加进 <?extends> 集合内。 List<? super Cat> 可以往里增加元素,但只能添加 Cat 自身及子类对象,假如放入一块石头,则明显违背了 Animal 大类的性质
- 第四段:所有 List<? super> 集合可以执行 get 操作,虽然能够返回元素,但是类型丢失,即只能返回 Object 对象。List<? extends Cat> 可以返回带类型的元素,但只能返回 Cat 自身及其父类对象,因为子类类型被擦除了
- 对于一个笼子,如果只是不断地向外取动物而不向里放的话,则属于 Get First,应采用 <? extends>;相反,如果经常向里放动物的话,则应采用 <? super T> ,属于 Put First
- 示例
- 元素的比较
- Comparable 和 Comparator
- Java 中两个对象相比较的方法通常用在元素排序中,常用的两个接口分别是 Comparable 和 Comparator,前者是自己和自己比,后者是第三方比较器
- Java 在 JDK7 中使用 TimeSort 算法取代了原来的归并排序
- 归并排序的分段不再从单个元素开始,而是每次先查找当前最大的排序好的数组片段 run,然后对 run 进行扩展并利用二分排序,之后将该 run 与其他已经排序好的 run 进行归并,产生排序好的大 run
- 引入二分排序,即 binarySort。二分排序是对插入排序的优化,在插入排序中不再是从后向前逐个元素对比,而是引入了二分查找的思想,将一次查找新元素的合适位置的时间复杂度由 O(n) 降低到 O(logn)
- hashCode和equals
- hashCode 和 equals 用来表示对象,两个方法协同工作可以用来判断两个对象是否相等。对象通过调用 Object.hashCode() 生成哈希值;由于不可避免会存在哈希值冲突的情况,因此当 hashCode 相同时,还需要再调用 equals 进行一次值的比较
- Object 类定义中对 hashCode 和equals 要求
- 如果两个对象的 equals 的结果时相等的,则两个对象的 hashCode 的返回结果也必须时相同的
- 任何时候覆写 equals,都必须同时覆写 hashCode
- 在 Map 和 Set类集合中,用到这两个方法时,首先判断hashCode的值,如果相等则再判断 equals 的结果,HashMap 的 get 判断代码如下
if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) return (e = getNode(hash(key), key)) == null ? null : e.value;
- Comparable 和 Comparator
- fail-fast机制
- 介绍
- 它是一种对集合遍历操作时的错误检测机制,在遍历中途出现意料之外的修改时,通过 unchecked 异常反馈出来。这种机制经常出现在多线程环境下,当前线程会维护一个计数比较器,即 expectedModCount,记录已经修改的次数。在进入遍历时,会把实时修改次数 modCount 赋值给 expectedModCount,如果这两个数据不相等,则抛出异常。java.util 下的所有集合类都是 fail-fast,而 concurrent 包中的集合类都是 fail-safe,与 fail-fast 不同,fail-safe 对当前集合直接处理,对修改的不做记录
public class SubListFailFast { public static void main(String[] args) { List masterList = new ArrayList(); masterList.add("one"); masterList.add("two"); masterList.add("three"); masterList.add("four"); masterList.add("five"); List branchList = masterList.subList(0, 3); // 下方三行代码,如果不注释掉,则会导致branchList操作出现异常 masterList.remove(0); masterList.add("ten"); masterList.clear(); // 下方四行全部可以正确执行 branchList.clear(); branchList.add("six"); branchList.add("seven"); branchList.remove(0); // 正常遍历结束,只有一个元素:seven for(Object t : branchList) { System.out.println(t); } // 子列表修改导致主列表也被修改,输出:{seven, four, five} System.out.println(masterList); } }
- 它是一种对集合遍历操作时的错误检测机制,在遍历中途出现意料之外的修改时,通过 unchecked 异常反馈出来。这种机制经常出现在多线程环境下,当前线程会维护一个计数比较器,即 expectedModCount,记录已经修改的次数。在进入遍历时,会把实时修改次数 modCount 赋值给 expectedModCount,如果这两个数据不相等,则抛出异常。java.util 下的所有集合类都是 fail-fast,而 concurrent 包中的集合类都是 fail-safe,与 fail-fast 不同,fail-safe 对当前集合直接处理,对修改的不做记录
- 应对
- 我们可以使用 Iterator 机制进行遍历时的删除,如果时多线程并发,还需要在 Iterator 遍历时加锁
Iterator<String> iterator = list.iterator(); while (iterator.hasNext()) { synchronized(对象) { String item = iterator.next(); if(删除的元素的条件){ iterator.remove(); } } }
- 或者使用并发容器 CopyOnWriteArrayList 代替 ArrayList,该容器内部会对 Iterator 进行加锁操作
- Copy-On-Write 是一种新思路,实行读写分离,如果是写操作,则复制一个新集合,在新集合内添加或删除元素。待一切修改完成后,再将原集合的引用指向新的集合,这样的好处时可以高并发地对 COW 进行读和遍历操作,且不需要加锁,因为当前集合不会添加元素。使用 COW 时应注意两点:第一、进来设置合理的容量初始值,它扩容的代价比较大;第二、使用批量添加或删除方法,如 addAll 或 removeAll 操作。高并发下,可以批量添加或删除避免增加一个元素复制整个集合
- COW 是 fail-safe 机制的,在并发包的集合中都是由这种机制实现的,fail-safe 是安全的副本(或者没有修改操作的正本)上进行遍历,集合修改与副本的遍历时没有任何关系的,但是缺点也很明显,就是读取不到最新的数据。这是 CAP 理论中 C(Consistency) 与 A(Availability) 的矛盾,即一致性与可用性的矛盾
- 我们可以使用 Iterator 机制进行遍历时的删除,如果时多线程并发,还需要在 Iterator 遍历时加锁
- 介绍
- Map类集合
-
介绍
- 在数据元素的存储、查找、修改和遍历中,Java 中 Map 类集合都与 Collection 类集合存在很大不同。它是与 Collection 类平级的一个接口,在集合框架图中,它有一条微弱的依赖线与 Collection 类产生关联,那是因为部分方法返回 Collection 视图,比如 values() 方法返回的所有 Value 的列表。Map 类集合中的存储单位是 KV 键值对,Map 类就是使用一定的哈希算法形成一组比较均匀的哈希值作为 key,Value 值挂在 Key 上
-
特点
- Map 类取代了旧的抽象类 Dictionary,拥有更好的性能
- 没有重复的 Key,可以有多个重复的 Value
- Value 可以是List、Map、Set类对象
- KV 是否允许为 null,以实现类约束为准
-
方法
- Map 接口除提供传统的增删改查方式外,还有三个 Map 类特有的方法,即返回所有的 Key,返回所有的 Value,返回所有的 KV 键值对
// 返回Map类对象中的Key的Set视图 Set<K> keySet(); // 返回Map类对象中的所有Value集合的Collection视图 // 返回的集合实现类为 Values extends AbstracCollection<v> Collection<V> values(); // 返回Map类对象中的Key-Value对的Set视图 Set<Map.Entry<K, V>> entrySet();
- 通常这些返回的视图是支持清楚操作的,但是修改和增加元素会抛出异常,因为 AbstractCollection 没有实现 add 操作,但是实现了 remove、clear等相关操作
- Map 接口除提供传统的增删改查方式外,还有三个 Map 类特有的方法,即返回所有的 Key,返回所有的 Value,返回所有的 KV 键值对
-
红黑树
- 树(tree)
- 树是一种常用的数据结构,它是一个由有限节点组成的一个具有层次关系的集合,数据就存在树的这些节点中。最顶层只有一个节点,称为根节点,在分支处有一个节点,指向多个方法,如果某节点下方没有任何分叉的话,就是叶子节点。从某节点出发,到叶子节点位置,最长简单路径上边的条数,称为该节点的高度;从根结点出发到某节点的条数,称为该节点的深度。如图所示的树,根节点 root 的高度是 5,深度是 0, 而节点 2 的高度是 4,深度是 1
- 树结构的特点如下
- 一个节点,即只有根节点,也可以是树
- 其中任何一个节点与下面所有节点构成的树称为子树
- 根节点没有父节点,而叶子节点没有子节点
- 除根节点外,任何节点有且仅有一个父节点
- 任何节点可以有 0 ~ n 个子节点
- 至多有两个子节点的树称为二叉树,如图所示签好是二叉树,二分法是经典的问题拆解算法,二叉树是近似于二分法的一种数据结构实现
- 平衡二叉树
- 如果把上图的左侧枝叶全部砍掉,剩余部分还是树,但是以"树"之名行"链表"之实。如果如下图所示让链表一样的树变得更有层次结构,平衡二叉树就呼之欲出了。高度差是一棵树是否为平衡二叉树的决定条件
- 平衡二叉树的性质
- 树的左右高度差不饿能超过1
- 任何往下递归的左子树与右子树,必须符合第一条性质
- 没有任何节点的空树或只有根节点的树也是平衡二叉树
- 二叉查找树
- 二叉查找树又称二叉搜索树,即 Binary Search Tree,其中 Search 也可以替换为 Sort,所以也称二叉排序树。Java 中集合的最终目的就是加工数据,二叉查找树也是此目的。二叉查找树非常善于数据查找。二叉查找树额外增加了其他要求:对于任意节点来说,它的左子树上所有节点的值都小于它,而它的右子树上所有节点的值都大于它。查找过程从树的根节点开始,沿着简单的判断向下走,小于节点值往左走,大于节点值的往右走,知道找到目标或到达叶子节点还未找到
- 遍历所有节点的常用方有三种:前序遍历,中序遍历。后序遍历
- 遍历规则
- 在任何递归子树中,左节点一定在右节点之前先遍历
- 前序、中序、后序仅指根节点在遍历时的位置顺序。前序遍历的顺序是根节点、左节点、右节点;中序遍历的顺序是左节点、根节点、右节点;而后序遍历的顺序是左节点、右节点、根节点
- 二叉查找树由于随着数据不断的增加或删除容易失衡,为了保持二叉树重要的平衡性,有很多算法的实现。如:AVL树、红黑树、SBT(Size Balanced Tree)、Treap(树堆)等
- AVL树
- AVL树算法是以苏联数学家 Adelson-Velsky 和 Landis 名字命名的平衡二叉树算法,可以使二叉树的使用效率最大化。AVL 树是一种平衡二叉树,增加和删除节点后通过树形旋转重新达到平衡。右旋是以某个节点为中心,将它沉入当前右子节点的位置,而让当前的左子节点作为新书的根节点,也成为顺时针旋转,同理左旋是以某个节点为中心,将它沉入当前左子节点的位置,而让当前右子节点作为新书的根节点,也成为逆时针旋转
- AVL树是通过不断旋转来达到树平衡的
- 红黑树
- 红黑树于 1972 年发明,当时称为二叉B树,1978年得以优化,正式命名为红黑树,它的主要特征是在每个节点上增加一个属性来表示节点的颜色,可以是红色也可以是黑色
- 红黑树和 AVL 树类似,都是在进行插入和删除元素时,通过特定的旋转来保持自身平衡的,从而获得较高的查找性能。与 AVL 树相比,红黑树并不追求所有递归子树的高度差不超过 1,而是保证从根节点到叶子节点的最长路径不超过最短路径的 2 倍,所以它的最坏运行时间也是 O(logn)。红黑树通过重新着色和左右旋转,更加高效地完成了插入和删除操作后的自平衡调整。当然红黑树本质还是二叉树查找树,他额外引入了五个约束条件
- 节点只能是红色或者黑色
- 根节点必须是黑色
- 所有 NIL 节点都是黑色的。NIL,即叶子节点下挂的两个虚节点
- 一条路径上不能出现相邻的两个红色节点
- 在任何递归子节点树内,根节点到叶子节点的所有路径上包含相同数目的黑色节点
- “有红必有黑,黑黑不相连”,上述五个条件保证了红黑树的新增、删除、查找的最坏时间复杂度均为 O(logn),如果一个树的左子节点或右子节点不存在,则均认定为黑色。红黑树的任何旋转在 3 此之内均可完成
- 红黑树与AVL树的比较
- 任意节点的黑深度(Black Depth)是指当前节点到 NIL(树尾端)途径的黑色节点个数。根据约束条件的第4、5条,可以推出对于任意高度的节点,它的黑深度都满足:Black Depth >= heignt/2。也是就是说,对于任意包含 n 个节点的红黑树而言,它的根节点高度 h =< 2logn2(n+1)。常规 BST 操作比如查找、插入、删除等,时间复杂度为 O(h),即取决于树的高度 h。当树失衡时,时间复杂度将有可能恶化到 O(n),即 h = n。所以当我们能保证树的高度始终保持在 O(logn) 时,便能保证操作的时间复杂度都能保持在 O(logn) 以内
- 红黑树的平衡性不如 AVL 树,它维持的只是一种大致上的平衡,并不严格保证左右子树的高度差不超过 1。这导致在相同的节点数情况下,红黑树的高度可能更高,即平均查找次数会高于相同情况下的 AVL 树。在插入时,红黑树和 AVL 树都能在至多两次旋转内恢复平衡。在删除时,由于红黑树只追求大致上的平衡,因此红黑树能在至多三次旋转内恢复平衡,而追求绝对平衡的 AVL 树,则至多需要 O(logn) 次旋转。AVL 树在插入与删除时,将向上回溯确定是否需要旋转,这个回溯的时间成本最差可能为 O(logn),而红黑树每次向上回溯的步长为 2,回溯成本低。因此面对频繁插入和删除,红黑树更合适,面对低频修改、大量查询时,AVL 树更合适
- 树(tree)
-
TreeMap
- TreeMap 是按照 Key 的排序结果来组织内部结构的 Map 类集合,它改变了 Map 类散乱无序的形象。虽然 TreeMap 没有 ConcurrentHashMap 和 HashMap 普及(毕竟插入和删除的效率远没有后两者高),但是在 Key 有排序要求的场景下,使用 TreeMap 可以事半功倍,在集合框架图中,他们都继承于 AbstractMap 抽象类,TreeMap 与 HashMap、ConcurrentHashMap 的关系如下
- 在 TreeMap 的接口继承树中,有两个与众不同的接口;SortedMap 和 NavigableMap。SortedMap 接口表示它的 Key 是有序不可重复的,支持获取头尾 Key-Value 元素,或者根据 Key 指定范围获取子集合等。插入的 Key 必须实现 Comparable 或提供额外的比较器 Comparator,所以 Key 不允许为 null,但是 Value 可以,NavigableMap 接口继承了 SortedMap 接口,根据指定的搜索条件返回最匹配的 Key-Value 元素。不同于 HashMap,TreeMap 并非一定要覆写 hashCode 和 equals 方法来达到 Key 去重的目的
public class TreeMapRepeat { public static void main(String[] args) { // 如果仅把此处的TreeMap换成HashMap,则size=1 // 因为HashMap是使用hashCode和equals实现去重的。而TreeMap依靠Comparable或Comparator来实现Key的去重 TreeMap map = new TreeMap(); map.put(new Key(), "value one"); map.put(new Key(), "value two"); // Treemap,size=2,因为Key去重规则是根据排序结果 System.out.println(map.size()); } } class Key implements Comparable<Key> { @Override // 返回负的常数,表示此对象永远小于输入的other对象,此处决定TreeMap的size=2 public int compareTo(Key other) { return -1; } // hash是相等的 @Override public int hashCode() { return 1; } // equals比较也是相等的 @Override public boolean equals(Object obj) { return true; } }
- 基于红黑树实现的 TreeMap 提供了平均和最坏复杂度均为 O(logn) 的增删改查操作,并实现了 NavigableMap 接口,该集合最大的特点是 Key 的有序性
public class TreeMap<K, V> extends AbstractMap<K, V> implements NavigableMap<K, V>, Cloneable, java.io.Serializable { // 排序使用的比较器,put源码解析时会提到 private final Comparator<? super K> comparator; // 根节点,put源码解析时会提到 private transient Entry<K, V> root; // 定义称为有字面含义的常量,下方fixAfterInsertion()解析时会提到 private static final boolean RED = false; private static final boolean BLACK = true; // TreeMap的内部类,存储红黑树节点的载体类,在整个TreeMap中高频出现 static final class Entry<K, V> implements Map.Entry<K, V> { K key; V value; Entry<K, V> left; // 指向左子树的引用 Entry<K, V> right; // 指向右子树的引用 Entry<K, V> parent; // 指向父节点的引用 boolean color = BLACK; // 节点颜色信息时红黑树的精髓所在,默认是黑色 } // ... }
- TreeMap 通过 put() 和 deleteEntry() 实现红黑树的增加和删除节点操作,在插入新节点之前需要明确三个前提
- 需要调整的新节点总是红色的
- 如果插入新节点的父节点时黑色的,无需调整
- 如果插入新节点的父节点时红色的,因为红黑树规定不能出现相邻的两个红色节点,所以进入循环判断,或重新着色,或左右旋转,最终达到红黑树的五个约束条件,退出条件如下
// 即如果时根节点,直接退出设置为黑色,如果不是根节点,并且父节点是红色则一直调整直到退出循环 while (x != null && x != root && x.parent.color == RED) {...}
- TreeMap 的插入操作就是按 Key 的䶏往下遍历,大于比较节点值的向右走,小于比较节点值的向左走,先按照二叉查找树的特性进行操作,无须关心节点颜色与树的平衡,后续会重新着色和旋转,保持红黑树的特性
// put源码 public V put (K key, V value) { // t 表示当前节点,记住这个很重要,先把TreeMap的根节点root引用赋值给当前节点 Entry<K, V> t = root; // 如果当前节点为null,即是空树,新增的KV形成的节点就是根节点 if (t == null) { // 看似多此一举,实际上预检了Key是否可以比较 compare(key, key); // 使用KV构造出新的Entry对象,其中第三个参数是parent,根节点没有父节点 root = new Entry<>(key, value, null); size = 1; modCount++; return null; } // cmp用来接收比较结果 int cmp; Entry<K, V> parent; // 构造方法中置入的外部比较器 Comparator<? super K> cpr = comparator; // 重点步骤:根据二叉查找树的特性,找到新节点插入的合适位置 if (cpr != null) { // 循环的目标:根据参数Key与当前节点的Key不断地进行比较 do { // 当前节点赋值给父节点,故从根节点开始遍历比较 parent = t; // 比较输出的参数key和当前节点key的大小 cmp = cpr.compare(key, t.key); // 参数的key更小,向左边走,把当前节点引用移动至它的左子节点上 if (cmp < 0) t = t.left; // 参数的key更大,向右边走,把当前节点引用移动至它的右子节点上 else if (cmp > 0) t = t.right; // 如果相等,则会残忍地覆盖当前节点地value值,并返回更新前地值 else return t.setValue(value); } while (t != null); } // 在没有指定比较器的情况下,调用自然排序的Comparable比较 else { if (key == null) throw new NullPointerException(); Comparable<? super K> k = (Comparable<? super k>) key; do { parent = t; cmp = k.compareTo(t.key); if (cmp < 0) t = t.left; else if (cmp > 0) t = t.right; else return t.setValue(value); } while (t != null); } // 创建Entry对象,并把parent置入参数 Entry<K, V> e = new Entry<>(key, value, parent); // 新节点找到自己的位置,原本以为可以安顿下来~ if (cmp < 0) // 如果比较结果小于0,则称为parent的左孩子 parent.left = e; else // 如果被叫结果大于0,则称为parent的右孩子 parent.right = e; // 还需要对这个新节点进行重新着色和旋转操作,以达到平衡 fixAfterInsertion(e); // 终于融入其中 size++; modCount++; // 成功插入新节点后,返回为null return null; }
- 如果一个新节点在插入时能够运行到 fixAfterInsertion() 进行着色和旋转,说明:第一.新节点加入之前时非空树;第二.新节点的 Key 与任何节点都不相同。fixAfterInsertion() 是插入节点后的动作,和删除节点操作中的 fixAfterInsertion() 的原理基本相同
private void fixAfterInsertion(Entry<K, V> x) { // 虽然内部类Entry的属性color默认为黑色,但新节点一律先赋值为红色 x.color = RED; // 新节点是根节点或者父节点(简称为父亲)为黑色 // 插入红色节点并不会破坏红黑树的性质,无需调整 // X值得改变已用红色高亮显示,改变的过程是在不断地往上游遍历(或内部遍历) // 直到父亲为黑色,或者达到根节点 while (x != null && x != root && x.parent.color == RED){ // 如果父亲是其父节点(简称为爷爷)的左子节点 if (parentOf(x) == leftOf(parentOf(parentOf(x)))) { // 这时,得看爷爷的右子节点(右叔)的脸色 Entry<K, V> y =rightOf(parentOf(parentOf(x))); // 如果右叔是红色,则是通过局部颜色调整,就可以使子树继续满足红黑树的性质 if (colorOf(y) == RED) { // 父亲置为黑色 setColor(parentOf(x), BLACK); // 右叔置为红色 setColor(y, BLACK); // 爷爷置为红色 setColor(parentOf(parentOf(x)), RED); // 爷爷成为新的节点,进入到下一轮循环 x = parentOf(parentOf(x)); // 如果右叔是黑色,则需要加入旋转 } else { // 如果x是父亲的右子节点,先对父亲左依次左旋转操作 // 转化x是父亲的左子节点的操作 if ( x == rightOf(parentOf(x))) { // 对父亲做一次左旋操作,红色的父亲会沉入其左侧位置 // 将父亲赋值给x x = parentOf(x); rotateLeft(x); } // 重新着色并对爷爷进行右旋操作 setColor(parentOf(x), BLACK); setColor(parentOf(parentOf(x)), RED); rotateRight(parentOf(parentOf(x))); } // 与上方阴影代码相反,如果父亲是爷爷的右子节点 } else { // 则看左叔的脸色,原理相同 ... } } root.color = BLACK; }
- 在上方源码中,第一处出现的colorOf()方法返回节点颜色,调整后的根节点必然是黑色的,叶子节点可能是黑色的,也可能是红色的,叶子节点下挂的两个虚节点即 NIL 节点必然是黑色的,下方源码中的 p == null 时,返回为 BLACK。这些都是红黑树的重要性质
private static <K, V> boolean colorOf(Entry<K, V> p) { return (p == null ? BLACK : p.color); }
- 左旋和右旋基本相同,结合之前的旋转示例图,输入参数为失去平衡的那棵子树的根节点
private void rotateLeft(Entry<K, V> p) { // 如果参数节点不是NIL节点 if (p != null) { // 获取p的右子节点r Entry<K, V> r = p.right; // 将r的右子节点设置为p的右子树 p.right = r.left; // 若r的左子树不为空,则将p设置为r左子树的父亲 if(r.left != null) r.left.parent = p; // 将p的父亲设置r的父亲 r.parent = p.parent; // 无论如何,r都要在p夫琴心目中替代p的位置 if (p.parent == null) root = r; else if (p.parent.left == p) p.parent.left = r; else p.parent.right = r; // 将p设置为r的左子树,将r设置为p的父类 r.left = p; p.parent = r; } }
- 示例
TreeMap<Integer, String> treeMap = new TreeMap<Integer, String>(); treeMap.put(55, "fifty-five"); treeMap.put(56, "fifty-six"); treeMap.put(57, "fifty-seven"); treeMap.put(58, "fifty-eight"); treeMap.put(83, "eight-three"); treeMap.remove(57); treeMap.put(59, "fifty-nine"); // 在58和59之间插入83和删除57只因为需要构造一个场景:旋转两次(先右旋,再左旋)
- 第一步:先分析55、56、57三个数的插入操作。在 55 插入的时候是空树,它就是根节点,根据红黑树的约束条件,根节点必须是黑色的,将节点 55 涂黑。继续插入节点 56 与节点 57,新节点的颜色设置为红色。当插入 56 时,由于父亲是黑色节点,不做任何调整;当插入 57 时,由于父节点 56 是红色的,出现两个连续红色节点,需要重新着色并且旋转
- 第二步:再分析节点 58 的插入操作。父亲 57 是爷爷 56 的右节点,左叔 55 为红色。这时把父亲和左叔同时涂黑,把爷爷 56 设置为红色,因为爷爷 56 是根节点,退出循环,最后一句代码是 root.color = BLACK,重新把 56 涂黑
- 第三步:再分析节点 83 的插入操作,根据自然排序的结果,从根节点 56 开始比较,比 56 大、比 57 大、比 58 大。所以放置在 58 的右子节点上。在重新调整平衡时,父亲 58 是爷爷 57 的右节点,左叔不存在,认为是黑色 NIL。这时把父亲颜色涂黑,把爷爷设置为红色。此时,爷爷 57 为失去平衡的那棵小树(57/58/83)的根节点,将它比作输入参数,进行左旋操作
- 第四步:删除节点 57,因为节点 57 没有任何子节点,也非根节点,本身又是红色,不影响红黑树性质,直接删除即可
- 第五步:再分析 59 的插入操作,根据自然排序结果,从根节点 56 开始比较,比 56 大、比 58 大、比 83 小,放置在 83 的左子节点上。对于 59,只有满足如下条件,才会进入右旋转操作:(1)父亲是爷爷的右子节点;(2)当前节点是父亲的左子节点;(3)左叔是黑色的(删除57的原因所在)。右旋之后,把 59 涂黑,把 58 置为红色,然后以 58 为输入参数,进入左旋操作
- 在树的演化过程中,插入节点的过程中,如果需要重新着色或旋转,存在三种情况:
- 节点的父亲是红色,叔叔是红色的,则重新着色
- 节点的父亲是红色,叔叔是黑色的,而新节点的父亲的左节点:进行右旋
- 节点的父亲是红色,叔叔是黑色的,而新节点是父亲的右节点:进行左旋
- 如上图所示,在旋转时,箭头防线的引出端均为红色,插入 55、56、58,删除 57 均并没有引起树的旋转调整,红黑树相比 AVL 树,任何不平衡都能在 3 次旋转之内调整完整。每次向上回溯的步长是 2,对于频繁插入和删除的场景,红黑树的优势非常明显
- 总的来说,TreeMap 的时间复杂度比 HashMap 要高一些,但是要合理利用好 TreeMap 集合的有序性和稳定性,以及支持范围查找的特性,往往在数据排序的场景中特别高效。另外,TreeMap 是线程不安全的集合,不能再多线程之间进行共享数据的写操作,在多线程进行写操作时,需要添加互斥机制,或者把对象放在 Collections.stnchronizedMap(treeMap)中实现同步
- 在 JDK7 之后的 HashMap、TreeSet、ConcurrentHashMap,也是用红黑树的方式管理节点,如果只是对单个元素进行排序,使用 TreeSet 即可。TreeSet 底层就是 TreeMap,Value 共享使用一个静态 Object 对象
private static final Object PRESENT = new Object(); public boolean add(E e) { return treeMap.put(e, PRESENT) == null; }
-
HashMap
- 除了局部方法或绝对线程安全的情形外,优先推荐使用 ConcurrentHashMap,两者性能相差无几,但后者解决了高并发下的线程安全问题
- 例如:某个应用在 init() 方法中初始化了一个 static 的 HashMap 集合对象,从数据库提取数据到集合中,应用启动过程中仅单线程调用一次初始化方法不会有问题。但机缘巧合下,init() 被执行了两次,启动失败、CPU 使用率飙升,dump 分析发现存在 HashMap 死链。第一种解决方案是用 COncurrentHashMap 替代 HashMap;第二种解决方案是使用 Collections.synchronizedMap(hashMap) 包装成同步集合;第三种解决方案是对 init() 进行同步操作。此案例选择的第三种,毕竟只有启动时调用
- 例如:新应用上线不久就发现业务高峰期,一台服务器 CPU 使用率飙升到 100%,从监控平台上发现大量请求超时,初步认定服务器负载容量不够,采取基金扩容并重启服务器顺序恢复正常,但数日后,同样的问题再次出现,通过 jstack 命令分析,发现了大量 RUNABLE 状态的线程都在执行 HashMap 的 put 和 get 操作
"SuperBizProcessor-8-thread-348" damon prio=10 tid=0x00007f1f0c808800 nid=0x10a4c runnable [0x000000004b860000] Thread.State: RUNNABLE at HashMap.get(HashMap.java:464)
名称 说明 table 存储所有节点数据的数组 slot 哈希槽。即table[i]这个位置 bucket 哈希桶。table[i]上所有元素形成的表或数的集合 - 为了分析死链问题,这里使用 JDK7 的源码来分析新增元素的过程
public V put(K key, V value) { int hash = hash(key); int i = indexFor(hash, table,.length); // 次循环通过hashCode返回值找到对应的数组下标位置 // 如果equals结果为真,则覆盖原值,如果都为false,则添加元素 for (Entry<K, V> e = table[i]; e != null; e = e.next) { Object k; // 如果Key的hash是相同的,那么再进行如下判断 // Key 是同一个对象或者equals返回为真,则覆盖原来的Value值 if (e.hash == hash && ((k == e.key) == key || key.equals(k))) { V oldValue = e.value; e.value = value; return oldValue; } } // 还没添加元素就进行modCount++,将为后续留下很多隐患 modCount++; // 添加元素,注意最后一个参数i是table数组的下标 addEntry(hash, key, value, i); return null; } void addEntry(int hash, K key, V value, int bucketIndex) { // 如果元素个数达到threshold的扩容阈值且数组下标位置已经存在元素,则进行扩容 if ((size >= threshold) && (null != table[bucketIndex])) { // 扩容2倍,size是实际存放元素的个数,而length是数组的容量大小(capacity) resize(2 * table.length); hash = (null != key) ? hash(key) : 0; bucketIndex = indexInFor(hash, table.length); } createEntry(hash, key, value, bucketIndex); } // 插入元素时,应插入头部而不是尾部 void createEntry(int hash, K key, V value, int bucketIndex) { // 不管原来的数组对应的下标元素是否为null,都作为Entry的bucketIndex的next值 (第一处) Entry<K, V> e = table[bucketIndex]; // 即使原来是链表,也把整条链都挂在新插入的节点上 table[bucketIndex] = new Entry<>(hash, key, vlaue, e); size++; }
- 如上,在 createEntry() 方法中,新添加的元素直接放在 slot 槽上,使新添加的元素在下次取值是可以更快地被访问到,如果两个线程同时执行到第一处,那么一个线程地赋值会被另一个覆盖掉,也是对象丢失地原因之一
- resize() 的数据迁移
public class HashMapSimpleResize { private static HashMap map = new HashMap(); public static void main(String[] args){ // 扩容的阈值时table.length * 0.75 // 第一次扩容发生在第13个元素置入时(第一处) for (int i = 0; i<13; i++>) { map.put(new UserKey(), new EasyCoding()); } } } class UserKey { // 目的是让所有的 Entry 都在同一个哈希桶内 public int hashCode (){ return 1; } // 保证e.hash == hash && ((k = e.key) == key || key.equals(k)) 为false // 如果为true,则会对同一个Key上的值进行覆盖,不会形成链表 public boolean equals(Object obj) { return false; } }
名称 说明 length table数组的长度 size 成功通过put方法添加到HashMap中的所有元素的个数 hashCode Object.hashCode()返回的int值,尽可能地离散均为分布 hash Object.hashCode()与当前集合地table.length进行位运算地结果,以确定哈希槽的位置 - 理想的哈希集合对象的存放应该尽量符合
- 只要对象不一样,hashCode 就不一样
- 只要 hashCode 不一样,得到的 hashCode 与 hashSeed 位运算的 hash 就不一样
- 只要 hash 不一样,存放在数组上的 slot 就不一样
- 多个元素落在同一个哈希桶中就会形成链表(JDK7之后,可能会随链表长度增加进化为红黑树)。这个链表的头节点保存在哈希槽上,所以遍历 Map 的元素从两个方向进行,第一个方向,从下标 table[0] 至 table[length-1] 遍历所有哈希槽;第二个方向,如果哈希槽上存在元素,则遍历哈希桶里的所有元素
- JDK7中的resize()和transfer()数据迁移
void resize(int newCapacity) { Entry[] newTable = new Entry[newCapacity]; // JDK8移除hashSeed计算,因为计算时调用Random.nextInt()存在性能问题 transfer(newTable, initHashSeedAsNeeded(newCapacity)); // 在此步骤完成前,旧表上依然可以进行元素的增加操作,这激素hi对象丢失的原因之一 table = newTable; // 注意 MAX是1<<30,如果1<<31则成Integer的最小值:-2147483648 threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1); } // 从旧表迁移数据到新表, void transfer(Entry[] newTable, boolean rehash){ // 外部参数传入时,指定新表大小为:2*oldTable.length int newCapacity = newTable.length; // 使用forEach方式遍历整个数组下标 for(Entry<K, V> e:table){ while(null !=e){ Entry<K, V> next = e.next; // 当前元素总是直接放在数组下标的slot上,而不是放在链表的最后 if(rehash){ e.hash = null ==e.key ? 0:hash(e.key); } int i = indexFor(e.hash, newCapacity); // 把原来slot上的元素作为当前元素的下一个 e.next = newTable[i]; // 新迁移过来的节点直接放在slot位置上 newTable[i] = e; // 链表继续向下遍历 e = next; } } }
- transfer() 数据迁移方法在数组非常大时会非常消耗资源。当前线程迁移过程中,其他线程新增的元素有可能落在已经遍历过的哈希槽上;在完成遍历之后,table 数组指向了新 newTable,这是新增的元素就会丢失,被无情地垃圾丢失,如果 resize 完成,执行了 table = newTable,则后续的元素就可以在新表上进行插入操作。但是如果多个线程同事执行 resize,每个线程又都会 new Entry[newCapacity],这是线程的局部变量,线程之间不可见。前一晚抽,resize 的线程会赋值给 table 线程红线遍历,从而覆盖其他线程的操作,因此在"新表"中进行插入操作的对象会被无情的丢弃
- HashMap 在高并发场景中,新增对象丢失的原因
- 并发赋值时被覆盖
- 已遍历区间元素会丢失
- "新表"被覆盖
- 迁移丢失,在迁移有并发时,next 提前被置为 null
- 对于死链的生成,根源在于 Entry 的 next 被并发修改
- 原先没有死链的同一个 slot 上节点遍历一定能够按顺序走完
- table 数组各线程都是可以共享修改的对象
- put()、get() 和 transfer() 三种操作在运行到此拥有死链的 slot 上,CPU 使用会飙升
- 死链可能导致
- 对象丢失
- 两个对象互链
- 对象自己互链
-
ConcurrentHashMap
- ConcurrentHashMap 的相关属性定义
// 默认为 null,ConcurrentHashMap 存放数据的地方,扩容时大小总是2的幂次方 // 初始化发生在第一次插入操作,数组默认初始化大小为16 transient volatile Node<K, V>[] table; // 默认为null,扩容时新生成的数组,其大小为原数组的两倍 private transient volatile Node<k, V>[] nextTable; // 存储单个KV节点,内部有key、value、hash、next指向下一个节点 // 它有四个在ConcurrentHashMap类内部定义的子类:TreeBin、TreeNode、ForwardingNode、ReservationNode,前三个子类都重写了查找元素的方法find() static class Node<K, V> implements Map.Entry<K, V> {...} // 它并不存储实际数据,维护对桶内红黑树的读写锁,存储对红黑树节点的引用 static final class TreeBin<K, V> extends Node<K, V> {...} // 在红黑树结构中,实际存储数据的节点 static final class TreeNode<K, V> extends Node<K, V> {...} // 扩容转发节点,放置此节点后,外部对原哈希槽的操作会转发到nextTable上 static final class ForwardingNode<K, V> extends Node<K, V> {...} // 占位加锁节点,执行某些方法时,对其加锁,如computerIfAbsent等 static final class ReservationNode<K, V> extends Node<K, V> {...} // 默认为0,重要属性,用来控制table的初始化和扩容操作 // sizeCtl=-1,表示正在初始化中 // sizeCtl=-n,表示(n-1)个线程正在进行扩容中 // sizeCtl>0,初始化或扩容中需要使用的容量 // sizeCtl=0,默认值,使用默认容量进行初始化 private transient volatile int sizeCtl; // 集合size小于64,无论如何,都不会使用红黑树 // 转化为红黑树还有一个条件时TREEIFY_THRESHOLD static final int MIN_TREEIFY_CAPACITY = 64; // 同一个哈希桶内存储的元素个数超过此阈值时则存储结构由链表转化为红黑树 static final int TREEFIFY_THRESHOLD = 8; // 同一个哈希桶内存储的元素个数小于等于此阈值时,从红黑树回退至链表结构,因为元素个数较少时,链表更快 static final int UNTREEFIFY_THRESHOLD = 6; ```![在这里插入图片描述](https://img-blog.csdnimg.cn/20210218144906400.PNG?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L2xiaF9wYW9wYW8=,size_16,color_FFFFFF,t_70#pic_center)
- table 的长度为 64,数据存储结构为两种:链表和红黑树。当某个槽内的元素个数增加到超过 8 个且 table 的容量大于等于 64 时,由链表转化为红黑树,如果 table 容量小于 64,只会扩容。当某个槽内的元素个数减少到 6 个时,由红黑树重新转回链表。在转化过程中,使用同步块锁住当前槽的首元素,放置其他进程对当前槽进行增删改查,转化完成后由 CAS 替换原有链表
- ForwardingNode与ReservationNode
- ForwardingNode 在 table 扩容时使用,内部记录了扩容后的 table,即 nextTable。当 table 需要进行扩容时,依次遍历当前 table 中的每一个槽,如果不为 null,则需要把其中所有的元素根据 hash 值放入扩容后的 nextTable 中,而原 table 的槽内会放置一个 ForwardingNode 节点,此节点会把 find() 请求转发到扩容后的 nextTable 上,而执行 put() 方法的线程如果碰到此节点,也会协助进行迁移
- ReservationNode 在 computerIfAbsent() 以及相关方法中作为一个预留节点使用。 computerIfAbsent() 方法会先判断相应的 Key 值是否已存在,如果不存在,则调用由用户实现的自定义方法来生成 Value 值,组成 KV 键值对,随后插入此哈希集合中,在并发场景下,在得知 Key 不存在到插入哈希集合内的时间间隔内,为了放置哈希槽被其他线程抢占,当前线程会使用一个 ReservationNode 节点放到槽中并加锁,从而保证了线程的安全性
- size
- 无论是 JDK7 还是 JDK8,ConcurrentHashMap 的 size() 方法都只能返回一个大概的数量。因为已经统计的槽可能在返回前又有了变化
- JDK7
- 当经过 3 次计算(2次对比)后,发现每次统计哈希都有结构性的变化,它就会把所有的 Segment 都加锁
- JDK8
// 记录元素总数值,主要用于在无竞争状态下。在总数更新后,通过CAS方式直接更新这个值 private transient volatile long baseCount; // 一个计数器单元,维护了一个value值 static final class CounterCell {...} // 在竞争激烈的状态下启用,线程会把总数更新情况存放到该结构内,当竞争进一步加剧时,会通过扩容减少竞争 private transient volatile CounterCell[] counterCells;
- 当并发较小时,优先使用 CAS 的方式直接更新 baseCount
- 当更新 baseCount 冲突时,则会认为进入到比较激烈的竞争状态,通过启用 counterCells 减少竞争,通过 CAS 的方式把总数更新情况记录在 counterCells 对应的位置上
- 如果更新 counterCells 上某个位置时出现了多次失败,则会通过扩容 counterCells 的方式减少冲突
- 当 counterCells 处在扩容期间时,会尝试更新 baseCount 值
- 所以统计 size 时,只需要让 baseCount 加上各 counterCells 内的数据,就可以得到哈希内的元素个数
- ConcurrentHashMap 的相关属性定义
-
第七章 并发与多线程
- 并发和并行
- 从并发( Concurrency )与并行( Parallelism )说起。并发是指在某个时间段内,多任务交替处理的能力。所谓不患寡而患不均,每个 CPU 不可能只顾着执行某个进程,让其他进程一直处于等待状态。所以,CPU 把可执行时间均匀地分成若干份,每个进程执行一段时间后,记录当前的工作状态,释放相关的执行资源并进入等待状态,让其他进程抢占 CPU 资源。并行是指同时处理多任务的能力。目前,CPU 已经发展为多核,可以同时执行多个互不依赖的指令及执行块。并发与并行两个概念非常容易混淆,它们的核心区别在于进程是否同时执行
- 特点
- 并发挥序之间有相互制约的关系。直接制约体现为一个程序需要另一个程序的计算结果,间接制约体现为多个程序竞争共享资源,如处理器、缓冲区等
- 并发程序的执行过程是断断续续的。程序需要记忆现场指令及执行点
- 当并发数设置合理并且 CPU 拥有足够的处理能力时,并发会提高程序的运行效率
- 线程安全
- 线程是 CPU 调度和分配的基本单位,线程可以拥有自己的操作栈、程序计数器、局部变量表等资源,它与同一进程内的其他线程共享该进程的所有资源。线程在生命周期内存在多种状态,有 NEW(新建),RUNNEABLE(就绪状态)、RUNNING(运行状态)、BLOCKED(阻塞状态)、DEAD(终止状态)五种状态
- NEW,即新建状态,是线程被创建且未启动的状态。创建线程的方式有三种:第一种是继承自 Thread 类,第二种是实现 Runnable 接口,第三种是实现 Callable 接口。实现 Callable 接口的好处是可以 通过 call() 获取返回值
- RUNNABLE,即就绪状态,是调用 start() 之后运行之前的状态。线程的 start() 不能被多次调用,否则会抛出 IllegalStateException 异常
- RUNNING,即运行状态,是 run() 正在执行时线程的状态。线程可能会由某些因素退出 RUNNING,如时间、异常、锁、调度等
- BLOCKED,即阻塞态度,进入此状态,有以下情况
- 同步阻塞:锁被其他线程占用
- 主动阻塞:调用 Thread 的某些方法。主动让出 CPU 执行权,比如 sleep()、join()等
- 等待阻塞:执行 wait()
- DEAD,即终止状态,是 run() 执行结束,或因异常退出后的状态,此状态不可逆转
- 高并发下的线程安全
- 数据单线程内可见。单线程总是安全的,通过限制线程仅在单线程内可见,可以避免数据被其他线程篡改。最典型的就是线程局部变量,它存储在独立虚拟机栈帧的局部变量表中,与其他线程毫无瓜葛。ThreadLocal 就是这样的
- 只读对象。只读对象总是安全的,它的特征是允许复制、拒绝写入。最典型的只读对象有 String、Interger 等。一个对象想要拒绝任何写入,必须满足以下条件:使用 final 关键字修饰类,避免被继;使用 private final 关键字避免属性中被中途修改;没有任何更新方法;返回值不能可变对象为引用
- 线程安全类。某些线程安全类的内部有非常明确的线程安全机制。比如 StringBuffer 就是一个线程安全类,它采用 synchronized 关键字来修饰相关方法
- 同步与锁机制。如果想要对某个对象进行同步更新操作就需要锁
- 要么只读、要么加锁
- 并发包的类族
- 线程同步类:CountDownLatch、Semaphore、CyclicBarrier
- 并发集合类:ConCurrentSkipListMap、CopyOnWriteArrayList、BlockingQUeue
- 线程管理类:Thread 和 ThreadLocal
- 锁相关:ReetrantLock
- 线程是 CPU 调度和分配的基本单位,线程可以拥有自己的操作栈、程序计数器、局部变量表等资源,它与同一进程内的其他线程共享该进程的所有资源。线程在生命周期内存在多种状态,有 NEW(新建),RUNNEABLE(就绪状态)、RUNNING(运行状态)、BLOCKED(阻塞状态)、DEAD(终止状态)五种状态
- 什么是锁
- 并发包中的锁类
- 并发包的类族,Lock 是 JUC 包的顶层接口,它的实现逻辑并未用到 synchronized,而利用 volatile 的可见性
- ReentrantkLock 对于 Lock 接口的实现主要依赖于 Sync,而 Sync 继承了 AbstractQueueSynchronizer(AQS),它是 JUC 包实现同步的基础工具。在 AQS 中,定义了一个 volatile int state 变量作为共享资源,如果线程获取资源失败,则进入同步 FIFO 队列中等待,如果成功获取资源就执行临界区代码。执行完释放资源时,会通知同步队列中的等待线程来获取资源后出队并执行
- AQS 是抽象类,内置自旋锁实现的同步队列,封装入队和出队的操作,提供独占、共享、中断等特性的方法。比如可重入锁 ReetrantLock,定义 state 为 0 时可以获取资源并置为 1。若已获得资源,state 不断加1, 在释放资源时 state 减 1,直至为 0;CountDwonLatch 初始化时定义了资源总量 state = count,countDown() 不断将 state 减 1,当 state = 0 时才能获得锁,释放后 state 就一直为 0;所有线程调用 await() 都不会等待,所以 CuntDownLatch 是一次性的,用完后想用就只能重新建一个,如果需要循环使用,推荐使用基于 ReetrantLock 实现的 CyclicBarrier。Semaphore 与 CountDownLatch 略不同,同样也定义了资源总量 state=permits,当 state>0 时就能获得锁,并将 state 减 1,当 state = 0 时只能等待其他线程释放锁,当释放锁时 state 加 1,其他等待线程又能获得这个锁,并将 state 减 1,当 state = 0 时只能等待其他线程释放锁,当释放锁时 state 加 1,其他线程又能获得这个锁,当 Semphore 的 permits 定义为 1 时,就是互斥锁,当 permits>1 就是共享锁
- JDK8 提出了一个新的锁,StampedLock,改进了读写锁 ReentrantReadWriteLock
- 利用同步代码块
- 同步代码块一般使用 Java 的 synchronized 关键字来实现,有两种方式对方法进行加锁,第一,在方法签名处加 synchronized 关键字;第二,使用 synchronized(对象或类)进行同步
- JVM 底层是通过监视锁来实现 synchronized 同步的。监视锁 monitor 是每个对象与生俱来的一个隐藏字段。使用 synchronized 时, JVM 会跟 synchronized 的当前使用环境,找到对应对象的 monitor ,再根据 monitor 的状态进行加、解锁的判断。例如,线程在进入同步方法或代码块时,会获取该方法或代码块所属对象的 monitor 进行加锁判断。如果成功加锁就成为该 monitor 的唯一持有者。monitor 在被释放前,不能再被其他线程获取
- 并发包中的锁类
- 线程同步
- 同步是什么
- 资源共享的两个原因时资源紧缺和共建需求
- 有些看似非常简单的操作其实不具备原子性,典型就是 i++ 操作,它需要分三部,即 ILOAD->IINC->ISTORE。另一方面,更加复杂的 CAS(Compare and Swap) 操作却具有原子性
- 计算机的线程同步,就是线程之间按某种机制协调先后次序执行,当有一个线程在对内存进行操作时,其他线程都不可以对这个内存地址进行操作,知道该线程完成操作。实现线程同步的方式有很多,比如同步方法、锁、阻塞队列
- volatile
- 当使用 volatile 修饰变量时,意昧着任何对此变量的操作都会在内存中进行,不会产生副本,以保证共享变量的可见性,局部阻止了指令重排的发生。由此可知,在使用单例设计模式时,即使用双检锁也不一定会拿到最新的数据
- volatile 解决的是多线程共享变量的可见性问题,但它不能保障原子性,例如 count++ 就会有错误数据,能实现 count++ 原子操作的其他类有 AtomicLong 和 LongAdder,JDK8 推荐使用 LongAdder 类,它比 AtomicLong 性能更好,有效减少了乐观锁的重试次数
- volatile 只是轻量级的线程操作可见方式,并非同步方式。如果是多写场景,一定会产生线程安全问题。如果是一写多读的并发场景,使用 volatile 修饰变量则非常合适。volatile 一写多读最典型的应用是 CopyOnWriteArrayList。它在修改数据时会把整个集合的数据全复制出俩坑对写操作枷锁,修改完之后,再用 setArray() 把 array 指向新的集合,使用 volatile 可以使读线程尽快地感知 array 的修改,不进行指令重排,操作后即对其他线程可见
- 信号量同步
- 信号量是指不同的线程之间,通过传递同步信号量来协调线程执行的先后次序,基于时间和信号两个维度有两个类:CountDownLatch、Semaphore
- 同步是什么
- 线程池
- 线程池的好处
- 线程池的作用
- 利用线程池管理并服用线程、控制最大并发数等
- 实现任务线程队列缓存策略和拒绝机制
- 实现某些与时间相关的功能,如定时任务,周期任务
- 隔离线程环境
- 线程池的创建
public ThreadPoolExecutor( int corePoolSize, // 第一个参数 int maximumPoolSize, // 第二个参数 long keepAliveTime, // 第三个参数 TimeUnit unit, // 第四个参数 BolckingQuene<runnable> workQuene, // 第五个参数 ThreadFactory threadFactory, // 第六个参数 RejectedExecutionHandler handler){ // 第七个参数 if ( corePoolSize <0 || // maximumPoolSize 必须大于或等于1 也要大于或等于 corePoolSize (第一处) maximumPoolSize <= 0 || maximumPoolSize < corePoolSize || keepAliveTime < 0) throw new IllegalArgumentException(); // (第二处) if (workQuene == null || threadFatory == null || handler == null) throw new NullPointerException(); // 其他代码。。。 } )
- 第一个参数:corePoolSize 表示常驻核心线程数。如果等于 0 ,则任务执行完后,没有任何请求进入时销毁线程池的线程,如果大于 0,即使本地任务执行完毕,核心线程也不会被销毁。这个值过大则会浪费资源,过小则会导致线程频繁地创建或销毁
- 第二个参数:maximumPoolSize 表示线程池能够容纳同时执行地最大线程书,此值必须大于或等于一。如果待执行地线程数大于此值,需要借助第五个参数缓存在队列中,如果 maximumPoolSize 与 corePoolSize 相等,即固定大小线程池
- 第三个参数:keepAliveTime 表示线程池中的线程空闲时间,当空闲时间达到 keepAliveTime 时,线程会被销毁,直到只剩下 corePoolSize 个线程为止,避免浪费内存和句柄子资源,默认情况下,当线程池的线程数大于 corePoolSize 时,keepAliveTime 才会起作用。但是当 ThreadPoolExecutor 的 allowCoreThreadTimeOut 变量设置为 true 时,核心线程超时后也会被回收
- 第四个参数:TimeUtil 表示时间单位,keepAliveTime 的时间单位通常都是 TimeUnit.SECONDS
- 第五个参数:workQueue 表示缓存队列。当请求的线程数大于 maximumPoolSize 时,线程进入 BlockingQueue 阻塞队列。后续实例中使用的 LinkedBlockingQueue 是单向链表,使用锁来控制入队和出队的原子性,两个锁分别控制元素的添加和获取,是一个生产消费模型队列
- 第六个参数:threadFactory 表示线程工厂,它用来生产一组相同任务的线程,线程池的命名是通过给这个 factory 增加组名前缀来实现的,在虚拟机栈分析时,就可以知道线程任务时由那个线程工厂产生的
- 第七个参数:handler 表示执行决绝策略的对象。当超过第五个参数的 workQuene 的任务缓存区上限的时候,就可以通过该策略处理请求,这是一种简单的限流保护。友好的策略有如下三种
- 保存到数据库进行削峰填谷,在空闲时在提取出来执行
- 转向某个提示页面
- 打印日志
- Exceutors与ThreadPoolExecutor的关系
-
ExecutorService 接口继承了 Executor 接口,定义了管理线程任务的方法。ExecutorService 的抽象类 AbstractExecutorService 提供了 submit()、invokeAll() 等部分方法的实现,但是核心方法 Executor.execute() 并没有在这里实现。因为所有的任务都在这个方法内执行,不同实现会带来不同的执行策略,这点在后续的 ThreadPoolExecutor 解析时,会一步一步分析。通过 Executors 的静态工厂方法可以创建三个线程池的包装对象:ForkJoinPool、ThreadPoolExecutor、SchduledThreadPoolExecutor
-
Executors 核心的方法有五个
- Executors.newWorkStealingPool:JDK8 引入,创建持有足够线程的线程池支持给定的并行度,并通过使用多个队列减少竞争,此构造方法这种把 CPU 数量设置为默认的并行度
- Executors.newCacheThreadPool:maximumPoolSize 最大可以至 Integer.MAX_VALUE,是高度可伸缩的线程池,达到上线肯定会抛出 OOM 异常。keepAliveTime 默认为 60 秒,工作线程处于空闲状态,则回收工作线程,如果任务数增加,再次创建出新线程处理任务
- Executors.newScheduledThreadPool:线程数最大至 Integer.MAX_VALUE,存在 OOM 风险,它是 ScheduledExecutorService 接口家族的实现类,支持定时及周期性任务执行,相比 Timer,ScheduledExecutorService 更安全,功能更强大,与 newCachedThreadPool 的区别是不回收工作线程
- Executors.newSingleThreadExecutor:创建一个单线程的线程池,相当于单线程串行执行所有任务,保证按任务的提交顺序依次执行
- Executors.newFixedThreadPool:输入的参数即是固定线程数,既是核心线程数也是最大线程数,不存在空闲线程,所以 keepAliveTime 等于 0
-
LinkedBlockingQueue
- 使用这样的无界队列,如果瞬时请求非常大,会有 OOM 的风险。除 newWorkStealingPool 外,其他四个创建方式都存在资源耗尽的风险
public LinkedBlockingQueue(){ this(Integer.MAX_VALUE); }
-
Executors 中默认的线程工厂和拒绝策略过于简单,通常对用户不够友好。线程工厂需要做创建前的准备工作,对线程池创建的线程必须明确标识。拒绝策略应该考虑到业务的场景,返回响应的提示或友好地跳转
public class UserThreadFactory implements ThreadFactory { private final String namePrefix; private final AtomicInteger nexId = new AtomicInteger(1); // 定义线程组名称,在使用jstack 来排查线程问题时,非常有帮助 UserThreadFactory(String whatFeatureOfGroup) { namePrefix = "UserThreadFactory's " + whatFeatureOfGroup + "-worker-"; } @Override public Thread newThread(Runnable task){ String name = namePrefix + newId.getAndIncrement(); Thread thread = new Thread(null, task, name, 0, false); System.out.println(thread.getName()); return thread; } } class Task implements Runnable { private final AtomicLong count = new AtomicLong(0L); @Override public void run(){ System.out.println("running_" + count.getAngIncrement()); } }
public class UserRejectHandler implements RejectExecutionHandler{ @override public void rejectExecution(Runnable task, ThreadPoolExecutor executor) { System.out.println("task reject. " + executor.toString()); } }
-
拒绝策略
- 在 ThreadPoolExecutor 中提供了四个公开地内部静态类
- AbortPolicy(默认):丢弃任务并抛出 RejectedExecutorException 异常
- DiscardPolicy:丢弃任务,但是不抛出异常,这是不推荐的做法
- DiscardOldestPolicy:抛弃队列中等待最久的任务,然后把当前任务加入队列中
- CallerRunsPolicy:调用任务的 run() 方法绕过线程池直接执行
- 在 ThreadPoolExecutor 中提供了四个公开地内部静态类
-
- 线程池的作用
- 线程池源码详解
- 在 ThreadpoolExecutor 的属性定义中频繁地用位移运算来表示线程池状态,位移运算是改变当前值地一种高效手段,包括左移与右移
// Integer 共有32位,最右边29位标识工作线程数,最左边3位表示线程池状态 (第一处) private static final int COUNt_BITS = Integer.SIZE - 3; // 000-11111111111111111111111111111,类似于子网掩码,用于位的与运算 // 得到左边3位,还是右边29位 private static final int COUNt_MASK = (1 << COUNT_BITS) - 1; // 用左边3位,实现5种线程池状态 // 111-00000000000000000000000000000,十进制数:-536,870,912 // 此状态标识线程池能接受新任务 private static final int RUNNING = -1 << COUNT_BITS; // 000-00000000000000000000000000000,十进制值:0 // 此状态不再接受新任务,但可以继续执行队列中的任务 private static final int SHUTDOWN = 0 << COUNT_BITS; // 001-00000000000000000000000000000,十进制值:536,870,912 // 此状态全面拒绝,并中断正在处理的任务 private static final int STOP = 1 << COUNT_BITS; // 010-00000000000000000000000000000,十进制值:1073,741,824 // 此状态表示所有任务都被终止 private static final int TIDYING = 2 << COUNT_BITS; // 011-00000000000000000000000000000,十进制值:1610,612,736 // 此状态表示已清理完现场 private static final int TERMINATES = 3 << COUNT_BITS; // 与运算,比如 001-00000000000000000000000100011,表示67个工作线程 // 掩码取反: 111-00000000000000000000000000000,即得到左边3位001, // 表示线程池当前处于STOP状态 private static int runStateOf(int c) { return c & ~COUNT_MASK; } // 同理掩码 000-11111111111111111111111111111,得到右边23位,即工作线程数 private static int workerCountOf(int c) { return c & COUNT_MASK; } // 把左边3位与右边29位按或运算,合成一个值 private static int ctlOf(int rs, int wc) { return rs | wc; }
- 第一处说明,线程池的状态用高3位表示,其中包括了符号位。五种状态的十进制按从小到大依次排序为:RUNNING < SHUTDOWN < STOP < TIDYING < TERMINATED,这样设计的好处是可以通过比较值的大小来确定线程池的状态
private static boolean isRunning(int c){ return c < SHUTDOWN; }
- 我们都知道 Executor 接口有且只有一个方法 executor,通过参数传入待执行线程的对象。下面分析 ThreadPoolExecutor 关于 execute 方法的实现
public void execute(Runnable command) { // 返回包含线程及线程池状态的 Integer 类型数值 int c = ctl.get(); // 如果工作线程数小于核心线程数,则创建线程任务并执行 if (workerCountOf(c) < corePoolSize) { // addWorker 是另一个极为重要的方法,见下一段源码解析(第一处) if (addWorker(command, true)){ return; } // 如果创建失败,防止外部已经在线程池中加入新任务,重新获取一下 c = ctl.get(); } // 只有线程池处于 RUNNING 状态,才执行后半句:置入队列 if (isRunning(c) && workQueue.off(command)) { int recheck = ctl.get(); // 如果线程池不是 RUNNING 状态,则将刚加入队列的任务一处 if (! isRunning(recheck) && remove(command)) { reject(command); } else if (workerCountOf(recheck) == 0) { // 如果之前的线程已被消费完,新建一个线程 addWorker(null, false); } // 核心池和队列都已满,尝试创建一个新线程 } else if (!addWorker(command, false)) { // 如果 addWorker 返回是 false,及创建失败,则唤醒拒绝策略(第二处) reject(command); } }
- 第一处:execute 方法在不同阶段有三次 addWorker 的尝试动作
- 第二处:发生拒绝的理由有两个:(1)线程池状态为非 RUNNING 状态;(2)等待队列已满
/** * 根据当前线程池状态,检查是否可以添加新的任务线程,如果可以则创建并启动任务 * 如果一切正常则返回true,返回 false 的可能性如下: * 1. 线程池没有处于 RUNNING 状态 * 2. 线程工厂创建新的任务线程失败 * firstTask:外部启动线程池时需要构建的第一个线程,它是线程的母体 * core:新增工作线程时的判断指标,解释如下 * true表示新增工作线程时,需要判断当前 RUNNING 状态的线程是否少于 corePoolSize * false表示新增工作线程时,需要判断当前 RUNNING 状态的线程是否少于 maximumPoolSize */ private boolean addWorker(Runnable firstTask, boolean core) { // 不需要任务预定义的语法标签,响应下文的 continue retry,快速退出多层嵌套循环(第一处) retry; for (int c = clt.get();;) { // 参考之前的状态分析:如果 RUNNING 状态,则条件为假,不执行后面的判断 // 如果时 STOP 及之上的状态,或者 firstTask 初始线程不为空,或者队列为空 // 都会直接返回创建失败 (第二处) if (runStateAtLeast(c, SHUTDOWN) && (runStateAtLeast(c, STOP) || firstTask != null || workQueue.isEmpty())) { return false; } for (;;) { // 如果超过最大允许线程数则不能再添加新的线程 // 最大线程数不能超过 2^29,否则影响左边 3 位的线程池状态 if (workerCountOf(c) >= ((core?corePoolSize:maximumPoolSize)&COUNT_MASK)){ return false; } // 将当前活动线程数+1(第三处) if (compareAndIncrementWorkerCount(c)) { break retry; } // 线程池状态和工作线程数是可变化的,需要经常提取或者最新值 c = ctl.get(); // 如果已经关闭,则再次从retry标签处进入,在第二处在做判断(第四处) if (runStateAtLeast(c, SHUTWODN)) { countine retry; } // 如果线程还是处于 RUNNING 状态,那就说明仅仅是第三处失败 // 继续循环执行(第五处) } } // 开始创建工作线程 boolean workerStarted = false; boolean workerAdded = false; Worker w = null; try{ // 利用 Worker 构造方法中的线程池工厂创建线程,并封装成工作线程 worker 对象 w = new ThreadPoolExecutor.Worker(firstTask); // 注意这是 Worker 中的属性对象 thread (第六出) final Thread t = w.thread; if (t != null) { // 在进行 ThreadPoolExecutor 的敏感操作时,都需要持有主锁,避免在添加和启动线程是被干扰 final ReentrantLock mainLock = this.mainLock; mainLock.lock(); try{ int c = ctl.get(); // 当线程池状态为 RUNNIG 或 SHUTDOWN 且 firstTask 初始线程为空时 if (isRunning(c) || (runStateLessThan(c, STOP) && firstTask == null )) { workers.add(w); int s = workers.size(); // 整个线程池在运行期间的最大并发任务个数 if (s > largestPoolSize) { largestPoolSize = s; } workerAdded = true; } } finally { mainLock.unlock(); } if (workAdded) { // 终于看到亲切的 start 方法 // 注意,并非线程池的 execute 的 command 参数指向的线程 t.start(); workerStarted = true; } } } finally { if (!workerStarted) { // 线程启动失败,把刚才第三处加上的工作线程计数再减回去 addWorkerFailed(w); } } return workerStarted; }
- Worker 对象时工作线程的核心类实现
/** * 它实现 Runnable 接口,并把本对象作为参数输入给 run() 方法中的 runWorker(this),所以内部属性线程 thread 在 start 的时候,即会调用 runWorker 方法 */ private final class Worker extends AbstractQueueSynchronizer implements Runnable { Worker(Runnbale firstTask) { // 它是AbstractQueueSynchronizer // 在runWorker方法执行之前禁止线程被中断 setState(-1); this.firstTask = firstTask; this.thread = getThreadFactory().newThread(this); } // 当 thread 被 start() 之后,执行 runWorker 的方法 public void run(){ runWorker(this); } }
- 在 ThreadpoolExecutor 的属性定义中频繁地用位移运算来表示线程池状态,位移运算是改变当前值地一种高效手段,包括左移与右移
- 使用线程池需要注意的地方
- 合理设置各类参数,应根据实际业务场景来设置合理的工作线程数
- 线程资源必须通过线程池提供,不允许在应用中自行显示创建线程
- 创建线程或线程池请指定有意义的线程名称,方便出错时回溯
- 线程池不允许使用 Excutors,而是推荐通过 ThreadPoolExecutor 的方式创建
- 线程池的好处
- ThreadLocal
- 引用类型
- 垃圾回收器在某些场景下,即使引用可达,也希望能够根据语义的强弱进行有选择地回收,以保证系统的正常运行。根据引用类型语义的强弱来决定垃圾回收的阶段,我们可以引用分为强引用、软引用、弱引用和虚引用四类。后三类本质上,可以让开发工程师通过代码方式来决定对象的垃圾回收时机
- 强引用,即 Strong Reference,如 Object object = new Object(); 这样的变量声明和定义就会产生对该对象的强引用。只要对象有强引用指向,并且 GC Roots 可达,那么 Java 内存回收时,即使濒临内存耗尽也不会回收该对象
- 软引用,即 Soft Refrerence,是用在非必需对象的场景。在即将 OOM 之前,垃圾回收器会把这些软引用指向的对象加入回收范围,以获得更多的内存空间,让程序能够继续健康运行。主要用来缓存服务器中间计算结果及不需要实时保存的用户行为等
- 弱引用,即 Weak Reference,也是用来描述非必需对象的。如果弱引用指向的对象质询在弱引用这一条线路,则在下一次 YGC 时会被回收。由于 YGC 时间的不确定性,弱引用何时被回收也具有不确定性,弱引用主要用于指向某个易消失的对象,在强引用断开后,此引用不会劫持对象。调用 WeakReference.get() 可能返回 null,要注意空指针异常
- 虚引用,即 Phantom Reference,是极弱的引用关系,定义完成后就无法通过该引用获取指向的对象。为一个对象设置虚引用的唯一目的就是希望能在整个对象被回收时收到一个系统通知。虚引用必须和引用队列联合使用,当垃圾回收时,如果发现粗在虚引用,就会在回收对象内存前,把这个虚引用加入与之关联的引用队列中
- 垃圾回收器在某些场景下,即使引用可达,也希望能够根据语义的强弱进行有选择地回收,以保证系统的正常运行。根据引用类型语义的强弱来决定垃圾回收的阶段,我们可以引用分为强引用、软引用、弱引用和虚引用四类。后三类本质上,可以让开发工程师通过代码方式来决定对象的垃圾回收时机
- ThreadLocal
- ThreadLocal副作用
- 脏数据:由于线程池回复用 Thread 对象,那么与 Thread 绑定的类的静态属性 ThreadLocal 变量也会被重用
- 内存泄漏:在源码中提示使用 static 关键字来修改 Threadlocal,在此场景下,寄希望于 ThreadLocal 对象失去引用后,出发弱引用机制来回收 Entry 的 Value 就不现实了
- 引用类型
第八章 单元测试
- 单元测试的优势
- 提升软件质量
- 促进代码优化
- 提升研发效率
- 增加重构自信
- 单元测试的基本原则
- 宏观上,单元测试要符合 AIR 原则
- A(Automatic 自动化):单元测试应该是全自动执行的。测试用例通常会被频地触发执行,执行过程必须完全自动化才有意义,如果单元测试的输出结果需要人工介入检查,那么它一定是不合格的。单元测试中不允许使用 System.out 来进行人工验证,而必须使用断言来验证
- I(Independent 独立性):为了保证单元测试稳定可靠且便于维护,需要保证其堵路性。用例之间不允许互相调用,也不允许出现执行次序的先后依赖
- R(Repeatable 可重复):单元测试是可以重复执行的,不能受到外界环境的影响。比如单元测试通常会被放到持续集成中,每次有代码提交时单元测试都会被触发执行。如果单测对外部环网络、服务、中间件等)有依赖 ,则容易导致持续集成机制的不可用
- 微观上,单元测试的代码层面要符合 BCDE 原则
- 编写单元测试时要保证测试粒度足够小,这样有助于精确定位问题,单元测试用例默认是方法级别的。单测不负责检查跨类或者跨系统的交互逻辑,那是集成测试需要覆盖的范围
- B: Border 边界值测试,包括循环边界、特殊取值、特殊时间点、数据顺序等
- C: Correct 正确的输入并得到预期的结果
- D: Design 与设计文档相结合,来编写单元测试
- E: Error 单元测试的目标是证明程序有错,而不是程序无错。为了发现代码中潜在的错误,我们需要在编写测试用例时有些强制的错误输入(如非法数据、异常流程、非业务允许输入等)来得到预期的错误结果
- mock
- 由于单元测试只是系统集成测试前的小模块测试,有些因素往往是不具备的,因此需要进行 mock
- 功能因素:比如被测试方法内部调用的功能不可用
- 时间因素:比如栓十一还没到来,与此时间相关的功能点
- 环境因素:政策环境,如支付宝政策类新功能,多端环境,如PC、手机等
- 数据因素:线下数据样本过小,难以覆盖各种线上真实场景
- 其他因素:为了简化测试编写,可以将一些复杂的依赖采用 mock 方式实现
- 宏观上,单元测试要符合 AIR 原则
- 单元测试覆盖率
- 单元测试是一种白盒测试,测试者依据程序的内部结构来实现测试代码。单侧覆盖率是指业务代码被单测测试的比例和程度
- 粗粒度的覆盖
- 粗粒度的覆盖包括类覆盖和方法覆盖两种,类覆盖是指类中只要有方法或变量被测测试用例调用或执行到,就说这个类被测试覆盖了。方法覆盖同理,只要测试用例执行过程中,某个方法被调用了,则无论执行了该方法中的多少行代码都可以认为该方法被覆盖了
- 细粒度的覆盖
- 行覆盖:行覆盖也被称为语句覆盖,用来度量可执行的语句是否被执行到。行覆盖率的计算公式的分子是执行到的语句行数,分母是总的可执行语句行数
- 分支覆盖:也称判定覆盖,用来度量程序中每一个判定分支是否都被执行到。分支覆盖率的计算公式中的分子是代码中被执行到的分支数,分母是代码中所有分支的总数
- 条件判定覆盖:条件判定覆盖要求设计足够的测试用例,能够让判定中每个条件的所有可能情况至少被执行一次 同时每个判定本身的所有可能结果也至少执行一次
- 条件组合覆盖:条件覆盖组合是值判定中所有条件的各种组合情况都出现至少一次
- 路径覆盖:路径覆盖要求能够测试到程序中所有可能的路径
- 单元测试编写
- Java 语言的单元测试框架相对单一,JUnit 和 TestNG 几乎始终处于市场前两位
- JUnit 单元测试框架
-
Junit5.x 由以下三个部分组成
- Junit Platform:用于在 JVM 上启动测试框架,统一命令行、Gradle 和 Maven 等方式执行测试的入口
- Junit Jupiter:包含 Junit5.x 全新的编程模型和扩展机制
- Junit Vintage:用于在新的框架中兼容运行 Junit3.x 和 Junit4.x 的测试用例
-
Junit 测试类结构
// 定义一个测试类并指定用例在测试报告中的展示名称 @DispalyName("售票器类型测试") public class TicketSellerTest{ // 定义待测类的实例 private TicketSeller ticketSeller; // 定义在整个测试类开始前执行的操作 // 通常包括全局和外部资源(包括测试桩)的创建和初始化 @BeforeAll public static void init(){ ... } // 定义在整个测试类完成后执行的操作 // 通常包括全局和外部资源的释放和销毁 @AfterAll public static void cleanup(){ ... } // 定义在每个测试用例开始前执行的操作 // 通常包括基础数据和运行环境的准备 @BeforeEach public void create(){ this.ticketSller = new TicketSeller(); ... } // 定义在每个测试用例完成后执行的操作 // 通常包括运行环境的清理 @AfterEach public void destroy(){ ... } // 测试用例,当车票出售余额应该减少 @Test @DisplayName("售票后余额应该减少") public void shouldReduceInventoryWhenTicketSoldOut(){ ticketSeller.setInvetory(10); ticketSeller.sell(1); assertThat(ticketSeller.getInventory()).isEqualTo(9); } // 测试用例,当余票不足时因该报错 @Test @DisplayName("余票不足应报错") public void shouldThrowExceptionWhenNoEnoughInventory(){ ticketSeller.setInventory(0); assertThatExceptionOfType(TicketException.class) .isThrownBy(()->{ ticketSeller.sell(1);}) .withMessageContaining("all ticket sold out") .withNoCause(); } // Disabled注释将禁用测试案例 // 该测试用例会出现在最终的报告中,但不会被执行 @Disabled @Test @DisplayName("有退票时余票应增加") public void shouldIncreaseInventoryWhenTicketRefund(){ ticketSeller.setInventory(10); ticketSeller.refund(1); assetThat(ticketSeller.getInventory()).isEqualTo(11); } }
- 需要注意的是,@DisplayName 注解仅仅对于采用 IDE 或图形化展示测试运行结果的场景有效,但对于使用 Maven 或 Gradle 等命令行方式运行单元测试的情况,该注解中的内容会被忽略,当测试案用例比较多是,为了更好的组织测试的结构,推荐使用 JUnit 的 @Nested 注解来表达有层次关系地测试用例:
@DisplayName("交易服务测试") public class TransactionServiceTest{ @Nested @DisplayName("用户交易测试") class UserTransactionTest{ @Nested @DispalyName("正向测试用例") class PositiveCase{ @Test @DisplayName("交易检查应通过") public void shouldPassCheckWhenParameterValid(){ ... } } @Nested @DisplayName("负向测试用例") class NegativeCase{ ... } } @Nested @DisplayName("商家交易测试") class CompanyTransactionTest{ ... } }
- Juint 没有限制嵌套的层级数,除非有必要,一般不建议超过三层
- 分组测试和数据驱动测试也是单元测试中十分实用的技巧。其中,分组测试能够实现测试在运行频率维度上的分层,例如:将所有单元测试用例分为“执行很快且很重要”的冒烟测试用例、“执行很慢但同样比较重要”的日常测试用例,以及“数量很多但不太重要”的回归测试用例,使用 JUnit 的 @Tag 注解可以很容易的实现这种区分
@DisplayName("售票器类型测试") public class TicketSellerTest{ @Test @Tag("fast") @DisplayName("售票后余票应减少") public void shouldReduceInventoryWhenTicketSoldOut(){ ... } @Test @Tag("slow") @DisplayName() public void shouldSuccessWhenBuy20TicketsOnce(){ ... } }
- 通过标签选择执行的用例类型,在 Maven 中可以通过配置 maven-surefire-plugin 插件来实现
<build> <plugins> <plugin> <artifactid>maven-surefire-plugin</artifactid> <version>2.22.0</version> <configuration> <properties> <includeTags>fast</includeTags> <excludeTags>slow</excludeTags> </properties> </configuration> </plugin> </plugins> </build>
junitPlatform { filters { engines { include 'junit-jupiter' , 'junit-vintage' } tags { include 'fast' exclude 'slow' } } }
- 数据驱动测试适用于计算密集型的算法单元,这些功能单元内部逻辑复杂,对于不同的输入会得到截然不同的输出,而使用 JUnit 的 @TestFatory 注解能将数据的输入和输出与测试逻辑分开
@DisplayName("售票器类型测试") public class ExchangeRateConverterTest { @TestFactory @DisplayName("时间售票检查") Stream<DynamicTest> oddNumberDynam cTestWithStream() { ticketSeller.setCloseTime(LocalTime.of(l2 , 20 , 25 , 0 )); return Stream.of( Lists.list("提前购票", LocalTime.of( l2 20 , 24 , 0 ) , true ), Lists.list("准点购买", Loca1Time.of( l2 20 , 25 , 0 ) , true ), Lists.list("晚点购票", Loca1Time.of( l2 20 , 26 , 0 ), false ) ).map(data -> DynamicTest.dynamicTest((String)data.get(0), () -> assertThat(ticketSeller.cloudSellAt(data.get(1))).isEqualTo(data.get(2)))); } }
-
命名
- 通常来说,单元测试类的定义与被测类一一对应,放置于被测类相同的包路径下,并以被测类名称加上 Test 命名
- 单元测试代码必须写在工程目录下 src/test/java 下,不允许写在业务代码目录下,因为主流 Java 测试框架如 JUnit,TestNG 测试代码都是默认放在 src/test/java 下的,测试资源文件则放在 src/test/resources 下,这样有利于代码目录标准化
- 主流的 Java 单元测试方法命名规范有两种:一种是传统的以 “test” 开头,然后加待测场景和期待结果的命名方式;另一种则是更易于阅读的 “should…When” 结构
-
断言与假设
- 当定义好了需要运行的测试方法后,下一步则是关注测试方法的细节处理,这就离不开断言(assert)和假设(assume)断言封装好了常用的判断逻辑,当不满足条件时,该测试用例会被认定为测试失败,假设与断言类似,只不过当条件不满足时测试会直接退出而不是认定为测试失败,最终记录的状态是跳过。断言和假设是单元测试中最重要的部分,各种单元测试框架均提供了丰富的方法
- 常用的断言被封装在 org.junit.jupiter.api.Assertions 类中,均为静态方法
方法 释义 fail 断言测试失败 assertTrue/asse11False 断言条件为真或为假 assertNull/assertNotNull 断言指定值为 null 或非 null assertEquals/assertNotEquals 断言指定两值相等或不相等,对于基本数据类型,使用值比较;对于对象,使用 equals 方法比较 assertArrayEquals 断言数组元素全部相等 assertSame/assertNotSame 断言指定两个对象是否为同一个对象 assettThrows/assertDoesNotThrow 断言是否抛出了一个特定类型的异常 assertTimeout/assertTimeoutPreemptively 断言是否执行超时,区别在于测试程序是否在同一个线程内执行 assertlterabIeEquals 断言迭代器中的元素全部相等 assertLinesMatch 断言字符串列表元素全部正则匹配 assertAll 断言多个条件同时满足 assumeTrue/assumeFalse 先判断给定的条件为真或假,再决定是否继续接下来的测试
-
第九章 代码规约
免责声明:本文仅用于个人学习、交流,非商业用途;版权均属于原出品公司及原作者!
免责声明:本文禁止转载!
个人建议买本正版书,这是本值得反复翻看反复学习的书
版权声明:本文标题:【读书笔记】码出高效:Java开发手册 内容由网友自发贡献,该文观点仅代表作者本人, 转载请联系作者并注明出处:http://www.freenas.com.cn/jishu/1725921962h893164.html, 本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容,一经查实,本站将立刻删除。
发表评论