基本概念

数据链路层使用的信道主要有以下两种类型:

  1. 点对点信道:一对一的点对点通信方式
  2. 广播信道一对多的广播通信方式

这两种信道使用的协议为PPP协议CSMA/CD协议

数据链路层三个功能:封装成帧、透明传输、差错检测

当主机$H_1$向主机$H_2$发送数据时,我们可以想象数据就是再数据链路层从左向右沿左向右水平方向传送的:
$H_ 1$的链路层 -> $R_ 1$的链路层 -> $R_2$的链路层 -> $R_3$的链路层 -> $H_1$的链路层
数据链路层的地位 的图像结果

点对点信道

数据链路和帧

链路(link):从一个结点到相邻节点的一段物理线路(有线或无线),而中间没有任何其他的交换节点

数据链路(data link):链路加上一些必要的通信协议(控制数据的传输)

  • 现在最常用的是使用网络适配器(既有硬件也有软件)来实现这些协议,一般的适配器都包括了数据链路层和物理层的功能

数据链路层的协议数据单元——

  • 数据链路层把网络层交下来的数据构成帧发送到链路上,以及把接受到的帧中的数据取出并上交给网络层

点对点信道的数据链路层在进行通信时的主要步骤如下:

  1. 结点A的数据链路层要把网络层交下来的IP数据报添加首部
  2. 结点A把封装好的帧发送给结点B的数据链路层
  3. 若结点B的数据链路层收到的帧无差错,则从收到的帧中提取出IP数据报交给上面的网络层,否则丢弃这个帧
    使用点对点信道的数据链路层 的图像结果

基本问题

  1. 封装成帧
    封装成帧就是在一段数据的前后分别添加首部和尾部,这样就构成了一个帧。网络层的IP数据报传到数据链路层就成为帧的数据部分
    一个帧的帧长等于数据部分长度加上帧首部和帧尾部的长度

    首部和尾部的一个重要功能就是帧定界(确定帧的界限)
    首部和尾部还包括许多必要的控制信息

    每一种链路层协议都规定了所能传送的帧的数据部分长度上限——最大传送单元MTU(Maximum Transfer Unit)
    用帧首部和帧尾部封装成帧 的图像结果

    帧定界可以使用特殊的帧定界符
    用控制字符进行帧定界 的图像结果

    • 控制字符SOH(Start Of Header)放在帧的最前面,表示帧的首部开始,十六进制编码为01(二进制是00000001)
    • 控制字符EOT(End Of Transmission)表示帧的结束,十六进制编码为04(二进制是00000100)
  2. 透明传输
    当传送的帧是用文本文件组成的帧时(文本文件中的字符都是从键盘上输入的),其数据部分显然不会出现SOH或EOT这样的帧定界控制字符,可见不管从键盘上输入什么字符都可以放在这样的帧中传输过去,因此这样的传输就是透明传输

    • 非ASCII码的文本文件如果某个字节的二进制代码恰好和SOH或EOT这种控制字符一样,数据链路层就会错误地找到帧的边界,把部分帧收下,把剩下的部分丢弃,这就不是透明传输

      透明某一个实际存在的事物看起来却好像不存在一样(比如玻璃)

    • 在“数据链路层透明传输数据”表示无论什么样的比特组合地数据,都能按照原样没有差错地通过

      字节填充(byte stuffing)/字符填充(character stuffing):发送端的数据链路层在数据部分出现控制字符SOH或EOT时,会在前面插入转义字符”ESC“(十六进制编码是1B,二进制是00011011),接收端发送往网络层时再删除
      字节填充法解决透明传输的问题 的图像结果

  3. 差错检测

    比特差错:比特在传输过程发生差错,如1变为0,0变为1

    误码率BER(Bit Error Rate):在一段时间内,传输错误的比特占所传输比特总数的比率

    数据链路层广泛使用了循环冗余检测CRC(Cyclic Redundancy Check)的检错技术

    • 模2运算:进行加法和减法都不进位

      循环冗余检测CRC计算假定数据M有k个比特,CRC运算就是在数据M后面添加供差错检测用的n位冗余码,然后构成一个帧发出去,一共发送(k + n)位

      n位冗余码获得方式:在M后面加上n个0,得到的(n + k)位的数初一事先商定的(n + 1)位除数P,得出商是Q而余数是R(n位),这个余数R就作为冗余码拼接在数据M后面发送出去,也就是发送(k + n)位的CRC码

    • 这种冗余码称作帧检验序列FCS(Frame Check Sequence)

      循环冗余检验 的图像结果

      接收端把收到的数据进行CRC检验:把收到的每一个帧除以相同的除数P(模2运算),然后检查得到的余数R

    • R = 0则判定没有差错,接收
    • R $\ne$ 0则出现差错(无法确定具体位置),丢弃

      我们并没有要求数据链路层提供可靠传输的服务,也就是说,数据链路层只能做到凡是在接收端数据链路层接受的帧均无差错

    • 可靠传输:发送端发送什么,在接收端就收到什么

      除了比特差错,传输差错还有一类更加复杂的情况,那就是出现了帧丢失、帧重复、帧失序

点对点协议PPP

我们知道,互联网用户通常要连接到某个ISP才能接入互联网,PPP协议就是用户计算机与ISP进行通信时所使用的数据链路层协议

点对点协议PPP(Point-to-Point Protocol)是目前使用的最广泛的数据链路层协议
用户到ISP的链路使用PPP协议 的图像结果

PPP协议应满足的需求:

  • 简单:对于数据链路层的帧,不需要纠错,不需要序号,也不要流量控制。简单的设计可以使协议在实现时不容易出错,从而使不同厂商在协议的不同实现上的互操作性提高了
    • 协议标准化的一个主要目的就是提高协议的互操作性
  • 封装成帧:必须规定特殊的字符作为帧界定符
  • 透明性:必须保证数据传输的透明性
  • 多种网络层协议:必须能够在同一条物理链路上同时支持多种网络层协议
  • 多种类型链路:比如串行的或并行的,同步的或异步的
  • 差错检测:必须能够对接收端收到的帧进行检测,并立即丢弃有差错的帧
  • 检测连接状态:必须有一种机制能够及时自动检测出链路是否处于正常工作状态
  • 最大传输单元:必须对每一种类型的点对点链路设置最大传送单元MTU的默认值
  • 网络层地址协商:必须提供一种机制使通信的两个网络层的实体能够通过协商知道或者能够配置彼此的网络层地址
  • 数据压缩协商:必须提供一种方法来协商使用数据压缩算法

PPP协议不支持多点线路,支支持点对点的链路通信
PPP协议只支持全双工链路

PPP协议的帧格式

PPP帧的首部和尾部分别为四个字段和两个字段

  • 首部第一个字段和尾部第二个字段都是标志字符F(Flag),规定为0x7E,表示一个帧的开始或结束,即PPP帧的定界符

连续两帧之间只需要一个标识字段,如果出现连续两个标志字段,就表示这是一个空帧,应当丢弃

首部中的地址字符A(0xFF)和控制字符C(0x03)实际上并没有携带PPP帧的信息

首部的第四个字段是2字节的协议字段

  • 当协议字段为0x0021时,PPP帧的信息字段就是IP数据报
  • 当协议字段为0xC021时,信息字段是PPP链路控制协议LCP的数据
  • 当协议字段是0x8021时,表示这是网络层的控制数据

MTU的默认值是1500字节,在RFC 1661中,MTU叫做最大接收单元MRU(Maximum Recieve Unit)

信息字段的长度是可变的,不超过1500字节

尾部第一个字段是使用CRC帧检验序列的FCS

PPP帧的格式 的图像结果

当PPP使用异步传输时,它把转义字符定义为0x7D,并使用字节填充

  • 把信息字段中出现的每一个0x7E字节转变成为2字节序列(0x7D,0x5E)
  • 若信息字段中出现一个0x7D的字节,则把0x7D转变为2字节序列(0x7D,0x5E)
  • 若信息字段中出现ASCII码的控制字符,则在该字符前面要加入一个0x7D字节,同时将该字符的编码加以改变

PPP协议用在SONET/SOH链路时,使用同步传输(一连串的比特连续传送)而不是异步传输(逐个字符地发送)

  • 在这种情况况下,PPP协议采用零比特填充方法来实现透明传输

零比特填充法:在发送端先扫面整个信息字段,发现5个连续的1就立即填入一个0

PPP协议的工作状态

  1. PPP链路的其实和终止状态永远是“链路禁止”(Link Dead)状态

    • 这时在用户个人电脑和ISP的路由器之间并不存在物理层的连接
  2. 在双方建立了物理层连接后,PPP就进入“链路建立”(Link Establish

    • 其目的是建立链路层LCP连接

      LCP开始协商一些配置选项,即发送LCP的配置请求帧(Configure-Request)

    • 这是一个PPP帧,其协议字段为LCP对应的代码,而信息字段包含特定的配置请求

      1. 配置确认帧(Configure-Ack):所有选项都能接受
      2. 配置否认帧(Configure-Nak):所有选项都理解但是不能接受
      3. 配置拒绝帧(Configure-Reject):选项有的无法识别或不能接受,需要协商
        PPP协议的状态图 的图像结果

      LCP配置选项包括链路上的最大帧长、所使用的鉴别协议(authentication protocol)的规约,以及不使用PPP帧中的地址和控制字段

  3. 协商完之后双方就建立了LCP链路,接着就进入鉴别(Authenticate)状态,这一状态只允许传送LCP协议的分组、鉴别协议的分组以及监测链路质量的分组

    • 口令鉴别协议PAP(Passward Authentication Protocol):需要发起通信的乙方发送身份标识符和口令,允许用户重试若干次
    • 口令握手鉴别协议CHAP(Challenge-Handshake Authentication Protocol):更加复杂,更有安全性
  4. 鉴别身份失败则到链路终止(Link Terminate)状态,鉴别成功则进入网络层协议(Network-Layer Protocol)状态

    网络层协议状态,PPP链路的两端的网络控制协议NCP根据网络层的不同协议互相交换网络层特定的网络控制分组。总之,PPP协议两端的网络层可以运行不同的网络层协议,但仍可以使用同一个PPP协议进行通信

    如果在PPP链路上运行的是IP协议,则对PPP链路的每一端配置IP协议模块时就要使用NCP中支持IP的协议——IP控制协议IPCP(IP Control Protocol)

    • IPCP分组也封装成PPP帧(其中协议字段为0x8021)在PPP链路上传送
  5. 当网络层配置完毕后,链路进入链路打开(Link Open)状态,链路的两个PPP端点可以彼此向对方发送分组

    • 两个PPP端点还可以发送回送请求LCP分组(Echo-Request)和回送回答LCP分组(Echo-Reply),以检查链路的状态
  6. 数据传输结束后,可以由链路的一端发来终止请求LCP分组(Terminate-Request)请求链路连接,在收到对方发来的终止确认LCP分组(Terminate-Ack)后,转到链路终止状态

    • 如果链路出现故障,也会转到链路终止状态
    • 当调制解调器的载波停止后,则回到链路终止状态

广播信道

局域网

局域网最主要的特点:网络为一个单位所拥有,且地理范围和站点数目均有限

局域网的主要优点:

  • 具有广播功能:从一个站点可很方便地访问全网
  • 便于系统的扩展和逐渐演变:各设备的位置可灵活调整改变
  • 提高系统的可靠性(reliability)、可用性(availability)和生存性(survivability)

局域网按照网络拓扑进行分类,可分为星形网(Star)、环形网(Ring)、总线网(Bus)
星形网 的图像结果

共享信道要着重考虑的一个问题是如何使众多用户能够合理而方便地共享通信媒体资源,这在技术上有两种方法:

  1. 静态划分信道:用户只要分配到了信道就不会和其他用户发生冲突
    • 代价较高,不适合局域网
  2. 动态媒体接入控制:又称为多点接入(multiple access),其特点是信道并非在用户通信时固定分配给用户,这分为两类:
    • 随机接入:所有的用户可随机地发送信息,但如果恰巧有两个或更多的用户在同一时刻发送信息,那么在共享媒体上就要发生碰撞,使得这些用户的发送都失败
    • 受控接入:用户不能随机发送信息而必须服从一定的控制,典型代表有分散控制的令牌环局域网和集中控制的多点线路探询(polling),或称为轮询

以太网的两个标准DIX Ethernet V2与IEEE的802.3标准只有很小的差别,因此很多人常把802.3局域网简称为“以太网”

  • 数据率都为10Mbit/s

局域网的数据链路层可以拆分为两个子层,即逻辑链路控制LLC(Logical Link Control)子层和介质访问控制MAC(Medium Access Control)子层

  • 与接入到传输媒体有关的内容都放在MAC子层
  • 不管采用何种传输媒体和MAC子层的局域网对LLC子层来说都是透明的
    局域网对LLC子层是透明的 的图像结果

计算机与外界局域网的连接是通过通信适配器(adapter)进行的。适配器本来是在主机箱内插入的一块网络接口板,这种接口板又称为网络接口卡NIC(Network Interface Card),简称网卡

适配器和局域网之间的通过电缆或双绞线以串行传输方式进行的
适配器和计算机之间的通信是通过计算机主板上的I/O总线以并行传输方式进行的

适配器的一个重要功能就是进行数据串行传输和并行传输的转换

适配器在接收和发送各种帧时,不适用计算机的CPU。当适配器收到有差错的帧时,就把这个帧丢弃。当适配器收到正确的帧时,他就使用中断来通知计算机,并交付协议栈中的网络层。当计算机要发送IP数据报时,就由协议栈把IP数据报向下交给适配器,组装成帧后发送到局域网
数通 | 从二层、三层的概念切入这段时间学习的数通知识_层二源标识信息_zhouie的博客-CSDN博客

常见局域网中的介质访问控制MAC:

  • 以太网Ethernet:逻辑上是总线拓扑,物理上是星形或拓展星形拓扑
  • 令牌环网Token Ring:逻辑上是环形拓扑,物理上是星形拓扑
  • 光线分布式数据接口FDDI:逻辑上是环形拓扑,物理上是双环拓扑

介质访问控制方法(Access Methods):

  • 确定性轮流(Deterministic—taking turns:令牌环网和FDDI
  • 争用式(Non-deterministic(probabilistic):先到先得
    • 纯ALOHA协议(Pure ALOHA):主机任何时候都可以发送数据,如果发生冲突,延迟一段时间再发送
    • 分段ALOHA协议(Slotted ALOHA):把信道在时间上分段,主机任何时候都发送数据,但必须等待下一个时间分段的开始才开始发送

确定性MAC协议(Deterministic MAC Protocols):

  1. 特殊数据令牌在环中循环
  2. 当主机收到令牌时,它可以传送数据而不是令牌,这被称作夺取(seizing)令牌
  3. 当发送(transmitted)的帧返回到发送器时,站点将发送新令牌;框架已从环上卸下或脱落(stripped)

非确定性MAC协议(Non-Deterministic MAC Protocols):

  1. 此 MAC 协议称为带冲突检测的载波侦听多路访问(CSMA/CD,Carrier Sense Multiple Access with Collision Detection)
  2. 为了使用这种共享介质(shared-medium)技术,以太网允许网络设备为传输权进行仲裁(arbitrate)
  3. 适用于总线结构的以太网

局域网数据传输(Transmitison)方式:三种

  • 单播(unicast):将单个数据包从源发送到网络上的单个目标
  • 多播(multicast):由发送到网络上特定节点子集的单个数据包组成,这些节点都有同样的进程进行响应
  • 广播(broadcast):由单个数据包组成,该数据包传输到网络上的所有节点。(广播的目的地址是 0x11111111)

CSMA/CD协议

多点接入说明这是总线型网络,协议的实质是载波侦听和碰撞检测

载波侦听就是检测信道

  • 无论在发送前还是发送中,每个站都必须不停地检测信道

碰撞检测:也就是边发送边监听,也称为冲突检测

如图是传播时延对载波侦听的影响
CSMA/CD_姜希成的博客-CSDN博客

  • T为总时间,$t_0$为发生碰撞的时间,则发送端在发送数据帧用最多经过2$t_0$就可以知道是否发生了碰撞
  • 2$t_0$称为争用期(contention period),也称为碰撞窗口(collision window)

因此在使用CSMA/CD协议时,一个站不可能同时进行发送和接受,但必须边发送边侦听信道,也就是半双工通信

以太网使用截断二进制指数退避(truncated binary exponential backoff)算法来确定碰撞后的重传时机

  • 为了方便可以直接使用比特作为争用期的单位
  • 从离散的集合[0, 1, 2, …, $2^k - 1$]中随机取出一个数,记作r,重传应推后的时间就是r倍的争用期
    • k = Min[重传次数, 10]
  • 当重传达16次仍不成功,则丢弃该帧并向高层报告

凡是长度小于64字节的都是由于冲突而异常终止的无效帧

以太网信道利用率

以太网信道利用率总不能达到100%
计算机网络读书笔记——数据链路层(4)_晨哥是个好演员的博客-CSDN博客

  • 我们注意到成功发送一个帧需要占用信道的时间是$T_0 + r$,这是因为当发送完最后一个比特时,这个比特还要在一台网上传播,而最坏情况是需要时间为r
  • 以太网单程端到端时延r与帧的发送时间$T_0$之比为:$a = \frac{r}{T_0}$
    • a越大则争用期比例越大,信道利用率越低

以太网的MAC层

硬件地址又称为物理地址或者MAC地址 ,实际上就是适配器地址或适配器标识符EUI-48

IEEE规定地址字段的第一字节的最低位为I/G位(Individual/Group)

  • I/G = 0则地址字段表示一个单个站地址
  • I/G = 1则地址字段表示一个组地址,用来进行多播(组播)

IEEE规定地址字段的第一字节的最低第二位为G/L位(Gloup/Local)

  • G/L = 0则是全球管理
  • G/L = 1则是本地管理

适配器从网络上收到一个MAC帧就先用硬件检查MAC帧中的目的地址,这里的帧包括以下三种:

  • 单播(unicast)帧(一对一):收到的帧的MAC地址与本站的硬件地址相同
  • 广播(broadcast)帧(一对全体):发送给本局域网上所有站点的帧(全1地址)
  • 多播(multicast)帧(一对多):发送给笨局域网上一部分站点的帧

MAC帧的格式

存在802.3标准和Ethernet V2标准

  1. 前文:从 1 和 0 的交替(alternating)模式开始,称为前同步码(preamble)

    • 告诉接收方,要来数据了,因为不是预约发数据的模式,这个码就是为了保证对方有相应准备时间,前面 7 个是前同步码(0b10101010),最后一个是帧开始定界符(0x10101011)
    • 使用曼彻斯特编码的方案,无传输的时候是 0 电平的,前同步码告诉接收站一帧即将到来,前同步码不是 MAC 帧的内容
  2. 目标和源物理地址字段
    • 源地址始终是单播地址
    • 目的地址可以是单播地址、组播地址或广播地址
    • MAC地址:6个字节为目的地址(Dest.add),6个字节为源地址(Source.add)
    • 先看目的地址的好处:交换机等看到目的地址就可以进行判断,提高效率
  3. 长度字段:长度字段指示在该字段之后且在帧检查序列字段之前的数据字节数

    • 2 个字节长,早期规范放的是长度,指定数据长度,以太网-2 标准下则是使用 type 来完成这部分内容,指定后面的 DATA 是 IP 还是 IPX 的报文数据
    • 没有长度也可以计算出来长度,通过有电平长度就可以计算出数据的长度
    • 数据长度的限制(46-1500 字节),以太网的帧长度不能长于 1518 字节
    • 为了避免歧义,只要保证 Length 的数据大于数据报的最大长度即可保证是表示 type,保证和之前兼容
  4. 数据字段:数据字段包含要发送的信息

    • 数据的长度为 46(18 + 46 = 64 字节)-1500 字节,帧的大小至少是 64 个字节,如果数据太短需要补充 0 才能生成 data,前引导码不算帧长度
    • 以太网规定,小于 64 字节的帧是由于冲突而终止的无效帧
    • 最前面8个字段不算帧的内容
    • 4 个 64 字节大小帧同时发送才能保证占据全部的链路,100m 链路,用 512us,就是 512bit
  1. FCS字段:包含循环冗余校验码

最小帧长问题:

MAC子层上的介质访问控制

MAC地址采用十六进制数,为48位,表示为12个十六进制数字

  • IEEE管理前6个十六进制数字表示制造商或供应商,并包括组织唯一标识符(OUI)
  • 其余6个十六进制数字包括接口序列号,由特定供应商管理

广播

  1. 地址
    • 目标MAC:全1(FFFF,FFFF,FFFF)
    • 保证所有设备都能收到这个地址
    • 会导致非目的主机进行地址解析
  2. 广播会不必要地打断电台,从而严重影响电台性能
  3. 只有目的地的MAC地址未知或目的地是全部主机才能使用广播

以太网是广播网络,也就是说每个站都可以看到所有帧,不管是不是目的地,因此可以通过MAC地址判断站点是否为目的地,目标站在OSI层上发送数据,其他节点丢弃帧

广播操作步骤

  1. 听然后传送
  2. 广播 jam 信号
    • 是一个 32bit 的全 1 的数据帧表示出现了冲突
    • 所有侦听的设备都会发送
  3. 发生碰撞(Collision)
    • 两个设备同时使用链路发送电信号,则会出错。
    • 如果有冲突,则会一直侦听总线,等到空闲则可以组织数据帧发送
    • 多台主机同时进行组织数据帧进行发送
    • 如果出现冲突,则会发出 jam 信号,只要有 0 或者 1 传输,有电平则会表示使用
  4. 设备退回适当的时间,然后重新传输

无线局域网

无线局域网:

  • 基于单元的通信
  • 电台发送的信号只能被附近的电台接收
  • 短距离传输

无线局域网标准:

协议名称 带宽 频率 描述
802.11 1-2Mbps
802.11b 11Mbps 2.4GHz 使用与802.11不同的编码技术来实现,向后兼容
802.11a 54Mbps 5GHz
802.11g 可提供与802.11a相同的功能,具有802.11b的向后兼容性
802.11n 108Mbps
  1. 802.11

    • 关键技术:直接序列扩频DSSS(Direct Sequence Spread Spectrum)
    • DSSS 适用于在 1 到 2 Mbps 范围内运行的无线设备,上面的这个速率在实际生活场景中要除以 2(一来一回才有一次通信)
    • DSSS可以高达11Mbps的速度运行,在 2 Mbps 以上时将不被视为兼容
    • 也称为 Wi-Fi™,无线保证度,是星型拓扑,基站作为中心
  2. IEEE 802.11b(Wi-Fi)

    • 传输能力提高到 11Mbps
    • 所有 802.11b 系统都向后兼容(backward compliant),因为它们还仅针对 DSSS 支持 1 和 2Mbps 数据速率的 802.11
    • 通过使用与 802.11 不同的编码技术来实现更高的数据吞吐率
    • 在 2.4 GHz 内运行,解决了 802.11 中出现的部分问题
    • 使用的是高速直连方案
  3. IEEE 802.11a

    • 涵盖在 5GHz 传输频带中运行的 WLAN 设备
    • 802.11a 能够提供 54 Mbps 的数据吞吐量,并且采用称为“速率加倍”的专有技术已达到 108 Mbps。
    • 实际上,更标准的等级是 20-26 Mbps。
    • 传播距离相比 802.11 和 802.11b 短(衰减强),但是对于多用户上网的支持更好了
    • 使用正交频分复用技术
  4. IEEE 802.11g

    • 可以提供与 802.11a(54Mbps)相同的功能,但具有 802.11b 的向后兼容性
    • 使用正交频分复用技术
  5. IEEE 802.11n(下一代WLAN):
    • 提供的带宽是 802.11g 的两倍,即 108Mbps,理论上可达 500-600Mbps。实际上是 100M 左右
    • 目前使用比较多的方案

无线局域网分为两类:

  • 有基础设施拓扑网络(Infrastructure mode)

  • 无基础设施拓扑网络(ad-hoc mode)


基础设施指的是提前建设好的基站

虚拟载波监听

  1. 源站把它要占用信道的时间(包括目的站发回确认帧所需的时间)写入到所发送的数据帧中(即在首部中的持续时间中写入需要占用信道的时间,以微秒为单位,一直到目的站把确认帧发送完为止),以便使其他所有站在这一段时间都不要发送数据
  2. 当站点检测到正在信道中传送的帧中的持续时间时,就调整自己的网络分配向量NAV(Network Allocation Vector)
    • NAV 指出了信道处于忙状态的持续时间。
  3. 为什么信道空闲还要再等待呢?就是考虑可能有其他站点有高优先级的帧要发送。如有,就让高优先级帧先发迭。等待的时间就是 帧间间隔IFS(Inter-Frame Space)
    • SIFS(Short Inter-Frame Space,短帧间间隔)最短
    • PIFS(Point Inter-Frame Space,点协调功能帧间间隔)其次
    • DIFS(Distributed Inter-Frame Space,分布协调功能帧间间隔)最长

WLAN 中的 CSMA/CA 示意
实际吞吐量由于源站点发出帧后,接收节点需要返回确定帧(ACK),这会导致吞吐量降到带宽的一半,也会收到信号强度的影响

无线网络拓扑


基本服务集BSS包括一个基站BS和几个无线主机

  • 所有主机都可以在本地 BSS 中直接相互通信
    • 基站中两个主机之间是不直接互相通信的。
    • 同一个 BSS 中的主机间直接通信
      接入点AP充当基础架构模式的基站
    • AP 硬连线到有线局域网,以提供 Internet 访问和与有线网络的连接
    • 安装 AP 后,将分配服务集标识符(SSID)和通道

一个 BSS 可以通过分发系统(DS)连接到另一个 BSS,并构造一个扩展服务集(ESS)

家里的路由器既有 AP 的功能又有路由器功能,但是理论上只应该是 AP 的功能,一般我们认为家用路由器是一个 AP

访问过程:在 WLAN 中激活客户端时,它将开始侦听与之关联的兼容设备,这被称为扫描

  • 主动扫描:导致从寻求加入网络的无线节点发送探测请求,探测请求将包含它希望加入的网络的服务集标识符,当找到具有相同 SSID 的 AP 时,该 AP 将发出探测响应,当身份验证和关联步骤已完成后,移动端发出请求帧,但是 AP 不发送自己的信息
    • AP 比较安全。不用发送出自己的 SSID
  • 被动扫描:侦听由 AP或对等节点传输的信标管理帧,包含自己的 SSID 信息,当节点接收到包含要尝试加入的网络的 SSID 的信标时,将尝试加入该网络
    • 被动扫描是一个连续的过程,并且随着信号强度的变化,节点可能会与 AP 关联或分离,也是因为强度变化,所以连接状态需要维持

需要和 AP 连接,才能向 AP 发送数据帧

无线局域网的帧结构

WLAN不使用标准的802.3帧,具体框架有三种:

  • 控制帧(Control Frames)
  • 管理帧(Management frames)
  • 数据帧(仅数据帧类似于802.3帧)

无线数据帧和802.3帧的有效载荷为 1500 字节

  • 以太帧不能超过 1518 字节,而无线帧则可能高达2346 字节
  • 无线网络帧的大小也不会太大,尽量避免转换成有线帧的时候出现帧的拆分,也就是说大小一般在 1500 字节以下,通常,WLAN 帧大小将被限制为 1518 字节,因为它最常连接到有线以太网

数据帧结构(802.11无线网)

  1. 去往 AP 和来自 AP 是我们需要重点确认
  2. WEP 规格,Wired Equivalent Privacy(有线等效保密)
  3. 持续期:参数很重要,CSMA/CA 需要这个信息
  4. 有时间窗口,如果超时没收到信号,则进行重传

数据帧地址分类:

  • ad hoc用地址4
  • 有基础设施用地址1、2、3


这是拓展星型拓扑,而两个AP之间通过有线进行通信,因此不能全是1

避免冲突的载波侦听多路访问CSMA/CA

Q: 为什么我们需要CSMA/CA?
A: 冲突(Collisions)可能发生在 WLAN 中,但是站点只能知道附近的传输,因此 CSMA/CD 不是一个好的选择

  • 隐藏站问题:当 A 将数据传输到 B 时,C 无法检测到 A 和 B 之间的传输,因此 C 可能会决定将数据传输到 B 并导致 B 发生冲突
  • 暴露站问题:当 B 将数据传输到 A 时,C 可以检测到传输,因此 C 不会将数据传输到 D。但这是一个错误(听到不应该听到的信号)

多路复用机制

  1. 以太网
    • 信号被传输到电缆上的所有站。
    • 发送站检测到冲突。
    • 一次只能在信道上发送一个有效帧。
  2. WLAN 无线网络
    • 信号通过电缆传输到发送站附近的站(相邻,不可以跨越有效距离发送)
    • MAC协议必须尽最大努力确保仅发送站靠近接收站,发送方只能发送一路信号给接受方,不能有多个发送方发送信号给一个接受点
    • 接收方检测确定冲突
    • 一次可以在通道上传输个有效帧,不可以产生冲突

CSMA/CA:发送站点在发送数据前,以控制短帧刺激接收站点发送应答短帧,使接收站点周围的站点监听到该帧,从而在一定时间内避免数据发送

基本过程

  1. A 向 B 发送请求发送RTS(Request To Send)帧,A 周围的站点在一定时间内不发送数据,以保证 CTS 帧返回给 A
    1. B 向 A 回答清除发送CTS(Clear To Send)帧,B周围的站点在一定时间内不发送数据,以保证A发送完数据
    2. A 开始发送
    3. 若控制帧RTS或CTS发生冲突,采用二进制指数后退算法等待随机时间,再重新开始(A 和 C 同时发送 RTS)

退避时间短的设备先传输
发现冲突所有设备同时退避

  1. 为避免冲突,802.11 所有站点在完成一个事务后必须等待一段时间才能进行下一个动作,这个时间被称为 IFS,具体取决于帧的类型。
  2. SIFS(Short interframe space):短帧间间隔 28us,用于本设备接受发送状态转换,不足够源站接受 CTS
  3. DIFS(Distributed Inter-frame Spacing):分布协调功能帧间间隔 128us(多个节点进行协调)
  4. 应答 CTS(Clear to Send),等待 SIFS(Short interframe space)后发送数据
  5. 过程中的时间写入时间数据标记位
  6. NAV(网络分配向量):网络协调时间,时间长度:NAV 计算方式在后面,NAV 是一开始就进行预估了,别的节点抢到了节点时,我们会减掉别人正常通信的时间,不是一直累积下去的情况。
  7. 下一次经过争用窗口来抢
  8. 源站需要收到确认信息 CTS 才能接着发送信息
  9. 多个源站向目的站发 RTS 给目的站,目的站发现冲突,告诉各自站点,PPT 处理的是 RTS

实际数据传输率

  1. 当源节点发送帧时,接收节点将返回ACK
  • 这可能导致消耗50%的可用带宽
  • 在额定为 11 Mbps 的 802.11b 无线局域网上,这会将实际数据吞吐量降低到最大 5.0 到 5.5Mbps
  1. 网络性能也会受到信号强度的影响
  • 随着信号变弱,可以调用自适应速率选择(ARS)
  • 信号会受到距离影响,越远信号越弱,功率越低,带宽不能稳定到初始带宽
  • 传输单元会将数据速率从 11 Mbps 降低到 5.5Mbps,从 5.5 Mbps 降低到 2 Mbps 或 2 Mbps 到 1 Mbps
Ethernet WLAN
信号被传输到连接在线缆上的所有站点上 信号只被传输到接近发送站点的站点
接受站点检测冲突
只会有一个有效帧在信道上传播 会有多个有效帧同时在信道上传播
MAC协议必须尽可能保证只有发送站点接近接收站点

设备

网卡NICs

  • 逻辑链接控制——与计算机上层通信
  • 媒体访问控制——提供对共享访问媒体的结构化访问
  • 命名——提供唯一的 MAC 地址标识符
  • 成帧——封装过程的一部分,打包比特以进行传输
  • 信号——使用内置收发器创建信号并与媒体接口

网桥Bridge:连接两个相同结构的局域网,并对流经网桥的数据进行转发

  • 目的是过滤局域网上的流量(根据MAC地址而不是协议),使流量保持在本地,同时允许连接到局域网的其他部分以获取指向那里的流量
  • 跟踪网桥两侧的MAC地址,并根据该MAC地址列表做出决策
  • 在网段之间收集和发送数据报
  • 创建冲突域
    • 减少较大的冲突域,碰撞减少
    • 延迟提高
  • 维护地址表
  • 储存转发设备,因为它必须接受整个帧并在转发前检验CRC

透明网桥原理


MAC表放到缓存的位置,刚启动时是空表,之后逐渐学习

  • 地址表具有生命周期
  • 透明指的是局域网中的站点并不知道所发送的帧将经过哪几个网桥
  • 即插即用

当网络上的设备要发送数据但不知道目标地址时,会向网络上的所有设备发送广播,网桥始终会转发这些广播

  • 广播过多会导致广播风暴

源路由网桥:发送帧时将详细的路由信息放在帧的首部中,从而使每个经过的网桥都了解帧的路径,在令牌环网中广泛使用

  • 源站以广播方式向目的站发送一个发现帧,每个发现帧都记录所经过的路由。发现帧到达目的站时就沿各自的路由返回源站。源站在得知这些路由后,从所有可能的路由中选择出一个最佳路由。凡从该源站向该目的站发送的帧的首部,都必须携带源站所确定的这一路由信息

交换机Switch:识别数据包中的MAC地址信息,然后根据MAC地址进行转发,并将这些MAC地址与对应的端口记录在自己内部的一个地址表中

  • 执行两个基本操作:
    • 切换数据帧:在输入介质上接收帧,然后将其传输到输出介质
    • 维护交换操作:交换器建立和维护交换表并搜索循环。,路由器构建并维护路由表和交换表
  • 交换是一项通过减少流量来缓解以太网 LAN 拥塞的技术
    • 交换机创建专用的网段或点对点连接,并将这些网段连接到交换机内的虚拟网络中
    • 之所以称为虚拟电路,是因为它仅在两个节点需要通信时才存在,并且在交换机内建立
    • 每个交换机端口将介质的全部带宽提供给每个主机
  • 连接到交换机的所有主机仍位于同一广播域中(路由器可以创建广播域)

通过添加中继器和集线器来扩展冲突域

  • 不会分割

可以通过添加网桥、交换机和路由器等智能设备对网络进行分割

基本概念

可以将物理层的主要任务描述为确定与传输媒体的接口有关的特性:

  1. 机械特性:指明接口所用的接线器的形状和尺寸、引脚数目和排列、固定和锁定装置等
  2. 电气特性:指明在接口电缆的各条线上出现的电压的范围
  3. 功能特性:指明某条线上出现的某一电平的电压的意义
  4. 过程特性:指明对于不同功能的各种可能事件的出现顺序

数据在计算机内部多采用并行传输方式,但在通信线路上一般都是串行传输(经济因素),即逐个比特按照时间顺序传输

数据通信

数据通信系统可划分为三个部分:源系统传输系统目的系统

数据通信系统模型 的图像结果

源系统一般分为两个部分:

  • 源点(source):源点设备产生要传输的数据
  • 发送器:通常源点生成的数字比特流要通过发送器编码后才能在传输系统中进行传输,典型的发送器是调节器

目的系统一般也分为两个部分:

  • 接收器:接受传输系统传送过来的信号,并把它转换为能够被目的设备处理的信息
  • 终点(destination):从接收器获取传送来的数字比特流,然后把信息输出

模拟信号是连续的,数字信号是离散的
代表不同离散数值的基本波形成为码元

  • 一个码元携带的信息量不固定,由调制方式和编码方式决定

从通信的双方信息交互方式来看,有以下三种基本方式:

  1. 单工通信:只能由一个方向的通信而没有反方向的交互
  2. 半双工通信:通信的双方都可以发送信息,但不能同时发送
  3. 全双工通信:通信的双方可以同时发送和接收信息

来自信源的信号成为基带信号,必须进行调制
调制可分为两大类:

  • 基带调制:仅仅对基带信号的波形进行变化
    • 这是把数字信号转换为另一种数字信号,因此成为编码
  • 带通调制:使用载波(carrier)进行调制,把基带信号的频率范围搬移到较高的频段,并转换为模拟信号
    • 经过调制的信号成为带通信号

编码和调制

常用编码方式如图
计算机网络学习【入门】——(二)物理层_Leon595的博客-CSDN博客

  • 不归零制:正电平代表1,负电平代表0
  • 归零制:正脉冲代表1,负脉冲代表0
  • 曼彻斯特编码:位周期中心的向上跳变代表0,向下跳变代表1
  • 差分曼彻斯特编码:每一位的中心处始终有跳变,位开始边界有跳变为0,没有跳变为1

从信号波形来看,曼彻斯特编码产生的信号频率比不归零制高
从自身同步能力来看,不归零制不能从信号波形本身中提取信号时钟频率

  • 曼彻斯特编码具有自同步能力

基本的带通调制方法如图
最基本的三种调制方法 的图像结果

  • 调幅(AM):载波的振幅随基带数字信号而变化
  • 调频(FM):载波的频率随基带数字信号而变化
  • 调相(PM):载波二点初始相位随基带数字信号而变化

信道

码元传输速率越高,信号传输距离越远,噪声干扰越大,传输媒体质量越差,在接收端的波形失真就越严重
计算机网络第二弹——物理层_信道和通信电路_ai-exception的博客-CSDN博客

限制码元在信道上传输速率的因素:

  1. 信道能够通过的频率范围

    码间串扰:信号中的高频分量在传输时收到衰减,那么在接收端收到的波形前沿和后沿就变得不那么陡峭了,收到的信号波形就失去了码元之间的清晰界限

    为避免码间串扰,奈奎斯特(Nyquist)提出了奈氏准则,给出了在假定的理想条件下,码元的传输速率的上限值

    奈氏准则:理想低通信道下的极限数据传输率 = 2W$\log_2V$(b/s)

    • 其中W是理想低通信道的带宽(Hz), V表示每个码元离散电平的数目(有多少种不同的码元)
  1. 信噪比
    信噪比就是信号的平均功率和噪声的平均功率之比,常记为S/N,并用分贝(dB)作为度量单位

    • 信噪比(dB) = $10log_{10} (S/N)$(dB)

      信息论的创始人香农(shannon)推导出了香农公式,推导出信道的极限传输速率

      香农公式:C = W$log_2(1 + S/N)$(bit/s)

    • 其中W为信道的带宽(Hz), S为信道内锁传信号的平均功率,N为信道内部高斯噪声的功率

      香农公式表明,信道的带宽或信道中的信噪比越大,信道的极限传输速率就越高

      可以通过编码的方式让每一个码元携带更多比特的信息量,以提高信息传输速率

传输媒体

传输媒体可以分为导引型传输媒体非导引型传输媒体

  • 在非导引型传输媒体中电磁波的传输常称为无线传输

导引型传输媒体

  1. 双绞线/双扭线
    把两根互相绝缘的铜导线并排放在一起,然后用规则的方法绞合(twist)起来就构成了双绞线

模拟传输和数字传输都可以使用双绞线

为了提高双绞线抗电磁干扰的能力,可以在双绞线外面再加上一层由金属丝编制成的屏蔽层,也就是屏蔽双绞线STP(Shielded Twisted Pair),价格也比无屏蔽双绞线UTP贵(Unshielded Twisted Pair)

  • 聚氯乙烯套层 -> (屏蔽层)-> 绝缘层 - > 铜线
绞合线类型 带宽 线缆特点 典型应用
3 16MHz 2对4芯双绞线 模拟电话,曾用于传统以太网(10Mbit/s)
4 20MHz 4对8芯双绞线 曾用于令牌局域网
5 100MHz 与4类相比增加了绞合度 传输速率不超过100Mbit/s的应用
5E(超5类) 125MHz 与5类相比衰减更小 传输速率不超过1Gbit/s的应用
6 250MHz 与5类相比改善了串扰等性能 传输速率高于1Gbit/s的应用
7 600MHz 使用屏蔽双绞线 传输速率不超过10Gbit/s的应用

现在最常用的是5类线

  1. 同轴电缆
    同轴电缆由内导体铜质导线(单股实心线或多股绞合线)、绝缘层、网状编织的外导体屏蔽层(也可以是单股的)以及保护塑料外层组成

    同轴电缆具有很好的抗干扰性,被广泛用于传输较高速率的数据

    同轴电缆结构 的图像结果

    同轴电缆的带宽取决于电缆的质量,目前高质量的同轴电缆带宽已接近1GHz

  2. 光缆
    光纤通信就是利用光导纤维传递光脉冲来进行通信

    • 一个光纤系统的传输带宽远远大于目前其他各种传输媒体的带宽

      光纤是光纤通信的传输媒体

      光纤通信--光纤的结构及分类 - 知乎

      从纤芯中射到纤芯表面的光线的入射角大于某个临界角度,就可产生全反射

      多模光纤:存在多条不同角度入射的光线在一条光纤中传输
      单模光纤:光纤的直径减少到只有一个光的波长,使光纤一直向前传播,而不会发生多次反射

      单模光纤和多模光纤的比较 的图像结果

      光纤非常细,必须做成很结实的光缆
      通信用特种光缆的结构特点及应用场景 - 知乎

光纤不仅具有通信容量非常大的优点,而且还有一些其他的特点:

  • 传输损耗小,中继距离长,对远距离传输特别经济
  • 抗雷电和电磁干扰性能好
  • 无串音干扰,保密性好
  • 体积小,重量轻

非导引型传输媒体

低频LF:30kHz ~ 300kHz
中频MF:300kHz ~ 3MHz
高频HF :3MHz ~ 30MHz

传统的微波通信主要有两种形式:

  1. 地面微波接力通信 :需要中继器将信号放大,可传输电话、电报、图像、数据等信息
    • 微波波段频率很高,其频带范围也很宽,因此通信信道容量很大(卫星通信也是
  2. 卫星通信
    最大特点是通信距离远,通信费用与通信距离无关,但是具有较大的传播时延

信道复用技术

复用是通信技术中的基本概念
频分复用示意图 的图像结果

最基本的复用就是频分复用FDM(Frequency Division Multiplexing)和时分复用TDM(Time Division Multiplexing)

频分复用的所有用户在同样的时间内占用不同的带宽资源

  • 这里的带宽指的是频率带宽而不是数据的发送速率

频分复用示意图 的图像结果

时分复用把时间划分成一段段等长的时分复用帧(TDM帧)

  • TDM信号也称为等时信号

时分复用的所有用户在不同的时间占用同样的频带宽度
时分复用示意图 的图像结果

当用户暂时无数据发送时,在时分复用帧中分配给该用户的时隙只能处于空闲状态,这会导致复用后信道利用率不高
现代通信技术之交换技术基础_Leslie_Waong的博客-CSDN博客

复用器和分用器总是成对使用,两者之间是用户共享的高速信道

统计时分复用STDM(Statistic TDM)
【计算机网络】信道复用技术_diligentyang的博客-CSDN博客
STDM帧不是固定分配时隙,而是按需动态分配时隙

  • 一个用户所占用的时隙并不是周期性地出现,因此又称为异步时分复用

波分复用WDM(Wavelength Division Multiplexing)就是光的频分复用

  • 现在已经能在一根光纤上复用几十路甚至更多路数的光载波信号,于是使用了密集波分复用DWDM(Dense Wavelength Division Multiplexing)这一名词

波分复用示意图 的图像结果
波分复用的复用器可称为合波器,解复用器(分用器)又称为分波器

码分复用CDM(Code Division Multiplexing)是一种共享信道的方法,其实人们更常用的名词是码分多址CDMA(Code Division Multiple Access),每一个用户可以在同样的时间使用同样的频带进行通信

在CDMA中,每一个比特时间再划分为m个短的间隙,成为码片(chip),通常m的值是64或128

了实现多用户同时通信而互补干扰,要求每个发送信号的站都使用不同的码片,也就是说使用CDMA的每一个站都被指派了一个唯一的mbit的码片序列

一个站如果要发送比特1,则发送他自己的mbit码片序列
一个站如果要发送比特0,则发送自己的mbit码片序列的反码

为了方便,我们按惯例将码片序列中的0写为-1,将1写为+1,这种通信方法称为直接序列扩频DSSS(Dienct Sequence Spread spectrum)

对于码片的挑选有以下原则:

  1. 分配给每个站的码片序列必须各不相同
  2. 分配给每个站的码片序列必须相互正交(规格化内积为零)

规格化内积计算公式:$S \cdot T = \frac{1}{m}\sum_{i = 1}^m{S_iT_i}=0$

  1. 任何一个码片向量和自己的码片反码的向量的内积为-1
  2. 任何一个码片向量和自己的码片向量的内积为1

互联网概述

互联网(internet)把许多网络通过路由器连接在一起

  • 与网络相连的计算机常称为主机

区分:Internet是专有名词互联网/因特网!!!

  • Internet使用TCP/IP协议作为通信规则,前身是美国的ARPANET

ISP(Internet Service Provider): 互联网服务提供商

ISP分为不同层次:主干ISP、地区ISP、本地ISP

  • 根据提供服务的覆盖面积大小和所拥有的IP地址数目不同划分

基于isp的三层结构的因特网 的图像结果

WWW(World Wide Web):万维网

ISOC(Internet Society):互联网协会

  • 对互联网进行全面管理以及在世界范围内促进其发展和使用

IAB(Internet Architecture Board):互联网体系结构委员会

  • 负责管理互联网有关协议的开发
  • 包括互联网工程部IETF(Internet Research Task Force)和互联网研究部IRTF(Internet Research Steering Group)

指定互联网标准要经过以下三个阶段:

  1. 互联网草案(Internet Draft)———六个月有效期
  2. 建议标准(Proposed Standard)———成为RFC文档
  3. 互联网标准(Internet Standard)———正式标准

互联网组成

从工作方式看,可以分为两部分:

  1. 边缘部分———由所有连接在互联网上的主机组成
  2. 核心部分———由大量网络和连接这些网络的路由器组成

边缘部分

客户-服务器方式(client-to-server)
客户是服务请求方,服务器是服务提供方

  • 客户程序必须知道服务器程序的地址,反之不用
  • 服务器程序可同时处理多个请求
    边缘部分核心部分 的图像结果

对等连接方式(peer to peer,简写P2P)
每一台主机既是客户又是服务器
边缘部分核心部分 的图像结果

核心部分

起特殊作用的是路由器(router),这是一种专用计算机(但不叫主机)

  • 实现分组交换(packet switching)的关键构件,其任务是转发收到的分组

电路交换三个步骤:建立连接->通话->释放连接

  • 在通话的全部时间内,通话的两个用户始终占有端到端的通信资源
  • 传输效率低

分组交换采用存储转发技术

要发送的整块数据成为一个报文(message),发送报文前先把较长的报文分成一个个较小的等长的数据段,每个数据段前面加上一些由必要的控制信息组成的首部(head),就构成了一个分组(packet)

分组是在互联网中传送的数据单元

主机和路由器的区别:

  1. 主机是为用户进行信息处理的
  2. 路由器是用来转发分组的,即进行分组交换

分组转发的有点:

  1. 高效 :在分组传输的过程中动态分配传输带宽
  2. 灵活:为每一个分组独立地选择最合适的转发路由
  3. 迅速 :可以不先建立连接就能向其他主机发送分组
  4. 可靠:保证可靠性的网络协议;分布式多路由的分组交换网,使网络由很好的生存性

分组在各路由器存储转发时需要排队,会产生一定的时延

电路交换 报文交换 分组交换 的图像结果

计算机网络

按照网络的作用范围进行分类:

  1. 广域网 WAN(Wide Area Network)
  • 互联网的核心部分
  1. 域域网 MAN(Metropolitan Area Network)
  2. 局域网 LAN(Local Area Network)
  3. 个人区域网 PAN(Personal Area Network)
  • 也常成为无线个人区域网 WPAN(Wreless PAN)

按照网络的使用者来进行分类

  1. 公用网(public network)
  2. 专有网(private network)

计算机网络的性能

  1. 速率
    网络技术中的速率指的是数据的传送速率,也成为数据率或者比特率,单位是bit/s
  • k = $10^3$ M = $10^6$ G = $10^9$
  1. 带宽
    指某个信号具有的频带宽度/频带范围,单位是Hz
    但是在计算机网络中,带宽常常指单位时间内网络中的某信道所能通过的“最高数据率”,这里带宽的单位是bit/s

  2. 吞吐量
    表示在单位时间内通过某个网络( 信道、接口 )的实际数据量

  3. 时延(delay)
    指数据从网络/链路 的一端传送到另一端所需的时间

    • 发送时延是主机或路由器发送数据帧所需要的时间,也就是从发送数据帧的第一个比特算起,到该帧的最后一个比特发送完毕所需要的时间,因此也叫做传输时延
      • 发送时延 = 数据帧长度(bit) / 发送速率(bit/s)
    • 传播时延是电磁波在信道中传播一定距离需要花费的时间

      • 传播时延 = 信道长度(m) / 电磁波在信道上的传播速率(m/s)

      发送时延在机器内部的发送器中, 与传输信道长度无关
      而传播时延在机器外部的传输信道媒体上

    • 处理时延

    • 排队时延

对于高速网络链路,我们提高的仅是数据的发送速率而不是比特在链路上的传播速率

  • 提高数据的发送速率只是减少了数据的发送时延
  1. 时延宽带积
    时延宽带积 = 传播时延 x 带宽

    • 因此又称为以比特位单位的链路长度
  2. 往返时间RTT
    RTT不包括发送时间

  3. 利用率
    分为信道利用率和网络利用率

    • 信道利用率指出某信道有百分之几的时间是被利用的
    • 网络利用率是全网络的信道利用率的加权平均值

      信道和网络利用率过高会产生非常大的时延

计算机网络体系

美国的IBM公司宣布了系统网络体系结构SNA(System Network Architecture),这个著名的网络标准就是按照分层的办法制定的

国际标准化组织ISO提出了开放系统互连基本参考模型(OSI/RM)(Open System Interconnection Reference Model),简称OSI

得到最广泛应用的是TCP/IP,其为事实上的国际标准

网络协议(network protocol): 为进行网络中的数据交换而建立的规则、标准或约定,由三个要素组成:

  1. 语法:数据与控制信息的结构或格式
  2. 语义:需要发出何种控制信息,完成何种动作以及做出何种响应
  3. 同步:事件实现顺序的详细说明

分层的好处:

  1. 各层之间是独立的
  2. 灵活性好
  3. 结构上可分隔开
  4. 易于实现和维护
  5. 能促进标准化工作

通常来说各层要完成的功能由以下一些:

  • 差错控制
  • 流量控制
  • 分段和重装
  • 复用和分用
  • 连接的建立和释放

计算机网络的各层以及协议的几何就是网络的体系结构计算机网络的体系结构就是这个计算机网络以及构件所应完成的功能的精确定义

TCP/IP是一个四层的体系结构,包含应用层、运输层、网际层、网络接口层

学习的时候常常采用一种五层协议体系结构,包含应用层、运输层、网络层、数据链路层、物理层

  1. 应用层(application):
    通过应用进程间的交互来完成特定的网络应用,应用层协议定义的是应用进程间通信和交互的规则,我们把应用层交互的数据单元成为报文

  2. 运输层(transport):
    . 向两台主机进程之间的通信提供通用的数据传输服务,运输层有分用服用功能

    • 传输控制协议TCP: 提供面向连接的、可靠的数据传输服务,其数据传输单位是报文段(segment)
    • 用户数据报协议UDP: 提供无连接的、尽最大努力的数据传输服务(不保证数据传输的可靠性),其数据传输单位是用户数据报
  3. 网络层(network):
    为分组交换网上的不同主机提供通信服务。在发送数据时,网络层把运输层产生的报文段或用户数据报封装成分组进行传送。由于网络层使用IP协议,因此分组也可叫做IP数据报,简称数据报(不同于UDP)

    • 无论哪一层传送的数据单元,都可笼统地用分组来表示
  4. 数据链路层(data link):
    将网络层交下来地IP数据报组装成(frame),在两个相邻结点间的链路上传送帧,还能检测所受到的帧有没有差错(可靠协议)

    • 每一帧都包括数据和必要的控制信息
  5. 物理层(physical):
    数据的单位是比特。物理层要考虑用多大的电压代表“1”和“0”,以及接收方如何识别出发送方所发送的比特,还要确定连接电缆的插头应当由多少根引脚以及各引脚应如何连接

    • 传递信息所利用的一些物理媒介,比如双绞线、同轴电缆、光纤等并不在物理层协议之内

数据在各层之间的传递过程 的图像结果

OSI模型把对等层次之间传送的数据单位成为该层地协议数据单元PDU(Protocol Data Unit)

协议是控制两个对等实体(或多个实体)进行通信的规则的集合

在协议的控制下,两个对等实体间的通信使得本层能够向上一层提供服务,要实现本层协议,还需要使用下面一层所提供的服务

OSI把层与层之间交换的数据的单位成为服务数据单元SDU(Service Data Unit),它可以和PDU不一样

TCP/IP体系结构
TCP/IP体系结构 的图像结果

TCP/IP协议可以为各式各样的应用提供服务,同时TCP/IP协议也允许IP协议在各式各样的网络构成的互联网上运行
TCP/IP体系结构 的图像结果

设备

局域网设备

集线器Hub:多端口中继器,用于连接个人计算机,属于物理层

  • 用于重新生成和定时网络信号
  • 传播信号
  • 无法过滤流量
  • 无法确定最佳路径
  • 用作网络集中点
  • 有时称为多端口中继器

网桥Bridge:连接两个相同结构的局域网,并对流经网桥的数据进行转发,属于数据链路层

  • 目的是过滤局域网上的流量,使流量保持在本地,同时允许连接到局域网的其他部分以获取指向那里的流量
  • 跟踪网桥两侧的MAC地址,并根据该MAC地址列表做出决策
  • 在网段之间收集和发送数据报
  • 创建冲突域
  • 维护地址表

交换机Switch:识别数据包中的MAC地址信息,然后根据MAC地址进行转发,并将这些MAC地址与对应的端口记录在自己内部的一个地址表中,属于数据链路层

  • 用于集中连接
  • 结合了集线器的连通性和网桥的流量控制
  • 将帧从传入端口切换到传出端口,为每个端口提供全带宽
  • 提供单独的数据路径

路由Router:连接不同的网络并完成路径选择和分组转发,属于网络层

  • 大型网络中重要的流量调节装置
  • 根据网络地址做出决策
  • 检查数据包(第3层数据),为它们选择最佳路径,然后将它们切换到正确的输出端口
  • 两个主要目的:路径选择和数据包切换到最佳路由

主机Hosts:直接连接到网段的设备,并不从属于任何层

中继器Repeaters:用于对数字信号进行再生,以扩展局域网段的长度,驱动长距离通信。属于物理层

  • 用于延长网络的长度
  • 清除、放大和重新发送被长电缆削弱的信号
  • 在比特级重新生成(放大)并重新定时网络信号,使其能够在媒体上传播更长的距离
  • 不执行筛选

广域网设备

路由Router:连接不同的网络并完成路径选择和分组转发,属于网络层

调制解调器Modem CSU/DSU TA/NT1: 模拟到数字信号,实现远程局域网连接

集线器和中继器的区别:

  • 中继器通常只有两个端口,集线器通常有四到二十个或更多端口
  • 中继器在一个端口接收,在另一个端口重复,而集线器在一个端口接收,在所有其他端口发送
  • 集线器常见于以太网10BaseT或100BaseT网络中

冲突

当两个比特在同一网络上同时传播时,会发生冲突

通过添加中继器和集线器来扩展冲突域

  • 不会分割

可以通过添加网桥、交换机和路由器等智能设备对网络进行分割

当超过延迟限制时,后期冲突的数量会急剧增加

延迟冲突是指在传输帧的前64个字节后发生冲突

这些延迟冲突帧增加的延迟称为消耗延迟

随着消耗延迟和延迟的增加,网络性能下降

整理BY:Misayaas

内容来自廖雪峰的官方网站: https://www.liaoxuefeng.com/

什么是泛型

泛型就是定义一种模板,例如ArrayList<T>,然后在代码中为用到的类创建对应的ArrayList<类型>

1
ArrayList<String> strList = new ArrayList<String>();

在Java标准库中的ArrayList<T>实现了List<T>接口,它可以向上转型为List<T>

1
2
3
4
5
public class ArrayList<T> implements List<T> {
...
}

List<String> list = new ArrayList<String>();

特别注意:不能把ArrayList<Integer>向上转型为ArrayList<Number>List<Number>

==ArrayList和ArrayList两者完全没有继承关系==

向上转型

使用泛型

使用ArrayList时,如果不定义泛型类型时,泛型类型实际上就是Object

编译器如果能自动推断出泛型类型,就可以省略后面的泛型类型。例如,对于下面的代码:

1
2
List<Number> list = new ArrayList<Number>();

编译器看到泛型类型List<Number>就可以自动推断出后面的ArrayList<T>的泛型类型必须是ArrayList<Number>,因此,可以把代码简写为:

1
2
// 可以省略后面的Number,编译器可以自动推断泛型类型:
List<Number> list = new ArrayList<>();

泛型接口

除了ArrayList<T>使用了泛型,还可以在接口中使用泛型

Arrays.sort(Object[])可以对任意数组进行排序,但待排序的元素必须实现Comparable<T>这个泛型接口:

1
2
3
4
5
6
7
8
public interface Comparable<T> {
/**
* 返回负数: 当前实例比参数o小
* 返回0: 当前实例与参数o相等
* 返回正数: 当前实例比参数o大
*/
int compareTo(T o);
}
  • String本身已经实现了Comparable<String>接口
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Person implements Comparable<Person> {
String name;
int score;
Person(String name, int score) {
this.name = name;
this.score = score;
}
public int compareTo(Person other) {
return this.name.compareTo(other.name);
}
public String toString() {
return this.name + "," + this.score;
}
}

运行上述代码,可以正确实现按name进行排序

编写泛型

把特定类型String替换为T,并申明<T>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Pair<T> {
private T first;
private T last;
public Pair(T first, T last) {
this.first = first;
this.last = last;
}
public T getFirst() {
return first;
}
public T getLast() {
return last;
}
}

静态方法

泛型类型<T>不能用于静态方法

对于静态方法,我们可以单独改写为“泛型”方法,只需要使用另一个类型即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Pair<T> {
private T first;
private T last;
public Pair(T first, T last) {
this.first = first;
this.last = last;
}
public T getFirst() { ... }
public T getLast() { ... }

// 静态泛型方法应该使用其他类型区分:
public static <K> Pair<K> create(K first, K last) {
return new Pair<K>(first, last);
}
}

多个泛型类型

我们希望Pair不总是存储两个类型一样的对象,就可以使用类型<T, K>

1
2
3
4
5
6
7
8
9
10
public class Pair<T, K> {
private T first;
private K last;
public Pair(T first, K last) {
this.first = first;
this.last = last;
}
public T getFirst() { ... }
public K getLast() { ... }
}

擦拭法

Java语言的泛型实现方式是擦拭法(Type Erasure)

  • 所谓擦拭法是指,虚拟机对泛型其实一无所知,所有的工作都是编译器做的

Java使用擦拭法实现泛型,导致了:

  • 编译器把类型<T>视为Object
  • 编译器根据<T>实现安全的强制转型

Java的泛型是由编译器在编译时实行的,编译器内部永远把所有类型T视为Object处理,但是,在需要转型的时候,编译器会根据T的类型自动为我们实行安全地强制转型

Java泛型的局限:

  1. <T>不能是基本类型,例如int,因为实际类型是ObjectObject类型无法持有基本类型:
    1
    Pair<int> p = new Pair<>(1, 2); // compile error!
  2. 无法取得带泛型的Class
  3. 无法判断带泛型的类型
    1
    2
    3
    4
    5
    Pair<Integer> p = new Pair<>(123, 456);
    // Compile error:
    if (p instanceof Pair<String>) {
    }

  4. 不能实例化T类型

不恰当的覆写方法

有些时候,一个看似正确定义的方法会无法通过编译。例如:

1
2
3
4
5
6
public class Pair<T> {
public boolean equals(T t) {
return this == t;
}
}

换个方法名,避开与Object.equals(Object)的冲突就可以成功编译:

1
2
3
4
5
public class Pair<T> {
public boolean same(T t) {
return this == t;
}
}

泛型继承

一个类可以继承自一个泛型类

在继承了泛型类型的情况下,子类可以获取父类的泛型类型

extends通配符

假设我们定义了Pair<T>

1
2
public class Pair<T> { ... }

然后,我们又针对Pair<Number>类型写了一个静态方法,它接收的参数类型是Pair<Number>

1
2
3
4
5
6
7
8
public class PairHelper {
static int add(Pair<Number> p) {
Number first = p.getFirst();
Number last = p.getLast();
return first.intValue() + last.intValue();
}
}

上述代码是可以正常编译的。使用的时候,我们传入:

1
2
int sum = PairHelper.add(new Pair<Number>(1, 2));

注意:传入的类型是Pair<Number>,实际参数类型是(Integer, Integer)

使用Pair<? extends Number>使得方法接收所有泛型类型为NumberNumber子类的Pair类型

1
2
3
4
5
6
7
8
9
10
11
12
public class Main {
public static void main(String[] args) {
Pair<Integer> p = new Pair<>(123, 456);
int n = add(p);
System.out.println(n);
}

static int add(Pair<? extends Number> p) {
Number first = p.getFirst();
Number last = p.getLast();
return first.intValue() + last.intValue();
}

  • 这种使用<? extends Number>的泛型定义称之为上界通配符(Upper Bounds Wildcards),即把泛型类型T的上界限定在Number

整理BY:Misayaas

内容来自廖雪峰的官方网站: https://www.liaoxuefeng.com/

Java的异常

调用方如何获知调用失败的信息?有两种方法:

方法一:约定返回错误码
例如,处理一个文件,如果返回0,表示成功,返回其他整数,表示约定的错误码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int code = processFile("C:\\test.txt");
if (code == 0) {
// ok:
} else {
// error:
switch (code) {
case 1:
// file not found:
case 2:
// no read permission:
default:
// unknown error:
}
}

  • 因为使用int类型的错误码,想要处理就非常麻烦。这种方式常见于底层C函数。

方法二:在语言层面上提供一个异常处理机制

Java内置了一套异常处理机制,总是使用异常来表示错误

异常是一种class,因此它本身带有类型信息。
异常可以在任何地方抛出,但只需要在上层捕获,这样就和方法调用分离了:

1
2
3
4
5
6
7
8
9
10
11
12
try {
String s = processFile(“C:\\test.txt”);
// ok:
} catch (FileNotFoundException e) {
// file not found:
} catch (SecurityException e) {
// no read permission:
} catch (IOException e) {
// io error:
} catch (Exception e) {
// other error:
}

因为Java的异常是class,它的继承关系如下:

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


┌───────────┐
│ Throwable │
└───────────┘

┌─────────┴─────────┐
│ │
┌───────────┐ ┌───────────┐
│ Error │ │ Exception │
└───────────┘ └───────────┘
▲ ▲
┌───────┘ ┌────┴──────────┐
│ │ │
┌─────────────────┐ ┌─────────────────┐┌───────────┐
│OutOfMemoryError │... │RuntimeException ││IOException│...
└─────────────────┘ └─────────────────┘└───────────┘

┌───────────┴─────────────┐
│ │
┌─────────────────────┐ ┌─────────────────────────┐
│NullPointerException │ │IllegalArgumentException │...
└─────────────────────┘ └─────────────────────────┘

从继承关系可知:Throwable是异常体系的,它继承自ObjectThrowable有两个体系:ErrorExceptionError表示严重的错误,程序对此一般无能为力,例如:

  • OutOfMemoryError内存耗尽
  • NoClassDefFoundError无法加载某个Class
  • StackOverflowError栈溢出

Exception则是运行时的错误它可以被捕获并处理

某些异常是应用程序逻辑处理的一部分,应该捕获并处理。例如:

  • NumberFormatException数值类型的格式错误
  • FileNotFoundException未找到文件
  • SocketException读取网络失败

还有一些异常是程序逻辑编写不对造成的,应该修复程序本身。例如:

  • NullPointerException对某个null的对象调用方法或字段
  • IndexOutOfBoundsException数组索引越界

Exception又分为两大类:

  1. RuntimeException以及它的子类
  2. RuntimeException(包括IOExceptionReflectiveOperationException等等)

Java规定:

  • 必须捕获的异常,包括Exception及其子类,但不包括RuntimeException及其子类,这种类型的异常称为Checked Exception

  • 不需要捕获的异常,包括Error及其子类,RuntimeException及其子类。

==注意:编译器对RuntimeException及其子类不做强制捕获要求,不是指应用程序本身不应该捕获并处理RuntimeException。是否需要捕获,具体问题具体分析==

捕获异常

捕获异常使用try...catch语句,把可能发生异常的代码放到try {...}中,然后使用catch捕获对应的Exception及其子类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Main {
public static void main(String[] args) {
byte[] bs = toGBK("中文");
System.out.println(Arrays.toString(bs));
}

static byte[] toGBK(String s) {
try {
// 用指定编码转换String为byte[]:
return s.getBytes("GBK");
} catch (UnsupportedEncodingException e) {
// 如果系统不支持GBK编码,会捕获到UnsupportedEncodingException:
System.out.println(e); // 打印异常信息
return s.getBytes(); // 尝试使用用默认编码
}
}
}

  • 如果我们不捕获UnsupportedEncodingException,会出现编译失败的问题
  • 编译器会报错,错误信息类似:unreported exception UnsupportedEncodingException; must be caught or declared to be thrown,并且准确地指出需要捕获的语句是return s.getBytes("GBK");
  • 意思是说,像UnsupportedEncodingException这样的Checked Exception,必须被捕获

这是因为String.getBytes(String)方法定义是:

1
2
3
public byte[] getBytes(String charsetName) throws UnsupportedEncodingException {
...
}

  • 在方法定义的时候,使用throws Xxx表示该方法可能抛出的异常类型。调用方在调用的时候,必须强制捕获这些异常,否则编译器会报错

toGBK()方法中,因为调用了String.getBytes(String)方法,就必须捕获UnsupportedEncodingException

我们也可以不捕获它,而是在方法定义处用throws表示toGBK()方法可能会抛出UnsupportedEncodingException,就可以让toGBK()方法通过编译器检查

1
2
3
4
5
6
7
8
9
10
public class Main {
public static void main(String[] args) {
byte[] bs = toGBK("中文");
System.out.println(Arrays.toString(bs));
}

static byte[] toGBK(String s) throws UnsupportedEncodingException {
return s.getBytes("GBK");
}
}

上述代码仍然会得到编译错误,但这一次,编译器提示的不是调用return s.getBytes("GBK");的问题,而是byte[] bs = toGBK("中文");。因为在main()方法中,调用toGBK(),没有捕获它声明的可能抛出的UnsupportedEncodingException
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Main {
public static void main(String[] args) {
try {
byte[] bs = toGBK("中文");
System.out.println(Arrays.toString(bs));
} catch (UnsupportedEncodingException e) {
System.out.println(e);
}
}

static byte[] toGBK(String s) throws UnsupportedEncodingException {
// 用指定编码转换String为byte[]:
return s.getBytes("GBK");
}
}

  • 可见,只要是方法声明的Checked Exception,不在调用层捕获,也必须在更高的调用层捕获。所有未捕获的异常,最终也必须在main()方法中捕获,不会出现漏写try的情况。这是由编译器保证的。main()方法也是最后捕获Exception的机会

  • 如果不想写任何try代码,可以直接把main()方法定义为throws Exception

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    public class Main {
    public static void main(String[] args) throws Exception {
    byte[] bs = toGBK("中文");
    System.out.println(Arrays.toString(bs));
    }

    static byte[] toGBK(String s) throws UnsupportedEncodingException {
    // 用指定编码转换String为byte[]:
    return s.getBytes("GBK");
    }
    }
    • 代价就是一旦发生异常,程序会立刻退出

还有一些童鞋喜欢在toGBK()内部“消化”异常:

1
2
3
4
5
6
7
static byte[] toGBK(String s) {
try {
return s.getBytes("GBK");
} catch (UnsupportedEncodingException e) {
// 什么也不干
}
return null;

这种捕获后不处理的方式是非常不好的,即使真的什么也做不了,也要先把异常记录下来
1
2
3
4
5
6
7
8
static byte[] toGBK(String s) {
try {
return s.getBytes("GBK");
} catch (UnsupportedEncodingException e) {
// 先记下来再说:
e.printStackTrace();
}
return null;

  • 所有异常都可以调用printStackTrace()方法打印异常栈

    捕获异常

    多catch语句

    VM在捕获到异常后,会从上到下匹配catch语句,匹配到某个catch后,执行catch代码块,然后_不再继续匹配
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    public static void main(String[] args) {
    try {
    process1();
    process2();
    process3();
    } catch (IOException e) {
    System.out.println(e);
    } catch (NumberFormatException e) {
    System.out.println(e);
    }
    }
  • 子类必须写在前面

finally语句

finally语句块保证有无错误都会执行

1
2
3
4
5
6
7
8
9
10
11
12
13
public static void main(String[] args) {
try {
process1();
process2();
process3();
} catch (UnsupportedEncodingException e) {
System.out.println("Bad encoding");
} catch (IOException e) {
System.out.println("IO error");
} finally {
System.out.println("END");
}
}

注意finally有几个特点:

  1. finally语句不是必须的,可写可不写;
  2. finally总是最后执行

可以没有catch,只使用try ... finally结构

1
2
3
4
5
6
7
void process(String file) throws IOException {
try {
...
} finally {
System.out.println("END");
}
}

因为方法声明了可能抛出的异常,所以可以不写catch

捕获多种异常

如果某些异常的处理逻辑相同,但是异常本身不存在继承关系,那么就得编写多条catch子句;

  • 若处理IOExceptionNumberFormatException的代码是相同的,我们可以把它两用|合并到一起
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    public static void main(String[] args) {
    try {
    process1();
    process2();
    process3();
    } catch (IOException | NumberFormatException e) { // IOException或NumberFormatException
    System.out.println("Bad input");
    } catch (Exception e) {
    System.out.println("Unknown error");
    }
    }

    抛出异常

    异常的传播

    当某个方法抛出了异常时,如果当前方法没有捕获异常,异常就会被抛到上层调用方法,直到遇到某个try ... catch被捕获为止
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    public class Main {
    public static void main(String[] args) {
    try {
    process1();
    } catch (Exception e) {
    e.printStackTrace();
    }
    }

    static void process1() {
    process2();
    }

    static void process2() {
    Integer.parseInt(null); // 会抛出NumberFormatException
    }
    }

通过printStackTrace()可以打印出方法的调用栈,类似:

1
2
3
4
5
6
java.lang.NumberFormatException: null
at java.base/java.lang.Integer.parseInt(Integer.java:614)
at java.base/java.lang.Integer.parseInt(Integer.java:770)
at Main.process2(Main.java:16)
at Main.process1(Main.java:12)
at Main.main(Main.java:5)

printStackTrace()对于调试错误非常有用,上述信息表示:NumberFormatException是在java.lang.Integer.parseInt方法中被抛出的,从下往上看,调用层次依次是:

  1. main()调用process1()
  2. process1()调用process2()
  3. process2()调用Integer.parseInt(String)
  4. Integer.parseInt(String)调用Integer.parseInt(String, int)

抛出异常

抛出异常分两步:

  1. 创建某个Exception的实例;
  2. throw语句抛出。
    1
    2
    3
    4
    5
    void process2(String s) {
    if (s==null) {
    throw new NullPointerException();
    }
    }

如果一个方法捕获了某个异常后,又在catch子句中抛出新的异常,就相当于把抛出的异常类型“转换”了:

1
2
3
4
5
6
7
8
9
10
11
12
13
void process1(String s) {
try {
process2();
} catch (NullPointerException e) {
throw new IllegalArgumentException();
}
}

void process2(String s) {
if (s==null) {
throw new NullPointerException();
}
}

如果在main()中捕获IllegalArgumentException,我们看看打印的异常栈:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class Main {
public static void main(String[] args) {
try {
process1();
} catch (Exception e) {
e.printStackTrace();
}
}

static void process1() {
try {
process2();
} catch (NullPointerException e) {
throw new IllegalArgumentException();
}
}

static void process2() {
throw new NullPointerException();
}
}

打印出的异常栈类似:
1
2
3
java.lang.IllegalArgumentException
at Main.process1(Main.java:15)
at Main.main(Main.java:5)

  • 这说明新的异常丢失了原始异常信息,我们已经看不到原始异常NullPointerException的信息了

为了能追踪到完整的异常栈,在构造异常的时候,把原始的Exception实例传进去,新的Exception就可以持有原始Exception信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class Main {
public static void main(String[] args) {
try {
process1();
} catch (Exception e) {
e.printStackTrace();
}
}

static void process1() {
try {
process2();
} catch (NullPointerException e) {
throw new IllegalArgumentException(e);
}
}

static void process2() {
throw new NullPointerException();
}
}

运行上述代码,打印出的异常栈类似:
1
2
3
4
5
6
java.lang.IllegalArgumentException: java.lang.NullPointerException
at Main.process1(Main.java:15)
at Main.main(Main.java:5)
Caused by: java.lang.NullPointerException
at Main.process2(Main.java:20)
at Main.process1(Main.java:13)

  • 注意到Caused by: Xxx,说明捕获的IllegalArgumentException并不是造成问题的根源,根源在于NullPointerException,是在Main.process2()方法抛出的

在代码中获取原始异常可以使用Throwable.getCause()方法。如果返回null,说明已经是“根异常”了

自定义异常

Java标准库定义的常用异常包括:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
Exception

├─ RuntimeException
│ │
│ ├─ NullPointerException
│ │
│ ├─ IndexOutOfBoundsException
│ │
│ ├─ SecurityException
│ │
│ └─ IllegalArgumentException
│ │
│ └─ NumberFormatException

├─ IOException
│ │
│ ├─ UnsupportedCharsetException
│ │
│ ├─ FileNotFoundException
│ │
│ └─ SocketException

├─ ParseException

├─ GeneralSecurityException

├─ SQLException

└─ TimeoutException

在一个大型项目中,可以自定义新的异常类型,但是,保持一个合理的异常继承体系是非常重要的。

一个常见的做法是自定义一个BaseException作为“根异常”,然后,派生出各种业务类型的异常。

BaseException需要从一个适合的Exception派生,通常建议从RuntimeException派生:

1
2
public class BaseException extends RuntimeException {
}

自定义的BaseException应该提供多个构造方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class BaseException extends RuntimeException {
public BaseException() {
super();
}

public BaseException(String message, Throwable cause) {
super(message, cause);
}

public BaseException(String message) {
super(message);
}

public BaseException(Throwable cause) {
super(cause);
}
}

NullPointerException

NullPointerException空指针异常,俗称NPE

如果一个对象为null,调用其方法或访问其字段就会产生NullPointerException,这个异常通常是由JVM抛出的,例如

1
2
3
4
5
6
public class Main {
public static void main(String[] args) {
String s = null;
System.out.println(s.toLowerCase());
}
}

使用断言

断言(Assertion)是一种调试程序的方式

1
2
3
4
5
public static void main(String[] args) {
double x = Math.abs(-123.45);
assert x >= 0;
System.out.println(x);
}

语句assert x >= 0;即为断言,断言条件x >= 0预期为true。如果计算结果为false,则断言失败,抛出AssertionError

  • 使用assert语句时,还可以添加一个可选的断言消息:

    1
    assert x >= 0 : "x must >= 0";

    这样,断言失败的时候,AssertionError会带上消息x must >= 0,更加便于调试

Java断言的特点是:断言失败时会抛出AssertionError,导致程序结束退出。因此,断言不能用于可恢复的程序错误,只应该用于开发和测试阶段

对于可恢复的程序错误,不应该使用断言。例如:

1
2
3
void sort(int[] arr) {
assert arr != null;
}

应该抛出异常并在上层捕获:
1
2
3
4
5
void sort(int[] arr) {
if (arr == null) {
throw new IllegalArgumentException("array cannot be null");
}
}

JVM默认关闭断言指令,即遇到assert语句就自动忽略了,不执行

  • 要执行assert语句,必须给Java虚拟机传递-enableassertions(可简写为-ea)参数启用断言。所以,上述程序必须在命令行下运行才有效果:

    1
    2
    3
    $ java -ea Main.java
    Exception in thread "main" java.lang.AssertionError
    at Main.main(Main.java:5)
  • 还可以有选择地对特定的类启用断言,命令行参数是:-ea:com.itranswarp.sample.Main,表示只对com.itranswarp.sample.Main这个类启用断言
  • 或者对特定地包启用断言,命令行参数是:-ea:com.itranswarp.sample...(注意结尾有3个.),表示对com.itranswarp.sample这个包启动断言

    使用JDK Logging

    日志就是Logging,它的目的是为了取代System.out.println()

输出日志,而不是用System.out.println(),有以下几个好处:

  1. 可以设置输出样式,避免自己每次都写"ERROR: " + var
  2. 可以设置输出级别,禁止某些级别输出。例如,只输出错误日志;
  3. 可以被重定向到文件,这样可以在程序运行结束后查看日志;
  4. 可以按包名控制日志级别,只输出某些包打的日志;

Java标准库内置了日志包java.util.logging,我们可以直接用。先看一个简单的例子:

1
2
3
4
5
6
7
8
9
10
11
12
import java.util.logging.Level;
import java.util.logging.Logger;

public class Hello {
public static void main(String[] args) {
Logger logger = Logger.getGlobal();
logger.info("start process...");
logger.warning("memory is running out...");
logger.fine("ignored.");
logger.severe("process will be terminated...");
}
}

运行上述代码,得到类似如下的输出:
1
2
3
4
5
6
Mar 02, 2019 6:32:13 PM Hello main
INFO: start process...
Mar 02, 2019 6:32:13 PM Hello main
WARNING: memory is running out...
Mar 02, 2019 6:32:13 PM Hello main
SEVERE: process will be terminated...

  • 再仔细观察发现,4条日志,只打印了3条,logger.fine()没有打印。这是因为,日志的输出可以设定级别

JDK的Logging定义了7个日志级别,从严重到普通:

  • SEVERE
  • WARNING
  • INFO
  • CONFIG
  • FINE
  • FINER
  • FINEST
    因为默认级别是INFO,因此,INFO级别以下的日志,不会被打印出来

使用日志级别的好处在于,调整级别,就可以屏蔽掉很多调试相关的日志输出

使用Java标准库内置的Logging有以下局限:

  1. Logging系统在JVM启动时读取配置文件并完成初始化,一旦开始运行main()方法,就无法修改配置;
  2. 配置不太方便,需要在JVM启动时传递参数-Djava.util.logging.config.file=<config-file-name>

因此,Java标准库内置的Logging使用并不是非常广泛

使用Commons Logging

和Java标准库提供的日志不同,Commons Logging是一个第三方日志库,它是由Apache创建的日志模块

Commons Logging的特色是,它可以挂接不同的日志系统,并通过配置文件指定挂接的日志系统。默认情况下,Commons Logging自动搜索并使用Log4j(Log4j是另一个流行的日志系统),如果没有找到Log4j,再使用JDK Logging

使用Commons Logging只需要和两个类打交道,并且只有两步:

第一步,通过LogFactory获取Log类的实例; 第二步,使用Log实例的方法打日志
示例代码如下:

1
2
3
4
5
6
7
8
9
10
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;

public class Main {
public static void main(String[] args) {
Log log = LogFactory.getLog(Main.class);
log.info("start...");
log.warn("end.");
}
}

  1. Commons Logging是一个第三方提供的库,所以,必须先把它下载下来
  2. 下载后,解压,找到commons-logging-1.2.jar这个文件,再把Java源码Main.java放到一个目录下
  3. 然后用javac编译Main.java,编译的时候要指定classpath,不然编译器找不到我们引用的org.apache.commons.logging
    1
    javac -cp commons-logging-1.2.jar Main.java

Commons Logging定义了6个日志级别:

  • FATAL
  • ERROR
  • WARNING
  • INFO
  • DEBUG
  • TRACE

默认级别是INFO

使用Commons Logging时,如果在静态方法中引用Log,通常直接定义一个静态类型变量

1
2
3
4
5
6
7
8
// 在静态方法中引用Log:
public class Main {
static final Log log = LogFactory.getLog(Main.class);

static void foo() {
log.info("foo");
}
}

此外,Commons Logging的日志方法,例如info(),除了标准的info(String)外,还提供了一个非常有用的重载方法:info(String, Throwable),这使得记录异常更加简单:

1
2
3
4
5
try {
...
} catch (Exception e) {
log.error("got exception!", e);
}

使用Log4j

真正的“日志实现”可以使用Log4j

Log4j是一个组件化设计的日志系统,它的架构大致如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
log.info("User signed in.");

│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
├──>│ Appender │───>│ Filter │───>│ Layout │───>│ Console │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘

│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
├──>│ Appender │───>│ Filter │───>│ Layout │───>│ File │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘

│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
└──>│ Appender │───>│ Filter │───>│ Layout │───>│ Socket │
└──────────┘ └──────────┘ └──────────┘ └──────────┘

当我们使用Log4j输出一条日志时,Log4j自动通过不同的Appender把同一条日志输出到不同的目的地

  • console:输出到屏幕;
  • file:输出到文件;
  • socket:通过网络输出到远程计算机;
  • jdbc:输出到数据库

通过Filter来过滤哪些log需要被输出,哪些log不需要被输出
通过Layout来格式化日志信息

因为Log4j也是一个第三方库,我们需要从这里下载Log4j,解压后,把以下3个jar包放到classpath中:

  • log4j-api-2.x.jar
  • log4j-core-2.x.jar
  • log4j-jcl-2.x.jar

因为Commons Logging会自动发现并使用Log4j,所以,把上一节下载的commons-logging-1.2.jar也放到classpath中。

使用SLF4J和Logback

SLF4J类似于Commons Logging,也是一个日志接口,而Logback类似于Log4j,是一个日志的实现

在Commons Logging中,我们要打印日志,有时候得这么写:

1
2
3
int score = 99;
p.setScore(score);
log.info("Set score " + score + " for Person " + p.getName() + " ok.");

拼字符串是一个非常麻烦的事情,所以SLF4J的日志接口改进成这样了:
1
2
3
int score = 99;
p.setScore(score);
logger.info("Set score {} for Person {} ok.", score, p.getName());

  • SLF4J的日志接口传入的是一个带占位符的字符串

如何使用SLF4J?它的接口实际上和Commons Logging几乎一模一样:

1
2
3
4
5
6
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

class Main {
final Logger logger = LoggerFactory.getLogger(getClass());
}

对比一下Commons Logging和SLF4J的接口:

Commons Logging SLF4J
org.apache.commons.logging.Log org.slf4j.Logger
org.apache.commons.logging.LogFactory org.slf4j.LoggerFactory

==不同之处就是Log变成了Logger,LogFactory变成了LoggerFactory==

整理BY:Misayaas

内容来自廖雪峰的官方网站: https://www.liaoxuefeng.com/

正则表达式简介

正则表达式可以用字符串来描述规则,并用来匹配字符串

例如,判断手机号,我们用正则表达式\d{11}

1
2
3
boolean isValidMobileNumber(String s) {
return s.matches("\\d{11}");
}

要判断用户输入的年份是否是20##年,我们先写出规则如下:

一共有4个字符,分别是:200~9任意数字0~9任意数字

对应的正则表达式就是:20\d\d,其中\d表示任意一个数字

把正则表达式转换为Java字符串就变成了20\\d\\d,注意Java字符串用\\表示\

匹配规则

正则表达式的匹配规则是从左到右按规则匹配

  • 对于正则表达式abc来说,它只能精确地匹配字符串"abc",不能匹配"ab""Abc""abcd"等其他任何字符串。

  • 如果正则表达式有特殊字符,那就需要\转义。例如,正则表达式a\&c,其中\&是用来匹配特殊字符&的,它能精确匹配字符串"a&c",但不能匹配"ac""a-c""a&&c"等。

  • 两个\\实际上表示的是一个\

匹配任意字符

我们可以用.匹配一个任意字符

  • .匹配一个字符且仅限一个字符

匹配数字

如果我们只想匹配0~9这样的数字,可以用\d匹配

  • \d仅限单个数字字符

匹配常用字符

\w可以匹配一个字母、数字或下划线,w的意思是word

匹配空白字符

\s可以匹配一个空格字符,注意空格字符不但包括空格,还包括tab字符(在Java中用\t表示)

匹配非数字

\D则匹配一个非数字

类似的,\W可以匹配\w不能匹配的字符,\S可以匹配\s不能匹配的字符,这几个正好是反着来的

重复匹配

修饰符*可以匹配任意个字符,包括0个字符

我们用A\d*可以匹配:

  • A:因为\d*可以匹配0个数字;
  • A0:因为\d*可以匹配1个数字0
  • A380:因为\d*可以匹配多个数字380

修饰符+可以匹配至少一个字符

修饰符?可以匹配0个或一个字符

修饰符{n}可以精确指定n个字符

  • 如果我们想指定匹配n~m个字符怎么办?用修饰符{n,m}就可以
  • 如果没有上限,那么修饰符{n,}就可以匹配至少n个字符

单个字符匹配规则

正则表达式 规则 可以匹配
A 指定字符 A
\u845c 指定Unicode字符
. 任意字符 a , b , & , 0
\d 数字0~9 0~9
\w 大小写字母,数字和下划线 a~z , A~Z , 0~9
\s 空格、Tab键 空格、Tab
\D 非数字 a , A , & , _ , …
\W 非\w & , @ , 中 , …
\S 非\s a , A , & , _ , …

多个字符的匹配规则

正则表达式 规则 可以匹配
A* 任意个数字符 空,A ,AA , …
A+ 至少一个字符 A ,AA , …
A? 0个或1个字符 空 , A
\d 数字0~9 0~9
A{3} 指定个数字符 AAA
A{2,} 至少n个字符 AA , AAA , …
A{0,3} 最多n个字符 空 , A ,AA , AAA

复杂匹配规则

匹配开头和结尾

用正则表达式进行多行匹配时,我们用^表示开头,,可以匹配"A001""A380"

匹配指定范围

使用[...]可以匹配范围内的字符,例如,[123456789]可以匹配1~9,这样就可以写出上述电话号码的规则:[1-9]\d{6,7}

要匹配大小写不限的十六进制数,比如1A2b3c,我们可以这样写:[0-9a-fA-F],它表示一共可以匹配以下任意范围的字符:

  • 0-9:字符0~9
  • a-f:字符a~f
  • A-F:字符A~F

[...]还有一种排除法,即不包含指定范围的字符。假设我们要匹配任意字符,但不包括数字,可以写[^1-9]{3}

或规则匹配

|连接的两个正则规则是或规则,例如,AB|CD表示可以匹配ABCD

使用括号

现在我们想要匹配字符串learn javalearn phplearn go,可以把公共部分提出来,然后用(...)把子规则括起来表示成learn\\s(java|php|go)
|正则表达式|规则|可以匹配|
|—-|—-|—-|
|^|开头|字符串开头|
|$|结尾|字符串结尾|
|[ABC]|[…]内任意字符|A,B,C|
|[A-F0-9xy]|指定范围的字符|A,…,F,0,…9,x,y|
|A-F|指定范围外的任意字符|非A-F|
|AB|CD|EF|AB或CD或EF|AB,CD,EF|

分组匹配

(...)还有一个重要作用,就是分组匹配

提取匹配的子串正确的方法是(...)先把要提取的规则分组,把上述正则表达式变为(\d{3,4})\-(\d{6,8})

按括号提取子串必须引入java.util.regex包,用Pattern对象匹配,匹配后获得一个Matcher对象,如果匹配成功,就可以直接从Matcher.group(index)返回子串

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

public class Main {
public static void main(String[] args) {
Pattern p = Pattern.compile("(\\d{3,4})\\-(\\d{7,8})");
Matcher m = p.matcher("010-12345678");
if (m.matches()) {
String g1 = m.group(1);
String g2 = m.group(2);
System.out.println(g1);
System.out.println(g2);
} else {
System.out.println("匹配失败!");
}
}
}

  • 传入0会得到整个字串

Pattern

但是反复使用String.matches()对同一个正则表达式进行多次匹配效率较低,因为每次都会创建出一样的Pattern对象

完全可以先创建出一个Pattern对象,然后反复使用,就可以实现编译一次,多次匹配

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

public class Main {
public static void main(String[] args) {
Pattern pattern = Pattern.compile("(\\d{3,4})\\-(\\d{7,8})");
psattern.matcher("010-12345678").matches(); // true
pattern.matcher("021-123456").matche(); // false
pattern.matcher("022#1234567").matches(); // false
// 获得Matcher对象:
Matcher matcher = pattern.matcher("010-12345678");
if (matcher.matches()) {
String whole = matcher.group(0); // "010-12345678", 0表示匹配的整个字符串
String area = matcher.group(1); // "010", 1表示匹配的第1个子串
String tel = matcher.group(2); // "12345678", 2表示匹配的第2个子串
System.out.println(area);
System.out.println(tel);
}
}
}

非贪婪匹配

正则表达式默认使用贪婪匹配:任何一个规则,它总是尽可能多地向后匹配,因此,\d+总是会把后面的0包含进来

  • (\d+)(0) 在规则\d+后面加个?即可表示*非贪婪匹配
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    import java.util.regex.*;

    public class Main {
    public static void main(String[] args) {
    Pattern pattern = Pattern.compile("(\\d+?)(0*)");
    Matcher matcher = pattern.matcher("1230000");
    if (matcher.matches()) {
    System.out.println("group1=" + matcher.group(1)); // "123"
    System.out.println("group2=" + matcher.group(2)); // "0000"
    }
    }
    }

搜索与替换

分割字符串

String.split()方法传入的正是正则表达式

1
2
3
"a b c".split("\\s"); // { "a", "b", "c" }
"a b c".split("\\s"); // { "a", "b", "", "c" }
"a, b ;; c".split("[\\,\\;\\s]+"); // { "a", "b", "c" }

搜索字符串

1
2
3
4
5
6
7
8
9
10
11
12
import java.util.regex.*;
public class Main {
public static void main(String[] args) {
String s = "the quick brown fox jumps over the lazy dog.";
Pattern p = Pattern.compile("\\wo\\w");
Matcher m = p.matcher(s);
while (m.find()) {
String sub = s.substring(m.start(), m.end());
System.out.println(sub);
}
}
}

我们获取到Matcher对象后,不需要调用matches()方法(因为匹配整个串肯定返回false),而是反复调用find()方法,在整个串中搜索能匹配上\\wo\\w规则的子串,并打印出来。这种方式比String.indexOf()要灵活得多,因为我们搜索的规则是3个字符:中间必须是o,前后两个必须是字符[A-Za-z0-9_]

替换字符串

使用正则表达式替换字符串可以直接调用String.replaceAll(),它的第一个参数是正则表达式,第二个参数是待替换的字符串

1
2
3
4
5
6
7
public class Main {
public static void main(String[] args) {
String s = "The quick\t\t brown fox jumps over the lazy dog.";
String r = s.replaceAll("\\s+", " ");
System.out.println(r); // "The quick brown fox jumps over the lazy dog."
}
}

反向引用

如果我们要把搜索到的指定字符串按规则替换,比如前后各加一个<b>xxxx</b>,这个时候,使用replaceAll()的时候,我们传入的第二个参数可以使用$1$2来反向引用匹配到的子串

1
2
3
4
5
6
7
public class Main {
public static void main(String[] args) {
String s = "the quick brown fox jumps over the lazy dog.";
String r = s.replaceAll("\\s([a-z]{4})\\s", " <b>$1</b> ");
System.out.println(r);
}
}

上述代码的运行结果是:

1
2
the quick brown fox jumps <b>over</b> the <b>lazy</b> dog.

它实际上把任何4字符单词的前后用<b>xxxx</b>括起来。实现替换的关键就在于" <b>$1</b> ",它用匹配的分组子串([a-z]{4})替换了$1

整理BY:Misayaas

内容来自廖雪峰的官方网站: https://www.liaoxuefeng.com/

字符串和编码

String

字符串在String内部是通过一个char[]数组表示的

1
String s2 = new String(new char[] {'H', 'e', 'l', 'l', 'o', '!'});

Java提供了"..."这种字符串字面量表示方法

Java字符串的一个重要特点就是字符串不可变。这种不可变性是通过内部的private final char[]字段,以及没有任何修改char[]的方法实现的

字符串比较

当我们想要比较两个字符串是否相同时,要特别注意,我们实际上是想比较字符串的内容是否相同。必须使用equals()方法而不能用==

1
2
3
4
5
6
7
8
public class Main {
public static void main(String[] args) {
String s1 = "hello";
String s2 = "hello";
System.out.println(s1 == s2);
System.out.println(s1.equals(s2));
}
}

从表面上看,两个字符串用==equals()比较都为true,但实际上那只是Java编译器在编译期,会自动把所有相同的字符串当作一个对象放入常量池,自然s1s2的引用就是相同的

  • 所以,这种==比较返回true纯属巧合

换一种写法,==比较就会失败

1
2
3
4
5
6
7
8
public class Main {
public static void main(String[] args) {
String s1 = "hello";
String s2 = "HELLO".toLowerCase();
System.out.println(s1 == s2);
System.out.println(s1.equals(s2));
}
}

要忽略大小写比较,使用equalsIgnoreCase()方法

String类还提供了多种方法来搜索子串、提取子串。常用的方法有:

1
2
// 是否包含子串:
"Hello".contains("ll"); // true

  • 注意到contains()方法的参数是CharSequence而不是String,因为CharSequenceString实现的一个接口

搜索子串的更多的例子:

1
2
3
4
"Hello".indexOf("l"); // 2
"Hello".lastIndexOf("l"); // 3
"Hello".startsWith("He"); // true
"Hello".endsWith("lo"); // true

提取子串的例子:

1
2
"Hello".substring(2); // "llo"
"Hello".substring(2, 4); "ll"

去除首尾空白字符

使用trim()方法可以移除字符串首尾空白字符。空白字符包括空格,\t\r\n

1
"  \tHello\r\n ".trim(); // "Hello"

  • ==注意:trim()并没有改变字符串的内容,而是返回了一个新字符串==

另一个strip()方法也可以移除字符串首尾空白字符。它和trim()不同的是,类似中文的空格字符\u3000也会被移除:

1
2
3
"\u3000Hello\u3000".strip(); // "Hello"
" Hello ".stripLeading(); // "Hello "
" Hello ".stripTrailing(); // " Hello"

String还提供了isEmpty()isBlank()来判断字符串是否为空和空白字符串:

1
2
3
4
"".isEmpty(); // true,因为字符串长度为0
" ".isEmpty(); // false,因为字符串长度不为0
" \n".isBlank(); // true,因为只包含空白字符
" Hello ".isBlank(); // false,因为包含非空白字符

替换子串

1.根据字符或字符串替换

1
2
3
String s = "hello";
s.replace('l', 'w'); // "hewwo",所有字符'l'被替换为'w'
s.replace("ll", "~~"); // "he~~o",所有子串"ll"被替换为"~~"

2.通过正则表达式替换
1
2
String s = "A,,B;C ,D";
s.replaceAll("[\\,\\;\\s]+", ","); // "A,B,C,D"

  • 上面的代码通过正则表达式,把匹配的子串统一替换为","

分割字符串

要分割字符串,使用split()方法,并且传入的也是正则表达式

1
2
String s = "A,B,C,D";
String[] ss = s.split("\\,"); // {"A", "B", "C", "D"}

拼接字符串

拼接字符串使用静态方法join(),它用指定的字符串连接字符串数组

1
2
String[] arr = {"A", "B", "C"};
String s = String.join("***", arr); // "A***B***C"

格式化字符串

字符串提供了formatted()方法和format()静态方法,可以传入其他参数,替换占位符,然后生成新的字符串

1
2
3
4
5
6
7
public class Main {
public static void main(String[] args) {
String s = "Hi %s, your score is %d!";
System.out.println(s.formatted("Alice", 80));
System.out.println(String.format("Hi %s, your score is %.2f!", "Bob", 59.5));
}
}

如果你不确定用啥占位符,那就始终用%s,因为%s可以显示任何数据类型

要查看完整的格式化语法,请参考JDK文档

类型转换

要把任意基本类型或引用类型转换为字符串,可以使用静态方法valueOf()

1
2
3
4
String.valueOf(123); // "123"
String.valueOf(45.67); // "45.67"
String.valueOf(true); // "true"
String.valueOf(new Object()); // 类似java.lang.Object@636be97c

把字符串转换为int类型
1
2
int n1 = Integer.parseInt("123"); // 123
int n2 = Integer.parseInt("ff", 16); // 按十六进制转换,255

把字符串转换为boolean类型:
1
2
boolean b1 = Boolean.parseBoolean("true"); // true
boolean b2 = Boolean.parseBoolean("FALSE"); // false

要特别注意,Integer有个getInteger(String)方法,它不是将字符串转换为int,而是把该字符串对应的系统变量转换为Integer
1
Integer.getInteger("java.version"); // 版本号,11

转换为char[]

Stringchar[]类型可以互相转换,方法是:

1
2
char[] cs = "Hello".toCharArray(); // String -> char[]
String s = new String(cs); // char[] -> String

如果修改了char[]数组,String并不会改变

1
2
3
4
5
6
7
8
9
public class Main {
public static void main(String[] args) {
char[] cs = "Hello".toCharArray();
String s = new String(cs);
System.out.println(s);
cs[0] = 'X';
System.out.println(s);
}
}

  • 这是因为通过new String(char[])创建新的String实例时,它并不会直接引用传入的char[]数组,而是会复制一份,所以,修改外部的char[]数组不会影响String实例内部的char[]数组,因为这是两个不同的数组
  • String的不变性设计可以看出,如果传入的对象有可能改变,我们需要复制而不是直接引用

例如,下面的代码设计了一个Score类保存一组学生的成绩

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class Main {
public static void main(String[] args) {
int[] scores = new int[] { 88, 77, 51, 66 };
Score s = new Score(scores);
s.printScores();
scores[2] = 99;
s.printScores();
}
}

class Score {
private int[] scores;
public Score(int[] scores) {
this.scores = scores;
}

public void printScores() {
System.out.println(Arrays.toString(scores));
}
}

由于Score内部直接引用了外部传入的int[]数组,这会造成外部代码对int[]数组的修改,影响到Score类的字段

字符编码

在早期的计算机系统中,为了给字符编码,美国国家标准学会(American National Standard Institute:ANSI)制定了一套英文字母、数字和常用符号的编码,它占用一个字节,编码范围从0127,最高位始终为0,称为ASCII编码。例如,字符'A'的编码是0x41,字符'1'的编码是0x31

GB2312标准使用两个字节表示一个汉字,其中第一个字节的最高位始终为1,以便和ASCII编码区分开。例如,汉字'中'GB2312编码是0xd6d0

为了统一全球所有语言的编码,全球统一码联盟发布了Unicode编码,它把世界上主要语言都纳入同一个编码,这样,中文、日文、韩文和其他语言就不会冲突。

Unicode编码需要两个或者更多字节表示

英文字符'A'ASCII编码和Unicode编码:

1
2
3
4
5
6
         ┌────┐
ASCII: │ 41 │
└────┘
┌────┬────┐
Unicode: │ 00 │ 41 │
└────┴────┘

  • 英文字符的Unicode编码就是简单地在前面添加一个00字节

中文字符'中'GB2312编码和Unicode编码:

1
2
3
4
5
6
         ┌────┬────┐
GB2312: │ d6 │ d0 │
└────┴────┘
┌────┬────┐
Unicode: │ 4e │ 2d │
└────┴────┘

因为英文字符的Unicode编码高字节总是00包含大量英文的文本会浪费空间,所以,出现了UTF-8编码,它是一种变长编码,用来把固定长度的Unicode编码变成1~4字节的变长编码。通过UTF-8编码,英文字符'A'UTF-8编码变为0x41,正好和ASCII码一致,而中文'中'UTF-8编码为3字节0xe4b8ad

UTF-8编码的另一个好处是容错能力强。如果传输过程中某些字符出错,不会影响后续字符,因为UTF-8编码依靠高字节位来确定一个字符究竟是几个字节,它经常用来作为传输编码

char类型实际上就是两个字节的Unicode编码。如果我们要手动把字符串转换成其他编码,可以这样做:

1
2
3
4
byte[] b1 = "Hello".getBytes(); // 按系统默认编码转换,不推荐
byte[] b2 = "Hello".getBytes("UTF-8"); // 按UTF-8编码转换
byte[] b2 = "Hello".getBytes("GBK"); // 按GBK编码转换
byte[] b3 = "Hello".getBytes(StandardCharsets.UTF_8); // 按UTF-8编码转换

==注意:转换编码后,就不再是char类型,而是byte类型表示的数组==

如果要把已知编码的byte[]转换为String,可以这样做:

1
2
3
byte[] b = ...
String s1 = new String(b, "GBK"); // 按GBK转换
String s2 = new String(b, StandardCharsets.UTF_8); // 按UTF-8转换

Java的Stringchar在内存中总是以Unicode编码表示

延伸阅读

早期JDK版本的String总是以char[]存储

1
2
3
4
5
public final class String {
private final char[] value;
private final int offset;
private final int count;
}

较新的JDK版本的String则以byte[]存储
1
2
3
public final class String {
private final byte[] value;
private final byte coder; // 0 = LATIN1, 1 = UTF16

  • 如果String仅包含ASCII字符,则每个byte存储一个字符,否则,每两个byte存储一个字符,这样做的目的是为了节省内存,因为大量的长度较短的String通常仅包含ASCII字符

StringBuilder

为了能高效拼接字符串,Java标准库提供了StringBuilder,它是一个可变对象,可以预分配缓冲区,这样,往StringBuilder中新增字符时,不会创建新的临时对象

1
2
3
4
5
6
StringBuilder sb = new StringBuilder(1024);
for (int i = 0; i < 1000; i++) {
sb.append(',');
sb.append(i);
}
String s = sb.toString();

StringBuilder还可以进行链式操作:
1
2
3
4
5
6
7
8
9
10
public class Main {
public static void main(String[] args) {
var sb = new StringBuilder(1024);
sb.append("Mr ")
.append("Bob")
.append("!")
.insert(0, "Hello, ");
System.out.println(sb.toString());
}
}

  • 进行链式操作的关键是,定义的append()方法会返回this,这样,就可以不断调用自身的其他方法

==注意:对于普通的字符串+操作,并不需要我们将其改写为StringBuilder,因为Java编译器在编译时就自动把多个连续的+操作编码为StringConcatFactory的操作==

StringJoiner

很多时候,我们拼接的字符串像这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Main {
public static void main(String[] args) {
String[] names = {"Bob", "Alice", "Grace"};
var sb = new StringBuilder();
sb.append("Hello ");
for (String name : names) {
sb.append(name).append(", ");
}
// 注意去掉最后的", ":
sb.delete(sb.length() - 2, sb.length());
sb.append("!");
System.out.println(sb.toString());
}
}

分隔符拼接数组的需求很常见,所以Java标准库还提供了一个StringJoiner来干这个事:
1
2
3
4
5
6
7
8
9
10
public class Main {
public static void main(String[] args) {
String[] names = {"Bob", "Alice", "Grace"};
var sj = new StringJoiner(", ");
for (String name : names) {
sj.add(name);
}
System.out.println(sj.toString());
}
}

StringJoiner的结果少了前面的"Hello "和结尾的"!"!遇到这种情况,需要给StringJoiner指定“开头”和“结尾”
1
2
3
4
5
6
7
8
9
10
public class Main {
public static void main(String[] args) {
String[] names = {"Bob", "Alice", "Grace"};
var sj = new StringJoiner(", ", "Hello ", "!");
for (String name : names) {
sj.add(name);
}
System.out.println(sj.toString());
}
}

String,join()

String还提供了一个静态方法join(),这个方法在内部使用了StringJoiner来拼接字符串,在不需要指定“开头”和“结尾”的时候,用String.join()更方便

1
2
String[] names = {"Bob", "Alice", "Grace"};
var s = String.join(", ", names);

包装类型

想要把int基本类型变成一个引用类型,我们可以定义一个Integer类,它只包含一个实例字段int,这样,Integer类就可以视为int包装类(Wrapper Class)

1
2
3
4
5
6
7
8
9
10
11
public class Integer {
private int value;

public Integer(int value) {
this.value = value;
}

public int intValue() {
return this.value;
}
}

定义好了Integer类,我们就可以把intInteger互相转换
1
2
3
Integer n = null;
Integer n2 = new Integer(99);
int n3 = n2.intValue();

因为包装类型非常有用,Java核心库为每种基本类型都提供了对应的包装类型
|基本类型|对应的引用类型
|—-|—-|
|boolean|java.lang.Boolean|
|byte|java.lang.Byte|
|short|java.lang.Short|
|int|java.lang.Integer|
|long|java.lang.Long|
|float|java.lang.Float|
|double|java.lang.Double|
|char|java.lang.Character|

我们可以直接使用,不需要自己定义

1
2
3
4
5
6
7
8
9
10
11
12
public class Main {
public static void main(String[] args) {
int i = 100;
// 通过new操作符创建Integer实例(不推荐使用,会有编译警告):
Integer n1 = new Integer(i);
// 通过静态方法valueOf(int)创建Integer实例:
Integer n2 = Integer.valueOf(i);
// 通过静态方法valueOf(String)创建Integer实例:
Integer n3 = Integer.valueOf("100");
System.out.println(n3.intValue());
}
}

Auto Boxing

Java编译器可以帮助我们自动在intInteger之间转型:

1
2
Integer n = 100; // 编译器自动使用Integer.valueOf(int)
int x = n; // 编译器自动使用Integer.intValue()

这种直接把int变为Integer的赋值写法,称为自动装箱(Auto Boxing),反过来,把Integer变为int的赋值写法,称为自动拆箱(Auto Unboxing)

  • ==注意:自动装箱和自动拆箱只发生在编译阶段,目的是为了少写代码==
  • 自动拆箱执行时可能会报NullPointerException
  • 装箱和拆箱会影响代码的执行效率,因为编译后的class代码是严格区分基本类型和引用类型的

不变类

所有的包装类型都是不变类

1
2
3
public final class Integer {
private final int value;
}

因此,一旦创建了Integer对象,该对象就是不变

对两个Integer实例进行比较要特别注意:绝对不能用==比较,因为Integer引用类型,必须使用equals()比较

我们自己创建Integer的时候,以下两种方法:

  • 方法1:Integer n = new Integer(100);
  • 方法2:Integer n = Integer.valueOf(100);

我们把能创建“新”对象的静态方法称为静态工厂方法

  • Integer.valueOf()就是静态工厂方法,它尽可能地返回缓存的实例以节省内存

==创建新对象时,优先选用静态工厂方法而不是new操作符==

进制转换

Integer类本身还提供了大量方法,例如,最常用的静态方法parseInt()可以把字符串解析成一个整数:

1
2
int x1 = Integer.parseInt("100"); // 100
int x2 = Integer.parseInt("100", 16); // 256,因为按16进制解析

Integer还可以把整数格式化为指定进制的字符串:
1
2
3
4
5
6
7
8
9
public class Main {
public static void main(String[] args) {
System.out.println(Integer.toString(100)); // "100",表示为10进制
System.out.println(Integer.toString(100, 36)); // "2s",表示为36进制
System.out.println(Integer.toHexString(100)); // "64",表示为16进制
System.out.println(Integer.toOctalString(100)); // "144",表示为8进制
System.out.println(Integer.toBinaryString(100)); // "1100100",表示为2进制
}
}

我们经常使用的System.out.println(n);是依靠核心库自动把整数格式化为10进制输出并显示在屏幕上,使用Integer.toHexString(n)则通过核心库自动把整数格式化为16进制

这里我们注意到程序设计的一个重要原则:数据的存储和显示要分离

ava的包装类型还定义了一些有用的静态变量

// boolean只有两个值true/false,其包装类型只需要引用Boolean提供的静态字段:

1
2
3
4
5
6
7
8
9
Boolean t = Boolean.TRUE;
Boolean f = Boolean.FALSE;
// int可表示的最大/最小值:
int max = Integer.MAX_VALUE; // 2147483647
int min = Integer.MIN_VALUE; // -2147483648
// long类型占用的bit和byte数量:
int sizeOfLong = Long.SIZE; // 64 (bits)
int bytesOfLong = Long.BYTES; // 8 (bytes)

最后,所有的整数和浮点数的包装类型都继承自Number,因此,可以非常方便地直接通过包装类型获取各种基本类型:

1
2
3
4
5
6
7
8
// 向上转型为Number:
Number num = new Integer(999);
// 获取byte, int, long, float, double:
byte b = num.byteValue();
int n = num.intValue();
long ln = num.longValue();
float f = num.floatValue();
double d = num.doubleValue();

处理无符号整型

在Java中,并没有无符号整型(Unsigned)的基本数据类型

  • 无符号整型和有符号整型的转换在Java中就需要借助包装类型的静态方法完成
    1
    2
    3
    4
    5
    6
    7
    8
    public class Main {
    public static void main(String[] args) {
    byte x = -1;
    byte y = 127;
    System.out.println(Byte.toUnsignedInt(x)); // 255
    System.out.println(Byte.toUnsignedInt(y)); // 127
    }
    }
    类似的,可以把一个short按unsigned转换为int,把一个int按unsigned转换为long

JavaBean

如果读写方法符合以下这种命名规范:

1
2
3
4
// 读方法:
public Type getXyz()
// 写方法:
public void setXyz(Type value)

那么这种class被称为JavaBean

boolean字段比较特殊,它的读方法一般命名为isXyz()

1
2
3
4
// 读方法:
public boolean isChild()
// 写方法:
public void setChild(boolean value)

我们通常把一组对应的读方法(getter)和写方法(setter称为属性property)。例如,name属性:

  • 对应的读方法是String getName()
  • 对应的写方法是setName(String)

只有getter的属性称为只读属性(read-only)
只有setter的属性称为只写属性(write-only)

  • 只读属性很常见,只写属性不常见
  • 属性只需要定义gettersetter方法,不一定需要对应的字段

例如,child只读属性定义如下:

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

public String getName() { return this.name; }
public void setName(String name) { this.name = name; }

public int getAge() { return this.age; }
public void setAge(int age) { this.age = age; }

public boolean isChild() {
return age <= 6;
}
}

可以看出,gettersetter也是一种数据封装的方法

JavaBean的作用

JavaBean主要用来传递数据,即把一组数据组合成一个JavaBean便于传输
JavaBean可以方便地被IDE工具分析,生成读写属性的代码,主要用在图形界面的可视化设计中

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

点击右键,在弹出的菜单中选择“Source”,“Generate Getters and Setters”,在弹出的对话框中选中需要生成gettersetter方法的字段,点击确定即可由IDE自动完成所有方法代码

枚举JavaBean属性

要枚举一个JavaBean的所有属性,可以直接使用Java核心库提供的Introspector

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public class Main {
public static void main(String[] args) throws Exception {
BeanInfo info = Introspector.getBeanInfo(Person.class);
for (PropertyDescriptor pd : info.getPropertyDescriptors()) {
System.out.println(pd.getName());
System.out.println(" " + pd.getReadMethod());
System.out.println(" " + pd.getWriteMethod());
}
}
}

class Person {
private String name;
private int age;

public String getName() {
return name;
}

public void setName(String name) {
this.name = name;
}

public int getAge() {
return age;
}

public void setAge(int age) {
this.age = age;
}
}

运行上述代码,可以列出所有的属性,以及对应的读写方法

  • ==注意class属性是从Object继承的getClass()方法带来的==

枚举类

enum

为了让编译器能自动检查某个值在枚举的集合内,并且,不同用途的枚举需要不同的类型来标记,不能混用,我们可以使用enum来定义枚举类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Main {
public static void main(String[] args) {
Weekday day = Weekday.SUN;
if (day == Weekday.SAT || day == Weekday.SUN) {
System.out.println("Work at home!");
} else {
System.out.println("Work at office!");
}
}
}

enum Weekday {
SUN, MON, TUE, WED, THU, FRI, SAT;
}

  • enum常量本身带有类型信息,即Weekday.SUN类型是Weekday,编译器会自动检查出类型错误
    1
    2
    3
    int day = 1;
    if (day == Weekday.SUN) { // Compile error: bad operand types for binary operator '=='
    }
  • 不可能引用到非枚举的值,因为无法通过编译
  • 不同类型的枚举不能互相比较或者赋值,因为类型不符
    1
    2
    Weekday x = Weekday.SUN; // ok!
    Weekday y = Color.RED; // Compile error: incompatible types

enum的比较

使用enum定义的枚举类是一种引用类型
引用类型比较,要始终使用equals()方法,enum类型可以例外

  • 这是因为enum类型的每个常量在JVM中只有一个唯一实例,所以可以直接用==比较
    1
    2
    3
    4
    if (day == Weekday.FRI) { // ok!
    }
    if (day.equals(Weekday.SUN)) { // ok, but more code!
    }

enum类型

enum定义的类型就是class,只不过它有以下几个特点:

  • 定义的enum类型总是继承自java.lang.Enum,且无法被继承
  • 只能定义出enum的实例,而无法通过new操作符创建enum的实例
  • 定义的每个实例都是引用类型的唯一实例
  • 可以将enum类型用于switch语句

因为enum是一个class,每个枚举的值都是class实例,因此,这些实例有一些方法:

  • name()

    返回常量名,例如:
    1
    String s = Weekday.SUN.name(); // "SUN"
  • ordinal()

    返回定义的常量的顺序,从0开始计数,例如:
    1
    int n = Weekday.MON.ordinal(); // 1
    改变枚举常量定义的顺序就会导致ordinal()返回值发生变化

如果在代码中编写了类似if(x.ordinal()==1)这样的语句,就要保证enum的枚举顺序不能变。新增的常量必须放在最后

实现enum和int之间的转化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class Main {
public static void main(String[] args) {
Weekday day = Weekday.SUN;
if (day.dayValue == 6 || day.dayValue == 0) {
System.out.println("Work at home!");
} else {
System.out.println("Work at office!");
}
}
}

enum Weekday {
MON(1), TUE(2), WED(3), THU(4), FRI(5), SAT(6), SUN(0);

public final int dayValue;

private Weekday(int dayValue) {
this.dayValue = dayValue;
}
}

  • 这样就无需担心顺序的变化,新增枚举常量时,也需要指定一个int

==注意:枚举类的字段也可以是非final类型,即可以在运行期修改,但是不推荐这样做!==

==注意:判断枚举常量的名字,要始终使用name()方法,绝不能调用toString()!==

switch

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class Main {
public static void main(String[] args) {
Weekday day = Weekday.SUN;
switch(day) {
case MON:
case TUE:
case WED:
case THU:
case FRI:
System.out.println("Today is " + day + ". Work at office!");
break;
case SAT:
case SUN:
System.out.println("Today is " + day + ". Work at home!");
break;
default:
throw new RuntimeException("cannot process " + day);
}
}
}

enum Weekday {
MON, TUE, WED, THU, FRI, SAT, SUN;
}

纪录类

假设我们希望定义一个Point类,有xy两个变量,同时它是一个不变类,可以这么写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public final class Point {
private final int x;
private final int y;

public Point(int x, int y) {
this.x = x;
this.y = y;
}

public int x() {
return this.x;
}

public int y() {
return this.y;
}
}

为了保证不变类的比较,还需要正确覆写equals()hashCode()方法,这样才能在集合类中正常使用。

record

把上述Point类改写为Record类,代码如下:

1
2
3
4
5
6
7
8
9
10
public class Main {
public static void main(String[] args) {
Point p = new Point(123, 456);
System.out.println(p.x());
System.out.println(p.y());
System.out.println(p);
}
}

record Point(int x, int y) {}

把上述定义改写为class,相当于以下代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
final class Point extends Record {
private final int x;
private final int y;

public Point(int x, int y) {
this.x = x;
this.y = y;
}

public int x() {
return this.x;
}

public int y() {
return this.y;
}

public String toString() {
return String.format("Point[x=%s, y=%s]", x, y);
}

public boolean equals(Object o) {
...
}
public int hashCode() {
...
}
}

换句话说,使用record关键字,可以一行写出一个不变类

  • enum类似,我们自己不能直接从Record派生,只能通过record关键字由编译器实现继承

构造方法

假设Point类的xy不允许负数,我们就得给Point的构造方法加上检查逻辑:

1
2
3
4
5
6
7
public record Point(int x, int y) {
public Point {
if (x < 0 || y < 0) {
throw new IllegalArgumentException();
}
}
}

注意到方法public Point {...}被称为Compact Constructor,它的目的是让我们编写检查逻辑,编译器最终生成的构造方法如下:
1
2
3
4
5
6
7
8
9
10
11
12
public final class Point extends Record {
public Point(int x, int y) {
// 这是我们编写的Compact Constructor:
if (x < 0 || y < 0) {
throw new IllegalArgumentException();
}
// 这是编译器继续生成的赋值代码:
this.x = x;
this.y = y;
}
...
}

作为recordPoint仍然可以添加静态方法。一种常用的静态方法是of()方法,用来创建Point
1
2
3
4
5
6
7
8
public record Point(int x, int y) {
public static Point of() {
return new Point(0, 0);
}
public static Point of(int x, int y) {
return new Point(x, y);
}
}

BigInteger

如果我们使用的整数范围超过了long怎么办?这个时候,就只能用软件来模拟一个大整数。java.math.BigInteger就是用来表示任意大小的整数。BigInteger内部用一个int[]数组来模拟一个非常大的整数:

1
2
BigInteger bi = new BigInteger("1234567890");
System.out.println(bi.pow(5)); // 2867971860299718107233761438093672048294900000

BigInteger做运算的时候,只能使用实例方法,例如,加法运算:

1
2
3
BigInteger i1 = new BigInteger("1234567890");
BigInteger i2 = new BigInteger("12345678901234567890");
BigInteger sum = i1.add(i2); // 12345678902469135780

  • BigInteger不会有范围限制,但缺点是速度比较慢

也可以把BigInteger转换成long型:

1
2
3
BigInteger i = new BigInteger("123456789000");
System.out.println(i.longValue()); // 123456789000
System.out.println(i.multiply(i).longValueExact()); // java.lang.ArithmeticException: BigInteger out of long range

使用longValueExact()方法时,如果超出了long型的范围,会抛出ArithmeticException

BigIntegerIntegerLong一样,也是不可变类,并且也继承自Number类。因为Number定义了转换为基本类型的几个方法:

  • 转换为bytebyteValue()
  • 转换为shortshortValue()
  • 转换为intintValue()
  • 转换为longlongValue()
  • 转换为floatfloatValue()
  • 转换为doubledoubleValue()

如果BigInteger表示的范围超过了基本类型的范围,转换时将丢失高位信息,即结果不一定是准确的。

如果需要准确地转换成基本类型,可以使用intValueExact()longValueExact()等方法,在转换时如果超出范围,将直接抛出ArithmeticException异常

BigDecimal

BigInteger类似,BigDecimal可以表示一个任意大小且精度完全准确的浮点数

BigDecimalscale()表示小数位数,例如:

1
2
3
4
5
6
BigDecimal d1 = new BigDecimal("123.45");
BigDecimal d2 = new BigDecimal("123.4500");
BigDecimal d3 = new BigDecimal("1234500");
System.out.println(d1.scale()); // 2,两位小数
System.out.println(d2.scale()); // 4
System.out.println(d3.scale()); // 0

通过BigDecimalstripTrailingZeros()方法,可以将一个BigDecimal格式化为一个相等的,但去掉了末尾0BigDecimal

1
2
3
4
5
6
7
8
9
BigDecimal d1 = new BigDecimal("123.4500");
BigDecimal d2 = d1.stripTrailingZeros();
System.out.println(d1.scale()); // 4
System.out.println(d2.scale()); // 2,因为去掉了00

BigDecimal d3 = new BigDecimal("1234500");
BigDecimal d4 = d3.stripTrailingZeros();
System.out.println(d3.scale()); // 0
System.out.println(d4.scale()); // -2

  • 如果一个BigDecimalscale()返回负数,例如,-2,表示这个数是个整数,并且末尾有2个0

可以对一个BigDecimal设置它的scale,如果精度比原始值低,那么按照指定的方法进行四舍五入或者直接截断:

1
2
3
4
5
6
7
8
9
public class Main {
public static void main(String[] args) {
BigDecimal d1 = new BigDecimal("123.456789");
BigDecimal d2 = d1.setScale(4, RoundingMode.HALF_UP); // 四舍五入,123.4568
BigDecimal d3 = d1.setScale(4, RoundingMode.DOWN); // 直接截断,123.4567
System.out.println(d2);
System.out.println(d3);
}
}

BigDecimal做加、减、乘时,精度不会丢失,但是做除法时,存在无法除尽的情况,这时,就必须指定精度以及如何进行截断:

1
2
3
4
BigDecimal d1 = new BigDecimal("123.456");
BigDecimal d2 = new BigDecimal("23.456789");
BigDecimal d3 = d1.divide(d2, 10, RoundingMode.HALF_UP); // 保留10位小数并四舍五入
BigDecimal d4 = d1.divide(d2); // 报错:ArithmeticException,因为除不尽

还可以对BigDecimal做除法的同时求余数
1
2
3
4
5
6
7
8
9
public class Main {
public static void main(String[] args) {
BigDecimal n = new BigDecimal("12.345");
BigDecimal m = new BigDecimal("0.12");
BigDecimal[] dr = n.divideAndRemainder(m);
System.out.println(dr[0]); // 102
System.out.println(dr[1]); // 0.105
}
}

  • 调用divideAndRemainder()方法时,返回的数组包含两个BigDecimal,分别是商和余数,其中商总是整数,余数不会大于除数

比较BigDecimal

在比较两个BigDecimal的值是否相等时,要特别注意,使用equals()方法不但要求两个BigDecimal的值相等,还要求它们的scale()相等

必须使用compareTo()方法来比较,它根据两个值的大小分别返回负数、正数和0,分别表示小于、大于和等于

  • ==总是使用compareTo()比较两个BigDecimal的值,不要使用equals()!==

如果查看BigDecimal的源码,可以发现,实际上一个BigDecimal是通过一个BigInteger和一个scale来表示的,即BigInteger表示一个完整的整数,而scale表示小数位数

1
2
3
4
public class BigDecimal extends Number implements Comparable<BigDecimal> {
private final BigInteger intVal;
private final int scale;
}

常用工具类

Math

求绝对值:

1
2
Math.abs(-100); // 100
Math.abs(-7.8); // 7.8

取最大或最小值:
1
2
Math.max(100, 99); // 100
Math.min(1.2, 2.3); // 1.2

计算xy次方:
1
Math.pow(2, 10); // 2的10次方=1024

计算√x:
1
Math.sqrt(2); // 1.414...

计算ex次方:
1
Math.exp(2); // 7.389...

计算以e为底的对数:
1
Math.log(4); // 1.386...

计算以10为底的对数:
1
Math.log10(100); // 2

三角函数:
1
2
3
4
5
Math.sin(3.14); // 0.00159...
Math.cos(3.14); // -0.9999...
Math.tan(3.14); // -0.0015...
Math.asin(1.0); // 1.57079...
Math.acos(1.0); // 0.0

Math还提供了几个数学常量:
1
2
3
double pi = Math.PI; // 3.14159...
double e = Math.E; // 2.7182818...
Math.sin(Math.PI / 6); // sin(π/6) = 0.5

生成一个随机数x,x的范围是0 <= x < 1
1
Math.random(); // 0.53907... 每次都不一样

如果我们要生成一个区间在[MIN, MAX)的随机数,可以借助Math.random()实现,计算如下:

1
2
3
4
5
6
7
8
9
10
11
12
// 区间在[MIN, MAX)的随机数
public class Main {
public static void main(String[] args) {
double x = Math.random(); // x的范围是[0,1)
double min = 10;
double max = 50;
double y = x * (max - min) + min; // y的范围是[10,50)
long n = (long) y; // n的范围是[10,50)的整数
System.out.println(y);
System.out.println(n);
}
}

Java标准库还提供了一个StrictMath,它提供了和Math几乎一模一样的方法

  • StrictMath保证所有平台计算结果都是完全相同的,而Math会尽量针对平台优化计算速度

HexFormat

要将byte[]数组转换为十六进制字符串,可以用formatHex()方法:

1
2
3
4
5
6
7
8
9
import java.util.HexFormat;

public class Main {
public static void main(String[] args) throws InterruptedException {
byte[] data = "Hello".getBytes();
HexFormat hf = HexFormat.of();
String hexData = hf.formatHex(data); // 48656c6c6f
}
}

如果要定制转换格式,则使用定制的HexFormat实例:

1
2
3
// 分隔符为空格,添加前缀0x,大写字母:
HexFormat hf = HexFormat.ofDelimiter(" ").withPrefix("0x").withUpperCase();
hf.formatHex("Hello".getBytes())); // 0x48 0x65 0x6C 0x6C 0x6F

从十六进制字符串到byte[]数组转换,使用parseHex()方法:
1
byte[] bs = HexFormat.of().parseHex("48656c6c6f");

Random

Random用来创建伪随机数。所谓伪随机数,是指只要给定一个初始的种子,产生的随机数序列是完全一样的
要生成一个随机数,可以使用nextInt()、nextLong()、nextFloat()、nextDouble():

1
2
3
4
5
6
Random r = new Random();
r.nextInt(); // 2071575453,每次都不一样
r.nextInt(10); // 5,生成一个[0,10)之间的int
r.nextLong(); // 8811649292570369305,每次都不一样
r.nextFloat(); // 0.54335...生成一个[0,1)之间的float
r.nextDouble(); // 0.3716...生成一个[0,1)之间的double

  • 如果不给定种子,就使用系统当前时间戳作为种子,因此每次运行时,种子不同,得到的伪随机数序列就不同

如果我们在创建Random实例时指定一个种子,就会得到完全确定的随机数序列

1
2
3
4
5
6
7
8
9
public class Main {
public static void main(String[] args) {
Random r = new Random(12345);
for (int i = 0; i < 10; i++) {
System.out.println(r.nextInt(100));
}
// 51, 80, 41, 28, 55...
}
}

  • 前面我们使用的Math.random()实际上内部调用了Random类,所以它也是伪随机数,只是我们无法指定种子

SecureRandom

。实际上真正的真随机数只能通过量子力学原理来获取,而我们想要的是一个不可预测的安全的随机数,SecureRandom就是用来创建安全的随机数

1
2
SecureRandom sr = new SecureRandom();
System.out.println(sr.nextInt(100));

SecureRandom无法指定种子,它使用RNG(random number generator)算法

实际使用的时候,可以优先获取高强度的安全随机数生成器,如果没有提供,再使用普通等级的安全随机数生成器:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Main {
public static void main(String[] args) {
SecureRandom sr = null;
try {
sr = SecureRandom.getInstanceStrong(); // 获取高强度安全随机数生成器
} catch (NoSuchAlgorithmException e) {
sr = new SecureRandom(); // 获取普通的安全随机数生成器
}
byte[] buffer = new byte[16];
sr.nextBytes(buffer); // 用安全随机数填充buffer
System.out.println(Arrays.toString(buffer));
}
}

SecureRandom的安全性是通过操作系统提供的安全的随机种子来生成随机数。这个种子是通过CPU的热噪声、读写磁盘的字节、网络流量等各种随机事件产生的“熵”

==需要使用安全随机数的时候,必须使用SecureRandom,绝不能使用Random!==

整理BY:Misayaas

内容来自廖雪峰的官方网站: https://www.liaoxuefeng.com/

变量和数据类型

  • byte是最小储存单位 == 8位二进制数(8个bit) 0-255
  • 一个字节是1byte,1024byte = 1K,1024K = 1M,1024M = 1G,1024G = 1T
  • int4字节,long8字节,float4字节,double8字节,char2字节
  • float类型要加上f,例如==float f1 = 3.14f==
  • 理论上布尔类型只要1bit,但是JVM内部会把boolean表示为4字节整数
  • char用引号且只有一个字符,String用引号(String是引用类型)
  • 引用类型类似于C的指针,指向某个对象在内存的位置

final关键字

修饰的变量会变为常量,常量初始化后不可赋值

var关键字

省略变量类型,例如:

1
StringBuilder sb = new StringBuilder(); 

可以变为
1
var sb = new StringBuilder();

整数运算

  • 整数除法对于除数为0的情况运行会报错,编译不报错
  • byteshort类型进行移位时会先转化为int
  • 如果运算的类型不同,自动转化为大类型
  • 超出范围的强制转型会得到错误的结果

浮点数运算

  • 浮点数在计算机无法精确表示,因此常常出错,只能保存个近似值(二进制
  • 浮点数在除数为0时不会报错,但会返回三个特殊值:NaN表示Not a Number、Infinity表示无穷大、-Infinity表示负无穷大

字符和字符串

字符类型

  • java在内存中使用Unicode表示字符,所以一个英文字符和一个中文字符都占两个字节
  • char类型赋给int类型即可显示Unicode编码
  • 还可以使用转义字符\u+Unicode编码表示一个字符
    1
    2
    3
    // 注意是十六进制:
    char c3 = '\u0041'; // 'A',因为十六进制0041 = 十进制65
    char c4 = '\u4e2d'; // '中',因为十六进制4e2d = 十进制20013

    字符串类型

  • 从java13开始可以用"""..."""表示多行字符串
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    public class Main {
    public static void main(String[] args) {
    String s = """
    SELECT * FROM
    users
    WHERE id > 100
    ORDER BY name DESC
    """;
    System.out.println(s);
    }
    }
    上述多行字符串实际上是5行,在最后一个DESC后面还有一个\n。如果我们不想在字符串末尾加一个\n,就需要这么写:
    1
    2
    3
    4
    5
    String s = """ 
    SELECT * FROM
    users
    WHERE id > 100
    ORDER BY name DESC""";
    还需要注意到,多行字符串前面共同的空格会被去掉,即总是以最短的行首空格为基准。

    不可变特性

  • 只会改变变量的指向,不会改变字符串
  • String[] 类型的对象是可变的,也就是说,可以修改数组中的元素值。
  • 空字符串不等于null

数组类型

数组一旦创建后,大小就不可改变

流程控制

输入输出

占位符 说明
%x 十六进制整数
%e 科学计数法
  • 连续两个%%表示一个%字符本身

有了Scanner对象后,用scanner.nextLine()读取字符串,用scanner.nextInt()读取输入的整数

  • Scanner自动转换数据类型,因此不必手动转换

if判断

==表示“引用是否相等”,或者说,是否指向同一个对象

要判断引用类型的变量内容是否相等,必须使用equals()方法

  • 执行语句s1.equals(s2)时,如果变量s1null,会报NullPointerException
  • 要避免NullPointerException错误,可以利用短路运算符&&
    1
    2
    3
    if (s1 != null && s1.equals("hello")) {
    System.out.println("hello");
    }

switch判断

如果没有匹配到任何case,那么switch语句不会执行任何语句。这时,可以给switch语句加一个default,当没有匹配到任何case时,执行default

case语句并没有花括号{},而且,case语句具有“_穿透性_”,漏写break将导致意想不到的结果

  • 后续语句将全部执行,直到遇到break语句

如果有几个case语句执行的是同一组语句块,可以这么写

1
2
3
4
case 2:
case 3:
System.out.println("Selected 2, 3");
break;

case匹配字符串时,是比较内容相等

数组操作

遍历数组

Arrays.toString()用于快速打印数组内容

数组排序

实际上,Java的标准库已经内置了排序功能,我们只需要调用JDK提供的Arrays.sort()就可以排序

必须注意,对数组排序实际上修改了数组本身

如果对一个字符串数组进行排序,原来的3个字符串在内存中均没有任何变化,但是ns数组的每个元素指向变化了

  • 排序前,这个数组在内存中表示如下:
1
2
3
4
5
6
7
8
9
                   ┌──────────────────────────────────┐
┌───┼──────────────────────┐ │
│ │ ▼ ▼
┌───┬─┴─┬─┴─┬───┬────────┬───┬───────┬───┬──────┬───┐
ns ─────▶│░░░│░░░│░░░│ │"banana"│ │"apple"│ │"pear"│ │
└─┬─┴───┴───┴───┴────────┴───┴───────┴───┴──────┴───┘
│ ▲
└─────────────────┘

  • 调用Arrays.sort(ns);排序后,这个数组在内存中表示如下:
    1
    2
    3
    4
    5
    6
    7
    8
                      ┌──────────────────────────────────┐
    ┌───┼──────────┐ │
    │ │ ▼ ▼
    ┌───┬─┴─┬─┴─┬───┬────────┬───┬───────┬───┬──────┬───┐
    ns ─────▶│░░░│░░░│░░░│ │"banana"│ │"apple"│ │"pear"│ │
    └─┬─┴───┴───┴───┴────────┴───┴───────┴───┴──────┴───┘
    │ ▲
    └──────────────────────────────┘

冒泡排序(从小到大)

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

public class Main {
public static void main(String[] args) {
int[] ns = { 28, 12, 89, 73, 65, 18, 96, 50, 8, 36 };
// 排序前:
System.out.println(Arrays.toString(ns));
for (int i = 0; i < ns.length - 1; i++) {
for (int j = 0; j < ns.length - i - 1; j++) {
if (ns[j] > ns[j+1]) {
// 交换ns[j]和ns[j+1]:
int tmp = ns[j];
ns[j] = ns[j+1];
ns[j+1] = tmp;
}
}
}
// 排序后:
System.out.println(Arrays.toString(ns));
}
}

二维数组

二维数组的每个数组元素的长度并不要求相同,例如,可以这么定义ns数组:

1
2
3
4
5
int[][] ns = {
{ 1, 2, 3, 4 },
{ 5, 6 },
{ 7, 8, 9 }
};

要打印一个二维数组,可以使用两层嵌套的for循环,或者使用Java标准库的Arrays.deepToString()

整理BY:Misayaas

内容来自廖雪峰的官方网站: https://www.liaoxuefeng.com/

方法

一个class可以包含多个field。但是,直接把fieldpublic暴露给外部可能会破坏封装性。为了避免外部代码直接去访问field,我们可以用private修饰field,拒绝外部访问

  • 所以我们需要使用方法(method)来让外部代码可以间接修改

所以,一个类通过定义方法,就可以给外部代码暴露一些操作的接口,同时,内部自己保证逻辑一致性。

定义方法

1
2
3
4
修饰符 方法返回类型 方法名(方法参数列表) {
若干方法语句;
return 方法返回值;
}
  • private方法
    定义private方法的理由是内部方法是可以调用private方法的

  • this变量
    在方法内部,可以使用一个隐含的变量this,它始终指向当前实例

    1
    2
    3
    4
    5
    6
    7
    class Person {
    private String name;

    public void setName(String name) {
    this.name = name; // 前面的this不可少,少了就变成局部变量name了
    }
    }

参数绑定

  • 基本类型参数的传递,是调用方值的复制。双方各自的后续修改,互不影响
  • 引用类型参数的传递,调用方的变量,和接收方的参数变量,指向的是同一个对象。双方任意一方对这个对象的修改,都会影响对方(因为指向同一个对象嘛)。

构造方法

创建实例的时候,实际上是通过构造方法来初始化实例的。

  • 构造方法的名称就是类名
  • 和普通方法相比,构造方法没有返回值(也没有void),调用构造方法,必须用new操作符。
  • 任何class都有构造方法
  • 如果一个类没有定义构造方法,编译器会自动为我们生成一个默认构造方法,它没有参数,也没有执行语句,类似这样:
    1
    2
    3
    4
    class Person {
    public Person() {
    }
    }
  • 如果既要能使用带参数的构造方法,又想保留不带参数的构造方法,那么只能把两个构造方法都定义出来

没有在构造方法中初始化字段时,引用类型的字段默认是null,数值类型的字段用默认值int类型默认值是0,布尔类型默认值是false

  • 也可以对字段直接进行初始化

在Java中,创建对象实例的时候,按照如下顺序进行初始化:

1.先初始化字段
2.执行构造方法的代码进行初始化

一个构造方法可以调用其他构造方法,这样做的目的是便于代码复用。调用其他构造方法的语法是this(…)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Person {
private String name;
private int age;

public Person(String name, int age) {
this.name = name;
this.age = age;
}

public Person(String name) {
this(name, 18); // 调用另一个构造方法Person(String, int)
}

public Person() {
this("Unnamed"); // 调用另一个构造方法Person(String)
}
}

方法重载(Overload)

方法名相同,但各自的参数不同

方法重载的返回值类型通常都是相同的。

方法重载的目的是,功能类似的方法使用同一名字,更容易记住,因此,调用起来更简单。

继承

子类自动获得了父类的所有字段,严禁定义与父类重名的字段!

任何类,除了Object,都会继承自某个类。

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


┌───────────┐
│ Person │
└───────────┘


┌───────────┐
│ Student │
└───────────┘

Java只允许一个class继承自一个类,因此,一个类有且仅有一个父类。只有Object特殊,它没有父类。

继承有个特点,就是子类无法访问父类的private字段或者private方法。

  • 为了让子类可以访问父类的字段,我们需要把private改为protected。用protected修饰的字段可以被子类访问

super

super关键字表示父类(超类)。子类引用父类的字段时,可以用super.fieldName

1
2
3
4
5
class Student extends Person {
public String hello() {
return "Hello, " + super.name;
}
}

实际上,这里使用super.name,或者this.name,或者name,效果都是一样的。编译器会自动定位到父类的name字段。

在Java中,任何class的构造方法,第一行语句必须是调用父类的构造方法。如果没有明确地调用父类的构造方法,编译器会帮我们自动加一句super();

  • 这里的super()是无参数构造方法
  • 如果父类没有默认的构造方法,子类就必须显式调用super()并给出参数以便让编译器定位到父类的一个合适的构造方法

子类不会继承任何父类的构造方法。子类默认的构造方法是编译器自动生成的,不是继承的

阻止继承

从Java 15开始,允许使用sealed修饰class,并通过permits明确写出能够从该class继承的子类名称。

1
2
3
public sealed class Shape permits Rect, Circle, Triangle {
...
}

  • 上述Shape类就是一个sealed类,它只允许指定的3个类继承它。

向上转型

如果Student是从Person继承下来的,那,一个引用类型为Person的变量,指向Student类型的实例

1
Person p = new Student()

  • 这种把一个子类类型安全地变为父类类型的赋值,被称为向上转型(upcasting)

向上转型实际上是把一个子类型安全地变为更加抽象的父类型:

1
2
3
4
Student s = new Student();
Person p = s; // upcasting, ok
Object o1 = p; // upcasting, ok
Object o2 = s; // upcasting, ok

向下转型

如果把一个父类类型强制转型为子类类型,就是向下转型(downcasting)

1
2
3
4
Person p1 = new Student(); // upcasting, ok
Person p2 = new Person();
Student s1 = (Student) p1; // ok,因为p1确实指向Student实例
Student s2 = (Student) p2; // runtime error! ClassCastException!子类功能比父类多,多的功能无法凭空变出来。

  • 因此,向下转型很可能会失败。失败的时候,Java虚拟机会报ClassCastException

为了避免向下转型出错,Java提供了instanceof操作符,可以先判断一个实例究竟是不是某种类型:

1
2
3
4
5
6
7
8
9
10
Person p = new Person();
System.out.println(p instanceof Person); // true
System.out.println(p instanceof Student); // false

Student s = new Student();
System.out.println(s instanceof Person); // true
System.out.println(s instanceof Student); // true

Student n = null;
System.out.println(n instanceof Student); // false

具有has关系不应该使用继承,而是使用组合,即Student可以持有一个Book实例:

1
2
3
4
class Student extends Person {
protected Book book;
protected int score;
}

因此,继承是is关系,组合是has关系。

多态

在继承关系中,子类如果定义了一个与父类方法签名完全相同的方法,被称为重写(Override)

Override和Overload不同的是,如果方法签名不同,就是Overload,Overload方法是一个新方法;如果方法签名相同,并且返回值也相同,就是Override

==Java的实例方法调用是基于运行时的实际类型的动态调用,而非变量的声明类型。==

  • 多态是指,针对某个类型的方法调用,其真正执行的方法取决于运行时期实际类型的方法
  • 多态的特性就是,运行期才能动态决定调用的子类方法。对某个类型调用某个方法,执行的实际方法可能是某个子类的覆写方法。

重写Object方法

因为所有的class最终都继承自Object,而Object定义了几个重要的方法:

  • toString():把instance输出为String
  • equals():判断两个instance是否逻辑相等;
  • hashCode():计算一个instance的哈希值。
    在有必要的情况下,我们可以重写这些方法

调用super

在子类的覆写方法中,如果要调用父类的被覆写的方法,可以通过super来调用

final

如果一个父类不允许子类对它的某个方法进行覆写,可以把该方法标记为final。用final修饰的方法不能被Override

  • 如果一个类不希望任何其他类继承自它,那么可以把这个类本身标记为final

可以在构造方法中初始化final字段:

1
2
3
4
5
6
class Person {
public final String name;
public Person(String name) {
this.name = name;
}
}

  • 这种方法更为常用,因为可以保证实例一旦创建,其final字段就不可修改

抽象类

如果父类的方法本身不需要实现任何功能,仅仅是为了定义方法签名,目的是让子类去重写它,那么,可以把父类的方法声明为抽象方法

1
2
3
class Person {
public abstract void run();
}

  • 因为这个抽象方法本身是无法执行的,所以,Person类也无法被实例化
  • 必须把Person类本身也声明为abstract,才能正确编译它

使用abstract修饰的类就是抽象类。我们无法实例化一个抽象类:

1
Person p = new Person(); // 编译错误

抽象类可以强迫子类实现其定义的抽象方法,否则编译会报错

这种引用抽象类的好处在于,我们对其进行方法调用,并不关心Person类型变量的具体子类型:

1
2
3
// 不关心Person变量的具体子类型:
s.run();
t.run();

同样的代码,如果引用的是一个新的子类,我们仍然不关心具体类型:
1
2
3
// 同样不关心新的子类是如何实现run()方法的:
Person e = new Employee();
e.run();

这种尽量引用高层类型,避免引用实际子类型的方式,称之为面向抽象编程

面向抽象编程的本质就是:

  • 上层代码只定义规范(例如:abstract class Person);

  • 不需要子类就可以实现业务逻辑(正常编译);

  • 具体的业务逻辑由不同的子类实现,调用者并不关心

接口

如果一个抽象类没有字段,所有方法全部都是抽象方法,就可以把该抽象类改写为接口:interface

在Java中,使用interface可以声明一个接口:

1
2
3
4
interface Person {
void run();
String getName();
}

所谓interface,就是比抽象类还要抽象的纯抽象接口,因为它连字段都不能有。因为接口定义的所有方法默认都是public abstract的,所以这两个修饰符不需要写出来(写不写效果都一样)

一个类可以实现多个interface(区别于继承)

1
2
3
class Student implements Person, Hello { // 实现了两个interface
...
}

注意区分术语:

  • Java的接口特指interface的定义,表示一个接口类型和一组方法签名
  • 编程接口泛指接口规范,如方法签名,数据格式,网络协议等。
abstract class interface
继承 只能extends一个class 可以implements多个interface
字段 可以定义实例字段 不能定义实例字段
抽象方法 可以定义抽象方法 可以定义抽象方法
非抽象方法 可以定义非抽象方法 可以定义default方法

接口继承

一个interface可以继承自另一个interface
interface继承自interface要使用extends,它相当于扩展了接口的方法。

1
2
3
4
5
6
7
8
interface Hello {
void hello();
}

interface Person extends Hello {
void run();
String getName();
}

继承关系

一般来说,公共逻辑适合放在abstract class中,具体逻辑放到各个子类,而接口层次代表抽象程度

在使用的时候,实例化的对象永远只能是某个具体的子类,但总是通过接口去引用它,因为接口比抽象类更抽象

default方法

在接口中,可以定义default方法。例如,把Person接口的run()方法改为default方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class Main {
public static void main(String[] args) {
Person p = new Student("Xiao Ming");
p.run();
}
}

interface Person {
String getName();
default void run() {
System.out.println(getName() + " run");
}
}

class Student implements Person {
private String name;

public Student(String name) {
this.name = name;
}

public String getName() {
return this.name;
}
}

实现类可以不必覆写default方法。
default方法的目的是,当我们需要给接口新增一个方法时,会涉及到修改全部子类

  • 当类实现接口时,类要实现接口中所有方法

如果新增的是default方法,那么子类就不必全部修改,只需要在需要覆写的地方去覆写新增方法。

default方法和抽象类的普通方法是有所不同的。因为interface没有字段,default方法无法访问字段,而抽象类的普通方法可以访问实例字段。

静态字段和静态方法

静态字段

实例字段在每个实例中都有自己的一个独立“空间”
静态字段只有一个共享“空间”,所有实例都会共享该字段。

对于静态字段,无论修改哪个实例的静态字段,效果都是一样的:所有实例的静态字段都被修改了,原因是静态字段并不属于实例

  • 实例可以访问静态字段,但它们指向的都是一个共享的静态字段
  • 在java程序中,实例对象并没有静态字段
    • 实例对象能访问静态字段只是因为编译器可以根据实例类型自动转换为类名.静态字段来访问静态对象
    • 推荐用类名来访问静态字段
    • 可以把静态字段理解为描述class本身的字段
      1
      2
      Person.number = 99;
      System.out.println(Person.number);

静态方法

调用静态方法则不需要实例变量,通过类名就可以调用

因为静态方法属于class而不属于实例,因此,静态方法内部,无法访问this变量,也无法访问实例字段,它只能访问静态字段

  • 通常情况下,通过实例变量访问静态字段和静态方法,会得到一个编译警告

静态方法经常用于工具类。例如:

  • Arrays.sort()

  • Math.random()

静态方法也经常用于辅助方法

  • 注意到Java程序的入口main()也是静态方法。

接口的静态字段

interface是可以有静态字段的,并且静态字段必须为final类型

  • 实际上,因为interface的字段只能public static final类型,所以我们可以把这些修饰符都去掉
    1
    2
    3
    4
    5
    public interface Person {
    // 编译器会自动加上public statc final:
    int MALE = 1;
    int FEMALE = 2;
    }

Java定义了一种名字空间,称之为package。一个类总是属于某个包,类名(比如Person)只是一个简写,真正的完整类名是包名.类名

  • 小明的Person类存放在包ming下面,因此,完整类名是ming.Person
  • 小红的Person类存放在包hong下面,因此,完整类名是hong.Person
  • 小军的Arrays类存放在包mr.jun下面,因此,完整类名是mr.jun.Arrays
  • JDK的Arrays类存放在包java.util下面,因此,完整类名是java.util.Arrays

在Java虚拟机执行的时候,JVM只看完整类名,因此,只要包名不同,类就不同

  • 包可以是多层结构,用.隔开。例如:java.util

要特别注意:包没有父子关系。java.util和java.util.zip是不同的包,==两者没有任何继承关系==

没有定义包名的class,它使用的是默认包,非常容易引起名字冲突,因此,不推荐不写包名的做法。

  • 在定义class的时候,我们需要在第一行声明这个class属于哪个包

包作用域

位于同一个包的类,可以访问包作用域的字段和方法

  • 不用publicprotectedprivate修饰的字段和方法就是包作用域

import

小明的ming.Person类,如果要引用小军的mr.jun.Arrays类,他有三种写法

  1. 直接写出完整类名
    1
    2
    3
    4
    5
    6
    7
    8
    // Person.java
    package ming;

    public class Person {
    public void run() {
    mr.jun.Arrays arrays = new mr.jun.Arrays();
    }
    }

2.是用import语句,导入小军的Arrays,然后写简单类名(最常用)

1
2
3
4
5
6
7
8
9
10
11
// Person.java
package ming;

// 导入完整类名:
import mr.jun.Arrays;

public class Person {
public void run() {
Arrays arrays = new Arrays();
}
}

在写import的时候,可以使用*,表示把这个包下面的所有class都导入进来(==但不包括子包的class==)
1
2
3
4
5
6
7
8
9
10
11
// Person.java
package ming;

// 导入mr.jun包的所有class:
import mr.jun.*;

public class Person {
public void run() {
Arrays arrays = new Arrays();
}
}

  • 不推荐这种写法,因为在导入了多个包后,很难看出Arrays类属于哪个包

3.import static的语法,它可以导入可以导入一个类的静态字段和静态方法

1
2
3
4
5
6
7
8
9
package main;
// 导入System类的所有静态字段和静态方法:
import static java.lang.System.*;
public class Main {
public static void main(String[] args) {
// 相当于调用System.out.println(…)
out.println("Hello, world!");
}
}

Java编译器最终编译出的.class文件只使用完整类名

在代码中,当编译器遇到一个class名称时:

  • 如果是完整类名,就直接根据完整类名查找这个class

  • 如果是简单类名,按下面的顺序依次查找:

    • 查找当前package是否存在这个class

    • 查找import的包是否包含这个class

    • 查找java.lang包是否包含这个class

如果按照上面的规则还无法确定类名,则编译报错

因此,编写class的时候,编译器会自动帮我们做两个import动作:

  • 默认自动import当前package的其他class

  • 默认自动import java.lang.*

注意:自动导入的是java.lang包,但类似java.lang.reflect这些包仍需要手动导入

如果有两个class名称相同,只能import其中一个,另一个必须写完整类名

最佳实践

为了避免名字冲突,我们需要确定唯一的包名

  • 推荐的做法是使用倒置的域名来确保唯一性
  • 子包就可以根据功能自行命名

要注意不要和java.lang包的类重名
要注意也不要和JDK常用类重名

编译与运行

假设我们创建了如下的目录结构:

1
2
3
4
5
6
7
8
9
10
work
├── bin
└── src
└── com
└── itranswarp
├── sample
│ └── Main.java
└── world
└── Person.java

其中,bin目录用于存放编译后的class文件,src目录按包结构存放Java源码

首先,确保当前目录是work目录,即存放srcbin的父目录:

1
2
$ ls
bin src

然后,编译src目录下的所有Java文件:
1
$ javac -d ./bin src/**/*.java

  • 命令行-d指定输出的class文件存放bin目录,后面的参数src/**/*.java表示src目录下的所有.java文件,包括任意深度的子目录。
  • 注意:Windows不支持**这种搜索全部子目录的做法,所以在Windows下编译必须依次列出所有.java文件:
    1
    C:\work> javac -d bin src\com\itranswarp\sample\Main.java src\com\itranswarp\world\Persion.java

如果编译无误,则javac命令没有任何输出。可以在bin目录下看到如下class文件:

1
2
3
4
5
6
7
bin
└── com
└── itranswarp
├── sample
│ └── Main.class
└── world
└── Person.class

现在,我们就可以直接运行class文件了。根据当前目录的位置确定classpath,例如,当前目录仍为work,则classpath为bin或者./bin
1
2
$ java -cp bin com.itranswarp.sample.Main 
Hello, world!

作用域

public

定义为publicclassinterface可以被其他任何类访问

定义为publicfieldmethod可以被其他类访问,前提是首先有访问class权限

private

定义为privatefieldmethod无法被其他类访问、

  • private访问权限被限定在class内部,而且与方法声明顺序无关
  • 由于Java支持嵌套类,如果一个类内部还定义了嵌套类,那么,嵌套类拥有访问private的权限
    • 定义在一个class内部的class称为嵌套类(nested class),Java支持好几种嵌套类

protected

protected作用于继承关系

定义为protected的字段和方法可以被子类访问,以及子类的子类

package

包作用域是指一个类允许访问同一个package的没有publicprivate修饰的class,以及没有publicprotectedprivate修饰的字段和方法

  • 只要在同一个包,就可以访问package权限的classfieldmethod
  • 注意,包名必须完全一致,包没有父子关系,com.apachecom.apache.abc是不同的包

内部类

Inner class

有一种类,它被定义在另一个类的内部,所以称为内部类(Nested Class)。

  • Inner Class的实例不能单独存在,必须依附于一个Outer Class的实例
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    public class Main {
    public static void main(String[] args) {
    Outer outer = new Outer("Nested"); // 实例化一个Outer
    Outer.Inner inner = outer.new Inner(); // 实例化一个Inner
    inner.hello();
    }
    }

    class Outer {
    private String name;

    Outer(String name) {
    this.name = name;
    }

    class Inner {
    void hello() {
    System.out.println("Hello, " + Outer.this.name);
    }
    }
    }

    Anonymous Class

    还有一种定义Inner Class的方法,它不需要在Outer Class中明确地定义这个Class,而是在方法内部,通过匿名类(Anonymous Class)来定义
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    public class Main {
    public static void main(String[] args) {
    Outer outer = new Outer("Nested");
    outer.asyncHello();
    }
    }

    class Outer {
    private String name;

    Outer(String name) {
    this.name = name;
    }

    void asyncHello() {
    Runnable r = new Runnable() {
    @Override
    public void run() {
    System.out.println("Hello, " + Outer.this.name);
    }
    };
    new Thread(r).start();
    }
    }
  • 观察asyncHello()方法,我们在方法内部实例化了一个RunnableRunnable本身是接口,接口是不能实例化的,所以这里实际上是定义了一个实现了Runnable接口的匿名类,并且通过new实例化该匿名类,然后转型为Runnable。在定义匿名类的时候就必须实例化它,定义匿名类的写法如下:
    1
    2
    3
    Runnable r = new Runnable() {
    // 实现必要的抽象方法...
    };
  • 匿名类和Inner Class一样,可以访问Outer Class的private字段和方法
  • 如果有多个匿名类,Java编译器会将每个匿名类依次命名为Outer$1Outer$2Outer$3

除了接口外,匿名类也完全可以继承自普通类

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

public class Main {
public static void main(String[] args) {
HashMap<String, String> map1 = new HashMap<>();
HashMap<String, String> map2 = new HashMap<>() {}; // 匿名类!
HashMap<String, String> map3 = new HashMap<>() {
{
put("A", "1");
put("B", "2");
}
};
System.out.println(map3.get("A"));
}
}

Static Nested Class

一种内部类和Inner Class类似,但是使用static修饰,称为静态内部类(Static Nested Class)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class Main {
public static void main(String[] args) {
Outer.StaticNested sn = new Outer.StaticNested();
sn.hello();
}
}

class Outer {
private static String NAME = "OUTER";

private String name;

Outer(String name) {
this.name = name;
}

static class StaticNested {
void hello() {
System.out.println("Hello, " + Outer.NAME);
}
}
}

static修饰的内部类和Inner Class有很大的不同,它不再依附于Outer的实例,而是一个完全独立的类,因此无法引用Outer.this,但它可以访问Outerprivate静态字段和静态方法。

classpath和jar

classpath

classpath是JVM用到的一个环境变量,它用来指示JVM如何搜索class

  • 编译后.class文件才是真正可以被JVM执行的字节码

classpath就是一组目录的集合,它设置的搜索路径与操作系统相关

  • 在Windows系统上,用;分隔,带空格的目录用""括起来
    1
    C:\work\project1\bin;C:\shared;"D:\My 	Documents\project1\bin"
  • 在Linux系统上,用:分隔
    1
    /usr/shared:/usr/local/bin:/home/liaoxuefeng/bin

现在我们假设classpath.;C:\work\project1\bin;C:\shared,当JVM在加载abc.xyz.Hello这个类时,会依次查找

  • <当前目录>\abc\xyz\Hello.class

  • C:\work\project1\bin\abc\xyz\Hello.class

  • C:\shared\abc\xyz\Hello.class
    注意到.代表当前目录

如果JVM在某个路径下找到了对应的class文件,就不再往后继续搜索。如果所有路径下都没有找到,就报错。

classpath的设定方法有两种:

  • 在系统环境变量中设置classpath环境变量

    • 污染整个系统环境
  • 启动JVM时设置classpath变量

    • java命令传入-classpath-cp参数
      1
      java -classpath .;C:\work\project1\bin;C:\shared abc.xyz.Hello
      1
      java -cp .;C:\work\project1\bin;C:\shared abc.xyz.Hello
    • 没有设置系统环境变量,也没有传入-cp参数,那么JVM默认的classpath.,即当前目录
      1
      java abc.xyz.Hello

在IDE中运行Java程序,IDE自动传入的-cp参数是当前工程的bin目录和引入的jar包

  • ==不要把任何Java核心库添加到classpath中!JVM根本不依赖classpath加载核心库!==

更好的做法是,不要设置classpath!默认的当前目录.对于绝大多数情况都够用了。

如果指定的.class文件不存在,或者目录结构和包名对不上,均会报错

jar包

jar包可以把package组织的目录层级,以及各个目录下的所有文件(包括.class文件和其他文件)都打成一个jar文件

jar包里的第一层目录,不能是bin

jar包还可以包含一个特殊的/META-INF/MANIFEST.MF文件,MANIFEST.MF是纯文本,可以指定Main-Class和其它信息

JVM会自动读取这个MANIFEST.MF文件,如果存在Main-Class,我们就不必在命令行指定启动的类名,而是用更方便的命令:

1
java -jar hello.jar

class版本

我们通常说的Java 8,Java 11,Java 17,是指JDK的版本,也就是JVM的版本,更确切地说,就是java.exe这个程序的版本

  • 而每个版本的JVM,它能执行的class文件版本也不同
  • 只要看到UnsupportedClassVersionError就表示当前要加载的class文件版本超过了JVM的能力,必须使用更高版本的JVM才能运行

指定编译输出有两种方式,一种是在javac命令行中用参数--release设置:

1
2
$ javac --release 11 Main.java

  • 参数--release 11表示源码兼容Java 11,编译的class输出版本为Java 11兼容,即class版本55。

第二种方式是用参数--source指定源码版本,用参数--target指定输出class版本

1
$ javac --source 9 --target 11 Main.java

  • 上述命令如果使用Java 17的JDK编译,它会把源码视为Java 9兼容版本,并输出class为Java 11兼容版本

注意--release参数和--source --target参数只能二选一,不能同时设置
指定版本如果低于当前的JDK版本,会有一些潜在的问题

==如果使用—release 11则会在编译时检查该方法是否在Java 11中存在==

如果运行时的JVM版本是Java 11,则编译时也最好使用Java 11,而不是用高版本的JDK编译输出低版本的class

如果使用javac编译时不指定任何版本参数,那么相当于使用--release 当前版本编译,即源码版本和输出版本均为当前版本

在开发阶段,多个版本的JDK可以同时安装,当前使用的JDK版本可由JAVA_HOME环境变量切换

在编译的时候,如果用--source--release指定源码版本,则使用指定的源码版本检查语法

模块

在Java 9之前,一个大型Java程序会生成自己的jar文件,同时引用依赖的第三方jar文件,而JVM自带的Java标准库,实际上也是以jar文件形式存放的,这个文件叫rt.jar,一共有60多M

如果是自己开发的程序,除了一个自己的app.jar以外,还需要一堆第三方的jar包,运行一个Java程序,一般来说,命令行写这样:

1
java -cp app.jar:a.jar:b.jar:c.jar com.liaoxuefeng.sample.Main

==注意:JVM自带的标准库rt.jar不要写到classpath中,写了反而会干扰JVM的正常运行==

  • 如果漏写了某个运行时需要用到的jar,那么在运行期极有可能抛出ClassNotFoundException
  • jar只是用于存放class的容器,它并不关心class之间的依赖

如果a.jar必须依赖另一个b.jar才能运行,那我们应该给a.jar加点说明啥的,让程序在编译和运行的时候能自动定位到b.jar,这种自带依赖关系class容器就是模块

从Java 9开始,原有的Java标准库已经由一个单一巨大的rt.jar分拆成了几十个模块,这些模块以.jmod扩展名标识,可以在$JAVA_HOME/jmods目录下找到它们:

  • java.base.jmod
  • java.compiler.jmod
  • java.datatransfer.jmod
  • java.desktop.jmod

这些.jmod文件每一个都是一个模块,模块名就是文件名

  • 模块java.base对应的文件就是java.base.jmod
  • 模块之间的依赖关系已经被写入到模块内的module-info.class文件了

所有的模块直接或间接地依赖java.base模块,只有java.base模块不依赖任何模块,它可以被看作是“根模块”,好比所有的类都是从Object直接或间接继承而来

模块支持多版本,即在同一个模块中可以为不同的JVM提供不同的版本

编写模块

oop-module工程为例,它的目录结构如下:

1
2
3
4
5
6
7
8
9
10
oop-module
├── bin
├── build.sh
└── src
├── com
│ └── itranswarp
│ └── sample
│ ├── Greeting.java
│ └── Main.java
└── module-info.java

其中,bin目录存放编译后的class文件,src目录存放源码,按包名的目录结构存放,仅仅在src目录下多了一个module-info.java这个文件,这就是模块的描述文件

  • 在这个模块中,它长这样:

    1
    2
    3
    4
    module hello.world {
    requires java.base; // 可不写,任何模块都会自动引入java.base
    requires java.xml;
    }

    其中,module是关键字,后面的hello.world是模块的名称,它的命名规范与包一致。花括号的requires xxx;表示这个模块需要引用的其他模块名。除了java.base可以被自动引入外,这里我们引入了一个java.xml的模块

  • 当我们使用模块声明了依赖关系后,才能使用引入的模块

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    package com.itranswarp.sample;

    // 必须引入java.xml模块后才能使用其中的类:
    import javax.xml.XMLConstants;

    public class Main {
    public static void main(String[] args) {
    Greeting g = new Greeting();
    System.out.println(g.hello(XMLConstants.XML_NS_PREFIX));
    }
    }

    如果把requires java.xml;module-info.java中去掉,编译将报错。可见,模块的重要作用就是声明依赖关系

创建模块

1.我们把工作目录切换到oop-module,在当前目录下编译所有的.java文件,并存放到bin目录下,命令如下:

1
$ javac -d bin src/module-info.java src/com/itranswarp/sample/*.java

如果编译成功,现在项目结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
oop-module
├── bin
│ ├── com
│ │ └── itranswarp
│ │ └── sample
│ │ ├── Greeting.class
│ │ └── Main.class
│ └── module-info.class
└── src
├── com
│ └── itranswarp
│ └── sample
│ ├── Greeting.java
│ └── Main.java
└── module-info.java

2.我们需要把bin目录下的所有class文件先打包成jar,在打包的时候,注意传入--main-class参数,让这个jar包能自己定位main方法所在的类:

1
$ jar --create --file hello.jar --main-class com.itranswarp.sample.Main -C bin .

3.继续使用JDK自带的jmod命令把一个jar包转换成模块:

1
$ jmod create --class-path hello.jar hello.jmod

于是,在当前目录下我们又得到了hello.jmod这个模块文件,这就是最后打包出来的传说中的模块

运行模块

要运行一个jar,我们使用java -jar xxx.jar命令。要运行一个模块,我们只需要指定模块名。试试:

1
$ java --module-path hello.jmod --module hello.world

结果是一个错误:
1
2
Error occurred during initialization of boot layer
java.lang.module.FindException: JMOD format not supported at execution time: hello.jmod

原因是.jmod不能被放入--module-path。换成.jar就没问题了:
1
2
$ java --module-path hello.jar --module hello.world
Hello, xml!

那我们辛辛苦苦创建的hello.jmod有什么用?答案是我们可以用它来打包JRE

打包JRE

JRE自身的标准库已经分拆成了模块,只需要带上程序用到的模块,其他的模块就可以被裁剪掉

  • 并不是说把系统安装的JRE给删掉部分模块,而是复制一份JRE,但只带上用到的模块
    1
    $ jlink --module-path hello.jmod --add-modules java.base,java.xml,hello.world --output jre/
  • 我们在--module-path参数指定了我们自己的模块hello.jmod,然后,在--add-modules参数中指定了我们用到的3个模块java.basejava.xmlhello.world,用,分隔。最后,在--output参数指定输出目录

在当前目录下,我们可以找到jre目录,这是一个完整的并且带有我们自己hello.jmod模块的JRE

要分发我们自己的Java应用程序,只需要把这个jre目录打个包给对方发过去,对方直接运行上述命令即可,既不用下载安装JDK,也不用知道如何配置我们自己的模块,极大地方便了分发和部署

访问权限

class的这些访问权限只在一个模块内有效,模块和模块之间,例如,a模块要访问b模块的某个class,必要条件是b模块明确地导出了可以访问的包

举个例子:我们编写的模块hello.world用到了模块java.xml的一个类javax.xml.XMLConstants,我们之所以能直接使用这个类,是因为模块java.xmlmodule-info.java中声明了若干导出:

1
2
3
4
5
6
module java.xml {
exports java.xml;
exports javax.xml.catalog;
exports javax.xml.datatype;
...
}

只有它声明的导出的包,外部代码才被允许访问。换句话说,如果外部代码想要访问我们的hello.world模块中的com.itranswarp.sample.Greeting类,我们必须将其导出:
1
2
3
4
5
6
module hello.world {
exports com.itranswarp.sample;

requires java.base;
requires java.xml;
}

因此,模块进一步隔离了代码的访问权限

0%