_little-star_

学习的博客

0%

[TOC]

第一章 概述

在这里插入图片描述


1.1.1 、概念、组成、功能和分类

  1. 计算机网络概念:

    一个将分散的、具有独立功能的计算机系统,通过通信设备(交换机、路由器)与线路连接起来,由功能完善的软件实现资源共享和信息传递的系统

    计算机网络是互连的、自治的计算机集合

    image-20210402030633726

  2. 计算机网络的功能

    1. 数据通信(连通性)
    2. 资源共享(硬件、软件、数据,三大资源共享)
    3. 分布式处理(多台计算机各自承担同一工作任务的不同部分 如:Hadoop平台)
    4. 提高可靠性(替代机)
    5. 负载均衡

    image-20210402031009538

  3. 组成

    1. 组成部分(硬件、软件、协议)

      1. 硬件:主机(端系统),链路(双绞线、光纤),通信设备(路由器,交换机)
      2. 软件:QQ,微信等
      3. 协议

      image-20210402031120533

    2. 工作方式

      1. 边缘部分:用户直接使用(C/S方式,P2P方式)
      2. 核心部分:为边缘部分服务(网络,路由器,交换机)

      image-20210402031159369

    3. 功能组成

      1. 通信子网:实现数据通信(OSI上三层)

      2. 资源子网:实现资源共享/数据处理(OSI下三层)

        传输层:是资源子网和通信子网的接口

      image-20210402031240261

    4. 计算机网络的分类

      1. 按分布范围分

        • 广域网(WAN,交换技术)

        • 城域网(MAN)

        • 局域网(LAN,广播技术)

        • 个人区域网(PAN)

      2. 按使用者分

        • 公用网
        • 专用网
      3. .按交换技术分

        • 电路交换
        • 报文交换
        • 分组交换
      4. 按拓扑结构分

        • 总线型
        • 星型
        • 环形
        • 网状型(常用于广域网)
      5. 按照传输技术分

        • 广播式网络(共享公共通信信道)
        • 点对点网络(使用分组存储转发和路由选择机制)

      image-20210402031749128

脑图:

在这里插入图片描述

1.1.2、 标准化工作及相关组织

  1. 标准化工作

    1. 标准的分类

      1. 法定标准

        由权威机构指定的正式的、合法的标准

        OSI

      2. 事实标准

        某些公司的产品在竞争中占据了主流,时间长了,这些产品中的协议和技术就成了标准

        TCP/IP

  2. RFC(Request For Comments)——因特网标准的形式

RFC要上升为因特网正式标准的四个阶段

  1. 因特网草案(Internet Draft)

    这个阶段还不是RFC文档,只是一个构思

  2. 建议标准(Proposed Standard)

    - 从这个阶段开始成为RFC文档
  3. 草案标准(Draft Standard)(现取消)

    • IETF、IAB审核
  4. 因特网标准(Internet Standard)

  5. 标准化工作的相关组织

    1. 国际标准化组织ISO

      OSI参考模型、HDLC协议

    2. 国际电信联盟ITU

      制定通信规则

    3. 国际电气电子工程师协会IEEE

      学术机构、IEEE802标准、5G

    4. Internet工程任务组IETF

      负责因特网相关标准的制定 RFC—XXXX

      image-20210405164611745

脑图:

image-20210405164728541

1.1.3、 计算机网络的相关性能指标

  1. 速率

    即数据率或称数据传输率或比特率

    连接在计算机网络上的主机在数字信道上传输数据位数的速率

    补充:

    ​ 速率的单位(千、兆、吉、太)

    ​ 1Tb/s=10^3Gb/s=10^6Mb/s=10^9kb/s=10^12b/s

    ​ 存储容量单位:

    ​ 1Btye=8 bit 1TB/s=2^10 GB/s=2^20 MB/s=2^30 KB/s=2^40 B/s

  2. 带宽

    原本指某个信号具有的频带宽度,即最高频率与最低频率之差,单位是赫兹(Hz)

    在计算机网络中,带宽用来表示网络的通信线路传送数据的能力

    通常是指单位时间内从网络的某一点到另一点所能通过的“最高数据率”

    单位与速率的单位相同。(比特每秒、b/s,kb/s,Mb/s,Gb/s)

    可以理解为:网络设备所支持的最高速度,即:发送的速率

  3. 吞吐量

    表示在单位时间内通过某个网络(信道、接口)的数据量。单位b/s,kb/s,Mb/s等

    吞吐量受网络的带宽或网络的额定速率的限制

    所有的链路加一起才是本次网络的数据的真正吞吐量

  4. 时延

    指数据(报文/分组/比特流)从网络(或链路)的一端传送到另一端所需时间。
    也叫延迟或者迟延,单位是s

    1. 发送时延/传输时延 = 数据长度/信道带宽(发送速率)

      从发送分组的第一个比特算起,到该分组的最后一个比特发送完毕所需的时间。

    2. 传播时延 = 信道长度/电磁波在信道上的传播速率;
      取决于电磁波传播速度和链路长度

      此处注意:传输时延传播时延的区别

      • 传输时延:发生在主机内部,一般是发生在网络适配器当中,发生在机器内部的发送器里面
      • 传播时延:发生在机器外部,发送在信道上
    3. 排队时延

      等待输出/输入链路可用

      路由器的缓存空间那里

    4. 处理时延

      • 检错找出口
    5. 时延抖动

      时延的不均匀性

    注意:高速链路(提高发送速率/信号带宽)只是降低了发送时延,对传播时延和传播速率没有影响

  5. 时延带宽积 = 传播时延 * 带宽

    时延带宽积又称为比特位长度的链路长度

    即:某段链路现在有多少比特,有“容量”的意思

    image-20210405173609630

  6. 往返时延RTT

    发送发发送数据开始,到发送方收到接收方的确认(接收方收到数据后立即发送确认)总共经历的时延RTT越大,在收到确认之前,可以发送的数据越多

    RTT包括

    1. 往返传播时延 = 传播时延 * 2
    2. 末端处理时间

    注意:RTT不包括传输时延

  7. 利用率

    • 信道利用率 = 有数据通过时间 / (有 + 无)数据通过的时间

    • 网络利用率 = 信道利用率加权平均值

    • 利用率如果趋近于1,时延会急剧增大

      这些性能指标可以分为三类

      1. 速率、带宽、吞吐率
      2. 时延、时延带宽积、往返时延RTT
      3. 利用率(利用率如果趋近于1,时延会急剧增大)

脑图:

11

1.2.1、 分层结构、协议、接口、服务

  1. 为什么要分层?

    发送文件前要完成的工作:

    1. 发起通信的计算机必须将数据通信的通路进行激活。
    2. 要告诉网络如何识别目的主机。
    3. 发起通信的计算机要查明目的主机是否开机,并且与网络连接正常。
    4. 发起通信的计算机要弄清楚,对方计算机中文件管理程序是否已经做好准备工作。
    5. 确保差错和意外可以解决。
    6. …….

    所以,在发送文件的过程中,会出现很多问题,需要把这些问题分成一个个小问题,然后解决

  2. 怎么分层

    • 实体、对等实体
    • 对等实体之间才会有协议
    • 上下层之间的接口
    • 下层给上层提供服务
  3. 分层的基本原则

    1. 各层之间相互独立,每层只实现一种相对独立的功能
    2. 每层之间的界面自然清晰,易于理解,相互交流尽可能少
    3. 结构上可分隔开。每层都采用最合适的技术来实现
    4. 保持下层上层的独立性,上层单向使用下层提供的服务
    5. 整个分层结构应该促进标准化工作。
  4. 正式认识分层结构

    1. 实体:第n层中的活动元素称为n层实体。同一层的实体叫对等实体
    2. 协议:为进行网络中的对等实体数据交换为建立的规则、标准或约定称为网络协议。【水平
      • 语法:规定传输数据的格式
      • 语义:规定所要完成的功能
      • 同步:规定各种操作的顺序
    3. 接口(访问服务点SAP):上层使用下层服务的入口。
    4. 服务:下层为相邻上层提供的功能调用。【垂直
    5. SDU、PCI、PDU
      • SDU服务数据单元:为完成用户所需要的功能而应传输的数据。
      • PCI协议控制信息:控制协议操作的信息。
      • PDU协议数据单元:对等层次之间传送的数据单位。
      • PDU=SDU+PCI

    image-20210405222724021

总结:

  • 网络体系结构是从功能上描述计算机网络结构。
  • 计算机网络体系结构简称网络体系结构是分层结构
  • 每层遵循某个/些网络协议以完成本层功能。
  • 计算机网络体系结构是计算机网络的各层及其协议的集合。
  • 第n层在向n+1层提供服务时,此服务不仅包含第n层本身的功能,还包含由下层服务提供的功能
  • 仅仅相邻层间有接口,且所提供服务的具体实现细节对上一层完全屏蔽。
  • 体系结构是抽象的,而实现是指能运行的一些软件和硬件。

脑图:

image-20210405223601395

1.2.2、 OSI参考模型

  1. 计算机网络分层结构

    • 7层OSI参考模型(法定标准)
    • 4层TCP/IP参考模型(事实标准)
    • 5层体系结构(主要是使我们学习计算机网络更加清晰,不是事实标准,也不是法定标准)
  2. OSI参考模型是怎么来的?
    提出第一个网络体系结构:SNA(IBM公司)之后,很多公司和机构纷纷提出自己的网络体系结构:DEC公司的DNA,美国国防部的TCP/IP。为了支持异构网络系统的互联互通,国际标准化组织(ISO)于1984年提出开放系统互连(OSI)参考模型。但是,理论成功,市场失败。

  3. OSI7层结构

    1. 物理层、数据链路层、网络层、传输层、会话层、表示层、应用层
      物、链、网、输、会、示、用
      物联网淑慧试用
    2. 资源子网(数据处理):上三层:会话层、表示层、应用层
    3. 通信子网(数据通信):下三层:物理层、数据链路层、网络层
  4. OSI参考模型解释通信过程

    在这里插入图片描述

    上四层实现的是端到端的通信
    下三层实现的是点到点的通信

    在这里插入图片描述

  5. 各层功能与协议

    1. 应用层
      用户与网络的界面,所有能和用户交互产生网络流量的程序
      典型应用层服务:

      • 文件传输(FTP)
      • 电子邮件(SMTP)
      • 万维网(HTTP)等
    2. 表示层
      用于处理在两个通信系统中交换信息的表示方式(语法和语义)

      • 功能1:数据格式变换(翻译官)
      • 功能2:数据加密和解密
      • 功能3:数据压缩和解压缩

      没有专门的协议,硬要说的话:主要协议有JPEG、ASCII

    3. 会话层
      向表示层实体/用户进程提供建立连接并在连接上有序传输数据
      这是会话,也是建立同步(SYN)

      • 功能1:建立、管理、终止会话
      • 功能2:使用校验点可以使会话在通信失效时,从校验点/同步点继续恢复通信,实现数据同步。(适用于传输大文件)
        主要协议:ADSP、ASP
    4. 传输层
      负责两个不同主机中两个进程的通信,即端到端的通信。传输单位是报文段或用户数据报。(功能:可差流用)

      • 功能1:可靠传输、不可靠传输
      • 功能2:差错控制
      • 功能3:流量控制
      • 功能4:复用分用
        • 复用:多个应用层进程可同时使用下面运输层的服务。
        • 分用:运输层把收到的信息分别交付给上面应用层中相应的进程

      主要协议:TCP、UDP

    5. 网络层(最重要)
      主要任务是把分组从源端传到目的端,为分组交换网上的不同主机提供通信服务。

      网络层传输单位是数据报

      数据报过长时,会将数据报切割成一个个小的分组,再放到链路上传递

      • 功能1:路由选择(最佳路径)
      • 功能2:流量控制(协调发送端和接收端的速度)
      • 功能3:差错控制(奇偶校验等)
      • 功能4:拥塞控制
        若所有结点都来不及接收分组,而要丢弃大量分组的话,网络就处于拥塞状态。因此要采取一定措施缓解这种拥塞。

      主要协议:IP、IPX、ICMP、IGMP、ARP、RARP、OSPF

    6. 数据链路层

      主要任务是把网络层传下来的数据报组装成帧

      数据链路层/链路层的传输单位是

      • 功能1:成帧(定义帧的开始和结束)
      • 功能2:差错控制(帧错+位错)
      • 功能3:流量控制
      • 功能4:访问(接入)控制 控制对信道的访问

      主要协议:SDLC、HDLC、PPP、STP

    7. 物理层

      傻瓜层

      把比特流转成电信号的形式,然后放到链路上面进行传输,不需要对数据进行改动。
      主要任务是在物理媒体上实现比特流的透明传输,传输单位是比特
      透明传输:指不管所传数据是什么样的比特组合,都应当能够在链路层上传送。

      • 功能1:定义接口特性
      • 功能2:定义传输模式(单工、半双工、双工)
      • 功能3:定义传输速率
      • 功能4:比特同步
      • 功能5:比特编码

      主要协议:Rj45、802.3

脑图:

image-20210405234108810

1.2.3、 TCP/IP参考模型和5层参考模型

先有TCP/IP协议栈再有TCP/IP参考模型

image-20210405233009957

  1. OSI参考模型与TCP/IP参考模型相同点

    1. 都分层
    2. 基于独立的协议栈的概念
    3. 都可以实现异构网络互联
  2. OSI参考模型与TCP/IP参考模型的不同点

    1. OSI定义三点:服务、协议、接口

    2. OSI先出现,参考模型先于协议发明,不偏向特定协议

    3. TCP/IP设计之初就考虑到异构网互联问题,将IP作为重要的层次

    4. OSI VS TCP/IP

      OSI TCP/IP
      网络层 无连接+面向连接 无连接
      传输层 面向连接 无连接+面向连接

      面向连接:分为三个阶段

      1. 建立连接,发出一个建立连接的请求
      2. 连接成功之后,开始数据传输
      3. 数据传输完毕,释放连接

      无连接:直接进行数据传输

    5. 5层参考模型

      综合了OSI和TCP/IP的优点

      5层参考模型的分层及每层的功能:

      在这里插入图片描述

      5层参考模型的数据封装与解封装过程:

      在这里插入图片描述

1.3 第一章总结

在这里插入图片描述

第二章 物理层

在这里插入图片描述


第二章要学习的主要内容

  • 通信基础
  • 两个公式lim(考研重点)
  • 看图说话(数字信号的波形)
  • 传输介质
  • 物理层设备(中继器、集线器)

2.1.1、 物理层基本概念

  1. 物理层基本概念

    物理层解决如何在连接各种计算机的传输媒体上传输数据比特流,而不是指具体的传输媒体(传输媒体可以看做是第0层,要与物理层分开看)

    物理层的主要任务:确定与传输媒体接口有关的一些特性,定义标准

    物理层定义了哪些特性?

    • 机械特性
      定义物理连接的特性,规定物理连接时所采用的的规格、接口形状、引线数目引脚数量和排列情况
    • 电气特性
      规定传输二进制位时,线路上信号的电压范围、阻抗匹配、传输速率距离限制等。
    • 功能特性
      指明某条线上出现的某一电平表示何种意义,接口部件的信号线的用途
      比如:描述一个物理层接口引脚处于高电平时的含义。
    • 规程特性
      (过程特性)定义各条物理线路的工作规程和时序关系。

2.1.2 、数据通信基础知识

典型的数据通信模型:

在这里插入图片描述

  1. 数据通信相关术语

    通信的目的是传送消息

    • 数据:传送信息的实体,通常是有意义的符号序列。

    • 信号:数据的电气/电磁的表现,是数据在传输过程中的存在形式

      • 数字信号:代表消息的参数取值是离散
      • 模拟信号:代表消息的参数取值是连续
    • 信源:产生和发送数据的源头

    • 信宿:接收数据的终点

    • 信道:信号的传输媒介。一般用来表示向某一个方向传送信息的介质,因此一条通信线路往往包含一条发送信道和一条接收信道.

      信道分类

      • 传输信号分:数字信道(传送数字信号)、模拟信道(传送模拟信号)
      • 传输介质分:无线信道、有线信道
  2. 三种通信方式

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

    • 单工通信
      只有一个方向的通信而没有反方向的交互,仅需要一条信道
    • 半双工通信
      通信的双方都可以发送或接收信息,但任何一方都不能同时发送和接收,需要两条信道
    • 全双工通信
      通信双方可以同时发送和接受信息,也需要两条信道
  3. 两种数据传输方式

    数据在信道上的传送方式

    • 串行传输
      速度慢,费用低、适合远距离
    • 并行传输
      速度快、费用高、适合近距离
      用于计算机内部数据传输(打印机,扫描机)

2.1.3 、码元、波特、速率、带宽

  1. 码元
    指用一个固定时长信号波形(数字脉冲),代表不同离散数值的基本波形,是数字通信中数字信号的计量单位,这个时长内的信号称为k进制码元,而该时长称为码元宽度。当码元的离散状态有M个时(M>2),此时码元为M进制码元。

    1码元可以携带多个比特的信息量。例如,在使用二进制编码时,只有两种不同的码元,一种代表0状态,另一种表示1状态。而四进制码元,一个码元可以携带2bit信息。(00/01/10/11)

    image-20210406001148097

  2. 速率、波特

    速率也叫数据率,是指数据的传输速率,表示单位时间内传输的数据量。可以用码元传输速率信息传输速率表示。

    1. 码元速率:(码元速率、波形速率、调制速率符号速率等等)
      它表示单位时间内数字通信系统所传输的码元个数(也可称为脉冲个数信号变化的次数),单位是波特(Baud)。1波特表示数字通信系统每秒传输一个码元。这里的码元可以是多进制的,也可以是二进制的,但是码元速率与进制数无关

      即:1s传输了多少码元

    2. 信息速率:
      表示单位时间内数字通信系统传输的二进制码元个数(即比特数)
      单位是比特/秒(b/s)

      即:1s传输多少比特

      关系:

      *若一个码元携带n bit的信息量,则M Buad的码元传输速率所对应的信息传输速率为Mn bit/s**

      即:*信息传输速率 = n bit * 码元传输速率*

      系统传输的是比特流,通常比较的的是信息传输速率

  3. 带宽
    表示在单位时间内从网络中的某一点到另一点所能通过的“最高数据率”,常用来表示网络的通信线路所能传输数据的能力。单位是b/s。

  4. 相关习题:

    image-20210406002432454

2.1.4、 奈氏准则和香农定理

  1. 失真:

    在这里插入图片描述

  2. 影响失真的因素:

    1. 码元传输速率(正相关)
      速率越快,信号失真程度越严重
    2. 信号传输距离(正相关)
      距离越远,衰减越久,干扰越久,对信号影响越大
    3. 噪声干扰(负相关)
      噪声越多,信号失真程度越大
    4. 传输媒体质量(负相关)
      传输媒体质量越差,越失真
  3. 失真的一种现象——码间串扰

    在这里插入图片描述

    信道带宽:信道能通过的最高频率和最低频率之差

    上图的信道带宽是:3300Hz-300Hz=3000Hz

    1. 速率过低为什么不能通过信道?

      速度太低,信号在信道上非常容易衰减

    2. 速度过高为什么不能通过信道?

      振动频率太快了,接收端在接收时区分不出来波形之间的差异(即:码间串扰)

      码间串扰:接收端收到的信好波形失去了码元之间清晰界限的现象

  4. 奈氏准则(奈奎斯特定理)

    在理想低通(无噪声,带宽受限)条件下,为了避免码间串扰,极限码元传输速率为2W Baud,W是信道带宽,单位是Hz在奈氏准则和香农定理中带宽的单位是Hz(不是bit/s)

    理想低通信道下的极限传输率=2Wlog2V(b/s)
    V:码元的种数/码元的离散电平数目

    根据奈氏准则可以得到以下4条结论:

    1. 在任何信道中,码元传输的速率是有上限的。若传输速率超过此上限,就会出现严重的码间串扰问题,使接收端对码元的完全正确识别成为不可能。
    2. 信道的频带越宽(即:能通过的信号高频分量越多),就可以用更高的速率进行码元的有效传输
    3. 奈氏准则给出了码元传输速率的限制,但并没有对信息传输速率给出限制
    4. 由于码元的传输速率受奈氏准则的制约,所以要提高数据的传输速率,就必须设法使每个码元能携带更多个比特的信息量,这就需要采用多元制的调制方法

    例:在无噪声的情况下,若某通信链路的带宽为3kHz,采用4个相位,每个相位具有4种振幅的QAM调制技术,则该通信链路的最大数据传输率是多少?

    分析:调相与调幅相结合

    解:

     信号有4 * 4=16种变化,则V=16;

    ​ 即:极限传输速率=2Wlog2(V)(b/s)= 2 * 3000 * log2(16)(b/s)= 24000(b/s)

  5. 香农定理

    噪声存在于所有的电子设备和通信系统中。由于噪声随机产生,它的瞬时值有时会很大,因此噪声会使接收端对码元的判决产生错误。但是噪声的影响是相对的,若信号较强,那么噪声影响相对较小。因此,信噪比就很重要。

    信噪比 = 信号的平均功率/噪声的平均功率,常记为S/N,并用分贝(dB)作为度量单位,即:
    信噪比(dB)=10 * log10(S/N)

    两者在数值上等价。

    而往往信噪比的这个值会很大,所以一般取对数。取了对数这个严格来说就叫做声强级,取对数实际上获得了次方的值,进而得到了声音(信息)的强度。但是两个东西表示的是同一信息。声强级是为了方便读数理解而对信噪比进行的变换(类似科学记数法)

    香农定理:

    带宽受限有噪声的信道中,为了不产生误差,信息的数据传输速率有上限值。

    信道的极限数据传输速率=W * log2(1+S/N) (b/s)

    S:信道所传信号的平均功率

    N:信道内的高斯噪声功率

    S/N即:信噪比

    W:带宽(Hz)

    image-20210406010014399

    根据香农定理可以得到以下5条结论:

    1. 信道的带宽或信道中的信噪比越大,则信息的极限传输速率就越高
    2. 对一定的传输带宽和一定的信噪比,信息传输速率的上限就确定了
    3. 只要信息的传输速率低于信道的极限传输速率,就一定能找到某种方法实现无差错传输
    4. 香农定理得出的为极限信息传输速率,实际信道能达到的传输速率要比它低不少
    5. 从香农定理可以看出,若信道带宽W或信噪比S/N没有上限(不可能),那么信道的极限信息传输速率也就没有上限。

    例题:

    image-20210406010235606

  6. 奈氏准则和香农定理的联系与区别:

    奈氏准则(内忧,码间串扰)香农定理(外患,外界噪声)
    有时候既需要使用奈氏准则,也需要使用香农定理,这时,需要取两者中的最小值

    image-20210406010414253

    例:二进制信号在信噪比为127∶1的4kHz信道上传输,最大的数据速率可达到多少?
    解:

    由奈氏准则知:最大数据传输速率为=2 * W * log2(V)
                              =2 * 4000 * 1=8000(b/s)
    有香农定理知:最大数据传输速率为=W * log2(1+S/N)
                              =4000 * log2(1+127)
                              =28000(b/s)
    最大的数据传输率为8000(b/s)

2.1.5 、编码与调制

  1. 基带信号与宽带信号

    信道上传送的信号

    1. 基带信号:将数字信号1和0直接用两种不同的电压表示,再送到数字信道上去传输(基带传输
      基带信号是来自信源的信号,就像计算机输出的代表各种文字或者图像文件的数据信号都属于基带信号。
      基带信号就是发出的直接表达了要传输的信息的信号,比如我们说话的声波就是基带信号。
    2. 宽带信号:将基带信号进行调制后形成的频分复用模拟信号,再传到模拟信道上去传输(宽带传输
      把基带信号经过载波调制后,把信号的频率范围搬迁到较高的频段一遍在信道中传输(即:仅在一段频率范围内能够通过信道)
      在传输距离较时,计算机网络采用基带传输方式(近距离衰减小,从而信号内容不易发生变化)
      在传输距离较时,计算机网络采用宽带传输方式(近距离衰减大,即使信号变化大也能最后过滤出来基带信号)

    image-20210407105303305

  2. 编码与调制

    数据–>数字信号 编码

    数据–>模拟信号 调制

    数字数据–数字发送器–>数字信号 编码

    数字数据–调制器–>模拟信号 调制

    模拟数据–PCM编码器–>数字信号 编码

    模拟数据–放大器调制器–>模拟信号 调制

  3. 四种编码与调制方法

    1. 数字数据编码为数字信号
    2. 数字数据调制为模拟信号
    3. 模拟数据编码为数字信号
    4. 模拟数据调制为模拟信号
  4. 数字数据编码为数字信号

    1. 非归零编码【NRZ】

      编码方式:高1低0

      编码容易实现,但没有检错功能,且无法判断一个码元的开始和结束,以至于收发双方难以保持同步。

      发送端全1或0,接收端都不好识别,需要确定时间周期

      不常用

    2. 曼彻斯特编码

      综合归零编码、非归零编码、反向不归零编码的优缺点而形成的非常优秀的编码

      它可以把时钟信号和数据都放在一块,不需要额外的信道传输时钟信号,就可以实现自己本身的同步,即:自同步
      编码方式:

      • 将一个码元分成两个相同的间隔,前一个间隔为低电平后一个为高电平表示码元1;
      • 码元0则正好相反。也可以采用相反的规定。

      该编码的特点是:在每一个码元的中间出现电平跳变,位于中间的跳变既作为时钟信号(用于同步),
      又作为数据信号,但它所占的频带宽度是原始的基带宽度的两倍。每一个码元都被调成两个电平,所以数据传输速率只有调制速率的1/2。

    3. 差分曼彻斯特编码(常用于局域网传输)

      编码方式:同1异0

      其规则是:若码元为1,则前半个码元的电平与上一个码元的后半个码元的电平相同,若为0,则相反。

      该编码的特点是:
      在每个码元的中间,都有一次电平的跳转,可以实现自同步,且抗干扰性强于曼彻斯特编码(因为实现算法更复杂)

    4. 归零编码【RZ】

      编码方式:信号电平在一个码元之内都要回复到零的方式

      全零不容易识别

      不常用

    5. 反向不归零编码【NRZI】

      编码方式:信号电平翻转表示0,信号电平不变表示1

      发送端全0,接收端容易识别,发送端全1,接收端不好识别

    6. 4B/5B编码

      比特流中插入额外的比特以打破一连串的0或1,就是用5个比特来编码4个比特的数据,之后再传给接收方,因此称为4B/5B。编码效率为80%

      只采用16种5位码对应16种不同的4位码,其他的16种5位码作为控制码(帧的开始或结束,线路的状态信息等)或保留。

      image-20210407110138680

    前三种重点掌握,后三种了解即可

  5. 数字数据调制为模拟信号

    数据调制技术:
    在发送端将数字信号转化为模拟信号,而在接收端将模拟信号还原为数字信号,分别对应于调制解调器的调制和解调过程。

    • 2ASK 调幅
      低电平0没有幅动,高电平1有幅动
    • 2FSK 调频
      低电平0低频,高电平1高频
    • 2PSK 调相
      0对应一种波形,1对应一种波形
    • QAM 调幅+调相
      例如:某通信链路的波特率是1200Baud,采用4个相位,每个相位有4种振幅的QAM调制技术,则该链路的信息传输速率是多少?
      解:
      信号有4 * 4 = 16种变化
      信息传输速率 = W * log2(V)= 1200 * 4 = 4800(b/s)

    在这里插入图片描述

  6. 模拟数据编码为数字信号

    1. 计算机内部处理的是二进制数据,处理的都是数字音频,所以需要将模拟音频通过采样、量化转换成有限个数字表示的离散序列(即实现音频数字化)。

    2. 最典型的例子就是对音频信号进行编码的脉码调制(PCM脉码调制),在计算机应用中,能够达到最高保真水平的就是PCM编码,被广泛用于素材保存及音乐欣赏,CD、DVD以及我们常见的WAV文件中均有应用。

      它主要包括三步:抽样、量化、编码。

      1. 抽样

        对模拟信号周期性扫描,把时间上连续的信号变成离散的信号。

        为了使所得的离散信号能够无失真地代表被抽样的模拟数据,要使用采样定理进行采样。

        采样定理:(奈奎斯特采样定理)
        f采样频率 >= 2 * f信号最高频率
        (最高分波形上至少采样两个点)

      2. 量化

        把抽样取得的电平幅值按照一定的分级标度转化为对应的数字值,并取整数,这就把连续的电平幅值转换为离散的数字量。

      3. 编码

        把量化的结果转换为与之对应的二进制编码

        image-20210407110815400

  7. 模拟数据调制为模拟信号
    为了实现传输的有效性,可能需要较高的频率。这种调制方式还可以使用频分复用技术,充分利用带宽资源。在电话机和本地交换机所传输的信号是采用模拟信号传输模拟数据的方式;模拟的声音数据是加载到模拟的载波信号中传输的。

脑图:

在这里插入图片描述

2.2.1、 物理层传输介质

  1. 传输介质及分类

    1. 传输介质也称传输媒体/传输媒介,它就是数据传输系统中在发送设备和接收设备之间的物理通路

    2. 传输媒体并不是物理层

      传输媒体在物理层的下面,因为物理层是体系结构的第一层,因此有时称传输媒体为0层。在传输媒体中传输的是信号,但传输媒体并不知道所传输的信号代表什么意思。但物理层规定了电气特性,因此能够识别所传送的比特流。

    3. 如果称物理层是傻瓜,那么传输媒体连傻瓜都不如

    4. 传输媒体分类

      1. 导向性传输媒体

        电磁波被导向沿着固体媒体(铜线/光纤)传播

      2. 非导向性传输媒体

        自由空间,介质可以是空气、真空、海水等。

  2. 导向性传输介质

    1. 双绞线:是古老、又最常用的传输介质,它由两根采用一定规则并排绞合的、相互绝缘的铜导线组成。

      绞合可以减少相邻导线的电磁干扰。

      为了进一步提高抗电磁干扰能力,可在双绞线的外面再加上一个由金属丝编织成的屏蔽层,这就是屏蔽双绞线(STP)

      无屏蔽层的双绞线就称为非屏蔽双绞线(UTP)。

      image-20210409094235649

      特点:

      • 双绞线价格便宜,是最常用的传输介质之一,在局域网和传统电话网中普遍使用。
      • 模拟传输和数字传输都可以使用双绞线,其通信距离一般为几公里到数十公里。
      • 距离太远时,对于模拟传输,要用放大器放大衰减的信号;
      • 对于数字传输,要用中继器将失真的信号整形
    2. 同轴电缆

      同轴电缆由导体铜质芯线绝缘层网状编织屏蔽层塑料外层构成。

      按特性阻抗数值的不同,通常将同轴电缆分为两类:50欧姆同轴电缆和75欧姆同轴电缆。

      其中,50欧姆同轴电缆主要用于传送基带数字信号,又称为基带同轴电缆,它在局域网中得到广泛应用;75欧姆同轴电缆主要用于传送宽带信号,又称为宽带同轴电缆,它主要用于有线电视系统。

      image-20210409094427395

      双绞线和同轴电缆的区别

      由于外导体屏蔽层的作用,同轴电缆抗干扰特性比双绞线,被广泛用于传输较高速率的数据,其传输距离更远,但价格较双绞线

    3. 光纤

      1. 光纤通信就是利用光导纤维(简称光纤)传递光脉冲来进行通信。有光脉冲表示1,无光脉冲表示O。

        而可见光的频率大约是10^8MHz,因此光纤通信系统的带宽远远大于目前其他各种传输媒体的带宽。

      2. 光纤在发送端有光源,可以采用发光二极管或半导体激光器,它们在电脉冲作用下能产生出光脉冲,在接收端用光电二极管做成光检测器,在检测到光脉冲时可还原出电脉冲。

      3. 光纤主要由纤芯(实心的!)和包层构成,光波通过纤芯进行传导,包层较纤芯有较低的折射率。当光线从高折射率的介质射向低折射率的介质时,其折射角将大于入射角。因此,如果入射角足够大,就会出现全反射,即光线碰到包层时候就会折射回纤芯、这个过程不断重复,光也就沿着光纤传输下去。

        超低损耗,传送超远距离

        image-20210409095419124

      4. 分类

        1. 多模光纤

          传播过程会有损耗,传播过程中会受到噪声的影响,如果距离过远可能会出现较为严重的失真,适合近距离传输

        2. 单模光纤

          单模与多模光纤的比较一根光缆少则只有一根光纤,多则包括十至数百根光纤

          在这里插入图片描述

      5. 光纤的特点

        1. 传输损耗小,中继距离长,对远距离传输特别经济
        2. 抗雷电和电磁干扰性能好
        3. 无串音干扰,保密性好,也不易被窃听或截取数据
        4. 体积小,重量轻
  3. 非导向性传输介质

    1. 无线电波

      信号向所有方向传播

      较强穿透能力,可传远距离,广泛用于通信领域(手机通信)

    2. 微波

      信号固定方向传播

      微波通信频率较高、频段范围宽,因此数据率很高

      1. 地面微波接力通信

        中继站

      2. 卫星通信

        同步卫星起到了中继站的作用

        • 优点
          1. 通信容量大
          2. 距离远
          3. 覆盖广
          4. 广播通信和多址通信
        • 缺点
          1. 传播时延长(250-270ms)
          2. 受气候影响大(eg:强风、太阳黑子爆发)
          3. 误码率较高
          4. 成本高
    3. 红外线、激光

      信号固定方向传播

      把要传输的信号分别转换为各自的信号格式,即红外光信号和激光信号,再在空间中传播。
      (微波不需要转换格式)

脑图:

在这里插入图片描述

2.2.2 、物理层设备

  1. 中继器

    1. 诞生的背景:由于存在损耗,在线路上传输的信号功率会逐渐衰减,衰减到一定程度时将会造成信号失真。

    2. 中继器的功能:对信号进行再生和还原,对衰减的信号进行放大,保持与原数据相同,以增加信号传输的距离,延长网络的长度。

简而言之:再生数字信号
3. 中继器的两端:

  1. 两端的网络部分是**网段**,而不是子网,适用于**完全相同的两类网络的互连**,且**两个网段速率要相同**。

  2. 中继器只将任何电缆段上的数据发送到另一段电缆上,它仅作用于信号的电气部分,并不管数据中是否有错误或不适于网段的数据。

  3. **两端可连相同的媒体,也可连不通的媒体**。

  4. 中继器两端的网段一定要是**同一个协议**。(**中继器不会存储转发**)
  1. 5-4-3规则:网络标准中都对信号的延迟范围作了具体的规定,因而中继器只能在规定的范围内进行,否则会网络故障。

    5个网段,4个网络设备,3个段可以连接计算机

    image-20210409164354784

  2. 集线器(多口中继器)

    1. 再生,放大信号

    2. 集线器的功能:对信号进行再生放大转发,对衰减的信号进行放大,接着转发到其他所有(除输入端口外)处于工作状态的端口上,以增加信号传输的距离,延长网络的长度。不具备信号的定向传送能力,是一个共享式设备

    3. 星型拓扑(广播通信)

      image-20210409165035181

    4. 集线器不能分割冲突域。当有超过两台主机同时发送数据给集线器就会发生信息碰撞,要等待随机一段时间之后在发送数据。当集线器连接的主机数目越来越多的时候,由于产生信息碰撞的概念变大,集线器的工作效率也会降低。连在集线器上的工作主机平分带宽。

2.3 、第二章总结

在这里插入图片描述

第三章 数据链路层

在这里插入图片描述


第三章学习的主要内容是:

  1. 链路层的功能
  2. 链路层的两种信道
  3. 局域网、广域网
  4. 链路层的设备

3.1.1、 数据链路层功能概述

  1. 数据链路层的研究思想:

    想象数据是直接从发送端的数据链路层,经过中间系统水平发送到接收端的数据链路层

  2. 数据链路层的基本概念

    1. 结点:主机、路由器

    2. 链路:网络中两个结点之间的物理通道,链路的传输介质主要有双绞线、光纤和微波。分为有线链路、无线链路。

    3. 数据链路:网络中两点之间的逻辑通道,把实际控制数据传输协议的硬件和软件加到链路上就构成了数据链路。

    4. 帧:链路层的协议数据单元,封装网络层的数据报

    数据链路层负责通过一条链路从一个节点向另一个物理链路直接相邻的相邻结点传送数据报。

  3. 数据链路层功能概述

    在物理层提供服务的基础之上向网络层提供服务,其最基本的服务是将源自网络层来的数据可靠地传输到相邻结点的目标机网络层。

    其主要作用是加强物理层传输原始比特流的功能,将物理层提供的可能出错的物理连接改造成逻辑上无差错的数据链路,使之对网络层表现为一条无差错的链路。

    • 功能1:为网络层提供服务。无确认连接服务有确认无连接服务有确认面向连接服务。(有连接一定有确认!)
      • 无确认连接服务:通常用于实时通信或者误码率比较低的通信信道。源主机在发送数据的时候不用事先与目的主机建立好链路的连接,而且目的主机收到数据帧的时候也不用返回确认。如果数据帧丢失了数据链路层也不负责重发,而直接交给上一层处理。(不负责但很快)
      • 有确认无连接服务:通常用于无线通信或者误码率比较高的通信信道。源主机在发送数据的时候不用事先与目的主机建立好链路的连接,但是目的主机收到数据帧的时候需要向源主机返回确认。如果源主机发现在规定时间内没有收到目的主机发送的确认信号,它就把刚才没有收到确认的这个数据帧重新发送,以此来提高数据链路层的可靠性。
      • 有确认面向连接服务:源主机在发送数据的时事先与目的主机建立好链路的连接,目的主机收到数据帧的时也向源主机返回确认。源主机发现确认信号才能发送下一个。(最安全最可靠但速度也是最慢的)
    • 功能2:链路管理,即连接的建立、维持、释放(用于面向连接的服务)
    • 功能3:组帧
    • 功能4:流量控制,限制发送方
    • 功能5:差错控制(帧错/位错)

3.1.2、 封装成帧和透明传输

  1. 封装成帧

    1. 概念:

      就是在一段数据的前后部分添加首部和尾部,这样就构成了一个帧。

      接收端在收到物理层上交的比特流后,就能根据首部和尾部的标记,从收到的比特流中识别帧的开始和结束。

      首部和尾部包含许多的控制信息,他们的一个重要的作用:帧定界(确定帧的界限)

    2. 帧同步:接收方应当能从接收到的二进制比特流中区分出帧的起始和终止。

    3. 组帧的四种方法

      1. 字符计数法
      2. 字符(节)填充法
      3. 零比特填充法
      4. 违规编码法
    4. 示意图

      在这里插入图片描述

  2. 透明传输

    指:不管所传数据是什么样的比特组合,都应当能够在链路上传送。

    因此,链路层就“看不见”有什么妨碍数据传输的东西。当所传数据中的比特组合恰巧与某一个控制信息完全一样时,必须采取适当的措施,使接收方不会将这样的数据错误认为是某种控制信息,这样才能保证数据链路层的传输是透明的。

  3. 字符计数法(不常用)

    帧首部使用一个计数字段(第一个字节,8位)来表明帧内字符数(字节数)。

    image-20210409182821894

    痛点:鸡蛋装在一个篮子里。

    如果第一个字节(计数字段)是错误的,则后面的帧全部发生错误。这样接收方没有办法正确接收每一个帧。

  4. 字符填充法

    当传送的帧是由文本文件组成时(文本文件的字符都是从键盘输入的,都是ASCII码),不管从键盘上输入什么字符都可以放在帧里面传过去,即透明传输

    image-20210409182945627

    当传送的帧是由非ASCII码的文本文件组成时(二进制代码的程序或图像等),就采用字符填充法实现透明传输。

    image-20210409183020592

    字符填充法的示意图:

    在这里插入图片描述

  5. 零比特填充法

    image-20210409192047830

    操作

  6. 在发送端,扫描整个信息字段(原始数据),只要连续5个1,就立即填入1个0

    即:5”1”,1”0”

  7. 在信息字段前后都加上0111110,作为帧的边界

  8. 在接收端收到一个帧时,先找到标志字段确定边界,再用硬件对比特流进行扫描。

    发现连续5个1时,就把后面的0删除。

    保证了透明传输:在传送的比特流中可以传送任意比特组合,而不会引起对帧边界的判断错误。

  9. 违规编码法

    对于曼彻斯特编码,可以使用高-高,低-低来定帧的起始和终止。

    局域网的IEE802标准就采用了该方法。

总结:

由于字节计数法中Count字段(第一个字节)的脆弱性(其值若有差错将导致灾难性后果)及字符填充实现上的复杂性和不兼容性,目前较普遍使用的帧同步法是比特填充违规编码法

3.1.3 、差错控制(比特错,检错编码,纠错编码)

  1. 差错从何而来?

    概括来说,传输中的差错都是由于噪声引起的。

    1. 全局性:由于线路本身电气特性所产生的的随机噪声(热噪声),是信道固有的,随机存在的。

      解决办法:提高信噪比来减少或避免干扰。(对传感器下手

    2. 局部性:外界特定的短暂原因所造成的冲击噪声,产生差错的主要原因。

      解决办法:通常利用编码技术来解决。

  2. 差错的分类

    1. 位错:比特位出错,1变成0,0变成1

    2. 帧错:分为三种:丢失,重复,失序

      例如:要传输三个帧[#1]-[#2]-[#3],则:

      • 帧丢失:[#1]-[#3]
      • 帧重复:[#1]-[#2]-[#2]-[#3]
      • 帧失序:[#3]-[#2]-[#1]

      针对这些帧错误,会采用帧编号、确认重传机制等来进行帧的差错控制。

      image-20210409195542838

      这是过去OSI模型的观点,现在通信链路的质量大大提高,因为通信链路质量不好引起的差错概率越来越小。

      现在的因特网会采用较为灵活的方法,针对不同的网络,我们会选择是否采用确认重传机制。

  3. 链路层为网络层提供的服务:

    1. 无确认无连接服务
    2. 有确认无连接服务
    3. 有确认面向连接服务

    若通信质量好,比如有线传输链路,链路层协议就不会采用确认和重传机制,而且也不要求链路层向网络层提供有效可靠传输的服务(即只有无确认无连接服务),如果发生差错,改错任务会交给上层协议(传输层)。

    若通信质量差,比如无线传输链路,链路层协议就会采用确认和重传机制数据链路层就需要向上提供可靠传输的服务(即需要提供有确认无连接服务和有确认面向连接服务)

  4. 数据链路层的差错控制(比特错,帧错会在后面讲解)

    差错控制:

    1. 检错编码
      1. 奇偶校验码
      2. 循环冗余码CRC
    2. 纠错编码
      1. 海明码
    3. 数据链路层编码和物理层编码的区别
      • 数据链路层编码和物理层的数据编码与调制不同
      • 物理层编码针对的是单个比特,解决传输过程中比特的同步等问题,如曼彻斯特编码。
      • 数据链路层的编码针对的是一组比特,它通过冗余码的技术实现一组二进制比特串在传输过程中是否出现了差错。
  5. 奇偶校验码

    ​ n-1位信息元,1位校验元

    1. 奇校验码

      信息元和校验元中,“1”的个数为奇数

    2. 偶校验码

      信息元和校验元中,“1”的个数为偶数

    奇偶校验码特点:只能检查出奇数个比特错误,检错能力为50%

  6. CRC循环冗余码

    1. 发送端最终发送的数据:要发送的数据+帧检验序列FCS

      计算冗余码(FCS帧检验序列)

      1. 第1步:加0 假设生成多项式G(x)的阶为r,则加r个0(多项式是n位,则阶是n-1位)

        加0是为了不改变原发送数据,FSC帧检验直接跟在原发送数据的后面即可

      2. 第2步:模2除法。数据加0后除以多项式,余数就是冗余码FCS。

        在除法过程中应该做减法的步骤,在模2除法中替换为异或

    2. 接收端检错过程

      把接收的每一帧都除以相同的除数(发送端的生成多项式),然后检查得到的除数R

      • 若R==0,判定这个帧没有差错,接受
      • 若R!=0,判断这个帧有差错(无法确认到位),丢弃
    3. FCS的生成以及接收端CRC检验都是由硬件实现,处理很迅速,因此不会延误数据的传输。

    4. 在数据链路层仅仅使用循环冗余检验CRC差错检测技术,只能做到对帧的无比特差错接收,即“凡是接收端数据链路层接受的帧,我们都能以非常接近于1的概率认为这些帧在传输过程中没有产生差错”。

      可以认为:“凡是接收端数据链路层接收的帧均无差错”

      接收端丢弃的帧虽然曾收到了,但是最终还是因为有差错被丢弃。

    5. 但是帧的无差错接受不是可靠传输,CRC循环冗余码只能检验出帧有错误并丢弃,但是不能对错误的帧进行校正。

      可靠传输是指:数据链路层发送端发送什么,接收端就收到什么。

  7. 海明码

    1. 发现双比特错,纠正单比特错

    2. 工作原理:牵一发而动全身

    3. 工作流程

      1. 确定校验码位数r

        海明不等式:2^r >= k+r+1 (r是冗余信息位,k为信息位)

      2. 确认校验码和数据的位置

        校验码只能填在2的n次方的位置(包括第一个位置)

        原码按顺序插入

      3. 求出校验码的值

        首先将数据位从低位到高位按1,2,3…进行编号,然后将编号用二进制表示,记录二进制表示中的第n位为1的数据位,令这些数据位上的上的数据异或=0,则Pn即为所求这些数据位上包括Pn,则公式可以改进为:Pn=这些数据位除了Pn之外异或(原理:相同异或为零)

        补充:异或的性质

        1. 任意二进制数与0异或之后是本身

        2. 任意二进制数与1异或之后是取反

        3. 偶数个1异或是0(2 * k个1,即在1的基础上进行2 * k-1次取反操作),

          奇数个1异或是1(2 * k+1个1,即在1的基础上进行2 * k次取反操作),

          无论0有多少个

      4. 检错并纠错

        记录二进制表示中的第n位为1的数据位,求这些数据位上的上的数据异或,则Jn即为所求
        J=JN…J1J2将J转换为十进制JT,即第JT位发生错误

    4. 比如:D=101101

      1. 第1步:先求出校验码的位数:2^4=16>=4+6+1,所以校验码为4位

      2. 第2步:按位数分别给校验码、原码编号,原码一共有6位,即编号为:D6 D5 D4 D3 D2 D1

        校验码一共有4位,即编号为P4 P3 P2 P1

        校验码和原码的位置分配:D6 D5 P4 D4 D3 D2 P3 D1 P2 P1

        ​ 位置编号 10 9 8 7 6 5 4 3 2 1
        ​ 位置二进制编码 1010 1001 1000 0111 0110 0101 0100 0011 0010 0001

      3. 第3步:求P1,D5 D4 D2 D1 P1的位置编码的第一位是1,所以令D5 D4 D2 D1 P1的异或=0,求出P1

        Pn的求法同理,先把能求的求出来,最后把之前不能求的再求出来

        求得:P1=P2=P3=0,P4=1

      4. 第4步:求得海明码1011100100

脑图:

在这里插入图片描述

3.1.4 、流量控制与可靠传输机制

  1. 数据链路层的流量控制

    1. 较高的发送速度较低的接收能力的不匹配,会造成传输出错,因此流量控制也是数据链路层的一项重要工作
    2. 流量控制在传输层也有
    3. 链路层与传输层流量控制的区别:
      1. 数据链路层的流量控制是点对点的,而传输层的流量控制是端到端的。
      2. 数据链路层流量控制的手段:接收方收不下就不回复确认
      3. 传输层流量控制手段:接收端给发送端一个窗口公告
  2. 流量控制的方法

    1. 停止等待协议(也可以算是一个特殊的滑动窗口协议,这种协议内发送和接收窗口都是1)

      每发送完一个帧就停止发送,等待对方的确认,在收到确认后再发送下一个帧。

      效率低

      发送窗口大小=1,接收窗口大小=1;窗口大小固定

      在这里插入图片描述

    2. 滑动窗口协议

      1. 后退N帧协议(GBN)

        发送窗口大小>1,接收窗口大小=1;窗口大小固定

      2. 选择重传协议(SR)

        发送窗口大小>1,接收窗口大小>1;窗口大小固定

  3. 可靠传输、滑动窗口、流量控制概念解析

    1. 可靠传输:发送端发啥,接收端收啥
    2. 流量控制:控制发送速率,使接收方有足够的换种空间来接收每一帧。
    3. 滑动窗口是解决流量控制(收不下就不给确认,想发也发不了)和可靠传输(发送方自动重传)的方式

3.1.4.1、 停止-等待协议

  1. 停止-等待协议究竟是哪一层?

    在早期的计算机网络中,由于通信链路质量差,出现差错比较多,为了提高传输效率,数据链路层应该承担一部分可靠传输的任务,把停止-等待协议放在了数据链路层。

    在现在的计算机网络中,通信链路质量大大提高,出现差错的情况很少,不用承担可靠传输的任务,提高了通信速度,降低了延迟。

    停止-等待协议放在了传输层链路层则主要负责差错控制

  2. 为什么要有停止-等待协议?

    1. 除了比特出差错,底层信道还会出现丢包问题

      丢包:物理线路故障、设备故障、病毒攻击、路由信息错误等原因会导致数据包的丢失
      (数据包其实就是一个数据,在数据链路层叫帧,在网络层就叫IP数据报或者分组,在传输层也可以叫报文段

    2. 为了解决丢包问题(可靠控制)和流量控制就出现了停止-等待协议

  3. 研究停止等待协议的前提?

    1. 虽然现在常用全双工通信方式,但为了讨论问题方便,仅考虑一方发送数据(发送方),一方接收数据(接收方)
    2. 因为是在讨论可靠传输的原理,所以并不考虑数据是在哪一层次上传送的
    3. “停止-等待”就是每发送完一个分组就停止发送没等待对方确认,在收到确认后再发送下一个分组。
  4. 停止等待协议有几种应用情况?

    1. 无差错情况

      image-20210409211423472

    2. 有差错情况

      1. 数据帧丢失和检测到帧出错

        1. 超时计时器:每发送一个帧就启动一个计时器

        2. 如果在计时器到期之前收到了确认帧,则计时器终止。

          如果计时器到期了还没有收到确认帧,则发送方会重新发送没收到确认帧的数据帧

        3. 超时计时器设置的重传时间应当比帧传输的平均RTT(往返传播时延)更长一些

        4. 注意事项

          1. 发送完一个帧后,必须保留它的副本。
          2. 数据帧和确认帧必须编号

        image-20210409211646062

      2. ACK丢失(确认帧丢失)

        发送方超时计时器到期后没有收到确认帧,发送方重传数据帧

        接收方收到了重复的数据帧,丢弃重复的数据帧,并重传确认帧

        image-20210409211747811

      3. ACK迟到(确认帧迟到)

        超时还没收到确认帧则重传数据帧,接收方收到了重复的数据帧,并丢弃重复的数据帧

        发送方之后在等待另一个确认帧时,收到了迟到的确认帧,会不对迟到的数据帧做处理

        image-20210409211901849

  5. 停止-等待协议性能分析

    1. 优点:简单

    2. 缺点:信道利用率太低

      image-20210409212059380

      信道利用率:发送方在一个发送周期内,有效地发送数据所需要的时间占整个发送周期的比率信道利用率 = (L/C)/ T

      • L:T内发送L比特数据
      • C:发送方数据传输率
      • T:发送周期,从发送数据开始,到收到第一个确认帧为止(一般包括发送时间和RTT,接收数据帧的时间可以忽略)

      信道吞吐率 = 信道利用率 * 发送方的发送速率

    例题:

    image-20210409212300872

脑图:

image-20210409212332589

3.1.4.2 、后退N帧协议(GBN)

停止等待协议的弊端:信道利用率太低,太闲了。

采用流水线技术对停止-等待协议(一个数据帧跟着数据帧发送)进行改进。

使用流水线技术后:

  1. 必须增加数据帧序号的范围
  2. 发送方需要缓存多个分组

所以出现了后退N帧协议(GBN)选择重传协议(SR)

  1. 后退N帧协议中的滑动窗口

    发送窗口:发送方维持一组连续的允许发送的帧的序号

    接收窗口:接收方维持一组连续的允许接收帧的序号。

    ​ 在后退N帧协议中,接收窗口只有一个

    ​ 在选择重传协议中,接收窗口有多个

    image-20210409213121135

  2. 后退N帧协议执行过程

    1. GBN发送方必须响应的三件事

      1. 上层(网络层)的调用

        上层要发送数据时,发送方先检查发送窗口是否已满,如果未满,则产生一个帧并将其发送;

        如果窗口已满,发送方只需将数据返回给上层,暗示上层窗口已满。上层等一会再发送(实际实现中,发送方可以缓存这些数据,窗口不满时再发送帧。)

      2. 收到了一个ACK

        GBN协议中,对n号帧的确认采用累计确认的方式,标明接收方已经收到n号帧和它之前的全部帧。

        累计确认:例如:接收方返回了一个对于3号帧的确认帧,而数据帧的编号也是从0号开始的(0/1/2/3/4/….)。如果接收方将一个3号帧对应的确认帧给发送方。发送方就知道接收方已经接收到3号帧以及3号帧以前的所有的帧(0/1/2帧)。也就是说0到3号帧接收方已经完全接收了。这就是累积确认方式。

        也就是说,在GBN协议当中,接收方不用对于每一个数据帧都逐个返回一个对应的确认帧。他可以隔一会在发送一个确认帧。它这个确认帧就是想告诉发送方:包括这个帧,以及这个帧以前的所有帧,它都已经全部正确接收了。

      3. 超时事件

        协议的名字为后退N帧/回退N帧,来源于出现丢失和时延过长帧时发送方的行为。就像在停等协议中一样,定时器将再次用于恢复数据帧或确认帧的丢失。如果出现超时,发送方重传所有已发送但未被确认的帧。

    2. GBN接收方要做的事

      如果正确收到n号帧,并且按序,那么接收方为n帧发送一个ACK,并将该帧中的数据部分交付给上层。

      其余情况都丢弃帧,并为最近按序接收的帧重新发送ACK。接收方无需缓存任何失序帧,只需要维护一个信息: expectedseqnum(下一个按序接收的帧序号)。

      即:接收方很专一,如果没有接收到对应帧的到来,后面的帧即使到了也会被丢弃

    3. 示意图

      在这里插入图片描述

  3. 滑动窗口长度可以无限长吗?

    若采用n个比特对帧编号,那么发送窗口的尺寸WT,应满足:1 <= W <= 2^n-1。因为发送窗口尺寸过大,就会使得接收方无法区别新帧和旧帧(新帧与旧帧的帧编号相同)。

  4. GBN协议重点总结

    1. 累计确认(偶尔捎带确认,接收方把确认帧放在了接收方要发给发送方的数据里)
    2. 接收方只按顺序接收帧,不按序无情丢弃
    3. 确认序列号最大的,按序到达的帧
    4. 发送窗口最大为2^n-1,接收窗口大小为1

    例题:

    image-20210409215055723

  5. GBN协议性能分析

    1. 优点:因连续发送数据帧而提高了信道利用率

    2. 缺点:在重传时,必须把原来已经正确传送的数据帧重传,使得传送效率降低

      选择重传协议可以解决这个缺点

脑图:

image-20210409215131878

3.1.4.3 、选择重传协议(SR)

GBN协议的弊端:累计确认—>批量重传。

可不可以只重传出错的帧?

解决办法:设置单个确认,同时加大接收窗口,设置接收缓存,缓存乱序到达的帧。

  1. 选择重传协议中的滑动窗口示意图

    在这里插入图片描述

  2. SR发送方必须响应的三件事

    1. 上层的调用

      从上层收到数据后,SR发送方检查下一个可用于该帧的序号,如果序号位于发送窗口内,则发送数据帧;

      否则就像GBN一样,要么将数据缓存,要么返回给上层之后再传输。

    2. 收到了一个ACK

      如果收到ACK,加入该帧序号在窗口内,则SR发送方将那个被确认的帧标记为已接收。

      如果该帧序号是窗口的下界(最左边第一个窗口对应的序号),则窗口向前移动到具有最小序号的未确认帧处。

      如果窗口移动了并且有序号在窗白内的未发送帧,则发送这些帧。

    3. 超时事件

      每一个帧都有自己的定时器,一个超时事件发生后只重传一个帧

      哪个帧的超时器超时,则重传哪个帧

  3. SR接收方要做的事情

    1. 窗口内的帧来者不拒

    2. SR接收方将确认一个正确接收的帧不管其是否按序

      失序的帧将被缓存,并返回给发送方一个该帧的确认帧【收谁就确认谁】,直到失序前面所有帧(即序号更小的帧)皆被接收到为止,这时才可以将一批帧按序交付给上层,然后向前滑动窗口

    3. 如果收到了窗口序号外(小于窗口下界)的帧,就返回一个ACK

    4. 其他情况就忽略该帧

  4. SR协议运行过程示意图

    在这里插入图片描述

  5. 滑动窗口长度可以无限长吗?

    1. 发送窗口大小最好等于接收窗口(大了会溢出,小了没意义)

      image-20210409223428612

    2. WTmax=WRmax=2^(n-1)

      image-20210409223645651

  6. SR协议重点总结

    1. 对数据帧逐一确认,收一个确认一个
    2. 只重传出错帧
    3. 接收方有缓存
    4. WTmax=WRmax=2^(n-1)

例题:

image-20210409223859479

脑图:

image-20210409223919103

3.2.1 、信道划分介质访问控制

  1. 传输数据使用的两种链路

    1. 点对点链路

      两个相邻节点通过一个链路相连,没有第三者。

      应用:PPP协议常用于广域网

    2. 广播式链路

      所有主机共享通信介质。

      应用:早期的总线以太网、无线局域网,常用于局域网

      典型拓扑结构:总线型、星型(逻辑总线型)

  2. 介质访问控制

    介质访问控制的内容就是,采取一定的措施,使得两对界限之间的通信不会发生相互干扰的情况

    介质访问控制分类

    1. 静态划分信道,即信道划分介质访问控制(C!WTF)

      1. 频分多路复用FDM(frequency)
      2. 时分多路复用TDM(time)
      3. 波分多路复用WDM(wave)
      4. 码分多路复用CDM(code)
    2. 动态分配信道

      1. 轮询访问介质访问控制

        令牌传递协议

      2. 随机访问介质访问控制

        1. ALOHA协议
        2. CSMA协议
        3. CSMA/CD协议(重要)
        4. CSMA/CA协议(重要)
  3. 信道划分介质访问控制

    将使用介质的每个设备与来自同一信道上的其他设备的通信隔离开,把时域和频域资源合理地分配给网络上的设备

    1. 多路复用技术

      把多个信号组合放在一条物理信道上进行传输,使得多个计算机或终端设备共享信道资源,提高信道利用率

      把一条广播信道,逻辑上分成几条用于两个节点之间通信的互不干扰的子信道,实际就是把广播信道转变为点对点信道

      图示:

      在这里插入图片描述

    2. 静态划分信道(信道划分介质访问控制)

      1. 频分多路复用FDM

        1. 概念:

          用户在分配到一定的频带后,在通信过程中自始至终都占用这个频带。

          频分复用的所有用户在同样的时间占用不同的带宽(频率带宽)资源。

          image-20210410023214368

        2. 优点:充分利用传输介质带宽,系统效率更高;由于技术比较成熟,实现也比较容易。

      2. 时分多路复用TDM

        1. 概念:

          将时间划分为一段段等长的时分复用帧(TDM帧)。

          每一个时分复用的用户在每一个TDM帧中占用固定序号的时隙,所有用户轮流占用信道。

          TDM帧与数据链路层的帧不同,TDM帧是在物理层传送的比特流所划分的帧,标志一个周期(cpu的时间片轮转)。

        这一个周期对应的是在一个周期内可以发送多少个比特。

        1. 频分复用——“并行”

          时分复用——“并发”

        2. 改进的时分复用——统计时分复用STDM(增加了信道的利用率)

          在这里插入图片描述

      3. 波分多路复用WDM

        概念:波分多路复用就是光的频分多路复用,在一根光纤中传输多种不同波长(频率)的光信号,由于波长(频率)不同,所以各路光信号互不干扰,最后再用波长分解复用器将各路波长分解出来。

        image-20210410023757326

      4. 码分多路复用CDM

        注意:码分多址(CDMA)是码分复用的一种方式,注意与码分多路复用区分

        1. 概念:
          把1个比特分为多个码片/芯片(chip),每一个站点被指定一个唯一的m位(m位通常是128位或64位)的芯片序列发送1时,站点发送送芯片序列,发送0时发送芯片序列的反码(在芯片序列中,把0写成-1,正交的码片,CDM原理是利用向量正交为0)

        2. 如何不打架:多个站点同时发送数据的时候,要求各个站点芯片序列相互正交规格内积化是0

          规格内积化:将对应的各位相乘,然后相加,最后在除于总的位数。

        3. 如何合并:各路数据在信道中被线性相加(对应的各个位进行相加)

        4. 如何分离:合并数据和原站(芯片序列 )规格化内积

        image-20210410024814953

3.2.2 、随机访问介质访问控制

动态分配信道,也叫动态媒体接入控制/多点接入

特点:信道并非在用户通信时固定分配给用户。

随机访问介质访问控制:所有用户可以随机发送信息,发送信息时占全部带宽。(不协调 =》冲突 =》 协议解决)

  • ALOHA协议 不听就说
  • CSMA协议 先听再说
  • CSMA/CD协议(重要) 先听再说,边听边说
  • CSMA/CA协议(重要)

1、ALOHA协议

ALOHA协议(非重点)

  1. 纯ALOHA协议

    1. 思想:不监听信道,不按时间槽发送,随机重发。(想发就发)

      image-20210410025200886

      其中T0规定的是一个数据帧的长度。(一般一个数据帧的长度都是用比特来衡量,这里用T0衡量是什么意思呢?)T0指的是这样一个数据帧的发送时间。这里面的发送时间既包括传输时间,也包括传播时间。也就是一个数据帧从刚开始发送到发送成功为止的这样一段时间就叫做T0。

    2. 冲突如何检测?

      如果发生冲突,接收方就会检测出差错,然后发送否定确认帧或者不发送确认帧,发送方在一定时间内收不到确认帧就判断冲突。

    3. 冲突如何解决?

      超时后等一随机时间再重传。

  2. 时隙ALOHA协议

    思想:把时间分成若干个相同的时间片(T0,也可以叫做时间槽),所有用户的时间片开始时刻同步接入网络信道,若发生冲突,则必须等到下一个时间片开始时刻再发送。(控制想发就发的随意性)

    主要特点:

    • 每一个站点在发送帧的时候,只能在一个时间片/时间槽的开始来发送
    • 若站点当前想要发送数据帧,但是还没到一个时间片的开始,那么站点就会等待一个时间片的到来之后在进行发送
    • 如果数据帧在发送过程中发生碰撞,那么这个结点就会在时隙结束之后,也就是经过一个T0之后,发送方发现了这样一个碰撞(接收方没有返回一个确认帧),发送方就判定数据在发送过程中发生了冲突。于是发送方进行超时重传。
    • 发送方进行超时重传是依旧遵循之前的协议。在一个时隙(时间片)开始的时候来重传数据帧

    image-20210410030015295

  3. 关于ALOHA协要知道的事

    1. 纯ALOHA比时隙ALOHA吞吐量更低,效率更低
    2. 纯ALOHA想发就发,时隙ALOHA只有在时间片段开始时才能发
    3. 不冲突概率=p(1-p)^2(N-1) = 1/2e,而时隙ALOHA只考虑一个时隙开始时,所以时隙ALOHA的效率是纯ALOHA效率的两倍

2、CSMA协议

  1. 名词详解

    载波监听多路访问协议CSMA(carrier sense multiple access)

    • CS:载波侦听/监听,每一个站在发送数据之前要检测一下总线上是否有其他计算机在发送数据。

      • 如何监听?

        当几个站同时在总线上发送数据时,总线上的信号电压摆动值将会增大(互相叠加)。当一个站检测到的信号电压摆动值超过一定门限值时,就认为总线上至少有两个站同时在发送数据,表明产生了碰撞,即发生了冲突。

    • MA:多点接入,表示许多计算以多点接入的方式连接在一根总线上

  2. 协议思想:发送帧之前,监听信道

    监听结果:

    1. 信道空闲:发送完整数据帧
    2. 信道忙:推迟发送
      • 1-坚持CSMA
      • 非坚持CSMA
      • P坚持CSMA
  3. 1-坚持CSMA

    • 坚持是指:对于监听信道之后的坚持。

    • 1-坚持CSMA思想:

      如果一个主机要发送信息,那么它先监听信道。

      • 监听结果空闲,则不必等待直接发送
      • 监听结果为忙,则一直监听,直到空闲马上传输
      • 如果有冲突(一段时间内未收到肯定回复),则等待一个随机长的时间再监听(等待随机长的时间这一点与ALOHA协议类似,后同),重复上述过程。
    • 优点:只要媒体空闲,站点就马上发送,避免了媒体利用率的丢失

    • 缺点:假如有两个或两个以上的站点有数据要发送,冲突就不可避免

      比如这多个站点全部采用1-坚持CSMA,则一检测到信道空闲,就会同时发送信息,就会发生冲突。

  4. 非坚持CSMA

    1. 非坚持CSMA思想:

      如果一个主机要发送信息,那么它先监听信道。

      • 监听结果空闲,则不必等待直接发送
      • 监听结果为忙,则等待以后随机时间之后再进行监听
    2. 优点:采用随机的重发延迟时间,可以减少冲突发生的可能性

    3. 缺点:可能存在大家都在延迟等待过程中,使得媒体仍可能处于空闲状态,媒体使用率低。

  5. P-坚持CSMA

    1. P-坚持是指:对于监听信道空闲的处理。

    2. P-坚持CSMA的思想

      如果一个主机要发送信息,那么它先监听信道。

      • 空闲则以p概率直接传输,不必等待;概率1-p等待到下一个时间槽再传输。
      • 忙则等待下一个时隙开始才监听,故叫做持续监听,重复上述过程
    3. 优点:既能像非坚持算法那样减少冲突,又能像1-坚持算法那样减少媒体空闲时间。

    4. 缺点:发生冲突后还是要坚持把数据帧发送完,造成了浪费(这是所有CSMA的缺点,1-坚持、非坚持、P-坚持CSMA都有的缺点)

    三种CSMA的对比(注意P-坚持CSMA不太一样):

    image-20210410032318214

3、CSMA/CD协议(重要)

  1. 大体思想:边发送数据,边监听信道,如果发生冲突就停止发送数据

  2. 名词详解

    载波监听多点接入/CD(也叫碰撞检测CSMA) (carrier sense multiple access with collision detection)

    CS:载波侦听/监听,每一个站点在发送数据之前以及发送数据时都要检测一下总线上是否有其他计算机在发送数据。

    与CSMA不同的是:CSMA/CD在发送数据时也会监听信道

    MA:多点接入,表示许多计算机以多点接入的方式连接在一根总线上。=》 总线型网络

    CD:碰撞检测(冲突检测),“边发送边监听”,应用于适配器边发送数据,边检测信道上信号电压的变化情况,以便判断自己在发送数据时,其他站是否也在发送数据。

    应用于:半双工网络

    主要应用于总线式以太网

  3. 为什么先监听后发送还会产生冲突?

    因为:电磁波在总线上总是以有限的速率传播的。

    传播时延对载波监听的影响:

    image-20210410032912095

    假设:单程端到端传播时延:t 最迟多久才能知道自己发送的数据没和别人碰撞?

    最多是两倍的总线到端的传播时延(2 * t),即总线的端到端的往返传播时延(2 * t)

    只要经过2 * t时间还没有检测到碰撞,就能肯定这次发送不会发生碰撞。

    image-20210410033340427

  4. 如何确定碰撞后的重传时机?

    如果检测到碰撞立即重发会导致恶性循环:

    image-20210410033509456

    截断二进制指数规避算法

    1. 确定基本退避(推迟)时间为争用期2t

    2. 定义参数k,它等于重传次数,但k不超过10,即k=min[重传次数,10]。

      • 当重传次数不超过10时,k等于重传次数;
      • 当重传次数大于10时,k就不再增大而一直等于10。
    3. 从离散的整数集合[0,1,…,2^k-1]中随机取出一个数r,重传所需要退避的时间就是r倍的基本退避时间,即2 * r * t。

    4. 当重传达16次(最大重传次数)仍不能成功时,说明网络太拥挤,认为此帧永远无法正确发出,抛弃此帧并向高层报告出错。

      截断二进制指数规避算法使用示例

      在这里插入图片描述

    例题:

    image-20210410034142801

  5. 最小帧长问题:

    A站发了一个很短的帧,但是发生了碰撞,不过帧在发送完毕后才检测出发生碰撞,没法停止发送。为了使CSMA/CD协议有意义,要定义一个最小帧长。

    帧的传输时延至少要两倍于信号在总线中的传播时延

    帧的传输时延 = 帧长(bit)/ 数据传输率 >= 2 * 总线传播时延

    即:最小帧长 = 2 * 总线传播时延 * 数据传输速率 = 2 * t * 数据传输速率

    补充:以太网规定最短帧长为64B,凡是长度小于64B的都是由于冲突而异常终止的无效帧。因此,以太网为了达到这个最小帧长,对于一个比较短的帧,它会对它进行一个填充操作,使它的帧长大于等于64B,然后才能将它放到链路上进行发送。

    脑图:

    image-20210410034655978

4、CSMA/CA协议(重要)

CA:对碰撞的避免

CD:对碰撞的检测

  1. 名词解释

    1. 载波监听多点接入/CA(碰撞避免CSMA,不能检测碰撞)(carrier sense multiple access with collision avoidance)

    2. 为什么要有CSMA/CA?

      主要是因为:CA主要应用于无线局域网

      1. 在无线局域网中无法使用CD协议,不能做到360度全面检测碰撞

        • CD主要应用于总线式以太网
      2. 隐蔽站问题,当A和C都检测不到信号,认为信道空闲时,同时向终端B发送数据帧,就会发生冲突。

        C相对于A就是隐蔽站

    3. 有礼貌的CSMA/CA:不光是先听后发,在听了之后,发送数据之前会等一小段时间。

  2. CSMA/CA工作原理

    发送数据之前,先检测信道是否空闲。

    若空闲则发出RTS(request to send),RTS包括发射端的地址、接收端的地址、下一份数据将持续发送的时间等信息;RTS可发可不发,发RTS是为了解决隐蔽站的问题

    若信道忙,则等待。接收端收到RTS后,将响应CTS(clear to send)

    RTS和CTS就是用来解决隐蔽站的问题:

    • 发送端收到CTS后,开始发送数据帧(同时开始预约信道:发送方告知其他站点自己要传多久数据)

    • 接收端收到数据帧后,将用CRC(CRC循环冗余检验)来检验数据是否正确,正确则响应ACK帧

    • 发送方收到ACK就可以进行下一个数据帧的发送,若没有则一直重传至规定重发次数为止

      (这里跟CD协议一样,采用二进制指数退避算法来确定随机的推迟时间。)

  3. 三个机制实现碰撞避免

    1. 预约信道
    2. ACK帧
    3. RTS/CTS帧(可选,主要是解决隐蔽站的问题)
  4. CD和CA协议的比较

    • 相同点:

      CD和CA机制都从属于CDMA的思路,其核心就是先听再说

      换言之,两个在接入信道前都要进行监听。当发现信道空闲后,才能进行接入。

    • 不同点:

      1. 传输介质不同
        • CD用于总线式以太网【有线】
        • CA用于无线局域网【无线】
      2. 载波检测方式不同
        • 应传输介质不同,CD和CA的检测方式也不同。
        • CD通过电缆中电压的变化来检测,当数据发生碰撞时,电缆中的电压就会随着发生变化;
        • CA采用能量检测(ED)、载波检测(CS)和能量载波检测三种检测信道空闲的方式。
        • CSMA/CD检测冲突CSMA/CA避免冲突,两者出现冲突后都会进行有上限的重传。

3.2.3 、轮询访问介质访问控制

信道划分介质访问控制(MAC Multiple Access Control)协议:

  • 基于多路复用技术划分资源
  • 网络负载重时,共享信道效率高,且公平
  • 网络负载轻时:共享信道效率低

随机访问MAC协议:

  • 用户根据意愿随机发送信息,发送信息时可独占信道带宽
  • 网络负载重时,产生冲突开销
  • 网络负载轻时,共享信道效率高,单个结点可利用信道全部带宽

轮询访问MAC协议/轮流协议/轮转访问MAC协议:

综合信道划分介质访问控制协议和随机访问MAC协议,既不产生冲突,也要发送时占用全部带宽

  • 轮询协议

    主结点轮流“邀请”从属结点发送数据

    image-20210504145458454

    问题:

    1. 轮询开销
    2. 靠后结点有等待延迟
    3. 单点故障:主结点发生故障
  • 令牌传递协议(重要)

    image-20210504145553598

主机

TCU(转发器)

令牌:一个特殊格式的MAC控制帧,不含任何信息

控制信道的使用,确保同一时刻只有一个结点独占信道。

每一个结点都可以在一定的时间内(令牌持有时间内)获得发送数据的权利,并不是无限制地持有令牌

问题:

  1. 令牌开销
  2. 等待延迟
  3. 单点故障(一个主机宕机后,线路故障)

通常应用于令牌环网(物理星型拓扑,逻辑环形拓扑)

采用令牌传送方式的网络常用于负载较重、通信量较大的网络中。

介质访问控制总结:

image-20210504150943372

3.3.1 、局域网基本概念和体系结构

局域网(LAN,Local Area Network)

  1. 概念:是指某一区域内由多台计算机互连成的计算机组,使用广播信道

  2. 特点

    1. 覆盖地理范围小,只在一个相对独立的局部范围内联,如一座或集中的建筑群内。
    2. 使用专门铺设的传输介质(双绞线、同轴电缆)进行联网,数据传输速率高(10Mb/s-10Gb/s)
    3. 通信延迟时间短,误码率低,可靠性高
    4. 各站点为平等关系,共享传输信道
    5. 多采用分布式控制和广播式通信,能进行广播和组播
  3. 决定局域网的主要要素为:网络拓扑传播介质介质访问控制方法

    1. 局域网的网络拓扑

      1. 星型拓扑

        中心节点是控制中心,任意两个节点间的通信最多只需两步,传输速度快,并且网络构形简单、建网容易、便于控制和管理。但这种网络系统,网络可靠性低,网络共享能力差,有单点故障问题

      2. 总线型拓扑(常用)

        网络可靠性高、网络节点间响应速度快、共享资源能力强、设备投入量少、成本低、安装使用方便,当某个工作站节点出现故障时,对整个网络系统影响小。

      3. 环形拓扑

        系统中通信设备和线路比较节省。有单点故障问题;由于环路是封闭的,所以不便于扩充,系统响应延时长,且信息传输效率相对较低。

      4. 树形拓扑

        易于拓展,易于隔离故障,也容易有单点故障。

    2. 局域网传输介质

      1. 有线局域网 常用介质:双绞线、同轴电缆、光纤
      2. 无线局域网 常用介质:电磁波
    3. 局域网介质访问控制方法

      1. CSMA/CD 常用于总线型局域网,也用于树型网络

      2. 令牌总线常用于总线型局域网,也用于树型网络

        它是把总线型或树型网络中的各个工作站按一定顺序如按接口地址大小排列形成一个逻辑环。只有令牌持有者才能控制总线,才有发送信息的权力。

      3. 令牌环 用于环形局域网,如令牌环网

        • 逻辑拓扑:环型(逻辑拓扑主要受通信思想的制约)
        • 物理拓扑:星型(物理拓扑主要受限制的制约)
  4. 局域网的分类

    1. 以太网

      以太网是应用最广泛的局域网,包括标准以太网(10Mbps)、快速以太网(100Mbps)、千兆以太网(1000Mbps)和10G以太网,它们都符合IEEE 802.3系列标准规范。

      逻辑拓扑总线型,物理拓扑是星型或拓展星型。使用CSMA/CD

    2. 令牌环网

      造价高,不是很实用,已是明日黄花

      物理拓扑星型,逻辑拓扑环型

    3. FDDI网(Fiber Distributed Data Interface)(了解)

      用的很少

      物理双环拓扑,逻辑环型拓扑

    4. ATM网(Asynchronous Transfer Mode)(了解)

      较新型的单元交换技术,使用53字节固定长度的单元进行交换

    5. 无线局域网(Wireless Local Area Network,WLAN)

      采用IEEE 802.11标准

  5. IEEE 802标准

    IEEE802系列标准是IEEE802LAN/MAN标准委员会制定的局域网、城域网技术标准(1980年2月成立)其中最广泛使用的有以太网、令牌环网、无线局域网。这一系列标准中的每一个子标准都由委员会中的一个专门工作组负责。

    1. IEEE802.3标准

      以太网介质访问控制协议及物理层技术规范

    2. IEEE802.5标准

      令牌环网的介质访问控制协议及物理层技术规范

    3. IEEE802.8标准

      光纤技术咨询组,提供有关光纤联网的技术咨询(FDDI网

    4. IEEE802.11

      无线局域网(WLAN)的介质访问控制协议及物理层技术规范

  6. MAC子层和LLC子层

    IEEE802标准所描述的局域网参考模型只对应OSI参考模型的数据链路层和物理层,它将数据链路层划分为逻辑链路层LLC子层介质访问控制MAC子层

    1. LLC负责识别网络层协议,然后对它们进行封装。LLC报头告诉数据链路层一旦帧被接收到时,应当对数据包做何处理。

      为网络层提供服务:无确认无连接、面向连接、带确认无连接、高速传送。

    2. MAC子层的主要功能包括数据帧的封装/卸装,帧的寻址和识别,帧的接收与发送,链路的管理,帧的差错控制等。

      MAC子层的存在屏蔽了不同物理链路种类的差异性。

    image-20210504150742911

脑图

image-20210504150832973

3.3.2、以太网概述

  1. 概念

    以太网(Ethernet)指的是由Xerox公司创建并由Xerox、Intel和DEC公司联合开发的基带总线局域网规范,是当今现有局域网采用的最通用的通信协议标准。以太网络使用CSMA/CD(载波监听多路访问及冲突检测)技术

  2. 以太网在局域网各种技术中占统治地位

    1. 造价低廉(以太网网卡不到100块)
    2. 是应用最广泛的局域网技术
    3. 比令牌环网、ATM网便宜,简单
    4. 满足网络速率的要求,10Mbps-10Gbps
  3. 以太网的两个标准

    1. DIX Ethernet V2:第一个局域网产品(以太网)规约。
    2. IEEE802.3:IEEE802委员会802.3工作组制定的第一个IEEE的以太网标准

    这两个标准的区别不大,只是在帧的格式上有两个字节的差异,
    因此只要满足两个标准中的一个都叫以太网,以太网也叫802.3局域网

  4. 以太网提供无连接、不可靠的服务

    • 无连接:发送方和接收方之间无“握手过程”。
    • 不可靠:
      • 不对发送方的数据帧编号,
      • 接收方不向发送方进行确认,
      • 差错帧直接丢弃,
      • 差错纠正由高层负责。

    以太网只实现无差错接受,不实现可靠传输

  5. 以太网传输介质和拓扑结构的发展

    • 传输介质:粗同轴电缆–>细同轴电缆–>双绞线+集线器
    • 物理拓扑:总线型–>星型
      • 使用集线器的以太网在逻辑上仍是一个总线网,各站共享逻辑上的总线,使用的还是CSMA/CD协议
    • 以太网拓扑:逻辑上总线型,物理上星型
  6. 10BASE-T以太网

    • 10BASE-T是传送基带信号的双绞线以太网,T表示采用双绞线,现10BASE-T采用的是无屏蔽双绞线(UTP),传输速率是10Mb/s
    • 物理上采用星型拓扑、逻辑上总线型,每段双绞线最长100m
    • 采用曼彻斯特编码
    • 采用CSMA/CD介质访问控制
  7. 适配器与MAC地址

    • 计算机与外界有局域网的连接是通过通信适配器的。
      • 网络接口板
      • 网络接口卡NIC(network interface card),现在不再使用网卡
      • 适配器上装有处理器和存储器(包括RAM和ROM)
      • ROM上有计算机硬件地址MAC地址
    • 在局域网中,硬件地址又称为物理地址,或MAC地址。【实际上是标识符
    • MAC地址:每个适配器由全球唯一的二进制地址,前24位代表厂家(有IEEE规定),后24位厂家自己指定。常用6个十六进制数表示。即:这个是12个16进制数决定,前六位是厂家,后六位是各个网络制造商自己规定的。如02-60-8c-e4-b1-21
  8. 以太网MAC帧

    • 最常用的MAC帧是以太网V2的格式

      img

      • 目的地址有三种情况
        • 单播地址,一个专有的MAC地址。传播给固定主机
        • 广播地址:8B的前导码全”1”(二进制),或者全”F”(十六进制)。会发生给所有主机
        • 多播地址
    • 与IEE 802.3的区别:

      1. 第三个字段是长度/类型
      2. 当长度/类型字段值小于0x0600时,数据字段必须装入LLC子层。
  9. 高速以太网

    1. 100BASE-T以太网

      • 双绞线上传送100Mb/s基带信号星型拓扑以太网,仍使用IEEE802.3的CSMA/CD协议。

      • 支持全双工和半双工,可在全双工方式下工作而无冲突(不使用CSMA/CD协议)。

        • 全双工(交换机可以隔离冲突域,每一个交换机的端口都是一个冲突域,一个主机在一个冲突域当中不存在冲突)

          image-20210504153357428

    2. 吉比特以太网

      • 光纤或双绞线上传送1Gb/s信号
      • 支持全双工和半双工,可在全双工方式下工作而无冲突
    3. 10吉比特

      • 10吉比特以太网在光纤上传送10Gb/s信号
      • 只支持全双工,无争用问题

脑图

image-20210504153615323

3.3.3、无线局域网

IEEE802.11是无线局与通信用的标准,它是由IEEE所定义的无线通信的标准

wifi是WLAN的一种应用,WLAN可以比较大。

802.11的MAC帧头格式

image-20210504153957809

image-20210504154046849

总结一下:

  • IBSS就是一个服务集内的移动站点不通过基站的直接通信
  • To AP 就是服务集内的移动站点向基站的通信
  • From AP 就是服务集内基站向移动站的通信
  • WDS就是不同服务集内的两个移动站之间的通信(漫游)

无线局域网的分类:

  1. 有固定基础设施无线局域网

    image-20210504154529596

  2. 无固定基础设施无线局域网的自组织网络

    image-20210504154658400

3.3.4、PPP协议和HDLC协议

广域网(WAN,Wide Area Network)通常跨接很大的物理范围,所覆盖的范围从几十公里到几千公里,它能连接多个城市或国家,或横跨几个洲并能提供远距离通信,形成国际性的远程网络。

广域网的通信子网主要使用分组交换技术。广域网的通信子网可以利用公用分组交换网、卫星通信网和无线分组交换网,它将分布在不同地区的局域网或计算机系统互连起来,达到资源共享的目的。如因特网(Internet)是世界范围内最大的广域网。

广域网强调资源共享,局域网强调数据传输

image-20210504154840374

  1. PPP协议:

    1. 点对点协议PPP(Point-to-Point Protocol)是目前使用最广泛的数据链路层协议,用户使用拨号电话接入因特网时一般都是用PPP协议

      • 只支持全双工链路
    2. PPP协议应满足的要求:

      • 简单 对于链路层的帧,无需纠错,无需序号,无需流量传输
      • 封装成帧 帧定界符
      • 透明传输 与帧定界符一样比特组合的数据应该如何处理:异步线路用字节填充同步线路用比特填充
      • 多种网络层协议 封装的IP数据报可以采用多种协议
      • 多种类型链路 串行/并行,异步/同步,光/电
      • 差错检测 错就丢弃
      • 检测连接状态 链路是否正常
      • 最大传送单元 数据部分最大长度MTU(默认不超过1500B)
      • 网络层地址协商 知道通信双方的网络层地址
      • 数据压缩协商
    3. PPP协议无需满足的要求:(纠流编多)

      • 纠错
      • 流量控制
      • 对帧编序号
      • 不支持多点线路
    4. PPP协议的三个组成部分:

      1. 一个将IP数据报封装到串行链路(同步串行/异步串行)的方法
      2. 链路控制协议LCP:建立并维护数据链路连接。(物理连接)
        • 应用:身份验证
      3. 网络控制协议NCP:PPP可支持多种网络层协议,每个不同的网络层协议都要一个相应的NCP来配置,为网络层协议建立和配置逻辑连接(逻辑连接)
    5. PPP协议的状态图

      img

    6. PPP协议的帧格式

      • 帧格式是什么东西?

      • 还有MAC帧格式(以字节为单位)(7E\7D)

        img

  2. HDLC协议

    高级数据链路控制(High-Level Data Link Control或简称HDLC),是一个在同步网上传输数据、面向比特的数据链路层协议,它是由国际标准化组织(ISO)根据IBM公司的SDLC(SynchronousData Link Control)协议扩展开发而成的。

    数据报文可透明传输,用于实现透明传输的“0比特插入法”易于硬件实现

    采用全双工通信

    所有帧采用CRC检验,对信息帧进行顺序编号可防止漏收或重份,传输可靠性高

    HDLC的站:主站从站复合站

    1. 主站的主要功能是发送命令(包括数据信息)帧、接收响应帧,并负责对整个链路的控制系统的初启、流程的控制、差错检测或恢复等。
    2. 从站的主要功能是接收由主站发来的命令帧,向主站发送响应帧,并且配合主站参与差错恢复等链路控制。
    3. 复合站的主要功能是既能发送,又能接收命令帧和响应帧,并且负责整个链路的控制。

    HDLC的三种数据操作方式:

    1. 正常响应方式

      从站发送消息要经过主站的同意,主站命令从站发送数据,从站才可以发送数据

    2. 异步平衡方式

      每一个复合站都可以对其他站的数据传输,每个站都是平等的地位

    3. 异步响应方式

      从站可以不经过主站的同意就进行数据的传输

    HDLC的帧格式:

    image-20210504160825465

    对于地址A:取决于当前选择的数据操作方式

    • 正常响应方式/异步响应方式:从站的地址
    • 异步平衡方式:对应站(应答站),也就是对方的地址

    对于控制C:决定了HDLC帧的类型(无奸细)

    • 信息帧(I)**:第1位为0,用来传输数据信息,或使用捎带技术对数据进行确认**;
    • 监督帧(S)**:10**, 用于流量控制和差错控制,执行对信息帧的确认、请求重发和请求暂停发送等功能
    • 无编号帧(U)**:11**, 用于提供对链路的建立、拆除等多种控制功能。
  3. PPP协议 & HDLC协议

    相同点:

    • HDLC、PPP只支持全双工链路
    • 都可以实现差错检测,但不纠正差错
    • 都可以实现透明传输
      • 关于透明传输的一点小差别:
        • PPP协议既可以实现0比特填充的比特型的填充方法,也可以实现字节填充的方法
        • HDLC协议只能实现0比特填充的比特型的填充方法

    区别:

    PPP协议 面向字节 2B协议字段 无序号和确认机制 不可靠
    HDLC协议 面向比特 没有 有编号和确认机制 可靠

image-20210504162250883

脑图

image-20210504162337180

3.4.1、链路层设备

3.4.1.1、物理层扩展以太网

采用光纤的方式

image-20210504162926611

采用集线器的方式

image-20210504163113104

可以扩展以太网,但是集线器会无脑将一个设备的所有消息转发到集线器所连的所有设备,故会将所连接的所有设备变成一个大的冲突域,同时只能有两台设备进行通信,且设备越多,冲突越多。由此诞生了网桥

3.4.1.2、数据链路层扩展以太网

采用网桥的方式:

网桥根据MAC帧的目的地址对帧进行转发过滤。当网桥收到一个帧时,并不向所有接口转发此帧,而是先检查此帧的目的MAC地址,然后再确定将该帧转发到哪一个接口,或者是把它丢弃(即过滤)。

image-20210504163354054

网桥优点:

  • 过滤通信量,增大吞吐量。
  • 扩大了物理范围。
  • 提高了可靠性。
  • 可互连不同物理层、不同MAC子层和不同速率的以太网。

网桥的分类:

  • 透明网桥:“透明”指以太网上的站点并不知道所发送的帧将经过哪几个网桥,是一种即插即用设备——自学习。

    • 关于自学习

      image-20210504164127428

  • 源路由网桥:在发送帧时,把详细的最佳路由信息( 路由最少/时间最短)放在帧的首部中。

    方法:源站以广播方式向欲通信的目的站发送一个发现帧

    image-20210504164759387

采用交换机的方法

image-20210504165249409

以太网交换机的两种交换方式:

  • 直通式交换机:查完目的地址(6B) 就立刻转发。
    • 优点:延迟小
    • 缺点:可靠性低,无法支持具有不同速率的端口的交换。
  • 存储转发式交换机(常用):将帧放入高速缓存,并检查否正确,正确则转发,错误则丢弃。
    • 优点:可靠性高,可以支持具有不同速率的端口的交换
    • 缺点:延迟大

冲突域 VS 广播域

  • 冲突域:在同一个冲突域中的每一个节点都能收到所有被发送的帧。简单的说就是同一时间内只能有一台设备发送信息的范围
  • 广播域:网络中能接收任一设备发出的广播帧的所有设备的集合。简单的说如果站点发出一个广播信号,所有能接收收到这个信号的设备范围称为一个广播域
能否隔离冲突域 能否隔离广播域
物理层设备[傻瓜]
(中继器、集线器)
× ×
链路层设备[路人]
(网桥、交换机)
×
网络层设备[大佬]
(路由器)

相关例题:(广播域看路由器,冲突域:交换机每一个接口就是一个冲突域)

image-20210504170220974

脑图:

image-20210504170343115

3.5、第三章总结

image-20210504170510895

第四章 网络层

img

img

4.1、网络层的任务与功能

image-20210505155309690

4.2、数据交换方式

1、网络的“掌中宝”——路由器

image-20210505155454971

2、为什么要数据交换

image-20210505155727387

3、数据交换的方式

数据交换可分为三种方式:

  • 电路交换
  • 报文交换
  • 分组交换
    • 数据报方式
    • 虚电路方式

4、电路交换

image-20210505160852979

5、报文交换

image-20210517160352740

6、分组交换

image-20210505161751869

7、报文交换 & 分组交换

image-20210505162605543

8、三种数据交换方式比较总结

image-20210505162837182

9、数据报方式&虚电路方式

image-20210505163608737

10、几种传输单元名词辨析

image-20210505164202246

11、数据报(应用于因特网)

image-20210505164423826

12、虚电路

image-20210505164740761

13、数据报 & 虚电路

image-20210505164918003

4.3.1、IP数据报格式

1、TCP/IP协议栈

image-20210505165458233

2、IP数据报格式

image-20210505165620439

image-20210505171044015

image-20210517165201436

4.3.2、IP数据报分片

1、最大传送单元MTU

image-20210505171323581

2、IP数据报格式——分片相关

image-20210505171749113

3、IP数据报分片例题

image-20210505172207007

4、IP数据报格式——相关单位(一种八片首饰)

image-20210505172335655

4.3.3、IPv4地址

1、IP地址

image-20210505172546692

2、IP编址的历史阶段

  • 分类的IP地址
  • 子网的划分
  • 构成超网(无分类编址方法)

3、分类的IP地址

image-20210505172901070

1、互联网中的IP地址:

image-20210505173055123

2、分类的IP地址:

image-20210505173438999

3、特殊IP地址

image-20210505173834400

4、私有IP地址

image-20210505174001052

5、分类IP使用个数

image-20210505192925617

4.3.4、网络地址转换NAT

image-20210505193330114

网络地址转换NAT:

image-20210505193808509

4.3.5、子网划分和子网掩码

1、子网划分

image-20210505194037834

image-20210505194204411

image-20210505194311386

2、子网掩码

子网掩码:是为了区分网段的 掩码和主机号与主机号比较来判断属不属于该网段

image-20210505194758394

相关习题:

习题1:

image-20210505195105282

习题2:

image-20210505200049573

3、使用子网时分组的转发

image-20210505200430899

4.3.6、无分类编址CIDR

image-20210505201051592

image-20210505201235591

构成超网

image-20210505201651932

最长前缀匹配

image-20210505202925728

image-20210505203331296

4.3.7、ARP协议

1、发送数据的过程

IP1向IP3发送数据:

image-20210505203902705

IP1向IP5发送数据:

image-20210505212537717

2、ARP协议

image-20210505212854516

ARP地址的相关习题

image-20210505213228348

4.3.8、DHCP协议

1、主机如何获得IP地址?

image-20210505223656891

2、DHCP协议

image-20210505224535421

4.3.9、ICMP协议

1、ICMP协议作用

为了更有效地转发IP数据报和提高交付成功的机会

2、网际控制报文协议ICMP

image-20210505225626767

3、ICMP差错报告报文(5种)

image-20210505225923078

4、ICMP差错报告报文数据字段

image-20210505230122748

5、不应发送ICMP差错报文的情况

image-20210505230238989

6、ICMP询问报文

image-20210505230910474

7、ICMP的应用

image-20210505231645689

4.4、IPv6

1、为什么有IPv6

image-20210505232136621

2、IPv6数据报格式

image-20210505233127480

image-20210505233806463

3、IPv6 VS IPv4

image-20210505234147607

4、IPv6地址表示形式

image-20210505234335367

5、IPv6基本地址类型

image-20210505234545274

6、IPv6向IPv4过渡的策略

image-20210505234827815

7、脑图

image-20210505234924693

4.5、路由算法及路由协议

1、路由算法

image-20210506000134403

2、路由算法的分类

image-20210506000028662

3、分层次的路由选择协议

image-20210506000422048

image-20210506000456424

4.6.1、RIP协议与距离向量算法

1、RIP协议

image-20210506000832013

2、RIP协议和谁交换?多久交换一次? 交换什么?

image-20210506001325532

3、距离向量算法

image-20210506001614574

相关例题:

image-20210506001913695

image-20210506002345004

4、RIP协议的报文格式

image-20210506002716945

5、RIP协议好消息传得快,坏消息传得慢

image-20210506002829463

image-20210506002927178

image-20210506003058016

image-20210506003123096

image-20210506003157668

脑图

补充下,RIP与距离向量算法不一样:因数据报服务在分组转发时,每个分组独立选择路由转发,从而引出了路由选择协议。RIP叫路由信息协议。为了找出RIP的最短距离引出了距离向量算法。

image-20210506003352720

4.6.2、OSPF协议与链路状态算法

1、OSPF协议

image-20210506003646135

2、链路状态路由算法

image-20210506004006614

3、OSPF的区域

image-20210506004158704

4、OSPF分组

image-20210506004522231

5、OSPF其他特点

image-20210506004650863

4.6.3、BGP协议

1、BGP协议

image-20210506004817609

2、BGP协议交换信息的过程

image-20210506005743562

image-20210506005824214

image-20210506005844401

3、BGP协议报文格式

image-20210506005948843

4、BGP协议特点

image-20210506010034467

5、BGP-4的四种报文

image-20210506010126542

6、三种路由协议比较

image-20210506010314753

image-20210506010353924

4.7、IP组播

1、IP数据报的三种传输方式

image-20210506010805488

image-20210506010910591

image-20210506011014041

2、IP组播地址

image-20210506011327598

3、硬件组播

image-20210506012222229

4、IGMP协议与组播路由选择协议

image-20210506012400642

5、网际组管理协议IGMP

image-20210506012545893

ICMP和IGMP都使用IP数据报传递报文

6、IGMP工作的两个阶段

image-20210506012720613

7、组播路由选择协议

image-20210506012924832

image-20210506013034949

组播路由选择协议常使用的三种算法:

  • 基于链路状态的路由选择
  • 基于距离-向量的路由选择
  • 协议无关的组播(稀疏/密集)

8、脑图

image-20210506013212541

4.8、移动IP

1、移动IP相关术语

image-20210506013719153

image-20210506013851391

2、移动IP通信过程

image-20210506014144890

image-20210506014312720

4.9、网络层设备

1、路由器

image-20210506014602052

2、输入端口对线路上收到的分组的处理

image-20210506014713942

image-20210506014800212

3、三层设备的区别

image-20210506014928456

4、路由表与路由转发

image-20210506015046620

4.10、网络层总结

image-20210506015143791

image-20210506015239776

image-20210506015254927

image-20210506015310030

image-20210506015341546

image-20210506015440181

image-20210506015550548

image-20210506015658610

image-20210506015721592

image-20210506015734462

image-20210506015810700

第五章 传输层

在这里插入图片描述

5.1、传输层概述

1、什么是传输层

image-20210509091650713

2、传输层的两个协议

image-20210509130716955

3、传输层的寻址与端口

image-20210509131021768

image-20210509131114772

5.2、UDP协议

1、用户数据报协议UDP概述

image-20210509131452260

2、UDP首部格式

image-20210509131719115

3、UDP校验

image-20210509131902697

image-20210509132143163

5.3.1、TCP协议特点和TCP报文段格式

1、TCP协议的特点

image-20210509132448448

2、TCP协议的特点 & TCP报文段首部格式

image-20210509133753564

image-20210509132848748

TCP首部——序号:

image-20210509133439721

TCP首部——确认号

image-20210509133333378

相关控制位:

image-20210509135329970

TCP首部控制位——紧急位URG

image-20210509134247734

TCP首部控制位——推送位PSH

image-20210509134713094

TCP首部——窗口

image-20210509135449995

TCP首部——紧急指针

image-20210509135608155

5.3.2、TCP连接管理

1、TCP连接管理

image-20210509135759910

2、TCP的连接建立

image-20210509140112306

3、SYN洪泛攻击

image-20210509143604587

4、TCP的连接释放

image-20210509143655748

image-20210509144000502

5.3.3、TCP可靠传输

1、TCP可靠传输

image-20210509144114911

2、序号

image-20210509144335882

3、确认

image-20210509144702310

4、重传

image-20210509144948012

image-20210509145112616

5.3.4、TCP流量控制

image-20210509145413434

image-20210509150152054

image-20210509150246065

5.3.5、TCP拥塞控制

1、TCP拥塞控制

image-20210509150503776

2、拥塞控制四种算法

image-20210509150710651

3、拥塞控制四种算法——慢开始和拥塞避免

传输轮次:

image-20210509151158840

image-20210509151631221

4、拥塞控制四种算法——快重传和快恢复

image-20210509152156033

5.4、传输层总结

image-20210509152240332

image-20210509152327273

image-20210509152352593

第六章 应用层

img

6.1、网络应用模型

1、应用层概述

image-20210510103902295

2、网络应用模型

1、客户/服务器模型(Client/Server)

image-20210510104239979

2、P2P模型(Peer-to-peer )

image-20210510104646313

6.2、域名解析(DNS)系统

1、DNS系统

image-20210510104903931

image-20210510105032397

2、域名

image-20210510105410771

域名树

image-20210510105508640

3、域名服务器

image-20210510105950375

image-20210510110153218

image-20210510110230680

4、域名解析过程

image-20210510110758844

6.3、文件传输协议FTP

1、文件传送协议

image-20210510111014038

2、FTP服务器和客户端

image-20210510111137982

3、FTP工作原理

image-20210510111724834

image-20210510112854765

image-20210510111833815

6.4、电子邮件

1、电子邮件系统概述——电子邮件的的信息格式

image-20210510114256285

2、电子邮件系统概述——组成结构

image-20210510114344542

image-20210510114417744

3、简单邮件传送协议SMTP

image-20210510114439779

image-20210510114512955

4、MIME

image-20210510114546291

5、邮局协议POP3

image-20210510114622467

6、网际报文存取协议IMAP

image-20210510114808711

7、基于万维网的电子邮件

image-20210510114701163

脑图

image-20210510114720692

6.5、万维网和HTTP协议

1、万维网概述

image-20210510115838121

2、超文本传输协议HTTP

image-20210510115923321

3、HTTP协议的特点

image-20210510120008065

4、HTTP协议的连接方式

image-20210510120102536

5、超文本传输协议HTTP——报文结构

image-20210510123648179

image-20210510123711370

image-20210510123757619

参考链接:

计算机网络(2019 王道考研)

计算机网络思维导图

Vue

Vue官网

一、邂逅Vuejs

1、遇见Vuejs

1、认识Vuejs

  • Vue (读Vue (读音 /vjuː/,类似于 view)音 /vjuː/,类似于 view)

  • Vue是一个渐进式的框架,渐进式的框架:

    • 渐进式意味着你可以将Vue作为你应用的一部分嵌入其中,带来更丰富的交互体验。
    • 如果你希望将更多的业务逻辑使用Vue实现,那么Vue的核心库以及其生态系统。比如Core+Vue-router+Vuex,也可以满足你各种各样的需求。
  • 与其它大型框架不同的是,Vue 被设计为可以自底向上逐层应用。Vue 的核心库只关注视图层,不仅易于上手,还便于与第三方库或既有项目整合。另一方面,当与现代化的工具链以及各种支持类库结合使用时,Vue 也完全能够为复杂的单页应用提供驱动。

2、Vue的特点和Web开发中常见的高级功能

  • 解耦视图和数据
  • 可复用的组件
  • 前端路由技术
  • 状态管理
  • 虚拟DOM

2、安装Vuejs

安装

1、方式一:下载和引入

官网上直接下载vue.js文件引入到项目(本地)中,其中有开发环境生产环境

注意:

在下载时不能直接点击,直接点击的话你将看到vue.js的源码。应该右键选中从链接另存文件

image-20210320014752045

其中

  • 开发环境用在开发的时候,其中的代码包含了有帮助的命令行警告,方便程序员查看源代码,但相对的文件比较大。

  • 生产环境用在发布产品的时候,其中的代码都是经过压缩的,优化了尺寸和速度,文件也比较小,方便用户下载,但代码的可读性极差。

一句话总结:开发环境面向的是程序员,生产环境面向的是用户。

2、方式二:直接CDN引入

你可以在你的项目中直接CDN(外部)引入:

1
2
3
4
5
6
7
8
9
10
<!-- 开发环境版本,包含了有帮助的命令行警告 -->
<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>

<!-- 生产环境版本,优化了尺寸和速度 -->
<script src="https://cdn.jsdelivr.net/npm/vue"></script>

<!--如果你使用原生 ES Modules,这里也有一个兼容 ES Module 的构建文件-->
<script type="module">
import Vue from 'https://cdn.jsdelivr.net/npm/vue@2.6.12/dist/vue.esm.browser.js'
</script>

3、方式三:NPM安装

在用 Vue 构建大型应用时推荐使用 NPM 安装[1]。NPM 能很好地和诸如 webpackBrowserify 模块打包器配合使用。同时 Vue 也提供配套工具来开发单文件组件

1
2
# 最新稳定版
$ npm install vue

3、第一个Vuejs程序

image-20210320015424885

1、代码的执行

  1. 阅读JavaScript代码,程序发现创建了一个Vue对象;
  2. 创建Vue对象的时候,传入了一些options:{}
    • {}中包含了el属性:该属性决定了这个Vue对象挂载到哪一个元素上,很明显,我们这里是挂载到了id为app的元素上;
    • {}中包含了data属性:该属性中通常会存储一些数据:
      • 这些数据可以是我们直接定义出来的,比如像上面代码这样
      • 也可能是来自网络,从服务器加载的

2、浏览器执行代码的流程

  1. 执行到10~13行代码显然出对应的HTML;
  2. 执行第16行代码创建Vue实例,并且对原HTML进行解析和修改

3、响应式

Vue代码是可以实现响应式的。在浏览器里进入开发者模式F12中的console。在里面修改代码可以实现浏览器的内容也随着修改而响应着改变。

image-20210320020223661

4、Vue与JavaScript (两种编程范式)

  • 命令式编程(JavaScript )

    命令式编程的主要思想是关注计算机执行的步骤,即一步一步告诉计算机先做什么再做什么。

    优点:数据和界面完全分离,不需要js创建页面元素等操作

  • 声明式编程(Vuejs)

    声明式编程是以数据结构的形式来表达程序执行的逻辑。它的主要思想是告诉计算机应该做什么,但不指定具体要怎么做。

    优点:当数据发生改变时界面自动发生改变(响应式)

5、Vue的MVVM

1、是什么MVVM

维基百科官方解释

MVVMModel–view–viewmodel)是一种软件架构模式

MVVM有助于将图形用户界面的开发与业务逻辑后端逻辑(数据模型)的开发分离开来,这是通过置标语言或GUI代码实现的。MVVM的视图模型是一个值转换器,[1] 这意味着视图模型负责从模型中暴露(转换)数据对象,以便轻松管理和呈现对象。在这方面,视图模型比视图做得更多,并且处理大部分视图的显示逻辑。[1] 视图模型可以实现中介者模式,组织对视图所支持的用例集的后端逻辑的访问。

image-20210402232753389

2、Vue的MVVM

image-20210320022023488

  • View层:
    • 视图层
    • 在前端开发中,通常就是DOM层
    • 主要的作用是给用户展示各种信息
  • Model层:
    • 数据层
    • 数据可能是我们固定的死数据,但更多的是来自我们服务器,从网络上请求下来的数据
  • VueModel层:
    • 视图模型层
    • 视图模型层是View和Model沟通的桥梁
    • 一方面它实现了Data Binding,也就是数据绑定,将Model的改变实时的反应到View中
    • 另一方面它实现了DOM Listener,也就是DOM监听,当DOM发生一些事件(点击、滚动、touch等)时,可以监听到,并在需要的情况下改变对应的Data

3、计数器的MVVM示例

计数器:点击 + 计数器+1,点击 - 计数器 -1

在Vue对象中

  • 新属性:methods。该属性用于在Vue对象中定义方法。
  • 新的指令:@click, 该指令用于监听某个元素的点击事件,并且需要指定当发生点击时,执行的方法(方法通常是methods中定义的方法)

image-20210320022244195

image-20210320022417139

计数器中就有严格的MVVM思想:

  • View依然是我们的DOM
  • Model就是我们我们抽离出来的obj
  • ViewModel就是我们创建的Vue对象实例

01-计数器的MVVM

它们之间如何工作呢?

  1. 首先ViewModel通过Data Binding让obj中的数据实时的在DOM中显示。
  2. 其次ViewModel通过DOM Listener来监听DOM事件,并且通过methods中的操作,来改变obj中的数据。

有了Vue帮助我们完成VueModel层的任务,在后续的开发,我们就可以专注于数据的处理,以及DOM的编写工作了。

6、创建Vue实例传入的options

在创建Vue实例的时候,传入了一个对象options。那么,这个options中可以包含哪些选项呢?详细解析

  • el:

    传入类型:string | HTMLElement

    作用:决定之后Vue实例会管理哪一个DOM,挂载要管理的元素

    限制:只在用 new 创建实例时生效

  • data:

    类型:Object | Function (组件当中data必须是一个函数)

    作用:Vue实例对应的数据对象

    限制:组件的定义只接受 function

  • methods:

    类型:{ [key: string]: Function }

    作用:定义属于Vue的一些方法,可以在其他地方调用,也可以在指令中使用。

  • components:

    类型:Object

    详细:包含 Vue 实例可用组件的哈希表。

  • computed:

    类型:{ [key: string]: Function | { get: Function, set: Function } }

    详细:

    计算属性将被混入到 Vue 实例中。所有 getter 和 setter 的 this 上下文自动地绑定为 Vue 实例。

    注意如果你为一个计算属性使用了箭头函数,则 this 不会指向这个组件的实例,不过你仍然可以将其实例作为函数的第一个参数来访问。

    计算属性的结果会被缓存,除非依赖的响应式 property 变化才会重新计算。注意,如果某个依赖 (比如非响应式 property) 在该实例范畴之外,则计算属性是不会被更新的。

  • 生命周期函数:

    所有的生命周期钩子hook自动绑定 this 上下文到实例中,因此你可以访问数据,对 property 和方法进行运算。(粗体表示常用)

    • beforeCreate:

      类型:Function

      详细:

      在实例初始化之后,数据观测 (data observer) 和 event/watcher 事件配置之前被调用。

    • created

      类型:Function

      详细:

      在实例创建完成后被立即调用。在这一步,实例已完成以下的配置:数据观测 (data observer),property 和方法的运算,watch/event 事件回调。然而,挂载阶段还没开始,$el property 目前尚不可用。

    • beforeMount:

      类型:Function

      详细:

      在挂载开始之前被调用:相关的 render 函数首次被调用。

      该钩子在服务器端渲染期间不被调用。

    • mounted

      类型:Function

      详细:

      实例被挂载后调用,这时 el 被新创建的 vm.$el 替换了。如果根实例挂载到了一个文档内的元素上,当 mounted 被调用时 vm.$el 也在文档内。

      注意 mounted 不会保证所有的子组件也都一起被挂载。如果你希望等到整个视图都渲染完毕,可以在 mounted 内部使用 vm.$nextTick

    • beforeUpdate:

      类型:Function

      详细:

      数据更新时调用,发生在虚拟 DOM 打补丁之前。这里适合在更新之前访问现有的 DOM,比如手动移除已添加的事件监听器。

      该钩子在服务器端渲染期间不被调用,因为只有初次渲染会在服务端进行。

    • updated:

      类型:Function

      详细:

      由于数据更改导致的虚拟 DOM 重新渲染和打补丁,在这之后会调用该钩子。

      当这个钩子被调用时,组件 DOM 已经更新,所以你现在可以执行依赖于 DOM 的操作。然而在大多数情况下,你应该避免在此期间更改状态。如果要相应状态改变,通常最好使用计算属性watcher 取而代之。

      注意 updated 不会保证所有的子组件也都一起被重绘。如果你希望等到整个视图都重绘完毕,可以在 updated 里使用 vm.$nextTick

    • activated

      类型:Function

      详细:

      被 keep-alive 缓存的组件激活时调用。

      该钩子在服务器端渲染期间不被调用。

    • deactivated:

      类型:Function

      详细:

      被 keep-alive 缓存的组件停用时调用。

      该钩子在服务器端渲染期间不被调用。

    • beforeDestroy:

      类型:Function

      详细:

      实例销毁之前调用。在这一步,实例仍然完全可用。

      该钩子在服务器端渲染期间不被调用。

    • destroyed:

      类型:Function

      详细:

      实例销毁后调用。该钩子被调用后,对应 Vue 实例的所有指令都被解绑,所有的事件监听器被移除,所有的子实例也都被销毁。

      该钩子在服务器端渲染期间不被调用。

    • errorCaptured:

      2.5.0+ 新增(具体查看)

      类型:(err: Error, vm: Component, info: string) => ?boolean

      详细:

      当捕获一个来自子孙组件的错误时被调用。此钩子会收到三个参数:错误对象、发生错误的组件实例以及一个包含错误来源信息的字符串。此钩子可以返回 false 以阻止该错误继续向上传播。

7、Vue的生命周期

以下图来自官网

Vue 实例生命周期

image-20210320025609038

简化:

image-20210320025717195

8、ES6补充

1、let/var

事实上var的设计可以看成JavaScript语言设计上的错误. 但是这种错误多半不能修复和移除, 以为需要向后兼容。于是,大概十年前,Brendan Eich就决定修复这个问题, 于是他添加了一个新的关键字: let。

我们可以将let看成更完美的var

1、块级作用域
  • JS中使用var来声明一个变量时, 变量的作用域主要是和函数的定义有关
  • 针对于其他块定义来说是没有作用域的,比如if/for等,这在我们开发中往往会引起一些问题。

我们可以通过ES6与ES5的不同来显示块级作用域的作用:

  • ES5中的var是没有块级作用域的(if/for),var只有在function中才有块级作用域。

    ES5之前因为if和for都没有块级作用域的概念,所以在很多时候,我们都必须借助于function的作用域来解决应用外面变量的问题。

  • ES6中的let是由块级作用的(if/for)

2、没有块级作用域引起的问题

for的块级:

1
2
3
4
5
6
var btns = document.getElementsByTagName('button');
for (var i=0; i<btns.length; i++) {
btns[i].addEventListener('click', function () {
console.log('第' + i + '个按钮被点击');
})
}

效果:无论点击哪个按钮,日志打印的都是第5个按钮被点击

image-20210320220031829

说明:由于var没有块级作用域,被var定义的i会随着i++的改变而改变。function里面的i受到for循环的i++的影响,被改变成了5,所以输出的都是第5个按钮被点击

3、解决方法:
  1. 用闭包可以解决问题。

    1
    2
    3
    4
    5
    6
    7
    8
    var btns = document.getElementsByTagName('button');
    for (var i=0; i<btns.length; i++) {
    (function (num) { // 0
    btns[i].addEventListener('click', function () {
    console.log('第' + num + '个按钮被点击');
    })
    })(i)
    }

    为什么闭包可以解决问题:函数是一个作用域。

  2. 用ES6的let

    1
    2
    3
    4
    5
    6
    const btns = document.getElementsByTagName('button')
    for (let i = 0; i < btns.length; i++) {
    btns[i].addEventListener('click', function () {
    console.log('第' + i + '个按钮被点击');
    })
    }

2、const

  • 在很多语言中已经存在, 比如C/C++中, 主要的作用是将某个变量修饰为常量。
  • 在JavaScript中也是如此, 使用const修饰的标识符为常量, 不可以再次赋值。

什么时候使用:

当我们修饰的标识符不会被再次赋值时, 就可以使用const来保证数据的安全性

建议:

在ES6开发中,优先使用const, 只有需要改变某一个标识符的时候才使用let。

使用const时要注意的点(以下代码为错误展示):

  • 一旦给const修饰的标识符被赋值之后, 不能修改

    1
    2
    const name = 'why';
    name = 'abc';
  • 在使用const定义标识符,必须进行赋值

    1
    const name;
  • 常量的含义是指向的对象不能修改, 但是可以改变对象内部的属性

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    const obj = {
    name: 'why',
    age: 18,
    height: 1.88
    }

    // const修饰的标识符被赋值之后, 不能修改
    // obj = {}

    // 但是可以改变对象内部的属性
    obj.name = 'kobe';
    obj.age = 40;
    obj.height = 1.87;

3、对象增强写法

ES6中,对对象字面量进行了很多增强。

属性初始化简写和方法的简写:

  • 属性初始化

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    // ES5的写法
    const obj = {
    name: name,
    age: age,
    height: height
    }

    // ES6的写法
    const obj = {
    name,
    age,
    height,
    }
  • 方法的简写

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    // ES5的写法
    const obj = {
    run: function () {

    },
    eat: function () {

    }
    }
    // ES6的写法
    const obj = {
    run() {

    },
    eat() {

    }
    }

二、Vue基础语法

1、语法糖

指计算机语言中添加的某种语法,这种语法对语言的功能并没有影响,但是更方便程序员使用。通常来说使用语法糖能够增加程序的可读性,从而减少程序代码出错的机会。

语法糖对程序员来说是友好的,但对机器本身却不怎么好。语法糖越甜,编译成的二进制也就越麻烦,出错的时候也会带来更多的麻烦。程序员要做的不是尽力避免错误,而是聚焦在快速发现并改正错误。真正以快速方式轻易解决错误,“快速的失败”远胜过“预防错误”。

Vue中常用的语法糖:

  • v-bind:
  • v-on@
  • v-once.once

2、插值语法

1、mustache语法

Mustache(胡子/胡须)是一款「logic-less(轻逻辑)」的前端模板引擎,它原本是基于 javascript 实现的,但是因为轻量易用,所以经过拓展目前支持更多的平台,如 java,.NET,PHP,C++ 等。Mustache 主要用于在表现和数据相分离的前端技术架构中,根据数据生成特定的动态内容,这些内容在网页中指的是HTML结构,而在小程序中则是WXML结构。在前后端分离的技术架构下面,前端模板引擎是一种可以被考虑的技术选型,随着重型框架(AngularJS、ReactJS、Vue)的流行,前端的模板技术已经成为了某种形式上的标配,Mustache 的价值在于其稳定和经典

主页:https://github.com/janl/mustache.js/

文档:https://mustache.github.io/mustache.5.html

项目主页:http://mustache.github.io/

Handlebars:基于 Mustache 的模板引擎:http://handlebarsjs.com/

对于Vue简单来说:"{{}}"(双大括号)不仅仅可以直接写变量,也可以写简单的表达式 更多的Mustache功能参考:https://www.jianshu.com/p/7f1cecdc27e1 我们可以像下面这样来使用,并且数据是响应式的: ![image-20210320030738172](VUE/11.png) #### 2、v-once 在某些情况下,我们可能不希望界面随意的跟随改变,这个时候,我们就可以使用一个Vue的指令:v-once v-once: - 该指令后面不需要跟任何表达式(比如v-for后面是由跟表达式的) - p该指令表示元素和组件(组件后面才会学习)只渲染一次,不会随着数据的改变而改变。 代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<div id="app">
<h2>{{message}}</h2>
<h2 v-once>{{message}}</h2>
</div>

<script src="../js/vue.js"></script>
<script>
const app = new Vue({
el: '#app',
data: {
message: '你好啊'
}
})
</script>
效果: ![image-20210320032116662](VUE/12.png) #### 3、v-html 某些情况下,我们从服务器请求到的数据本身就是一个HTML代码。如果我们直接通过"{{}}"来输出,会将HTML代码也一起输出。但是我们可能希望的是按照HTML格式进行解析,并且显示对应的内容。这个时候,我们就可以使用一个Vue的指令:v-html

v-html:

  • 该指令后面往往会跟上一个string类型
  • 会将string的html解析出来并且进行渲染

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<div id="app">
<h2>{{url}}</h2>
<h2 v-html="url"></h2>
</div>

<script src="../js/vue.js"></script>
<script>
const app = new Vue({
el: '#app',
data: {
message: '你好啊',
url: '<a href="http://www.baidu.com">百度一下</a>'
}
})
</script>

效果:

image-202103200324301694、v-text(不常用)

nv-text作用和Mustache比较相似:都是用于将数据显示在界面中

nv-text

  • 通常情况下,接受一个string类型

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<div id="app">
<h2>{{message}}, 李银河!</h2>
<h2 v-text="message">, 李银河!</h2>
</div>

<script src="../js/vue.js"></script>
<script>
const app = new Vue({
el: '#app',
data: {
message: '你好啊'
}
})
</script>

效果:

image-20210320032749380

5、v-pre

v-pre用于跳过这个元素和它子元素的编译过程,用于显示原本的Mustache语法。

比如下面的代码:

  • 第一个h2元素中的内容会被编译解析出来对应的内容
  • 第二个h2元素中会直接显示

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<div id="app">
<h2>{{message}}</h2>
<h2 v-pre>{{message}}</h2>
</div>

<script src="../js/vue.js"></script>
<script>
const app = new Vue({
el: '#app',
data: {
message: 'Hello World'
}
})
</script>

效果:

image-20210320032946332

6、v-cloak

在某些情况下,我们浏览器可能会直接显然出未编译的Mustache标签(加载过慢)。

v-cloak

  • 存在期限:在vue解析之前存在,在vue解析之后消失。
  • 该指令后面不需要跟任何表达式

cloak:斗篷(起遮挡作用)

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
32
33
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
<style>
[v-cloak] {
display: none;
}
</style>
</head>
<body>

<div id="app" v-cloak>
<h2>{{message}}</h2>
</div>

<script src="../js/vue.js"></script>
<script>
// 在vue解析之前, div中有一个属性v-cloak
// 在vue解析之后, div中没有一个属性v-cloak
setTimeout(function () {
const app = new Vue({
el: '#app',
data: {
message: '你好啊'
}
})
}, 1000)
</script>

</body>
</html>

效果:

  • 在没加v-cloak之前,浏览器先显示,过1s后显示“你好啊”
  • 在加了v-cloak之后,浏览器先显示空白,过1s后显示“你好啊”

3、绑定属性(v-bind)

1、v-bind基础

前面的插值指令主要作用是将值插入到我们模板的内容当中。但是,除了内容需要动态来决定外,某些属性我们也希望动态来绑定。

  • 比如动态绑定a元素中网站的链接href
  • 比如动态绑定img元素的src属性
  • 动态绑定一些类、样式

v-bind指令:

  • 作用:绑定一个或多个属性值,或者向另一个组件传递props值
  • 缩写::
  • 预期:any (with argument) | Object (without argument)
  • 参数:attrOrProp (optional)

通过Vue实例中的data绑定元素的src和href:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<div id="app">
<!-- 错误的做法: 这里不可以使用mustache语法-->
<!--<img src="{{imgURL}}" alt="">-->
<!-- 正确的做法: 使用v-bind指令 -->
<img v-bind:src="imgURL" alt="">
<a v-bind:href="aHref">百度一下</a>
<!--<h2>{{}}</h2>-->

<!--语法糖的写法-->
<img :src="imgURL" alt="">
<a :href="aHref">百度一下</a>
</div>

<script src="../js/vue.js"></script>
<script>
const app = new Vue({
el: '#app',
data: {
imgURL: 'https://vuejs.org/images/log.png',
aHref: 'https://vuejs.org'
}
})
</script>

2、v-bind绑定class

很多时候,我们希望动态的来切换class:

  • 当数据为某个状态时,字体显示红色。
  • 当数据另一个状态时,字体显示黑色。
1、绑定方式:对象语法

对象语法的含义是:class后面跟的是一个对象。

语法:v-bind:class=’{类名: boolean,类名: boolean}’

eg:v-bind:class=”{类名1: true, 类名2: boolean}

对象语法有下面这些用法:

  • 直接通过{}绑定一个类:

    1
    <h2 :class="{'active': isActive}">Hello World</h2>
  • 通过判断,传入多个值:

    1
    <h2 :class="{'active': isActive, 'line': isLine}">Hello World</h2>
  • 和普通的类同时存在,并不冲突

    注:如果isActive和isLine都为true,那么会有title/active/line三个class

    1
    <h2 class="title" :class="{'active': isActive, 'line': isLine}">Hello World</h2>
  • 如果过于复杂,可以放在一个methods或者computed

    注:classes是一个计算属性

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    <h2 class="title" :class="classes">Hello World</h2>

    <script>
    const app = new Vue({
    el: '#app',
    data: {
    isActive: true,
    isLine: true
    },
    computed: {
    classes: function () {
    return {active: this.isActive, line: this.isLine}
    }
    }
    })
2、绑定方式:数组语法

数组语法的含义是:class后面跟的是一个数组。

数组语法有下面这些用法:

  • 直接通过{}绑定一个类:

    1
    <h2 :class="['active','line']">Hello World</h2>
  • 和普通的类同时存在,并不冲突

    注:会有title/active/line三个class

    1
    <h2 class="title" :class=“[‘active’, 'line']">Hello World</h2>
  • 如果过于复杂,可以放在一个methods或者computed中

    注:classes是一个计算属性

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    <div id="app">
    <h2 class="title" :class="getClasses()">{{message}}</h2>
    </div>

    <script src="../js/vue.js"></script>
    <script>
    const app = new Vue({
    el: '#app',
    data: {
    active: 'aaaaaa',
    line: 'bbbbbbb'
    },
    methods: {
    getClasses: function () {
    return [this.active, this.line]
    }
    }
    })
    </script>

3、v-bind绑定style

我们可以利用v-bind:style来绑定一些CSS内联样式。

在写CSS属性名的时候,比如font-size

  • 我们可以使用驼峰式 (camelCase) fontSize
  • 或短横线分隔 (kebab-case,记得用单引号括起来) ‘font-size’
1、绑定方式:对象语法

style后面跟的是一个对象类型

  • 对象的key是CSS属性名称
  • 对象的value是具体赋的值,值可以来自于data中的属性
  • 如果过于复杂,可以放在一个methods或者computed
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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
<style>
.title {
font-size: 50px;
color: red;
}
</style>
</head>
<body>

<div id="app">
<!--<h2 :style="{key(属性名): value(属性值)}">{{message}}</h2>-->

<!--'50px'必须加上单引号, 否则是当做一个变量去解析-->
<h2 :style="{fontSize: '50px'}">{{message}}</h2>

<!--finalSize当成一个变量使用-->
<!--<h2 :style="{fontSize: finalSize}">{{message}}</h2>-->
<h2 :style="{fontSize: finalSize + 'px', backgroundColor: finalColor}">{{message}}</h2>
<h2 :style="getStyles()">{{message}}</h2>
</div>

<script src="../js/vue.js"></script>
<script>
const app = new Vue({
el: '#app',
data: {
message: '你好啊',
finalSize: 100,
finalColor: 'red',
},
methods: {
getStyles: function () {
return {fontSize: this.finalSize + 'px', backgroundColor: this.finalColor}
}
}
})
</script>

</body>
</html>
2、绑定方式:数组语法

style后面跟的是一个数组类型

  • 多个值以,分割即可
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<div id="app">
<h2 :style="[baseStyle, baseStyle1]">{{message}}</h2>
</div>

<script src="../js/vue.js"></script>
<script>
const app = new Vue({
el: '#app',
data: {
message: '你好啊',
baseStyle: {backgroundColor: 'red'},
baseStyle1: {fontSize: '100px'},
}
})
</script>

4、计算属性(computed)

1、是什么计算属性

在模板中可以直接通过插值语法显示一些data中的数据。但是在某些情况,我们可能需要对数据进行一些转化后再显示,或者需要将多个数据结合起来进行显示:

  • 比如我们有firstName和lastName两个变量,我们需要显示完整的名称:

    • undefined undefined
  • 但是如果多个地方都需要显示完整的名称,我们就需要写多个

    。代码臃肿

我们可以将上面的代码换成计算属性:写在实例Vue的computed选项中的。

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
<div id="app">
<h2>{{firstName + ' ' + lastName}}</h2>
<h2>{{firstName}} {{lastName}}</h2>

<h2>{{getFullName()}}</h2>

<h2>{{fullName}}</h2>
</div>

<script src="../js/vue.js"></script>
<script>
const app = new Vue({
el: '#app',
data: {
firstName: 'Lebron',
lastName: 'James'
},
// computed: 计算属性()
computed: {
fullName: function () {
return this.firstName + ' ' + this.lastName
}
},
methods: {
getFullName() {
return this.firstName + ' ' + this.lastName
}
}
})
</script>

2、计算属性:

  • 解决代码臃肿

  • 可以进行一些更加复杂的操作

    image-20210320150614370

  • 计算属性会进行缓存,如果多次使用时,计算属性只会调用一次。

3、计算属性的setter和getter

每个计算属性都包含一个getter和一个setter

  • 在上面的例子中,我们只是使用getter来读取。

  • 在某些情况下,你也可以提供一个setter方法(不常用)。

    由于一般我们不希望有人能任意修改我们的计算属性的值,所以一般省略setter方法。而计算属性的getter就能简写成:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    // 简写前(如果有setter方法):
    computed: {
    fullName: {
    get() {
    console.log('---调用了fullName的get');
    return this.firstName + ' ' + this.lastName
    }
    },
    set(newValue) {
    console.log('---调用了fullName的get');
    const names = newValue.split(' ');
    this.firstName = names[0];
    this.lastName = names[1];
    }
    }

    // 简写后:
    computed: {
    fullName: function () {
    return this.firstName + ' ' + this.lastName
    }
    },

4、methods与computed

methods和computed看起来都可以实现我们的功能,那么为什么还要多一个计算属性这个东西呢?

原因:计算属性会进行缓存,如果多次使用时,计算属性只会调用一次。

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
32
33
34
35
36
37
38
39
40
41
42
43
<div id="app">
<!--1.直接拼接: 语法过于繁琐-->
<h2>{{firstName}} {{lastName}}</h2>

<!--2.通过定义methods-->
<!--<h2>{{getFullName()}}</h2>-->
<!--<h2>{{getFullName()}}</h2>-->
<!--<h2>{{getFullName()}}</h2>-->
<!--<h2>{{getFullName()}}</h2>-->

<!--3.通过computed-->
<h2>{{fullName}}</h2>
<h2>{{fullName}}</h2>
<h2>{{fullName}}</h2>
<h2>{{fullName}}</h2>
</div>

<script src="../js/vue.js"></script>
<script>
// angular -> google
// TypeScript(microsoft) -> ts(类型检测)
// flow(facebook) ->
const app = new Vue({
el: '#app',
data: {
firstName: 'Kobe',
lastName: 'Bryant'
},
methods: {
getFullName: function () {
console.log('getFullName');
return this.firstName + ' ' + this.lastName
}
},
computed: {
fullName: function () {
console.log('fullName');
return this.firstName + ' ' + this.lastName
}
}
})

</script>

效果:

  • 当使用computed时:由于有缓存,浏览器只执行了一次。

    image-20210320153300912

  • 当使用methods时:没有缓存,浏览器执行多次,加重了浏览器的负担。

    image-20210320153616146

5、事件监听(v-on)

在前端开发中,我们需要经常和用于交互。

这个时候,我们就必须监听用户发生的时间,比如点击、拖拽、键盘事件等等

在Vue中如何监听事件呢?使用v-on指令

v-on:

  • 作用:绑定事件监听器
  • 缩写(语法糖):@
  • 预期:Function | Inline Statement | Object
  • 参数:event

1、v-on基础

  • 一般v-on后面加上:,然后加上动作如点击(click)、拖拽、键盘事件(keyup/keydown)等等。

  • 若v-on监听的事件简单,可以在v-on后面直接实现

    1
    <button v-on:click="counter++">+</button>
  • 若v-on监听的事件复杂,就需要将事件的实现抽取成一个方法

    1
    2
    3
    4
    5
    6
    7
    <button v-on:click="increment">+</button>

    methods: {
    increment() {
    this.counter++
    }
    }

2、v-on参数

当通过methods中定义方法,以供@click调用时,需要注意参数问题

  • 如果该方法不需要额外参数,那么方法后的()可以不添加。

    但是注意:如果方法本身中有一个参数,那么会默认将原生事件event参数传递进去。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    <!--1.事件调用的方法没有参数-->
    <!--1.1函数后添加()-->
    <button @click="btn1Click()">按钮1</button>
    <!--1.1函数后不添加()-->
    <button @click="btn1Click">按钮1</button>

    methods: {
    btn1Click() {
    console.log("btn1Click");
    }
    }
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    <!--2.在事件定义时, 写方法时省略了小括号, 但是方法本身是需要一个参数的, 这个时候, Vue会默认将浏览器生产的event事件对象作为参数传入到方法-->
    <!--2.1函数后没添加()-->
    <button @click="btn2Click">按钮2</button>

    <!--2.2函数需要参数,()里传入参数-->
    <!--<button @click="btn2Click(123)">按钮2</button>-->

    <!--2.3如果函数需要参数,但是没有传入, 那么函数的形参为undefined-->
    <!--<button @click="btn2Click()">按钮2</button>-->

    methods: {
    btn2Click(event) {
    console.log('--------', event);
    }
    }

    2.1的效果:

    image-20210320232959026

    2.3的效果:

    image-20210320233429354

  • 如果需要同时传入某个参数,同时需要event时,可以通过$event传入事件。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    <!--3.方法定义时, 我们需要event对象, 同时又需要其他参数-->
    <!--3.1在调用函数时, 如何手动的获取到浏览器参数的event对象: $event-->
    <button @click="btn3Click('abc', $event)">按钮3</button>
    <!--3.2在调用函数时,若event没有加$,那么浏览器将默认将event当成一个变量,若event在app实例里没有定义的话,浏览器会找不到该变量而报错并且返回undefined-->
    <button @click="btn3Click('abc', event)">按钮3</button>
    <!--3.3在调用函数时,若函数没传入参数,那么浏览器将默认将浏览器参数的event放入第一个参数中,又因为第二个参数没有传值,浏览器会将其变为undefined-->
    <button @click="btn3Click">按钮3</button>

    methods: {
    btn3Click(abc, event) {
    console.log('++++++++', abc, event);
    }
    }

    3.1的效果:

    image-20210320234009069

    3.2的效果:

    image-20210320235528249

    3.3的效果:

    image-20210320235715797

3、v-on修饰符

在某些情况下,我们拿到event的目的可能是进行一些事件处理。Vue提供了修饰符来帮助我们方便的处理一些事件:

  • .stop - 调用 event.stopPropagation()。
  • .prevent - 调用 event.preventDefault()。
  • .{keyCode | keyAlias} - 只当事件是从特定键触发时才触发回调。
  • .native - 监听组件根元素的原生事件。
  • .once - 只触发一次回调。

image-20210321000423484

更多修饰符参考官网的事件修饰符,以下来自官网。

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
32
<!-- 阻止单击事件继续传播 -->
<a v-on:click.stop="doThis"></a>

<!-- 提交事件不再重载页面 -->
<form v-on:submit.prevent="onSubmit"></form>

<!-- 修饰符可以串联 -->
<a v-on:click.stop.prevent="doThat"></a>

<!-- 只有修饰符 -->
<form v-on:submit.prevent></form>

<!-- 添加事件监听器时使用事件捕获模式 -->
<!-- 即内部元素触发的事件先在此处理,然后才交由内部元素进行处理 -->
<div v-on:click.capture="doThis">...</div>

<!-- 只当在 event.target 是当前元素自身时触发处理函数 -->
<!-- 即事件不是从内部元素触发的 -->
<div v-on:click.self="doThat">...</div>

<!-- 点击事件将只会触发一次 -->
<a v-on:click.once="doThis"></a>

<!-- 滚动事件的默认行为 (即滚动行为) 将会立即触发 -->
<!-- 而不会等待 `onScroll` 完成 -->
<!-- 这其中包含 `event.preventDefault()` 的情况 -->
<div v-on:scroll.passive="onScroll">...</div>

<!-- 只有在 `key` 是 `Enter` 时调用 `vm.submit()` -->
<input v-on:keyup.enter="submit">

<input v-on:keyup.page-down="onPageDown">

6、条件判断

1、v-if、v-else-if、v-else

  • 这三个指令与JavaScript的条件语句if、else、else if类似。

  • Vue的条件指令可以根据表达式的值在DOM中渲染或销毁元素或组件。

简单的案例演示:

image-20210321004504280

image-20210321005848254

v-if的原理:

  • v-if后面的条件为false时,对应的元素以及其子元素不会渲染。

  • 也就是根本没有不会有对应的标签出现在DOM中。

2、一个简单的小案例(用户登陆方式切换)

用户再登录时,可以切换使用用户账号登录还是邮箱地址登录。类似如下情景:

image-20210321013857218

代码:

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
32
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>

<div id="app">
<span v-if="isUser">
<label for="username">用户账号</label>
<input type="text" id="username" placeholder="用户账号">
</span>
<span v-else>
<label for="email">用户邮箱</label>
<input type="text" id="email" placeholder="用户邮箱">
</span>
<button @click="isUser = !isUser">切换类型</button>
</div>

<script src="../js/vue.js"></script>
<script>
const app = new Vue({
el: '#app',
data: {
isUser: true
}
})
</script>

</body>
</html>
1、问题

以上案例会有一个小问题:如果我们在有输入内容的情况下,切换了类型,我们会发现文字依然显示之前的输入的内容。

为什么呢?按道理讲,我们在一个input输入的内容(value),在切换到另外一个input元素后应该消失,因为在另一个input元素中,我们并没有输入内容。

2、问题解答
  • 这是因为Vue在进行渲染时,不会直接渲染在浏览器上面,Vue会在其之间构建一个虚拟NOM,Vue会先渲染在虚拟DOM上面,然后在渲染在浏览器上。而出于性能考虑,当出现两个只存在一个(if -else)的时候,会尽可能的复用已经存在的元素,而不是重新创建新的元素。
  • 在上面的案例中,Vue内部会发现原来(if)的input元素不再使用,直接作为else中的input来使用了。此时并不会重新构建一个input,并且改变的只有与之前input不同的内容(如for、id、placeholder等等),所以文本里面的内容不会改变。
3、解决方案

如果我们不希望Vue出现类似重复利用的问题,可以给对应的input添加key

并且需要我们保证key的值不同。(若key的值相同的话还是会继承文本内容)

1
2
3
4
5
6
7
8
<span v-if="isUser">
<label for="username">用户账号</label>
<input type="text" id="username" placeholder="用户账号" key="username">
</span>
<span v-else>
<label for="email">用户邮箱</label>
<input type="text" id="email" placeholder="用户邮箱" key="email">
</span>

3、v-show

v-show的用法和v-if非常相似,也用于决定一个元素是否渲染

v-if和v-show对比:

  • v-if: 当条件为false时, 包含v-if指令的元素, 根本就不会存在dom中
  • v-show: 当条件为false时, v-show只是给我们的元素添加一个行内样式: display: none

v-if和v-show都可以决定一个元素是否渲染,开发中如何选择呢:

  • 当需要在显示与隐藏之间切换很频繁时,使用v-show
  • 当只有一次切换时,通过使用v-if

7、循环遍历(v-for)

1、v-for遍历数组

当我们有一组数据需要进行渲染时,我们就可以使用v-for来完成

  • v-for的语法类似于JavaScript中的for循环。

  • 格式如下:item in items的形式。

  • 如果在遍历的过程中不需要使用索引值

    1
    2
    3
    <ul>
    <li v-for="item in names">{{item}}</li>
    </ul>
  • 如果在遍历的过程中,我们需要拿到元素在数组中的索引值

    1
    2
    3
    4
    5
    6
    <ul>
    <li v-for="(item, index) in names">
    // 使遍历从1开始
    {{index+1}}.{{item}}
    </li>
    </ul>

2、v-for遍历对象

当有一对象需要我们对其里面的数据进行渲染时,我们就可以使用v-for来完成

  • 在遍历对象的过程中, 如果只是获取一个值, 那么获取到的是value

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    <ul>
    <li v-for="item in info">{{item}}</li>
    </ul>

    <script>
    const app = new Vue({
    el: '#app',
    data: {
    info: {
    name: 'why',
    age: 18,
    height: 1.88
    }
    }
    })
    </script>

    效果:

    • why
    • 18
    • 1.88
  • 获取key和value 格式: (value, key)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    <ul>
    <li v-for="(value, key) in info">{{value}}-{{key}}</li>
    </ul>

    <script>
    const app = new Vue({
    el: '#app',
    data: {
    info: {
    name: 'why',
    age: 18,
    height: 1.88
    }
    }
    })
    </script>

    效果:

    • why-name
    • 18-age
    • 1.88-height
  • 获取key和value和index 格式: (value, key, index)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    <ul>
    <li v-for="(value, key, index) in info">{{value}}-{{key}}-{{index + 1}}</li>
    </ul>

    <script>
    const app = new Vue({
    el: '#app',
    data: {
    info: {
    name: 'why',
    age: 18,
    height: 1.88
    }
    }
    })
    </script>

    效果:

    • why-name-1
    • 18-age-2
    • 1.88-height-3

3、v-for的组件的key属性

官方推荐我们在使用v-for时,给对应的元素或组件添加上一个:key属性。

为什么需要这个key属性呢?

  • 这个其实和Vue的虚拟DOM的Diff算法有关系

  • 我们借用React’s diff algorithm中的一张图来简单说明一下:

    image-20210321153710582

  • 当某一层有很多相同的节点时,也就是列表节点时,我们希望插入一个新的节点

    • 我们希望可以在B和C之间加一个F,Diff算法默认执行起来是这样的
    • 即把C更新成F,D更新成C,E更新成D,最后再插入E
  • 这样做会使程序的执行效率变低,所以可以用key这个属性来给每个节点做一个唯一标识

    • Diff算法就可以正确的识别此节点
    • 找到正确的位置区插入新的节点
  • key的作用主要是为了高效的更新虚拟DOM

4、检测数组更新(响应式)

因为Vue是响应式的,所以当数据发生变化时,Vue会自动检测数据变化,视图会发生对应的更新。

Vue中包含了一组观察数组编译的方法,使用它们改变数组也会触发视图的更新:

  • push():在数组末尾添加一个或多个元素

    1
    2
    3
    4
    5
    6
    data: {
    letters: ['a', 'b', 'c', 'd']
    }

    this.letters.push('aaa')
    this.letters.push('aaaa', 'bbbb', 'cccc')
  • pop():删除数组中的最后一个元素

    1
    2
    3
    4
    5
    data: {
    letters: ['a', 'b', 'c', 'd']
    }

    this.letters.pop();
  • shift():删除数组中的第一个元素

    1
    2
    3
    4
    5
    data: {
    letters: ['a', 'b', 'c', 'd']
    }

    this.letters.shift();
  • unshift():在数组最前面添加一个或多个元素

    1
    2
    3
    4
    5
    6
    data: {
    letters: ['a', 'b', 'c', 'd']
    }

    this.letters.unshift()
    this.letters.unshift('aaa', 'bbb', 'ccc')
  • splice(start,index,…items):删除元素/插入元素/替换元素

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    data: {
    letters: ['a', 'd', 'c', 'b']
    }

    // 删除元素: 第二个参数传入你要删除几个元素(如果没有传,就删除后面所有的元素)
    this.letters.splice(1, 3)
    this.letters.splice(1)

    // 替换元素: 第二个参数, 表示我们要替换几个元素, 后面是用于替换前面的元素
    this.letters.splice(1, 3, 'm', 'n', 'l', 'x')

    // 插入元素: 第二个参数, 传入0, 并且后面跟上要插入的元素
    this.letters.splice(1, 0, 'x', 'y', 'z')
  • sort():对数组进行排序。(参数可以添加排序的规则的方法)

    1
    2
    3
    4
    5
    data: {
    letters: ['a', 'd', 'c', 'b']
    }

    this.letters.sort()
  • reverse():

    1
    2
    3
    4
    5
    data: {
    letters: ['a', 'd', 'c', 'b']
    }

    this.letters.reverse()

注意:通过索引值修改数组中的元素不能做到响应式

1
2
3
4
5
data: {
letters: ['a', 'd', 'c', 'b']
}

this.letters[0] = 'bbbbbb';

此时可以通过splice方法或Vue的set方法的方式来修改以达到响应式的目的

1
2
3
4
5
// splice方法
this.letters.splice(0, 1, 'bbbbbb')

// Vue的set(要修改的对象, 索引值, 修改后的值)
Vue.set(this.letters, 0, 'bbbbbb')

5、作业:(v-for + v-bind + v-on + 当前索引方法的应用)

需求:有一电影列表,点击哪一部影片,哪一部影片就表现为红色。

代码:

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
32
33
34
35
36
37
38
39
40
41
42
43
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>homework</title>
</head>
<style>
.active{
color: red;
}
</style>
<body>

<div id = "app">
<ul>
<li v-for="(movie,index) in movies"
:class="{active: currentIndex === index}"
@click="isClick(index)">
{{index}}.{{movie}}
</li>
</ul>
</div>
<script src="../js/vue.js"></script>

<script>

const app = new Vue({
el: '#app',
data: {
movies: ['海贼王','火影忍者','进击的巨人','妖精的尾巴'],
currentIndex: -1
},
methods: {
isClick: function (index) {
this.currentIndex = index
}
}
})

</script>

</body>
</html>

6、高阶函数filter|map|reduce

1、filter:过滤作用

filter函数的参数是一个回调函数,返回值为一个数组:

  • 回调函数的参数为循环遍历的值n
  • 回调函数有一个要求: 必须返回一个boolean值
    • true: 当返回true时, 函数内部会自动将这次回调的n加入到新的数组中
    • false:当返回false时, 函数内部会过滤掉这次的n

使用:

1
2
3
4
5
6
const nums = [10, 20, 111, 222, 444, 40, 50]

// newNums = [10,20,40,50]
let newNums = nums.filter(function (n) {
return n < 100
})
2、map:映射作用

map函数的参数是一个回调函数,返回值为一个数组:

  • 回调函数的参数为循环遍历的值n
  • 可以在回调函数内对数组的值进行操作,map会帮操作完的值映射到一个新的数组

使用:

1
2
3
4
5
// newNums = [10,20,40,50]
// new2Nums = [20,40,80,100]
let new2Nums = newNums.map(function (n) { // 20
return n * 2
})
3、reduce:作用对数组中所有的内容进行汇总

map函数的参数是一个回调函数,和一个初始值

  • 回调函数有两个参数(previousValue,start)
    • previousValue:数组当前值的前一个值
    • start:数组当前值
  • 初始值为数组一开始值(第一个元素,index=0)的前一个值
1
2
3
4
5
// new2Nums = [20,40,80,100]
// total = 240
let total = new2Nums.reduce(function (preValue, n) {
return preValue + n
}, 0)
4、总结

需求:筛选出数组nums里所有小于100的值,然后就值乘以2再相加。

1
2
3
4
5
6
7
8
9
const nums = [10, 20, 111, 222, 444, 40, 50]

let total = nums.filter(function (n) {
return n < 100
}).map(function (n) {
return n * 2
}).reduce(function (prevValue, n) {
return prevValue + n
}, 0)

以上三个高阶函数的回调函数都可以用箭头函数表示。

1
let total = nums.filter(n => n < 100).map(n => n * 2).reduce((pre, n) => pre + n)

8、表单绑定(v-mode)

1、v-mode基础

表单控件在实际开发中是非常常见的。特别是对于用户信息的提交,需要大量的表单。

Vue中使用v-model指令来实现表单元素和数据的双向绑定。

案例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<div id="app">
<input type="text" v-model="message">
{{message}}
</div>

<script src="../js/vue.js"></script>
<script>
const app = new Vue({
el: '#app',
data: {
message: 'Hello Vue'
}
})
</script>

image-20210321210459660

案例的解析:

当我们在输入框输入内容时,因为input中的v-model绑定了message,所以会实时将输入的内容传递给message,message发生改变。当message发生改变时,因为上面我们使用Mustache语法,将message的值插入到DOM中,所以DOM会发生响应的改变。所以,通过v-model实现了双向的绑定。

当然,我们也可以将v-model用于textarea元素。

2、v-mode的原理

v-model其实是一个语法糖,它的背后本质上是包含两个操作:

  • v-bind绑定一个value属性
  • v-on指令给当前元素绑定input事件
1
2
3
4
5
6
7
8
9
10
<input type="text" v-model="message">
<!--等同与-->
<input type="text" :value="message" @input="valueChange">
methods: {
valueChange(event) {
this.message = event.target.value;
}
}
<!--也等同与-->
<input type="text" v-bind:value="message" v-on:input="message = $event.target.value">

3、v-mode:radio

当存在多个单选框时,v-mode可用于将单选框的值和与之对应变量进行双向绑定。

其中一个label与一个input组合,label里面的for与input里面的id一一对应,实现用户点击文字就可以选中对应的单选框。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<div id="app">
<label for="male">
<input type="radio" id="male" value="男" v-model="sex">
</label>
<label for="female">
<input type="radio" id="female" value="女" v-model="sex">
</label>
<h2>您选择的性别是: {{sex}}</h2>
</div>

<script src="../js/vue.js"></script>
<script>
const app = new Vue({
el: '#app',
data: {
sex: '女'
}
})
</script>

4、v-mode:checkbox

checkbox复选框分为两种情况:单个勾选框和多个勾选框

  • 单个勾选框:

    • v-model即为布尔值

    • 此时input的value并不影响v-model的值

    • 常用于让用户点击同意协议后才能点击下一步的业务场景

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      <div id="app">
      <label for="licence">-->
      <input type="checkbox" id="licence" v-model="isAgree">同意协议
      </label>
      <h2>您选择的是: {{isAgree}}</h2>
      <button :disabled="!isAgree">下一步</button>
      </div>

      <script src="../js/vue.js"></script>
      <script>
      const app = new Vue({
      el: '#app',
      data: {
      isAgree: false,
      }
      })
      </script>
  • 多个复选框

    • 当是多个复选框时,因为可以选中多个,所以对应的data中属性是一个数组

    • 当选中某一个时,就会将input的value添加到数组中

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      <div id="app">
      <input type="checkbox" value="篮球" v-model="hobbies">篮球
      <input type="checkbox" value="足球" v-model="hobbies">足球
      <input type="checkbox" value="乒乓球" v-model="hobbies">乒乓球
      <input type="checkbox" value="羽毛球" v-model="hobbies">羽毛球
      <h2>您的爱好是: {{hobbies}}</h2>

      <!--label中的:for与input的:id对应-->
      <label v-for="item in originHobbies" :for="item">
      <input type="checkbox" :value="item" :id="item" v-model="hobbies">{{item}}
      </label>
      </div>

      <script src="../js/vue.js"></script>
      <script>
      const app = new Vue({
      el: '#app',
      data: {
      hobbies: [],
      originHobbies: ['篮球', '足球', '乒乓球', '羽毛球', '台球', '高尔夫球']
      }
      })
      </script>

5、v-mode:select

select也分单选和多选两种情况:

  • 单选:只能选中一个值:

    • v-model绑定的是一个值

    • 当我们选中option中的一个时,会将它对应的value赋值到mySelect中

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      <div id="app">
      <select name="abc" v-model="fruit">
      <option value="苹果">苹果</option>
      <option value="香蕉">香蕉</option>
      <option value="榴莲">榴莲</option>
      <option value="葡萄">葡萄</option>
      </select>
      <h2>您选择的水果是: {{fruit}}</h2>
      </div>

      <script src="../js/vue.js"></script>
      <script>
      const app = new Vue({
      el: '#app',
      data: {
      // 默认香蕉
      fruit: '香蕉'
      }
      })
      </script>
  • 多选:可以选中多个值(属性加上multiple):

    • v-model绑定的是一个数组

    • 当选中多个值时,就会将选中的option对应的value添加到数组mySelects中

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      <div id="app">
      <select name="abc" v-model="fruits" multiple>
      <option value="苹果">苹果</option>
      <option value="香蕉">香蕉</option>
      <option value="榴莲">榴莲</option>
      <option value="葡萄">葡萄</option>
      </select>
      <h2>您选择的水果是: {{fruits}}</h2>
      </div>

      <script src="../js/vue.js"></script>
      <script>
      const app = new Vue({
      el: '#app',
      data: {
      fruits: []
      }
      })
      </script>

6、值绑定

动态的给value赋值而已。我们前面的value中的值,都是在定义input的时候直接给定的(写死),但是真实开发中,这些input的值可能是从网络获取或定义在data中的。所以我们可以通过v-bind:value动态的给value绑定值(其实就是v-bind在input中的应用)

7、修饰符

1、lazy修饰符

默认情况下,v-model默认是在input事件中同步输入框的数据的。也就是说,一旦有数据发生改变对应的data中的数据就会自动发生改变。

lazy修饰符可以让数据在失去焦点或者回车时才会更新。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<div id="app">
<input type="text" v-model.lazy="message">
<h2>{{message}}</h2>
</div>

<script src="../js/vue.js"></script>
<script>
const app = new Vue({
el: '#app',
data: {
message: 'Hello Vue'
}
})
</script>

效果:

  • 聚焦时:

image-20210321214116893

  • 失焦时:

image-20210321214156460

2、number修饰符

默认情况下,在输入框中无论我们输入的是字母还是数字,都会被当做字符串类型进行处理。但是如果我们希望处理的是数字类型,那么最好直接将内容当做数字处理。

number修饰符可以让在输入框中输入的内容自动转成数字类型

1
2
3
4
5
6
7
8
9
10
11
12
13
<div id="app">
<input type="number" v-model.number="age">
<h2>{{age}}-{{typeof age}}</h2>

<script src="../js/vue.js"></script>
<script>
const app = new Vue({
el: '#app',
data: {
age: 18
}
})
</script>

效果:

  • 没加number:

    image-20210321214845631

  • 加上number:

    image-20210321214942404

3、trim修饰符

如果输入的内容首尾有很多空格,通常我们希望将其去除,trim修饰符可以过滤内容左右两边的空格(浏览器会格式化显示时帮忙去掉多余的空格,但在代码里空格依旧存在,trim修饰符可以过滤内容左右两边的空格)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<div id="app">
<input type="text" v-model.trim="name">
<h2>您输入的名字:{{name}}</h2>
</div>

<script src="../js/vue.js"></script>
<script>
const app = new Vue({
el: '#app',
data: {
name: ''
}
})
</script>

效果:

  • 没加trim:

    image-20210321215801147

  • 加上trim

    image-20210321215539094

三、组件化开发

1、什么是组件化

如果我们将一个页面中所有的处理逻辑全部放在一起,处理起来就会变得非常复杂,而且不利于后续的管理以及扩展。但如果,我们讲一个页面拆分成一个个小的功能块,每个功能块完成属于自己这部分独立的功能,那么之后整个页面的管理和维护就变得非常容易了。

如图:

  • 我们将一个完整的页面分成很多个组件。
  • 每个组件都用于实现页面的一个功能块。
  • 而每一个组件又可以进行细分。

image-20210321224044742

2、Vue组件化思想

组件化是Vue.js中的重要思想。它提供了一种抽象,让我们可以开发出一个个独立可复用的小组件来构造我们的应用。任何的应用都会被抽象成一颗组件树。

image-20210321224237074

组件化思想的应用:

  • 有了组件化的思想,我们在之后的开发中就要充分的利用它。
  • 尽可能的将页面拆分成一个个小的、可复用的组件。
  • 这样让我们的代码更加方便组织和管理,并且扩展性也更强。

注意:每一个组件都有独属于自己的data、methodscomputedcomponentstemplate等等。其中app可以看成所有组件的根组件(root)。但要注意,app也只能调用自己的儿子组件,不能去跨辈调用孙子组件。

3、注册组件

组件的使用分成三个步骤:

  1. 创建组件构造器
  2. 注册组件
  3. 使用组件:组件只能在注册过的实例里使用,否则Vue因没有进行管理不会加载组件。

注意:字符串的表达除了有''(单引号)、""(双引号)以外,还有``(尖引号)。尖引号可以实现字符串的跨行。

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
32
33
34
35
36
37
38
39
<div id="app">
<!--3.使用组件-->
<my-cpn></my-cpn>
<my-cpn></my-cpn>
<my-cpn></my-cpn>
<my-cpn></my-cpn>

<div>
<div>
<my-cpn></my-cpn>
</div>
</div>
</div>

<!--以下没有在app实例里使用,Vue没有进行管理不会加载组件-->
<my-cpn></my-cpn>

<script src="../js/vue.js"></script>
<script>
// 1.创建组件构造器对象
const cpnC = Vue.extend({
template: `
<div>
<h2>组件标题</h2>
<p>我是组件中的一个段落内容</p>
</div>`
})

// 2.注册组件
// 参数1:组件的名称,参数2:组件构造器对象的名称
Vue.component('my-cpn', cpnC)

const app = new Vue({
el: '#app',
data: {
message: '你好啊'
}
})
</script>

注册组件步骤解析

  1. Vue.extend():

    1. 调用Vue.extend()创建的是一个组件构造器;
    2. 通常在创建组件构造器时,传入template代表我们自定义组件的模板;
    3. 该模板就是在使用到组件的地方,要显示的HTML代码;
    4. 事实上,这种写法在Vue2.x的文档中几乎已经看不到了,它会直接使用下面我们会讲到的语法糖,但是在很多资料还是会提到这种方式,而且这种方式是学习后面方式的基础
  2. Vue.component():

    1. 调用Vue.component()是将刚才的组件构造器注册为一个组件,并且给它起一个组件的标签名称;
    2. 所以需要传递两个参数:
      1. 注册组件的标签名
      2. 组件构造器
  3. 组件必须挂载在某个Vue实例下,否则它不会生效:

    1. 我们来看下面我使用了三次

    2. 而第三次其实并没有生效:

      image-20210322023308247

4、全局组件和局部组件

当我们通过调用Vue.component()注册组件时,组件的注册是全局的

这意味着该组件可以在任意Vue示例下使用。

image-20210322023704676

如果我们注册的组件是挂载在某个实例中, 那么就是一个局部组件

image-20210322023725407

5、父组件和子组件

在前面我们看到了组件树:组件和组件之间存在层级关系。而其中一种非常重要的关系就是父子组件的关系

我们来看通过代码如何组成的这种层级关系:

image-20210322024427807

父子组件错误用法:以子标签的形式在Vue实例中使用

  • 因为当子组件注册到父组件的components时,Vue会编译好父组件的模块
  • 该模板的内容已经决定了父组件将要渲染的HTML(相当于父组件中已经有了子组件中的内容了)
  • 是只能在父组件中被识别的。
  • 类似这种用法,是会被浏览器忽略的。

6、注册组件语法糖

在上面注册组件的方式,可能会有些繁琐。Vue为了简化这个过程,提供了注册的语法糖。

主要是省去了调用Vue.extend()的步骤,而是可以直接使用一个对象来代替。

语法糖注册全局组件和局部组件:

  • 全局组件的语法糖:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    <div id="app">
    <cpn1></cpn1>
    </div>

    <script src="../js/vue.js"></script>

    <script>
    Vue.component('cpn1', {
    template: `
    <div>
    <h2>我是标题1</h2>
    <p>我是内容, 哈哈哈哈</p>
    </div>
    `
    })

    const app = new Vue({
    el: '#app'
    }
    })
    </script>
  • 局部组件的语法糖:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    <div id="app">
    <cpn2></cpn2>
    </div>

    <script src="../js/vue.js"></script>
    <script>
    // 注册局部组件的语法糖
    const app = new Vue({
    el: '#app',
    components: {
    'cpn2': {
    template: `
    <div>
    <h2>我是标题2</h2>
    <p>我是内容, 呵呵呵</p>
    </div>
    `
    }
    }
    })
    </script>

7、模板的分离写法

以上代码虽然通过语法糖简化了Vue组件的注册过程,但还有一个地方的写法比较麻烦,就是template模块写法。如果我们能将其中的HTML分离出来写,然后挂载到对应的组件上,必然结构会变得非常清晰。

Vue提供了两种方案来定义HTML模块内容:

  • 使用

23种设计模式


[TOC]

1、引子

1、设计模式采用的七大原则:

  • 单一职责原则

  • 接口隔离原则

  • 依赖倒转原则

  • 里氏替换原则

  • 开闭原则(ocp)

    • 工厂模式

      image-20210410201229604

  • 迪米特原则

  • 合成复用原则

单例设计模式一共有 8 种写法:

  • 饿汉式 两种
  • 懒汉式 三种
  • 双重检查
  • 静态内部类
  • 枚举

2、设计模式的重要性

  • 软件工程中,设计模式(design pattern)是对软件设计中普遍存在(反复出现)的各种问题,所提出的解决方案。这个术语是由埃里希·伽玛(Erich Gamma)等人在 1990 年代从建筑设计领域引入到计算机科学的
  • 拿实际工作经历来说, 当一个项目开发完后,如果客户提出增新功能,怎么办?(可扩展性,使用设计模式,软件具有很好的扩展性)
  • 如果项目开发完后,原来程序员离职,你接手维护该项目怎么办? (维护性[可读性、规范性])
  • 目前程序员门槛越来越高,一线IT公司(大厂),都会问你在实际项目中使用过什么设计模式,怎样使用的,解决了什么问题
  • 设计模式在软件中哪里?面向对象(oo)=>功能模块[设计模式+算法(数据结构)]=>框架[使用到多种设计模式]=> 架构 [服务器集群]
  • 如果想成为合格软件工程师,那就花时间来研究下设计模式是非常必要的.

3、设计模式的讲解过程

讲解的步骤

  1. 应用场景
  2. 普通代码解决
  3. 设计模式解决【对比】
  4. 剖析原理
  5. 分析实现步骤(图解)
  6. 代码实现
  7. 框架或项目源码分析(找到使用的地方) 的步骤讲解

2、设计模式七大原则(单接依里开迪合)

2.1、设计模式的目的

编写软件过程中,程序员面临着来自 耦合性,内聚性以及可维护性,可扩展性,重用性,灵活性 等多方面的挑战,设计模式是为了让程序(软件),具有更好的:

  1. 代码重用性 (即:相同功能的代码,不用多次编写)
  2. 可读性 (即:编程规范性, 便于其他程序员的阅读和理解)
  3. 可扩展性 (即:当需要增加新的功能时,非常的方便,称为可维护)
  4. 可靠性 (即:当我们增加新的功能后,对原来的功能没有影响)
  5. 使程序呈现高内聚,低耦合的特性

2.2 、设计模式七大原则

设计模式原则,其实就是程序员在编程时,应当遵守的原则,也是各种设计模式的基础(即:设计模式为什么这样设计的依据)

设计模式常用的七大原则有:

  1. 单一职责原则
  2. 接口隔离原则
  3. 依赖倒转(倒置)原则
  4. 里氏替换原则
  5. 开闭原则
  6. 迪米特法则
  7. 合成复用原则

2.3、单一职责原则(Single Responsibility Principle)

2.3.1、基本介绍

单一职责的含义是:类的职责单一,引起类变化的原因单一。对类来说的,即一个类应该只负责一项职责。解释一下,这也是灵活的前提,如果我们把类拆分成最小的职能单位,那组合与复用就简单的多了,如果一个类做的事情太多,在组合的时候,必然会产生不必要的方法出现,这实际上是一种污染。

如类 A 负责两个不同职责:职责 1,职责 2。当职责 1 需求变更而改变 A 时,可能造成职责 2 执行错误,所以需要将类 A 的粒度分解为 A1,A2。

SRP优点:消除耦合,减小因需求变化引起代码僵化。

2.3.2、应用实例

需求:以交通工具案例讲解(海陆空)

方案 1 [分析说明]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class SingleResponsibility1 {
public static void main(String[] args) {
// TODO Auto-generated method stub
Vehicle vehicle = new Vehicle();
vehicle.run("摩托车");
vehicle.run("汽车");
vehicle.run("飞机");
}

// 交通工具类
// 方式 1
// 1. 在方式 1 的 run 方法中,违反了单一职责原则
// 2. 解决的方案非常的简单,根据交通工具运行方法不同,分解成不同类即可
class Vehicle {
public void run(String vehicle) {
System.out.println(vehicle + " 在公路上运行....");
}
}
}

方案 2 [分析说明]

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
32
33
public class SingleResponsibility1 {
public static void main(String[] args) {
// TODO Auto-generated method stub
RoadVehicle roadVehicle = new RoadVehicle();
roadVehicle.run("摩托车");
roadVehicle.run("汽车");

AirVehicle airVehicle = new AirVehicle();
airVehicle.run("飞机");
}

//方案 2
//1. 遵守单一职责原则
//2. 但是这样做的改动很大,即将类分解,同时修改客户端
//3. 改进:直接修改 Vehicle 类,改动的代码会比较少=>方案 3
class RoadVehicle {
public void run(String vehicle) {
System.out.println(vehicle + "公路运行");
}
}

class AirVehicle {
public void run(String vehicle) {
System.out.println(vehicle + "天空运行");
}
}

class WaterVehicle {
public void run(String vehicle) {
System.out.println(vehicle + "水中运行");
}
}
}

方案 3 [分析说明]

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
public class SingleResponsibility3 {
public static void main(String[] args) {
// TODO Auto-generated method stub Vehicle2 vehicle2 = new Vehicle2();
vehicle2.run("汽车");
vehicle2.runWater("轮船");
vehicle2.runAir("飞机");
}

//方式 3
//1. 这种修改方法没有对原来的类做大的修改,只是增加方法
//2. 这里虽然没有在类这个级别上遵守单一职责原则,但是在方法级别上,仍然是遵守单一职责
class Vehicle2 {
public void run(String vehicle) {
//处理
System.out.println(vehicle + " 在公路上运行....");
}

public void runAir(String vehicle) {
System.out.println(vehicle + " 在天空上运行....");
}

public void runWater(String vehicle) {
System.out.println(vehicle + " 在水中行....");
}
}
}

2.3.3、单一职责原则注意事项和细节

  1. 降低类的复杂度,一个类只负责一项职责。
  2. 提高类的可读性,可维护性
  3. 降低变更引起的风险
  4. 在实际编码的过程中很难将它恰当地运用,需要结合实际情况进行运用。
  5. 通常情况下,我们应当遵守单一职责原则,只有逻辑足够简单,才可以在代码级违反单一职责原则;只有类中方法数量足够少,可以在方法级别保持单一职责原则

2.4 、接口隔离原则(Interface Segregation Principle)

2.4.1、基本介绍

  1. 它的含义是尽量使用职能单一的接口,而不使用职能复杂、全面的接口。

  2. 接口是为了让子类实现的,如果子类想达到职能单一,那么接口也必须满足职能单一。 相反,如果接口融合了多个不相关的方法,那它的子类就被迫要实现所有方法,尽管有些方法是根本用不到的。这就是接口污染。

  3. 客户端不应该依赖它不需要的接口,即一个类对另一个类的依赖应该建立在最小的接口上

  4. 先看一张图:

    image-20210411004513392

  5. 类 A 通过接口 Interface1 依赖类 B,类 C 通过接口 Interface1 依赖类 D,如果接口 Interface1 对于类 A 和类 C来说不是最小接口,那么类 B 和类 D 必须去实现他们不需要的方法。

  6. 按隔离原则应当这样处理:

    将接口 Interface1 拆分为独立的几个接口**(这里我们拆分成 **3 个接口**)**,类 A 和类 C 分别与他们需要的接口建立依赖关系。也就是采用接口隔离原则

2.4.2、应用实例

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
public class Segregation1 {
public static void main(String[] args) {
// TODO Auto-generated method stub
}
//接口
interface Interface1 {
void operation1();
void operation2();
void operation3();
void operation4();
void operation5();
}

class B implements Interface1 {
public void operation1() {
System.out.println("B 实现了 operation1");
}
public void operation2() {
System.out.println("B 实现了 operation2");
}
public void operation3() {
System.out.println("B 实现了 operation3");
}
public void operation4() {
System.out.println("B 实现了 operation4");
}
public void operation5() {
System.out.println("B 实现了 operation5");
}
}

class D implements Interface1 {
public void operation1() {
System.out.println("D 实现了 operation1");
}
public void operation2() {
System.out.println("D 实现了 operation2");
}
public void operation3() {
System.out.println("D 实现了 operation3");
}
public void operation4() {
System.out.println("D 实现了 operation4");
}
public void operation5() {
System.out.println("D 实现了 operation5");
}
}

class A { //A 类通过接口 Interface1 依赖(使用) B 类,但是只会用到 1,2,3 方法
public void depend1(Interface1 i) {
i.operation1();
}
public void depend2(Interface1 i) {
i.operation2();
}
public void depend3(Interface1 i) {
i.operation3();
}
}

class C { //C 类通过接口 Interface1 依赖(使用) D 类,但是只会用到 1,4,5 方法
public void depend1(Interface1 i) {
i.operation1();
}
public void depend4(Interface1 i) {
i.operation4();
}
public void depend5(Interface1 i) {
i.operation5();
}
}

}

2.4.3、应传统方法的问题和使用接口隔离原则改进

  1. 类 A 通过接口 Interface1 依赖类 B,类 C 通过接口 Interface1 依赖类 D,如果接口 Interface1 对于类 A 和类 C来说不是最小接口,那么类 B 和类 D 必须去实现他们不需要的方法

  2. 将接口 Interface1 拆分为独立的几个接口,类 A 和类 C 分别与他们需要的接口建立依赖关系。也就是采用接口隔离原则

  3. 接口 Interface1 中出现的方法,根据实际情况拆分为三个接口:

    image-20210411010354543

  4. 代码实现

    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
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    public class Segregation1 {
    public static void main(String[] args) {
    // TODO Auto-generated method stub
    // 使用一把
    A a = new A();
    a.depend1(new B()); // A 类通过接口去依赖 B 类
    a.depend2(new B());
    a.depend3(new B());

    C c = new C();

    c.depend1(new D()); // C 类通过接口去依赖(使用)D 类
    c.depend4(new D());
    c.depend5(new D());
    }
    }

    // 接 口 1
    interface Interface1 {
    void operation1();
    }

    // 接 口 2
    interface Interface2 {
    void operation2();
    void operation3();
    }
    // 接 口 3
    interface Interface3 {
    void operation4();
    void operation5();
    }

    class B implements Interface1, Interface2 {
    public void operation1() {
    System.out.println("B 实现了 operation1");
    }
    public void operation2() {
    System.out.println("B 实现了 operation2");
    }
    public void operation3() {
    System.out.println("B 实现了 operation3");
    }
    }

    class D implements Interface1, Interface3 {
    public void operation1() {
    System.out.println("D 实现了 operation1");
    }
    public void operation4() {
    System.out.println("D 实现了 operation4");
    }
    public void operation5() {
    System.out.println("D 实现了 operation5");
    }
    }

    class A { // A 类通过接口 Interface1,Interface2 依赖(使用) B 类,但是只会用到 1,2,3 方法
    public void depend1(Interface1 i) {
    i.operation1();
    }
    public void depend2(Interface2 i) {
    i.operation2();
    }
    public void depend3(Interface2 i) {
    i.operation3();
    }
    }

    class C { // C 类通过接口 Interface1,Interface3 依赖(使用) D 类,但是只会用到 1,4,5 方法
    public void depend1(Interface1 i) {
    i.operation1();
    }
    public void depend4(Interface3 i) {
    i.operation4();
    }
    public void depend5(Interface3 i) {
    i.operation5();
    }
    }

2.4.4、接口隔离原则注意事项和细节

  1. 接口隔离原则的思想在于建立单一接口,尽可能地去细化接口,接口中的方法尽可能少
  2. 但是凡事都要有个度,如果接口设计过小,则会造成接口数量过多,使设计复杂化。所以一定要适度。

2.5 、依赖倒转原则(Dependence Inversion Principle)

2.5.1、基本介绍

依赖倒转原则(Dependence Inversion Principle)是指:

  1. 高层模块不应该依赖低层模块,二者都应该依赖其抽象
  2. 抽象不应该依赖细节,细节应该依赖抽象
  3. 依赖倒转(倒置)的中心思想是面向接口编程面向抽象编程,解耦调用和被调用者
  4. 依赖倒转原则是基于这样的设计理念:相对于细节的多变性,抽象的东西要稳定的多。以抽象为基础搭建的架构比以细节为基础的架构要稳定的多。在 java 中,抽象指的是接口或抽象类,细节就是具体的实现类
  5. 当两个模块之间存在紧密的耦合关系时,最好的方法就是分离接口和实现:在依赖之间定义一个抽象的接口使得高层模块调用接口,而底层模块实现接口的定义,以此来有效控制耦合关系,达到依赖于抽象的设计目标。
  6. 使用接口或抽象类的目的是制定好规范,而不涉及任何具体的操作,把展现细节的任务交给他们的实现类去完成

2.5.2、应用实例

请编程完成 Person 接收消息 的功能。

实现方案 1 + 分析说明:

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 DependecyInversion {
public static void main(String[] args) {
Person person = new Person();
person.receive(new Email());
}
}

class Email {
public String getInfo() {
return "电子邮件信息: hello,world";
}
}

//完成Person接收消息的功能
//方式1分析
//1. 简单,比较容易想到
//2. 但如果我们获取的对象是 微信,短信等等,则新增类,同时Perons也要增加相应的接收方法
//3. 解决思路:引入一个抽象的接口IReceiver, 表示接收者, 这样Person类与接口IReceiver发生依赖
// 因为Email, WeiXin 等等属于接收的范围,他们各自实现IReceiver 接口就ok, 这样我们就符号依赖倒转原则
class Person {
public void receive(Email email) {
System.out.println(email.getInfo());
}
}

实现方案 2(依赖倒转) + 分析说明(同时也满足了开闭原则ocp)

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
32
33
34
35
public class DependecyInversion {
public static void main(String[] args) {
//客户端无需改变
Person person = new Person();
person.receive(new Email());

person.receive(new WeiXin());
}
}

//定义接口
interface IReceiver {
public String getInfo();
}

class Email implements IReceiver {
public String getInfo() {
return "电子邮件信息: hello,world";
}
}

//增加微信
class WeiXin implements IReceiver {
public String getInfo() {
return "微信信息: hello,ok";
}
}

//方式2
class Person {
//这里我们是对接口的依赖
public void receive(IReceiver receiver ) {
System.out.println(receiver.getInfo());
}
}

2.5.3、 依赖关系传递的三种方式和应用案例

2.5.3.1、接口传递

实现:

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
32
33
34
35
36
37
38
39
40
41
42
public class DependencyPass {

public static void main(String[] args) {
ChangHong changHong = new ChangHong();
// 方式1: 通过接口传递实现依赖
OpenAndClose openAndClose = new OpenAndClose();
openAndClose.open(changHong);
}

}

// 方式1: 通过接口传递实现依赖
// 开关的接口
interface IOpenAndClose {
//抽象方法,接收接口
public void open(ITV tv);
}
//ITV接口
interface ITV {
public void play();
}

class ChangHong implements ITV {
@Override
public void play() {
System.out.println("长虹电视机,打开");
}
}
// 实现IOpenAndClose接口
class OpenAndClose implements IOpenAndClose{
public void open(ITV tv){
tv.play();
}
}
// 实现ITV接口
class ChangHong implements ITV {
@Override
public void play() {
// TODO Auto-generated method stub
System.out.println("长虹电视机,打开");
}
}
2.5.3.2、构造方法传递

实现:

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
32
33
34
35
36
37
38
public class DependencyPass {
public static void main(String[] args) {
ChangHong changHong = new ChangHong();
// 方式2: 通过构造方法依赖传递
OpenAndClose openAndClose = new OpenAndClose(changHong);
openAndClose.open();
}
}

// 方式2: 通过构造方法依赖传递
interface IOpenAndClose {
//抽象方法
public void open();
}
//ITV接口
interface ITV {
public void play();
}
// 实现IOpenAndClose接口
class OpenAndClose implements IOpenAndClose{
//成员
public ITV tv;
//构造器
public OpenAndClose(ITV tv){
this.tv = tv;
}
public void open(){
this.tv.play();
}
}
// 实现ITV接口
class ChangHong implements ITV {
@Override
public void play() {
// TODO Auto-generated method stub
System.out.println("长虹电视机,打开");
}
}
2.5.3.3、setter 方式传递

实现:

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
32
33
34
35
36
37
38
39
public class DependencyPass {
public static void main(String[] args) {
ChangHong changHong = new ChangHong();
//通过setter方法进行依赖传递
OpenAndClose openAndClose = new OpenAndClose();
openAndClose.setTv(changHong);
openAndClose.open();
}
}

// 方式3 , 通过setter方法传递
interface IOpenAndClose {
// 抽象方法
public void open();
// setter方法
public void setTv(ITV tv);
}
// ITV接口
interface ITV {
public void play();
}
// 实现IOpenAndClose接口
class OpenAndClose implements IOpenAndClose {
private ITV tv;
public void setTv(ITV tv) {
this.tv = tv;
}
public void open() {
this.tv.play();
}
}
// 实现ITV接口
class ChangHong implements ITV {
@Override
public void play() {
// TODO Auto-generated method stub
System.out.println("长虹电视机,打开");
}
}

2.5.4、依赖倒转原则的注意事项和细节

  1. 低层模块尽量都要有抽象类或接口,或者两者都有,程序稳定性更好.
  2. 变量的声明类型尽量是抽象类或接口, 这样我们的变量引用和实际对象间,就存在一个缓冲层,利于程序扩展和优化
  3. 继承时遵循里氏替换原则.

2.6、里氏替换原则(Liskov Substitution Principle)

2.6.1、OO 中的继承性的思考和说明

  1. 继承包含这样一层含义:父类中凡是已经实现好的方法,实际上是在设定规范和契约,虽然它不强制要求所有的子类必须遵循这些契约,但是如果子类对这些已经实现的方法任意修改,就会对整个继承体系造成破坏
  2. 继承在给程序设计带来便利的同时,也带来了弊端。比如使用继承会给程序带来侵入性,程序的可移植性降低增加对象间的耦合性,如果一个类被其他的类所继承,则当这个类需要修改时,必须考虑到所有的子类,并且父类修改后,所有涉及到子类的功能都有可能产生故障
  3. 里氏替换原则的重点在不影响原功能,而不是不覆盖原方法。
  4. 问题提出:在编程中,如何正确的使用继承? => 里氏替换原则

2.6.2、 基本介绍

  1. 里氏替换原则(Liskov Substitution Principle)在 1988 年,由麻省理工学院的以为姓里的女士提出的。
  2. 里氏替换原则的含义是:子类可以在任何地方替换它的父类。
  3. 也就是说在程序中将基类替换为子类,程序的行为不会发生任何变化。
  4. Liskov替换原则是关于继承机制的设计原则违反了Liskov替换原则就必然导致违反开放封闭原则
  5. 如果对每个类型为 T1 的对象 o1,都有类型为 T2 的对象 o2,使得以 T1 定义的所有程序 P 在所有的对象 o1 都代换成 o2 时,程序 P 的行为没有发生变化,那么类型 T2 是类型 T1 的子类型。换句话说,所有引用基类的地方必须能透明地使用其子类的对象
  6. 在使用继承时,遵循里氏替换原则,在**子类中尽量不要重写父类的方法**。
  7. 里氏替换原则告诉我们,继承实际上让两个类耦合性增强了,在适当的情况下,可以通过**聚合,组合,依赖**来解决问题。

里氏原则的优点:

  1. 能够保证系统具有良好的拓展性
  2. 同时实现基于多态的抽象机制
  3. 能够减少代码冗余
  4. 避免运行期的类型判别

2.6.3、 一个程序引出的问题和思考

image-20210411022902982

程序员原本是想调用b中继承的a的func1的方法求出11-3,但b无意重写了a的func1方法,使相减变成了相加。

2.6.4、解决方法

  1. 我们发现原来运行正常的相减功能发生了错误。原因就是类 B 无意中重写了父类的方法,造成原有功能出现错误。在实际编程中,我们常常会通过重写父类的方法完成新的功能,这样写起来虽然简单,但整个继承体系的复用性会比较差。特别是运行多态比较频繁的时候

  2. 通用的做法是:原来的父类和子类都继承一个更通俗的基类,原有的继承关系去掉,采用依赖,聚合,组合等关系代替.

  3. 即:子类可以扩展父类的功能,但不能改变父类原有的功能。

  4. 改进方案:

    image-20210411023703967

    代码实现:

    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
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    public class Liskov {
    public static void main(String[] args) {
    A a = new A();
    System.out.println("11-3=" + a.func1(11, 3));
    System.out.println("1-8=" + a.func1(1, 8));
    System.out.println("-----------------------");
    B b = new B();
    //因为B类不再继承A类,因此调用者,不会再认为func1是求减法
    //调用完成的功能就会很明确
    System.out.println("11+3=" + b.func1(11, 3));//这里本意是求出11+3
    System.out.println("1+8=" + b.func1(1, 8));// 1+8
    System.out.println("11+3+9=" + b.func2(11, 3));

    //使用组合仍然可以使用到A类相关方法
    System.out.println("11-3=" + b.func3(11, 3));// 这里本意是求出11-3
    }
    }
    //创建一个更加基础的基类
    class Base {
    //把更加基础的方法和成员写到Base类
    public int func1(int num1, int num2) {}
    }
    // A类继承了Base
    class A extends Base {
    // 重写func1返回两个数的差
    public int func1(int num1, int num2) {
    return num1 - num2;
    }
    }
    // B类继承了Base
    // 增加了一个新功能:完成两个数相加,然后和9求和
    class B extends Base {
    //如果B需要使用A类的方法,使用组合关系
    private A a = new A();

    //这里,重写了Base类的方法,
    public int func1(int a, int b) {
    return a + b;
    }

    public int func2(int a, int b) {
    return func1(a, b) + 9;
    }

    //我们仍然想使用A的方法
    public int func3(int a, int b) {
    return this.a.func1(a, b);
    }
    }

2.7、开闭原则(Open Closed Principle)

2.7.1、基本介绍

  1. 开闭原则(Open Closed Principle)是编程中最基础、最重要的设计原则
  2. 一个软件实体如类,模块和函数应该对扩展开放(对提供方)**,对修改关闭(对使用方)。用抽象构建框架,用实现扩展细节**。
  3. 采用逆向思维方式来想。如果每次需求变动都去修改原有的代码,那原有的代码就存在被修改错误的风险,当然这其中存在有意和无意的修改,都会导致原有正常运行的功能失效的风险,这样很有可能会展开可怕的蝴蝶效应,使维护工作剧增。
  4. 所以当软件需要变化时,尽量通过扩展软件实体的行为来实现变化,而不是通过修改已有的代码来实现变化。
  5. 编程中遵循其它原则,以及使用设计模式的目的就是遵循开闭原则

2.7.2、看下面一段代码

实现画图形的功能

类图:

image-20210411025709019

代码:

image-20210411025938974

但我们增加一个功能:画三角形

方式1:

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
public class Ocp {
public static void main(String[] args) {
GraphicEditor graphicEditor = new GraphicEditor();
graphicEditor.drawShape(new Rectangle());
graphicEditor.drawShape(new Circle());
//使用看看存在的问题
graphicEditor.drawShape(new Triangle());
}
}

//这是一个用于绘图的类 [使用方]
class GraphicEditor {
//接收Shape对象,然后根据type,来绘制不同的图形
public void drawShape(Shape s) {
if (s.m_type == 1)
drawRectangle(s);
else if (s.m_type == 2)
drawCircle(s);
// 修改1
else if (s.m_type == 3)
drawTriangle(s);
}
//绘制矩形
public void drawRectangle(Shape r) {
System.out.println(" 绘制矩形 ");
}
//绘制圆形
public void drawCircle(Shape r) {
System.out.println(" 绘制圆形 ");
}
//绘制三角形
//修改2
public void drawTriangle(Shape r) {
System.out.println(" 绘制三角形 ");
}
}
//Shape类,基类
class Shape {
int m_type;
}
class Rectangle extends Shape {
Rectangle() {
super.m_type = 1;
}
}
class Circle extends Shape {
Circle() {
super.m_type = 2;
}
}
//新增功能:画三角形
class Triangle extends Shape {
Triangle() {
super.m_type = 3;
}
}

2.7.3、方式 1 的优缺点

  1. 优点是比较好理解,简单易操作。

  2. 缺点是违反了设计模式的 ocp 原则,即**对扩展开放(提供方),对修改关闭(使用方)**。即当我们给类增加新功能的时候,尽量不修改代码,或者尽可能少修改代码.

  3. 比如我们这时要新增加一个图形种类三角形,我们需要做如下修改,修改的地方较多(使用方要修改两次)

  4. 代码演示(方式2)

    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
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    public class Ocp {

    public static void main(String[] args) {
    GraphicEditor graphicEditor = new GraphicEditor();
    graphicEditor.drawShape(new Rectangle());
    graphicEditor.drawShape(new Circle());
    //使用方直接使用
    graphicEditor.drawShape(new Triangle());
    }

    }
    //这是一个用于绘图的类 [使用方]
    class GraphicEditor {
    //接收Shape对象,调用draw方法
    public void drawShape(Shape s) {
    s.draw();
    }
    }
    //Shape类,基类(使用抽象类)
    abstract class Shape {
    //抽象方法
    public abstract void draw();
    }
    // 继承抽象类Shape
    class Rectangle extends Shape {
    @Override
    public void draw() {
    // TODO Auto-generated method stub
    System.out.println(" 绘制矩形 ");
    }
    }
    // 继承抽象类Shape
    class Circle extends Shape {
    @Override
    public void draw() {
    // TODO Auto-generated method stub
    System.out.println(" 绘制圆形 ");
    }
    }
    //新增功能:画三角形
    // 继承抽象类Shape
    class Triangle extends Shape {
    @Override
    public void draw() {
    // TODO Auto-generated method stub
    System.out.println(" 绘制三角形 ");
    }
    }

2.7.4、改进的思路分析

把创建 Shape 类做成抽象类,并提供一个抽象的 draw 方法,让子类去实现即可,这样我们有新的图形种类时,只需要让新的图形类继承 Shape,并实现 draw 方法即可,使用方的代码就不需要修改-> 满足了开闭原则

2.7.5、开闭原则注意事项和细节

  1. OCP 可以具有良好的可扩展性,可维护性。
  2. 不可能让一个系统的所有模块都满足 OCP 原则,我们能做到的是尽可能地不要修改已经写好的代码,已有的功能,而是去扩展它。

2.8、迪米特法则(Demeter Principle)

2.8.1、基本介绍

  1. 迪米特原则要求尽量的封装,尽量的独立,尽量的使用低级别的访问修饰符。就是说一个对象应当对其他对象有尽可能少的了解,不和陌生人说话。
  2. 一个对象应该对其他对象保持最少的了解
  3. 类与类关系越密切,耦合度越大
  4. 迪米特法则(Demeter Principle)又叫最少知道原则,即一个类对自己依赖的类知道的越少越好。也就是说,对于被依赖的类不管多么复杂,都尽量将逻辑封装在类的内部。对外除了提供的 public 方法,不对外泄露任何信息
  5. 迪米特法则还有个更简单的定义:只与直接的朋友通信
  6. 直接的朋友:每个对象都会与其他对象有耦合关系,只要两个对象之间有耦合关系,我们就说这两个对象之间是朋友关系。耦合的方式很多,依赖关联组合聚合等。其中,我们称出现成员变量方法参数方法返回值中的类为直接的朋友,而出现在局部变量中的类不是直接的朋友。也就是说,陌生的类最好不要以局部变量的形式出现在类的内部。最好将其封装到直接朋友里面。
  7. 迪米特原则要求类之间的直接联系尽量的少,两个类的访问,通过第三个中介类来实现。

2.8.2、应用实例

有一个学校,下属有各个学院和总部,现要求打印出学校总部员工 ID 和学院员工的 id。编程实现上面的功能, 看代码演示

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
//客户端
public class Demeter1 {
public static void main(String[] args) {
//创建了一个 SchoolManager 对象
SchoolManager schoolManager = new SchoolManager();
//输出学院的员工id 和 学校总部的员工信息
schoolManager.printAllEmployee(new CollegeManager());
}
}
//学校总部员工类
class Employee {
private String id;
public void setId(String id) {
this.id = id;
}
public String getId() {
return id;
}
}
//学院的员工类
class CollegeEmployee {
private String id;
public void setId(String id) {
this.id = id;
}
public String getId() {
return id;
}
}

//管理学院员工的管理类
class CollegeManager {
////添加学院的员工
public List<CollegeEmployee> getAllEmployee() {
List<CollegeEmployee> list = new ArrayList<CollegeEmployee>();
for (int i = 0; i < 10; i++) { //这里我们增加了10个员工到 list
CollegeEmployee emp = new CollegeEmployee();
emp.setId("学院员工id= " + i);
list.add(emp);
}
return list;
}
}
//学校管理类
//分析 SchoolManager 类的直接朋友类有哪些 Employee、CollegeManager
//CollegeEmployee 不是 直接朋友 而是一个陌生类,这样违背了 迪米特法则
class SchoolManager {
//返回学校总部的员工
public List<Employee> getAllEmployee() {
List<Employee> list = new ArrayList<Employee>();
//这里我们增加了5个员工到 list
for (int i = 0; i < 5; i++) {
Employee emp = new Employee();
emp.setId("学校总部员工id= " + i);
list.add(emp);
}
return list;
}
//该方法完成输出学校总部和学院员工信息(id)
void printAllEmployee(CollegeManager sub) {
//分析问题
//1. 这里的 CollegeEmployee 不是 SchoolManager的直接朋友
//2. CollegeEmployee 是以局部变量方式出现在 SchoolManager
//3. 违反了 迪米特法则

//获取到学院员工
List<CollegeEmployee> list1 = sub.getAllEmployee();
System.out.println("------------学院员工------------");
for (CollegeEmployee e : list1) {
System.out.println(e.getId());
}
//获取到学校总部员工
List<Employee> list2 = this.getAllEmployee();
System.out.println("------------学校总部员工------------");
for (Employee e : list2) {
System.out.println(e.getId());
}
}
}

2.8.3、应用实例改进

  1. 前面设计的问题在于 SchoolManager 中,CollegeEmployee 类并不是 SchoolManager 类的直接朋友 (分析)

  2. 按照迪米特法则,应该避免类中出现这样非直接朋友关系的耦合

  3. 对代码按照迪米特法则 进行改进:

  4. 代码演示:

    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
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    //客户端
    public class Demeter1 {
    public static void main(String[] args) {
    //创建了一个 SchoolManager 对象
    SchoolManager schoolManager = new SchoolManager();
    //输出学院的员工id 和 学校总部的员工信息
    schoolManager.printAllEmployee(new CollegeManager());
    }
    }
    //学校总部员工类
    class Employee {
    private String id;
    public void setId(String id) {
    this.id = id;
    }
    public String getId() {
    return id;
    }
    }
    //学院的员工类
    class CollegeEmployee {
    private String id;
    public void setId(String id) {
    this.id = id;
    }
    public String getId() {
    return id;
    }
    }
    //管理学院员工的管理类
    class CollegeManager {
    //添加学院的员工
    public List<CollegeEmployee> getAllEmployee() {
    List<CollegeEmployee> list = new ArrayList<CollegeEmployee>();
    //这里我们增加了10个员工到 list
    for (int i = 0; i < 10; i++) {
    CollegeEmployee emp = new CollegeEmployee();
    emp.setId("学院员工id= " + i);
    list.add(emp);
    }
    return list;
    }
    //输出学院员工的信息
    public void printEmployee() {
    //获取到学院员工
    List<CollegeEmployee> list1 = getAllEmployee();
    System.out.println("------------学院员工------------");
    for (CollegeEmployee e : list1) {
    System.out.println(e.getId());
    }
    }
    }
    //学校管理类
    class SchoolManager {
    //返回学校总部的员工
    public List<Employee> getAllEmployee() {
    List<Employee> list = new ArrayList<Employee>();
    //这里我们增加了5个员工到 list
    for (int i = 0; i < 5; i++) {
    Employee emp = new Employee();
    emp.setId("学校总部员工id= " + i);
    list.add(emp);
    }
    return list;
    }
    //该方法完成输出学校总部和学院员工信息(id)
    void printAllEmployee(CollegeManager sub) {
    //分析问题
    //1. 将输出学院的员工方法,封装到CollegeManager
    sub.printEmployee();
    //获取到学校总部员工
    List<Employee> list2 = this.getAllEmployee();
    System.out.println("------------学校总部员工------------");
    for (Employee e : list2) {
    System.out.println(e.getId());
    }
    }
    }

2.8.4、迪米特法则注意事项和细节

  1. 迪米特法则的核心是降低类之间的耦合

  2. 但是注意:由于每个类都减少了不必要的依赖,因此迪米特法则只是要求降低类间(对象间)耦合关系, 并不是要求完全没有依赖关系

2.9、合成复用原则(Composite Reuse Principle)

2.9.1、基本介绍

原则是尽量使用合成/聚合的方式,而不是使用继承

聚合组合是一种 “黑箱” 复用,因为细节对象的内容对客户端来说是不可见的。

因为继承的耦合性更大,组合聚合只是引用其他的类的方法,而不会受引用的类的继承而改变血统。说白了就是我只用你的方法,但我们并不是同类。

在面向对象的设计中,如果直接继承基类,会破坏封装,因为继承将基类的实现细节暴露给子类;如果基类的实现发生了改变,则子类的实现也不得不改变;从基类继承而来的实现是静态的,不可能在运行时发生改变,没有足够的灵活性。于是就提出了组合/聚合复用原则,也就是在实际开发设计中,尽量使用组合/聚合,不要使用类继承。

image-20210411150334377

2.10、设计原则核心思想

  1. 找出应用中可能需要变化之处,把它们独立出来,不要和那些不需要变化的代码混在一起。
  2. 针对接口编程,而不是针对实现编程。
  3. 为了交互对象之间的松耦合设计而努力

2.11、设计七大原则总结

这 7 种设计原则是软件设计模式必须尽量遵循的原则,是设计模式的基础。在实际开发过程中,并不是一定要求所有代码都遵循设计原则,而是要综合考虑人力、时间、成本、质量,不刻意追求完美,要在适当的场景遵循设计原则。这体现的是一种平衡取舍,可以帮助我们设计出更加优雅的代码结构。

各种原则要求的侧重点不同,下面我们分别用一句话归纳总结软件设计模式的七大原则,如下表所示。

设计原则 一句话归纳 目的
开闭原则 对扩展开放,对修改关闭 降低维护带来的新风险
依赖倒置原则 高层不应该依赖低层,要面向接口编程 更利于代码结构的升级扩展
单一职责原则 一个类只干一件事,实现类要单一 便于理解,提高代码的可读性
接口隔离原则 一个接口只干一件事,接口要精简单一 功能解耦,高聚合、低耦合
迪米特法则 不该知道的不要知道,一个类应该保持对其它对象最少的了解,降低耦合度 只和朋友交流,不和陌生人说话,减少代码臃肿
里氏替换原则 不要破坏继承体系,子类重写方法功能发生改变,不应该影响父类方法的含义 防止继承泛滥
合成复用原则 尽量使用组合或者聚合关系实现代码复用,少使用继承 降低代码耦合

实际上,这些原则的目的只有一个:降低对象之间的耦合,增加程序的可复用性、可扩展性和可维护性

记忆口诀:访问加限制,函数要节俭,依赖不允许,动态加接口,父类要抽象,扩展不更改。

在程序设计时,我们应该将程序功能最小化,每个类只干一件事。若有类似功能基础之上添加新功能,则要合理使用继承。对于多方法的调用,要会运用接口,同时合理设置接口功能与数量。最后类与类之间做到低耦合高内聚。

3、UML 类图

3.1、UML 基本介绍

  1. UML——Unified modeling language UML (统一建模语言),是一种用于软件系统分析和设计的语言工具,它用于帮助软件开发人员进行思考和记录思路的结果

  2. UML 本身是一套符号的规定,就像数学符号和化学符号一样,这些符号用于描述软件模型中的各个元素和他们之间的关系,比如类、接口、实现、泛化、依赖、组合、聚合等,如右图:

    image-20210411172838273

    image-20210411173002251

  3. 使用 UML 来建模,常用的工具有 Rational Rose , 也可以使用一些插件来建模

3.2、UML 图

画 UML 图与写文章差不多,都是把自己的思想描述给别人看,关键在于思路和条理,UML 图分类:

  1. 用例图(use case)
  2. 静态结构图:类图、对象图、包图、组件图、部署图
  3. 动态行为图:交互图(时序图与协作图)、状态图、活动图

说明:

  1. 类图是描述类与类之间的关系的,是 UML 图中最核心的
  2. 在讲解设计模式时,我们必然会使用类图,为了让学员们能够把设计模式学到位,需要先给大家讲解类图

3.3、UML 类图

  1. 用于描述系统中的类**(对象)本身的组成和类(对象)**之间的各种静态关系。

  2. 类之间的关系:依赖、泛化(继承)、实现、关联、聚合与组合。

  3. 类图简单举例代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    public class Person{ //代码形式->类图
    private Integer id; private String name;
    public void setName(String name){
    this.name=name;
    }
    public String getName(){
    return name;
    }
    }
  4. 类图

    image-20210411192255196

3.4、类图—依赖关系(Dependence)

只要是在类中用到了对方,那么他们之间就存在依赖关系。如果没有对方,连编绎都通过不了。

对应类图:

image-20210411192418668

依赖关系小结:

  1. 类中用到了对方
  2. 如果是类的成员属性
  3. 如果是方法的返回类型
  4. 是方法接收的参数类型
  5. 方法中使用到

3.5、类图—泛化关系(generalization)

泛化关系实际上就是继承关系,他是依赖关系的特例

相关类图:

image-20210411192748570

泛化关系小结:

  1. 泛化关系实际上就是继承关系
  2. 如果 A 类继承了 B 类,我们就说 A 和 B 存在泛化关系

3.6、类图—实现关系(Implementation)

实现关系实际上就是 A 类实现 B 接口,他是依赖关系的特例

相关类图:

image-20210411193029031

3.7、类图—关联关系(Association)

image-20210411193416238

3.8、类图—聚合关系(Aggregation)

3.8.1、基本介绍

聚合关系(Aggregation)表示的是整体和部分的关系整体与部分可以分开。聚合关系是关联关系的特例,所以他具有关联的导航性与多重性

如:一台电脑由键盘(keyboard)、显示器(monitor),鼠标等组成;组成电脑的各个配件是可以从电脑上分离出来的,使用带空心菱形的实线来表示:

image-20210411193519679

3.9、类图—组合关系(Composition)

3.9.1、基本介绍

组合关系:也是整体与部分的关系,但是整体与部分不可以分开

再看一个案例:在程序中我们定义实体:Person 与 IDCard、Head, 那么 Head 和 Person 就是 组合IDCard 和Person 就是聚合。

但是如果在程序中 Person 实体中定义了对 IDCard 进行级联删除,即删除 Person 时连同 IDCard 一起删除,那么 IDCard 和 Person 就是组合了.

代码:

1
2
3
4
5
6
7
public class Person{ 
private IDCard card;
// 在创建Person对象的同时创建了Head对象
private Head head = new Head();
}
public class IDCard{}
public class Head{}

对应类图:

image-20210411193901631

4、设计模式概述

4.1、设计模式介绍

  1. 设计模式是程序员在面对同类软件工程设计问题所总结出来的有用的经验,模式不是代码,而是某类问题的通用解决方案,设计模式(Design pattern)代表了最佳的实践。这些解决方案是众多软件开发人员经过相当长的一段时间的试验和错误总结出来的。
  2. 设计模式的本质提高软件的维护性,通用性和扩展性,并降低软件的复杂度
  3. <<设计模式>> 是经典的书,作者是 Erich Gamma、Richard Helm、Ralph Johnson 和 John Vlissides Design(俗称 “四人组 GOF”)
  4. 设计模式并不局限于某种语言,java,php,c++ 都有设计模式.

4.2、设计模式类型

设计模式分为三种类型,共 23

  1. 创建型模式:单例模式、抽象工厂模式、原型模式、建造者模式、工厂模式
  2. 结构型模式:适配器模式、桥接模式、装饰模式、组合模式、外观模式、享元模式、代理模式
  3. 行为型模式:模版方法模式、命令模式、访问者模式、迭代器模式、观察者模式、中介者模式、备忘录模式、解释器模式(Interpreter 模式)、状态模式、策略模式、职责链模式(责任链模式)。

注意:不同的书籍上对分类和名称略有差别

对于创建型模式的概述请看第27点

对于结构型模式的概述请看第28点

对于行为型模式的概述请看第29点

5、单例设计模式Singleton(创建型设计模式)

image-20210415031845856

5.1、单例设计模式介绍

所谓类的单例设计模式,就是采取一定的方法保证在整个的软件系统中,对某个类只能存在一个对象实例, 并且该类**只提供一个取得其对象实例的方法(静态方法)**。

比如 Hibernate 的 SessionFactory,它充当数据存储源的代理,并负责创建 Session 对象。SessionFactory 并不是轻量级的,一般情况下,一个项目通常只需要一个 SessionFactory 就够,这是就会使用到单例模式

注意:

  1. 单例类只有一个实例对象;
  2. 该单例对象必须由单例类自行创建;
  3. 单例类对外提供一个访问该单例的全局访问点。

测试方法(除了枚举):

1
2
3
4
5
6
7
8
9
10
11
public class Singleton {
public static void main(String[] args) {
//判断创建的两个实例是不是同一个
Singleton instance = Singleton.getInstance();
Singleton instance2 = Singleton.getInstance();
// true
System.out.println(instance == instance2);
System.out.println("instance.hashCode=" + instance.hashCode());
System.out.println("instance2.hashCode=" + instance2.hashCode());
}
}

5.2、单例设计模式八种方式

加黑属于推荐使用

  1. 饿汉式(两种)
    1. 饿汉式**(静态常量)**
    2. 饿汉式(静态代码块)
  2. 懒汉式(三种)
    1. 懒汉式(线程不安全)
    2. 懒汉式(线程安全,同步方法)
    3. 懒汉式(同步代码块)
  3. 双重检查
  4. 静态内部类
  5. 枚举

5.3、饿汉式(两种)

5.3.1、饿汉式(静态常量)

使用步骤:

  1. 构造器私有化 (防止外部使用new创建实例)
  2. 类的内部创建对象
  3. 向外暴露一个静态的公共方法getInstance

代码实现:

1
2
3
4
5
6
7
8
9
10
11
// 饿汉式(静态常量)
final class Singleton {
//1. 构造器私有化, 外部不能new
private Singleton() {}
//2.本类内部创建对象实例
private final static Singleton instance = new Singleton();
//3. 提供一个公有的静态方法,返回实例对象
public static Singleton getInstance() {
return instance;
}
}

优缺点说明:

  • 优点:这种写法比较简单,就是在类装载的时候就完成实例化。避免了线程同步问题。
  • 缺点:在类装载的时候就完成实例化,没有达到 Lazy Loading 的效果。如果从始至终从未使用过这个实例,则会造成内存的浪费
  • 这种方式基于 classloder 机制避免了多线程的同步问题,不过,instance 在类装载时就实例化,在单例模式中大多数都是调用 getInstance 方法, 但是导致类装载的原因有很多种,因此不能确定有其他的方式(或者其他的静态方法)导致类装载,这时候初始化 instance 就没有达到 lazy loading 的效果
  • 结论:这种单例模式可用可能造成内存浪费,同时也不能实现懒加载(lazy loading)

5.3.2、饿汉式(静态代码块)

使用步骤:

  1. 构造器私有化 (防止外部使用new创建实例)
  2. 静态代码块中,创建单例对象
  3. 向外暴露一个静态的公共方法getInstance

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 饿汉式(静态代码块)
final class Singleton {
//1. 构造器私有化, 外部不能new
private Singleton() {}
// 2.在静态代码块中,创建单例对象
private static Singleton instance;
static {
instance = new Singleton();
}
//3. 提供一个公有的静态方法,返回实例对象
public static Singleton getInstance() {
return instance;
}
}

优缺点说明:

  1. 这种方式和上面的方式其实类似,只不过将类实例化的过程放在了静态代码块中,也是在类装载的时候,就执行静态代码块中的代码初始化类的实例。优缺点和上面是一样的。
  2. 结论:这种单例模式可用,但是可能造成内存浪费

5.4、懒汉式(三种)

5.4.1、懒汉式(线程不安全)

使用步骤:

  1. 构造器私有化 (防止外部使用new创建实例)
  2. 提供一个静态的公有方法getInstance(),当使用到该方法时,才去创建 instance

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 懒汉式(线程不安全)
final class Singleton {
private static Singleton instance;
// 1.构造器私有化(防止外部使用new创建实例)
private Singleton() {}

//2.提供一个静态的公有方法,当使用到该方法时,才去创建 instance
//即懒汉式
public static Singleton getInstance() {
if(instance == null) {
instance = new Singleton();
}
return instance;
}
}

优缺点说明:

  1. 起到了 Lazy Loading 的效果,但是只能在单线程下使用
  2. 如果在多线程下,一个线程进入了 if (singleton == null)判断语句块,还未来得及往下执行,另一个线程也通过了这个判断语句,这时便会产生多个实例。所以在多线程环境下不可使用这种方式。(线程不安全)
  3. 结论:在实际开发中,不要使用这种方式.

5.4.2、懒汉式(线程安全,同步方法)

使用步骤:

  1. 构造器私有化 (防止外部使用new创建实例)
  2. 提供一个静态的公有方法getInstance(),加入同步处理的代码synchronized ,解决线程安全问题

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 懒汉式(线程安全,同步方法)
final class Singleton {
private static Singleton instance;
// 1.构造器私有化(防止外部使用new创建实例)
private Singleton() {}
// 2.提供一个静态的公有方法,加入同步处理的代码,解决线程安全问题
//即懒汉式
public static synchronized Singleton getInstance() {
if(instance == null) {
instance = new Singleton();
}
return instance;
}
}

优缺点说明:

  1. 解决了线程安全问题
  2. 效率太低了,每个线程在想获得类的实例时候,执行getInstance()方法都要进行同步。而其实这个方法只执行一次实例化代码就够了后面的想获得该类实例,直接 return 就行了。方法进行同步效率太低。
  3. 结论:在实际开发中,不推荐使用这种方式

5.4.3、懒汉式(同步代码块)

  1. 构造器私有化 (防止外部使用new创建实例)
  2. 提供一个静态的公有方法getInstance(),加入同步产生实例化的的代码块,解决效率问题。

代码:

image-20210412194454470

优缺点说明:

  1. 这种方式,本意是想对第四种实现方式的改进,因为前面同步方法效率太低,改为同步产生实例化的的代码块。
  2. 但是这种同步并不能起到线程同步的作用。跟第3种实现方式遇到的情形一致,假如一个线程进入了if (singleton == null)判断语句块,还未来得及往下执行,另一个线程也通过了这个判断语句,这时便会产生多个实例。(线程不安全
  3. 结论:在实际开发中,**不能使用**这种方式

5.5、双重检查

使用步骤:

  1. 构造器私有化 (防止外部使用new创建实例)
  2. 提供一个静态的公有方法,加入**双重检查代码(双if)**,解决线程安全问题, 同时解决懒加载问题

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 双重检查
final class Singleton {
private static volatile Singleton instance;
private Singleton() {}
//提供一个静态的公有方法,加入双重检查代码,解决线程安全问题, 同时解决懒加载问题
//同时保证了效率, 推荐使用
public static Singleton getInstance() {
if(instance == null) {
synchronized (Singleton.class) {
if(instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}

优缺点说明:

  1. Double-Check 概念是多线程开发中常使用到的,如代码中所示,我们进行了两次 if (singleton == null)检查,这样就可以保证线程安全了。
  2. 是对懒汉式(线程安全,同步方法)的优化
  3. 这样,实例化代码只用执行一次,后面再次访问时,判断 if (singleton == null),直接 return 实例化对象,也避免的反复进行方法同步
  4. 线程安全延迟加载效率较高
  5. 结论:在实际开发中,推荐使用这种单例设计模式

其他说明:

  • 双重检锁虽然是线程安全的,会出现内部成员变量空指针异常,如果要使用,需将类实例用volatile修饰
  • volatile 是改变立即更新到主存保证变化各线程可见,即:立即从主内存中获取值,更新工作内存的值。在多线程情况下,不仅防止指令重排,而且保证happes-before规则,前一个线程的操作对后一个线程可见。(happens-before规则相关)
  • 如果不用volatile关键字,有可能会出现异常。因为instance = new Singleton();并不是一个原子操作。new对象分为三步:
    1. 第一步:分配对象的内存空间
    2. 第二步:初始化对象
    3. 第三步:设置instance指向内存空间
  • 但是这个被返回的instance是有问题的——它还没有被初始化(第二步还未被执行)。
  • 这里必须要volatile,volatile就是保证一个线程更新了instance,其余线程立马可知,不然第二个if没有用。(可见性)
  • volitile保证了线程间的可见性,和一定程度上的顺序性(不能保证原子性),更好的方式是用一个boolean变量标识对象是否创建过(原子性)

双重检查创建单例实现步骤

  1. 第一个if(singleton==null){}:第一层检查,检查是否有引用指向对象,高并发情况下会有多个线程同时进
  2. synchronized (Singleton.class) {}:第一层锁,保证只有一个线程进入
  3. 第二个if(singleton==null){}:第二层检查
    • 双重检查,防止多个线程同时进入第一层检查(因单例模式只允许存在一个对象,故在创建对象之前无引用指向对象,所有线程均可进入第一层检查)
    • 当某一线程获得锁创建一个Singleton对象时,即已有引用指向对象,singleton不为空,从而保证只会创建一个对象
    • 假设没有第二层检查,那么第一个线程创建完对象释放锁后,后面进入对象也会创建对象,会产生多个对象。(5.4.3的情况)
  4. instance = new Singleton():volatile关键字作用为禁止指令重排,保证返回Singleton对象一定在创建对象后
    • 该语句为非原子性,实际上会执行以下内容:
      1. 在堆上开辟空间
      2. 属性初始化
      3. 引用指向对象
    • 假设以上三个内容为三条单独指令,因指令重排可能会导致执行顺序为1->3->2(正常为1->2->3),当单例模式中存在普通变量需要在构造方法中进行初始化操作时,单线程情况下,顺序重排没有影响;但在多线程情况下,假如线程1执行singleton=new Singleton()语句时先1再3,由于系统调度线程2的原因没来得及执行步骤2,但此时已有引用指向对象也就是singleton!=null,故线程2在第一次检查时不满足条件直接返回singleton,此时singleton为一个没有被步骤2正确初始化的singleton。
    • volatile关键字可保证singleton=new Singleton()语句执行顺序为123,因其为非原子性依旧可能存在系统调度问题(即执行步骤时被打断),但能确保的是只要singleton!=null,就表明一定执行了属性初始化操作;而若在步骤3之前被打断,此时singleton依旧为null,其他线程可进入第一层检查向下执行创建对象。

5.6、静态内部类

使用步骤:

  1. 构造器私有化 (防止外部使用new创建实例)
  2. 写一个静态内部类,该类中有一个静态属性 Singleton
  3. 提供一个静态的公有方法,直接返回SingletonInstance.INSTANCE

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 静态内部类完成, 推荐使用
final class Singleton {
private static volatile Singleton instance;
// 1.构造器私有化
private Singleton() {}
// 2.写一个静态内部类,该类中有一个静态属性 Singleton
private static class SingletonInstance {
private static final Singleton INSTANCE = new Singleton();
}
// 3.提供一个静态的公有方法,直接返回SingletonInstance.INSTANCE
public static Singleton getInstance() {
return SingletonInstance.INSTANCE;
}
}

优缺点说明:

  1. 这种方式采用了类装载的机制保证初始化实例时只有一个线程
  2. 静态内部类方式在 Singleton 类被装载时并不会立即实例化,而是在需要实例化时,调用 getInstance 方法,才会装载 SingletonInstance 类,从而完成 Singleton 的实例化
  3. 类的静态属性只会在第一次加载类的时候初始化,所以在这里,JVM 帮助我们保证了线程的安全性,在类进行初始化时,别的线程是无法进入的。
  4. 优点:避免了线程不安全,利用静态内部类特点实现延迟加载效率高
  5. 缺点:不能传参
  6. 结论:在实际开发中,推荐使用这种单例设计模式

其他说明:

  • 静态内部类:这里的关键是类在加载的时候是线程安全的,一个类只会被加载一次
  • JVM初始化时机:
    1. 首次,主动使用才会初始化。即只有第一次加载类的时候初始化。
    2. 之后调用getInstance()方法,直接返回对象,不会再次初始化了
  • 这种方式只适用于静态域的情况,双检锁方式可在实例域需要延迟初始化时使用。

5.7、枚举

使用步骤:

  1. 直接使用枚举实现单例
  2. 在枚举里面有INSTANCE属性
  3. 外部直接通过Singleton.INSTANCE的方式创建实例

代码:

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
32
33
34
public class SingletonTest {
public static void main(String[] args) {
// 通过Singleton.INSTANCE的方式创建实例
Singleton instance = Singleton.INSTANCE;
Singleton instance2 = Singleton.INSTANCE;
System.out.println(instance == instance2);

System.out.println(instance.hashCode());
System.out.println(instance2.hashCode());

instance.sayOK();
}
}
// 使用枚举,可以实现单例, 推荐
final enum Singleton {
// 属性
INSTANCE;
// 自定义构造函数
int value;
private SingletonEnum() {
value = 1;
System.out.println("INSTANCE now created!");
}
// 方法
public void sayOK() {
System.out.println("ok~");
}
public int getValue() {
return value;
}
public void setValue(int value) {
this.value = value;
}
}

优缺点说明:

  1. 这借助 JDK1.5 中添加的枚举来实现单例模式。不仅能避免多线程同步问题,而且还能防止反序列化重新创建新的对象
  2. JVM 会阻止反射获取枚举类的私有构造方法
  3. 枚举真正实现了单例,把反序列化和反射创建第二对象的路都堵死了
  4. 这种方式是 Effective Java 作者 Josh Bloch 提倡的方式
  5. 缺点:无法进行懒加载。如果Singleton必须拓展一个超类,而不是扩展Enum的时候,则不宜使用这个方法。
  6. 结论:在实际开发中,推荐使用这种单例设计模式

5.8、单例模式在 JDK 应用的源码分析

我们 JDK 中,java.lang.Runtime 就是经典的单例模式(饿汉式)

原码:

image-20210413021321623

5.9、单例模式总结

5.9.1、单例模式的优缺点

优点:

  • 单例模式可以保证内存里只有一个实例,减少了内存的开销。
  • 可以避免对资源的多重占用。
  • 单例模式设置全局访问点,可以优化和共享资源的访问。

缺点:

  • 单例模式一般没有接口,扩展困难。如果要扩展,则除了修改原来的代码,没有第二种途径,违背开闭原则。
  • 在并发测试中,单例模式不利于代码调试。在调试过程中,如果单例中的代码没有执行完,也不能模拟生成一个新的对象。
  • 单例模式的功能代码通常写在一个类中,如果功能设计不合理,则很容易违背单一职责原则。

5.9.2、单例模式的应用场景

对于 Java 来说,单例模式可以保证在一个 JVM 中只存在单一实例。单例模式的应用场景主要有以下几个方面。

  • 需要频繁创建的一些类,使用单例可以降低系统的内存压力,减少 GC。
  • 某类只要求生成一个对象的时候,如一个班中的班长、每个人的身份证号等。
  • 某些类创建实例时占用资源较多,或实例化耗时较长,且经常使用。
  • 某类需要频繁实例化,而创建的对象又频繁被销毁的时候,如多线程的线程池网络连接池等。
  • 频繁访问数据库或文件的对象
  • 对于一些控制硬件级别的操作,或者从系统上来讲应当是单一控制逻辑的操作,如果有多个实例,则系统会完全乱套。
  • 对象需要被共享的场合。由于单例模式只允许创建一个对象,共享该对象可以节省内存,并加快对象访问速度。如 Web 中的配置对象、数据库的连接池等。

5.9.3、单例模式的结构

单例模式的主要角色如下。

  • 单例类:包含一个实例且能自行创建这个实例的类。
  • 访问类:使用单例的类。

结构:

image-20210412211111576

5.9.4、相关的设计模式

在以下模式中, 多数情况下只会生成一个实例。

  • AbstractFactory模式
  • Builder模式
  • Facade模式
  • Prototype模式

5.9.5、单例模式注意事项和细节说明

  • 单例模式保证了 系统内存中该类只存在一个对象,节省了系统资源,对于一些需要频繁创建销毁的对象,使用单例模式可以提高系统性能
  • 当想实例化一个单例类的时候,必须要记住使用相应的获取对象的方法,而不是使用 new
  • 枚举是最安全的单例,是不可破坏的,其余所有的单例都是可以用反射破坏的
  • 那我们什么时候应该用Singleton呢?实际上,很多程序,尤其是Web程序,大部分服务类都应该被视作Singleton,如果全部按Singleton的写法写,会非常麻烦,所以,通常是通过约定让框架(例如Spring)来实例化这些类,保证只有一个实例,调用方自觉通过框架获取实例而不是new操作符。因此,除非确有必要,否则Singleton模式一般以“约定”为主,不会刻意实现它。
  • 经验之谈:一般情况下,不建议使用第 1 种和第 2 种懒汉方式,建议使用第 3 种饿汉方式。只有在要明确实现 lazy loading 效果时,才会使用第 5 种静态内部方式。如果涉及到反序列化创建对象时,可以尝试使用第 6 种枚举方式。如果有其他特殊的需求,可以考虑使用第 4 种双检锁方式。

5.9.6、反射与反序列化破坏单例模式的方法及解决办法

除枚举方式外, 其他方法都会通过反射或反序列化的方式破坏单例

5.9.6.1、反射破坏单例模式

反射如何破坏单例模式

通过反射获得单例类的构造函数,由于该构造函数是private的,通过setAccessible(true)指示反射的对象在使用时应该取消 Java 语言访问检查,使得私有的构造函数能够被访问,这样使得单例模式失效。

1
2
3
4
5
6
7
8
9
10
11
12
public class Test {
public static void main(String[] args) throws Exception{
Singleton s1 = Singleton.getInstance();

Constructor<Singleton> constructor = Singleton.class.getDeclaredConstructor();
constructor.setAccessible(true);
Singleton s2 = constructor.newInstance();

System.out.println(s1.hashCode());
System.out.println(s2.hashCode());
}
}

如果我们想要阻止单例破坏,可以在构造方法中进行判断,若已有实例, 则阻止生成新的实例,解决办法如下:

1
2
3
4
5
private SingletonObject1(){
if (instance !=null){
throw new RuntimeException("实例已经存在,请通过 getInstance()方法获取");
}
}
5.9.6.2、反序列化破坏单例模式

序列化和反序列化的对单例破坏的防止及其原理

如果单例类实现了序列化接口Serializable, 就可以通过反序列化破坏单例。

我们使用正常的方式来获取一个对象。通过序列化将对象写入文件中,然后我们通过反序列化的到一个对象,我们再对比这个对象,输出的内存地址和布尔结果都表示这不是同一个对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class HungrySingleton implements Serializable{
private final static HungrySingleton hungrySingleton;
static{
hungrySingleton = new HungrySingleton();
}
private HungrySingleton(){
if(hungrySingleton != null){
throw new RuntimeException("单例构造器禁止反射调用");
}
}
public static HungrySingleton getInstance(){
return hungrySingleton;
}
}

这里我们使用之前的饿汉式的单例作为例子。在之前饿汉式的代码上做点小改动。就是让我们的单例类实现 Serializable接口。然后我们在测试类中测试一下怎么破坏。

1
2
3
4
5
6
7
8
9
10
11
12
public class SingletonTest {
public static void main(String[] args) throws IOException, ClassNotFoundException {
HungrySingleton instance = HungrySingleton.getInstance();
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("singleton_file"));
oos.writeObject(instance);
File file = new File("singleton_file");
ObjectInputStream ois = new ObjectInputStream(new FileInputStream(file));
HungrySingleton newInstance = (HungrySingleton) ois.readObject();
System.out.println(instance == newInstance;
}

}

解决方法:

所以我们可以不实现序列化接口,如果非得实现序列化接口,可以重写反序列化方法readResolve(), 反序列化时直接返回相关单例对象。

1
2
3
public Object readResolve() throws ObjectStreamException{
return instance;
}

5.9.7、单例模式的扩展

单例模式可扩展为有限的多例(Multitcm)模式,这种模式可生成有限个实例并保存在 ArrayList 中,客户需要时可随机获取,结构图:

image-20210412215700680

6、工厂模式(创建型设计模式)

在日常开发中,凡是需要生成复杂对象的地方,都可以尝试考虑使用工厂模式来代替。

注意:上述复杂对象指的是类的构造函数参数过多等对类的构造有影响的情况,因为类的构造过于复杂,如果直接在其他业务类内使用,则两者的耦合过重,后续业务更改,就需要在任何引用该类的源代码内进行更改,光是查找所有依赖就很消耗时间了,更别说要一个一个修改了。

工厂模式的定义:定义一个创建产品对象的工厂接口,将产品对象的实际创建工作推迟到具体子工厂类当中。这满足创建型模式中所要求的“创建与使用相分离”的特点。

按实际业务场景划分,工厂模式有 3 种不同的实现方式,分别是

  • 简单工厂模式
  • 工厂方法模式
  • 抽象工厂模式

6.1、简单工厂模式SimpleFactory

6.1.1、简单工厂模式介绍

  1. 简单工厂模式是属于创建型模式,是工厂模式的一种。简单工厂模式是由一个工厂对象决定创建出哪一种产品类的实例。简单工厂模式是工厂模式家族中最简单实用的模式。
  2. 简单工厂模式:定义了一个创建对象的类,由这个类来封装实例化对象的行为(代码)
  3. 在软件开发中,当我们会用到大量的创建某种、某类或者某批对象时,就会使用到工厂模式。
  4. 我们把被创建的对象称为“产品”,把创建产品的对象称为“工厂”。如果要创建的产品不多,只要一个工厂类就可以完成,这种模式叫“简单工厂模式”。
  5. 在简单工厂模式中创建实例的方法通常为静态(static)方法,因此简单工厂模式(Simple Factory Pattern)又叫作静态工厂方法模式(Static Factory Method Pattern)。

可总结:

  1. 一个调用者想创建一个对象,只要知道其名称就可以了。
  2. 扩展性高,如果想增加一个产品,只要扩展一个工厂类就可以。
  3. 屏蔽产品的具体实现,调用者只关心产品的接口。

6.1.2、模式的结构与实现

简单工厂模式的主要角色如下:

  • 简单工厂(SimpleFactory):是简单工厂模式的核心,负责实现创建所有实例的内部逻辑。工厂类的创建产品类的方法可以被外界直接调用,创建所需的产品对象。
  • 抽象产品(Product):是简单工厂创建的所有对象的父类,负责描述所有实例共有的公共接口。
  • 具体产品(ConcreteProduct):是简单工厂模式的创建目标。

其结构图如下图所示:

image-20210413003915038

根据上图写出该模式的代码如下:(模板)

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
32
33
34
35
36
public class Client {
public static void main(String[] args) {
}
//抽象产品
public interface Product {
void show();
}
//具体产品:ProductA
static class ConcreteProduct1 implements Product {
public void show() {
System.out.println("具体产品1显示...");
}
}
//具体产品:ProductB
static class ConcreteProduct2 implements Product {
public void show() {
System.out.println("具体产品2显示...");
}
}
final class Const {
static final int PRODUCT_A = 0;
static final int PRODUCT_B = 1;
static final int PRODUCT_C = 2;
}
static class SimpleFactory {
public static Product makeProduct(int kind) {
switch (kind) {
case Const.PRODUCT_A:
return new ConcreteProduct1();
case Const.PRODUCT_B:
return new ConcreteProduct2();
}
return null;
}
}
}

6.1.3、应用实例

看一个具体的需求:披萨的项目:要便于披萨种类的扩展,要便于维护

  1. 披萨的种类很多(比如 GreekPizz、CheesePizz 等)
  2. 披萨的制作有 prepare,bake, cut, box
  3. 完成披萨店订购功能。

使用简单工厂模式实现:

简单工厂模式的设计方案: 定义一个可以实例化 Pizaa 对象的类,封装创建对象的代码。

image-20210413001450106

代码实现(省略pizza抽象类与具体实现类的编写):

根据简单工厂模式创建:

  1. 创建工厂类

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    //简单工厂类
    public class SimpleFactory {
    //根据orderType 返回对应的Pizza 对象
    public Pizza createPizza(String orderType) {
    Pizza pizza = null;
    if (orderType.equals("greek")) {
    pizza = new GreekPizza();
    pizza.setName(" 希腊披萨 ");
    } else if (orderType.equals("cheese")) {
    pizza = new CheesePizza();
    pizza.setName(" 奶酪披萨 ");
    } else if (orderType.equals("pepper")) {
    pizza = new PepperPizza();
    pizza.setName("胡椒披萨");
    }
    return pizza;
    }
    }
  2. 创建订购披萨类OrderPizza

    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
    32
    33
    34
    35
    36
    37
    38
    39
    public class OrderPizza {
    //定义一个简单工厂对象
    SimpleFactory simpleFactory;
    Pizza pizza = null;
    //构造器
    public OrderPizza(SimpleFactory simpleFactory) {
    setFactory(simpleFactory);
    }
    public void setFactory(SimpleFactory simpleFactory) {
    String orderType = ""; //用户输入的
    this.simpleFactory = simpleFactory; //设置简单工厂对象
    do {
    orderType = getType();
    pizza = this.simpleFactory.createPizza(orderType);
    //输出pizza
    if(pizza != null) { //订购成功
    pizza.prepare();
    pizza.bake();
    pizza.cut();
    pizza.box();
    } else {
    System.out.println(" 订购披萨失败 ");
    break;
    }
    }while(true);
    }
    // 写一个方法,可以获取客户希望订购的披萨种类
    private String getType() {
    try {
    BufferedReader strin = new BufferedReader(new InputStreamReader(System.in));
    System.out.println("input pizza 种类:");
    String str = strin.readLine();
    return str;
    } catch (IOException e) {
    e.printStackTrace();
    return "";
    }
    }
    }
  3. 客户端PizzaStore:

    1
    2
    3
    4
    5
    6
    7
    8
    //相当于一个客户端,发出订购
    public class PizzaStore {
    public static void main(String[] args) {
    //使用简单工厂模式
    new OrderPizza(new SimpleFactory());
    System.out.println("~~退出程序~~");
    }
    }

根据静态工厂模式创建:

  1. 创建静态工厂类

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    public class SimpleFactory {
    //简单工厂模式 也叫 静态工厂模式
    public static Pizza createPizza(String orderType) {
    Pizza pizza = null;
    if (orderType.equals("greek")) {
    pizza = new GreekPizza();
    pizza.setName(" 希腊披萨 ");
    } else if (orderType.equals("cheese")) {
    pizza = new CheesePizza();
    pizza.setName(" 奶酪披萨 ");
    } else if (orderType.equals("pepper")) {
    pizza = new PepperPizza();
    pizza.setName("胡椒披萨");
    }
    return pizza;
    }
    }
  2. 创建订购披萨类OrderPizza

    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
    32
    33
    public class OrderPizza {
    Pizza pizza = null;
    String orderType = "";
    // 构造器
    public OrderPizza() {
    do {
    orderType = getType();
    pizza = SimpleFactory.createPizza2(orderType);
    // 输出pizza
    if (pizza != null) { // 订购成功
    pizza.prepare();
    pizza.bake();
    pizza.cut();
    pizza.box();
    } else {
    System.out.println(" 订购披萨失败 ");
    break;
    }
    } while (true);
    }
    // 写一个方法,可以获取客户希望订购的披萨种类
    private String getType() {
    try {
    BufferedReader strin = new BufferedReader(new InputStreamReader(System.in));
    System.out.println("input pizza 种类:");
    String str = strin.readLine();
    return str;
    } catch (IOException e) {
    e.printStackTrace();
    return "";
    }
    }
    }
  3. 客户端PizzaStore:

    1
    2
    3
    4
    5
    6
    7
    8
    //相当于一个客户端,发出订购
    public class PizzaStore {
    public static void main(String[] args) {
    //使用静态工厂模式
    new OrderPizza();
    System.out.println("~~退出程序~~");
    }
    }

6.1.4、简单工厂模式(静态工厂模式)的相关说明

  • 优点:

    1. 工厂类包含必要的逻辑判断,可以决定在什么时候创建哪一个产品的实例。客户端可以免除直接创建产品对象的职责,很方便的创建出相应的产品。工厂和产品的职责区分明确。
    2. 客户端无需知道所创建具体产品的类名,只需知道参数即可。
    3. 也可以引入配置文件,在不修改客户端代码的情况下更换和添加新的具体产品类
  • 缺点:

    1. 简单工厂模式的工厂类单一,负责所有产品的创建,职责过重,一旦异常,整个系统将受影响。且工厂类代码会非常臃肿违背高聚合原则
    2. 使用简单工厂模式会增加系统中类的个数(引入新的工厂类),增加系统的复杂度和理解难度
    3. 系统扩展困难,一旦增加新产品不得不修改工厂逻辑,在产品类型较多时,可能造成逻辑过于复杂
    4. 简单工厂模式使用了 static 工厂方法,造成工厂角色无法形成基于继承的等级结构。
  • 应用场景

    对于产品种类相对较少的情况,考虑使用简单工厂模式。使用简单工厂模式的客户端只需要传入工厂类的参数,不需要关心如何创建对象的逻辑,可以很方便地创建所需产品。

  • 简单来说,简单工厂模式有一个具体的工厂类,可以生成多个不同的产品,属于创建型设计模式。简单工厂模式不在 GoF 23 种设计模式之列。

6.2、工厂方法模式Factory Method

image-20210415031807022

6.2.1、工厂方法模式介绍

  1. 简单工厂模式提到了违背了开闭原则,而“工厂方法模式”是对简单工厂模式的进一步抽象化,其好处是可以使系统在不修改原来代码的情况下引进新的产品,即满足开闭原则
  2. 工厂方法模式:定义了一个创建对象的抽象方法,由子类决定要实例化的类。工厂方法模式将对象的实例化推迟到子类。

6.2.2、模式的结构与实现

工厂方法模式由抽象工厂、具体工厂、抽象产品和具体产品等4个要素构成。本节来分析其基本结构和实现方法。

6.2.2.1. 模式的结构

工厂方法模式的主要角色如下。

  1. 抽象工厂(Abstract Factory):提供了创建产品的接口,调用者通过它访问具体工厂的工厂方法 newProduct() 来创建产品。
  2. 具体工厂(ConcreteFactory):主要是实现抽象工厂中的抽象方法,完成具体产品的创建。
  3. 抽象产品(Product):定义了产品的规范,描述了产品的主要特性和功能。
  4. 具体产品(ConcreteProduct):实现了抽象产品角色所定义的接口,由具体工厂来创建,它同具体工厂之间一一对应。

其结构图如图 1 所示:

image-20210413004548795

6.2.2.2、模式的实现

根据图 1 写出该模式的代码如下:

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
package FactoryMethod;
public class AbstractFactoryTest {
public static void main(String[] args) {
try {
Product a;
AbstractFactory af;
af = (AbstractFactory) ReadXML1.getObject();
a = af.newProduct();
a.show();
} catch (Exception e) {
System.out.println(e.getMessage());
}
}
}
//抽象产品:提供了产品的接口
interface Product {
public void show();
}
//具体产品1:实现抽象产品中的抽象方法
class ConcreteProduct1 implements Product {
public void show() {
System.out.println("具体产品1显示...");
}
}
//具体产品2:实现抽象产品中的抽象方法
class ConcreteProduct2 implements Product {
public void show() {
System.out.println("具体产品2显示...");
}
}
//抽象工厂:提供了厂品的生成方法
interface AbstractFactory {
public Product newProduct();
}
//具体工厂1:实现了厂品的生成方法
class ConcreteFactory1 implements AbstractFactory {
public Product newProduct() {
System.out.println("具体工厂1生成-->具体产品1...");
return new ConcreteProduct1();
}
}
//具体工厂2:实现了厂品的生成方法
class ConcreteFactory2 implements AbstractFactory {
public Product newProduct() {
System.out.println("具体工厂2生成-->具体产品2...");
return new ConcreteProduct2();
}
}

6.2.3、应用实例

  1. 披萨项目新的需求:客户在点披萨时,可以点不同口味的披萨,比如 北京的奶酪 pizza、北京的胡椒 pizza 或者是伦敦的奶酪 pizza、伦敦的胡椒 pizza

  2. 思路分析图解:

    image-20210413012849728

  3. 代码实现:

    订购披萨OrderPizza

    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
    public abstract class OrderPizza {
    //定义一个抽象方法,createPizza , 让各个工厂子类自己实现
    abstract Pizza createPizza(String orderType);
    // 构造器
    public OrderPizza() {
    Pizza pizza = null;
    String orderType; // 订购披萨的类型
    do {
    orderType = getType();
    pizza = createPizza(orderType); //抽象方法,由工厂子类完成
    //输出pizza 制作过程
    pizza.prepare();
    pizza.bake();
    pizza.cut();
    pizza.box();
    } while (true);
    }
    // 写一个方法,可以获取客户希望订购的披萨种类
    private String getType() {
    try {
    BufferedReader strin = new BufferedReader(new InputStreamReader(System.in));
    System.out.println("input pizza 种类:");
    String str = strin.readLine();
    return str;
    } catch (IOException e) {
    e.printStackTrace();
    return "";
    }
    }
    }

    北京的pizza继承OrderPizza(伦敦同)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    public class BJOrderPizza extends OrderPizza {
    @Override
    Pizza createPizza(String orderType) {
    Pizza pizza = null;
    if(orderType.equals("cheese")) {
    pizza = new BJCheesePizza();
    } else if (orderType.equals("pepper")) {
    pizza = new BJPepperPizza();
    }
    // TODO Auto-generated method stub
    return pizza;
    }
    }

    客户端:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    public class PizzaStore {
    public static void main(String[] args) {
    String loc = "bj";
    if (loc.equals("bj")) {
    //创建北京口味的各种Pizza
    new BJOrderPizza();
    } else {
    //创建伦敦口味的各种Pizza
    new LDOrderPizza();
    }
    }
    }

6.2.4、工厂方法模式的相关说明

  • 优点:

    1. 用户只需要知道具体工厂的名称就可得到所要的产品,无须知道产品的具体创建过程。
    2. 灵活性增强,对于新产品的创建,只需多写一个相应的工厂类
    3. 典型的解耦框架。高层模块只需要知道产品的抽象类,无须关心其他实现类,满足迪米特法则、依赖倒置原则和里氏替换原则。
  • 缺点:

    1. 类的个数容易过多,增加复杂度
    2. 增加了系统的抽象性和理解难度
    3. 抽象产品只能生产一种产品,此弊端可使用抽象工厂模式解决。
  • 应用场景:

    1. 客户只知道创建产品的工厂名,而不知道具体的产品名。如 TCL 电视工厂、海信电视工厂等。
    2. 创建对象的任务由多个具体子工厂中的某一个完成,而抽象工厂只提供创建产品的接口。
    3. 客户不关心创建产品的细节,只关心产品的品牌
  • 注意:

    当需要生成的产品不多且不会增加,一个具体工厂类就可以完成任务时,可删除抽象工厂类。这时工厂方法模式将退化到简单工厂模式。

6.2.5、工厂方法模式的登场角色补充(来自《图解设计模式》)

在 Factory Method 模式中有以下登场角色。 通过查看 Factory Method 模式的类图,我们可以知道, 父类(框架)这一方的 Creator 角色Product 角色的关系子类(具体加工)这一方的 ConcreteCreator 角色和 ConcreteProduct 角色的关系是平行的

image-20210413023513528

  • Product (产品)

    Product角色属于框架这一方, 是一个抽象类。它定义了在Factory Method模式中生成的那些实例所持有的接口(API), 但具体的处理则由子类ConcreteProduct角色决定。 在示例程序中由Product类扮演此角色。

  • Creator (创建者)

    Creator角色属千框架这一方, 它是负责生成 Product角色的抽象类,但具体的处理则由子类ConcreteCreator角色决定。 在示例程序中, 由Factory类扮演此角色。

  • Creator角色对于实际负责生成实例的ConcreteCreator角色一无所知,它唯一知道的就是, 只要调用Product角色和生成实例的方法(图4-3中的factoryMethod 方法), 就可以生成Productde的实例。 在示例程序中,createProduct 方法是用于生成实例的方法。 不用new关键字来生成实例, 而是调用生成实例的专用方法来生成实例, 这样就可以防止父类与其他具体类耦合。

  • ConcreteProduct (具体的产品)

    Concrete Product角色属于具体加工这一方,它决定了具体的产品。 在示例程序中, 由IDCard 类扮演此角色。

  • ConcreteCreator (具体的创建者)

    ConcreteCreator角色属于具体加工这一方, 它负责生成具体的产品。 在示例程序中,由IDCardFactory类扮演此角色。

6.2.6、相关的设计模式

  • Template Method 模式

    Factory Method模式是Template Method的典型应用。在示例程序中, create方法就是模板方法。

  • Singleton 模式

    在多数情况下我们都可以将Singleton模式用于扮演Creator角色(或是ConcreteCreator角色) 的类。这是因为在程序中没有必要存在多个 Creator角色(或是ConcreteCreator角色)的实例。不过在示例程序中, 我们并没有使用Singleton模式。

  • Composite 模式

    有时可以将 Composite模式用于Product角色(或是ConcreteProduct角色)。

  • Iterator 模式

    有时, 在Iterator模式中使用iterator方法生成Iterator的实例时会使用Factory Method 模式。

6.3、抽象工厂模式Abstract Factory

image-20210415032040170

6.3.1、抽象工厂模式介绍

  1. 前面介绍的工厂方法模式中考虑的是一类产品的生产,如畜牧场只养动物、电视机厂只生产电视机、计算机软件学院只培养计算机软件专业的学生等。
  2. 同种类称为同等级,也就是说:工厂方法模式只考虑生产同等级的产品,但是在现实生活中许多工厂是综合型的工厂,能生产多等级(种类) 的产品,如农场里既养动物又种植物,电器厂既生产电视机又生产洗衣机或空调,大学既有软件专业又有生物专业等。
  3. 抽象工厂模式将考虑多等级产品的生产,将同一个具体工厂所生产的位于不同等级的一组产品称为一个产品族.

抽象工厂(AbstractFactory)模式的定义:定义了一个 interface 用于创建相关或有依赖关系的对象簇,而无需指明具体的类,是一种为访问类提供一个创建一组相关或相互依赖对象的接口,且访问类无须指定所要产品的具体类就能得到同族的不同等级的产品的模式结构。可以看作:抽象工厂是一个超级工厂,围绕一个超级工厂创建其他工厂,该超级工厂又称为其他工厂的工厂。

抽象工厂模式是工厂方法模式的升级版本,工厂方法模式只生产一个等级的产品,而抽象工厂模式可生产多个等级的产品。抽象工厂模式可以将简单工厂模式和工厂方法模式进行整合。

从设计层面看,抽象工厂模式就是对简单工厂模式的改进(或者称为进一步的抽象)。

将工厂抽象成两层,AbsFactory(**抽象工厂)** 和 具体实现的工厂子类。程序员可以根据创建对象类型使用对应的工厂子类。这样将单个的简单工厂类变成了工厂簇,更利于代码的维护和扩展。

使用抽象工厂模式一般要满足以下条件。

  • 系统中有多个产品族,每个具体工厂创建同一族但属于不同等级结构的产品。
  • 系统一次只可能消费其中某一族产品,即同族的产品一起使用。

6.3.2、模式的结构与实现

6.3.2.1、模式的结构

抽象工厂模式的主要角色如下。

  1. 抽象工厂(Abstract Factory):提供了创建产品的接口,它包含多个创建产品的方法 newProduct(),可以创建多个不同等级的产品。
  2. 具体工厂(Concrete Factory):主要是实现抽象工厂中的多个抽象方法,完成具体产品的创建。
  3. 抽象产品(Product):定义了产品的规范,描述了产品的主要特性和功能,抽象工厂模式有多个抽象产品。
  4. 具体产品(ConcreteProduct):实现了抽象产品角色所定义的接口,由具体工厂来创建,它同具体工厂之间是多对一的关系。
6.3.2.2、模式的实现类图(具体代码

image-20210413015123090

6.3.3、应用实例

使用抽象工厂模式来完成披萨项目。

类图:

image-20210413015603989

代码:

抽象工厂:

1
2
3
4
5
//一个抽象工厂模式的抽象层(接口)
public interface AbsFactory {
//让下面的工厂子类来 具体实现
public Pizza createPizza(String orderType);
}

北京工厂实现抽象工厂生产披萨(伦敦同)

1
2
3
4
5
6
7
8
9
10
11
12
13
//这是工厂子类
public class BJFactory implements AbsFactory {
@Override
public Pizza createPizza(String orderType) {
Pizza pizza = null;
if(orderType.equals("cheese")) {
pizza = new BJCheesePizza();
} else if (orderType.equals("pepper")){
pizza = new BJPepperPizza();
}
return pizza;
}
}

订购披萨:

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
32
33
34
35
36
37
38
public class OrderPizza {
AbsFactory factory;
// 构造器
public OrderPizza(AbsFactory factory) {
setFactory(factory);
}
private void setFactory(AbsFactory factory) {
Pizza pizza = null;
String orderType = ""; // 用户输入
this.factory = factory;
do {
orderType = getType();
// factory 可能是北京的工厂子类,也可能是伦敦的工厂子类
pizza = factory.createPizza(orderType);
if (pizza != null) { // 订购ok
pizza.prepare();
pizza.bake();
pizza.cut();
pizza.box();
} else {
System.out.println("订购失败");
break;
}
} while (true);
}
// 写一个方法,可以获取客户希望订购的披萨种类
private String getType() {
try {
BufferedReader strin = new BufferedReader(new InputStreamReader(System.in));
System.out.println("input pizza 种类:");
String str = strin.readLine();
return str;
} catch (IOException e) {
e.printStackTrace();
return "";
}
}
}

客户端:

1
2
3
4
5
6
7
public class PizzaStore {
public static void main(String[] args) {
new OrderPizza(new BJFactory());
// new OrderPizza(new LDFactory());
}

}

6.3.4、抽象工厂模式的相关说明

  • 抽象工厂模式除了具有工厂方法模式的优点外,其他主要优点如下。

    1. 可以在类的内部对产品族中相关联的多等级产品共同管理,而不必专门引入多个新的类来进行管理。
    2. 当需要产品族时,抽象工厂可以保证客户端始终只使用同一个产品的产品组
    3. 抽象工厂增强了程序的可扩展性,当增加一个新的产品族时,不需要修改原代码,满足开闭原则
  • 其缺点是:当产品族中需要增加一个新的产品时,所有的工厂类都需要进行修改。增加了系统的抽象性和理解难度

    模式的应用场景

    抽象工厂模式最早的应用是用于创建属于不同操作系统的视窗构件。如 Java 的 AWT 中的 Button 和 Text 等构件在 Windows 和 UNIX 中的本地实现是不同的。

    1. 当需要创建的对象是一系列相互关联或相互依赖的产品族时,如电器工厂中的电视机、洗衣机、空调等。
    2. 系统中有多个产品族,但每次只使用其中的某一族产品。如有人只喜欢穿某一个品牌的衣服和鞋。
    3. 系统中提供了产品的类库,且所有产品的接口相同,客户端不依赖产品实例的创建细节和内部结构。
  • 模式的扩展

    抽象工厂模式的扩展有一定的“开闭原则”倾斜性:

    1. 当增加一个新的产品族时只需增加一个新的具体工厂,不需要修改原代码,满足开闭原则。
    2. 当产品族中需要增加一个新种类的产品时,则所有的工厂类都需要进行修改,不满足开闭原则。

    另一方面,当系统中只存在一个等级结构的产品时,抽象工厂模式将退化到工厂方法模式。

  • 进阶阅读

    如果您想了解抽象工厂在框架源码中的应用,可阅读以下文章。

6.3.5、相关的设计模式

  • Builder模式

    Abstract Factory模式通过调用抽象产品的接口 (APl) 来组装抽象产品, 生成具有复杂结构的实例。

    Builder模式则是分阶段地制作复杂实例。

  • Factory Method模式

    有时AbstractFactory模式中零件和产品的生成会使用到Factory Method模式。

  • Composite模式

    有时AbstractFactory模式在制作产品时会使用Composite模式。

  • Singleton模式

    有时AbstractFactory模式中的具体工厂会使用Singleton模式。

6.4、工厂模式在 JDK-Calendar 应用的源码分析

JDK 中的 Calendar 类中,就使用了简单工厂模式

原码:

image-20210413021425940

其中createCalendar()方法:

image-20210413022320157

6.5、工厂模式小结

  1. 工厂模式的意义

    将实例化对象的代码提取出来,放到一个类中统一管理和维护,达到和主项目的依赖关系的解耦。从而提高项目的扩展和维护性。

  2. 三种工厂模式

    1. 简单工厂模式(不在23种之中)
    2. 工厂方法模式
    3. 抽象工厂模式
  3. 设计模式的依赖抽象原则

创建对象实例时,不要直接 new 类, 而是把这个 new 类的动作放在一个工厂的方法中,并返回。有的书上说, 变量不要直接持有具体类的引用。

不要让类继承具体类,而是继承抽象类或者是实现 interface(接口)

不要覆盖基类中已经实现的方法。

7、原型模式ProtoType(创建型设计模式)

image-20210415031921912

7.1、基本介绍

  1. 原型模式(Prototype 模式)是指:用原型实例指定创建对象的种类,并且通过拷贝这些原型,创建新的对象
  2. 原型模式是一种创建型设计模式,允许一个对象再创建另外一个可定制的对象,无需知道如何创建的细节
  3. 工作原理是:通过将一个原型对象传给那个要发动创建的对象,这个要发动创建的对象通过请求原型对象拷贝它们自己来实施创建,即 对象**.clone**()

7.2、原型模式原理结构图-uml 类图

image-20210413222550496

原理结构图说明:

  • Prototype : 原型类,声明一个克隆自己的接口
  • ConcretePrototype: 具体的原型类, 实现一个克隆自己的操作
  • Client: 使用者;让一个原型对象克隆自己,从而创建一个新的对象(属性一样)

7.3、应用举例

克隆羊问题:

现在有一只羊 tom,姓名为: tom, 年龄为:1,颜色为:白色,请编写程序创建和 tom 羊 属性完全相同的 10只羊。

7.3.1、传统方式解决克隆羊问题

7.3.1.1、思路分析(类图)

image-20210413222817657

7.3.1.2、相关代码:(在Client中)
1
2
3
//传统的方法
Sheep sheep = new Sheep("tom", 1, "白色");
Sheep sheep2 = new Sheep(sheep.getName(), sheep.getAge(), sheep.getColor());
7.3.1.3、传统的方式的优缺点
  1. 优点是比较好理解,简单易操作。
  2. 在创建新的对象时,总是需要重新获取原始对象的属性,如果创建的对象比较复杂时,效率较低
  3. 总是需要重新初始化对象,而不是动态地获得对象运行时的状态, 不够灵活
7.3.1.4、改进方法(使用原型模式)

Java 中 Object 类是所有类的根类,Object 类提供了一个 clone()方法,该方法可以将一个 Java 对象复制一份,但是需要实现 clone 的 Java 类必须要实现一个接口 Cloneable,该接口表示该类能够复制且具有复制的能力 =>原型模式

7.3.2、原型模式解决克隆羊问题

实现步骤:

  1. 实例实现接口Cloneable,并重写Object的clone方法
  2. 在Client使用创建的实例的clone方法进行对象的克隆

代码实现:

sheep:

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
public class Sheep implements Cloneable {
private String name;
private int age;
private String color;
private String address = "蒙古羊";
public Sheep friend; //是对象, 克隆是会如何处理
public Sheep(String name, int age, String color) {
super();
this.name = name;
this.age = age;
this.color = color;
}
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;
}
public String getColor() {
return color;
}
public void setColor(String color) {
this.color = color;
}
@Override
public String toString() {
return "Sheep [name=" + name + ", age=" + age + ", color=" + color + ", address=" + address + "]";
}
//克隆该实例,使用默认的clone方法来完成
@Override
protected Object clone() {
Sheep sheep = null;
try {
sheep = (Sheep)super.clone();
} catch (Exception e) {
System.out.println(e.getMessage());
}
return sheep;
}
}

Client:

1
2
3
4
5
6
7
8
9
public class Client {
public static void main(String[] args) {
Sheep sheep = new Sheep("tom", 1, "白色");
sheep.friend = new Sheep("jack", 2, "黑色");
//克隆
Sheep sheep2 = (Sheep)sheep.clone();
System.out.println("sheep2 =" + sheep2 + "sheep2.friend=" + sheep2.friend.hashCode());
}
}

使用原型模式改进传统方式,让程序具有更高的效率和扩展性

7.4、浅拷贝和深拷贝

7.4.1、浅拷贝的介绍

  1. 对于数据类型是基本数据类型的成员变量,浅拷贝会直接进行值传递,也就是将该属性值复制一份给新的对象
  2. 对于String,虽然String不是基本数据结构,而是应用数据类型。但是在JVM中存在字符串常量池会存储已创建的字符串。在克隆的时候也是引用也是直接指向字符串常量池里的字符串。所以在clone当中可以将String近似于看作基本数据类型。
  3. 对于数据类型是引用数据类型的成员变量,比如说成员变量是某个数组、某个类的对象等,那么浅拷贝会进行引用传递,也就是只是将该成员变量的引用值(内存地址)复制一份给新的对象。因为实际上两个对象的该成员变量都指向同一个实例。在这种情况下,在一个对象中修改该成员变量会影响到另一个对象的该成员变量值
  4. 前面我们克隆羊就是浅拷贝(里面的friend是同一个friend,即所有的克隆羊都有同一个朋友)
  5. 浅拷贝是使用默认的 clone()方法来实现:sheep = (Sheep) super.clone();

7.4.2、深拷贝基本介绍

  1. 复制对象的所有基本数据类型的成员变量值
  2. 所有引用数据类型的成员变量申请存储空间,并复制每个引用数据类型成员变量所引用的对象,直到该对象可达的所有对象。也就是说,对象进行深拷贝要对整个对象(包括对象的引用类型)进行拷贝
  3. 深拷贝实现方式 1:重写 clone 方法来实现深拷贝
  4. 深拷贝实现方式 2:通过对象序列化实现深拷贝(推荐)

7.4.3、深拷贝应用实例

7.4.3.1、使用 重写 clone 方法实现深拷贝

DeepCloneableTarget:其他实例当中的成员变量:实现克隆接口与序列化接口Serializable, Cloneable

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class DeepCloneableTarget implements Serializable, Cloneable {
private static final long serialVersionUID = 1L;
private String cloneName;
private String cloneClass;
//构造器
public DeepCloneableTarget(String cloneName, String cloneClass) {
this.cloneName = cloneName;
this.cloneClass = cloneClass;
}
//因为该类的属性,都是String , 因此我们这里使用默认的clone完成即可
@Override
protected Object clone() throws CloneNotSupportedException {
return super.clone();
}
}

DeepProtoType:要进行克隆的实例类,其中有成员变量DeepCloneableTarget

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class DeepProtoType implements Serializable, Cloneable{
public String name; //String 属性
public DeepCloneableTarget deepCloneableTarget;// 引用类型
public DeepProtoType() {
super();
}
//深拷贝 - 方式 1 使用clone 方法
@Override
protected Object clone() throws CloneNotSupportedException {
Object deep = null;
//这里完成对基本数据类型(属性)和String的克隆
deep = super.clone();
//对引用类型的属性,进行单独处理
DeepProtoType deepProtoType = (DeepProtoType)deep;
deepProtoType.deepCloneableTarget = (DeepCloneableTarget)deepCloneableTarget.clone();
return deepProtoType;
}
}

Client:对DeepProtoType进行克隆

1
2
3
4
5
6
7
8
9
10
11
12
public class Client {
public static void main(String[] args) throws Exception {
DeepProtoType p = new DeepProtoType();
p.name = "宋江";
p.deepCloneableTarget = new DeepCloneableTarget("大牛", "小牛");
//方式1 完成深拷贝
DeepProtoType p2 = (DeepProtoType) p.clone();

System.out.println("p.name=" + p.name + "p.deepCloneableTarget=" + p.deepCloneableTarget.hashCode());
System.out.println("p2.name=" + p.name + "p2.deepCloneableTarget=" + p2.deepCloneableTarget.hashCode());
}
}
7.4.3.2、使用序列化来实现深拷贝

DeepCloneableTarget:同上

DeepProtoType:要进行克隆的实例类,其中有成员变量DeepCloneableTarget

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
32
33
34
35
36
37
38
39
public class DeepProtoType implements Serializable, Cloneable{
public String name; //String 属性
public DeepCloneableTarget deepCloneableTarget;// 引用类型
public DeepProtoType() {
super();
}
//深拷贝 - 方式2 通过对象的序列化实现 (推荐)
public Object deepClone() {
//创建流对象
ByteArrayOutputStream bos = null;
ObjectOutputStream oos = null;
ByteArrayInputStream bis = null;
ObjectInputStream ois = null;
try {
//序列化
bos = new ByteArrayOutputStream();
oos = new ObjectOutputStream(bos);
oos.writeObject(this); //当前这个对象以对象流的方式输出
//反序列化
bis = new ByteArrayInputStream(bos.toByteArray());
ois = new ObjectInputStream(bis);
DeepProtoType copyObj = (DeepProtoType)ois.readObject();
return copyObj;
} catch (Exception e) {
e.printStackTrace();
return null;
} finally {
//关闭流
try {
bos.close();
oos.close();
bis.close();
ois.close();
} catch (Exception e2) {
System.out.println(e2.getMessage());
}
}
}
}

Client:对DeepProtoType进行克隆

1
2
3
4
5
6
7
8
9
10
11
12
public class Client {
public static void main(String[] args) throws Exception {
// TODO Auto-generated method stub
DeepProtoType p = new DeepProtoType();
p.name = "宋江";
p.deepCloneableTarget = new DeepCloneableTarget("大牛", "小牛");
//方式2 完成深拷贝
DeepProtoType p2 = (DeepProtoType) p.deepClone();
System.out.println("p.name=" + p.name + "p.deepCloneableTarget=" + p.deepCloneableTarget.hashCode());
System.out.println("p2.name=" + p.name + "p2.deepCloneableTarget=" + p2.deepCloneableTarget.hashCode());
}
}
7.4.3.3、对于实例类的成员变量为本身的实例的深拷贝:

使用序列化可以实现,但是使用clone方法会报StackOverflowError异常

sheep:

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
public class Sheep implements Cloneable, Serializable {
private String name;
private int age;
private String color;
//实例类的成员变量为本身
public Sheep friend;
public Sheep(String name, int age, String color) {
super();
this.name = name;
this.age = age;
this.color = color;
}
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;
}
public String getColor() {
return color;
}
public void setColor(String color) {
this.color = color;
}

@Override
public String toString() {
return "Sheep{" +
"name='" + name + '\'' +
", age=" + age +
", color='" + color + '\'' +
", friend=" + friend +
'}';
}
//深拷贝 通过对象的序列化实现
public Object deepClone() {
//创建流对象
ByteArrayOutputStream bos = null;
ObjectOutputStream oos = null;
ByteArrayInputStream bis = null;
ObjectInputStream ois = null;
try {
//序列化
bos = new ByteArrayOutputStream();
oos = new ObjectOutputStream(bos);
oos.writeObject(this); //当前这个对象以对象流的方式输出
//反序列化
bis = new ByteArrayInputStream(bos.toByteArray());
ois = new ObjectInputStream(bis);
Sheep copyObj = (Sheep)ois.readObject();
return copyObj;
} catch (Exception e) {
e.printStackTrace();
return null;
} finally {
//关闭流
try {
bos.close();
oos.close();
bis.close();
ois.close();
} catch (Exception e2) {
System.out.println(e2.getMessage());
}
}
}
}

Client:

1
2
3
4
5
6
7
8
9
10
11
12
public class Client {
public static void main(String[] args) throws Exception {
Sheep sheep = new Sheep("Tom",1,"black");
sheep.friend = new Sheep("john", 2, "white");

Sheep sheep1 = (Sheep) sheep.deepClone();

System.out.println("sheep.name=" + sheep.getName() + " sheep.age=" + sheep.getAge() + " sheep.color=" + sheep.getColor() + " sheep.friend" + sheep.friend.hashCode());
System.out.println("sheep1.name=" + sheep1.getName() + " sheep1.age=" + sheep1.getAge() + " sheep1.color=" + sheep1.getColor() + " sheep1.friend" + sheep1.friend.hashCode());
System.out.println(sheep.friend == sheep1.friend);
}
}

7.4.4、对于深拷贝的clone方法与序列化方法

7.4.4.1、clone方法
  • clone方法分成两步:
    1. 先克隆基本数据类型和String
    2. 在对其引用数据类型进行多次克隆
  • 如果想要深拷贝一个对象, 这个对象必须要实现Cloneable接口,实现clone方法,并且在clone方法内部,把该对象引用的其他对象也要clone一份 , 这就要求这个被引用的对象必须也要实现Cloneable接口并且实现clone方法。
  • clone实际上就是实现了多重clone,实例本身有其他的应用数据类型(除String),就先重写其他的引用数据类型的clone方法;若在其他的应用数据类型(除String)又有其他的引用数据类型,又重复该过程,直到做到所有的成员变量都完成clone。
  • 所以,如果在拷贝一个对象时,要想让这个拷贝的对象和源对象完全彼此独立,那么在引用链上的每一级对象都要被显式的拷贝。所以创建彻底的深拷贝是非常麻烦的,尤其是在引用关系非常复杂的情况下, 或者在引用链的某一级上引用了一个第三方的对象, 而这个对象没有实现clone方法, 那么在它之后的所有引用的对象都是被共享的。或者如果某一个类没有实现Cloneable接口,我们还要对其进行深拷贝的话,就必然需要修改该类,这样就违反了OCP原则
  • 所以在开发中这种深拷贝方式不常用
7.4.4.2、序列化方法
  • 序列化方法也分成两步
    1. 将要实现克隆的实例进行序列化
    2. 在将其进行反序列化出来实现实例的拷贝
  • 使用该类的对象必须要实现Serializable接口,否则是没有办法实现克隆的。无须继承Cloneable接口实现clone()方法。
  • 在内存中通过字节流的拷贝是比较容易实现的。把母对象写入到一个字节流中,再从字节流中将其读出来,这样就可以创建一个新的对象了,并且该新对象与母对象之间并不存在引用共享的问题,真正实现对象的深拷贝
  • 能实现对于实例类的成员变量为本身的实例的深拷贝
  • 缺点:使用该类的对象必须要实现Serializable接口,所以在一些类并没有实现Serializable接口,如果还要对其进行深拷贝的话,就必然需要修改该类,这样就违反了OCP原则
  • 所以在开发中推荐使用这种方式进行深拷贝。

7.5、原型模式在 Spring 框架中源码分析

Spring 中原型 bean 的创建,就是原型模式的应用

image-20210414025832896

image-20210414030430256

7.6、new一个对象的过程和clone一个对象的过程区别

关于new:

new操作符的本意是分配内存。程序执行到new操作符时,会先去看new操作符后面的类型,因为知道了类型,才能知道要分配多大的内存空间。分配内存之后,再调用构造函数,填充对象的各个域,这一步叫做对象的初始化,构造方法返回后,一个对象创建完毕,可以把它的引用(也就是地址)发布到外部,在外部就可以使用这个引用操作这个对象。

关于clone:

clone在第一步是和new相似的,都是分配内存,调用clone方法时,分配的内存和原对象(即调用clone方法的对象)相同,然后再使用原对象中对应的各个域,填充新对象的域,填充完成之后,clone方法返回,一个新的相同的对象被创建,同样可以把这个新对象的引用发布到外部

  1. clone()不会调用构造方法;new会调用构造方法。
  2. new对象时根据类型确定分配内存空间的大小, clone是根据原对象分配内

7.7、原型模式的总结

原型模式的优点:

  • Java 自带的原型模式基于内存二进制流的复制,在性能上比直接 new 一个对象更加优良
  • 逃避了构造函数的约束
  • 可以使用深克隆方式保存对象的状态,使用原型模式将对象复制一份,并将其状态保存起来,简化了创建对象的过程,以便在需要的时候使用(例如恢复到历史某一状态),可辅助实现撤销操作

原型模式的缺点:

  • 需要为每一个类都配置一个 clone 方法
  • clone 方法位于类的内部,当对已有类进行改造的时候,需要修改代码,违背了开闭原则
  • 当实现深克隆时,需要编写较为复杂的代码,而且当对象之间存在多重嵌套引用时,为了实现深克隆,每一层对象对应的类都必须支持深克隆,实现起来会比较麻烦。因此,深克隆、浅克隆需要运用得当。

原型模式的应用场景:

  • 对象之间相同或相似,即只是个别的几个属性不同的时候。
  • 创建对象成本较大,例如初始化时间长,占用CPU太多,或者占用网络资源太多等,需要优化资源。类初始化需要消化非常多的资源,这个资源包括数据、硬件资源等。
  • 创建一个对象需要繁琐的数据准备或访问权限等,需要提高性能或者提高安全性。
  • 一个对象需要提供给其他对象访问,而且各个调用者可能都需要修改其值时,可以考虑使用原型模式拷贝多个对象供调用者使用
  • 资源优化场景
  • 一个对象多个修改者的场景
  • 想要生成实例的框架不依赖与具体的类,解耦框架与生成的实例

在实际项目中,原型模式很少单独出现,一般是和工厂方法模式一起出现,通过 clone 的方法创建一个对象,然后由工厂方法提供给调用者。原型模式已经与 Java 融为浑然一体,大家可以随手拿来使用。

Spring 中,原型模式应用的非常广泛,例如 scope=’prototype’、JSON.parseObject() 等都是原型模式的具体应用。

7.8、原型模式的扩展(带原型管理器的原型模式)

原型模式可扩展为带原型管理器的原型模式,它在原型模式的基础上增加了一个原型管理器 PrototypeManager 类。该类用 HashMap 保存多个复制的原型,Client 类可以通过管理器的 get(String id) 方法从中获取复制的原型。其结构图:

image-20210414092155079

举例:

用带原型管理器的原型模式来生成包含“圆”和“正方形”等图形的原型,并计算其面积。分析:本实例中由于存在不同的图形类,例如,“圆”和“正方形”,它们计算面积的方法不一样,所以需要用一个原型管理器来管理它们,是其结构图:

image-20210414092311712

ProtoTypeManager :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class ProtoTypeManager {
private HashMap<String, Shape> ht = new HashMap<String, Shape>();
public ProtoTypeManager() {
ht.put("Circle", new Circle());
ht.put("Square", new Square());
}
public void addshape(String key, Shape obj) {
ht.put(key, obj);
}
public Shape getShape(String key) {
Shape temp = ht.get(key);
return (Shape) temp.clone();
}
}

7.9、进阶阅读

原型模式也称为克隆模式,如果您想深入了解原型(克隆)模式,可以猛击阅读下面的文章。

7.10、相关的设计模式

  • Flyweight 模式

    使用Prototype模式可以生成一个与当前实例的状态完全相同的实例。 而使用Flyweight模式可以在不同的地方使用同一个实例。

  • Memento 模式

    使用Prototype模式可以生成一个与当前实例的状态完全相同的实例。而使用Memento模式可以保存当前实例的状态, 以实现快照和撤销功能。

  • Composite 模式以及 Decorator 模式

    经常使用Composite模式和Decorator模式时, 需要能够动态地创建复杂结构的实例。 这时可 以使用Prototype模式, 以帮助我们方便地生成实例。

  • Command 模式

    想要复制Command模式中出现的命令时, 可以使用Prototype模式。

7.11、原型模式的注意事项和细节

  • 创建新的对象比较复杂时,可以利用原型模式简化对象的创建过程,同时也能够提高效率
  • 不用重新初始化对象,而是动态地获得对象运行时的状态
  • 如果原始对象发生变化(增加或者减少属性),其它克隆对象的也会发生相应的变化,无需修改代码
  • 在实现深克隆的时候可能需要比较复杂的代码
  • Cloneable接口是一个标记接口,没有声明方法
  • 缺点:需要为每一个类配备一个克隆方法,这对全新的类来说不是很难,但对已有的类进行改造时,需要修改其源代码,违背了 ocp 原则。
  • 原型模式应用不是很广泛,因为很多实例会持有类似文件、Socket这样的资源,而这些资源是无法复制给另一个对象共享的,只有存储简单类型的“值”对象可以复制。

8、建造者模式Builder(创建型设计模式)

image-20210415031952053

8.1、基本介绍

  1. 建造者模式(Builder Pattern) 又叫生成器模式,是一种对象构建模式。它可以将复杂对象的建造过程抽象出来(抽象类别),使这个抽象过程的不同实现方法可以构造出不同表现(属性)的对象
  2. 建造者模式将一个复杂对象的构造与它的表示分离,使同样的构建过程可以创建不同的表示。它是将一个复杂的对象分解为多个简单的对象,然后一步一步构建而成。它将变与不变相分离,即产品的组成部分不变的,但每一部分是可以灵活选择的。

8.2、建造者模原理结构图-uml类图与模板实现

8.2.1、建造者模式的四个角色

建造者(Builder)模式由产品抽象建造者具体建造者指挥者等 4 个要素构成

  1. Product(产品角色): 它是包含多个组成部件的复杂对象,由具体建造者来创建其各个零部件
  2. Builder(抽象建造者): 它是一个包含创建产品各个子部件的抽象方法的接口,通常还包含一个返回**复杂产品的方法 getResult()**。
  3. ConcreteBuilder(具体建造者): 实现 Builder 接口,完成复杂产品的各个部件的具体创建方法。
  4. Director(指挥者): 构建一个使用 Builder 接口的对象。它主要是用于创建一个复杂的对象。它主要有两个作用:
    1. 隔离了客户与对象的生产过程
    2. 负责控制产品对象的生产过程

8.2.2、建造者模式原理类图

image-20210414182531377

8.2.3、类图的模板代码实现

产品角色Product:包含多个组成部件的复杂对象

1
2
3
4
5
6
7
8
9
10
11
12
13
class Product {
private String partA;
private String partB;
private String partC;
// setters方法
public void setPartA(String partA) {
this.partA = partA;
}
// ...
public void show() {
//显示产品的特性
}
}

抽象建造者Builder:包含创建产品各个子部件的抽象方法

1
2
3
4
5
6
7
8
9
10
11
abstract class Builder {
//创建产品对象
protected Product product = new Product();
public abstract void buildPartA();
public abstract void buildPartB();
public abstract void buildPartC();
//返回产品对象
public Product getResult() {
return product;
}
}

具体建造者ConcreteBuilder:实现了抽象建造者接口

1
2
3
4
5
6
7
8
9
10
11
public class ConcreteBuilder extends Builder {
public void buildPartA() {
product.setPartA("建造 PartA");
}
public void buildPartB() {
product.setPartB("建造 PartB");
}
public void buildPartC() {
product.setPartC("建造 PartC");
}
}

指挥者Director:调用建造者中的方法完成复杂对象的创建

1
2
3
4
5
6
7
8
9
10
11
12
13
class Director {
private Builder builder;
public Director(Builder builder) {
this.builder = builder;
}
//产品构建与组装方法
public Product construct() {
builder.buildPartA();
builder.buildPartB();
builder.buildPartC();
return builder.getResult();
}
}

客户类

1
2
3
4
5
6
7
8
public class Client {
public static void main(String[] args) {
Builder builder = new ConcreteBuilder();
Director director = new Director(builder);
Product product = director.construct();
product.show();
}
}

8.3、应用举例

盖房项目需求

  1. 需要建房子:这一过程为打桩、砌墙、封顶
  2. 房子有各种各样的,比如普通房,高楼,别墅,各种房子的过程虽然一样,但是要求不要相同的.
  3. 请编写程序,完成需求.

8.3.1、传统方法

类图:

image-20210414183731177

传统方式的问题分析

  1. 优点是比较好理解,简单易操作。
  2. 设计的程序结构,过于简单,没有设计缓存层对象,程序的扩展和维护不好. 也就是说,这种设计方案,把产品(**即:房子) 和 **创建产品的过程(即:建房子流程) 封装在一起,耦合性增强了
  3. 解决方案:将产品和产品建造过程解耦 => 建造者模式

8.3.2、建造者模式解决盖房子问题

思路分析图解(类图)

image-20210414183956399

8.4、建造者模式在JDK中的应用与源码分析

java.lang.StringBuilder 中的建造者模式

代码说明:

image-20210414185001199

源码中建造者模式角色分析

  • Appendable 接口定义了多个 append 方法(抽象方法), 即 Appendable 为抽象建造者(builder), 定义了抽象方法
  • AbstractStringBuilder 实现了 Appendable 接口方法,这里的 AbstractStringBuilder 已经是建造者(ConcreteBuilder),只是不能实例化
  • StringBuilder 即充当了指挥者角色(Director),同时充当了具体的建造者(ConcreteBuilder)建造方法的实现是由 AbstractStringBuilder 完成 , 而 StringBuilder 继承了 AbstractStringBuilder。直接使用了AbstractStringBuilder实现的方法。

8.5、建造者模式与工厂模式对区别

  • 抽象工厂模式实现对产品家族的创建,一个产品家族是这样的一系列产品:具有不同分类维度的产品组合,采用抽象工厂模式不需要关心构建过程,只关心什么产品由什么工厂生产即可。
  • 建造者模式则是要求按照指定的蓝图建造产品,它的主要目的是通过组装零配件而产生一个新产品

主要区别:

  • 建造者模式更加注重方法的调用顺序工厂模式注重创建对象
  • 创建对象的力度不同建造者模式创建复杂的对象,由各种复杂的部件组成工厂模式创建出来的对象都一样
  • 关注重点不一样工厂模式只需要把对象创建出来就可以了,而建造者模式不仅要创建出对象,还要知道对象由哪些部件组成
  • 建造者模式根据建造过程中的顺序不一样,最终对象部件组成也不一样

建造者模式唯一区别于工厂模式的是针对复杂对象的创建。也就是说,如果创建简单对象,通常都是使用工厂模式进行创建,而如果创建复杂对象,就可以考虑使用建造者模式

当需要创建的产品具备复杂创建过程时,可以抽取出共性创建过程,然后交由具体实现类自定义创建流程,使得同样的创建行为可以生产出不同的产品,分离了创建与表示,使创建产品的灵活性大大增加。

8.6、建造者模式总结

主要优点如下:

  1. 封装性好,构建和表示分离。
  2. 扩展性好,各个具体的建造者相互独立,有利于系统的解耦。
  3. 客户端不必知道产品内部组成的细节,建造者可以对创建过程逐步细化,而不对其它模块产生任何影响,便于控制细节风险

缺点如下:

  1. 产品的组成部分必须相同,这限制了其使用范围。
  2. 如果产品的内部变化复杂,如果产品内部发生变化,则建造者也要同步修改,后期维护成本较大

模式的应用场景:

  • 相同的方法不同的执行顺序产生不同的结果
  • 多个部件或零件,都可以装配到一个对象中,但是产生的结果又不相同
  • 产品类非常复杂,或者产品类中不同的调用顺序产生不同的作用
  • 初始化一个对象特别复杂,参数多,而且很多参数都具有默认值。

8.7、进阶阅读

如果您想了解建造者模式在实际项目中的应用,可猛击阅读以下文章。

8.8、相关的设计模式

  • Template Method 模式

    • 在 Builder 模式中, Director 角色控制 Builder 角色。
    • 在 Template Method 模式中 , 父类控制子类。
  • Composite 模式

    有些情况下 Builder 模式生成的实例构成了 Composite 模式。

  • Abstract Factory 模式

    Builder 模式和 Abstract Factory 模式都用千生成复杂的实例。

  • Facade 模式

    在 Builder 模式中, Director 角色通过组合 Builder 角色中的复杂方法向外部提供可以简单生成 实例的接口 (API) (相当于示例程序中的 construct 方法)。

    Facade 模式中的 Facade 角色则是通过组合内部模块向外部提供可以简单调用的接口 (API)。

8.9、建造者模式的注意事项和细节

  1. 客户端(使用程序)不必知道产品内部组成的细节,将产品本身与产品的创建过程解耦,使得相同的创建过程可以创建不同的产品对象
  2. 每一个具体建造者都相对独立,而与其他的具体建造者无关,因此可以很方便地替换具体建造者或增加新的具体建造者, 用户使用不同的具体建造者即可得到不同的产品对象
  3. 可以更加精细地控制产品的创建过程 。将复杂产品的创建步骤分解在不同的方法中,使得创建过程更加清晰, 也更方便使用程序来控制创建过程
  4. 增加新的具体建造者无须修改原有类库的代码,指挥者类针对抽象建造者类编程,系统扩展方便符合“开闭原则”
  5. 建造者(Builder)模式在应用过程中可以根据需要改变,如果创建的产品种类只有一种,只需要一个具体建造者,这时可以省略抽象建造者,甚至可以省略掉指挥者角色
  6. 建造者模式所创建的产品一般具有较多的共同点,其组成部分相似,如果产品之间的差异性很大,则不适合使用建造者模式,因此其使用范围受到一定的限制。
  7. 如果产品的内部变化复杂,可能会导致需要定义很多具体建造者类来实现这种变化,导致系统变得很庞大,因此在这种情况下,要考虑是否选择建造者模式.

9、适配器模式Adapter(结构型模式)

image-20210415031546174

9.1、基本介绍

  1. 适配器模式(Adapter Pattern)将某个类的接口转换成客户端期望的另一个接口表示,主的目的是兼容性,让原本因接口不匹配不能一起工作的两个类可以协同工作。其别名为包装器(Wrapper)

  2. 适配器模式属于结构型模式

  3. 主要分为三类

    1. 类适配器模式
    2. 对象适配器模式
    3. 接口适配器模式
  4. 工作原理

    适配器模式:将一个类的接口转换成另一种接口.让原本接口不兼容的类可以兼容

    1. 从用户的角度看不到被适配者,是解耦的

    2. 用户调用适配器转化出来的目标接口方法,适配器再调用被适配者的相关接口方法

    3. 用户收到反馈结果,感觉只是和目标接口交互,如图

      image-20210414214311154

9.2、适配器模式原理结构图-uml类图

适配器模式(Adapter)包含以下主要角色。

  1. 目标(Target)接口:当前系统业务所期待的接口,它可以是抽象类或接口
  2. 被适配者(Adaptee\src)类:它是被访问和适配的现存组件库中的组件接口
  3. 适配器(Adapter)类:它是一个转换器,通过继承(类适配器)或引用(对象适配器)适配者的对象,把适配者接口转换成目标接口,让客户按目标接口的格式访问适配者。

9.2.1、类适配器模式

结构图:

image-20210414215313478

代码:

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
package adapter;
//目标接口
interface Target {
public void request();
}
//适配者接口
class Adaptee {
public void specificRequest() {
System.out.println("适配者中的业务代码被调用!");
}
}
//类适配器类
class ClassAdapter extends Adaptee implements Target {
public void request() {
specificRequest();
}
}
//客户端代码
public class ClassAdapterTest {
public static void main(String[] args) {
System.out.println("类适配器模式测试:");
Target target = new ClassAdapter();
target.request();
}
}

9.2.2、对象适配器模式

结构图:

image-20210414215540735

代码实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package adapter;
//对象适配器类
class ObjectAdapter implements Target {
private Adaptee adaptee;
public ObjectAdapter(Adaptee adaptee) {
this.adaptee=adaptee;
}
public void request() {
adaptee.specificRequest();
}
}
//客户端代码
public class ObjectAdapterTest {
public static void main(String[] args) {
System.out.println("对象适配器模式测试:");
Adaptee adaptee = new Adaptee();
Target target = new ObjectAdapter(adaptee);
target.request();
}
}

9.2.3、接口适配器模式

结构图:

image-20210414215753292

代码:

Interface4(适配者(Adaptee)类):

1
2
3
4
5
6
public interface Interface4 {
public void m1();
public void m2();
public void m3();
public void m4();
}

AbsAdapter(适配器(Adapter)类):

1
2
3
4
5
6
7
8
//在AbsAdapter 我们将 Interface4 的方法进行默认实现
public abstract class AbsAdapter implements Interface4 {
//默认实现
public void m1() {}
public void m2() {}
public void m3() {}
public void m4() {}
}

Client():只需要去覆盖我们 需要使用 接口方法

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Client {
public static void main(String[] args) {
AbsAdapter absAdapter = new AbsAdapter() {
//只需要去覆盖我们 需要使用 接口方法
@Override
public void m1() {
// TODO Auto-generated method stub
System.out.println("使用了m1的方法");
}
};
absAdapter.m1();
}
}

9.3、三种适配器模式的基本介绍

9.3.1、类适配器模式

实现步骤:Adapter 类,通过继承 src 类,实现 dst 类接口,完成 src->dst 的适配

类适配器模式注意事项和细节:

  1. 由于其继承了 src 类,所以它可以根据需求重写 src 类的方法,使得 Adapter 的灵活性增强了。
  2. Java 是单继承机制,所以类适配器需要继承 src 类这一点算是一个缺点, 因为这要求 dst 必须是接口,有一定局限性。
  3. 由于Adapter继承了src类,所以不可避免的会去重写src的方法。在一定程度上违反了里氏原则合成复用原则
  4. src 类的方法在 Adapter 中都会暴露出来,也增加了使用的成本

9.3.2、对象适配器模式

对象适配器模式介绍

  1. 基本思路和类的适配器模式相同,只是将 Adapter 类作修改,不是继承 src 类,而是持有 src 类的实例(依赖),以解决兼容性的问题**。
  2. 实现步骤:持有 src 类,实现 dst 类接口,完成 src->dst 的适配
  3. 根据“合成复用原则”,在系统中尽量使用关联关系(聚合)来替代继承关系。
  4. 对象适配器模式是适配器模式常用的一种

对象适配器模式注意事项和细节

  1. 对象适配器和类适配器其实算是同一种思想,只不过实现方式不同
  2. 根据合成复用原则,使用聚合替代继承, 所以它解决了类适配器必须继承 src 的局限性问题,也不再要求 dst必须是接口
  3. 使用成本更低,更灵活

9.3.3、接口适配器模式

接口适配器模式介绍

  1. 一些书籍称为:适配器模式(Default Adapter Pattern)**或缺省适配器模式**。
  2. 核心思路:当不需要全部实现接口提供的方法时,可先设计一个抽象类实现接口,并为该接口中每个方法提供一个默认实现(空方法),那么该抽象类的子类可有选择地覆盖父类的某些方法来实现需求
  3. 适用于一个接口不想使用其所有的方法的情况。

接口适配器模式注意事项和细节

  1. JDK8开始,接口就可以默认实现了,所以这个可以不要抽象类,全部弄个默认实现就好。
  2. 然后定义接口的实现类可有选择地覆盖接口的默认方法来实现需求

9.4、应用举例

需求:

以生活中充电器的例子来讲解适配器,充电器本身相当于 Adapter,220V 交流电相当于 src (即被适配者),我们的目 dst(即 目标)是 5V 直流电。

9.4.1、使用类适配器模式实现

思路分析(类图)

image-20210414222333677

代码实现

Voltage220V:被适配者(Adaptee\src)类

1
2
3
4
5
6
7
8
9
//被适配的类
public class Voltage220V {
//输出220V的电压
public int output220V() {
int src = 220;
System.out.println("电压=" + src + "伏");
return src;
}
}

IVoltage5V:目标(Target)接口

1
2
3
4
//适配接口
public interface IVoltage5V {
public int output5V();
}

VoltageAdapter:适配器(Adapter)类

1
2
3
4
5
6
7
8
9
10
11
12
//适配器类
public class VoltageAdapter extends Voltage220V implements IVoltage5V {
@Override
public int output5V() {
// TODO Auto-generated method stub
//获取到220V电压
int srcV = output220V();
int dstV = srcV / 44 ; //转成 5v
return dstV;
}

}

phone\Client:客户端进行使用

phone:

1
2
3
4
5
6
7
8
9
10
public class Phone {
//充电
public void charging(IVoltage5V iVoltage5V) {
if(iVoltage5V.output5V() == 5) {
System.out.println("电压为5V, 可以充电~~");
} else if (iVoltage5V.output5V() > 5) {
System.out.println("电压大于5V, 不能充电~~");
}
}
}

Client:

1
2
3
4
5
6
7
8
public class Client {
public static void main(String[] args) {
System.out.println(" === 类适配器模式 ====");
Phone phone = new Phone();
phone.charging(new VoltageAdapter());
}

}

9.4.2、使用对象适配器模式实现

Voltage220V:被适配者(Adaptee\src)类:同上

IVoltage5V:目标(Target)接口:同上

VoltageAdapter:适配器(Adapter)类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//适配器类
public class VoltageAdapter implements IVoltage5V {
private Voltage220V voltage220V; // 关联关系-聚合
//通过构造器,传入一个 Voltage220V 实例
public VoltageAdapter(Voltage220V voltage220v) {
this.voltage220V = voltage220v;
}
@Override
public int output5V() {
int dst = 0;
if(null != voltage220V) {
int src = voltage220V.output220V();//获取220V 电压
System.out.println("使用对象适配器,进行适配~~");
dst = src / 44;
System.out.println("适配完成,输出的电压为=" + dst);
}
return dst;
}
}

phone\Client:客户端进行使用

phone:同上

Client:

1
2
3
4
5
6
7
public class Client {
public static void main(String[] args) {
System.out.println(" === 对象适配器模式 ====");
Phone phone = new Phone();
phone.charging(new VoltageAdapter(new Voltage220V()));
}
}

9.4.3、接口适配器模式应用实例

  • Android 中的属性动画 ValueAnimator 类可以通过 addListener(AnimatorListener listener)方法添加监听器, 那么常规写法如下:

    image-20210414224410293

  • 有时候我们不想实现 Animator.AnimatorListener 接口的全部方法,我们只想监听 onAnimationStart,我们会如下写

    image-20210414224454278

  • AnimatorListenerAdapter 类,就是一个接口适配器,代码如下图:它空实现了Animator.AnimatorListener 类(src)的所有方法

    image-20210414224618055

  • AnimatorListener 是一个接口

    image-20210414224645907

  • 程序里的匿名内部类就是 Listener 具体实现类

    image-20210414224710829

9.5、适配器模式在Spring MVC的应用与源码分析

  • SpringMvc 中的 HandlerAdapter, 就使用了适配器模式

  • SpringMVC 处理请求的流程回顾

    image-20210414234012692

    image-20210414234049194

  • 使用 HandlerAdapter 的原因分析:

    可以看到处理器的类型不同,有多重实现方式,那么调用方式就不是确定的,如果需要直接调用 Controller 方法,需要调用的时候就得不断是使用 if else 来进行判断是哪一种子类然后执行。那么如果后面要扩展 Controller, 就得修改原来的代码,这样违背了 OCP 原则

  • 代码分析+Debug 源码

    image-20210415002836481

    image-20210415002913253

    image-20210415002940414

    image-20210415003150913

    image-20210415003221357

    image-20210415003242857

image-20210415003342721

相关类图:
image-20210415010612924

动手写 SpringMVC 通过适配器设计模式获取到对应的 Controller 的源码:

相关类图:

image-20210414225130202

实现代码:

HandlerAdapter:一个Adapter接口

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
//定义一个Adapter接口 
public interface HandlerAdapter {
public boolean supports(Object handler);
public void handle(Object handler);
}
// 多种适配器类
class SimpleHandlerAdapter implements HandlerAdapter {
public void handle(Object handler) {
((SimpleController) handler).doSimplerHandler();
}
public boolean supports(Object handler) {
return (handler instanceof SimpleController);
}
}
class HttpHandlerAdapter implements HandlerAdapter {
public void handle(Object handler) {
((HttpController) handler).doHttpHandler();
}
public boolean supports(Object handler) {
return (handler instanceof HttpController);
}
}
class AnnotationHandlerAdapter implements HandlerAdapter {
public void handle(Object handler) {
((AnnotationController) handler).doAnnotationHandler();
}
public boolean supports(Object handler) {
return (handler instanceof AnnotationController);
}
}

Controller:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//多种Controller实现  
public interface Controller {
}
class HttpController implements Controller {
public void doHttpHandler() {
System.out.println("http...");
}
}
class SimpleController implements Controller {
public void doSimplerHandler() {
System.out.println("simple...");
}
}
class AnnotationController implements Controller {
public void doAnnotationHandler() {
System.out.println("annotation...");
}
}

DispatchServlet:

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
32
33
34
import java.util.ArrayList;
import java.util.List;

public class DispatchServlet {
public static List<HandlerAdapter> handlerAdapters = new ArrayList<HandlerAdapter>();
public DispatchServlet() {
handlerAdapters.add(new AnnotationHandlerAdapter());
handlerAdapters.add(new HttpHandlerAdapter());
handlerAdapters.add(new SimpleHandlerAdapter());
}
public void doDispatch() {
// 此处模拟SpringMVC从request取handler的对象,
// 适配器可以获取到希望的Controller
HttpController controller = new HttpController();
// AnnotationController controller = new AnnotationController();
//SimpleController controller = new SimpleController();
// 得到对应适配器
HandlerAdapter adapter = getHandler(controller);
// 通过适配器执行对应的controller对应方法
adapter.handle(controller);
}
public HandlerAdapter getHandler(Controller controller) {
//遍历:根据得到的controller(handler), 返回对应适配器
for (HandlerAdapter adapter : this.handlerAdapters) {
if (adapter.supports(controller)) {
return adapter;
}
}
return null;
}
public static void main(String[] args) {
new DispatchServlet().doDispatch(); // http...
}
}

相关补充:

  • Spring定义了一个适配接口,使得每一种Controller有一种对应的适配器实现类
  • 适配器代替Controller执行相应的方法
  • 扩展Controller时(即添加一个OtherController),只需要增加一个适配器类就完成了SpringMVC的扩展了(满足OCP原则)

9.6、适配器模式总结

主要优点如下:

  • 客户端通过适配器可以透明地调用目标接口
  • 复用了现存的类,程序员不需要修改原有代码而重用现有的适配者类。
  • 目标类和适配者类解耦解决了目标类和适配者类接口不一致的问题。
  • 在很多业务场景中符合开闭原则

其缺点是:

  • 适配器编写过程需要结合业务场景全面考虑,可能会增加系统的复杂性
  • 增加代码阅读难度,降低代码可读性,过多使用适配器会使系统代码变得凌乱。

模式的应用场景:

  • 以前开发的系统存在满足新系统功能需求的类,但其接口同新系统的接口不一致
  • 使用第三方提供的组件,但组件接口定义和自己要求的接口定义不同

9.7、适配器模式的扩展

适配器模式(Adapter)可扩展为双向适配器模式,双向适配器类既可以把适配者接口转换成目标接口,也可以把目标接口转换成适配者接口,其结构图如图所示。

image-20210414225959454

相关代码:

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
package adapter;
//目标接口
interface TwoWayTarget {
public void request();
}
//适配者接口
interface TwoWayAdaptee {
public void specificRequest();
}
//目标实现
class TargetRealize implements TwoWayTarget {
public void request() {
System.out.println("目标代码被调用!");
}
}
//适配者实现
class AdapteeRealize implements TwoWayAdaptee {
public void specificRequest() {
System.out.println("适配者代码被调用!");
}
}
//双向适配器
class TwoWayAdapter implements TwoWayTarget,TwoWayAdaptee {
private TwoWayTarget target;
private TwoWayAdaptee adaptee;
public TwoWayAdapter(TwoWayTarget target) {
this.target=target;
}
public TwoWayAdapter(TwoWayAdaptee adaptee) {
this.adaptee=adaptee;
}
public void request() {
adaptee.specificRequest();
}
public void specificRequest() {
target.request();
}
}
//客户端代码
public class TwoWayAdapterTest {
public static void main(String[] args) {
System.out.println("目标通过双向适配器访问适配者:");
TwoWayAdaptee adaptee = new AdapteeRealize();
TwoWayTarget targe = new TwoWayAdapter(adaptee);
target.request();
System.out.println("-------------------");
System.out.println("适配者通过双向适配器访问目标:");
target = new TargetRealize();
adaptee = new TwoWayAdapter(target);
adaptee.specificRequest();
}
}

9.8、进阶阅读

如果您想了解适配器模式在实际中的应用,可猛击阅读以下文章。

9.9、相关的设计模式

  • Bridge模式

    Adapter模式用千连接接口(API)不同的类,而Bridge模式则用于连接类的功能层次结构与实现层次结构。

  • Decorator 模式

    Adapter 模式用于填补不同接口 (API) 之间的缝隙,而 Decorator 模式则是在不改变接口 (API)的前提下增加功能。

9.10、适配器模式的注意事项和细节

  1. 三种命名方式,是根据 src 是以怎样的形式给到 Adapter(在 Adapter 里的形式)来命名的。
  2. 类适配器:以给到,在 Adapter 里,就是将 src 当做继承
  3. 对象适配器:以对象给到,在 Adapter 里,将 src 作为一个对象,持有接口适配器:以接口给到,在 Adapter 里,将 src 作为一个接口实现
  4. Adapter 模式最大的作用还是将原本不兼容的接口融合在一起工作
  5. 实际开发中,实现起来不拘泥于我们讲解的三种经典形式

10、桥接模式Bridge(结构型模式)

image-20210415032242015

10.1、基本介绍

  1. 桥接模式(Bridge 模式)是指:将实现与抽象放在两个不同的类层次中,使两个层次可以独立改变
  2. 是一种结构型设计模式
  3. Bridge 模式基于类的最小设计原则,通过使用封装聚合继承等行为让不同的类承担不同的职责。它的主要特点是把抽象(Abstraction)与行为实现(Implementation)分离开来,从而可以保持各部分的独立性以及应对他们的功能扩展
  4. 它通过提供抽象化和实现化之间的桥接结构,来实现二者的解耦。
  5. 将抽象与实现分离,使它们可以独立变化。它是用组合关系代替继承关系来实现,从而降低了抽象和实现这两个可变维度的耦合度。

10.2、桥接模式原结构图-uml类图

可以将抽象化部分与实现化部分分开,取消二者的继承关系,改用组合关系。

10.2.1、 模式的结构

桥接(Bridge)模式包含以下主要角色。

  1. 抽象化(Abstraction)角色:定义抽象类,并包含一个对实现化对象的引用
  2. 扩展抽象化(Refined Abstraction)角色:是抽象化角色的子类实现父类中的业务方法,并通过组合关系调用实现化角色中的业务方法
  3. 实现化(Implementor)角色:定义实现化角色的接口,供扩展抽象化角色调用
  4. 具体实现化(Concrete Implementor)角色:给出实现化角色接口的具体实现

相关类图:

image-20210415020129290

代码实现:

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
32
33
34
35
36
37
38
package bridge;

public class BridgeTest {
public static void main(String[] args) {
Implementor imple = new ConcreteImplementorA();
Abstraction abs = new RefinedAbstraction(imple);
abs.Operation();
}
}
//实现化角色
interface Implementor {
public void OperationImpl();
}
//具体实现化角色
class ConcreteImplementorA implements Implementor {
public void OperationImpl() {
System.out.println("具体实现化(Concrete Implementor)角色被访问");
}
}
//抽象化角色
abstract class Abstraction {
protected Implementor imple;

protected Abstraction(Implementor imple) {
this.imple = imple;
}
public abstract void Operation();
}
//扩展抽象化角色
class RefinedAbstraction extends Abstraction {
protected RefinedAbstraction(Implementor imple) {
super(imple);
}
public void Operation() {
System.out.println("扩展抽象化(Refined Abstraction)角色被访问");
imple.OperationImpl();
}
}

10.3、应用举例

手机操作问题:

现在对不同手机类型的不同品牌实现操作编程(比如:开机、关机、上网,打电话等),如图:

image-20210415020831299

10.3.1、使用传统方式

实现类图:

image-20210415021009808

传统方案解决手机操作问题分析

  1. 扩展性问题(类爆炸),如果我们再增加手机的样式(旋转式),就需要增加各个品牌手机的类,同样如果我们增加一个手机品牌,也要在各个手机样式类下增加。
  2. 违反了单一职责原则,当我们增加手机样式时,要同时增加所有品牌的手机,这样增加了代码维护成本.
  3. 解决方案-使用桥接模式

10.3.2、使用桥接模式

对应的类图

image-20210415021509975

对于类图的相关解析:

  1. 在FoldedPhone调用的open()方法其实调用了其父类Phone的open()方法
  2. 然而在Phone当中是通过聚合了Brand接口拿到了open()方法
  3. 而Vivo类才是真正实现Brank接口open()方法的实现类
  4. 所以FoldedPhone调用的open()方法最终是调用了Vivo的open()方法
  5. 而Phone在这其中起到了一个桥接的作用

image-20210415024111311

代码实现:

Brand:实现化(Implementor)

1
2
3
4
5
6
//接口
public interface Brand {
void open();
void close();
void call();
}

Phone:抽象化(Abstraction)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public abstract class Phone {
//组合品牌
private Brand brand;
//构造器
public Phone(Brand brand) {
super();
this.brand = brand;
}
protected void open() {
this.brand.open();
}
protected void close() {
brand.close();
}
protected void call() {
brand.call();
}
}

FoldedPhone:扩展抽象化(Refined Abstraction)(UpRightPhone类似)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//折叠式手机类,继承 抽象类 Phone
public class FoldedPhone extends Phone {
//构造器
public FoldedPhone(Brand brand) {
super(brand);
}
public void open() {
super.open();
System.out.println(" 折叠样式手机 ");
}
public void close() {
super.close();
System.out.println(" 折叠样式手机 ");
}
public void call() {
super.call();
System.out.println(" 折叠样式手机 ");
}
}

Vivo:具体实现化(Concrete Implementor)(XiaoMi类似)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Vivo implements Brand {
@Override
public void open() {
System.out.println(" Vivo手机开机 ");
}
@Override
public void close() {
System.out.println(" Vivo手机关机 ");
}
@Override
public void call() {
System.out.println(" Vivo手机打电话 ");
}
}

Client:调用者

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Client {
public static void main(String[] args) {
//获取折叠式手机 (样式 + 品牌 )
Phone phone1 = new FoldedPhone(new XiaoMi());
phone1.open();
phone1.call();
phone1.close();

System.out.println("=======================");

Phone phone2 = new UpRightPhone(new Vivo());
phone2.open();
phone2.call();
phone2.close();
}

}

10.4、桥接模式在JDBC的应用与源码分析

JDBC的 Driver 接口,如果从桥接模式来看,Driver 就是一个接口,下面可以有 MySQL 的 Driver,Oracle 的Driver,这些就可以当做实现接口类

代码分析+Debug 源码

image-20210415030654222

相关类图:

image-20210415031227696

10.5、桥接模式总结

桥接模式遵循了里氏替换原则依赖倒置原则最终实现了开闭原则,对修改关闭,对扩展开放。这里将桥接模式的优缺点总结如下。

优点:

  • 抽象与实现分离,扩展能力强
  • 符合开闭原则
  • 符合合成复用原则
  • 其实现细节对客户透明

缺点:

由于聚合关系建立在抽象层,要求开发者针对抽象化进行设计与编程,能正确地识别出系统中两个独立变化的维度,这增加了系统的理解与设计难度。

桥接模式的应用场景:

一个类内部具备两种或多种变化维度时,使用桥接模式可以解耦这些变化的维度,使高层代码架构稳定。

桥接模式通常适用于以下场景:

  1. 一个类存在两个独立变化的维度,且这两个维度都需要进行扩展时。
  2. 当一个系统不希望使用继承或因为多层次继承导致系统类的个数急剧增加时。
  3. 当一个系统需要在构件的抽象化角色具体化角色之间增加更多的灵活性时。

桥接模式的一个常见使用场景就是替换继承。我们知道,继承拥有很多优点,比如,抽象、封装、多态等,父类封装共性,子类实现特性。继承可以很好的实现代码复用(封装)的功能,但这也是继承的一大缺点。因为父类拥有的方法,子类也会继承得到,无论子类需不需要,这说明继承具备强侵入性(父类代码侵入子类),同时会导致子类臃肿。因此,在设计模式中,有一个原则为优先使用组合/聚合,而不是继承(合成复用原则)

在实际系统开发时常见的应用场景:

  • JDBC 驱动程序

  • 银行转账系统转账分类:

    网上转账,柜台转账,AMT 转账 (抽象层)

    转账用户类型:普通用户,银卡用户,金卡用户.. (实现层)

  • 消息管理

    消息类型:即时消息,延时消息 (抽象层)

    消息分类:手机短信,邮件消息,QQ 消息… (实现层)

image-20210415025038485

很多时候,我们分不清该使用继承还是组合/聚合或其他方式等,其实可以从现实语义进行思考。因为软件最终还是提供给现实生活中的人使用的,是服务于人类社会的,软件是具备现实场景的。当我们从纯代码角度无法看清问题时,现实角度可能会提供更加开阔的思路。

10.6、桥接模式的扩展

在软件开发中,有时桥接(Bridge)模式可与适配器模式联合使用。当桥接(Bridge)模式的实现化角色的接口与现有类的接口不一致时,可以在二者中间定义一个适配器将二者连接起来,其具体结构图如图所示。

image-20210415025940379

10.7、进阶阅读

如果您想深入了解桥接模式,可猛击阅读以下文章。

10.8、相关设计模式

  • Template Method 模式

    在 Template Method 模式中使用了 "类的实现层次结构"。父类调用抽象方法, 而子类实现抽象方法。

  • Abstract Factory 模式

    为了能够根据需求设计出良好的 ConcreteImplementor 角色, 有时我们会使用Abstract Factory 模式。

  • Adapter 模式

    使用 Bridge 模式可以达到类的功能层次结构与类的实现层次结构分离的目的, 并在此基础上使这些层次结构结合起来。

    而使用Adapter 模式则可以结合那些功能上相似但是接口 (API) 不同的类。

10.9、桥接模式的注意事项和细节

  1. 实现了抽象和实现部分的分离,从而极大的提升了系统的灵活性,让抽象部分和实现部分独立开来,这有助于系统进行分层设计,从而产生更好的结构化系统
  2. 对于系统的高层部分,只需要知道抽象部分和实现部分的接口就可以了,其它的部分由具体业务来完成。
  3. 桥接模式替代多层继承方案,可以减少子类的个数,降低系统的管理和维护成本
  4. 桥接模式的引入增加了系统的理解和设计难度,由于聚合关联关系建立在抽象层,要求开发者**针对抽象进行设计和编程桥接模式要求正确识别出系统中两个独立变化的维度(抽象、和实现)**,因此其使用范围有一定的局限性。

11、装饰者模式Decorator(结构型模式)

image-20210415032321784

11.1、基本介绍

  1. 装饰者模式:在不改变现有对象结构的情况下,动态的将新功能附加到对象上。在对象功能扩展方面,它比继承更有弹性,装饰者模式也**体现了开闭原则(ocp)**。
  2. 它属于对象结构型模式

11.2、装饰者模式原理结构图-uml类图

通常情况下,扩展一个类的功能会使用继承方式来实现。但继承具有静态特征,耦合度高,并且随着扩展功能的增多,子类会很膨胀(类爆炸)。如果使用组合关系来创建一个包装对象(即装饰对象)来包裹真实对象,并在保持真实对象的类结构不变的前提下,为其提供额外的功能,这就是装饰器模式的目标。装饰者模式就像打包一个快递。

下面来分析其基本结构和实现方法。

11.2.1、模式的结构

装饰器模式主要包含以下角色:

  1. 抽象构件(Component)角色:定义一个抽象接口以规范准备接收附加责任的对象。(被装饰者)
  2. 具体构件(ConcreteComponent)角色:实现抽象构件,通过装饰角色为其添加一些职责。
  3. 抽象装饰(Decorator)角色:继承抽象构件,并包含具体构件的实例(组合),可以通过其子类扩展具体构件的功能。(装饰者)
  4. 具体装饰(ConcreteDecorator)角色:实现抽象装饰的相关方法,并给具体构件对象添加附加的责任
  5. (可选)缓冲角色:如果有太多的具体构建角色,可以在具体构件(ConcreteComponent)角色与抽象构件(Component)角色建立一个缓冲角色。抽取具体构件(ConcreteComponent)角色的公共部分,对其进行进一步的抽象。

装饰器模式的结构图:

image-20210415105021790

11.2.2、实现代码

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
package decorator;

public class DecoratorPattern {
public static void main(String[] args) {
Component p = new ConcreteComponent();
p.operation();
System.out.println("---------------------------------");
Component d = new ConcreteDecorator(p);
d.operation();
}
}
//抽象构件角色
interface Component {
public void operation();
}
//具体构件角色
class ConcreteComponent implements Component {
public ConcreteComponent() {
System.out.println("创建具体构件角色");
}
public void operation() {
System.out.println("调用具体构件角色的方法operation()");
}
}
//抽象装饰角色
class Decorator implements Component {
private Component component;
public Decorator(Component component) {
this.component = component;
}
public void operation() {
component.operation();
}
}
//具体装饰角色
class ConcreteDecorator extends Decorator {
public ConcreteDecorator(Component component) {
super(component);
}
public void operation() {
super.operation();
addedFunction();
}
public void addedFunction() {
System.out.println("为具体构件角色增加额外的功能addedFunction()");
}
}

11.3、应用举例

星巴克咖啡订单项目(咖啡馆):

  1. 咖啡种类/单品咖啡:Espresso(意大利浓咖啡)、ShortBlack、LongBlack(美式咖啡)、Decaf(无因咖啡)
  2. 调料:Milk、Soy(豆浆)、Chocolate
  3. 要求在扩展新的咖啡种类时,具有良好的扩展性、改动方便、维护方便
  4. 使用 OO 的来计算不同种类咖啡的费用: 客户可以点单品咖啡,也可以单品咖啡+调料组合。

11.3.1、使用方案1(较差)解决需求

思路分析(类图):

image-20210415110413076

方案 1-解决星巴克咖啡订单实现与问题分析

  1. Drink 是一个抽象类,表示饮料
  2. des 就是对咖啡的描述, 比如咖啡的名字
  3. cost() 方法就是计算费用,Drink 类中做成一个抽象方法.
  4. Decaf 就是单品咖啡, 继承 Drink, 并实现 cost
  5. Espress && Milk 就是单品咖啡+调料, 这个组合很多
  6. 问题:这样设计,会有很多类,当我们增加一个单品咖啡,或者一个新的调料,类的数量就会倍增,就会出现类爆炸

11.3.2、使用方案2(较好)解决需求

思路分析(类图):

前面分析到方案 1 因为咖啡单品+调料组合会造成类的倍增,因此可以做改进:

  1. 将调料内置到 Drink 类,这样就不会造成类数量过多。从而提高项目的维护性
  2. 说明: milk,soy,chocolate 可以设计为 Boolean,表示是否要添加相应的调料.

image-20210415110911652

方案 2-解决星巴克咖啡订单问题分析

  1. 方案 2 将调料放在了Drink当中,把它作为成员变量。它可以控制类的数量,不至于造成很多的类。
  2. 增加或者删除调料种类时,代码的维护量很大
  3. 考虑到用户可以添加多份调料时,可以将hasMilk返回一个对应int
  4. 考虑使用 装饰者 模式

11.3.3、使用装饰者模式解决需求

说明:

  • Drink 类就是前面说的抽象类,Component
  • ShortBlack 就单品咖啡
  • Decorator 是一个装饰类,含有一个被装饰的对象(Drink obj)
  • Decorator 的cost 方法进行一个费用的叠加计算,递归的计算价格
  • Coffee类就是具体构件(ConcreteComponent)角色与抽象构件(Component)角色之间的缓冲角色,将ShortBlack等等各种咖啡抽象成一个Coffee类

类图:

image-20210415111510913

装饰者模式下的订单:2 份巧克力+一份牛奶的 LongBlack的CoffeeBar(Client)实现思路:

  • Milk包含了LongBlack
  • 一份Chocolate包含了(Milk+LongBlack)
  • 一份Chocolate包含了(Chocolate+Milk+LongBlack)
  • 这样不管是什么形式的单品咖啡+调料组合,通过递归方式可以方便的组合和维护。

image-20210415112049116

实现代码:

Drink:饮料抽象类。抽象构件(Component)角色

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public abstract class Drink {
public String des; // 描述
private float price = 0.0f;
public String getDes() {
return des;
}
public void setDes(String des) {
this.des = des;
}
public float getPrice() {
return price;
}
public void setPrice(float price) {
this.price = price;
}
//计算费用的抽象方法
//子类来实现
public abstract float cost();
}

Coffee:咖啡类。(可选)缓冲角色

1
2
3
4
5
6
public class Coffee  extends Drink {
@Override
public float cost() {
return super.getPrice();
}
}

ShortBlack:具体咖啡对象。具体构件(ConcreteComponent)角色(其他具体咖啡类类似)

1
2
3
4
5
6
public class ShortBlack extends Coffee{	
public ShortBlack() {
setDes(" shortblack ");
setPrice(4.0f);
}
}

Decorator:调料装饰者。抽象装饰(Decorator)角色

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Decorator extends Drink {
private Drink obj;
public Decorator(Drink obj) { //组合
this.obj = obj;
}
@Override
public float cost() {
// getPrice 自己价格
return super.getPrice() + obj.cost();
}
@Override
public String getDes() {
// super.des/super.getPrice():输出装饰者的描述信息与价格
// obj.getDes() 输出被装饰者的信息
return super.des + " " + super.getPrice() + " && " + obj.getDes();
}
}

Milk:牛奶。具体装饰(ConcreteDecorator)角色

1
2
3
4
5
6
7
8
public class Milk extends Decorator {
public Milk(Drink obj) {
super(obj);
setDes(" 牛奶 ");
setPrice(2.0f);
}

}

CoffeeBar:星巴克。调用2 份巧克力+一份牛奶的 LongBlack

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class CoffeeBar {
public static void main(String[] args) {
// 装饰者模式下的订单:2份巧克力+一份牛奶的LongBlack
// 1. 点一份 LongBlack
Drink order = new LongBlack();
System.out.println("费用1=" + order.cost());
System.out.println("描述=" + order.getDes());
// 2. order 加入一份牛奶
order = new Milk(order);
System.out.println("order 加入一份牛奶 费用 =" + order.cost());
System.out.println("order 加入一份牛奶 描述 = " + order.getDes());
// 3. order 加入一份巧克力
order = new Chocolate(order);
System.out.println("order 加入一份牛奶 加入一份巧克力 费用 =" + order.cost());
System.out.println("order 加入一份牛奶 加入一份巧克力 描述 = " + order.getDes());
// 4. order 再加入一份巧克力
order = new Chocolate(order);
System.out.println("order 加入一份牛奶 加入2份巧克力 费用 =" + order.cost());
System.out.println("order 加入一份牛奶 加入2份巧克力 描述 = " + order.getDes());
}
}

11.4、装饰者模式在IO结构的应用与源码

Java 的 IO 结构,FilterInputStream 就是一个装饰者

相关类图:

image-20210415131821549

源码:

image-20210415132235381

image-20210415132326718

image-20210415132344822

对源码的解析:

  1. InputStream 是抽象类, 类似我们前面讲的 Drink
  2. FileInputStream 是 InputStream 子类,类似我们前面的 DeCaf, LongBlack
  3. FilterInputStream 是 InputStream 子类:类似我们前面 的 Decorator 修饰者
  4. DataInputStream 是 FilterInputStream 子类,具体的修饰者,类似前面的 Milk, Soy 等
  5. FilterInputStream 类 有 protected volatile InputStream in; 即含被装饰者
  6. 分析得出在jdk 的io体系中,就是使用装饰者模式

11.5、装饰者模式总结

主要优点有:

  • 装饰器是继承的有力补充,比继承灵活,在不改变原有对象的情况下动态的给一个对象扩展功能,即插即用
  • 通过使用不用装饰类及这些装饰类的排列组合,可以实现不同效果
  • 装饰器模式完全遵守开闭原则
  • 装饰类和被装饰类可以独立发展,不会相互耦合,装饰模式是继承的一个替代模式,装饰模式可以动态扩展一个实现类的功能。

主要缺点:

  • 装饰器模式会增加许多子类,过度使用会增加程序得复杂性

装饰者模式的应用场景:

  • 当需要给一个现有类添加附加职责,而又不能采用生成子类的方法进行扩充时。例如,该类被隐藏或者该类是终极类或者采用继承方式会产生大量的子类
  • 当需要通过对现有的一组基本功能进行排列组合而产生非常多的功能时,采用继承关系很难实现,而采用装饰器模式却很好实现。
  • 当对象的功能要求可以动态地添加,也可以再动态地撤销时。(可插拔)

装饰器模式在 Java 语言中的最著名的应用莫过于 Java I/O 标准库的设计了。例如,InputStream 的子类 FilterInputStream,OutputStream 的子类 FilterOutputStream,Reader 的子类 BufferedReader 以及 FilterReader,还有 Writer 的子类 BufferedWriter、FilterWriter 以及 PrintWriter 等,它们都是抽象装饰类。

11.6、装饰者模式扩展

装饰器模式所包含的 4 个角色不是任何时候都要存在的,在有些应用环境下模式是可以简化的,如以下两种情况。

11.6.1、如果只有一个具体构件而没有抽象构件时,可以让抽象装饰继承具体构件

image-20210415115027987

11.6.2、如果只有一个具体装饰时,可以将抽象装饰和具体装饰合并

image-20210415115057914

11.7、进阶阅读

如果您想深入了解装饰器模式,可猛击阅读以下文章。

11.8、相关设计模式

  • Adapter模式

    Decorator 模式可以在不改变被装饰物的接口 (API) 的前提下, 为被装饰物添加边框(透明性)。

    Adapter 模式用千适配两个不同的接口 (API)。

  • Stragety模式

    Decorator 模式可以像改变被装饰物的边框或是为被装饰物添加多重边框那样, 来增加类的功能。

    Stragety 模式通过整体地替换算法来改变类的功能。

11.9、装饰者模式的注意事项与细节

  • 得益于接口(API)的透明性, Decorator模式中也形成了类似千Composite模式中的递归结构。
  • 也就是说, 装饰边框里面的 ”被装饰物” 实际上又是别的物体的 "装饰边框"。就像是剥洋葱时以为洋葱心要出来了, 结果却发现还是皮。
  • 不过, Decorator模式虽然与Composite模式一样, 都具有递归 结构, 但是它们的使用目的不同。
  • Decorator模式的主要目的是通过添加装饰物来增加对象的功能。

12、组合模式Composite(结构型模式)

image-20210415133116987

12.1、基本介绍

  1. 组合模式(Composite Pattern),又叫部分整体模式,它创建了对象组的树形结构,将对象组合成树状结构以表示“整体-部分”的层次关系。

  2. 组合模式依据树形结构来组合对象,用来表示部分以及整体层次。

  3. 这种类型的设计模式属于结构型模式

  4. 组合模式使得用户对单个对象和组合对象的访问具有一致性,即:组合能让客户以一致的方式处理个别对象以及组合对象

  5. 组合模式创建了一个包含自己对象组的类。该类提供了修改相同对象组的方式。

  6. 组合模式一般用来描述整体与部分的关系,它将对象组织到树形结构中,顶层的节点被称为根节点,根节点下面可以包含树枝节点叶子节点树枝节点下面又可以包含树枝节点和叶子节点,树形结构图如下:

    image-20210415201954362

    由上图可以看出,其实根节点和树枝节点本质上属于同一种数据类型,可以作为容器使用;而叶子节点与树枝节点在语义上不属于用一种类型。但是在组合模式中,会把树枝节点和叶子节点看作属于同一种数据类型(用统一接口定义),让它们具备一致行为

    这样,在组合模式中,整个树形结构中的对象都属于同一种类型,带来的好处就是用户不需要辨别是树枝节点还是叶子节点,可以直接进行操作,给用户的使用带来极大的便利。

12.2、组合模式的原理结构图-uml类图

组合模式主要包含以下角色:

  • 抽象构件(Component)角色:这是组合中对象声明接口,在适当情况下,实现所有类共有的接口默认行为,用于访问和管理Component 子部件, Component 可以是抽象类或者接口
  • 树叶构件(Leaf)角色: 在组合中表示叶子节点,叶子节点没有子节点。用于继承或实现抽象构件。
  • 树枝构件(Composite)角色 / 中间构件:是组合中的分支节点对象,非叶子节点,用于存储子部件。它的主要作用是存储和管理子部件,在Component接口中实现子部件的相关操作,比如增加(add), 删除(remove)。

装饰器模式的结构图:

image-20210415203743668

代码实现:

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
public class CompositePattern {
public static void main(String[] args) {
Component c0 = new Composite();
Component c1 = new Composite();
Component leaf1 = new Leaf("1");
Component leaf2 = new Leaf("2");
Component leaf3 = new Leaf("3");
c0.add(leaf1);
c0.add(c1);
c1.add(leaf2);
c1.add(leaf3);
c0.operation();
}
}
//抽象构件
interface Component {
public void add(Component c);
public void remove(Component c);
public Component getChild(int i);
public void operation();
}
//树叶构件
class Leaf implements Component {
private String name;
public Leaf(String name) {
this.name = name;
}
public void operation() {
System.out.println("树叶" + name + ":被访问!");
}
}
//树枝构件
class Composite implements Component {
private ArrayList<Component> children = new ArrayList<Component>();
public void add(Component c) {
children.add(c);
}
public void remove(Component c) {
children.remove(c);
}
public Component getChild(int i) {
return children.get(i);
}
public void operation() {
for (Object obj : children) {
((Component) obj).operation();
}
}
}

12.3、应用举例

看一个学校院系展示需求

编写程序展示一个学校院系结构:需求是这样,要在一个页面中展示出学校的院系组成,一个学校有多个学院, 一个学院有多个系。如图:

image-20210415205203158

12.3.1、传统方案解决需求

思路解析(类图)

image-20210415205605144

传统方案解决学校院系展示存在的问题分析

  1. 将学院看做是学校的子类,系是学院的子类,这样实际上是站在组织大小来进行分层次的
  2. 实际上我们的要求是 :在一个页面中展示出学校的院系组成,一个学校有多个学院,一个学院有多个系, 因此这种方案,不能很好实现的管理的操作,比如对学院、系的添加,删除,遍历等
  3. 解决方案:把学校、院、系都看做是组织结构他们之间没有继承的关系,而是一个树形结构,可以更好的实现管理操作。 => 组合模式

12.3.2、组合模式进阶需求

思路分析和图解(类图)

image-20210415215204104

代码实现

OrganizationComponent:组织。抽象构件(Component)角色

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
32
33
34
35
36
public abstract class OrganizationComponent {
private String name; // 名字
private String des; // 说明
// add():增加
protected void add(OrganizationComponent organizationComponent) {
//默认实现
throw new UnsupportedOperationException();
}
// remove():删除
protected void remove(OrganizationComponent organizationComponent) {
//默认实现
throw new UnsupportedOperationException();
}
//方法print, 做成抽象的, 子类都需要实现
protected abstract void print();
//构造器
public OrganizationComponent(String name, String des) {
super();
this.name = name;
this.des = des;
}
// getters and setters
// ...
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getDes() {
return des;
}
public void setDes(String des) {
this.des = des;
}
}

University:大学,组织的一种,管理学院College。树枝构件(Composite)角色 / 中间构件

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
32
33
34
35
36
37
//University 就是 Composite , 可以管理College
public class University extends OrganizationComponent {
// 组合抽象构件(Component)角色:OrganizationComponent
// List 中 存放的College
List<OrganizationComponent> organizationComponents = new ArrayList<OrganizationComponent>();
// 构造器
public University(String name, String des) {
super(name, des);
}
// 重写add
@Override
protected void add(OrganizationComponent organizationComponent) {
organizationComponents.add(organizationComponent);
}
// 重写remove
@Override
protected void remove(OrganizationComponent organizationComponent) {
organizationComponents.remove(organizationComponent);
}
@Override
public String getName() {
return super.getName();
}
@Override
public String getDes() {
return super.getDes();
}
// print方法,就是输出University 包含的学院
@Override
protected void print() {
System.out.println("--------------" + getName() + "--------------");
//遍历 organizationComponents
for (OrganizationComponent organizationComponent : organizationComponents) {
organizationComponent.print();
}
}
}

College:学院,组织的一种,被University管理,管理各个专业。树枝构件(Composite)角色 / 中间构件

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
32
33
34
35
36
37
38
39
40
import java.util.ArrayList;
import java.util.List;

public class College extends OrganizationComponent {
// 组合抽象构件(Component)角色:OrganizationComponent
// List 中 存放的Department
List<OrganizationComponent> organizationComponents = new ArrayList<OrganizationComponent>();
// 构造器
public College(String name, String des) {
super(name, des);
}
// 重写add
@Override
protected void add(OrganizationComponent organizationComponent) {
// 将来实际业务中,Colleage 的 add 和 University add 不一定完全一样
organizationComponents.add(organizationComponent);
}
// 重写remove
@Override
protected void remove(OrganizationComponent organizationComponent) {
organizationComponents.remove(organizationComponent);
}
@Override
public String getName() {
return super.getName();
}
@Override
public String getDes() {
return super.getDes();
}
// print方法,就是输出University 包含的学院
@Override
protected void print() {
System.out.println("--------------" + getName() + "--------------");
//遍历 organizationComponents
for (OrganizationComponent organizationComponent : organizationComponents) {
organizationComponent.print();
}
}
}

Department:专业,组织的一种,被学院College管理,本身是叶子构件,没有管理对象。树叶构件(Leaf)角色

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class Department extends OrganizationComponent {
// 本身是叶子构件,没有管理对象,没有集合
public Department(String name, String des) {
super(name, des);
}

//add , remove 就不用写了,因为他是叶子节点

@Override
public String getName() {
return super.getName();
}
@Override
public String getDes() {
return super.getDes();
}
@Override
protected void print() {
System.out.println(getName());
}
}

Client:调用方

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class Client {
public static void main(String[] args) {
//从大到小创建对象 学校
OrganizationComponent university = new University("清华大学", " 中国顶级大学 ");
//创建 学院
OrganizationComponent computerCollege = new College("计算机学院", " 计算机学院 ");
OrganizationComponent infoEngineercollege = new College("信息工程学院", " 信息工程学院 ");
//创建各个学院下面的系(专业)
computerCollege.add(new Department("软件工程", " 软件工程不错 "));
computerCollege.add(new Department("网络工程", " 网络工程不错 "));
computerCollege.add(new Department("计算机科学与技术", " 计算机科学与技术是老牌的专业 "));

infoEngineercollege.add(new Department("通信工程", " 通信工程不好学 "));
infoEngineercollege.add(new Department("信息工程", " 信息工程好学 "));
//将学院加入到 学校
university.add(computerCollege);
university.add(infoEngineercollege);

//university.print();
infoEngineercollege.print();
}
}

12.4、组合模式在JDK的应用与源码

Java 的集合类-HashMap 就使用了组合模式

代码分析+Debug 源码:

image-20210415223718110

相关类图:

image-20210415225016875

说明:

  1. Map 就是一个抽象的构建 (类似我们的Component)

  2. HashMap是一个中间的构建(Composite), 实现/继承了相关方法put, putall等等

  3. Node 是 HashMap的静态内部类,类似Leaf叶子节点, 这里就没有put, putall等方法

    static class Node<K,V> implements Map.Entry<K,V>

Map:

image-20210415224234562

AbstractMap:

image-20210415224422322

HashMap:

image-20210415224607666

image-20210415224705647

Node:HashMap的静态内部类

image-20210415224934928

12.5、组合模式总结

主要优点有:

  1. 组合模式使得客户端代码可以一致地处理单个对象和组合对象,无须关心自己处理的是单个对象,还是组合对象,这简化了客户端代码;
  2. 更容易在组合体内加入新的对象,客户端不会因为加入了新的对象而更改源代码,满足“开闭原则”;

主要缺点是:

  1. 设计较复杂,客户端需要花更多时间理清类之间的层次关系;
  2. 不容易限制容器中的构件;
  3. 不容易用继承的方法来增加构件的新功能;

组合模式的应用场景

  1. 在需要表示一个对象整体与部分的层次结构的场合。
  2. 要求对用户隐藏组合对象与单个对象的不同,用户可以用统一的接口使用组合结构中的所有对象的场合。

应用实例:

  1. 算术表达式包括操作数、操作符和另一个操作数,其中,另一个操作符也可以是操作数、操作符和另一个操作数。
  2. JAVA AWT 和 SWING 中,对于 Button 和 Checkbox 是树叶,Container 是树枝。

12.6、组合模式扩展

如果对前面介绍的组合模式中的树叶节点和树枝节点进行抽象,也就是说树叶节点和树枝节点还有子节点,这时组合模式就扩展成复杂的组合模式了,如 Java AWT/Swing 中的简单组件 JTextComponent 有子类 JTextField、JTextArea,容器组件 Container 也有子类 Window、Panel。复杂的组合模式的结构图如图所示。

image-20210415221758204

12.7、进阶阅读

如果您想深入了解组合模式,可猛击阅读以下文章。

12.8、相关设计模式

  • Command模式

    使用Command模式编写宏命令时使用了Composite模式。

  • Visitor模式

    可以使用Visitor模式访问Composite模式中的递归结构。

  • Decorator 模式

    Composite模式通过Component角色使容器(Composite角色)和内容(Leaf角色)具有一致性

    Decorator模式使装饰框和内容具有一致性。

12.9、组合模式的注意事项与细节

  1. 简化客户端操作。客户端只需要面对一致的对象而不用考虑整体部分或者节点叶子的问题。
  2. 具有较强的扩展性。当我们要更改组合对象时,我们只需要调整内部的层次关系,客户端不用做出任何改动。满足了OCP原则
  3. 方便创建出复杂的层次结构。客户端不用理会组合里面的组成细节,容易添加节点或者叶子从而创建出复杂的树形结构。
  4. 需要遍历组织机构,或者处理的对象具有树形结构时, 非常适合使用组合模式.
  5. 要求较高的抽象性,如果节点和叶子有很多差异性的话,比如很多方法和属性都不一样不适合使用组合模式

13、外观模式Facade(结构型模式)

image-20210415225627971

13.1、基本介绍

  1. 外观模式(Facade),也叫过程模式门面模式:外观模式为子系统中的一组接口提供一个一致的界面,此模式定义了一个高层接口,这个接口使得这一子系统更加容易使用
  2. 外观模式通过定义一个一致的接口,用以屏蔽内部子系统的细节,使得调用端只需跟这个接口发生调用,而无需关心这个子系统的内部细节。这样会大大降低应用程序的复杂度,提高了程序的可维护性。
  3. 外观设计模式属于结构型模式,它向现有的系统添加一个接口,来隐藏系统的复杂性。
  4. 在日常编码工作中,我们都在有意无意的大量使用外观模式。只要是高层模块需要调度多个子系统(2个以上的类对象),我们都会自觉地创建一个新的类封装这些子系统,提供精简的接口,让高层模块可以更加容易地间接调用这些子系统的功能。尤其是现阶段各种第三方SDK开源类库,很大概率都会使用外观模式。

13.2、外观模式的原理结构图-uml类图

外观(Facade)模式的结构比较简单,主要是定义了一个高层接口。它包含了对各个子系统的引用,客户端可以通过它访问各个子系统的功能。现在来分析其基本结构和实现方法。

13.2.1、模式的结构

外观(Facade)模式包含以下主要角色。

  1. 外观(Facade)角色:为多个子系统对外提供一个统一的接口
  2. 子系统(Sub System)角色:实现系统的部分功能,客户可以通过外观角色访问它。
  3. 客户(Client)角色:通过一个外观角色访问各个子系统的功能。

其结构图类图:

image-20210416002002708

13.2.2、模式的实现

外观模式的实现代码如下:

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
32
33
34
35
36
37
package facade;
//客户角色
public class FacadePattern {
public static void main(String[] args) {
Facade f = new Facade();
f.method();
}
}
//外观角色
class Facade {
private SubSystem01 obj1 = new SubSystem01();
private SubSystem02 obj2 = new SubSystem02();
private SubSystem03 obj3 = new SubSystem03();
public void method() {
obj1.method1();
obj2.method2();
obj3.method3();
}
}
//子系统角色
class SubSystem01 {
public void method1() {
System.out.println("子系统01的method1()被调用!");
}
}
//子系统角色
class SubSystem02 {
public void method2() {
System.out.println("子系统02的method2()被调用!");
}
}
//子系统角色
class SubSystem03 {
public void method3() {
System.out.println("子系统03的method3()被调用!");
}
}

13.3、应用举例

image-20210416003604096

13.3.1、使用传统方式解决需求

思路解析(相关类图):

image-20210416003740197

传统方式解决影院管理问题分析

  1. 在 ClientTest 的 main 方法中,创建各个子系统的对象,并直接去调用子系统(对象)相关方法,会造成调用过程混乱,没有清晰的过程

  2. 不利于在 ClientTest 中,去维护对子系统的操作

  3. 解决思路:定义一个高层接口,给子系统中的一组接口提供一个一致的界面(比如在高层接口提供四个方法ready, play, pause, end ),用来访问子系统中的一群接口

  4. 也就是说:就是通过定义一个一致的接口(界面类),用以屏蔽内部子系统的细节(既使子系统之间互相调用),使得调用端只需跟这个接口发生调用,而无需关心这个子系统的内部细节 => 外观模式

    image-20210416004046940

13.3.2、使用外观模式解决需求

传统方式解决影院管理说明:

  1. 外观模式可以理解为转换一群接口,客户只要调用一个接口,而不用调用多个接口才能达到目的。比如:在 pc 上安装软件的时候经常有一键安装选项(省去选择安装目录、安装的组件等等),还有就是手机的重启功能(把关机和启动合为一个操作)。
  2. 外观模式就是解决多个复杂接口带来的使用困难,起到简化用户操作的作用

思路解析(类图):

image-20210416004253007

代码实现:

Screen:显示器。子系统(Sub System)角色,使用单例模式实现子系统角色的创建。(其他子系统角色类似)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Screen {
//1. 构造器私有化, 外部不能new
private Screen() {}
//2.本类内部创建对象实例
private final static Screen instance = new Screen();
//3. 提供一个公有的静态方法,返回实例对象
public static Screen getInstance() {
return instance;
}
// 显示器相关操作
public void up() {
System.out.println(" Screen up ");
}
public void down() {
System.out.println(" Screen down ");
}
}

HomeTheaterFacade:家庭电影院外观控制器。外观(Facade)角色

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
public class HomeTheaterFacade {
//定义各个子系统对象
private TheaterLight theaterLight;
private Popcorn popcorn;
private Stereo stereo;
private Projector projector;
private Screen screen;
private DVDPlayer dVDPlayer;
//构造器(单例模式)
public HomeTheaterFacade() {
super();
this.theaterLight = TheaterLight.getInstance();
this.popcorn = Popcorn.getInstance();
this.stereo = Stereo.getInstance();
this.projector = Projector.getInstance();
this.screen = Screen.getInstance();
this.dVDPlayer = DVDPlayer.getInstanc();
}
//操作分成 4 步
// 开始
public void ready() {
popcorn.on();
popcorn.pop();
screen.down();
projector.on();
stereo.on();
dVDPlayer.on();
theaterLight.dim();
}
// 播放
public void play() {
dVDPlayer.play();
}
// 暂停
public void pause() {
dVDPlayer.pause();
}
// 结束
public void end() {
popcorn.off();
theaterLight.bright();
screen.up();
projector.off();
stereo.off();
dVDPlayer.off();
}
}

Client:客户端。客户(Client)角色

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Client {
public static void main(String[] args) {
HomeTheaterFacade homeTheaterFacade = new HomeTheaterFacade();
// 开始
homeTheaterFacade.ready();
// 播放
homeTheaterFacade.play();
// 暂停
homeTheaterFacade.pause();
// 结束
homeTheaterFacade.end();
}
}

13.4、外观模式在Mybatis的应用与源码

MyBatis 中的 Configuration 去创建 MetaObject 对象使用到外观模式

代码分析+Debug 源码+示意图

image-20210416005522209

对源码中使用到的外观模式的角色类图:

image-20210416010838982

Mybatis的Configuration:(Facade外观)

image-20210416005648774

Configuration中组合的三个工厂对象:(子系统Sub System)

image-20210416010052394

Configuration中的newMetaObject()方法

image-20210416010317177

MetaObject:Client借助Mybatis的Configuration生成的对象

image-20210416010527422

image-20210416010710795

13.5、外观模式总结

外观(Facade)模式是“迪米特法则”的典型应用

主要优点:

  1. 降低了子系统与客户端之间的耦合度,使得子系统的变化不会影响调用它的客户类。
  2. 对客户屏蔽了子系统组件,减少了客户处理的对象数目,并使得子系统使用起来更加容易
  3. 降低了大型软件系统中的编译依赖性简化了系统在不同平台之间的移植过程,因为编译一个子系统不会影响其他的子系统,也不会影响外观对象。

主要缺点:

  1. 不能很好地限制客户使用子系统类,很容易带来未知风险。
  2. 增加新的子系统可能需要修改外观类或客户端的源代码违背了“开闭原则”,继承重写都不合适。

外观模式的应用场景:

  1. 分层结构系统构建时,使用外观模式定义子系统中每层的入口点可以简化子系统之间的依赖关系。
  2. 一个复杂系统的子系统很多时,外观模式可以为系统设计一个简单的接口供外界访问。
  3. 客户端与多个子系统之间存在很大的联系时,引入外观模式可将它们分离,从而提高子系统的独立性和可移植性。

外观模式应用实例:

  1. 去医院看病,可能要去挂号、门诊、划价、取药,让患者或患者家属觉得很复杂,如果有提供接待人员,只让接待人员来处理,就很方便
  2. JAVA 的三层开发模式:Controller、Service、Dao
  3. 很多Web程序,内部有多个子系统提供服务,经常使用一个统一的Facade入口,例如一个RestApiController,使得外部用户调用的时候,只关心Facade提供的接口,不用管内部到底是哪个子系统处理的。
  4. 更复杂的Web程序,会有多个Web服务,这个时候,经常会使用一个统一的网关入口来自动转发到不同的Web服务,这种提供统一入口的网关就是Gateway,它本质上也是一个Facade,但可以附加一些用户认证、限流限速的额外服务。

13.6、外观模式扩展

在外观模式中,当增加或移除子系统时需要修改外观类,这违背了“开闭原则”。如果引入抽象外观类,则在一定程度上解决了该问题,其结构图如图所示:

image-20210416002803095

13.7、进阶阅读

如果您想了解外观模式的实际应用,可猛击阅读《使用外观模式整合调用已知API》一节。

13.8、相关设计模式

  • Abstract Factory 模式

    可以将AbstractFactory模式看作生成复杂实例时的Facade模式。 因为它提供了 “要想生成这个实例只需要调用这个方法就OK了" 的简单接口。

  • Singleton 模式

    有时会使用Singleton模式创建Facade角色。

  • Mediator 模式

    在Facade模式中,Facade角色单方面地使用其他角色来提供高层接口(API)。

    而在Mediator模式中,Mediator角色作为Colleague角色间的仲裁者负责调停。 可以说, Facade模式是单向的, 而Mediator角色是双向的。

13.9、外观模式的注意事项与细节

  1. 外观模式对外屏蔽了子系统的细节,因此外观模式降低了客户端对子系统使用的复杂性
  2. 外观模式对客户端与子系统的耦合关系 - 解耦,让子系统内部的模块更易维护和扩展
  3. 通过合理的使用外观模式,可以帮我们更好的划分访问的层次
  4. 当系统需要进行分层设计时,可以考虑使用 Facade 模式
  5. 维护一个遗留的大型系统时,可能这个系统已经变得非常难以维护和扩展,此时可以考虑为新系统开发一个Facade 类,来提供遗留系统的比较清晰简单的接口,让新系统与 Facade 类交互,提高复用性
  6. 不能过多的或者不合理的使用外观模式,使用外观模式好,还是直接调用模块好。要以让系统有层次利于维护为目的。

14、享元模式Flyweight(结构型模式)

image-20210416011234518

14.1、基本介绍

  1. 享元模式(Flyweight Pattern) 也叫 蝇量模式: 运用共享技术有效地支持大量细粒度的对象。它通过共享已经存在的对象来大幅度减少需要创建的对象数量、避免大量相似类的开销,从而提高系统资源的利用率。

  2. 享元模式属于结构型模式,它提供了减少对象数量从而改善应用所需的对象结构的方式

  3. 常用于系统底层开发,解决系统的性能问题。像数据库连接池,里面都是创建好的连接对象,在这些连接对象中有我们需要的则直接拿来用,避免重新创建,如果没有我们需要的,则创建一个。

  4. 享元模式能够解决重复对象的内存浪费的问题,当系统中有大量相似对象,需要缓冲池时。不需总是创建新对象,可以从缓冲池里拿。这样可以降低系统内存,同时提高效率

  5. 享元模式的核心思想:如果一个对象实例一经创建就不可变,那么反复创建相同的实例就没有必要,直接向调用方返回一个共享的实例就行,这样即节省内存,又可以减少创建对象的过程提高运行速度。(注意区别单例模式)

    一言蔽之:通过尽量共享实例来避免new出实例

  6. 享元模式经典的应用场景就是池技术了,String 常量池数据库连接池缓冲池等等都是享元模式的应用,享元模式是池技术的重要实现方式

    image-20210416020802732

14.2、享元模式的原理结构图-uml类图

14.2.1、内部状态与外部状态

享元模式的定义提出了两个要求,细粒度共享对象。因为要求细粒度,所以不可避免地会使对象数量多且性质相近,此时我们就将这些对象的信息分为两个部分:内部状态和外部状态。

  • 内部状态指对象共享出来的信息,存储在享元信息内部,并且不会随环境的改变而改变
  • 外部状态指对象得以依赖的一个标记随环境的改变而改变,不可共享

比如围棋、五子棋、跳棋,它们都有大量的棋子对象,围棋和五子棋只有黑白两色,跳棋颜色多一点,所以棋子颜色就是棋子的内部状态;而各个棋子之间的差别就是位置的不同,当我们落子后,落子颜色是定的,但位置是变化的,所以棋子坐标就是棋子的外部状态

又比如,连接池中的连接对象,保存在连接对象中的用户名密码连接URL等信息,在创建对象的时候就设置好了,不会随环境的改变而改变,这些为内部状态。而当每个连接要被回收利用时,我们需要将它标记为可用状态,这些为外部状态

享元模式的本质是缓存共享对象,降低内存消耗。

举个例子:围棋理论上有 361 个空位可以放棋子,每盘棋都有可能有两三百个棋子对象产生,因为内存空间有限,一台服务器很难支持更多的玩家玩围棋游戏,如果用享元模式来处理棋子,那么棋子对象就可以减少到只有两个实例,这样就很好的解决了对象的开销问题

14.2.2、模式的结构

享元模式的主要角色有如下。

  1. 抽象享元角色(Flyweight)(轻量级):是所有的具体享元类的基类,为具体享元规范需要实现的公共接口非享元的外部状态参数的形式通过方法传入
  2. 具体享元(Concrete Flyweight)角色实现抽象享元角色中所规定的接口
  3. 非享元(Unsharable Flyweight)角色:是不可以共享的外部状态,它以参数的形式注入具体享元的相关方法中。一般不会出现在享元工厂
  4. 享元工厂(Flyweight Factory)角色(轻量级):负责创建和管理享元角色。用于构建一个池容器(集合), 同时提供从池中获取对象方法(池技术)。当客户对象请求一个享元对象时,享元工厂检査系统中是否存在符合要求的享元对象,如果存在则提供给客户;如果不存在的话,则创建一个新的享元对象。

享元模式的结构图,其中:

  • UnsharedConcreteFlyweight 是非享元角色,里面包含了非共享的外部状态信息 info;
  • Flyweight 是抽象享元角色,里面包含了享元方法 operation(UnsharedConcreteFlyweight state),非享元的外部状态以参数的形式通过该方法传入;
  • ConcreteFlyweight 是具体享元角色,包含了关键字 key,它实现了抽象享元接口;
  • FlyweightFactory 是享元工厂角色,它是关键字 key 来管理具体享元;
  • 客户角色通过享元工厂获取具体享元,并访问具体享元的相关方法。

image-20210416021500116

14.2.3、代码实现

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
public class FlyweightPattern {
public static void main(String[] args) {
FlyweightFactory factory = new FlyweightFactory();
Flyweight f01 = factory.getFlyweight("a");
Flyweight f02 = factory.getFlyweight("a");
Flyweight f03 = factory.getFlyweight("a");
Flyweight f11 = factory.getFlyweight("b");
Flyweight f12 = factory.getFlyweight("b");
f01.operation(new UnsharedConcreteFlyweight("第1次调用a。"));
f02.operation(new UnsharedConcreteFlyweight("第2次调用a。"));
f03.operation(new UnsharedConcreteFlyweight("第3次调用a。"));
f11.operation(new UnsharedConcreteFlyweight("第1次调用b。"));
f12.operation(new UnsharedConcreteFlyweight("第2次调用b。"));
}
}
//非享元角色
class UnsharedConcreteFlyweight {
private String info;
UnsharedConcreteFlyweight(String info) {
this.info = info;
}
public String getInfo() {
return info;
}
public void setInfo(String info) {
this.info = info;
}
}
//抽象享元角色
interface Flyweight {
public void operation(UnsharedConcreteFlyweight state);
}
//具体享元角色
class ConcreteFlyweight implements Flyweight {
private String key;
ConcreteFlyweight(String key) {
this.key = key;
System.out.println("具体享元" + key + "被创建!");
}
public void operation(UnsharedConcreteFlyweight outState) {
System.out.print("具体享元" + key + "被调用,");
System.out.println("非享元信息是:" + outState.getInfo());
}
}
//享元工厂角色
class FlyweightFactory {
// 使用HashMap作池
private HashMap<String, Flyweight> flyweights = new HashMap<String, Flyweight>();
public Flyweight getFlyweight(String key) {
Flyweight flyweight = (Flyweight) flyweights.get(key);
if (flyweight != null) {
System.out.println("具体享元" + key + "已经存在,被成功获取!");
} else {
flyweight = new ConcreteFlyweight(key);
flyweights.put(key, flyweight);
}
return flyweight;
}
}

14.3、应用举例

展示网站项目需求:

小型的外包项目,给客户 A 做一个产品展示网站,客户 A 的朋友感觉效果不错,也希望做这样的产品展示网站,但是要求都有些不同:

  • 有客户要求以新闻的形式发布
  • 有客户人要求以博客的形式发布
  • 有客户希望以微信公众号的形式发布

14.3.1、使用传统方式解决需求

  1. 直接复制粘贴一份,然后根据客户不同要求,进行定制修改
  2. 给每个网站租用一个空间

思路分析(类图):

image-20210416024816721

传统方案解决网站展现项目问题分析:

  1. 需要的网站结构相似度很高,而且都不是高访问量网站,如果分成多个虚拟空间来处理,相当于一个相同网站的实例对象很多,造成服务器的资源浪费
  2. 解决思路:整合到一个网站中,共享其相关的代码和数据,对于硬盘、内存、CPU、数据库空间等服务器资源都可以达成共享,减少服务器资源
  3. 对于代码来说,由于是一份实例,维护和扩展都更加容易
  4. 上面的解决思路就可以使用享元模式来解决

14.3.2、使用享元模式解决需求

思路分析和图解(类图):

image-20210416025317394

代码实现:

WebSite:网站,当中的use方法传入外部状态User。抽象享元角色(Flyweight)

1
2
3
public abstract class WebSite {
public abstract void use(User user);//抽象方法
}

ConcreteWebSite:具体网站,继承WebSite,包含有内部状态type。具体享元(Concrete Flyweight)角色

1
2
3
4
5
6
7
8
9
10
11
12
13
//具体网站
public class ConcreteWebSite extends WebSite {
//共享的部分,内部状态
private String type = ""; //网站发布的形式(类型)
//构造器
public ConcreteWebSite(String type) {
this.type = type;
}
@Override
public void use(User user) {
System.out.println("网站的发布形式为:" + type + " 在使用中 .. 使用者是" + user.getName());
}
}

User:用户,不同的用户的构建网站的类型不同,属于外部状态,通过方法参数传入。非享元(Unsharable Flyweight)角色

1
2
3
4
5
6
7
8
9
10
11
12
13
public class User {
private String name;
public User(String name) {
super();
this.name = name;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}

WebSiteFactory:网站工厂类,根据需要返回一个网站。享元工厂(Flyweight Factory)角色

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

// 网站工厂类,根据需要返回一个网站
public class WebSiteFactory {
//集合, 充当池的作用
private HashMap<String, ConcreteWebSite> pool = new HashMap<>();
//根据网站的类型,返回一个网站, 如果没有就创建一个网站,并放入到池中,并返回
public WebSite getWebSiteCategory(String type) {
if(!pool.containsKey(type)) {
//就创建一个网站,并放入到池中
pool.put(type, new ConcreteWebSite(type));
}
return (WebSite)pool.get(type);
}
//获取网站分类的总数 (池中有多少个网站类型)
public int getWebSiteCount() {
return pool.size();
}
}

Client:客户端调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class Client {
public static void main(String[] args) {
// 创建一个工厂类
WebSiteFactory factory = new WebSiteFactory();
// 客户tom要一个以新闻形式发布的网站
WebSite webSite1 = factory.getWebSiteCategory("新闻");
webSite1.use(new User("tom"));
// 客户jack要一个以博客形式发布的网站
WebSite webSite2 = factory.getWebSiteCategory("博客");
webSite2.use(new User("jack"));
// 客户smith要一个以博客形式发布的网站
WebSite webSite3 = factory.getWebSiteCategory("博客");
webSite3.use(new User("smith"));
// 客户king要一个以博客形式发布的网站
WebSite webSite4 = factory.getWebSiteCategory("博客");
webSite4.use(new User("king"));
// 网站的分类共=2
System.out.println("网站的分类共=" + factory.getWebSiteCount());
}
}

14.4、享元模式在JDK-Integer的应用与源码

Integer 中的享元模式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class FlyWeight {
public static void main(String[] args) {
Integer x = Integer.valueOf(127); // 得到 x实例,类型 Integer
Integer y = new Integer(127); // 得到 y 实例,类型 Integer
Integer z = Integer.valueOf(127);//..
Integer w = new Integer(127);

System.out.println(x.equals(y)); // 大小,true
System.out.println(x == y ); // false
System.out.println(x == z ); // true
System.out.println(w == x ); // false
System.out.println(w == y ); // false

Integer x1 = Integer.valueOf(200);
Integer x2 = Integer.valueOf(200);
System.out.println("x1==x2" + (x1 == x2)); // false
}
}

代码分析:

  • 如果 Integer.valueOf(x)的参数 x 在 -128 — 127 之间,就是使用享元模式返回,如果不在范围内,则仍然 new 一个Integer对象
  • 在valueOf 方法中,先判断值是否在 IntegerCache 中,如果不在,就创建新的Integer(new);否则,就直接从缓存池返回
  • valueOf 方法,就使用到享元模式
  • 如果使用valueOf 方法得到一个Integer 实例,范围在 -128 - 127 ,执行速度比 new 快

为什么使用Integer.valueOf(x)并且其参数 x 的范围在 -128 — 127 之间就是同一个对象,看源码:

Debug 源码+说明:

Integer:

image-20210416031357474

Integer当中的IntegerCache:

image-20210416031820357

image-20210416032125850

14.5、享元模式总结

主要优点是

  • 相同对象只要保存一份,这降低了系统中对象的数量,从而降低了系统中细粒度对象给内存带来的压力

主要缺点是:

  • 为了使对象可以共享,需要将一些不能共享的状态外部化,这将增加程序的复杂性
  • 享元模式提高了系统的复杂度。需要分离出内部状态和外部状态,而外部状态具有固有化的性质,不应该随着内部状态的变化而变化,否则会造成系统的混乱。这是我们使用享元模式需要注意的地方。
  • 读取享元模式的外部状态会使得运行时间稍微变长

享元模式的应用场景:

当系统中多处需要同一组信息时,可以把这些信息封装到一个对象中,然后对该对象进行缓存,这样,一个对象就可以提供给多出需要使用的地方,避免大量同一对象的多次创建降低大量内存空间的消耗

享元模式其实是工厂方法模式的一个改进机制享元模式同样要求创建一个或一组对象,并且就是通过工厂方法模式生成对象的,只不过享元模式为工厂方法模式增加了缓存这一功能。

享元模式是通过减少内存中对象的数量来节省内存空间的,所以以下几种情形适合采用享元模式。

  1. 系统中存在大量相同或相似的对象,这些对象耗费大量的内存资源。
  2. 大部分的对象可以按照内部状态进行分组,且可将不同部分外部化,这样每一个组只需保存一个内部状态
  3. 由于享元模式需要额外维护一个保存享元的数据结构(多为HashMap\HashTable),所以应当在有足够多的享元实例时才值得使用享元模式。

14.6、享元模式扩展

在前面介绍的享元模式中,其结构图通常包含可以共享的部分不可以共享的部分。在实际使用过程中,有时候会稍加改变,即存在两种特殊的享元模式:单纯享元模式复合享元模式

14.6.1、单纯享元模式

这种享元模式中的所有的具体享元类都是可以共享的,不存在非共享的具体享元类。如类图所示:

image-20210416024136156

14.6.2、复合享元模式

这种享元模式中的有些享元对象是由一些单纯享元对象组合而成的,它们就是复合享元对象。虽然复合享元对象本身不能共享,但它们可以分解成单纯享元对象再被共享。如类图所示:

image-20210416024246399

14.7、进阶阅读

如果您想深入了解享元模式,可猛击阅读以下文章。

14.8、相关设计模式

  • Proxy 模式

    如果生成实例的处理需要花费较长时间, 那么使用 Flyweight 模式可以提高程序的处理速度。

    而 Proxy 模式则是通过设置代理提高程序的处理速度。

  • Composite 模式

    有时可以使用 Flyweight 模式共享 Composite 模式中的 Leaf 角色。

  • Singleton 模式

    在 FlyweightFactory 角色中有时会使用 Singleton 模式。

    此外如果使用了 Singleton 模式,由于只会生成一个 Singleton 角色,因此所有使用该实例的地方都共享同一个实例。 在 Singleton 角色的实例中只持有内部(固有)信息。

14.9、享元模式的注意事项与细节

  1. 在享元模式这样理解,“享”就表示共享“元”表示对象
  2. 系统中有大量对象,这些对象消耗大量内存,并且对象的状态大部分可以外部化时,我们就可以考虑选用享元模式
  3. 唯一标识码判断,如果在内存中有,则返回这个唯一标识码所标识的对象,用 HashMap/HashTable 存储
  4. 享元模式大大减少了对象的创建降低了程序内存的占用提高效率
  5. 享元模式提高了系统的复杂度。需要分离出内部状态和外部状态,而外部状态具有固有化的性质,不应该随着内部状态的变化而变化,否则会造成系统的混乱。这是我们使用享元模式需要注意的地方。
  6. 使用享元模式时,注意划分内部状态和外部状态,并且需要有一个工厂类加以控制
  7. 在使用享元模式的时候要注意:不要让被共享的实例被垃圾回收机制(GC)回收了
  8. 享元模式经典的应用场景是需要缓冲池的场景,比如 String 常量池、数据库连接池。

14.10、享元模式与单例模式的区别

  1. 单例模式是整个应用系统共用一个实例对象

    享元模式是整个系统共用好几个同类型对象

  2. 连接池本身是单例模式,连接池里的多个连接对象是享元模式

  3. 而且享元模式的共享对象是按需分配的,如果不够还会再创建

    单例模式绝对不会重复创建第二个对象,这是本质不同

  4. 享元模式里的共享对象在使用时一定是线程私有的

    就比如共享单车,虽然是共享的,但在使用时一定是只属于你的

  5. 享元模式的共享对象有借有还的,在宏观上是共享的。

15、代理模式Proxy(结构型模式)

image-20210416084620231

15.1、基本介绍

  1. 代理模式:为一个对象提供一个替身,以控制对这个对象的访问。即通过代理对象访问目标对象。这时,访问对象不适合或者不能直接引用目标对象,代理对象作为访问对象和目标对象之间的中介。

  2. 这样做的好处是:可以在目标对象实现的基础上,增强额外的功能操作,即扩展目标对象的功能

  3. 被代理的对象可以是远程对象创建开销大的对象需要安全控制的对象

  4. 代理模式有不同的形式, 主要有三种:

    • 静态代理模式:
      • 静态代理在使用时,需要定义接口或者父类,被代理对象(即目标对象)与代理对象一起实现相同的接口或者是继承相同父类。
    • 动态代理模式 (JDK 代理、接口代理):
      • 代理对象不需要实现接口,但是目标对象要实现接口,否则不能用动态代理
      • 代理对象的生成,是利用 JDK 的 API (反射)动态的在内存中构建代理对象
      • 动态代理也叫做:JDK 代理接口代理
      • JDK 中生成代理对象的 API:
        • 代理类所在包:java.lang.reflect.Proxy
        • JDK 实现代理只需要使用 newProxyInstance 方法,但是该方法需要接收三个参数,完整的写法是:
        • static Object newProxyInstance(ClassLoader loader, Class<?>[] interfaces,InvocationHandler h )
          1. ClassLoader loader : 指定当前目标对象使用的类加载器, 获取加载器的方法固定
          2. Class<?>[] interfaces: 目标对象实现的接口类型,使用泛型方法确认类型
          3. InvocationHandler h : 事情处理,执行目标对象的方法时,会触发事情处理器方法, 会把当前执行的目标对象方法作为参数传入
    • Cglib 代理模式(可以在内存动态的创建对象,而不需要实现接口, 他是属于动态代理的范畴) :
      • 静态代理和 JDK 代理模式都要求目标对象是实现一个接口,但是有时候目标对象只是一个单独的对象并没有实现任何的接口,这个时候可使用目标对象子类来实现代理-这就是 Cglib 代理。
      • Cglib 代理也叫作子类代理**,它是在内存中构建一个子类对象从而实现对目标对象功能扩展**, 有些书也将Cglib 代理归属到动态代理。
      • Cglib 是一个强大的高性能的代码生成包,它可以在运行期扩展 java 类与实现 java 接口。它广泛的被许多 AOP 的框架使用,例如 Spring AOP,实现方法拦截
      • 在 AOP 编程中如何选择代理模式:
        • 目标对象需要实现接口,用 JDK 代理
        • 目标对象不需要实现接口,用 Cglib 代理
      • Cglib 包的底层是通过使用字节码处理框架 ASM 来转换字节码并生成新的类
  5. 代理模式总的类图:

    image-20210416132054743

15.2、代理模式的原理结构图-uml类图

代理模式的主要角色如下:

  1. 抽象主题(Subject)类:通过接口或抽象类声明真实主题和代理对象实现的业务方法。
  2. 真实主题(Real Subject)类:实现了抽象主题中的具体业务,是代理对象所代表的真实对象,是最终要引用的对象。
  3. 代理(Proxy)类:提供了与真实主题相同的接口,其内部含有对真实主题的引用,它可以访问、控制或扩展真实主题的功能。

15.2.1、静态代理模式

相关类图:

image-20210416132423219

代码实现:

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
32
33
34
35
36
package proxy;

public class ProxyTest {
public static void main(String[] args) {
Proxy proxy = new Proxy();
proxy.Request();
}
}
//抽象主题
interface Subject {
void Request();
}
//真实主题
class RealSubject implements Subject {
public void Request() {
System.out.println("访问真实主题方法...");
}
}
//代理
class Proxy implements Subject {
private RealSubject realSubject;
public void Request() {
if (realSubject == null) {
realSubject = new RealSubject();
}
preRequest();
realSubject.Request();
postRequest();
}
public void preRequest() {
System.out.println("访问真实主题之前的预处理。");
}
public void postRequest() {
System.out.println("访问真实主题之后的后续处理。");
}
}

15.2.2、JDK动态代理模式

相关类图:
image-20210416134249061

执行原理:

preview

代码实现:

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
public class Client {
public static void main(String[] args) {
//创建目标对象
Subject target = new RealSubject();
//给目标对象,创建代理对象, 可以转成 ITeacherDao
Subject proxyInstance = (Subject)new ProxyFactory(target).getProxyInstance();
// proxyInstance=class com.sun.proxy.$Proxy0 内存中动态生成了代理对象
System.out.println("proxyInstance=" + proxyInstance.getClass());
//通过代理对象,调用目标对象的方法
proxyInstance.Request();
}
}
//抽象主题
interface Subject {
void Request();
}
//真实主题
class RealSubject implements Subject {
public void Request() {
System.out.println("访问真实主题方法...");
}
}
// 代理工厂
public class ProxyFactory {
//维护一个目标对象 , Object
private Object target;
//构造器 , 对target 进行初始化
public ProxyFactory(Object target) {
this.target = target;
}
//给目标对象 生成一个代理对象
public Object getProxyInstance() {
//说明
/*
* public static Object newProxyInstance(ClassLoader loader,
Class<?>[] interfaces,
InvocationHandler h)

//1. ClassLoader loader : 指定当前目标对象使用的类加载器, 获取加载器的方法固定
//2. Class<?>[] interfaces: 目标对象实现的接口类型,使用泛型方法确认类型
//3. InvocationHandler h : 事情处理,执行目标对象的方法时,会触发事情处理器方法, 会把当前执行的目标对象方法作为参数传入
*/
return Proxy.newProxyInstance(target.getClass().getClassLoader(),
target.getClass().getInterfaces(),
new InvocationHandler() {
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
System.out.println("JDK代理开始~~");
//反射机制调用目标对象的方法
Object returnVal = method.invoke(target, args);
System.out.println("JDK代理提交");
return returnVal;
}
});
}
}

15.2.3、Cglib代理模式

相关类图:

image-20210416135654386

执行原理:
preview

代码实现:

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
public class Client {
public static void main(String[] args) {
//创建目标对象
RealSubject target = new RealSubject();
//获取到代理对象,并且将目标对象传递给代理对象
RealSubject proxyInstance = (RealSubject)new ProxyFactory(target).getProxyInstance();
//执行代理对象的方法,触发intecept 方法,从而实现 对目标对象的调用
String res = proxyInstance.Request();
System.out.println("res=" + res);
}
}
//真实主题
class RealSubject {
public void Request() {
System.out.println("访问真实主题方法...");
}
}
// 代理工厂
public class ProxyFactory implements MethodInterceptor {
//维护一个目标对象
private Object target;
//构造器,传入一个被代理的对象
public ProxyFactory(Object target) {
this.target = target;
}
//返回一个代理对象: 是 target 对象的代理对象
public Object getProxyInstance() {
//1. 创建一个工具类
Enhancer enhancer = new Enhancer();
//2. 设置父类
enhancer.setSuperclass(target.getClass());
//3. 设置回调函数
enhancer.setCallback(this);
//4. 创建子类对象,即代理对象
return enhancer.create();
}
//重写 intercept 方法,会调用目标对象的方法
@Override
public Object intercept(Object arg0, Method method, Object[] args, MethodProxy arg3) throws Throwable {
System.out.println("Cglib代理模式 ~~ 开始");
Object returnVal = method.invoke(target, args);
System.out.println("Cglib代理模式 ~~ 提交");
return returnVal;
}
}

15.3、应用举例

具体要求:

  1. 定义一个接口:ITeacherDao
  2. 目标对象 TeacherDAO 实现接口 ITeacherDAO
  3. 使用静态代理方式,就需要在代理对象 TeacherDAOProxy 中也实现 ITeacherDAO
  4. 调用的时候通过调用代理对象的方法来调用目标对象.

15.3.1、使用静态代理模式解决需求

思路分析图解(类图):

image-20210416132715029

实现代码:

ITeacherDao:教师操作接口。抽象主题(Subject)类

1
2
3
4
//接口
public interface ITeacherDao {
void teach(); // 授课的方法
}

TeacherDao:教师操作接口实现类。真实主题(Real Subject)类

1
2
3
4
5
6
public class TeacherDao implements ITeacherDao {
@Override
public void teach() {
System.out.println(" 老师授课中 。。。。。");
}
}

TeacherDaoProxy:教师操作代理对象。代理(Proxy)类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//代理对象,静态代理
public class TeacherDaoProxy implements ITeacherDao{
private ITeacherDao target; // 目标对象,通过接口来组合
//构造器
public TeacherDaoProxy(ITeacherDao target) {
this.target = target;
}
@Override
public void teach() {
System.out.println("开始代理 完成某些操作。。。。。 ");//方法
target.teach();
System.out.println("提交。。。。。");//方法
}
}

Client:调用方。

1
2
3
4
5
6
7
8
9
10
11
public class Client {
public static void main(String[] args) {
//创建目标对象(被代理对象)
TeacherDao teacherDao = new TeacherDao();
//创建代理对象, 同时将被代理对象传递给代理对象
TeacherDaoProxy teacherDaoProxy = new TeacherDaoProxy(teacherDao);
//通过代理对象,调用到被代理对象的方法
//即:执行的是代理对象的方法,代理对象再去调用目标对象的方法
teacherDaoProxy.teach();
}
}

15.3.2、使用动态代理模式解决需求

思路分析图解(类图):

image-20210416141108093

代码实现:

ITeacherDao:教师操作接口。抽象主题(Subject)类

1
2
3
4
5
//接口
public interface ITeacherDao {
void teach(); // 授课方法
void sayHello(String name);
}

TeacherDao:教师操作接口实现类。真实主题(Real Subject)类

1
2
3
4
5
6
7
8
9
10
public class TeacherDao implements ITeacherDao {
@Override
public void teach() {
System.out.println(" 老师授课中.... ");
}
@Override
public void sayHello(String name) {
System.out.println("hello " + name);
}
}

ProxyFactory:代理工厂,用来生成代理对象。生成的对象:代理(Proxy)类

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
32
33
34
35
36
37
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
// 代理工厂,用来生成代理对象
public class ProxyFactory {
//维护一个目标对象 , Object
private Object target;
//构造器 , 对target 进行初始化
public ProxyFactory(Object target) {
this.target = target;
}
//给目标对象 生成一个代理对象
public Object getProxyInstance() {
//说明
/*
* public static Object newProxyInstance(ClassLoader loader,
Class<?>[] interfaces,
InvocationHandler h)

//1. ClassLoader loader : 指定当前目标对象使用的类加载器, 获取加载器的方法固定
//2. Class<?>[] interfaces: 目标对象实现的接口类型,使用泛型方法确认类型
//3. InvocationHandler h : 事情处理,执行目标对象的方法时,会触发事情处理器方法, 会把当前执行的目标对象方法作为参数传入
*/
return Proxy.newProxyInstance(target.getClass().getClassLoader(),
target.getClass().getInterfaces(),
new InvocationHandler() {
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
System.out.println("JDK代理开始~~");
//反射机制调用目标对象的方法
Object returnVal = method.invoke(target, args);
System.out.println("JDK代理提交");
return returnVal;
}
});
}
}

Client:调用方。

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Client {
public static void main(String[] args) {
//创建目标对象
ITeacherDao target = new TeacherDao();
//给目标对象,创建代理对象, 可以转成 ITeacherDao
ITeacherDao proxyInstance = (ITeacherDao)new ProxyFactory(target).getProxyInstance();
// proxyInstance=class com.sun.proxy.$Proxy0 内存中动态生成了代理对象
System.out.println("proxyInstance=" + proxyInstance.getClass());
//通过代理对象,调用目标对象的方法
proxyInstance.teach();
proxyInstance.sayHello(" tom ");
}
}

15.3.3、使用cglib代理模式解决需求

思路分析图解(类图):

image-20210416143133820

代码实现:

TeacherDao:教师操作类。真实主题(Real Subject)类

1
2
3
4
5
6
public class TeacherDao {
public String teach() {
System.out.println(" 老师授课中 , 我是cglib代理,不需要实现接口 ");
return "hello";
}
}

ProxyFactory:代理工厂,实现cglib的MethodInterceptor接口,用来生成代理对象。生成的对象:代理(Proxy)类

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
32
import java.lang.reflect.Method;
import net.sf.cglib.proxy.Enhancer;
import net.sf.cglib.proxy.MethodInterceptor;
import net.sf.cglib.proxy.MethodProxy;
// ProxyFactory:代理工厂,实现cglib的MethodInterceptor接口,用来生成代理对象
public class ProxyFactory implements MethodInterceptor {
//维护一个目标对象
private Object target;
//构造器,传入一个被代理的对象
public ProxyFactory(Object target) {
this.target = target;
}
//返回一个代理对象: 是 target 对象的代理对象
public Object getProxyInstance() {
//1. 创建一个工具类
Enhancer enhancer = new Enhancer();
//2. 设置父类
enhancer.setSuperclass(target.getClass());
//3. 设置回调函数
enhancer.setCallback(this);
//4. 创建子类对象,即代理对象
return enhancer.create();
}
//重写 intercept 方法,会调用目标对象的方法
@Override
public Object intercept(Object arg0, Method method, Object[] args, MethodProxy arg3) throws Throwable {
System.out.println("Cglib代理模式 ~~ 开始");
Object returnVal = method.invoke(target, args);
System.out.println("Cglib代理模式 ~~ 提交");
return returnVal;
}
}

Client:调用方。

1
2
3
4
5
6
7
8
9
10
11
public class Client {
public static void main(String[] args) {
//创建目标对象
TeacherDao target = new TeacherDao();
//获取到代理对象,并且将目标对象传递给代理对象
TeacherDao proxyInstance = (TeacherDao)new ProxyFactory(target).getProxyInstance();
//执行代理对象的方法,触发intecept 方法,从而实现 对目标对象的调用
String res = proxyInstance.teach();
System.out.println("res=" + res);
}
}

15.4、代理模式总结

15.4.1、静态代理优缺点

  • 优点:
    • 在不修改目标对象的功能前提下, 能通过代理对象对目标功能扩展
  • 缺点:
    • 因为代理对象需要与目标对象实现一样的接口,所以会有很多代理类
    • 一旦接口增加方法,目标对象与代理对象都要维护

15.4.2、JDK动态代理优缺点

  • 优点:
    • JDK原声动态代理时java原声支持的、不需要任何外部依赖
  • 缺点:
    • 但是它只能基于接口进行代理(因为它已经继承了proxy了,java不支持多继承)

15.4.3、Cglib动态代理优缺点

优点:

  • CGLIB通过继承的方式进行代理、无论目标对象没有没实现接口都可以代理

缺点:

  • 需要引入 cglib 的 jar 文件

    image-20210416143100693

  • 在内存中动态构建子类,注意代理的类不能为 final,否则报错java.lang.IllegalArgumentException:

  • 目标对象的方法如果为 final/static,那么就不会被拦截,即不会执行目标对象额外的业务方法。(final修饰的方法不能被覆写)

15.4.4、两种动态代理模式的对比(JDK VS CGLIB)

JDK原生动态代理 CGLB动态代理
核心原理 基于”接口实现”方式 基于类集成方式
优点 Java原生支持的,不需要任何依赖 对与代理的目标对象无限制,无需实现接口
不足之处 只能基于接口进行实现 无法处理final方法
实现方式 Java原生支持,不需要任何依赖 需要引用JAR包cglib-nodep-3.2.5.jar和asm.jar

15.4.5、代理模式总结

主要优点有:

  • 代理模式在客户端与目标对象之间起到一个中介作用和保护目标对象的作用;
  • 代理对象可以扩展目标对象的功能
  • 代理模式能将客户端与目标对象分离,在一定程度上降低了系统的耦合度增加了程序的可扩展性

主要缺点是(有些缺点可通过动态代理解决):

  • 代理模式会造成系统设计中类的数量增加
  • 在客户端和目标对象之间增加一个代理对象,会造成请求处理速度变慢
  • 增加了系统的复杂度

应用场景:

无法或不想直接引用某个对象访问某个对象存在困难时,可以通过代理对象来间接访问。使用代理模式主要有两个目的:一是保护目标对象,二是增强目标对象

  1. 远程代理:

    • 远程代理即Remote Proxy本地的调用者持有的接口实际上是一个代理,这个代理负责把对接口的方法访问转换成远程调用,然后返回结果。
    • 这种方式通常是为了隐藏目标对象存在于不同地址空间的事实,方便客户端访问。例如,用户申请某些网盘空间时,会在用户的文件系统中建立一个虚拟的硬盘,用户访问虚拟硬盘时实际访问的是网盘空间。
    • Java内置的RMI机制就是一个完整的远程代理模式。
  2. 虚拟代理:

    • 虚代理即Virtual Proxy,它让调用者先持有一个代理对象,但真正的对象尚未创建。如果没有必要,这个真正的对象是不会被创建的,直到客户端需要真的必须调用时,才创建真正的对象。
    • 这种方式通常用于要创建的目标对象开销很大时。例如,下载一幅很大的图像需要很长时间,因某种计算比较复杂而短时间无法完成,这时可以先用小比例的虚拟代理替换真实的对象,消除用户对服务器慢的感觉。
    • JDBC的连接池返回的JDBC连接(Connection对象)就可以是一个虚代理,即获取连接时根本没有任何实际的数据库连接,直到第一次执行JDBC查询或更新操作时,才真正创建实际的JDBC连接。
  3. 保护代理:

    • 保护代理即Protection Proxy,它用代理对象控制对原始对象的访问,常用于鉴权。
    • 这种方式通常用于控制不同种类客户对真实对象的访问权限。
  4. 智能引用:

    • 智能引用即Smart Reference,它也是一种代理对象,如果有很多客户端对它进行访问,通过内部的计数器可以在外部调用者都不使用后自动释放它
    • 主要用于调用目标对象时,代理附加一些额外的处理功能。例如,增加计算真实对象的引用次数的功能,这样当该对象没有被引用时,就可以自动释放它。
  5. 延迟加载:

    • 延迟加载即Cache缓存代理。指为了提高系统的性能,延迟对目标的加载。例如,Hibernate 中就存在属性的延迟加载和关联表的延时加载。
    • 当请求图片文件等资源时,先到缓存代理取,如果取到资源则 ok,如果取不到资源,再到公网或者数据库取,然后缓存。
  6. 防火墙(Firewall)代理:内网通过代理穿透防火墙,实现对公网的访问。

  7. 同步化(Synchronization)代理:主要使用在多线程编程中,完成多线程间同步工作

  8. Copy-on-Write 代理:它是虚拟代理的一种,把复制(克隆)操作延迟到只有在客户端真正需要时才执行。一般来说,对象的深克隆是一个开销较大的操作,Copy-on-Write代理可以让这个操作延迟,只有对象被用到的时候才被克隆。

    Immer提供了一种更方便的不可变状态操作方式。详情:Copy-on-write + Proxy = ?

    方便之处主要体现在:

    • 只有一个(核心)API:produce(currentState, producer: (draftState) => void): nextState
    • 不引入额外的数据结构:没有 List、Map、Set 等任何自定义数据结构,因此也不需要特殊的相等性比较方法
    • 数据操作完全基于类型:用纯原生 API 操作数据,符合直觉

应用实际:

  • spring aop
  • 我们实际使用的DataSource,例如HikariCP,都是基于代理模式实现的,原理同上,但增加了更多的如动态伸缩的功能(一个连接空闲一段时间后自动关闭)。
  • 买火车票不一定在火车站买,也可以去代售点。
  • 一张支票或银行存单是账户中资金的代理。支票在市场交易中用来代替现金,并提供对签发人账号上资金的控制。

15.5、代理模式扩展

动态代理的一种实现类图:

image-20210416145841201

15.6、进阶阅读

如果您想深入了解代理模式,可猛击阅读以下文章。

15.7、相关设计模式

  • Adapter 模式:

    Adapter 模式适配了两种具有不同接口 (API) 的对象,以使它们可以一同工作。而在 Proxy 模式中,Proxy 角色与Rea)Subject 角色的接口 (API) 是相同的(透明性)。

  • Decorator 模式:

    Decorator 模式与 Proxy 模式在实现上很相似.不过它们的使用目的不同。

    Decorator 模式的目的在于增加新的功能。而在 Proxy 模式中,与增加新功能相比,它更注重通过设置代理人的方式来减轻本人的工作负担。

15.8、代理模式与其他模式的区别

15.8.1、Proxy模式VSDecorator模式

  • Decorator模式让调用者自己创建核心类,然后组合各种功能
  • Proxy模式决不能让调用者自己创建再组合,否则就失去了代理的功能
  • Proxy模式让调用者认为获取到的是核心类接口,但实际上是代理类。
  • 装饰器模式为了增强功能,而代理模式是为了加以控制。

15.8.2、Proxy模式VSAdapter模式

  • 适配器模式主要改变所考虑对象的接口
  • 代理模式不能改变所代理类的接口

15.9、代理模式的注意事项与细节

  1. 代理模式通过封装一个已有接口,并向调用方返回相同的接口类型,能让调用方在不改变任何代码的前提下增强某些功能(例如,鉴权、延迟加载、连接池复用等)。
  2. 静态代理模式:代理对象与目标对象(被代理对象)都实现同一个接口或者继承同一个抽象类
  3. JDK动态代理模式:目标对象(被代理对象)需要实现接口或继承抽象类,而代理对象不用,它由一个代理工厂来生产
  4. CGLIB动态代模式:目标对象(被代理对象)不需要实现接口或继承抽象类,但是用来生产代理对象的代理工厂需要实现cglib的MethodInterceptor接口。

16、模板方法模式Template Method(行为型模式)

16.1、基本介绍

  1. 模板方法模式(Template Method Pattern),又叫模板模式(Template Pattern)**,在一个抽象类公开定义了执行它的方法的模板。它的子类可以按需要重写方法实现,但调用将以抽象类中定义的方式进行**。
  2. 简单说,模板方法模式定义一个操作中的算法的骨架,而将一些步骤延迟到子类中,使得子类可以不改变一个算法的结构,就可以重定义该算法的某些特定步骤
  3. 模板方法的核心思想是:父类定义骨架子类实现某些细节
  4. 这种类型的设计模式属于行为型模式

16.2、模板方法模式的原理结构图-uml类图

模板方法模式需要注意抽象类与具体子类之间的协作。它用到了虚函数的多态性技术以及“不用调用我,让我来调用你”的反向控制技术

16.2.1、模式的结构

模板方法模式包含以下主要角色:

  • 抽象类/抽象模板(Abstract Class)
    • 抽象模板类,负责给出一个算法的轮廓和骨架。它由一个模板方法和若干个基本方法构成。这些方法的定义如下:
      • 模板方法:定义了算法的骨架,按某种顺序调用其包含的基本方法。
      • 基本方法:是整个算法中的一个步骤,包含以下几种类型:
        • 抽象方法:在抽象类中声明,由具体子类实现。
        • 具体方法:在抽象类中已经实现,在具体子类中可以继承或重写它。
        • 钩子方法:在抽象类中已经实现,我们可以定义一个方法,它默认不做任何事,子类可以视情况要不要覆盖它。包括用于判断的逻辑方法和需要子类重写的空方法两种。
  • 具体子类/具体实现(Concrete Class):具体实现类,实现抽象类中所定义的抽象方法和钩子方法,它们是一个顶级逻辑的一个组成步骤。

相关类图:

image-20210416200400531

16.2.2、模式的实现

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
32
public class TemplateMethodPattern {
public static void main(String[] args) {
AbstractClass tm = new ConcreteClass();
tm.TemplateMethod();
}
}
//抽象类
abstract class AbstractClass {
//模板方法
public void TemplateMethod() {
SpecificMethod();
abstractMethod1();
abstractMethod2();
}
//具体方法
public void SpecificMethod() {
System.out.println("抽象类中的具体方法被调用...");
}
//抽象方法1
public abstract void abstractMethod1();
//抽象方法2
public abstract void abstractMethod2();
}
//具体子类
class ConcreteClass extends AbstractClass {
public void abstractMethod1() {
System.out.println("抽象方法1的实现被调用...");
}
public void abstractMethod2() {
System.out.println("抽象方法2的实现被调用...");
}
}

16.3、应用举例

豆浆制作问题:

编写制作豆浆的程序,说明如下:

  1. 制作豆浆的流程 选材—>添加配料—>浸泡—>放到豆浆机打碎
  2. 通过添加不同的配料,可以制作出不同口味的豆浆
  3. 也可以不添加配料,制作纯豆浆(钩子方法)
  4. 选材、浸泡和放到豆浆机打碎这几个步骤对于制作每种口味的豆浆都是一样的
  5. 请使用模板方法模式完成

思路分析和图解(类图):

image-20210416200834855

代码实现:

SoyaMilk:制作豆浆的抽象类。抽象类/抽象模板(Abstract 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
29
30
//抽象类,表示豆浆
public abstract class SoyaMilk {
//模板方法, make , 模板方法可以做成final , 不让子类去覆盖.
final void make() {
select();
if(customerWantCondiments()) {
addCondiments();
}
soak();
beat();
}
//选材料
void select() {
System.out.println("第一步:选择好的新鲜黄豆 ");
}
//添加不同的配料, 抽象方法, 子类具体实现
abstract void addCondiments();
//浸泡
void soak() {
System.out.println("第三步, 黄豆和配料开始浸泡, 需要3小时 ");
}
//打碎
void beat() {
System.out.println("第四步:黄豆和配料放到豆浆机去打碎 ");
}
//钩子方法,决定是否需要添加配料
boolean customerWantCondiments() {
return true;
}
}

RedBeanSoyaMilk:红豆豆浆。具体子类/具体实现(Concrete Class)(PeanutSoyaMilk等等其他豆浆类似)

1
2
3
4
5
6
public class RedBeanSoyaMilk extends SoyaMilk {
@Override
void addCondiments() {
System.out.println(" 加入上好的红豆 ");
}
}

PureSoyaMilk:纯豆浆。具体子类/具体实现(Concrete Class)(钩子方法)

1
2
3
4
5
6
7
8
9
10
public class PureSoyaMilk extends SoyaMilk{
@Override
void addCondiments() {
//空实现
}
@Override
boolean customerWantCondiments() {
return false;
}
}

Client:调用制作豆浆。客户端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Client {
public static void main(String[] args) {
//制作红豆豆浆
System.out.println("----制作红豆豆浆----");
SoyaMilk redBeanSoyaMilk = new RedBeanSoyaMilk();
redBeanSoyaMilk.make();

System.out.println("----制作花生豆浆----");
SoyaMilk peanutSoyaMilk = new PeanutSoyaMilk();
peanutSoyaMilk.make();

System.out.println("----制作纯豆浆----");
SoyaMilk pureSoyaMilk = new PureSoyaMilk();
pureSoyaMilk.make();
}
}

16.4、模板方法模式在Spring框架的应用与源码

Spring IOC 容器初始化时运用到的模板方法模式

代码分析+角色分析+说明类图:

16.4.1、说明类图

image-20210416204002753

16.4.2、角色分析

image-20210416204322032

image-20210416204443297

image-20210416204542192

16.4.3、代码分析

ConfigurableApplicationContext接口与refresh()抽象模板方法

image-20210416204750043

image-20210416204918844

AbstractApplicationContext抽象类实现了ConfigurableApplicationContext接口,并对refresh()模板方法进行了重写

image-20210416204959220

image-20210416205223899

refresh()模板方法:

image-20210416205621834

refresh()模板方法当中的obtainFreshBeanFactory()方法

image-20210416205735418

refresh()模板方法当中的钩子方法postProcessBeanFactory()与onRefresh()

image-20210416210548820

image-20210416210318005

image-20210416210706001

GenericApplicationContext类继承了AbstractApplicationContext抽象类,对父类的getBeanFactory()与refreshBeanFactory()抽象方法进行重写

image-20210416211006295

image-20210416211737520

XmlWebApplicationContext类、ClassPathXmlApplicationContext类等等子类继承了各自的父类。最好按照父类定义好的模板去实现对应的需求。

image-20210416212050470

16.5、模板方法模式总结

主要优点:

  1. 封装了不变部分扩展可变部分。它把认为是不变部分的算法封装到父类中实现,而把可变部分算法由子类继承实现,便于子类继续扩展。
  2. 它在父类中提取了公共的部分代码,便于代码复用。
  3. 部分方法是由子类实现的,因此子类可以通过扩展方式增加相应的功能符合开闭原则

主要缺点:

  1. 每个不同的实现都需要定义一个子类,这会导致类的个数增加,系统更加庞大,设计也更加抽象,间接地增加了系统实现的复杂度。
  2. 父类中的抽象方法由子类实现,子类执行的结果会影响父类的结果,这导致一种反向的控制结构,它提高了代码阅读的难度
  3. 由于继承关系自身的缺点,如果父类添加新的抽象方法,则所有子类都要改一遍

模式的应用场景:

  1. 算法的整体步骤很固定,但其中个别部分易变时,这时候可以使用模板方法模式,将容易变的部分抽象出来,供子类实现。
  2. 多个子类存在公共的行为时,可以将其提取出来并集中到一个公共父类中以避免代码重复。首先,要识别现有代码中的不同之处,并且将不同之处分离为新的操作。最后,用一个调用这些新的操作的模板方法来替换这些不同的代码。
  3. 需要控制子类的扩展时,模板方法只在特定点调用钩子操作,这样就只允许在这些点进行扩展

应用实例:

  1. 在造房子的时候,地基、走线、水管都一样,只有在建筑的后期才有加壁橱加栅栏等差异
  2. spring 中对 Hibernate 的支持,将一些已经定好的方法封装起来,比如开启事务、获取 Session、关闭 Session 等,程序员不重复写那些已经规范好的代码,直接丢一个实体就可以保存。

16.6、模板方法模式扩展

在模板方法模式中,基本方法包含:抽象方法、具体方法和钩子方法,正确使用“钩子方法”可以使得子类控制父类的行为。如下面例子中,可以通过在具体子类中重写钩子方法 HookMethod1() 和 HookMethod2() 来改变抽象父类中的运行结果,其结构图:

image-20210416203027578

代码实现:

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
public class HookTemplateMethod {
public static void main(String[] args) {
HookAbstractClass tm = new HookConcreteClass();
tm.TemplateMethod();
}
}
//含钩子方法的抽象类
abstract class HookAbstractClass {
//模板方法
public void TemplateMethod() {
abstractMethod1();
HookMethod1();
if (HookMethod2()) {
SpecificMethod();
}
abstractMethod2();
}
//具体方法
public void SpecificMethod() {
System.out.println("抽象类中的具体方法被调用...");
}
//钩子方法1
public void HookMethod1() {
}
//钩子方法2
public boolean HookMethod2() {
return true;
}
//抽象方法1
public abstract void abstractMethod1();
//抽象方法2
public abstract void abstractMethod2();
}
//含钩子方法的具体子类
class HookConcreteClass extends HookAbstractClass {
public void abstractMethod1() {
System.out.println("抽象方法1的实现被调用...");
}
public void abstractMethod2() {
System.out.println("抽象方法2的实现被调用...");
}
public void HookMethod1() {
System.out.println("钩子方法1被重写...");
}
public boolean HookMethod2() {
return false;
}
}

如果钩子方法 HookMethod1() 和钩子方法 HookMethod2() 的代码改变,则程序的运行结果也会改变。

16.7、进阶阅读

如果您想深入了解模板方法模式,可猛击阅读以下文章。

16.8、相关设计模式

  • Factory Method 模式

    Factory Method模式是将Template Method模式用于生成实例的一个典型例子。

  • Strategy 模式

    在Template Method模式中, 可以使用继承改变程序的行为。 这是因为Template Method模式在父类中定义程序行为的框架.在子类中决定具体的处理。

    与此相对的是Strategy模式 , 它可以使用委托改变程序的行为。 与Template Method模式中改 变部分程序行为不同的是,Strategy模式用于替换整个算法

16.9、模板方法模式的注意事项与细节

  1. 基本思想是:算法只存在于一个地方,也就是在父类中容易修改。需要修改算法时,只要修改父类的模板方法或者已经实现的某些步骤,子类就会继承这些修改。
  2. 实现了最大化代码复用父类的模板方法和已实现的某些步骤会被子类继承而直接使用
  3. 统一了算法,也提供了很大的灵活性。父类的模板方法确保了算法的结构保持不变,同时由子类提供部分步骤的实现。
  4. 该模式的不足之处:每一个不同的实现都需要一个子类实现,导致类的个数增加,使得系统更加庞大
  5. 一般模板方法都加上 final 关键字防止子类重写模板方法
  6. 模板方法模式使用场景:当要完成在某个过程,该过程要执行一系列步骤这一系列的步骤基本相同,但其个别步骤在实现时可能不同,通常考虑用模板方法模式来处理
  7. 模板方法是一种高层定义骨架底层实现细节的设计模式,适用于流程固定但某些步骤不确定或可替换的情况。

17、命令模式Command(行为型模式)

image-20210416212741618

17.1、基本介绍

  1. 命令(Command)模式的定义如下:将一个请求封装为一个对象,使发出请求的责任和执行请求的责任分割开。这样两者之间通过命令对象进行沟通,这样方便将命令对象进行储存、传递、调用、增加与管理
  2. 命令模式(Command Pattern):在软件设计中,我们经常需要向某些对象发送请求,但是并不知道请求的接收者是谁,也不知道被请求的操作是哪个,我们只需在程序运行时指定具体的请求接收者即可,此时,可以使用命令模式来进行设计
  3. 命名模式使得请求发送者与请求接收者消除彼此之间的耦合,让对象之间的调用关系更加灵活,实现解耦。
  4. 在命名模式中,会将一个请求封装为一个对象,以便使用不同参数来表示不同的请求(即命名)**,同时命令模式也支持可撤销**的操作。
  5. 通俗易懂的理解:将军发布命令,士兵去执行。其中有几个角色:将军(命令发布者)、士兵(命令的具体执行者)、命令(连接将军和士兵)。Invoker 是调用者(将军),Receiver 是被调用者(士兵),MyCommand 是命令,实现了 Command 接口,持有接收对象

17.2、命令模式的原理结构图-uml类图

17.2.1、模式的结构

命令模式包含以下主要角色。

  1. 抽象命令类(Command)角色:是命令角色,需要执行的所有命令都在这里,拥有执行命令的抽象方法 execute(),可以是接口或抽象类
  2. 具体命令类(Concrete Command)角色:是抽象命令类的具体实现类,它拥有接收者对象,并通过调用接收者的功能来完成命令要执行的操作。将一个接受者对象与一个动作绑定,调用接受者相应的操作,实现 execute
  3. 实现者/接收者(Receiver)角色:执行命令功能的相关操作,是具体命令对象业务的真正实现者。
  4. 调用者/请求者(Invoker)角色:是请求的发送者,它通常拥有很多的命令对象,并通过访问命令对象来执行相关请求,它不直接访问接收者。

其结构图如图:

image-20210417005514313

173.2.2、模式的实现

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
package command;

public class CommandPattern {
public static void main(String[] args) {
Command cmd = new ConcreteCommand();
Invoker ir = new Invoker(cmd);
System.out.println("客户访问调用者的call()方法...");
ir.call();
}
}
//调用者
class Invoker {
private Command command;
public Invoker(Command command) {
this.command = command;
}
public void setCommand(Command command) {
this.command = command;
}
public void call() {
System.out.println("调用者执行命令command...");
command.execute();
}
}
//抽象命令
interface Command {
public abstract void execute();
public abstract void undo();
}

//具体命令
class ConcreteCommand implements Command {
private Receiver receiver;
ConcreteCommand() {
receiver = new Receiver();
}
public void execute() {
receiver.action();
}
public void undo() {
System.out.println("命令被撤销...");
}
}
//接收者
class Receiver {
public void action() {
System.out.println("接收者的action()方法被调用...");
}
}

17.3、应用举例

17.1 智能生活项目需求

  1. 我们买了一套智能家电,有照明灯、风扇、冰箱、洗衣机,我们只要在手机上安装 app 就可以控制对这些家电工作。
  2. 这些智能家电来自不同的厂家,我们不想针对每一种家电都安装一个 App,分别控制,我们希望只要一个app就可以控制全部智能家电。
  3. 要实现一个 app 控制所有智能家电的需要,则每个智能家电厂家都要提供一个统一的接口给 app 调用,这时 就可以考虑使用命令模式。
  4. 命令模式可将“动作的请求者”从“动作的执行者”对象中解耦出来.
  5. 在我们的例子中,动作的请求者是手机 app,动作的执行者是每个厂商的一个家电产
  6. 编写程序,使用命令模式完成前面的智能家电项目

思路分析和图解

image-20210417010355805

代码实现:

Command:命令接口。抽象命令类(Command)角色

1
2
3
4
5
6
7
//创建命令接口
public interface Command {
//执行动作(操作)
public void execute();
//撤销动作(操作)
public void undo();
}

LightReceiver:电灯接受者。实现者/接收者(Receiver)角色

1
2
3
4
5
6
7
8
public class LightReceiver {
public void on() {
System.out.println(" 电灯打开了.. ");
}
public void off() {
System.out.println(" 电灯关闭了.. ");
}
}

LightOnCommand:打开电灯的操作。具体命令类(Concrete Command)角色

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class LightOnCommand implements Command {
//聚合LightReceiver
LightReceiver light;
//构造器
public LightOnCommand(LightReceiver light) {
super();
this.light = light;
}
@Override
public void execute() {
//调用接收者的方法
light.on();
}
@Override
public void undo() {
//调用接收者的方法
light.off();
}
}

LightOffCommand:关闭电灯的操作。具体命令类(Concrete Command)角色

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class LightOffCommand implements Command {
// 聚合LightReceiver
LightReceiver light;
// 构造器
public LightOffCommand(LightReceiver light) {
super();
this.light = light;
}
@Override
public void execute() {
// 调用接收者的方法
light.off();
}
@Override
public void undo() {
// 调用接收者的方法
light.on();
}
}

NoCommand:空命令。具体命令类(Concrete Command)角色

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* 没有任何命令,即空执行: 用于初始化每个按钮, 当调用空命令时,对象什么都不做
* 其实,这样是一种设计模式, 可以省掉对空判断
* @author Administrator
*
*/
public class NoCommand implements Command {
@Override
public void execute() {
}
@Override
public void undo() {
}
}

RemoteController:遥控器。调用者/请求者(Invoker)角色

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
32
33
34
35
36
37
38
39
40
public class RemoteController {
// 开 按钮的命令数组
Command[] onCommands;
Command[] offCommands;
// 执行撤销的命令
Command undoCommand;
// 构造器,完成对按钮初始化
public RemoteController() {
onCommands = new Command[5];
offCommands = new Command[5];
for (int i = 0; i < 5; i++) {
onCommands[i] = new NoCommand();
offCommands[i] = new NoCommand();
}
}
// 给我们的按钮设置你需要的命令
public void setCommand(int no, Command onCommand, Command offCommand) {
onCommands[no] = onCommand;
offCommands[no] = offCommand;
}
// 按下开按钮
public void onButtonWasPushed(int no) { // no 0
// 找到你按下的开的按钮, 并调用对应方法
onCommands[no].execute();
// 记录这次的操作,用于撤销
undoCommand = onCommands[no];

}
// 按下关按钮
public void offButtonWasPushed(int no) { // no 0
// 找到你按下的关的按钮, 并调用对应方法
offCommands[no].execute();
// 记录这次的操作,用于撤销
undoCommand = offCommands[no];
}
// 按下撤销按钮
public void undoButtonWasPushed() {
undoCommand.undo();
}
}

Client:客户端。调用遥控器RemoteController

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
32
33
public class Client {
public static void main(String[] args) {
//使用命令设计模式,完成通过遥控器,对电灯的操作
//创建电灯的对象(接受者)
LightReceiver lightReceiver = new LightReceiver();
//创建电灯相关的开关命令
LightOnCommand lightOnCommand = new LightOnCommand(lightReceiver);
LightOffCommand lightOffCommand = new LightOffCommand(lightReceiver);
//需要一个遥控器
RemoteController remoteController = new RemoteController();
//给我们的遥控器设置命令, 比如 no = 0 是电灯的开和关的操作
remoteController.setCommand(0, lightOnCommand, lightOffCommand);
System.out.println("--------按下灯的开按钮-----------");
remoteController.onButtonWasPushed(0);
System.out.println("--------按下灯的关按钮-----------");
remoteController.offButtonWasPushed(0);
System.out.println("--------按下撤销按钮-----------");
remoteController.undoButtonWasPushed();

System.out.println("=========使用遥控器操作电视机==========");
TVReceiver tvReceiver = new TVReceiver();
TVOffCommand tvOffCommand = new TVOffCommand(tvReceiver);
TVOnCommand tvOnCommand = new TVOnCommand(tvReceiver);
//给我们的遥控器设置命令, 比如 no = 1 是电视机的开和关的操作
remoteController.setCommand(1, tvOnCommand, tvOffCommand);
System.out.println("--------按下电视机的开按钮-----------");
remoteController.onButtonWasPushed(1);
System.out.println("--------按下电视机的关按钮-----------");
remoteController.offButtonWasPushed(1);
System.out.println("--------按下撤销按钮-----------");
remoteController.undoButtonWasPushed();
}
}

17.4、命令模式在Spring框架的应用与源码

Spring 框架的 JdbcTemplate 就使用到了命令模式

代码分析:

image-20210417014539023

具体代码:

JdbcTemplate类的query方法

image-20210417021850007

在query方法使用递归调用了query方法

image-20210417022006816

query方法:

image-20210417022151453

StatementCallback接口,里面有doInstatement抽象方法

image-20210417022235885

QueryStatementCallback这个静态内部类实现了StatementCallback接口,在里面实现了doInstatement抽象方法

image-20210417022649537

image-20210417022952614

同时,QueryStatementCallback又作为实现者/接收者(Receiver) 角色执行execute方法

image-20210417023039518

StatementCallback接口的其他实现类:ExecuteStatementCallback

image-20210417023427416

ExecuteStatementCallback的excute方法:最后调用了JdbcTemplate的excute方法

image-20210417023156907

JdbcTemplate的excute方法:

image-20210417023635405

image-20210417023940383

17.5、命令模式总结

主要优点:

  1. 通过引入中间件(抽象接口)降低系统的耦合度
  2. 扩展性良好增加或删除命令非常方便。采用命令模式增加与删除命令不会影响其他类,且满足“开闭原则”
  3. 可以实现宏命令。命令模式可以与组合模式结合,将多个命令装配成一个组合命令,即宏命令
  4. 方便实现 Undo 和 Redo 操作。命令模式可以与后面介绍的备忘录模式结合,实现命令的撤销与恢复
  5. 可以在现有命令的基础上,增加额外功能。比如日志记录,结合装饰器模式会更加灵活

缺点是:

  1. 可能产生大量具体的命令类。因为每一个具体操作都需要设计一个具体命令类,这会增加系统的复杂性。
  2. 命令模式的结果其实就是接收方的执行结果,但是为了以命令的形式进行架构、解耦请求与实现,引入了额外类型结构(引入了请求方与抽象命令接口),增加了理解上的困难。不过这也是设计模式的通病,抽象必然会额外增加类的数量,代码抽离肯定比代码聚合更加难理解。

命令模式的应用场景:

当系统的某项操作具备命令语义,且命令实现不稳定(变化)时,可以通过命令模式解耦请求与实现。使用抽象命令接口使请求方的代码架构稳定,封装接收方具体命令的实现细节。接收方与抽象命令呈现弱耦合(内部方法无需一致),具备良好的扩展性。

命令模式通常适用于以下场景:

  1. 请求调用者需要与请求接收者解耦时,命令模式可以使调用者和接收者不直接交互。
  2. 系统随机请求命令或经常增加、删除命令时,命令模式可以方便地实现这些功能。
  3. 当系统需要执行一组操作时,命令模式可以定义宏命令来实现该功能。
  4. 当系统需要支持命令的撤销(Undo)操作和恢复(Redo)操作时,可以将命令对象存储起来,采用备忘录模式来实现。
  5. 界面的一个按钮都是一条命令、模拟 CMD(DOS 命令)订单的撤销/恢复、触发- 反馈机制

17.6、命令模式扩展

在软件开发中,有时将命令模式与前面学的组合模式联合使用,这就构成了宏命令模式,也叫组合命令模式宏命令包含了一组命令,它充当了具体命令与调用者的双重角色,执行它时将递归调用它所包含的所有命令,其具体结构图如图:

image-20210417013804833

实现代码:

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
package command;
import java.util.ArrayList;

public class CompositeCommandPattern {
public static void main(String[] args) {
AbstractCommand cmd1 = new ConcreteCommand1();
AbstractCommand cmd2 = new ConcreteCommand2();
CompositeInvoker ir = new CompositeInvoker();
ir.add(cmd1);
ir.add(cmd2);
System.out.println("客户访问调用者的execute()方法...");
ir.execute();
}
}
//抽象命令
interface AbstractCommand {
public abstract void execute();
}
//树叶构件: 具体命令1
class ConcreteCommand1 implements AbstractCommand {
private CompositeReceiver receiver;
ConcreteCommand1() {
receiver = new CompositeReceiver();
}
public void execute() {
receiver.action1();
}
}
//树叶构件: 具体命令2
class ConcreteCommand2 implements AbstractCommand {
private CompositeReceiver receiver;
ConcreteCommand2() {
receiver = new CompositeReceiver();
}
public void execute() {
receiver.action2();
}
}
//树枝构件: 调用者
class CompositeInvoker implements AbstractCommand {
private ArrayList<AbstractCommand> children = new ArrayList<AbstractCommand>();
public void add(AbstractCommand c) {
children.add(c);
}
public void remove(AbstractCommand c) {
children.remove(c);
}
public AbstractCommand getChild(int i) {
return children.get(i);
}
public void execute() {
for (Object obj : children) {
((AbstractCommand) obj).execute();
}
}
}
//接收者
class CompositeReceiver {
public void action1() {
System.out.println("接收者的action1()方法被调用...");
}
public void action2() {
System.out.println("接收者的action2()方法被调用...");
}
}

17.7、进阶阅读

如果您想深入了解命令模式,可猛击阅读以下文章。

17.8、相关设计模式

  • Composite 模式

    有时会使用Composite模式实现宏命令(macrocommand)。

  • Memento 模式

    有时会使用Memento模式来保存Command角色的历史记录。

  • Protype 模式

    有时会使用Protype模式复制发生的事件(生成的命令)。

17.9、命令模式的注意事项与细节

  1. 将发起请求的对象与执行请求的对象解耦。发起请求的对象是调用者,调用者只要调用命令对象的 execute()方法就可以让接收者工作,而不必知道具体的接收者对象是谁、是如何实现的,命令对象会负责让接收者执行请求的动作,也就是说:”请求发起者”和“请求执行者”之间的解耦是通过命令对象实现的,命令对象起到了纽带桥梁的作用
  2. 容易设计一个命令队列。只要把命令对象放到列队,就可以多线程的执行命令
  3. 容易实现对请求的撤销和重做
  4. 命令模式不足:可能导致某些系统有过多的具体命令类,增加了系统的复杂度,这点在在使用的时候要注意
  5. 空命令也是一种设计模式,它为我们省去了判空的操作。在上面的实例中,如果没有用空命令,我们每按下一个按键都要判空,这给我们编码带来一定的麻烦。
  6. 命令模式经典的应用场景:界面的一个按钮都是一条命令模拟 CMD(DOS 命令)订单的撤销/恢复触发- 反馈机制

18、访问者模式Visitor(行为型模式)

image-20210417141823535

18.1、基本介绍

  1. 访问者模式(Visitor Pattern),封装一些作用于某种数据结构的各元素的操作,它可以在不改变数据结构的前提下定义作用于这些元素的新的操作。为数据结构中的每个元素提供多种访问方式。它将对数据的操作与数据结构进行分离,是行为类模式中最复杂的一种模式。
  2. 主要将数据结构与数据操作分离解决数据结构和操作耦合性问题
  3. 访问者模式的基本工作原理是:在被访问的类里面加一个对外提供接待访问者的接口
  4. 访问者模式主要应用场景是:需要对一个对象结构中的对象进行很多不同操作(这些操作彼此没有关联)**,同时需要避免让这些操作”污染”这些对象的类**,可以选用访问者模式解决

18.2、访问者模式的原理结构图-uml类图

访问者(Visitor)模式实现的关键是如何将作用于元素的操作分离出来封装成独立的类

18.2.1、模式的结构

访问者模式包含以下主要角色。

  1. 抽象访问者(Visitor)角色:定义一个访问具体元素的接口,为每个具体元素类对应一个访问操作 visit() ,该操作中的**参数类型标识了被访问的具体元素**。即:为该对象结构中的 ConcreteElement 的每一个类声明一个 visit 操作。
  2. 具体访问者(ConcreteVisitor)角色:实现抽象访问者角色中声明的各个访问操作,确定访问者访问一个元素时该做什么。
  3. 抽象元素(Element)角色:声明一个包含接受操作 accept() 的接口被接受的访问者对象作为 accept() 方法的参数。即:定义一个 accept 方法,接收一个访问者对象。(与抽象访问者(Visitor)角色实现互相关联(但相关联的抽象元素的具体实现类))
  4. 具体元素(ConcreteElement)角色:实现抽象元素角色提供的 accept() 操作,其方法体通常都是 visitor.visit(this)双分派),另外具体元素中可能还包含本身业务逻辑的相关操作。
  5. 对象结构(Object Structure)角色:是一个包含元素角色的容器,提供让访问者对象遍历容器中的所有元素的方法,通常由 ListSetMap 等聚合类实现。

其结构图类图:

img

18.2.2、代码实现

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
package net.biancheng.c.visitor;
import java.util.*;

public class VisitorPattern {
public static void main(String[] args) {
ObjectStructure os = new ObjectStructure();
os.add(new ConcreteElementA());
os.add(new ConcreteElementB());
Visitor visitor = new ConcreteVisitorA();
os.accept(visitor);
System.out.println("------------------------");
visitor = new ConcreteVisitorB();
os.accept(visitor);
}
}
//抽象访问者
interface Visitor {
// 参数是具体的元素类
void visit(ConcreteElementA element);
void visit(ConcreteElementB element);
}
//具体访问者A类
class ConcreteVisitorA implements Visitor {
public void visit(ConcreteElementA element) {
System.out.println("具体访问者A访问-->" + element.operationA());
}
public void visit(ConcreteElementB element) {
System.out.println("具体访问者A访问-->" + element.operationB());
}
}
//具体访问者B类
class ConcreteVisitorB implements Visitor {
public void visit(ConcreteElementA element) {
System.out.println("具体访问者B访问-->" + element.operationA());
}
public void visit(ConcreteElementB element) {
System.out.println("具体访问者B访问-->" + element.operationB());
}
}
//抽象元素类
interface Element {
// 参数是Visitor访问类
void accept(Visitor visitor);
}
//具体元素A类
class ConcreteElementA implements Element {
public void accept(Visitor visitor) {
// 通过visit(this)实现双分派
visitor.visit(this);
}
public String operationA() {
return "具体元素A的操作。";
}
}
//具体元素B类
class ConcreteElementB implements Element {
public void accept(Visitor visitor) {
visitor.visit(this);
}
public String operationB() {
return "具体元素B的操作。";
}
}
//对象结构角色
class ObjectStructure {
private List<Element> list = new ArrayList<Element>();
public void accept(Visitor visitor) {
Iterator<Element> i = list.iterator();
while (i.hasNext()) {
((Element) i.next()).accept(visitor);
}
}
public void add(Element element) {
list.add(element);
}
public void remove(Element element) {
list.remove(element);
}
}

18.3、应用举例

测评系统的需求

将观众分为男人和女人,对歌手进行测评,当看完某个歌手表演后,得到他们对该歌手不同的评价(评价有不同的种类,比如 成功、失败等,之后会增加一种状态“待定”以测试程序的扩展性)

18.3.1、使用传统方式解决需求

思路分析:

image-20210417144957917

传统方式的问题分析:

  1. 如果系统比较小,还是 ok 的,但是考虑系统增加越来越多新的功能时,对代码改动较大,违反了 ocp 原则, 不利于维护
  2. 扩展性不好,比如增加了新的人员类型,或者管理方法,都不好做
  3. 引出我们会使用新的设计模式 – 访问者模式

18.3.2、使用访问者模式解决需求

思路分析(类图):

image-20210417145718031

代码实现:

Action:行为抽象类,在里面的方法将具体元素作为参数传入抽象访问者(Visitor)角色

1
2
3
4
5
6
public abstract class Action {
//得到男性 的测评
public abstract void getManResult(Man man);
//得到女的 测评
public abstract void getWomanResult(Woman woman);
}

Success:成功的行为。具体访问者(ConcreteVisitor)角色(Fail失败与Wait待定等等行为类似)

1
2
3
4
5
6
7
8
9
10
public class Success extends Action {
@Override
public void getManResult(Man man) {
System.out.println(" 男人给的评价该歌手很成功 !");
}
@Override
public void getWomanResult(Woman woman) {
System.out.println(" 女人给的评价该歌手很成功 !");
}
}

Person:人类,将访问者Visitor作为参数传入accept()方法抽象元素(Element)角色

1
2
3
4
public abstract class Person {
//提供一个方法,让访问者可以访问
public abstract void accept(Action action);
}

Man:男人,重写父类的accept方法,并在accept方法里调用访问者的方法并将this作为参数传入,以此实现双分派。具体元素(ConcreteElement)角色(Woman女人类类似)

1
2
3
4
5
6
7
8
9
10
//说明
//1. 这里我们使用到了双分派, 即首先在客户端程序中,将具体状态作为参数传递Woman中(第一次分派)
//2. 然后Man类调用作为参数的 "具体方法" 中方法getWomanResult, 同时将自己(this)作为参数
// 传入,完成第二次的分派
public class Man extends Person {
@Override
public void accept(Action action) {
action.getManResult(this);
}
}

ObjectStructure:对象结构(Object Structure)角色

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.LinkedList;
import java.util.List;

//数据结构,管理很多人(Man , Woman)
public class ObjectStructure {
//维护了一个集合
private List<Person> persons = new LinkedList<>();
//增加到list
public void attach(Person p) {
persons.add(p);
}
//移除
public void detach(Person p) {
persons.remove(p);
}
//显示测评情况
public void display(Action action) {
for(Person p: persons) {
p.accept(action);
}
}
}

Client:客户端,用来进行调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class Client {
public static void main(String[] args) {
//创建ObjectStructure
ObjectStructure objectStructure = new ObjectStructure();
objectStructure.attach(new Man());
objectStructure.attach(new Woman());
// 成功
Success success = new Success();
objectStructure.display(success);
// 失败
System.out.println("===============");
Fail fail = new Fail();
objectStructure.display(fail);
// 待定
System.out.println("=======给的是待定的测评========");
Wait wait = new Wait();
objectStructure.display(wait);
}
}

18.4、双分派

整理一下 Visitor 模式中方法的调用关系:

  • accept(接受)方法的调用方式如下:

    element.accept(visitor);

  • visit(访问)方法的调用方式如下:

  • visitor.visit(element);

对比一下这两个方法会发现, 它们是相反的关系。 element 接受 visitor, 而 visitor 又访问 element

在 Visitor 模式中, ConcreteElement 和 ConcreteVisitor 这两个角色共同决定了实际进行的处理。这种消息分发的方式一般被称为双重分发 (double dispatch)。

访问者模式为了实现所谓的“双重分派”,设计了一个回调再回调的机制。因为Java只支持基于多态的单分派模式,这里强行模拟出“双重分派”反而加大了代码的复杂性。

所谓双分派是指不管类怎么变化,我们都能找到期望的方法运行。双分派意味着得到执行的操作取决于请求的种类和两个接收者的类型

上述实例为例,假设我们要添加一个 Wait 的状态类,考察 Man 类和 Woman 类的反应,由于使用了双分派,只需增加一个 Action 子类即可在客户端调用即可,不需要改动任何其他类的代码。

18.5、访问者模式总结

主要优点如下:

  1. 扩展性好。能够在不修改对象结构中的元素的情况下,为对象结构中的元素添加新的功能。
  2. 复用性好。可以通过访问者来定义整个对象结构通用的功能,从而提高系统的复用程度。
  3. 灵活性好。访问者模式将数据结构与作用于结构上的操作解耦,使得操作集合可相对自由地演化而不影响系统的数据结构。
  4. 符合单一职责原则。访问者模式把相关的行为封装在一起,构成一个访问者,使每一个访问者的功能都比较单一。可以做报表、UI、拦截器与过滤器,适用于数据结构相对稳定的系统

主要缺点如下:

  1. 增加新的元素类很困难。在访问者模式中,每增加一个新的元素类,都要在每一个具体访问者类中增加相应的具体操作,这违背了“开闭原则”
  2. 破坏封装。访问者模式中具体元素对访问者公布细节,这破坏了对象的封装性。
  3. 违反了依赖倒置原则。访问者模式依赖了具体类,而没有依赖抽象类
  4. 具体元素对访问者公布细节,违反了迪米特原则

总结一下就是:易于增加的ConcreteVisitor角色,难以增加的ConcreteElement角色

模式的应用场景:

当系统中存在类型数量稳定(固定)的一类数据结构时,可以使用访问者模式方便地实现对该类型所有数据结构的不同操作,而又不会对数据产生任何副作用(脏数据)。

简而言之,就是当对集合中的不同类型数据(类型数量稳定)进行多种操作时,使用访问者模式。

通常在以下情况可以考虑使用访问者(Visitor)模式:

  1. 对象结构相对稳定,但其操作算法经常变化的程序。
  2. 对象结构中的对象需要提供多种不同且不相关的操作,而且要避免让这些操作的变化影响对象的结构。
  3. 对象结构包含很多类型的对象,希望对这些对象实施一些依赖于其具体类型的操作。

18.6、访问者模式扩展

访问者(Visitor)模式是使用频率较高的一种设计模式,它常常同以下两种设计模式联用

  • 与“迭代器模式”联用。因为访问者模式中的“对象结构”是一个包含元素角色的容器,当访问者遍历容器中的所有元素时,常常要用迭代器。如应用举例中的对象结构是用 List 实现的,它通过 List 对象的 Iterator() 方法获取迭代器。如果对象结构中的聚合类没有提供迭代器,也可以用迭代器模式自定义一个。
  • 访问者(Visitor)模式同“组合模式”联用。因为访问者(Visitor)模式中的“元素对象可能是叶子对象或者是容器对象,如果元素对象包含容器对象,就必须用到组合模式,其结构图如图:

image-20210417153952680

18.7、进阶阅读

如果您想深入了解访问者模式,可猛击阅读以下文章。

18.8、相关设计模式

  • Iterator模式

    Iterator模式和Visitor模式都是在某种数据结构上进行处理。

    Iterator模式用于逐个遍历保存在数据结构中的元素。

    Visitor模式用于对保存在数据结构中的元素进行某种特定的处理。

  • Composite模式

    有时访问者所访问的数据结构会使用Composite模式。

  • Interpreter模式

    在Interpreter模式中, 有时会使用Visitor模式。 例如, 在生成了语法树后, 可能会使用Visitor 模式访问语法树的各个节点进行处理。

19、迭代器模式Iterator(行为型模式)

image-20210417155331998

19.1、基本介绍

  1. 迭代器模式(Iterator Pattern)是常用的设计模式,属于行为型模式
  2. 如果我们的集合元素是用不同的方式实现的,有数组,还有 java 的集合类,或者还有其他方式,当客户端要遍历这些集合元素的时候就要使用多种遍历方式,而且还会暴露元素的内部结构,可以考虑使用迭代器模式解决。
  3. 迭代器模式,提供一种遍历集合元素的统一接口,用一致的方法遍历集合元素,不需要知道集合对象的底层表示,即:不暴露其内部的结构
  4. 迭代器模式在客户访问类与聚合类之间插入一个迭代器,这分离了聚合对象与其遍历行为,对客户也隐藏了其内部细节,且满足“单一职责原则”和“开闭原则”

19.2、迭代器模式的原理结构图-uml类图

迭代器模式是通过将聚合对象的遍历行为分离出来,抽象成迭代器类来实现的,其目的是在不暴露聚合对象的内部结构的情况下,让外部代码透明地访问聚合的内部数据。现在我们来分析其基本结构与实现方法。

19.2.1、 模式的结构

迭代器模式主要包含以下角色:

  1. 抽象聚合(Aggregate)角色:一个统一的聚合接口, 将客户端和具体聚合解耦。定义存储、添加、删除聚合对象以及创建迭代器对象的接口。
  2. 具体聚合(ConcreteAggregate)角色:实现抽象聚合类,并提供一个方法,返回一个具体迭代器的实例。该迭代器可以正确遍历集合
  3. 抽象迭代器(Iterator)角色:定义访问和遍历聚合元素的接口,是java系统提供的,通常包含 hasNext()、remove()、next() 等方法。
  4. 具体迭代器(Concretelterator)角色:实现抽象迭代器接口中所定义的方法,完成对聚合对象的遍历,记录遍历的当前位置。
  5. Client :客户端, 通过 Iterator 和 Aggregate 依赖子类

其结构图类图:

image-20210417182430431

19.2.2、代码实现

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
package net.biancheng.c.iterator;

import java.util.*;
public class IteratorPattern {
public static void main(String[] args) {
Aggregate ag = new ConcreteAggregate();
ag.add("中山大学");
ag.add("华南理工");
ag.add("韶关学院");
System.out.print("聚合的内容有:");
Iterator it = ag.getIterator();
while (it.hasNext()) {
Object ob = it.next();
System.out.print(ob.toString() + "\t");
}
Object ob = it.first();
System.out.println("\nFirst:" + ob.toString());
}
}
//抽象聚合
interface Aggregate {
public void add(Object obj);
public void remove(Object obj);
public Iterator getIterator();
}
//具体聚合
class ConcreteAggregate implements Aggregate {
private List<Object> list = new ArrayList<Object>();
public void add(Object obj) {
list.add(obj);
}
public void remove(Object obj) {
list.remove(obj);
}
public Iterator getIterator() {
return (new ConcreteIterator(list));
}
}
//抽象迭代器
interface Iterator {
Object first();
Object next();
boolean hasNext();
}
//具体迭代器
class ConcreteIterator implements Iterator {
private List<Object> list = null;
private int index = -1;
public ConcreteIterator(List<Object> list) {
this.list = list;
}
public boolean hasNext() {
if (index < list.size() - 1) {
return true;
} else {
return false;
}
}
public Object first() {
index = 0;
Object obj = list.get(index);
;
return obj;
}
public Object next() {
Object obj = null;
if (this.hasNext()) {
obj = list.get(++index);
}
return obj;
}
}

19.3、应用举例

编写程序展示一个学校院系结构:需求是这样,要在一个页面中展示出学校的院系组成,一个学校有多个学院, 一个学院有多个系。如图:

image-20210417183413954

19.3.1、使用传统方式解决需求

思路解析(类图)

image-20210415205605144

传统的方式的问题分析:

  1. 将学院看做是学校的子类,系是学院的子类,这样实际上是站在组织大小来进行分层次的
  2. 实际上我们的要求是 :在一个页面中展示出学校的院系组成,一个学校有多个学院,一个学院有多个系, 因此这种方案,不能很好实现的遍历的操作
  3. 解决方案:=> 迭代器模式

19.3.2、使用迭代器模式解决需求

原理类图:

image-20210417184732598

代码实现:

Department:专业。元素。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class Department {
private String name;
private String desc;
public Department(String name, String desc) {
super();
this.name = name;
this.desc = desc;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getDesc() {
return desc;
}
public void setDesc(String desc) {
this.desc = desc;
}
}

抽象迭代器(Iterator)角色:java自带的Iterator迭代器接口

ComputerCollegeIterator:计算机学院迭代器,实现了迭代器接口里的hasNext()与next()方法,具体迭代器(Concretelterator)角色

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
import java.util.Iterator;

public class ComputerCollegeIterator implements Iterator {
//这里我们需要Department 是以怎样的方式存放=>数组
Department[] departments;
int position = 0; //遍历的位置

public ComputerCollegeIterator(Department[] departments) {
this.departments = departments;
}
//判断是否还有下一个元素
@Override
public boolean hasNext() {
// TODO Auto-generated method stub
if(position >= departments.length || departments[position] == null) {
return false;
}else {

return true;
}
}
@Override
public Object next() {
// TODO Auto-generated method stub
Department department = departments[position];
position += 1;
return department;
}
//删除的方法,默认空实现
public void remove() {}
}

InfoColleageIterator:信息学院迭代器,实现了迭代器接口里的hasNext()与next()方法,具体迭代器(Concretelterator)角色

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
import java.util.Iterator;
import java.util.List;

public class InfoColleageIterator implements Iterator {
List<Department> departmentList; // 信息工程学院是以List方式存放系
int index = -1;//索引

public InfoColleageIterator(List<Department> departmentList) {
this.departmentList = departmentList;
}
//判断list中还有没有下一个元素
@Override
public boolean hasNext() {
// TODO Auto-generated method stub
if(index >= departmentList.size() - 1) {
return false;
} else {
index += 1;
return true;
}
}
@Override
public Object next() {
// TODO Auto-generated method stub
return departmentList.get(index);
}

//空实现remove
public void remove() {}
}

College:学院接口,里面有createIterator()方法返回一个迭代器。抽象聚合(Aggregate)角色

1
2
3
4
5
6
7
8
9
import java.util.Iterator;

public interface College {
public String getName();
//增加系的方法
public void addDepartment(String name, String desc);
//返回一个迭代器,遍历
public Iterator createIterator();
}

ComputerCollege:计算机学院,实现学院接口,里面对专业这个元素采用数组方式存储。具体聚合(ConcreteAggregate)角色

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
import java.util.Iterator;

public class ComputerCollege implements College {
Department[] departments;
int numOfDepartment = 0 ;// 保存当前数组的对象个数
public ComputerCollege() {
departments = new Department[5];
addDepartment("Java专业", " Java专业 ");
addDepartment("PHP专业", " PHP专业 ");
addDepartment("大数据专业", " 大数据专业 ");
}
@Override
public String getName() {
return "计算机学院";
}
@Override
public void addDepartment(String name, String desc) {
Department department = new Department(name, desc);
departments[numOfDepartment] = department;
numOfDepartment += 1;
}
@Override
public Iterator createIterator() {
return new ComputerCollegeIterator(departments);
}
}

InfoCollege:信息学院,实现学院接口,里面对专业这个元素采用集合方式存储。具体聚合(ConcreteAggregate)角色

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
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;

public class InfoCollege implements College {
List<Department> departmentList;
public InfoCollege() {
departmentList = new ArrayList<Department>();
addDepartment("信息安全专业", " 信息安全专业 ");
addDepartment("网络安全专业", " 网络安全专业 ");
addDepartment("服务器安全专业", " 服务器安全专业 ");
}
@Override
public String getName() {
return "信息工程学院";
}
@Override
public void addDepartment(String name, String desc) {
Department department = new Department(name, desc);
departmentList.add(department);
}
@Override
public Iterator createIterator() {
return new InfoColleageIterator(departmentList);
}
}

OutPutImpl:遍历实现对象。

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
import java.util.Iterator;
import java.util.List;

public class OutPutImpl {
//学院集合
List<College> collegeList;
public OutPutImpl(List<College> collegeList) {
this.collegeList = collegeList;
}
//遍历所有学院,然后调用printDepartment 输出各个学院的系
public void printCollege() {
//从collegeList 取出所有学院, Java 中的 List 已经实现Iterator
Iterator<College> iterator = collegeList.iterator();
while(iterator.hasNext()) {
//取出一个学院
College college = iterator.next();
System.out.println("=== "+college.getName() +"=====" );
printDepartment(college.createIterator()); //得到对应迭代器
}
}
//输出 学院输出 系
public void printDepartment(Iterator iterator) {
while(iterator.hasNext()) {
Department d = (Department)iterator.next();
System.out.println(d.getName());
}
}
}

Client:客户端

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

public class Client {
public static void main(String[] args) {
//创建学院
List<College> collegeList = new ArrayList<College>();
ComputerCollege computerCollege = new ComputerCollege();
InfoCollege infoCollege = new InfoCollege();

collegeList.add(computerCollege);
collegeList.add(infoCollege);

OutPutImpl outPutImpl = new OutPutImpl(collegeList);
outPutImpl.printCollege();
}
}

19.4、迭代器模式在JDK的应用与源码

JDK 的 ArrayList 集合中就使用了迭代器模式

类图:

image-20210417202305992

角色说明:

  • 内部类 Itr 充当具体实现迭代器 Iterator 的类, 作为 ArrayList 内部类
  • List 就是充当了聚合接口,含有一个 iterator() 方法,返回一个迭代器对象
  • ArrayList 是实现聚合接口 List 的子类,实现了 iterator()
  • Iterator 接口系统提供
  • 迭代器模式解决了 不同集合(ArrayList ,LinkedList) 统一遍历问题

代码分析:

image-20210417200928815

List接口,其中有获取迭代器Iterator的抽象方法,交给实现类去实现

image-20210417203037908

ArrayList实现了List接口,并实现了List接口的Iterator方法

image-20210417202736654

image-20210417203908088

在ArrayList中把元素对象存进了数组里面

image-20210417204155507

Itr为ArrayList的内部类,实现了Iterator接口,并实现了接口的next()方法与hasNext()方法,由于元素是定义在ArrayList当中的,直接使用即可。

image-20210417204819687

image-20210417205056646

另外实现List接口的实现类的LinkedList类

image-20210417210006152

LinkedList继承了AbstractSequentialList类实现了AbstractSequentialList类当中的Iterator迭代器方法

image-20210417210011958

image-20210417211542896

Enumerator实现了Iterator接口

image-20210417224627356

19.5、迭代器模式总结

主要优点如下:

  1. 访问一个聚合对象的内容而无须暴露它的内部表示。
  2. 遍历任务交由迭代器完成,这简化了聚合类。
  3. 支持以不同方式遍历一个聚合,甚至可以自定义迭代器的子类以支持新的遍历
  4. 增加新的聚合类和迭代器类都很方便,无须修改原有代码
  5. 封装性良好,为遍历不同的聚合结构提供一个统一的接口。

其主要缺点是:增加了类的个数,这在一定程度上增加了系统的复杂性。

在日常开发中,我们几乎不会自己写迭代器。除非需要定制一个自己实现的数据结构对应的迭代器,否则,开源框架提供的 API 完全够用

应用场景:

  1. 需要为聚合对象提供多种遍历方式时。
  2. 需要为遍历不同的聚合结构提供一个统一的接口时。
  3. 访问一个聚合对象的内容而无须暴露其内部细节的表示时。
  4. 当要展示一组相似对象,或者遍历一组相同对象时。

由于聚合与迭代器的关系非常密切,所以大多数语言在实现聚合类时都提供了迭代器类,因此大数情况下使用语言中已有的聚合类的迭代器就已经够了。

19.6、迭代器模式扩展

迭代器模式常常与组合模式结合起来使用,在对组合模式中的容器构件进行访问时,经常将迭代器潜藏在组合模式的容器构成类中。当然也可以构造一个外部迭代器来对容器构件进行访问,其结构图:

image-20210417200821530

19.7、进阶阅读

如果您想了解迭代器模式在框架源码中的应用,可猛击阅读以下文章。

19.8、相关设计模式

  • Visitor 模式

    Iterator模式是从集合中一个一个取出元素进行遍历, 但是并没有在Iterator接口中声明对取出的元素进行何种处理。

    Visitor模式则是在遍历元素集合的过程中, 对元素进行相同的处理。

    在遍历集合的过程中对元素进行固定的处理是常有的需求。 Visitor模式正是为了应对这种需求而出现的。 在访问元素集合的过程中对元素进行相同的处理, 这种模式就是Visitor模式。

  • Composite 模式

    Composite模式是具有递归结构的模式, 在其中使用Iterator模式比较困难。

  • Factory Method 模式

    在iterator方法中生成Iterator的实例时可能会使用Factory Method模式。

19.9、迭代器模式的注意事项与细节

提供了一种设计思想,就是一个类应该只有一个引起变化的原因(叫做单一责任原则)。在聚合类中,我们把迭代器分开,就是要把管理对象集合和遍历对象集合的责任分开,这样一来集合改变的话,只影响到聚合对象。而如果遍历方式改变的话,只影响到了迭代器。

20、观察者模式Observer(行为型模式)

20.1、基本介绍

  1. 观察者(Observer)模式的定义:指多个对象间存在一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都得到通知并被自动更新。这种模式有时又称作发布-订阅模式模型-视图模式,它是对象行为型模式

  2. 观察者模式类似订牛奶业务

  3. 奶站/气象局:Subject 用户/第三方网站:Observer

  4. Subject:登记注册、移除和通知

    • registerObserver 注 册
    • removeObserver 移 除
    • notifyObservers() 通知所有的注册的用户,根据不同需求,可以是更新数据,让用户来取,也可能是实施推送, 看具体需求定
    • Observer:接收输入

    观察者模式:对象之间多对一依赖的一种设计方案,被依赖的对象为 Subject,依赖的对象为 Observer,Subject

    通知 Observer 变化,比如这里的奶站是 Subject,是 1 的一方。用户时 Observer,是多的一方。

20.2、观察者模式的原理结构图-uml类图

实现观察者模式时要注意具体目标对象具体观察者对象之间不能直接调用,否则将使两者之间紧密耦合起来,这违反了面向对象的设计原则

20.2.1、模式的结构

观察者模式的主要角色如下:

  1. 抽象主题(Subject)角色:也叫抽象目标类,它提供了一个用于保存观察者对象的聚集类和增加、删除观察者对象的方法,以及通知所有观察者的抽象方法
  2. 具体主题(Concrete Subject)角色:也叫具体目标类,它实现抽象目标中的通知方法,当具体主题的内部状态发生改变时,通知所有注册过的观察者对象。
  3. 抽象观察者(Observer)角色:它是一个抽象类或接口,它包含了一个更新自己的抽象方法,当接到具体主题的更改通知时被调用。
  4. 具体观察者(Concrete Observer)角色:实现抽象观察者中定义的抽象方法,以便在得到目标的更改通知时更新自身的状态。

观察者模式的结构图:

image-20210417224339045

20.2.2、代码实现

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
package net.biancheng.c.observer;
import java.util.*;

public class ObserverPattern {
public static void main(String[] args) {
Subject subject = new ConcreteSubject();
Observer obs1 = new ConcreteObserver1();
Observer obs2 = new ConcreteObserver2();
subject.add(obs1);
subject.add(obs2);
subject.notifyObserver();
}
}
//抽象目标
abstract class Subject {
protected List<Observer> observers = new ArrayList<Observer>();
//增加观察者方法
public void add(Observer observer) {
observers.add(observer);
}
//删除观察者方法
public void remove(Observer observer) {
observers.remove(observer);
}
public abstract void notifyObserver(); //通知观察者方法
}
//具体目标
class ConcreteSubject extends Subject {
public void notifyObserver() {
System.out.println("具体目标发生改变...");
System.out.println("--------------");
for (Object obs : observers) {
((Observer) obs).response();
}
}
}
//抽象观察者
interface Observer {
void response(); //反应
}
//具体观察者1
class ConcreteObserver1 implements Observer {
public void response() {
System.out.println("具体观察者1作出反应!");
}
}
//具体观察者1
class ConcreteObserver2 implements Observer {
public void response() {
System.out.println("具体观察者2作出反应!");
}
}

20.3、应用举例

天气预报项目需求,具体要求如下:

  1. 气象站可以将每天测量到的温度,湿度,气压等等以公告的形式发布出去(比如发布到自己的网站或第三方)。
  2. 需要设计开放型 API,便于其他第三方也能接入气象站获取数据。
  3. 提供温度、气压和湿度的接口
  4. 测量数据更新时,要能实时的通知给第三方

20.3.1、使用传统方法解决需求

image-20210418001550797

image-20210418001617106

实现代码:

WeatherData:天气情况

image-20210418002547098

CurrentConditions

image-20210418002701455

Client

image-20210418002801559

问题分析:

  1. 其他第三方接入气象站获取数据的问题
  2. 无法在运行时动态的添加第三方 (新浪网站)
  3. 违反 ocp 原则=>观察者模式
1
2
3
4
//在 WeatherData 中,当增加一个第三方,都需要创建一个对应的第三方的公告板对象,并加入到 dataChange, 不利于维护,也不是动态加入
public void dataChange() {
currentConditions.update(getTemperature(), getPressure(), getHumidity());
}

20.3.2、使用观察者模式解决需求

类图说明:

image-20210418002150098

代码实现:

Subject:抽象主题(Subject)角色

1
2
3
4
5
6
//接口, 让WeatherData 来实现 
public interface Subject {
public void registerObserver(Observer o);
public void removeObserver(Observer o);
public void notifyObservers();
}

WeatherData:包含最新的天气情况信息。具体主题(Concrete Subject)角色

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
import java.util.ArrayList;

/**
* 类是核心
* 1. 包含最新的天气情况信息
* 2. 含有 观察者集合,使用ArrayList管理
* 3. 当数据有更新时,就主动的调用 ArrayList, 通知所有的(接入方)就看到最新的信息
* @author Administrator
*
*/
public class WeatherData implements Subject {
private float temperatrue;
private float pressure;
private float humidity;
//观察者集合
private ArrayList<Observer> observers;
//加入新的第三方
public WeatherData() {
observers = new ArrayList<Observer>();
}
public float getTemperature() {
return temperatrue;
}
public float getPressure() {
return pressure;
}
public float getHumidity() {
return humidity;
}
public void dataChange() {
//调用 接入方的 update
notifyObservers();
}
//当数据有更新时,就调用 setData
public void setData(float temperature, float pressure, float humidity) {
this.temperatrue = temperature;
this.pressure = pressure;
this.humidity = humidity;
//调用dataChange, 将最新的信息 推送给 接入方 currentConditions
dataChange();
}
//注册一个观察者
@Override
public void registerObserver(Observer o) {
// TODO Auto-generated method stub
observers.add(o);
}
//移除一个观察者
@Override
public void removeObserver(Observer o) {
if(observers.contains(o)) {
observers.remove(o);
}
}
//遍历所有的观察者,并通知
@Override
public void notifyObservers() {
for(int i = 0; i < observers.size(); i++) {
observers.get(i).update(this.temperatrue, this.pressure, this.humidity);
}
}
}

Observer:观察者接口,由观察者来实现。抽象观察者(Observer)角色

1
2
3
4
//观察者接口,由观察者来实现
public interface Observer {
public void update(float temperature, float pressure, float humidity);
}

CurrentConditions:当前环境。具体观察者(Concrete Observer)角色(百度、新浪等等第三方类似)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class CurrentConditions implements Observer {
// 温度,气压,湿度
private float temperature;
private float pressure;
private float humidity;
// 更新 天气情况,是由 WeatherData 来调用,我使用推送模式
public void update(float temperature, float pressure, float humidity) {
this.temperature = temperature;
this.pressure = pressure;
this.humidity = humidity;
display();
}
// 显示
public void display() {
System.out.println("***Today mTemperature: " + temperature + "***");
System.out.println("***Today mPressure: " + pressure + "***");
System.out.println("***Today mHumidity: " + humidity + "***");
}
}

Client:客户端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class Client {
public static void main(String[] args) {
//创建一个WeatherData
WeatherData weatherData = new WeatherData();
//创建观察者
CurrentConditions currentConditions = new CurrentConditions();
BaiduSite baiduSite = new BaiduSite();
//注册到weatherData
weatherData.registerObserver(currentConditions);
weatherData.registerObserver(baiduSite);
//测试
System.out.println("通知各个注册的观察者, 看看信息");
weatherData.setData(10f, 100f, 30.3f);
weatherData.removeObserver(currentConditions);
//测试
System.out.println();
System.out.println("通知各个注册的观察者, 看看信息");
weatherData.setData(10f, 100f, 30.3f);
}
}

20.4、观察者模式在JDK的应用与源码

Jdk 的 Observable 类就使用了观察者模式

角色分析:

  • Observable 的作用和地位等价于 我们前面讲过 Subject
  • Observable 是类,不是接口,类中已经实现了核心的方法 ,即管理 Observer 的方法 add.. delete .. notify…
  • Observer 的作用和地位等价于我们前面讲过的 Observer, 有 update
  • Observable 和 Observer 的使用方法和前面讲过的一样,只是 Observable 是类,通过继承来实现观察者模式

代码分析

image-20210418015159710

Observable:相当于Subject接口,但是Observable为一个普通的类

image-20210418015442922

image-20210418015737148

Observe接口:

image-20210418015636229

image-20210418020018759

20.5、观察者模式总结

主要优点如下:

  1. 降低了目标与观察者之间的耦合关系,两者之间是抽象耦合关系。符合依赖倒置原则
  2. 目标与观察者之间建立了一套触发机制

主要缺点如下:

  1. 如果在观察者和观察目标之间有循环依赖的话,观察目标会触发它们之间进行循环调用,可能导致系统崩溃

    image-20210418011458744

  2. 观察者模式没有相应的机制让观察者知道所观察的目标对象是怎么发生变化的,而仅仅只是知道观察目标发生了变化。

  3. 观察者对象很多时,通知的发布会花费很多时间,影响程序的效率

在软件系统中,当系统一方行为依赖另一方行为的变动时,可使用观察者模式松耦合联动双方,使得一方的变动可以通知到感兴趣的另一方对象,从而让另一方对象对此做出响应。

观察者模式的应用情景:

  1. 对象间存在一对多关系一个对象的改变将导致其他一个或多个对象也发生改变,而不知道具体有多少对象将发生改变,可以降低对象之间的耦合度。
  2. 一个对象必须通知其他对象,而并不知道这些对象是谁
  3. 一个抽象模型有两个方面,其中一个方面依赖于另一方面时,可将这二者封装在独立的对象中以使它们可以各自独立地改变和复用
  4. 实现类似广播机制的功能,不需要知道具体收听者,只需分发广播,系统中感兴趣的对象会自动接收该广播。
  5. 需要在系统中创建一个触发链,A对象的行为将影响B对象,B对象的行为将影响C对象……,多层级嵌套使用,形成一种链式触发机制,使得事件具备跨域(跨越两种观察者类型)通知。

20.6、观察者模式扩展

在 Java 中,通过 java.util.Observable 类和 java.util.Observer 接口定义了观察者模式,只要实现它们的子类就可以编写观察者模式实例。

20.6.1、 Observable类

Observable 类是抽象目标类,它有一个 Vector 向量用于保存所有要通知的观察者对象,下面来介绍它最重要的 3 个方法:

  1. void addObserver(Observer o) 方法:用于将新的观察者对象添加到向量中。
  2. void notifyObservers(Object arg) 方法:调用向量中的所有观察者对象的 update() 方法,通知它们数据发生改变。通常越晚加入向量的观察者越先得到通知。(类似于栈结构)
  3. void setChange() 方法:用来设置一个 boolean 类型的内部标志位,注明目标对象发生了变化。当它为真时,notifyObservers() 才会通知观察者。

20.6.2、 Observer 接口

Observer 接口是抽象观察者,它监视目标对象的变化,当目标对象发生变化时,观察者得到通知,并调用 void update(Observable o,Object arg) 方法,进行相应的工作。

20.6.3、对应例子

利用 Observable 类和 Observer 接口实现原油期货的观察者模式实例。

分析:当原油价格上涨时,空方伤心,多方局兴;当油价下跌时,空方局兴,多方伤心。本实例中的抽象目标(Observable)类在 Java 中已经定义,可以直接定义其子类,即原油期货(OilFutures)类,它是具体目标类,该类中定义一个 SetPriCe(float price) 方法,当原油数据发生变化时调用其父类的 notifyObservers(Object arg) 方法来通知所有观察者;另外,本实例中的抽象观察者接口(Observer)在 Java 中已经定义,只要定义其子类,即具体观察者类(包括多方类 Bull 和空方类 Bear),并实现 update(Observable o,Object arg) 方法即可。

相关类图:

image-20210418010916983

20.6.4、java. util.Observer接口和java. util . Observable类的相关解析

话虽如此,但是java. util.Observer接口和java. util . Observable类并不好用。理由很简单,传递给java. util . Observer接口的Subject角色必须是java . util. Observable类型(或者它的子类型)的。但Java只能单继承, 也就说如果Subject角色已经是某个类的子类了,那么它将无法继承java . util . Observable类。

20.7、进阶阅读

如果您想了解观察者模式在实际项目中的应用,可猛击阅读《基于Java API实现通知机制》文章。

20.8、相关设计模式

Mediator模式

  • 在Mediator模式中,有时会使用Observer模式来实现Mediator角色与Colleague角色之间的通信。
  • 就“发送状态变化通知”这一- 点而言,Mediator 模式与Observer模式是类似的。不过,两种模式中,通知的目的和视角不同。
  • 在Mediator模式中,虽然也会发送通知,不过那不过是为了对Colleague角色进行仲裁而已。
  • 而在Observer模式中,将Subject角色的状态变化通知给Observer 角色的目的则主要是为了使Subject角色和Observer角色同步。

20.9、观察者模式的注意事项与细节

  1. 观察者模式,又称发布-订阅模式,是一种一对多的通知机制,使得双方无需关心对方,只关心通知本身
  2. JAVA 中已经有了对观察者模式的支持类,但一般不支持使用。
  3. 避免循环引用
  4. 各个观察者是依次获得的同步通知,如果上一个观察者处理太慢,会导致下一个观察者不能及时获得通知。此外,如果观察者在处理通知的时候,发生了异常,还需要被观察者处理异常,才能保证继续通知下一个观察者。
  5. 如果顺序执行,某一观察者错误会导致系统卡壳,一般采用异步方式

21、中介者模式Mediator(行为型模式)

image-20210418021014591

21.1、基本介绍

  1. 中介者模式(Mediator Pattern),又称调停者模式,是迪米特法则的典型应用。用一个中介对象来封装一系列的对象交互。中介者使各个对象不需要显式地相互引用,从而使其耦合松散,而且可以独立地改变它们之间的交互。它的目的是把多方会谈变成双方会谈,从而实现多方的松耦合。

  2. 中介者模式属于行为型模式,使代码易于维护

  3. 比如 MVC 模式,C(Controller 控制器)是 M(Model 模型)和 V(View 视图)的中介者,在前后端交互时起到了中间人的作用

    image-20210418141702195

21.2、中介者模式的原理结构图-uml类图

中介者模式实现的关键是找出“中介者”,下面对它的结构和实现进行分析。

21.2.1、模式的结构

中介者模式包含以下主要角色。

  1. 抽象中介者(Mediator)角色:它是中介者的接口,提供了同事对象注册与转发同事对象信息的抽象方法。
  2. 具体中介者(Concrete Mediator)角色:实现中介者接口,定义一个 List 来管理同事对象,协调各个同事角色之间的交互关系,因此它依赖于同事角色
  3. 抽象同事类(Colleague)角色:定义同事类的接口,保存中介者对象提供同事对象交互的抽象方法实现所有相互影响的同事类的公共功能
  4. 具体同事类(Concrete Colleague)角色:是抽象同事类的实现者,当需要与其他同事对象交互时,由中介者对象负责后续的交互。

image-20210418141912987

中介者模式的结构图如图:

image-20210418141852365

21.2.2、代码实现

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
package net.biancheng.c.mediator;
import java.util.*;

public class MediatorPattern {
public static void main(String[] args) {
Mediator md = new ConcreteMediator();
Colleague c1, c2;
c1 = new ConcreteColleague1();
c2 = new ConcreteColleague2();
md.register(c1);
md.register(c2);
c1.send();
System.out.println("-------------");
c2.send();
}
}
//抽象中介者
abstract class Mediator {
public abstract void register(Colleague colleague);
public abstract void relay(Colleague cl); //转发
}
//具体中介者
class ConcreteMediator extends Mediator {
private List<Colleague> colleagues = new ArrayList<Colleague>();
public void register(Colleague colleague) {
if (!colleagues.contains(colleague)) {
colleagues.add(colleague);
colleague.setMedium(this);
}
}
public void relay(Colleague cl) {
for (Colleague ob : colleagues) {
if (!ob.equals(cl)) {
((Colleague) ob).receive();
}
}
}
}
//抽象同事类
abstract class Colleague {
protected Mediator mediator;
public void setMedium(Mediator mediator) {
this.mediator = mediator;
}
public abstract void receive();

public abstract void send();
}
//具体同事类
class ConcreteColleague1 extends Colleague {
public void receive() {
System.out.println("具体同事类1收到请求。");
}
public void send() {
System.out.println("具体同事类1发出请求。");
mediator.relay(this); //请中介者转发
}
}
//具体同事类
class ConcreteColleague2 extends Colleague {
public void receive() {
System.out.println("具体同事类2收到请求。");
}
public void send() {
System.out.println("具体同事类2发出请求。");
mediator.relay(this); //请中介者转发
}
}

21.3、应用举例

智能家庭项目:

  1. 智能家庭包括各种设备,闹钟、咖啡机、电视机、窗帘 等
  2. 主人要看电视时,各个设备可以协同工作,自动完成看电视的准备工作
  3. 比如流程为:闹铃响起->咖啡机开始做咖啡->窗帘自动落下->电视机开始播放

21.3.1、使用传统方式解决需求

思路分析(类图)

image-20210418142259490

传统的方式的问题分析:

  1. 当各电器对象有多种状态改变时,相互之间的调用关系会比较复杂
  2. 各个电器对象彼此联系,你中有我,我中有你,不利于松耦合.
  3. 各个电器对象之间所传递的消息(参数),容易混乱当系统增加一个新的电器对象时,或者执行流程改变时,代码的可维护性、扩展性都不理想 =》 考虑中介者模式

21.3.2、使用中介者模式解决需求

思路分析(类图):

image-20210418142556064

代码实现:

Mediator:中介者。抽象中介者(Mediator)角色

1
2
3
4
5
6
7
8
public abstract class Mediator {
//将给中介者对象,加入到集合中
public abstract void Register(String colleagueName, Colleague colleague);
//接收消息, 具体的同事对象发出
public abstract void GetMessage(int stateChange, String colleagueName);
//发送消息
public abstract void SendMessage();
}

Colleague:抽象同事类(Colleague)角色

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//同事抽象类
public abstract class Colleague {
private Mediator mediator;
public String name;
public Colleague(Mediator mediator, String name) {
this.mediator = mediator;
this.name = name;
}
public Mediator GetMediator() {
return this.mediator;
}
// 发送消息
public abstract void SendMessage(int stateChange);
}

ConcreteMediator:具体中介者(Concrete Mediator)角色

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
import java.util.HashMap;

//具体的中介者类
public class ConcreteMediator extends Mediator {
//集合,放入所有的同事对象
private HashMap<String, Colleague> colleagueMap;
private HashMap<String, String> interMap;
public ConcreteMediator() {
colleagueMap = new HashMap<String, Colleague>();
interMap = new HashMap<String, String>();
}
@Override
public void Register(String colleagueName, Colleague colleague) {
// 将具体的同事类放入集合中
colleagueMap.put(colleagueName, colleague);
if (colleague instanceof Alarm) {
interMap.put("Alarm", colleagueName);
} else if (colleague instanceof CoffeeMachine) {
interMap.put("CoffeeMachine", colleagueName);
} else if (colleague instanceof TV) {
interMap.put("TV", colleagueName);
} else if (colleague instanceof Curtains) {
interMap.put("Curtains", colleagueName);
}
}
//具体中介者的核心方法
//1. 根据得到消息,完成对应任务
//2. 中介者在这个方法,协调各个具体的同事对象,完成任务
@Override
public void GetMessage(int stateChange, String colleagueName) {
//处理闹钟发出的消息
if (colleagueMap.get(colleagueName) instanceof Alarm) {
if (stateChange == 0) {
((CoffeeMachine) (colleagueMap.get(interMap
.get("CoffeeMachine")))).StartCoffee();
((TV) (colleagueMap.get(interMap.get("TV")))).StartTv();
} else if (stateChange == 1) {
((TV) (colleagueMap.get(interMap.get("TV")))).StopTv();
}
//处理咖啡机发出的消息
} else if (colleagueMap.get(colleagueName) instanceof CoffeeMachine) {
((Curtains) (colleagueMap.get(interMap.get("Curtains"))))
.UpCurtains();
} else if (colleagueMap.get(colleagueName) instanceof TV) {//如果TV发现消息
} else if (colleagueMap.get(colleagueName) instanceof Curtains) {
//如果是以窗帘发出的消息,这里处理...
}
}
@Override
public void SendMessage() {
}
}

具体同事类(Concrete Colleague)角色:

Alarm:闹钟同事类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//具体的同事类
public class Alarm extends Colleague {
//构造器
public Alarm(Mediator mediator, String name) {
super(mediator, name);
//在创建Alarm 同事对象时,将自己放入到ConcreteMediator 对象中[集合]
mediator.Register(name, this);
}
public void SendAlarm(int stateChange) {
SendMessage(stateChange);
}
@Override
public void SendMessage(int stateChange) {
//调用的中介者对象的getMessage
this.GetMediator().GetMessage(stateChange, this.name);
}
}

CoffeeMachine:咖啡机

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class CoffeeMachine extends Colleague {
public CoffeeMachine(Mediator mediator, String name) {
super(mediator, name);
mediator.Register(name, this);
}
@Override
public void SendMessage(int stateChange) {
this.GetMediator().GetMessage(stateChange, this.name);
}
public void StartCoffee() {
System.out.println("It's time to startcoffee!");
}
public void FinishCoffee() {
System.out.println("After 5 minutes!");
System.out.println("Coffee is ok!");
SendMessage(0);
}
}

TV:电视机、Curtains:窗帘类似

Client:客户端。负责调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Client {
public static void main(String[] args) {
//创建一个中介者对象
Mediator mediator = new ConcreteMediator();
//创建Alarm 并且加入到 ConcreteMediator 对象的HashMap
Alarm alarm = new Alarm(mediator, "alarm");
//创建了CoffeeMachine 对象,并且加入到ConcreteMediator对象的HashMap
CoffeeMachine coffeeMachine = new CoffeeMachine(mediator,"coffeeMachine");
//创建 Curtains , 并且加入到ConcreteMediator对象的HashMap
Curtains curtains = new Curtains(mediator, "curtains");
TV tV = new TV(mediator, "TV");
//让闹钟发出消息
alarm.SendAlarm(0);
coffeeMachine.FinishCoffee();
alarm.SendAlarm(1);
}
}

21.4、中介者模式总结

主要优点如下:

  1. 类之间各司其职,符合迪米特法则。
  2. 降低了对象之间的耦合性,使得对象易于独立地被复用。
  3. 对象间的一对多关联转变为一对一的关联,把多边关系变成多个双边关系,提高系统的灵活性,使得系统易于维护和扩展。

其主要缺点是:

  1. 中介者模式将原本多个对象直接的相互依赖变成了中介者和多个同事类的依赖关系
  2. 同事类越多时,中介者就会越臃肿,变得复杂且难以维护。

中介者模式的应用场景:

  • 对象之间存在复杂的网状结构关系而导致依赖关系混乱且难以复用时。
  • 当想创建一个运行于多个类之间的对象,又不想生成新的子类时。

21.5、中介者模式扩展

在实际开发中,通常采用以下两种方法来简化中介者模式,使开发变得更简单。

  1. 不定义中介者接口,把具体中介者对象实现成为单例
  2. 同事对象不持有中介者,而是在需要的时候直接获取中介者对象并调用

简化中介者模式的结构图:

image-20210418143949322

代码实现:

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
package net.biancheng.c.mediator;
import java.util.*;

public class SimpleMediatorPattern {
public static void main(String[] args) {
SimpleColleague c1, c2;
c1 = new SimpleConcreteColleague1();
c2 = new SimpleConcreteColleague2();
c1.send();
System.out.println("-----------------");
c2.send();
}
}
//简单单例中介者
class SimpleMediator {
private static SimpleMediator smd = new SimpleMediator();
private List<SimpleColleague> colleagues = new ArrayList<SimpleColleague>();
private SimpleMediator() {
}
public static SimpleMediator getMedium() {
return (smd);
}
public void register(SimpleColleague colleague) {
if (!colleagues.contains(colleague)) {
colleagues.add(colleague);
}
}
public void relay(SimpleColleague scl) {
for (SimpleColleague ob : colleagues) {
if (!ob.equals(scl)) {
((SimpleColleague) ob).receive();
}
}
}
}
//抽象同事类
interface SimpleColleague {
void receive();
void send();
}
//具体同事类
class SimpleConcreteColleague1 implements SimpleColleague {
SimpleConcreteColleague1() {
SimpleMediator smd = SimpleMediator.getMedium();
smd.register(this);
}
public void receive() {
System.out.println("具体同事类1:收到请求。");
}
public void send() {
SimpleMediator smd = SimpleMediator.getMedium();
System.out.println("具体同事类1:发出请求...");
smd.relay(this); //请中介者转发
}
}
//具体同事类
class SimpleConcreteColleague2 implements SimpleColleague {
SimpleConcreteColleague2() {
SimpleMediator smd = SimpleMediator.getMedium();
smd.register(this);
}
public void receive() {
System.out.println("具体同事类2:收到请求。");
}
public void send() {
SimpleMediator smd = SimpleMediator.getMedium();
System.out.println("具体同事类2:发出请求...");
smd.relay(this); //请中介者转发
}
}

21.6、进阶阅读

如果您想了解中介者模式在JDK源码中的应用,可猛击阅读《中介者模式在JDK源码中的应用》文章。

21.7、相关设计模式

  • Facade模式

    在Mediator模式中,Mediator 角色与Colleague角色进行交互。

    而在Facade模式中,Facade 角色单方面地使用其他角色来对外提供高层接口( API)。因此,可以说Mediator模式是双向的,而Facade模式是单向的。

  • Observer模式

    有时会使用Observer模式来实现Mediator角色与Colleague 角色之间的通信。

21.8、中介者模式的注意事项与细节

  1. 多个类相互耦合,会形成网状结构,使用中介者模式将网状结构分离为星型结构进行解耦
  2. 减少类间依赖,降低了耦合,符合迪米特原则
  3. 中介者承担了较多的责任,一旦中介者出现了问题,整个系统就会受到影响
  4. 如果设计不当,中介者对象本身变得过于复杂,这点在实际使用时,要特别注意

22、备忘录模式Memento(行为型模式)

image-20210418145233471

22.1、基本介绍

  1. 备忘录模式(Memento Pattern),该模式又叫快照模式。在不破坏封装性的前提下,捕获一个对象的内部状态,并在该对象之外保存这个状态。这样以后就可将该对象恢复到原先保存的状态。
  2. 可以这里理解备忘录模式:现实生活中的备忘录是用来记录某些要去做的事情,或者是记录已经达成的共同意见的事情,以防忘记了。而在软件层面,备忘录模式有着相同的含义,备忘录对象主要用来记录一个对象的某种状态,或者某些数据,当用户后悔时能撤销当前操作,使数据恢复到它原先的状态。
  3. 备忘录模式属于行为型模式。

22.2、备忘录模式的原理结构图-uml类图

备忘录模式的核心是设计备忘录类以及用于管理备忘录的管理者类。

22.2.1、模式的结构

备忘录模式的主要角色如下:

  1. 发起人(Originator)角色:记录当前时刻的内部状态信息,提供创建备忘录和恢复备忘录数据的功能,实现其他业务功能,它可以访问备忘录里的所有信息。

  2. 备忘录(Memento)角色:负责存储发起人的内部状态,在需要的时候提供这些内部状态给发起人。

    Memento角色有以下两种按口( API ):

    1. wide interface - 宽接口( API ):

      Memento角色提供的“宽接口( API)”是指所有用于获取恢复对象状态信息的方法的集合。由于宽接口( API)会暴露所有Memento角色的内部信息,因此能够使用宽接口( API)的只有Originator角色。

    2. narrowinterface - 窄接口 ( API ):

      Memento角色为外部的Caretaker角色提供了“窄接口( API)”。可以通过窄接口( API)获取的Memento角色的内部信息非常有限,因此可以有效地防止信息泄露。

    通过对外提供以上两种接口( API),可以有效地防止对象的封装性被破坏。

  3. 守护者(Caretaker)角色:对备忘录进行管理,提供保存与获取备忘录的功能,但其不能对备忘录的内容进行访问与修改。

说明:如果希望保存多个 originator 对象的不同时间的状态也可以,只需要在守护者Caretaker当中使用 HashMap <String, 集合>进行保存就行。

备忘录模式的结构图:

image-20210418152450078

22.2.2、代码实现

发起人(Originator)角色:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class Originator {
private String state;//状态信息
public String getState() {
return state;
}
public void setState(String state) {
this.state = state;
}
//编写一个方法,可以保存一个状态对象 Memento
//因此编写一个方法,返回 Memento
public Memento saveStateMemento() {
return new Memento(state);
}
//通过备忘录对象,恢复状态
public void getStateFromMemento(Memento memento) {
state = memento.getState();
}
}

备忘录(Memento)角色

1
2
3
4
5
6
7
8
9
10
11
public class Memento {
private String state;
//构造器
public Memento(String state) {
super();
this.state = state;
}
public String getState() {
return state;
}
}

守护者(Caretaker)角色

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import java.util.ArrayList;
import java.util.List;

public class Caretaker {
//在List 集合中会有很多的备忘录对象
private List<Memento> mementoList = new ArrayList<Memento>();
public void add(Memento memento) {
mementoList.add(memento);
}
//获取到第index个Originator 的 备忘录对象(即保存状态)
public Memento get(int index) {
return mementoList.get(index);
}
}

Client:客户端

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
import java.util.ArrayList;
import java.util.HashMap;

public class Client {
public static void main(String[] args) {
Originator originator = new Originator();
Caretaker caretaker = new Caretaker();
// 状态#1
originator.setState(" 状态#1 攻击力 100 ");
//保存了当前的状态
caretaker.add(originator.saveStateMemento());
// 状态#2
originator.setState(" 状态#2 攻击力 80 ");
caretaker.add(originator.saveStateMemento());
// 状态#3
originator.setState(" 状态#3 攻击力 50 ");
caretaker.add(originator.saveStateMemento());

System.out.println("当前的状态是 =" + originator.getState());
//希望得到状态 1, 将 originator 恢复到状态1
originator.getStateFromMemento(caretaker.get(0));
System.out.println("恢复到状态1 , 当前的状态是");
System.out.println("当前的状态是 =" + originator.getState());
}
}

22.3、应用举例

游戏角色状态恢复问题:

游戏角色有攻击力和防御力,在大战 Boss 前保存自身的状态(攻击力和防御力),当大战 Boss 后攻击力和防御力下降,从备忘录对象恢复到大战前的状态。

22.3.1、使用传统模式解决需求

思路分析(类图):

image-20210418153109616

传统的方式的问题分析:

  1. 一个对象,就对应一个保存对象状态的对象, 这样当我们游戏的对象很多时,不利于管理,开销也很大。
  2. 传统的方式是简单地做备份,new 出另外一个对象出来,再把需要备份的数据放到这个新对象,但这就暴露了对象内部的细节
  3. 解决方案: => 备忘录模式

22.3.2、使用备忘录模式解决需求

思路分析和图解(类图):

image-20210418153258446

代码实现:

GameRole:游戏角色。发起人(Originator)角色

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
public class GameRole {
private int vit;
private int def;
//创建Memento ,即根据当前的状态得到Memento
public Memento createMemento() {
return new Memento(vit, def);
}
//从备忘录对象,恢复GameRole的状态
public void recoverGameRoleFromMemento(Memento memento) {
this.vit = memento.getVit();
this.def = memento.getDef();
}
//显示当前游戏角色的状态
public void display() {
System.out.println("游戏角色当前的攻击力:" + this.vit + " 防御力: " + this.def);
}
public int getVit() {
return vit;
}
public void setVit(int vit) {
this.vit = vit;
}
public int getDef() {
return def;
}
public void setDef(int def) {
this.def = def;
}
}

Memento:备忘录(Memento)角色

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class Memento {
//攻击力
private int vit;
//防御力
private int def;
public Memento(int vit, int def) {
super();
this.vit = vit;
this.def = def;
}
public int getVit() {
return vit;
}
public void setVit(int vit) {
this.vit = vit;
}
public int getDef() {
return def;
}
public void setDef(int def) {
this.def = def;
}
}

Caretaker:守护者(Caretaker)角色

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import java.util.ArrayList;
import java.util.HashMap;
//守护者对象, 保存游戏角色的状态
public class Caretaker {
//如果只保存一次状态
private Memento memento;
//对GameRole 保存多次状态
//private ArrayList<Memento> mementos;
//对多个游戏角色保存多个状态
//private HashMap<String, ArrayList<Memento>> rolesMementos;

public Memento getMemento() {
return memento;
}
public void setMemento(Memento memento) {
this.memento = memento;
}
}

Client:客户端,负责调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class Client {
public static void main(String[] args) {
//创建游戏角色
GameRole gameRole = new GameRole();
gameRole.setVit(100);
gameRole.setDef(100);
System.out.println("和boss大战前的状态");
gameRole.display();
//把当前状态保存caretaker
Caretaker caretaker = new Caretaker();
caretaker.setMemento(gameRole.createMemento());
System.out.println("和boss大战~~~");
gameRole.setDef(30);
gameRole.setVit(30);
gameRole.display();
System.out.println("大战后,使用备忘录对象恢复到大战前");
gameRole.recoverGameRoleFromMemento(caretaker.getMemento());
System.out.println("恢复后的状态");
gameRole.display();
}
}

22.4、备忘录模式总结

主要优点如下:

  • 提供了一种可以恢复状态的机制。当用户需要时能够比较方便地将数据恢复到某个历史的状态
  • 实现了内部状态的封装。除了创建它的发起人之外,其他对象都不能够访问这些状态信息。
  • 简化了发起人(Originator)类。发起人不需要管理和保存其内部状态的各个备份,所有状态信息都保存在备忘录中,并由管理者进行管理,这符合单一职责原则

主要缺点是:

  • 资源消耗大。如果要保存的内部状态信息过多或者特别频繁,将会占用比较大的内存资源。而且每一次保存都会消耗一定的内存

备忘录模式应用场景:

  1. 需要保存与恢复数据的场景,如玩游戏时的中间结果的存档功能。
  2. 需要提供一个可回滚操作的场景,如 Word、记事本、Photoshop,Eclipse 等软件在编辑时按 Ctrl+Z 组合键,还有数据库中事务操作。

备忘录模式应用实例:

  • 后悔药
  • 打游戏时的存档
  • Windows 里的 ctri + z
  • IE 中的后退
  • 数据库的事务管理
  • 编辑过程中的Undo(撤销)、Redo(重做)、History(历史记录)、Snapshot (快照)都是备忘录模式的应用

22.5、备忘录模式扩展

22.5.1、备忘录模式 + 原型模式

备忘录模式如何同原型模式混合使用。在备忘录模式中,通过定义“备忘录”来备份“发起人”的信息,而原型模式的 clone() 方法具有自备份功能,所以,如果让发起人实现 Cloneable 接口就有备份自己的功能,这时可以删除备忘录类,其结构图

image-20210418154508695

代码实现:

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
package net.biancheng.c.memento;

public class PrototypeMemento {
public static void main(String[] args) {
OriginatorPrototype or = new OriginatorPrototype();
PrototypeCaretaker cr = new PrototypeCaretaker();
or.setState("S0");
System.out.println("初始状态:" + or.getState());
cr.setMemento(or.createMemento()); //保存状态
or.setState("S1");
System.out.println("新的状态:" + or.getState());
or.restoreMemento(cr.getMemento()); //恢复状态
System.out.println("恢复状态:" + or.getState());
}
}
//发起人原型
class OriginatorPrototype implements Cloneable {
private String state;
public void setState(String state) {
this.state = state;
}
public String getState() {
return state;
}
public OriginatorPrototype createMemento() {
return this.clone();
}
public void restoreMemento(OriginatorPrototype opt) {
this.setState(opt.getState());
}
public OriginatorPrototype clone() {
try {
return (OriginatorPrototype) super.clone();
} catch (CloneNotSupportedException e) {
e.printStackTrace();
}
return null;
}
}
//原型管理者
class PrototypeCaretaker {
private OriginatorPrototype opt;
public void setMemento(OriginatorPrototype opt) {
this.opt = opt;
}
public OriginatorPrototype getMemento() {
return opt;
}
}

22.5.2、关于备忘录模式在源码当中的应用

由于 JDK、Spring、Mybatis 中很少有备忘录模式,所以该设计模式不做典型应用源码分析。

Spring Webflow 中 DefaultMessageContext 类实现了 StateManageableMessageContext 接口,查看其源码可以发现其主要逻辑就相当于给 Message 备份

22.6、进阶阅读

如果您想了解备忘录模式在实际项目中的应用,可猛击阅读《使用备忘录模式实现草稿箱功能》文章。

22.7、相关设计模式

  • Command模式

    在使用Command模式处理命令时,可以使用Memento模式实现撤销功能。

  • Protype模式

    在Memento模式中,为了能够实现快照和撤销功能,保存了对象当前的状态。保存的信息只是在恢复状态时所需要的那部分信息。

    而在Protype模式中,会生成- 一个与当前实例完全相同的另外一个实例。 这两个实例的内容完全一样。

  • State 模式

    在Memento模式中,是用“实例”表示状态。

    而在State模式中,则是用“类”表示状态。

22.8、备忘录模式的注意事项与细节

  1. 给用户提供了一种可以恢复状态的机制,可以使用户能够比较方便地回到某个历史的状态

  2. 实现了信息的封装,使得用户不需要关心状态的保存细节

  3. 如果类的成员变量过多,势必会占用比较大的资源,而且每一次保存都会消耗一定的内存, 这个需要注意

  4. 为了节约内存,备忘录模式可以和原型模式配合使用

  5. 在守护者当中的不同情况:

    1. 如果只保存一次状态Memento

      1
      private Memento memento;
    2. 对发起人(Originator)对象保存多次状态Memento

      1
      private ArrayList<Memento> mementos;
    3. 对多个发起人(Originator)角色保存多个状态Memento

      1
      private HashMap<String, ArrayList<Memento>> rolesMementos;

23、解释器模式Interpreter(行为型模式)

image-20210418162744613

23.1、基本介绍

  1. 在编译原理中,一个算术表达式通过词法分析器形成词法单元,而后这些词法单元再通过语法分析器构建语法分析树,最终形成一颗抽象的语法分析树。这里的词法分析器和语法分析器都可以看做是解释器
  2. 解释器模式(Interpreter Pattern):是指给分析对象定义一个语言,并定义该语言的文法表示,再设计一个解析器来解释语言中的句子(表达式)。也就是说,用编译语言的方式来分析应用中的实例。
  3. 这种模式实现了文法表达式处理的接口,该接口解释一个特定的上下文。
  4. 这里提到的文法和句子的概念同编译原理中的描述相同,“文法”指语言的语法规则,而“句子”是语言集中的元素。

23.2、编译原理中的“文法、句子、语法树”等相关概念

23.2.1、文法

文法是用于描述语言的语法结构的形式规则。没有规矩不成方圆,例如,有些人认为完美爱情的准则是“相互吸引、感情专一、任何一方都没有恋爱经历”,虽然最后一条准则较苛刻,但任何事情都要有规则,语言也一样,不管它是机器语言还是自然语言,都有它自己的文法规则。

例如,中文中的“句子”的文法如下:

注:这里的符号“::=”表示“定义为”的意思,用“〈”和“〉”括住的是非终结符,没有括住的是终结符。

1
2
3
4
5
6
7
〈句子〉::=〈主语〉〈谓语〉〈宾语〉
〈主语〉::=〈代词〉|〈名词〉
〈谓语〉::=〈动词〉
〈宾语〉::=〈代词〉|〈名词〉
〈代词〉你|我|他
〈名词〉7大学生I筱霞I英语
〈动词〉::=是|学习

23.2.2、句子

句子是语言的基本单位,是语言集中的一个元素,它由终结符构成,能由“文法”推导出。

例如,上述文法可以推出“我是大学生”,所以它是句子。

23.2.3、语法树

语法树是句子结构的一种树型表示,它代表了句子的推导结果,它有利于理解句子语法结构的层次。

下图所示是“我是大学生”的语法树:

image-20210418180415987

解释器模式的结构与组合模式相似,不过其包含的组成元素比组合模式多,而且组合模式是对象结构型模式,而解释器模式是类行为型模式。

23.3、解释器模式的原理结构图-uml类图

23.3.1、模式的结构

解释器模式包含以下主要角色:

  1. 抽象表达式(Abstract Expression)角色:定义解释器的接口,约定解释器的解释操作,主要**包含解释方法 interpret()**,这个方法为抽象语法树中所有的节点所共享。
  2. 终结符表达式(Terminal Expression)角色:是抽象表达式的子类,用来实现文法中与终结符相关的操作,文法中的每一个终结符都有一个具体终结表达式与之相对应
  3. 非终结符表达式(Nonterminal Expression)角色:也是抽象表达式的子类,用来实现文法中与非终结符相关的操作,文法中的每条规则都对应于一个非终结符表达式
  4. 环境(Context)角色:通常包含各个解释器需要的数据或是公共的功能,一般用来传递被所有解释器共享的数据,后面的解释器可以从这里获取这些值
  5. 客户端(Client):主要任务是将需要分析的句子或表达式转换成使用解释器对象描述的抽象语法树,然后调用解释器的解释方法,当然也可以通过环境角色间接访问解释器的解释方法。

解释器模式的结构图:

image-20210418180640123

23.3.2、代码实现

解释器模式实现的关键是定义文法规则、设计终结符类与非终结符类、画出结构图,必要时构建语法树,其代码结构如下:

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
package net.biancheng.c.interpreter;

//抽象表达式类
interface AbstractExpression {
public void interpret(String info); //解释方法
}
//终结符表达式类
class TerminalExpression implements AbstractExpression {
public void interpret(String info) {
//对终结符表达式的处理
}
}
//非终结符表达式类
class NonterminalExpression implements AbstractExpression {
private AbstractExpression exp1;
private AbstractExpression exp2;
public void interpret(String info) {
//非对终结符表达式的处理
}
}
//环境类
class Context {
private AbstractExpression exp;
public Context() {
//数据初始化
}
public void operation(String info) {
//调用相关表达式类的解释方法
}
}

23.4、应用举例

四则运算问题:

通过解释器模式来实现四则运算,如计算 a+b-c 的值,具体要求:

  1. 先输入表达式的形式,比如 a+b+c-d+e, 要求表达式的字母不能重复

  2. 在分别输入 a ,b, c, d, e 的值

  3. 最后求出结果:如图

    image-20210418181009303

23.4.1、使用传统方式解决需求

传统方案解决四则运算问题分析:

  1. 编写一个方法,接收表达式的形式,然后根据用户输入的数值进行解析,得到结果
  2. 问题分析:如果加入新的运算符,比如*(乘)/(除) 等等,不利于扩展,另外让一个方法来解析会造成程序结构混乱,不够清晰。
  3. 解决方案:可以考虑使用解释器模式,即: 表达式 -> 解释器(可以有多种) -> 结果

23.4.2、使用解释器模式解决需求

思路分析和图解(类图):

image-20210418181353124

代码实现:

Expression:抽象表达式(Abstract Expression)

1
2
3
4
5
6
7
8
9
10
11
12
13
import java.util.HashMap;
/**
* 抽象类表达式,通过HashMap 键值对, 可以获取到变量的值
*
* @author Administrator
*
*/
public abstract class Expression {
// a + b - c
// 解释公式和数值, key 就是公式(表达式) 参数[a,b,c], value就是就是具体值
// HashMap {a=10, b=20}
public abstract int interpreter(HashMap<String, Integer> var);
}

VarExpression:变量的解释器。终结符表达式(Terminal Expression)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import java.util.HashMap;
/**
* 变量的解释器
* @author Administrator
*
*/
public class VarExpression extends Expression {
private String key; // key=a,key=b,key=c
public VarExpression(String key) {
this.key = key;
}
// var 就是{a=10, b=20}
// interpreter 根据 变量名称,返回对应值
@Override
public int interpreter(HashMap<String, Integer> var) {
return var.get(this.key);
}
}

SymbolExpression:抽象运算符号解析器。非终结符表达式(Nonterminal Expression)

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.HashMap;

/**
* 抽象运算符号解析器 这里,每个运算符号,都只和自己左右两个数字有关系,
* 但左右两个数字有可能也是一个解析的结果,无论何种类型,都是Expression类的实现类
*
* @author Administrator
*
*/
public class SymbolExpression extends Expression {
protected Expression left;
protected Expression right;
public SymbolExpression(Expression left, Expression right) {
this.left = left;
this.right = right;
}
//因为 SymbolExpression 是让其子类来实现,因此 interpreter 是一个默认实现
@Override
public int interpreter(HashMap<String, Integer> var) {
return 0;
}
}

AddExpression:加法解释器(减法解释器SubExpression类似)继承了SymbolExpression

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import java.util.HashMap;
/**
* 加法解释器
* @author Administrator
*
*/
public class AddExpression extends SymbolExpression {
public AddExpression(Expression left, Expression right) {
super(left, right);
}
//处理相加
//var 仍然是 {a=10,b=20}..
public int interpreter(HashMap<String, Integer> var) {
//super.left.interpreter(var) : 返回 left 表达式对应的值 a = 10
//super.right.interpreter(var): 返回right 表达式对应值 b = 20
return super.left.interpreter(var) + super.right.interpreter(var);
}
}

Calculator:计算器。环境(Context)

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
32
33
34
35
36
37
38
39
40
41
42
43
import java.util.HashMap;
import java.util.Stack;

public class Calculator {
// 定义表达式
private Expression expression;
// 构造函数传参,并解析
public Calculator(String expStr) { // expStr = a+b
// 安排运算先后顺序
Stack<Expression> stack = new Stack<>();
// 表达式拆分成字符数组
char[] charArray = expStr.toCharArray();// [a, +, b]
Expression left = null;
Expression right = null;
//遍历我们的字符数组, 即遍历 [a, +, b]
//针对不同的情况,做处理
for (int i = 0; i < charArray.length; i++) {
switch (charArray[i]) {
case '+': // '+'号
left = stack.pop();// 从stack取出left => "a"
right = new VarExpression(String.valueOf(charArray[++i]));// 取出右表达式 "b"
stack.push(new AddExpression(left, right));// 然后根据得到left 和 right 构建 AddExpresson加入stack
break;
case '-': // '-'号
left = stack.pop();
right = new VarExpression(String.valueOf(charArray[++i]));
stack.push(new SubExpression(left, right));
break;
default:
//如果是一个 Var 就创建要给 VarExpression 对象,并push到 stack
stack.push(new VarExpression(String.valueOf(charArray[i])));
break;
}
}
//当遍历完整个 charArray 数组后,stack 就得到最后Expression
this.expression = stack.pop();
}
public int run(HashMap<String, Integer> var) {
//最后将表达式a+b和 var = {a=10,b=20}
//然后传递给expression的interpreter进行解释执行
return this.expression.interpreter(var);
}
}

Client:客户端。负责调用

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
32
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.HashMap;

public class Client {
public static void main(String[] args) throws IOException {
String expStr = getExpStr(); // a+b
HashMap<String, Integer> var = getValue(expStr);// var {a=10, b=20}
Calculator calculator = new Calculator(expStr);
System.out.println("运算结果:" + expStr + "=" + calculator.run(var));
}
// 获得表达式
public static String getExpStr() throws IOException {
System.out.print("请输入表达式:");
return (new BufferedReader(new InputStreamReader(System.in))).readLine();
}
// 获得值映射
public static HashMap<String, Integer> getValue(String expStr) throws IOException {
HashMap<String, Integer> map = new HashMap<>();
for (char ch : expStr.toCharArray()) {
if (ch != '+' && ch != '-') {
if (!map.containsKey(String.valueOf(ch))) {
System.out.print("请输入" + String.valueOf(ch) + "的值:");
String in = (new BufferedReader(new InputStreamReader(System.in))).readLine();
map.put(String.valueOf(ch), Integer.valueOf(in));
}
}
}
return map;
}
}

23.5、解释器模式在Spring框架的应用与源码

Spring 框架中 SpelExpressionParser 就使用到解释器模式

代码分析+Debug源码:

image-20210418194307766

main:

image-20210418194430819

Expression接口:

image-20210418194613016

image-20210418194717484

SpelExpressionParser的parseExpression()方法是继承了其父类TemplateAwareExpressionParser的parseExpression()方法,而TemplateAwareExpressionParser又实现了ExpressionParser接口

image-20210418194936179

image-20210418195208607

image-20210418195226794

image-20210418195314345

TemplateAwareExpressionParser的parseExpression()方法:

image-20210418195658830

其中的parseTemplate()方法

image-20210418195826923

其中的doParseTemplate()方法

image-20210418200021479

子类SpelExpressionParser实现了父类TemplateAwareExpressionParser的doParseTemplate()方法

image-20210418200257640

image-20210418200553239

说明:

image-20210418194009115

23.6、解释器模式总结

主要优点如下:

  1. 扩展性好。由于在解释器模式中使用类来表示语言的文法规则,因此可以通过继承等机制来改变或扩展文法
  2. 容易实现。在语法树中的每个表达式节点类都是相似的,所以实现其文法较为容易。

主要缺点如下:

  1. 执行效率较低。解释器模式中通常使用大量的循环和递归调用,当要解释的句子较复杂时,其运行速度很慢,且代码的调试过程也比较麻烦。
  2. 会引起类膨胀。解释器模式中的每条规则至少需要定义一个类,当包含的文法规则很多时,类的个数将急剧增加,导致系统难以管理与维护。
  3. 可应用的场景比较少。在软件开发中,需要定义语言文法的应用实例非常少,所以这种模式很少被使用到。

应用场景:

  1. 语言的文法较为简单,且执行效率不是关键问题时。
  2. 问题重复出现,且可以用一种简单的语言来进行表达时。
  3. 一个语言需要解释执行,并且语言中的句子可以表示为一个抽象语法树的时候,如 XML 文档解释

应用实例:编译器、运算表达式计算、正则表达式、机器人等

注意:解释器模式在实际的软件开发中使用比较少,因为它会引起效率、性能以及维护等问题。如果碰到对表达式的解释,在 Java 中可以用 Expression4J 或 Jep 等来设计。

23.7、解释器模式扩展

在项目开发中,如果要对数据表达式进行分析与计算,无须再用解释器模式进行设计了,Java 提供了以下强大的数学公式解析器:Expression4J、MESP(Math Expression String Parser) 和 Jep 等,它们可以解释一些复杂的文法,功能强大,使用简单。

现在以 Jep 为例来介绍该工具包的使用方法。Jep 是 Java expression parser 的简称,即 Java 表达式分析器,它是一个用来转换和计算数学表达式的 Java 库。通过这个程序库,用户可以以字符串的形式输入一个任意的公式,然后快速地计算出其结果。而且 Jep 支持用户自定义变量、常量和函数,它包括许多常用的数学函数和常量。

下面以计算存款利息为例来介绍。存款利息的计算公式是:本金x利率x时间=利息,其相关代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package net.biancheng.c.interpreter;
import com.singularsys.jep.*;

public class JepDemo {
public static void main(String[] args) throws JepException {
Jep jep = new Jep();
//定义要计算的数据表达式
String 存款利息 = "本金*利率*时间";
//给相关变量赋值
jep.addVariable("本金", 10000);
jep.addVariable("利率", 0.038);
jep.addVariable("时间", 2);
jep.parse(存款利息); //解析表达式
Object accrual = jep.evaluate(); //计算
System.out.println("存款利息:" + accrual);
}
}

23.8、进阶阅读

如果您想了解解释器模式在框架源码中的应用,可猛击阅读《解释器模式在JDK和Spring源码中的应用》文章。

23.9、相关设计模式

  • Composite模式

    NonterminalExpression角色多是递归结构,因此常会使用Composite模式来实现NonterminalExpression角色

  • Flyweight 模式

    有时会使用Flyweight模式来共享TerminalExpression角色。

  • Visitor 模式

    在推导出语法树后,有时会使用Visitor模式来访问语法树的各个节点。

23.10、解释器模式的注意事项与细节

  1. 当有一个语言需要解释执行,可将该语言中的句子表示为一个抽象语法树,就可以考虑使用解释器模式,让程序具有良好的扩展
  2. 使用解释器可能带来的问题:解释器模式会引起类膨胀、解释器模式采用递归调用方法,将会导致调试非常复杂、效率可能降低。
  3. 解释器模式通过抽象语法树实现对用户输入的解释执行。
  4. 解释器模式的实现通常非常复杂,且一般只能解决一类特定问题。

24、状态模式State(行为型模式)

image-20210418201124106

24.1、基本介绍

  1. 状态模式(State Pattern):它主要用来解决对象在多种状态转换时,需要对外输出不同的行为的问题。状态和行为是一一对应的,状态之间可以相互转换
  2. 对有状态的对象,把复杂的“判断逻辑”提取到不同的状态对象中,允许状态对象在其内部状态发生改变时改变其行为。
  3. 当一个对象的内在状态改变时,允许改变其行为,这个对象看起来像是改变了其类
  4. 替代了使用if-else解决问题

24.2、状态模式的原理结构图-uml类图

状态模式把受环境改变的对象行为包装在不同的状态对象里,其意图是让一个对象在其内部状态改变的时候,其行为也随之改变

24.2.1、模式的结构

状态模式包含以下主要角色:

  1. 环境类(Context)角色:也称为上下文,它定义了客户端需要的接口内部维护一个当前状态,并负责具体状态的切换
  2. 抽象状态(State)角色:定义一个接口,用以封装环境对象中的特定状态所对应的行为,可以有一个或多个行为。
  3. 具体状态(Concrete State)角色:实现抽象状态所对应的行为,并且在需要的情况下进行状态切换

其结构图类图:

image-20210418205430235

24.2.2、代码实现

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
public class StatePatternClient {
public static void main(String[] args) {
Context context = new Context(); //创建环境
context.Handle(); //处理请求
context.Handle();
context.Handle();
context.Handle();
}
}
//环境类
class Context {
private State state;
//定义环境类的初始状态
public Context() {
this.state = new ConcreteStateA();
}
//设置新状态
public void setState(State state) {
this.state = state;
}
//读取状态
public State getState() {
return (state);
}
//对请求做处理
public void Handle() {
state.Handle(this);
}
}
//抽象状态类
abstract class State {
public abstract void Handle(Context context);
}
//具体状态A类
class ConcreteStateA extends State {
public void Handle(Context context) {
System.out.println("当前状态是 A.");
context.setState(new ConcreteStateB());
}
}
//具体状态B类
class ConcreteStateB extends State {
public void Handle(Context context) {
System.out.println("当前状态是 B.");
context.setState(new ConcreteStateA());
}
}

24.3、应用举例

APP 抽奖活动问题:

请编写程序完成 APP 抽奖活动 具体要求如下:

  1. 假如每参加一次这个活动要扣除用户 50 积分,中奖概率是 10%

  2. 奖品数量固定,抽完就不能抽奖

  3. 活动有四个状态:

    • 可以抽奖
    • 不能抽奖
    • 发放奖品
    • 奖品领完
  4. 活动的四个状态转换关系图:

    image-20210418205732564

24.3.1、使用传统方式解决需求

通常通过if/else判断抽奖的状态,从而实现不同的逻辑,伪代码如下

1
2
3
4
5
6
7
8
if(不能抽奖){
//代码逻辑
}else if(可以抽奖){
//代码逻辑
}else if(发放奖品){
//代码逻辑
}
//...

传统的方式的问题分析:

  • 这类代码难以应对变化,在添加一种状态时,我们需要手动添加if/else
  • 在添加一种功能时,要对所有的状态进行判断。
  • 因此代码会变得越来越臃肿,并且一旦没有处理某个状态,便会发生极其严重的BUG,难以维护
  • 不符合开闭原则

24.3.2、使用状态模式解决需求

思路分析和图解(类图)

  • 定义出一个接口叫状态接口,每个状态都实现它。
  • 接口有扣除积分方法、抽奖方法、发放奖品方法

image-20210418210441898

代码实现:

State:抽象状态(State)

1
2
3
4
5
6
7
8
9
10
11
12
/**
* 状态抽象类
* @author Administrator
*/
public abstract class State {
// 扣除积分 - 50
public abstract void deductMoney();
// 是否抽中奖品
public abstract boolean raffle();
// 发放奖品
public abstract void dispensePrize();
}

具体状态(Concrete State):四种状态:

NoRaffleState:不能抽奖状态

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
/**
* 不能抽奖状态
* @author Administrator
*/
public class NoRaffleState extends State {
// 初始化时传入活动引用,扣除积分后改变其状态
RaffleActivity activity;
public NoRaffleState(RaffleActivity activity) {
this.activity = activity;
}
// 当前状态可以扣积分 , 扣除后,将状态设置成可以抽奖状态
@Override
public void deductMoney() {
System.out.println("扣除50积分成功,您可以抽奖了");
activity.setState(activity.getCanRaffleState());
}
// 当前状态不能抽奖
@Override
public boolean raffle() {
System.out.println("扣了积分才能抽奖喔!");
return false;
}
// 当前状态不能发奖品
@Override
public void dispensePrize() {
System.out.println("不能发放奖品");
}
}

CanRaffleState:可以抽奖的状态

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
32
33
34
35
36
37
38
39
import java.util.Random;
/**
* 可以抽奖的状态
* @author Administrator
*/
public class CanRaffleState extends State {
RaffleActivity activity;
public CanRaffleState(RaffleActivity activity) {
this.activity = activity;
}
//已经扣除了积分,不能再扣
@Override
public void deductMoney() {
System.out.println("已经扣取过了积分");
}
//可以抽奖, 抽完奖后,根据实际情况,改成新的状态
@Override
public boolean raffle() {
System.out.println("正在抽奖,请稍等!");
Random r = new Random();
int num = r.nextInt(10);
// 10%中奖机会
if(num == 0){
// 改变活动状态为发放奖品 context
activity.setState(activity.getDispenseState());
return true;
}else{
System.out.println("很遗憾没有抽中奖品!");
// 改变状态为不能抽奖
activity.setState(activity.getNoRafflleState());
return false;
}
}
// 不能发放奖品
@Override
public void dispensePrize() {
System.out.println("没中奖,不能发放奖品");
}
}

DispenseState:发放奖品的状态

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
32
33
34
35
/**
* 发放奖品的状态
* @author Administrator
*/
public class DispenseState extends State {
// 初始化时传入活动引用,发放奖品后改变其状态
RaffleActivity activity;
public DispenseState(RaffleActivity activity) {
this.activity = activity;
}
@Override
public void deductMoney() {
System.out.println("不能扣除积分");
}
@Override
public boolean raffle() {
System.out.println("不能抽奖");
return false;
}
//发放奖品
@Override
public void dispensePrize() {
if(activity.getCount() > 0){
System.out.println("恭喜中奖了");
// 改变状态为不能抽奖
activity.setState(activity.getNoRafflleState());
}else{
System.out.println("很遗憾,奖品发送完了");
// 改变状态为奖品发送完毕, 后面我们就不可以抽奖
activity.setState(activity.getDispensOutState());
//System.out.println("抽奖活动结束");
//System.exit(0);
}
}
}

DispenseOutState:奖品发放完毕状态

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
/**
* 奖品发放完毕状态
* 说明,当我们activity 改变成 DispenseOutState, 抽奖活动结束
* @author Administrator
*/
public class DispenseOutState extends State {
// 初始化时传入活动引用
RaffleActivity activity;
public DispenseOutState(RaffleActivity activity) {
this.activity = activity;
}
@Override
public void deductMoney() {
System.out.println("奖品发送完了,请下次再参加");
}
@Override
public boolean raffle() {
System.out.println("奖品发送完了,请下次再参加");
return false;
}
@Override
public void dispensePrize() {
System.out.println("奖品发送完了,请下次再参加");
}
}

Activity:抽奖活动。环境类(Context)

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
/**
* 抽奖活动
* @author Administrator
*/
public class Activity {
// state 表示活动当前的状态,是变化
State state = null;
// 奖品数量
int count = 0;
// 四个属性,表示四种状态
State noRafflleState = new NoRaffleState(this);
State canRaffleState = new CanRaffleState(this);
State dispenseState = new DispenseState(this);
State dispensOutState = new DispenseOutState(this);
//构造器
//1. 初始化当前的状态为 noRafflleState(即不能抽奖的状态)
//2. 初始化奖品的数量
public RaffleActivity( int count) {
this.state = getNoRafflleState();
this.count = count;
}
//扣分, 调用当前状态的 deductMoney
public void debuctMoney(){
state.deductMoney();
}
//抽奖
public void raffle(){
// 如果当前的状态是抽奖成功
if(state.raffle()){
//领取奖品
state.dispensePrize();
}
}
public State getState() {
return state;
}
public void setState(State state) {
this.state = state;
}
//这里请大家注意,每领取一次奖品,count--
public int getCount() {
int curCount = count;
count--;
return curCount;
}
public void setCount(int count) {
this.count = count;
}
public State getNoRafflleState() {
return noRafflleState;
}
public void setNoRafflleState(State noRafflleState) {
this.noRafflleState = noRafflleState;
}
public State getCanRaffleState() {
return canRaffleState;
}
public void setCanRaffleState(State canRaffleState) {
this.canRaffleState = canRaffleState;
}
public State getDispenseState() {
return dispenseState;
}
public void setDispenseState(State dispenseState) {
this.dispenseState = dispenseState;
}
public State getDispensOutState() {
return dispensOutState;
}
public void setDispensOutState(State dispensOutState) {
this.dispensOutState = dispensOutState;
}
}

Client:客户端,调用者。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
* 状态模式测试类
* @author Administrator
*/
public class ClientTest {
public static void main(String[] args) {
// 创建活动对象,奖品有1个奖品
RaffleActivity activity = new RaffleActivity(1);
// 我们连续抽30次奖
for (int i = 0; i < 30; i++) {
System.out.println("--------第" + (i + 1) + "次抽奖----------");
// 参加抽奖,第一步点击扣除积分
activity.debuctMoney();
// 第二步抽奖
activity.raffle();
}
}
}

24.4、状态模式在实际项目-借贷平台源码分析

借贷平台的订单,有审核-发布-抢单 等等 步骤,随着操作的不同,会改变订单的状态, 项目中的这个模块实现就会使用到状态模式:

image-20210418213432465

image-20210418213517578

实现类图:

image-20210418213541498

代码实现:

State:

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
32
33
34
35
36
37
38
39
/**
* 状态接口
* @author Administrator
*/
public interface State {
/**
* 电审
*/
void checkEvent(Context context);
/**
* 电审失败
*/
void checkFailEvent(Context context);
/**
* 定价发布
*/
void makePriceEvent(Context context);
/**
* 接单
*/
void acceptOrderEvent(Context context);
/**
* 无人接单失效
*/
void notPeopleAcceptEvent(Context context);
/**
* 付款
*/
void payOrderEvent(Context context);
/**
* 接单有人支付失效
*/
void orderFailureEvent(Context context);
/**
* 反馈
*/
void feedBackEvent(Context context);
String getCurrentState();
}

AbstractState:实现State接口方法的默认实现。子类通过自己的需求进行重写

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
32
33
34
35
public abstract class AbstractState implements State {
rotected static final RuntimeException EXCEPTION = new RuntimeException("操作流程不允许");
@Override
public void checkEvent(Context context) {
throw EXCEPTION;
}
@Override
public void checkFailEvent(Context context) {
throw EXCEPTION;
}
@Override
public void makePriceEvent(Context context) {
throw EXCEPTION;
}
@Override
public void acceptOrderEvent(Context context) {
throw EXCEPTION;
}
@Override
public void notPeopleAcceptEvent(Context context) {
throw EXCEPTION;
}
@Override
public void payOrderEvent(Context context) {
throw EXCEPTION;
}
@Override
public void orderFailureEvent(Context context) {
throw EXCEPTION;
}
@Override
public void feedBackEvent(Context context) {
throw EXCEPTION;
}
}

各种具体状态类:

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
//各种具体状态类
class FeedBackState extends AbstractState {
@Override
public String getCurrentState() {
return StateEnum.FEED_BACKED.getValue();
}
}
class GenerateState extends AbstractState {
@Override
public void checkEvent(Context context) {
context.setState(new ReviewState());
}
@Override
public void checkFailEvent(Context context) {
context.setState(new FeedBackState());
}
@Override
public String getCurrentState() {
return StateEnum.GENERATE.getValue();
}
}
class NotPayState extends AbstractState {
@Override
public void payOrderEvent(Context context) {
context.setState(new PaidState());
}
@Override
public void feedBackEvent(Context context) {
context.setState(new FeedBackState());
}
@Override
public String getCurrentState() {
return StateEnum.NOT_PAY.getValue();
}
}
class PaidState extends AbstractState {
@Override
public void feedBackEvent(Context context) {
context.setState(new FeedBackState());
}
@Override
public String getCurrentState() {
return StateEnum.PAID.getValue();
}
}
class PublishState extends AbstractState {
@Override
public void acceptOrderEvent(Context context) {
context.setState(new NotPayState());
}
@Override
public void notPeopleAcceptEvent(Context context) {
context.setState(new FeedBackState());
}
@Override
public String getCurrentState() {
return StateEnum.PUBLISHED.getValue();
}
}
class ReviewState extends AbstractState {
@Override
public void makePriceEvent(Context context) {
context.setState(new PublishState());
}
@Override
public String getCurrentState() {
return StateEnum.REVIEWED.getValue();
}
}

StateEnum:状态枚举类

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
/**
* 状态枚举类
* @author Administrator
*/
public enum StateEnum {
//订单生成
GENERATE(1, "GENERATE"),
//已审核
REVIEWED(2, "REVIEWED"),
//已发布
PUBLISHED(3, "PUBLISHED"),
//待付款
NOT_PAY(4, "NOT_PAY"),
//已付款
PAID(5, "PAID"),
//已完结
FEED_BACKED(6, "FEED_BACKED");
private int key;
private String value;
StateEnum(int key, String value) {
this.key = key;
this.value = value;
}
public int getKey() {return key;}
public String getValue() {return value;}
}

Context:环境上下文

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
//环境上下文
public class Context extends AbstractState{
private State state;
@Override
public void checkEvent(Context context) {
state.checkEvent(this);
getCurrentState();
}
@Override
public void checkFailEvent(Context context) {
state.checkFailEvent(this);
getCurrentState();
}
@Override
public void makePriceEvent(Context context) {
state.makePriceEvent(this);
getCurrentState();
}
@Override
public void acceptOrderEvent(Context context) {
state.acceptOrderEvent(this);
getCurrentState();
}
@Override
public void notPeopleAcceptEvent(Context context) {
state.notPeopleAcceptEvent(this);
getCurrentState();
}
@Override
public void payOrderEvent(Context context) {
state.payOrderEvent(this);
getCurrentState();
}
@Override
public void orderFailureEvent(Context context) {
state.orderFailureEvent(this);
getCurrentState();
}
@Override
public void feedBackEvent(Context context) {
state.feedBackEvent(this);
getCurrentState();
}
public State getState() {
return state;
}
public void setState(State state) {
this.state = state;
}
@Override
public String getCurrentState() {
System.out.println("当前状态 : " + state.getCurrentState());
return state.getCurrentState();
}
}

ClientTest:测试类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**测试类*/
public class ClientTest {
public static void main(String[] args) {
Context context = new Context();
context.setState(new PublishState());
//然后可以根据操作变化状态.
//publish --> not pay
context.acceptOrderEvent(context);
//not pay --> paid
context.payOrderEvent(context);
// 失败, 检测失败时,会抛出异常
try {
context.checkFailEvent(context);
System.out.println("流程正常..");
} catch (Exception e) {
System.out.println(e.getMessage());
}
}
}

24.5、状态模式总结

主要优点如下:

  1. 结构清晰,状态模式将与特定状态相关的行为局部化到一个状态中,并且将不同状态的行为分割开来,满足“单一职责原则”
  2. 枚举可能的状态,在枚举状态之前需要确定状态种类。
  3. 将状态转换显示化,减少对象间的相互依赖。将不同的状态引入独立的对象中会使得状态转换变得更加明确,且减少对象间的相互依赖。
  4. 允许状态转换逻辑与状态对象合成一体,而不是某一个巨大的条件语句块。
  5. 所有与某个状态有关的行为放到一个类中,并且可以方便地增加新的状态,只需要改变对象状态即可改变对象的行为。
  6. 可以让多个环境对象共享一个状态对象,从而减少系统中对象的个数
  7. 状态类职责明确,有利于程序的扩展。通过定义新的子类很容易地增加新的状态和转换。

状态模式的主要缺点如下:

  1. 状态模式的使用必然会增加系统的类与对象的个数
  2. 状态模式的结构与实现都较为复杂,如果使用不当会导致程序结构和代码的混乱。
  3. 状态模式对开闭原则的支持并不太好,对于可以切换状态的状态模式,增加新的状态类需要修改那些负责状态转换的源码,否则无法切换到新增状态,而且修改某个状态类的行为也需要修改对应类的源码。

状态模式的应用场景:

  • 当一个对象的行为取决于它的状态,并且它必须在运行时根据状态改变它的行为时,就可以考虑使用状态模式。
  • 一个操作中含有庞大的分支结构,并且这些分支决定于对象的状态时。
  • 一个事件或者对象有很多种状态状态之间会相互转换,对不同的状态要求有不同的行为的时候, 可以考虑使用状态模式

24.6、状态模式扩展

24.6.1、状态模式 + 享元模式

在有些情况下,可能有多个环境对象需要共享一组状态,这时需要引入享元模式,将这些具体状态对象放在集合中供程序共享,其结构图:

image-20210418212557965

分析:共享状态模式的不同之处是在环境类中增加了一个 HashMap 来保存相关状态,当需要某种状态时可以从中获取,其程序代码如下:

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
package state;
import java.util.HashMap;

public class FlyweightStatePattern {
public static void main(String[] args) {
ShareContext context = new ShareContext(); //创建环境
context.Handle(); //处理请求
context.Handle();
context.Handle();
context.Handle();
}
}
//环境类
class ShareContext {
private ShareState state;
private HashMap<String, ShareState> stateSet = new HashMap<String, ShareState>();
public ShareContext() {
state = new ConcreteState1();
stateSet.put("1", state);
state = new ConcreteState2();
stateSet.put("2", state);
state = getState("1");
}
//设置新状态
public void setState(ShareState state) {
this.state = state;
}
//读取状态
public ShareState getState(String key) {
ShareState s = (ShareState) stateSet.get(key);
return s;
}
//对请求做处理
public void Handle() {
state.Handle(this);
}
}
//抽象状态类
abstract class ShareState {
public abstract void Handle(ShareContext context);
}
//具体状态1类
class ConcreteState1 extends ShareState {
public void Handle(ShareContext context) {
System.out.println("当前状态是: 状态1");
context.setState(context.getState("2"));
}
}
//具体状态2类
class ConcreteState2 extends ShareState {
public void Handle(ShareContext context) {
System.out.println("当前状态是: 状态2");
context.setState(context.getState("1"));
}
}

24.6.2、状态模式与责任链模式的区别

  1. 状态模式和责任链模式都能消除 if-else 分支过多的问题。但在某些情况下,状态模式中的状态可以理解为责任,那么在这种情况下,两种模式都可以使用
  2. 定义来看,状态模式强调的是一个对象内在状态的改变,而责任链模式强调的是外部节点对象间的改变
  3. 代码实现上来看,两者最大的区别就是状态模式的各个状态对象知道自己要进入的下一个状态对象,而责任链模式并不清楚其下一个节点处理对象,因为链式组装由客户端负责

24.6.3、状态模式与策略模式的区别

状态模式和策略模式的 UML 类图架构几乎完全一样,但两者的应用场景是不一样的策略模式的多种算法行为择其一都能满足,彼此之间是独立的用户可自行更换策略算法,而状态模式的各个状态间存在相互关系,彼此之间在一定条件下存在自动切换状态的效果,并且用户无法指定状态,只能设置初始状态

24.7、进阶阅读

如果您想深入了解状态模式,可猛击阅读以下文章。

24.8、相关设计模式

  • Singleton 模式

    Singleton模式常常会出现在ConcreteState角色中。这是因为在表示状态的类中并没有定义任何实例字段(即表示实例的状态的字段)。

  • Flyweight 模式

    在表示状态的类中并没有定义任何实例字段。因此,有时我们可以使用Flyweight模式在多个Context角色之间共享ConcreteState角色。

24.9、状态模式的注意事项与细节

  1. 代码有很强的可读性。状态模式将每个状态的行为封装到对应的一个类
  2. 方便维护。将容易产生问题的 if-else 语句删除了,如果把每个状态的行为都放到一个类中,每次调用方法时都要判断当前是什么状态,不但会产出很多 if-else 语句,而且容易出错
  3. 会产生很多类。每个状态都要一个对应的类,当状态过多时会产生很多类,加大维护难度

25、策略模式Strategy(行为型模式)

image-20210418214750299

25.1、基本介绍

  1. 策略模式(Strategy Pattern)中,定义算法族(策略组),分别封装起来,让他们之间可以互相替换,此模式让算法的变化独立于使用算法的客户
  2. 策略模式属于对象行为模式,它通过对算法进行封装,把使用算法的责任和算法的实现分割开来,并委派给不同的对象对这些算法进行管理
  3. 在策略模式中,我们创建表示各种策略的对象和一个行为随着策略对象改变而改变的 context 对象。策略对象改变 context 对象的执行算法。
  4. 策略模式的核心思想是在一个计算方法中把容易变化的算法抽出来作为“策略”参数传进去,从而使得新增策略不必修改原有逻辑
  5. 这算法体现了几个设计原则:
    1. 把变化的代码从不变的代码中分离出来
    2. 第二、针对接口编程而不是具体类(定义了策略接口)
    3. 第三、多用组合/聚合,少用继承(客户通过组合方式使用策略)。

25.2、策略模式的原理结构图-uml类图

策略模式是准备一组算法,并将这组算法封装到一系列的策略类里面,作为一个抽象策略类的子类。策略模式的重心不是如何实现算法,而是如何组织这些算法,从而让程序结构更加灵活,具有更好的维护性和扩展性。

25.2.1、模式的结构

策略模式的主要角色如下:

  1. 抽象策略(Strategy)类:定义了一个公共接口各种不同的算法以不同的方式实现这个接口环境角色使用这个接口调用不同的算法,一般使用接口或抽象类实现。
  2. 具体策略(Concrete Strategy)类:实现了抽象策略定义的接口,提供具体的算法实现。
  3. 环境(Context)类:持有一个策略类的引用,最终给客户端调用。

其结构图如图:

image-20210418225857983

25.3.2、代码实现

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
public class StrategyPattern {
public static void main(String[] args) {
Context c = new Context();
Strategy s = new ConcreteStrategyA();
c.setStrategy(s);
c.strategyMethod();
System.out.println("-----------------");
s = new ConcreteStrategyB();
c.setStrategy(s);
c.strategyMethod();
}
}
//抽象策略类
interface Strategy {
public void strategyMethod(); //策略方法
}
//具体策略类A
class ConcreteStrategyA implements Strategy {
public void strategyMethod() {
System.out.println("具体策略A的策略方法被访问!");
}
}
//具体策略类B
class ConcreteStrategyB implements Strategy {
public void strategyMethod() {
System.out.println("具体策略B的策略方法被访问!");
}
}
//抽象策略类1
interface Strategy1 {
public void strategyMethod(); //策略方法
}
//具体策略类C
class ConcreteStrategyC implements Strategy1 {
public void strategyMethod() {
System.out.println("具体策略C的策略方法被访问!");
}
}
//具体策略类D
class ConcreteStrategyD implements Strategy1 {
public void strategyMethod() {
System.out.println("具体策略D的策略方法被访问!");
}
}
//环境类
class Context {
private Strategy strategy;
private Strategy1 strategy1;
public Strategy getStrategy() {
return strategy;
}
public void setStrategy(Strategy strategy) {
this.strategy = strategy;
}
public void strategyMethod() {
strategy.strategyMethod();
}
}

25.3、应用举例

编写鸭子项目,具体要求如下:

  1. 有各种鸭子(比如 野鸭、北京鸭、水鸭等, 鸭子有各种行为,比如 叫、飞行等)
  2. 显示鸭子的信息

25.3.1、使用传统方式解决需求

传统的设计方案(类图):

image-20210418230442078

传统的方式实现的问题分析和解决方案:

  1. 其它鸭子,都继承了 Duck 类,所以 fly 让所有子类都会飞了,这是不正确的
  2. 上面说的 1 的问题,其实是继承带来的问题:对类的局部改动,尤其超类的局部改动,会影响其他部分。会有溢出效应
  3. 为了改进 1 问题,我们可以通过覆盖 fly 方法来解决 => 覆盖解决
  4. 问题又来了,如果我们有一个玩具鸭子 ToyDuck, 这样就需要 ToyDuck 去覆盖 Duck 的所有实现的方法 => 解决思路 -》 策略模式 (strategy pattern)

25.3.2、使用策略模式解决需求

思路分析(类图):

策略模式:分别封装行为接口,实现算法族,超类里放行为接口对象,在子类里具体设定行为对象。

原则就是: 分离变化部分,封装接口,基于接口编程各种功能。此模式让行为的变化独立于算法的使用者。

image-20210418230737776

代码实现:

FlyBehavior:飞行。(QuackBehavior:叫行为。等等其它抽象策略与其具体实现类类似)抽象策略(Strategy)

1
2
3
public interface FlyBehavior {	
void fly(); // 子类具体实现
}

GoodFlyBehavior:飞行技术高超。(BadFlyBehavior:飞行技术一般、NoFlyBehavior:不会飞行等等类似)具体策略(Concrete Strategy)

1
2
3
4
5
6
public class GoodFlyBehavior implements FlyBehavior {
@Override
public void fly() {
System.out.println(" 飞翔技术高超 ~~~");
}
}

Duck:鸭子抽象类。环境(Context)

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
public abstract class Duck {
//属性, 策略接口
FlyBehavior flyBehavior;
//其它属性<->策略接口
QuackBehavior quackBehavior;
public Duck() {}
public abstract void display();//显示鸭子信息
public void quack() {
System.out.println("鸭子嘎嘎叫~~");
}
public void swim() {
System.out.println("鸭子会游泳~~");
}
public void fly() {
//改进
if(flyBehavior != null) {
flyBehavior.fly();
}
}
public void setFlyBehavior(FlyBehavior flyBehavior) {
this.flyBehavior = flyBehavior;
}
public void setQuackBehavior(QuackBehavior quackBehavior) {
this.quackBehavior = quackBehavior;
}
}

WildDuck:野鸭具体类,继承了鸭子抽象类。(PekingDuck:北京鸭,飞行技术一般、ToyDuck:玩具鸭,不会飞行类似)

1
2
3
4
5
6
7
8
9
10
public class WildDuck extends Duck {
//构造器,传入FlyBehavor 的对象
public WildDuck() {
flyBehavior = new GoodFlyBehavior();
}
@Override
public void display() {
System.out.println(" 这是野鸭 ");
}
}

Client:客户端,负责调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Client {
public static void main(String[] args) {
WildDuck wildDuck = new WildDuck();
wildDuck.fly();
ToyDuck toyDuck = new ToyDuck();
toyDuck.fly();
PekingDuck pekingDuck = new PekingDuck();
pekingDuck.fly();
//动态改变某个对象的行为, 北京鸭 不能飞
pekingDuck.setFlyBehavior(new NoFlyBehavior());
System.out.println("北京鸭的实际飞翔能力");
pekingDuck.fly();
}

}

25.4、策略模式在JDK的应用与源码

JDK 的 Arrays 的 Comparator 就使用了策略模式

代码分析+Debug 源码:

image-20210419001920044

main:

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
import java.util.Arrays;
import java.util.Comparator;

public class Strategy {
public static void main(String[] args) {
//数组
Integer[] data = { 9, 1, 2, 8, 4, 3 };
// 实现降序排序,返回-1放左边,1放右边,0保持不变

// 说明
// 1. 实现了 Comparator 接口(策略接口) , 匿名类 对象 new Comparator<Integer>(){..}
// 2. 对象 new Comparator<Integer>(){..} 就是实现了 策略接口 的对象
// 3. public int compare(Integer o1, Integer o2){} 指定具体的处理方式
Comparator<Integer> comparator = new Comparator<Integer>() {
public int compare(Integer o1, Integer o2) {
if (o1 > o2) {
return -1;
} else {
return 1;
}
};
};
// 说明
/*
* public static <T> void sort(T[] a, Comparator<? super T> c) {
if (c == null) {
sort(a); //默认方法
} else {
if (LegacyMergeSort.userRequested)
legacyMergeSort(a, c); //使用策略对象c
else
// 使用策略对象c
TimSort.sort(a, 0, a.length, c, null, 0, 0);
}
}
*/
//方式1
Arrays.sort(data, comparator);
System.out.println(Arrays.toString(data)); // 降序排序

//方式2- 同时lambda 表达式实现 策略模式
Integer[] data2 = { 19, 11, 12, 18, 14, 13 };

Arrays.sort(data2, (var1, var2) -> {
if(var1.compareTo(var2) > 0) {
return -1;
} else {
return 1;
}
});
System.out.println("data2=" + Arrays.toString(data2));
}
}

Comparator:是一个接口,其中有一个compare的核心方法:告诉代码应该怎么去比较两个实例,然后根据比较结果进行排序

image-20210419000512228

image-20210419001129754

Array的sort排序方法

image-20210419001333373

25.5、 策略模式总结

主要优点如下:

  1. 多重条件语句不易维护,而使用策略模式可以避免使用多重条件语句,如 if…else 语句、switch…case 语句。
  2. 策略模式提供了一系列的可供重用的算法族恰当使用继承可以把算法族的公共代码转移到父类里面,从而避免重复的代码。
  3. 策略模式可以提供相同行为的不同实现客户可以根据不同时间或空间要求选择不同的实现
  4. 策略模式提供了对开闭原则的完美支持,可以在不修改原代码的情况下,灵活增加新算法。
  5. 策略模式把算法的使用放到环境类中,而算法的实现移到具体策略类中,实现了二者的分离

其主要缺点如下:

  1. 所有策略类都需要对外暴露。客户端必须理解所有策略算法的区别,以便适时选择恰当的算法类。
  2. 策略模式造成很多的策略类,增加维护难度

策略模式的应用场景:

策略模式在很多地方用到,如 Java SE 中的容器布局管理就是一个典型的实例,Java SE 中的每个容器都存在多种布局供用户选择。在程序设计中,通常在以下几种情况中使用策略模式较多。

  1. 一个系统需要动态地在几种算法中选择一种时,可将每个算法封装到策略类中。
  2. 一个类定义了多种行为,并且这些行为在这个类的操作中以多个条件语句的形式出现,可将每个条件分支移入它们各自的策略类中以代替这些条件语句
  3. 系统中各算法彼此完全独立,且要求对客户隐藏具体算法的实现细节时。
  4. 系统要求使用算法的客户不应该知道其操作的数据时,可使用策略模式来隐藏与算法相关的数据结构。
  5. 多个类只区别在表现行为不同,可以使用策略模式,在运行时动态选择具体要执行的行为。

25.6、策略模式扩展(策略模式+工厂模式)

在一个使用策略模式的系统中,当存在的策略很多时,客户端管理所有策略算法将变得很复杂,如果在环境类中使用策略工厂模式来管理这些策略类将大大减少客户端的工作复杂度,其结构图如图:

image-20210418232858259

25.7、进阶阅读

如果您想深入了解策略模式,可猛击阅读以下文章。

25.8、相关设计模式

  • Flyweight模式

    有时会使用Flyweight模式让多个地方可以共用ConcreteStrategy角色。

  • Abstract Factory模式

    使用Strategy模式可以整体地替换算法。

    使用Abstract Factory模式则可以整体地替换具体工厂、零件和产品。

  • State 模式

    使用Strategy模式和State模式都可以替换被委托对象,而且它们的类之间的关系也很相似。但是两种模式的目的不同。

    在Strategy模式中,ConcreteStrategy 角色是表示算法的类。在Strategy模式中,可以替换被委托对象的类。当然如果没有必要,也可以不替换。

    而在State模式中,ConcreteState角色是表示“状态”的类。在State模式中,每次状态变化时,被委托对象的类都必定会被替换。

25.9、策略模式的注意事项与细节

  1. 策略模式的关键是:分析项目中变化部分与不变部分
  2. 策略模式的核心思想是:多用组合/聚合 少用继承;用行为类组合,而不是行为的继承。更有弹性
  3. 体现了“对修改关闭,对扩展开放”原则,客户端增加行为不用修改原有代码,只要添加一种策略(或者行为) 即可,避免了使用多重转移语句(if..else if..else)
  4. 提供了可以替换继承关系的办法: 策略模式将算法封装在独立的 Strategy 类中使得你可以独立于其 Context 改变它,使它易于切换、易于理解、易于扩展
  5. 需要注意的是:每添加一个策略就要增加一个类,当策略过多是会导致类数目庞大
  6. 如果一个系统的策略多于四个,就需要考虑使用混合模式(策略模式+工厂模式),解决策略类膨胀的问题。

26、职责链模式Chain of Responsibility(行为型模式)

image-20210419002157100

26.1、基本介绍

  1. 职责链模式(Chain of Responsibility Pattern), 又叫责任链模式,为请求创建了一个接收者对象的链。这种模式对请求的发送者和接收者进行解耦
  2. 为了避免请求发送者与多个请求处理者耦合在一起,于是将所有请求的处理者通过前一对象记住其下一个对象的引用而连成一条链;当有请求发生时,可将请求沿着这条链传递,直到有对象处理它为止。
  3. 职责链模式通常每个接收者都包含对另一个接收者的引用(形成闭环)。如果一个对象不能处理该请求,那么它会把相同的请求传给下一个接收者,依此类推。
  4. 在责任链模式中,客户只需要将请求发送到责任链上即可,无须关心请求的处理细节和请求的传递过程,请求会自动进行传递。所以责任链将请求的发送者和请求的处理者解耦了。
  5. 这种类型的设计模式属于行为型模式

26.2、职责链模式的原理结构图-uml类图

通常情况下,可以通过数据链表来实现职责链模式的数据结构。

26.2.1、模式的结构

职责链模式主要包含以下角色:

  1. 抽象处理者(Handler)角色:定义一个处理请求的接口,包含抽象处理方法和一个后继连接Handler对象。
  2. 具体处理者(Concrete Handler)角色:实现抽象处理者的处理方法,判断能否处理本次请求,如果可以处理请求则处理,否则将该请求转给它的后继者。从而形成一个职责链。
  3. 请求类(Request)角色 , 含义很多属性,表示一个请求
  4. 客户类(Client)角色:创建处理链,并向链头的具体处理者对象提交请求,它不关心处理细节和请求的传递过程。

责任链模式的本质是解耦请求与处理,让请求在处理链中能进行传递与被处理;理解责任链模式应当理解其模式,而不是其具体实现。责任链模式的独到之处是将其节点处理者组合成了链式结构,并允许节点自身决定是否进行请求处理或转发,相当于让请求流动起来。

其结构图如图:

image-20210419020914621

客户端可按下图所示设置责任链:

image-20210419021241297

26.2.2、代码实现

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
package chainOfResponsibility;

public class ChainOfResponsibilityPattern {
public static void main(String[] args) {
//组装责任链
Handler handler1 = new ConcreteHandler1();
Handler handler2 = new ConcreteHandler2();
handler1.setNext(handler2);
//提交请求
handler1.handleRequest("two");
}
}
//抽象处理者角色
abstract class Handler {
private Handler next;
public void setNext(Handler next) {
this.next = next;
}
public Handler getNext() {
return next;
}
//处理请求的方法
public abstract void handleRequest(String request);
}
//具体处理者角色1
class ConcreteHandler1 extends Handler {
public void handleRequest(String request) {
if (request.equals("one")) {
System.out.println("具体处理者1负责处理该请求!");
} else {
if (getNext() != null) {
getNext().handleRequest(request);
} else {
System.out.println("没有人处理该请求!");
}
}
}
}
//具体处理者角色2
class ConcreteHandler2 extends Handler {
public void handleRequest(String request) {
if (request.equals("two")) {
System.out.println("具体处理者2负责处理该请求!");
} else {
if (getNext() != null) {
getNext().handleRequest(request);
} else {
System.out.println("没有人处理该请求!");
}
}
}
}

在上面代码中,我们把消息硬编码为 String 类型,而在真实业务中,消息是具备多样性的,可以是 int、String 或者自定义类型。因此,在上面代码的基础上,可以对消息类型进行抽象 Request,增强了消息的兼容性

26.3、应用举例

学校 OA 系统的采购审批项目:需求是

采购员采购教学器材

  1. 如果金额 小于等于 5000, 由教学主任审批 (0<=x<=5000)
  2. 如果金额 小于等于 10000, 由院长审批 (5000<x<=10000)
  3. 如果金额 小于等于 30000, 由副校长审批 (10000<x<=30000)
  4. 如果金额 超过 30000 以上,有校长审批 ( 30000<x)

请设计程序完成采购审批项目

26.3.1、使用传统方法解决需求

思路分析(类图):

image-20210419022414374

传统方案解决 OA 系统审批问题分析:

传统方式是:接收到一个采购请求后,根据采购金额来调用对应的 Approver (审批人)完成审批。

传统方式的问题分析 :

  1. 客户端这里会使用到分支判断(比如 switch) 来对不同的采购请求处理, 这样就存在如下问题:
    1. 如果各个级别的人员审批金额发生变化,在客户端的也需要变化
    2. 客户端必须明确的知道 有多少个审批级别和访问
  2. 这样 对一个采购请求进行处理 和 Approver (审批人) 就存在强耦合关系,不利于代码的扩展和维护
  3. 解决方案 =》 职责链模式

26.3.2、使用职责链模式解决需求

思路分析和图解(类图):

image-20210419022837598

代码实现:

PurchaseRequest:采购请求。请求类(Request)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//请求类
public class PurchaseRequest {
private int type = 0; //请求类型
private float price = 0.0f; //请求金额
private int id = 0;
//构造器
public PurchaseRequest(int type, float price, int id) {
this.type = type;
this.price = price;
this.id = id;
}
public int getType() {
return type;
}
public float getPrice() {
return price;
}
public int getId() {
return id;
}
}

Approver:抽象处理者(Handler)

1
2
3
4
5
6
7
8
9
10
11
12
13
public abstract class Approver {
Approver approver; //下一个处理者
String name; // 名字
public Approver(String name) {
this.name = name;
}
//下一个处理者
public void setApprover(Approver approver) {
this.approver = approver;
}
//处理审批请求的方法,得到一个请求, 处理是子类完成,因此该方法做成抽象
public abstract void processRequest(PurchaseRequest purchaseRequest);
}

DepartmentApprover:教学主任处理类。(其中CollegeApprover:院长处理类(5000<x<=10000)、ViceSchoolMasterApprover:副校长处理类(10000<x<=30000)、SchoolMasterApprover:院长处理类(30000<x)类似)具体处理者(Concrete Handler)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class DepartmentApprover extends Approver {
public DepartmentApprover(String name) {
// TODO Auto-generated constructor stub
super(name);
}
@Override
public void processRequest(PurchaseRequest purchaseRequest) {
if(purchaseRequest.getPrice() <= 5000) {
System.out.println(" 请求编号 id= " + purchaseRequest.getId() + " 被 " + this.name + " 处理");
}else {
approver.processRequest(purchaseRequest);
}
}
}

Client:客户类(Client)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class Client {
public static void main(String[] args) {
//创建一个请求
PurchaseRequest purchaseRequest = new PurchaseRequest(1, 31000, 1);
//创建相关的审批人
DepartmentApprover departmentApprover = new DepartmentApprover("张主任");
CollegeApprover collegeApprover = new CollegeApprover("李院长");
ViceSchoolMasterApprover viceSchoolMasterApprover = new ViceSchoolMasterApprover("王副校");
SchoolMasterApprover schoolMasterApprover = new SchoolMasterApprover("佟校长");
//需要将各个审批级别的下一个设置好 (处理人构成环形: )
departmentApprover.setApprover(collegeApprover);
collegeApprover.setApprover(viceSchoolMasterApprover);
viceSchoolMasterApprover.setApprover(schoolMasterApprover);
schoolMasterApprover.setApprover(departmentApprover);

departmentApprover.processRequest(purchaseRequest);
viceSchoolMasterApprover.processRequest(purchaseRequest);
}
}

26.4、职责链模式在SpringMVC框架的应用与源码

SpringMVC-HandlerExecutionChain 类就使用到职责链模式

SpringMVC 请求流程简图:

image-20210419011833309

代码分析+Debug 源码:

image-20210419012005876

main:

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
32
33
34
35
36
import org.springframework.web.servlet.HandlerExecutionChain;
import org.springframework.web.servlet.HandlerInterceptor;

public class ResponsibilityChain {
public static void main(String[] args) {
// DispatcherServlet
//说明
/*
*
* protected void doDispatch(HttpServletRequest request, HttpServletResponse response) throws Exception {
* HandlerExecutionChain mappedHandler = null;
* mappedHandler = getHandler(processedRequest);//获取到HandlerExecutionChain对象
* //在 mappedHandler.applyPreHandle 内部 得到啦 HandlerInterceptor interceptor
* //调用了拦截器的 interceptor.preHandle
* if (!mappedHandler.applyPreHandle(processedRequest, response)) {
return;
}

//说明:mappedHandler.applyPostHandle 方法内部获取到拦截器,并调用
//拦截器的 interceptor.postHandle(request, response, this.handler, mv);
mappedHandler.applyPostHandle(processedRequest, response, mv);
* }
*
*
* //说明:在 mappedHandler.applyPreHandle内部中,
* 还调用了 triggerAfterCompletion 方法,该方法中调用了
* HandlerInterceptor interceptor = getInterceptors()[i];
try {
interceptor.afterCompletion(request, response, this.handler, ex);
}
catch (Throwable ex2) {
logger.error("HandlerInterceptor.afterCompletion threw exception", ex2);
}
*/
}
}

SpringMVC中的最重要的DispatcherServlet类,当中有一个核心方法:doDispatcher方法

image-20210419012029050

在doDispatcher方法中一开始就获取了HandlerExecutionChain对象

image-20210419012220314

调用preHandle方法:

在得到HandlerExecutionChain对象后调用了其applyPreHandle()方法,在其内部得到了HandlerInterceptor interceptor拦截器并调用了拦截器的interceptor.preHandle方法。调用成功就返回。

image-20210419012326046

在applyPreHandle()中通过getInterceptors( ) [i] ;方法从拦截器数组当中获取对应的拦截器,并调用了拦截器的preHandle方法。

image-20210419012558289

调用postHandle方法:

在doDispatcher方法的applyPreHandle()下面:HandlerExecutionChain对象还调用了其applyPostHandle()方法

image-20210419013424983

在applyPostHandle()中通过getInterceptors() [i] ;方法从拦截器数组当中获取对应的拦截器,并调用了拦截器的postHandle方法。

image-20210419013555142

调用afterCompletion方法:

triggerAfterCompletion方法中得到了拦截器HandlerInterceptor并调用了拦截器的interceptor.afterCompletion方法

image-20210419014131628

image-20210419014819146

对源码总结

  1. springmvc 请求的流程图中,执行了 拦截器相关方法 interceptor.preHandler 等等
  2. 在处理 SpringMvc 请求时,使用到职责链模式还使用到适配器模式
  3. HandlerExecutionChain 主要负责的是请求拦截器的执行和请求处理,但是他本身不处理请求,只是将请求分配给链上注册处理器执行,这是职责链实现方式,减少职责链本身与处理逻辑之间的耦合,规范了处理流程
  4. HandlerExecutionChain 维护了 HandlerInterceptor 的集合, 可以向其中注册相应的拦截器.

26.5、职责链模式总结

主要优点如下:

  1. 降低了对象之间的耦合度。该模式使得一个对象无须知道到底是哪一个对象处理其请求以及链的结构,发送者和接收者也无须拥有对方的明确信息。
  2. 增强了系统的可扩展性。可以根据需要增加新的请求处理类,满足开闭原则
  3. 增强了给对象指派职责的灵活性。当工作流程发生变化,可以动态地改变链内的成员或者调动它们的次序,也可动态地新增或者删除责任
  4. 责任链简化了对象之间的连接每个对象只需保持一个指向其后继者的引用,不需保持其他所有处理者的引用,这避免了使用众多的 if 或者 if···else 语句
  5. 责任分担。每个类只需要处理自己该处理的工作,不该处理的传递给下一个对象完成,明确各类的责任范围,符合类的单一职责原则

主要缺点如下:

  1. 不能保证每个请求一定被处理。由于一个请求没有明确的接收者,所以不能保证它一定会被处理,该请求可能一直传到链的末端都得不到处理。所以最好形成闭环调用,保证请求一定可以得到调用。
  2. 对比较长的职责链,请求的处理可能涉及多个处理对象系统性能将受到一定影响
  3. 职责链建立的合理性要靠客户端来保证,增加了客户端的复杂性,可能会由于职责链的错误设置而导致系统出错,如可能会造成循环调用
  4. 可能不容易观察运行时的特征,有碍于除错。

模式的应用场景:

  1. 多个对象可以处理一个请求,但具体由哪个对象处理该请求在运行时自动确定。
  2. 动态指定一组对象处理请求,或添加新的处理者
  3. 需要在不明确指定请求处理者的情况下,向多个处理者中的一个提交请求

应用实例:

  1. JS 中的事件冒泡。
  2. JAVA WEB 中 Apache Tomcat 对 Encoding 的处理
  3. Struts2 的拦截器
  4. jsp servlet 的 Filter
  5. 责任链模式经常用在拦截、预处理请求等。

26.6、职责链模式扩展

职责链模式存在以下两种情况:

  1. 纯的职责链模式:一个请求必须被某一个处理者对象所接收,且一个具体处理者对某个请求的处理只能采用以下两种行为之一:自己处理(承担责任);把责任推给下家处理。
  2. 不纯的职责链模式:允许出现某一个具体处理者对象在承担了请求的一部分责任后又将剩余的责任传给下家的情况,且一个请求可以最终不被任何接收端对象所接收

26.7、进阶阅读

如果您想深入了解责任链模式,可猛击阅读以下文章。

26.8、相关设计模式

  • Composite模式

    Handler角色经常会使用Composite模式。

  • Command模式

    有时会使用Command模式向Handler角色发送请求。

26.9、职责链模式的注意事项与细节

  1. 将请求和处理分开,实现解耦,提高系统的灵活性
  2. 简化了对象,使对象不需要知道链的结构
  3. 性能会受到影响,特别是在链比较长的时候,因此需控制链中最大节点数量,一般通过在 Handler 中设置一个最大节点数量在 setNext()方法中判断是否已经超过阀值,超过则不允许该链建立,避免出现超长链无意识地破坏系统性能
  4. 调试不方便。采用了类似递归的方式,调试时逻辑可能比较复杂
  5. 最佳应用场景:有多个对象可以处理同一个请求时,比如:多级请求、请假/加薪等审批流程、Java Web 中 Tomcat对 Encoding 的处理、拦截器

27、创建型模式的特点和分类

创建型模式的主要关注点是“怎样创建对象?”,它的主要特点是“将对象的创建与使用分离”。这样可以降低系统的耦合度,使用者不需要关注对象的创建细节,对象的创建由相关的工厂来完成。就像我们去商场购买商品时,不需要知道商品是怎么生产出来一样,因为它们由专门的厂商生产。

创建型模式分为以下几种:

  • 单例(Singleton)模式:某个类只能生成一个实例,该类提供了一个全局访问点供外部获取该实例,其拓展是有限多例模式。
  • 原型(Prototype)模式:将一个对象作为原型,通过对其进行复制而克隆出多个和原型类似的新实例。
  • 工厂方法(FactoryMethod)模式:定义一个用于创建产品的接口,由子类决定生产什么产品。
  • 抽象工厂(AbstractFactory)模式:提供一个创建产品族的接口,其每个子类可以生产一系列相关的产品。
  • 建造者(Builder)模式:将一个复杂对象分解成多个相对简单的部分,然后根据不同需要分别创建它们,最后构建成该复杂对象。

以上 5 种创建型模式,除了工厂方法模式属于类创建型模式,其他的全部属于对象创建型模式

28、结构型模式概述(结构型模式的分类)

结构型模式描述如何将类或对象按某种布局组成更大的结构。它分为类结构型模式对象结构型模式前者采用继承机制来组织接口和类后者釆用组合或聚合来组合对象

由于组合关系或聚合关系比继承关系耦合度低,满足“合成复用原则”,所以对象结构型模式比类结构型模式具有更大的灵活性

结构型模式分为以下 7 种:

  1. 代理(Proxy)模式:为某对象提供一种代理以控制对该对象的访问。即客户端通过代理间接地访问该对象,从而限制、增强或修改该对象的一些特性。
  2. 适配器(Adapter)模式:将一个类的接口转换成客户希望的另外一个接口,使得原本由于接口不兼容而不能一起工作的那些类能一起工作。
  3. 桥接(Bridge)模式:将抽象与实现分离,使它们可以独立变化。它是用组合关系代替继承关系来实现的,从而降低了抽象和实现这两个可变维度的耦合度。
  4. 装饰(Decorator)模式:动态地给对象增加一些职责,即增加其额外的功能。
  5. 外观(Facade)模式:为多个复杂的子系统提供一个一致的接口,使这些子系统更加容易被访问。
  6. 享元(Flyweight)模式:运用共享技术来有效地支持大量细粒度对象的复用。
  7. 组合(Composite)模式:将对象组合成树状层次结构,使用户对单个对象和组合对象具有一致的访问性。

以上 7 种结构型模式,除了适配器模式分为类结构型模式和对象结构型模式两种其他的全部属于对象结构型模式

29、行为型模式概述(行为型模式的分类)

行为型模式用于描述程序在运行时复杂的流程控制,即描述多个类或对象之间怎样相互协作共同完成单个对象都无法单独完成的任务,它涉及算法与对象间职责的分配。

行为型模式分为类行为模式和对象行为模式前者采用继承机制来在类间分派行为后者采用组合或聚合在对象间分配行为

由于组合关系或聚合关系比继承关系耦合度低,满足“合成复用原则”,所以对象行为模式比类行为模式具有更大的灵活性。

行为型模式是 GoF 设计模式中最为庞大的一类,它包含以下 11 种模式。

  1. 模板方法(Template Method)模式:定义一个操作中的算法骨架,将算法的一些步骤延迟到子类中,使得子类在可以不改变该算法结构的情况下重定义该算法的某些特定步骤。
  2. 策略(Strategy)模式:定义了一系列算法,并将每个算法封装起来,使它们可以相互替换,且算法的改变不会影响使用算法的客户。
  3. 命令(Command)模式:将一个请求封装为一个对象,使发出请求的责任和执行请求的责任分割开。
  4. 职责链(Chain of Responsibility)模式:把请求从链中的一个对象传到下一个对象,直到请求被响应为止。通过这种方式去除对象之间的耦合。
  5. 状态(State)模式:允许一个对象在其内部状态发生改变时改变其行为能力。
  6. 观察者(Observer)模式:多个对象间存在一对多关系,当一个对象发生改变时,把这种改变通知给其他多个对象,从而影响其他对象的行为。
  7. 中介者(Mediator)模式:定义一个中介对象来简化原有对象之间的交互关系,降低系统中对象间的耦合度,使原有对象之间不必相互了解。
  8. 迭代器(Iterator)模式:提供一种方法来顺序访问聚合对象中的一系列数据,而不暴露聚合对象的内部表示。
  9. 访问者(Visitor)模式:在不改变集合元素的前提下,为一个集合中的每个元素提供多种访问方式,即每个元素有多个访问者对象访问。
  10. 备忘录(Memento)模式:在不破坏封装性的前提下,获取并保存一个对象的内部状态,以便以后恢复它。
  11. 解释器(Interpreter)模式:提供如何定义语言的文法,以及对语言句子的解释方法,即解释器。

以上 11 种行为型模式,除了模板方法模式和解释器模式是类行为型模式其他的全部属于对象行为型模式

30、一句话归纳设计模式

分类 设计模式 简述 一句话归纳 目的 生活案例
创建型设计模式 (简单来说就是用来创建对象的) 工厂模式(Factory Pattern) 不同条件下创建不同实例 产品标准化,生产更高效 封装创建细节 实体工厂
单例模式(Singleton Pattern) 保证一个类仅有一个实例,并且提供一个全局访问点 世上只有一个我 保证独一无二 CEO
原型模式(Prototype Pattern) 通过拷贝原型创建新的对象 拔一根猴毛,吹出千万个 高效创建对象 克隆
建造者模式(Builder Pattern) 用来创建复杂的复合对象 高配中配和低配,想选哪配就哪配 开放个性配置步骤 选配
结构型设计模式 (关注类和对象的组合) 代理模式(Proxy Pattern) 为其他对象提供一种代理以控制对这个对象的访问 没有资源没时间,得找别人来帮忙 增强职责 媒婆
外观模式(Facade Pattern) 对外提供一个统一的接口用来访问子系统 打开一扇门,通向全世界 统一访问入口 前台
装饰器模式(Decorator Pattern) 为对象添加新功能 他大舅他二舅都是他舅 灵活扩展、同宗同源 煎饼
享元模式(Flyweight Pattern) 使用对象池来减少重复对象的创建 优化资源配置,减少重复浪费 共享资源池 全国社保联网
组合模式(Composite Pattern) 将整体与局部(树形结构)进行递归组合,让客户端能够以一种的方式对其进行处理 人在一起叫团伙,心在一起叫团队 统一整体和个体 组织架构树
适配器模式(Adapter Pattern) 将原来不兼容的两个类融合在一起 万能充电器 兼容转换 电源适配
桥接模式(Bridge Pattern) 将两个能够独立变化的部分分离开来 约定优于配置 不允许用继承
行为型设计模式 (关注对象之间的通信) 模板模式(Template Pattern) 定义一套流程模板,根据需要实现模板中的操作 流程全部标准化,需要微调请覆盖 逻辑复用 把大象装进冰箱
策略模式(Strategy Pattern) 封装不同的算法,算法之间能互相替换 条条大道通罗马,具体哪条你来定 把选择权交给用户 选择支付方式
责任链模式(Chain of Responsibility Pattern) 拦截的类都实现统一接口,每个接收者都包含对下一个接收者的引用。将这些对象连接成一条链,并且沿着这条链传递请求,直到有对象处理它为止。 各人自扫门前雪,莫管他们瓦上霜 解耦处理逻辑 踢皮球
迭代器模式(Iterator Pattern) 提供一种方法顺序访问一个聚合对象中的各个元素 流水线上坐一天,每个包裹扫一遍 统一对集合的访问方式 逐个检票进站
命令模式(Command Pattern) 将请求封装成命令,并记录下来,能够撤销与重做 运筹帷幄之中,决胜千里之外 解耦请求和处理 遥控器
状态模式(State Pattern) 根据不同的状态做出不同的行为 状态驱动行为,行为决定状态 绑定状态和行为 订单状态跟踪
备忘录模式(Memento Pattern) 保存对象的状态,在需要时进行恢复 失足不成千古恨,想重来时就重来 备份、后悔机制 草稿箱
中介者模式(Mediator Pattern) 将对象之间的通信关联关系封装到一个中介类中单独处理,从而使其耦合松散 联系方式我给你,怎么搞定我不管 统一管理网状资源 朋友圈
解释器模式(Interpreter Pattern) 给定一个语言,定义它的语法表示,并定义一个解释器,这个解释器使用该标识来解释语言中的句子 我想说”方言“,一切解释权都归我 实现特定语法解析 摩斯密码
观察者模式(Observer Pattern) 状态发生改变时通知观察者,一对多的关系 到点就通知我 解耦观察者与被观察者 闹钟
访问者模式(Visitor Pattern) 稳定数据结构,定义新的操作行为 横看成岭侧成峰,远近高低各不同 解耦数据结构和数据操作 KPI考核
委派模式(Delegate Pattern) 允许对象组合实现与继承相同的代码重用,负责任务的调用和分配 这个需求很简单,怎么实现我不管 只对结果负责 授权委托书

31、其他设计模式(不属于23种)

  1. MVC 模式:Model-View-Controller(模型-视图-控制器) 模式。这种模式用于应用程序的分层开发。

    • Model(模型) - 模型代表一个存取数据的对象或 JAVA POJO。它也可以带有逻辑,在数据变化时更新控制器。

    • View(视图) - 视图代表模型包含的数据的可视化。

    • Controller(控制器) - 控制器作用于模型和视图上。它控制数据流向模型对象,并在数据变化时更新视图。它使视图与模型分离开。

      image-20210419025540539

  2. 业务代表模式(Business Delegate Pattern):用于对表示层和业务层解耦。它基本上是用来减少通信或对表示层代码中的业务层代码的远程查询功能。在业务层中我们有以下实体:

    • 客户端(Client) - 表示层代码可以是 JSP、servlet 或 UI java 代码。
    • 业务代表(Business Delegate) - 一个为客户端实体提供的入口类,它提供了对业务服务方法的访问。
    • 查询服务(LookUp Service) - 查找服务对象负责获取相关的业务实现,并提供业务对象对业务代表对象的访问。
    • 业务服务(Business Service) - 业务服务接口。实现了该业务服务的实体类,提供了实际的业务实现逻辑。
  3. 组合实体模式(Composite Entity Pattern):用在 EJB 持久化机制中。一个组合实体是一个 EJB 实体 bean,代表了对象的图解。当更新一个组合实体时,内部依赖对象 beans 会自动更新,因为它们是由 EJB 实体 bean 管理的。以下是组合实体 bean 的参与者:

    • 组合实体(Composite Entity) - 它是主要的实体 bean。它可以是粗粒的,或者可以包含一个粗粒度对象,用于持续生命周期。
    • 粗粒度对象(Coarse-Grained Object) - 该对象包含依赖对象。它有自己的生命周期,也能管理依赖对象的生命周期。
    • 依赖对象(Dependent Object) - 依赖对象是一个持续生命周期依赖于粗粒度对象的对象。
    • 策略(Strategies) - 策略表示如何实现组合实体。
  4. 数据访问对象模式(Data Access Object Pattern)或 DAO 模式:用于把低级的数据访问 API 或操作从高级的业务服务中分离出来。以下是数据访问对象模式的参与者:

    • 数据访问对象接口(Data Access Object Interface) - 该接口定义了在一个模型对象上要执行的标准操作。
    • 数据访问对象实体类(Data Access Object concrete class) - 该类实现了上述的接口。该类负责从数据源获取数据,数据源可以是数据库,也可以是 xml,或者是其他的存储机制。
    • 模型对象/数值对象(Model Object/Value Object) - 该对象是简单的 POJO,包含了 get/set 方法来存储通过使用 DAO 类检索到的数据。
  5. 前端控制器模式(Front Controller Pattern):是用来提供一个集中的请求处理机制,所有的请求都将由一个单一的处理程序处理。该处理程序可以做认证/授权/记录日志,或者跟踪请求,然后把请求传给相应的处理程序。以下是这种设计模式的实体:

    • 前端控制器(Front Controller) - 处理应用程序所有类型请求的单个处理程序,应用程序可以是基于 web 的应用程序,也可以是基于桌面的应用程序。
    • 调度器(Dispatcher) - 前端控制器可能使用一个调度器对象来调度请求到相应的具体处理程序。
    • 视图(View) - 视图是为请求而创建的对象。
  6. 拦截过滤器模式(Intercepting Filter Pattern):用于对应用程序的请求或响应做一些预处理/后处理。定义过滤器,并在把请求传给实际目标应用程序之前应用在请求上。过滤器可以做认证/授权/记录日志,或者跟踪请求,然后把请求传给相应的处理程序。以下是这种设计模式的实体:

    • 过滤器(Filter) - 过滤器在请求处理程序执行请求之前或之后,执行某些任务。
    • 过滤器链(Filter Chain) - 过滤器链带有多个过滤器,并在 Target 上按照定义的顺序执行这些过滤器。
    • Target - Target 对象是请求处理程序。
    • 过滤管理器(Filter Manager) - 过滤管理器管理过滤器和过滤器链。
    • 客户端(Client) - Client 是向 Target 对象发送请求的对象。
  7. 服务定位器模式(Service Locator Pattern):用在我们想使用 JNDI 查询定位各种服务的时候。考虑到为某个服务查找 JNDI 的代价很高,服务定位器模式充分利用了缓存技术。在首次请求某个服务时,服务定位器在 JNDI 中查找服务,并缓存该服务对象。当再次请求相同的服务时,服务定位器会在它的缓存中查找,这样可以在很大程度上提高应用程序的性能。以下是这种设计模式的实体:

    • 服务(Service) - 实际处理请求的服务。对这种服务的引用可以在 JNDI 服务器中查找到。
    • Context / 初始的 Context - JNDI Context 带有对要查找的服务的引用。
    • 服务定位器(Service Locator) - 服务定位器是通过 JNDI 查找和缓存服务来获取服务的单点接触。
    • 缓存(Cache) - 缓存存储服务的引用,以便复用它们。
    • 客户端(Client) - Client 是通过 ServiceLocator 调用服务的对象。
  8. 传输对象模式(Transfer Object Pattern):用于从客户端向服务器一次性传递带有多个属性的数据。传输对象也被称为数值对象。传输对象是一个具有 getter/setter 方法的简单的 POJO 类,它是可序列化的,所以它可以通过网络传输。它没有任何的行为。服务器端的业务类通常从数据库读取数据,然后填充 POJO,并把它发送到客户端或按值传递它。对于客户端,传输对象是只读的。客户端可以创建自己的传输对象,并把它传递给服务器,以便一次性更新数据库中的数值。以下是这种设计模式的实体:

    • 业务对象(Business Object) - 为传输对象填充数据的业务服务。
    • 传输对象(Transfer Object) - 简单的 POJO,只有设置/获取属性的方法。
    • 客户端(Client) - 客户端可以发送请求或者发送传输对象到业务对象。
  9. 在空对象模式(Null Object Pattern)中,一个空对象取代 NULL 对象实例的检查。Null 对象不是检查空值,而是反应一个不做任何动作的关系。这样的 Null 对象也可以在数据不可用的时候提供默认的行为。

    在空对象模式中,我们创建一个指定各种要执行的操作的抽象类和扩展该类的实体类,还创建一个未对该类做任何实现的空对象类,该空对象类将无缝地使用在需要检查空值的地方。

32、设计模式相关的网站

0、延伸

1、各种生成实例的方法的介绍

在Java中可以使用下面这些方法生成实例。

1、new

一般我们使用Java关键字new生成实例。

可以像下面这样生成Something类的实例并将其保存在obj变量中。

1
Something obj = new Something(); 

这时, 类名(此处的Something)会出现在代码中 。(即形成强耦合关系)

2、clone

我们也可以使用在Prototype模式中学习过的clone方法, 根据现有 的实例复制出一个新的实例。

我们可以像下面这样根据自身来复制出新的实例(不过不会调用构造函数)。

1
2
3
4
5
6
7
8
9
10
11
12
class Something { 
// ...
public Something createClone() {
Something obj = null;
try {
obj = (Something) clone();
} catch (CloneNotSupportedException e) {
e.printStackTrace();
}
return obj;
}
}

3、new Instance

使用java.lang.Class类的newinstance方法可以通过Class类的实例生成出Class类所表示的类0的实例(会调用无参构造函数)。

下面我们再看一个例子。 假设我们现在已经有了Something类的实例someobj, 通过下面的表达式可以生成另外一个 Something类的实例。

1
someobj.getClass().newinstance() 

实际上, 调用newinstance方法可能会导致抛出InstantiationException异常或是 illegalAccessException异常, 因此需要将其置千try…catch语句块中或是用throws关键字指定调用newinstance方法的方法可能会抛出的异常。

2、类名是束缚吗

话说回来, 在源程序中使用类名到底会有什么问题呢?在代码中出现要使用的类的名字不是理 所当然的吗?

这里, 让我们再回忆一下面向对象编程的目标之一,即“作为组件复用” 。

在代码中出现要使用的类的名字并非总是坏事。 不过 ,—旦在代码中出现要使用的类的名字, 就无法与该类分离开来, 也就无法实现复用

当然 , 可以通过替换源代码或是改变类名来解决这个问题。 但是, 此处说的“作为组件复用”中不包含替换源代码。 以Java来说, 重要的是当手边只有class文件(.class)时, 该类能否被复用。 即使没有Java文件(.java)也能复用该类才是关键。

当多个类必须紧密结合时, 代码中出现这些类的名字是没有问题的。但是如果那些需要被独立 出来作为组件复用的类的名字出现在代码中, 那就有问题了。

3、类的层次与抽象类

父类对子类的要求:

我们在理解类的层次时 , 通常是站在子类的角度进行思考的。也就是说 , 很容易着眼千以 下几点:

  • 在子类中可以使用父类中定义的方法
  • 可以通过在子类中增加方法以实现新的功能
  • 在子类中重写父类的方法可以改变程序的行为

现在 , 让我们稍微改变一下立场 , 站在父类的角度进行思考。在父类中, 我们声明了抽象方法、而将该方法的实现交给了子类。换言之 , 就程序而言,声明抽象方法是希望达到以下目的:

  • 期待子类去实现抽象方法
  • 要求子类去实现抽象方法

也就是说 ,子类具有实现在父类中所声明的抽象方法的责任。因此,这种责任被称为 “子类责任”(subclass responsibility)。

参考链接:

Java设计模式:23种设计模式全面解析

菜鸟驿站-设计模式

廖雪峰-设计模式

面向对象的七大设计原则

设计模式之七大基本原则

happens-before规则相关

详解Java中的clone方法 – 原型模式

java提高篇(五)—–使用序列化实现对象的拷贝

动态代理的几种实现方式及优缺点

有关于Copy-on-write代理:

Copy-on-write + Proxy = ?

相关书籍:

《图解设计模式》

[TOC]

JVM

上篇:内存与垃圾回收

1、JVM与Java体系结构

1、关于Java与JVM

Java:跨平台的语言

第01章_Java语言的跨平台性

JVM:跨语言的平台

第01章_JVM跨语言的平台

2、字节码

随着JDK7.0的正式发布,JVM平台上运行非Java语言编写的程序。

JVM根本不关心运行在其内部的程序到底是使用何种编程语言编写的,它只关心“字节码”文件。也就是说Java虚拟机拥有语言无关性,并不会单纯地与Java语言“终身绑定”,只要其他编程语言的编译结果满足并包含JVM的内部指令集、符号表以及其他的辅助信息,它就是一个有效的字节码文件,就能够被虚拟机所识别并装载运行。

我们平时说的java字节码,指的是用java语言编译成的字节码。准确的说任何能在jvm平台上执行的字节码格式都是一样的。所以应该统称为:jvm字节码

不同的编译器,可以编译出相同的字节码文件,字节码文件也可以在不同的JVM上运行。

Java虚拟机与Java语言并没有必然的联系,它只与特定的二进制文件格式——Class文件格式所关联, Class文件中包含了JVM指令集(或者称为字节码、Bytecodes)和符号表,还有一些其他辅助信息。

3、多语言混合编程

Java平台上的多语言混合编程正成为主流,通过特定领域的语言去解决特定领域的问题是当前软件开发应对日趋复杂的项目需求的一个方向。

例如:在一个项目之中,并行处理用Clojure语言编写,展示层使用JRuby/Rails,中间层则是Java,每个应用层都将使用不同的编程语言来完成,而且,接口对每一层的开发者都是透明的,各种语言之间的交互不存在任何困难,就像使用自己语言的原生API一样方便,因为它们最终都运行在一个虚拟机之上

对这些运行于Java虚拟机之上、Java之外的语言,来自系统级的、底层的支持正在迅速增强,以JSR-292为核心的一系列项目和功能改进(如DaVinci Machine项目、 Nashorn引擎、InvokeDynamic指令、java. lang. invoke包等),推动Java虚拟机从“Java语言的虚拟机”向“多语言虚拟机”的方向发展

4、虚拟机与JVM(java虚拟机)

1、什么是虚拟机

所谓虚拟机(Virtual Machine),就是一台虚拟的计算机,它是一款软件,用来执行一系列虚拟计算机指令。大体上,虚拟机可以分为系统虚拟机程序虚拟机

  • 大名鼎鼎的Visual Box, VMware就属于系统虚拟机,它们完全是对物理计算机的仿真,提供了一个可运行完整操作系统的软件平台。
  • 程序虚拟机的典型代表就是Java虚拟机,它专门为执行单个计算机程序而设计,在Java虛拟机中执行的指令我们称为Java字节码指令。

但无论是系统虚拟机还是程序虚拟机,在上面运行的软件都被限制于虚拟机提供的资源中。

2、JVM(java虚拟机)

JVM是一台执行Java字节码的虚拟计算机,它拥有独立的运行机制,其运行的Java字节码也未必由Java语言编译而成。

JVM平台的各种语言可以共享JVM带来的跨平台性、优秀的垃圾回器,以及可靠的即时编译器。

Java技术的核心就是Java虚拟机(JVM,Java Virtual Machine) ,因为所有的Java程序都运行在JVM内部。

3、JVM的作用

JVM就是二进制字节码的运行环境,负责装载字节码到其内部,解释/编译为对应平台上的机器指令执行。每一条Java指令Java虚拟机规范中都有详细定义,如怎么取操作数,怎么处理操作数,处理结果放在哪里。

4、JVM的特点
  • 一次编译,到处运行
  • 自动内存管理
  • 自动垃圾回收功能

带来的好处:

  • 从代码层面:降低了内存泄漏与内存溢出的风险
  • 从程序员层面:让程序员将自己对重心放在业务层面,不用再去手动地进行垃圾回收

带来的坏处:

  • 降低了对程序员对要求,当出现内存方面的问题时不能有效解决。
5、JVM的位置

第01章_JVM所处位置

JVM是运行在操作系统之上的,它与硬件没有直接的交互。

JVM模拟的是系统,在不同系统之上,构建了一个统一的系统平台。所以在安装JDK的时候要关注JDK是安装在哪个操作系统上,因为不同的操作系统上安装的JVM是不同的。

JDK的构成:下图来自JDK8官网。

image-20210420004314261

java程序想要正确运行需要经历两个过程:

  • java文件 –(编译)–> class字节码文件:使用的编译器为:前端编译器。典型:javac
  • class字节码文件–(解释)–> 二进制文件:运行。解释会用到:Java SE API 还有后端编译器(将class字节码文件编译为二进制文件)(后端编译器在JVM当中)

5、JVM的整体结构

HotSpotVM是目前市面上高性能虚拟机的代表作之一。

它采用解释器与即时编译器并存的架构

在今天,Java程序的运行性能早已脱胎换骨,已经达到了可以和C/C+ +程序一较高下的地步。

JVM的架构简图:程序的解释运行图

第02章_JVM架构-简图

其中将其分成三层:

  1. 上层:class字节码文件进入类装载器子系统(Class loader),将class字节码文件加载到内存当中,生成一个大的class对象。这个过程中会涉及到:

    1. 加载
    2. 链接(分成三步)
    3. 初始化
  2. 中层:

    • 方法区和栈是多线程共享
    • (Java栈(本地方法栈),本地方法栈,程序计数器是每个线程独有一份
  3. 下层:把字节码文件加载到内存以后,就可以进行解释运行了。执行引擎(Execution Engine),有三部分内容:

    1. 解释器(Interpreter):负责字节码文件的解释运行主要保证程序执行的响应时间

    2. 及时编译器(JIT Compiler):对于反复运行的热点代码进行提前的编译缓存。及时编译器又叫做后端编译器,用来将字节码文件字节码指令编译成操作系统能读懂的机器指令。(高级语言->机器语言)主要负责程序的执行性能

    3. 垃圾回收器(Garbage Collection,简称GC):实现垃圾的自动回收

      image-20210420011524768

6、Java代码的执行流程

image-20210420013453679

7、JVM的架构模型

Java编译器输入的指令流基本上分为两种:

  • 是一种基于栈的指令集架构
  • 另外一种指令集架构则是基于寄存器的指令集架构

两种架构的区别:

  • 基于栈式架构的特点:
    • 设计和实现更简单,适用于资源受限的系统
    • 避开了寄存器的分配难题:使用零地址指令方式分配;
    • 指令流中的指令大部分是零地址指令,其执行过程依赖于操作栈。指令集更小,编译器容易实现。
    • 不需要硬件支持,可移植性更好,更好实现跨平台
  • 基于寄存器架构的特点
    • 典型的应用是x86的二进制指令集:比如传统的PC以及Android的Davlik虛拟机;
    • 指令集架构则完全依赖硬件,可移植性差
    • 性能优秀执行更高效;
    • 花费更少的指令去完成一项操作
    • 在大部分情况下,基于寄存器架构的指令集往往都以一地址指令、二地址指令和三地址指令为主,而基于栈式架构的指令集却是以零地址指令为主。

总结:

  • 由于跨平台性的设计,Java的指令都是根据栈来设计的。不同平台CPU架构不同,所以不能设计为基于寄存器的。
    • 优点是跨平台, 指令集小,编译器容易实现
    • 缺点是性能下降,实现同样的功能需要更多的指令
  • 时至今日,尽管嵌入式平台已经不是Java程序的主流运行平台了(准确来说应该是HotSpotVM的宿主环境已经不局限于嵌入式平台了),那么为什么不将架构更换为基于寄存器的架构呢?
    1. 基于栈式的架构在设计与实现上比基于寄存器架构的设计要简单
    2. 基于栈式的架构在非资源受限的场景当中也是可以使用
    3. 主要还是因为栈式架构可以实现跨平台,而基于寄存器架构由于与硬件的耦合度太高,不能实现跨平台。

8、JVM的生命周期

1、虚拟机的启动

Java虛拟机的启动是通过引导类加载器(bootstrap class loader) 创建一个初始类(initial class) 来完成的,这个类是由虚拟机的具体实现指定的。

程序的执行:主方法(main)所在类加载到内存当中。而自定义的类的加载是通过系统类加载器(应用类加载器)实现的。由于父类的加载要早于子类,这就导致了java虚拟机的启动,创建一个初始类(initial class) ,然后调用初始类(initial class)当中的main方法,在这main方法当中使用其他的一些类来相继地加载后继的所有类。

类加载器分成:

  • 引导类加载器(负责超类的加载(如Object))
  • 扩展类加载器
  • 系统类加载器(负责自定义类的类加载)
  • 启动类加载器
  • 用户自定义的类加载器
2、虚拟机的执行
  • 一个运行中的Java虚拟机有着一个清晰的任务:执行Java程序
  • 程序开始执行时他才运行程序结束时他就停止
  • 执行一个所谓的Java程序的时候,真真正正在执行的是一个叫做Java虛拟机的进程
3、虚拟机的退出

有如下的几种情况:

  • 程序正常执行结束
  • 程序在执行过程中遇到了异常或错误而异常终止
  • 由于操作系统出现错误而导致Java虛拟机进程终止
  • 某线程调用Runtime类或System类的exit方法,或Runtime类的halt方法,并且Java安全管理器也允许这次exit或halt操作
  • 除此之外,JNI ( Java Native Interface) 规范描述了用JNI Invocation API来加载或卸载Java虚拟机时,Java虚拟机的退出情况。

9、JVM的发展历程

1、Sun Classic VM(SUN)

Sun公司发布的世界上第一款商用Java虚拟机,在JDK1.4时被完全淘汰。

这款虚拟机内部只提供解释器

如果使用JIT编译器,就需要进行外挂。但是一旦使用了JIT编译器,JIT就会接管虚拟机的执行系统。解释器就不再工作。解释器和编译器不能配合工作。(两者只能存一

  • 只使用解释器:当代码中重复的代码多(如循环等等)的时候执行效率低
  • 只使用JIT编译器:由于将字节码文件当中字节码指令编译成机器指令进行缓存也是需要时间的。这就导致了程序启动时间过长,加上占用的缓存空间有限。

现在hotspot内置了此虚拟机

2、Exact VM(SUN)

为了解决上一个虚拟机问题,JDK1.2时, sun提供了此虚拟机。

Exact Memory Management:准确式内存管理

  • 也可以叫Non-Conservative/ Accurate Memory Management
  • 虚拟机可以知道内存中某个位置的数据具体是什么类型。

具备现代高性能虚拟机的雏形

  • 热点探测
  • 编译器与解释器混合工作模式

只在Solaris平台短暂使用,其他平台上还是classic vm。最终被Hotspot虚拟机替代

3、Hotspot虚拟机(三大虚拟机之一)(Longview Technologies\SUN\Oracle)

JDK1.3时,HotSpot VM成为默认虚拟机

目前Hotspot占有绝对的市场地位

  • 不管是现在仍在广泛使用的JDK6,还是使用比例较多的JDK8中,默认的虚拟机都是
    HotSpot
  • Sun/Oracle JDK和OpenJDK的默认虚拟机
  • 因此本课程中默认介绍的虛拟机都是HotSpot,相关机制也主要是指HotSpot的GC机
    。(比如其他两个商用虚拟机都没有方法区的概念)

从服务器、桌面到移动端、嵌入式都有应用。

名称中的HotSpot指的就是它的热点代码探测技术。

  • 通过计数器找到最具编译价值代码,触发即时编译或栈上替换
  • 通过编译器与解释器协同工作在最优化的程序响应时间与最佳执行性能中取得平衡
4、JRockit(三大虚拟机之一)(BEA\Oracle)

专注于服务器端应用

  • 它可以不太关注程序启动速度,因此JRockit内部不包含解析器实现,全部代码都靠即时编译器编译后执行。

大量的行业基准测试显示,JRockit JVM是 世界上最快的JVM

  • 使用JRockit产品,客户已经体验到了显著的性能提高(一些超过了70号)和硬件成本的减少(达50号)

优势:全面的Java运行时解决方案组合

  • JRockit面向延迟敏感型应用的解决方案:JRockit Real Time提供以毫秒或微秒级的JVM响应时间,适合财务、军事指挥、电信网络的需要
  • MissionControl服务套件:它是一组以极低的开销来监控、管理和分析生产
    环境中的应用程序的工具。
    • JDK Mission Control(JMC)(Oracle公司整合)(主要是用来监控内存泄漏)
      • 内存泄漏监测器
      • JVM运行时分析器
      • 管理的控制台

2008年,BEA被Oracle收购。

Oracle表达了整合两大优秀虚拟机的工作,大致在JDK 8中完成。整合的方式是在HotSpot的基础上,移植JRockit的优秀特性。

5、J9(三大虚拟机之一)(IBM)

全称: IBM Technology for Java Virtual Machine, 简称IT4J,内部代号: J9

市场定位与HotSpot接近,服务器端、桌面应用、嵌入式等多用途VM,广泛用于IBM的各种Java产品。

目前,有影响力的三大商用服务器之一,也号称是世界上最快的Java虚拟机(在使用自己家产品时)。

2017年左右,IBM发布了开源J9 VM,命名为openJ9,交给Eclipse基金会管理,也称为Ecilpse OpenJ9

6、KVM和CDC/ CLDC Hotspot

Oracle在Java ME产品线上的两款虚拟机为: CDC/CLDC HotSpot Implementation VM

KVM (Kilobyte)是CLDC- HI早期产品

目前移动领域地位尴尬,智能手机被Android和iOS二分天下。

KVM简单、轻量、高度可移植,而向更低端的设备上还维持自己的一片市场

  • 智能控制器、传感器
  • 老人手机、经济欠发达地区的功能手机

所有的虚拟机的原则:一次编译,到处运行

7、Azul VM(Azul Systems)

前而三大“高性能Java虚拟机”使用在通用硬件平台

这里Azul VM和BEA Liquid VM是与特定硬件平台绑定、软硬件配合的专有虚拟机(高性能Java虚拟机中的战斗机)
Azul VM是Azul Systems 公司在HotSpot基础上进行大量改进,运行于Azul Systems 公司的专有硬件Vega系统上的Java虚拟机。

每个Azul VM实例都可以管理至少数十个CPU和数百GB内存的硬件资源,并提供在巨大内存范围内实现可控的GC时间的垃圾收集器、专有硬件优化的线
程调度等优秀特性。

2010年,Azul Systems 公司开始从硬件转向软件,发布了自己的Zing JVM,可以在通用x86平台上提供接近于Vega系统的特性。

8、Liquid VM(BEA)

高性能Java虚拟机中的战斗机

BEA公司开发的,直接运行在自家Hypervisor系统上

Liquid VM即是现在的JRockit VM(Virtual Edition),Liquid VM不需要成操作系统的支持,或者说它自己本身实现了一个专用操作系统的必要功能,如线程调度、文件系统、网络支持等

随着JRockit虚拟机终止开发,Liquid VM项目也停止了。

9、Apache Harmony(IBM和Inter)

Apache Harmony是IBM和Inter联合开发的开源JVM,受到同样开源的OpenJDK的压制。

虽然目前并没有Apache Harmony被大规模商用的案例,但是它的Java类库代码吸纳进了Android SDK。

10、Microsoft JVM(Microsoft)

微软为了在IE3浏览器中支持Java Applets,开发了Microsoft JVM。

只能在Window平台下运行。但确实是当时Windows下性能最好的Java VM。

1997年,Sun以侵犯商标、不正当竞争罪名指控微软成功,赔了sun很多钱。微软在windowsXP SP3中抹掉了其VM。现在windows上安装的jdk都是HotSpot。

11、TaobaoJVM(Alibaba)

Alibaba基于OpenJDK开发了自己的定制版本AlibabaJDK,简称AJDK。是整个阿里Java体系的基石。

基于OpenJDK HotSpot VM发布的国内第一个优化、深度定制且开源的高性能服务器版Java虚拟机

  • 创新GCIH (GC invisible heap)技术实现了off-heap,即将生命周期较长的java对象从heap中移到heap之外,并且GC不能管理GCIH内部的java对象,以此达到降低GC的回收频率和提升GC的回收效率的目的
  • GCIH中的对象还能够在多个java虚拟机进程中实现共享
  • 使用crc32指令顺序JVM intrinsic降低JNI 的调用开销
  • PMU hardware的java profiling tool 的诊断协助功能
  • 针对大数据场景的ZenGC

taobao NM应用在阿里产品上性能高,硬件严重依赖Intel的CPU,损失了兼容性,但提高 了性能

目前已经在淘宝、天猫上线,把Oracle官方版本全部替换了。

12、Dalvik VM(Google)

谷歌开发的,应用与Android系统,并在Android2.2中提供了JIT,发展迅猛。

Dalvik VM只能称作虚拟机,而不能称作”Java 虚拟机”,它没有遵循Java虚拟机规范

不能执行Java的Class文件

基于寄存器架构,不是jvm的栈架构

执行的是编译以后的dex(Dalvik Executable)文件。执行效率比较高。

  • 它执行的dex(Dalvik Executable)文件可以通过Class文件转化而来,使用Java语法编写的应用程序,可以直接使用大部分的Java API等。

Android5.0使用支持提前编译(Ahead of Time Compila,AOT)的ART VM替换了Dalvik VM

13、Graal VM

2018年4月,Oracle Labs公开了Graal VM,号称”Run Programs Faster Anywhere“。与1995年java的”write once,run anywhere”遥相呼应。

Graal VM在HotSpot VM基础上增强而成的跨语言全栈虚拟机可以作为”任何语言”的运行平台使用。语言包括:Java、Scala、Groovy、Kotlin;C、C++、JavaScript、Ruby、Python、R等。

支持不同语言中混合对方的接口和对象,支持这些语言使用已经编写好的本地库文件

工作原理:将这些语言的源代码或源代码编译后的中间格式,通过解释器转换成能被Graal VM接收的中间表示。Graal VM通过Truffle工具快速构建面向一种新语言的解释器。在运行时还能进行即时编译优化,获得比原生编译器更优秀的执行效率。

如果说HotSpot有一天真的被取代,Graal VM希望最大。但是Java的软件生态没有丝毫变化。

14、其他虚拟机
  • Java Card VM
  • Squawk VM
  • JavaInJava
  • Maxine VM
  • Jikes RVM
  • IKVM.NET
  • Jam VM
  • Cacao VM
  • Sable VM
  • Kaffe
  • Jelatine JVM
  • Nano VM
  • MRP
  • Moxie JVM

2、类加载器子系统(Class Loader)

1、内部结构概述

1、类加载器子系统作用
  • 类加载器子系统负责从文件系统或者网络中加载Class文件,class文件在文件开头有特定的文件标识(CA FE BA BE,是一个魔数(Coffee baby))。
  • ClassLoader只负责class文件的加载,至于它是否可以运行,则由Execution Engine决定。
  • 加载的类信息存放与一块称为方法区的内存空间。除了类信息以外,方法区还会存放运行时常量池信息,可能还包括字符串字面量和数字常量(这部分常量信息是Class文件中常量池部分的内存映射)常量池在运行过程中加载到内存里,叫运行时常量池。
2、类加载器ClassLoader角色

image-20210420231839277

  1. class file 存在于本地硬盘上,可以理解为设计师画在纸上的模板,而最终这个模板在执行的时候是要加载到JVM当中来,根据这个文件实例化出n个一模一样的实例。
  2. class file 加载到JVM中,被称为DNA元数据模板,放在方法区。
  3. 在.class文件-> JVM ->最终成为元数据模板,此过程就要一个运输工具(类装载器Class Loader) ,扮演一个快递员的角色。

2、类加载器与类的加载过程

1、JVM架构

JVM架构-简图:
第02章_JVM架构-简图

JVM架构-详细图解(中英文):

image-20210420161504521

2、类加载的过程

类加载的过程:

image-20210420232214018

程序加载过程:

image-20210420232257344

3、类加载的三个阶段
1、阶段一:Loading(加载)

加载:

  1. 通过一个类的全限定类名获取定义此类的二进制字节流
  2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
  3. 在内存中生成一个代表这个类的java. lang.Class对象, 作为方法区这个类的各种数据的访问入口。

补充:加载.class文件的方式:

  • 本地系统中直接加载
  • 通过网络获取,典型场景: Web Applet
  • zip压缩包中读取,成为日后jar、war格式的基础
  • 运行时计算生成,使用最多的是:动态代理技术(java.lang.reflect.proxy)
  • 其他文件生成,典型场景:JSP应用
  • 专有数据库中提取.class文件,比较少见
  • 加密文件中获取,典型的防Class文件被反编译的保护措施
2、阶段二:Linking(链接)

验证(Verify) :

  • 目的在于确保Class文件的字节流中包含信息符合当前虚拟机要求保证被加载类的正确性,不会危害虚拟机自身安全。

  • 主要包括四种验证

    • 文件格式验证
    • 元数据验证
    • 字节码验证
    • 符号引用验证
  • 例如:class文件在文件开头有特定的文件标识(CA FE BA BE,是一个魔数(Coffee baby))

    image-20210420234046557

准备(Prepare) :

  • 类变量分配内存并且设置该类变量的默认初始值,即零值。
  • 这里不包含用final修饰的static(即:常量),因为final在编译的时候就会分配了,准备阶段会显式初始化。
  • 这里不会为实例变量分配初始化类变量会分配在方法区中,而实例变量是会随着对象一起分配到Java堆中。

例如:

1
2
//prepare: a = 0 ---> Initialization : a = 1
private static int a = 1;

其中数据类型不同,默认初始值也就不同:

  • 整型(byte\short\int\long):0
  • 浮点型(float\double):0.0f
  • 字符型(char):\u0000
  • 布尔型(boolean):false
  • 引用类型:null

解析(Resolve) :

  • 常量池内的符号引用转换为直接引用的过程。
  • 事实上,解析操作往往会伴随着JVM在执行完初始化之后再执行。
  • 符号引用就是一组符号来描述所引用的目标。符号引用的字面量形式明确定义在《java虚拟机规范》的Class文件格式中。直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄
  • 解析动作主要针对接口字段类方法接口方法方法类型等。对应常量池中的CONSTANT_ Class_ info、CONSTANT Fieldref_ info、 CONSTANT Methodref_ info等。
3、阶段三:Initialization(初始化)

初始化:

  • 初始化阶段就是执行类构造器方法()的过程

  • 此方法不需定义,是javac编译器自动收集类中的所有类变量的赋值动作静态代码块中的语句合并而来。

    当不涉及到类变量的赋值动作与有关静态static(包括静态代码快、静态构造器,静态变量等等)的动作时,类构造器方法()不会创建。

    1
    2
    3
    4
    5
    6
    7
    public class ClinitTest {
    //任何一个类声明以后,内部至少存在一个类的构造器<init>
    private int a = 1;
    public static void main(String[] args) {
    int b = 2;
    }
    }

    image-20210421005041972

  • 构造器方法中指令按语句在源文件中出现的顺序执行

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    public class ClassInitTest {
    private static int num = 1;
    static{
    num = 2;
    // 赋值
    number = 20;
    System.out.println(num);
    //System.out.println(number);//报错:非法的前向引用。
    }
    // 声明
    private static int number = 10; //linking之prepare: number = 0 --> initial: 20 --> 10
    public static void main(String[] args) {
    System.out.println(ClassInitTest.num);//2
    System.out.println(ClassInitTest.number);//10
    }
    }
  • ()不同于类的构造器。(关联: 构造器是虚拟机视角下的 ())

    任何一个类声明以后,内部至少存在一个类的构造器(可以是自己声明的,也可以说系统默认提供的)

    image-20210421005632840

  • 若该类具有父类,JVM会保证子类的 ()执行前,父类的()已经执行完毕

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    public class ClinitTest1 {
    static class Father{
    public static int A = 1;
    static{
    A = 2;
    }
    }
    static class Son extends Father{
    public static int B = A;
    }
    public static void main(String[] args) {
    //加载Father类,其次加载Son类。
    System.out.println(Son.B);//2
    }
    }
  • 虚拟机必须保证一个类的()方法在多线程下被同步加锁

    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 DeadThreadTest {
    public static void main(String[] args) {
    Runnable r = () -> {
    System.out.println(Thread.currentThread().getName() + "开始");
    DeadThread dead = new DeadThread();
    System.out.println(Thread.currentThread().getName() + "结束");
    };
    Thread t1 = new Thread(r,"线程1");
    Thread t2 = new Thread(r,"线程2");
    t1.start();
    t2.start();
    }
    }
    class DeadThread{
    static{
    if(true){
    // 若一个类的<clinit>()方法在多线程下被同步加锁
    // 那么这里的打印代码就只会执行一次
    System.out.println(Thread.currentThread().getName() + "初始化当前类");
    while(true){

    }
    }
    }
    }

    执行结果:

    线程2开始
    线程1开始
    线程2初始化当前类

    一个类只需要往内存中加载一次就可以了,加载之后将其放在方法区(方法区在JDK7之前被称为永久代,JDK7之后被称为源空间),源空间其实使用的是本地内存,即类加载到内存之后是使用直接内存进行缓存。若以后使用到该类,那么使用的都是内存中已经存在的类本身。所以,虚拟机在加载类的时候只会调用一次方法

3、类加载器分类

1、类加载器分类

JVM支持两种类型的类加载器,分别为引导类加载器( Bootstrap ClassLoader)**和自定义类加载器(User-Defined ClassLoader)** 。

  • 引导类加载器( Bootstrap ClassLoader):

    • 本身不是使用java语言编写,而是使用C与C++进行编写
  • 自定义类加载器(User-Defined ClassLoader)

    • 使用java语言编写

    • 派生于抽象类ClassLoader。所以扩展类加载器(Extinction Class Loader)与系统类加载器(System Class Loader)都属于自定义类加载器

      其中sun.misc.Launcher它是一个java虚拟机的入口应用

      image-20210421012614177

从概念上来讲,自定义类加载器一般指的是程序中由开发人员自定义的一类类加载器,但是Java虚拟机规范却没有这么定义,而是将所有派生于抽象类ClassLoader的类加载器都划分为自定义类加载器

无论类加载器的类型如何划分,在程序中我们最常见的类加载器始终只有3个:

image-20210421011954294

这里的四者之间的关系是包含关系。不是上层下层,也不是子父类的继承关系。

对于引导类加载器(Bootstrap Class Loader)、扩展类加载器(Extension Class Loader)与系统类加载器(System Class Loader)三者的关系:

  • 系统类加载器(System Class Loader)的上层就是扩展类加载器(Extension Class Loader):对于用户自定义类来说:默认使用系统类加载器进行加载
  • 扩展类加载器(Extension Class Loader)的上层是引导类加载器(Bootstrap Class Loader)
  • 引导类加载器(Bootstrap Class Loader)是最高层的类加载器:Java的核心类库都是使用引导类加载器进行加载的。并且我们获取不到引导类加载器。因为引导类加载器并不是所以java语言进行编写的。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class ClassLoaderTest {
public static void main(String[] args) {
//获取系统类加载器
ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader();
System.out.println(systemClassLoader);//sun.misc.Launcher$AppClassLoader@18b4aac2
//获取其上层:扩展类加载器
ClassLoader extClassLoader = systemClassLoader.getParent();
System.out.println(extClassLoader);//sun.misc.Launcher$ExtClassLoader@1540e19d
//获取其上层:获取不到引导类加载器
ClassLoader bootstrapClassLoader = extClassLoader.getParent();
System.out.println(bootstrapClassLoader);//null
//对于用户自定义类来说:默认使用系统类加载器进行加载
ClassLoader classLoader = ClassLoaderTest.class.getClassLoader();
System.out.println(classLoader);//sun.misc.Launcher$AppClassLoader@18b4aac2
//String类使用引导类加载器进行加载的。---> Java的核心类库都是使用引导类加载器进行加载的。
ClassLoader classLoader1 = String.class.getClassLoader();
System.out.println(classLoader1);//null
}
}
2、启动类加载器(引导类加载器,Bootstrap ClassLoader )(虚拟机自带的加载器)
  • 这个类加载使用C/C++语言实现的,嵌套在JVM内部,是JVM的一部分。
  • 它用来加载Java的核心库(JAVA HOME/jre/lib/rt.jar、resources. jar或sun.boot.class.path路径下的内容) , 用于提供JVM自身需要的类
  • 不继承自java. lang .ClassLoader,没有父加载器
  • 加载扩展类和应用程序类加载器,并指定为他们的父类加载器
  • 出于安全考虑,Bootstrap启动类加载器只加载包名为java、javax、sun等开头的类
3、扩展类加载器(Extension ClassLoader)(虚拟机自带的加载器)
  • Java语言编写,由sun.misc.Launcher$ExtClassLoader实现

  • 派生于ClassLoader类

  • 父类加载器为启动类加载器

  • 从java. ext. dirs系统属性所指定的目录中加载类库,或从JDK的安装目录的jre/lib/ext子目录(扩展目录)下加载类库。

    如果用户创建的JAR放在此目录下,也会自动由扩展类加载器加载。

  • JDK9以后扩展类加载器改为平台类加载器

4、应用程序类加载器(系统类加载器,AppClassLoader)(虚拟机自带的加载器)
  • java语言编写,由sun.misc.Launcher$AppClassLoader实现
  • 派生于ClassLoader类
  • 父类加载器为扩展类加载器
  • 它负责加载环境变量classpath或系统属性java.class.path指定路径下的类库
  • 该类加载是程序中默认的类加载器,一般来说,Java应用的类都是由它来完成加载
  • 通过ClassLoader#getSystemClassLoader()方法可以获取到该类加载器
5、用户自定义类加载器
  • 在Java的日常应用程序开发中,类的加载几乎是由上述3种类加载器相互配合执行的,在必要时,我们还可以自定义类加载器,来定制类的加载方式。

  • 为什么要自定义类加载器:

    • 隔离加载类

      在某些框架中需要使用中间件,然而中间件与应用模块是隔离的。所以需要把类加载到不同的环境当中,确保应用当中引用到的框架的jar包与中间件的jar包是不冲突的(冲突:框架与中间件的某些类的类名一样,路径也相同)。

      所以需要做一个类的仲裁。一般主流的容器类框架都会自定义类加载器,让本身与不同中间件之间是隔离的,避免类的冲突。

    • 修改类加载的方式

      除了引导类加载器,其他类加载器都可以在需要的时候进行动态加载

    • 扩展加载源

      可以从数据库当中,或者电视机的机饼盒等等加载字节码文件的来源

    • 防止源码泄漏

      对字节码文件进行加密,防止被反编译篡改。

      加密之后运行代码时就需要进行解密,这时候就可以通过自定义加载器的方式进行解密

  • 用户自定义类加载器实现步骤:

    1. 开发人员可以通过继承抽象类java.lang.ClassLoader类的方式,实现自己的类加载器,以满足一些特殊的需求;

    2. 在JDK1.2之前,在自定义类加载器时,总会去继承ClassLoader类并重写loadClass()方法,从而实现自定义的类加载类,但是在JDK1.2之后已不再建议用户去覆盖loadClass()方法,而是建议把自定义的类加载逻辑写在findClass()方法中。(findClass()方法与defineClass()方法配合使用)

      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 CustomClassLoader extends ClassLoader {
      @Override
      protected Class<?> findClass(String name) throws ClassNotFoundException {
      try {
      byte[] result = getClassFromCustomPath(name);
      if(result == null){
      throw new FileNotFoundException();
      }else{
      return defineClass(name,result,0,result.length);
      }
      } catch (FileNotFoundException e) {
      e.printStackTrace();
      }
      throw new ClassNotFoundException(name);
      }
      private byte[] getClassFromCustomPath(String name){
      //从自定义路径中加载指定类:细节略
      //如果指定路径的字节码文件进行了加密,则需要在此方法中进行解密操作。
      return null;
      }
      public static void main(String[] args) {
      CustomClassLoader customClassLoader = new CustomClassLoader();
      try {
      Class<?> clazz = Class.forName("One",true,customClassLoader);
      Object obj = clazz.newInstance();
      System.out.println(obj.getClass().getClassLoader());
      } catch (Exception e) {
      e.printStackTrace();
      }
      }
      }
    3. 在编写自定义类加载器时,如果没有太过于复杂的需求,可以直接继承URLClassLoader类,这样就可以避免自己去编写findClass()方法及其获取字节码流的方式,使自定义类加载器编写更加简洁

4、Class Loader的使用说明

ClassLoader类,它是一个抽象类,其后所有的类加载器都继承自ClassLoader (不包括启动类加载器)

相关方法与描述:

方法名称 方法名称
getParent() 返回该类加载器的超类加载器
loadClass(String name) 加载名称为name的类,返回结果为java.lang.Class类的实例
findClass(String name) 查找名称为name的类,返回结果为java lang Class类的实例
findLoadedClass(String name) 查找名称为name的已经被加载过的类,返回结果为java lang Class类的实例
defineClass(String name, byte[] b, int off, int len) 把字节数组b中的内容转换为一个Java类,返回结果为java.lang.Class类的实例
resolveClass(Class<?> c) 连接指定的一个Java类

获取ClassLoader的途径:

  1. 获取当前类的ClassLoader

    1
    clazz.getClassLoader()
  2. 获取当前线程上下文的ClassLoader

    1
    Thread.currentThread().getContextClassLoader()
  3. 获取系统的ClassLoader

    1
    ClassLoader.getSystemClassLoader()
  4. 获取调用者的ClassLoader

    1
    DriverManager.getCallerClassLoader()

5、双亲委派机制

1、什么是双亲委派机制

Java虚拟机对class文件采用的是按需加载的方式,也就是说当需要使用该类时才会将它的class文件加载到内存生成class对象。而且加载某个类的class文件时,Java虚拟机采用的是双亲委派模式,即把请求交由父类处理,它是一种任务委派模式。

2、工作原理
  1. 如果一个类加载器收到类加载请求,它并不会自己去加载,而是把这个请求委托给父类的加载器去执行;
  2. 如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终将到达顶层的启动类加载器;
  3. 如果父类加载器可以完成类加载任务,就成功返回,倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载,这就是双亲委派模式。

image-20210421103744316

3、例子

image-20210421105124634

  • SPI接口是由引导类加载器加载的
  • 具体接口的实现类由于使用了第三方jdbc.jar,所以是由线程上下文类加载器加载的,而线程上下文类加载器的默认就是系统类加载器。(反向委派)
4、优势
  • 避免类的重复加载

  • 保护程序安全,防止核心API被随意篡改

    • 自定义类:java.lang.String
    • 自定义类:java.lang,ShkStart

在src目录下新建java.lang.String:

1
2
3
4
5
6
7
8
9
10
11
package java.lang;

public class String {
static{
System.out.println("我是自定义的String类的静态代码块");
}
//错误: 在类 java.lang.String 中找不到 main 方法
public static void main(String[] args) {
System.out.println("hello,String");
}
}

运行结果:

在类 java.lang.String 中找不到 main 方法

在src目录下新建java.lang并在该包下编写自定义的类:

1
2
3
4
5
public class ShkStart {
public static void main(String[] args) {
System.out.println("hello!");
}
}

运行结果:java.lang.SecurityException:Prohibited package name:java.lang

沙箱安全机制

自定义String类,但是在加载自定义String类的时候会率先随意引导类加载器加载,而引导类加载器在加载的过程中会先加载jdk自带的文件(rt.jar包中java\lang\String.class),报错信息说没有main方法就是因为加载的是rt.jar包中的String类。这样可以保证对java核心源代码的保护,这就是沙箱安全机制

6、其他

1、在JVM中表示两个class对象是否为同一个类存在两个必要条件
  • 类的完整类名必须一致,包括包名
  • 加载这个类的ClassLoader(指ClassLoader实例对象)必须相同

换句话说:在JVM中,既使这两个类对象(class对象)来源于同一个Class文件,被同一个虚拟机所加载,但只要加载它们的ClassLoader实例对象不同,那么这两个类对象也是不相等的。

2、对类加载器的引用

JVM必须知道一个类型是由启动类加载器加载的还是有用户类加载器加载的。如果一个类型是由用户类加载器加载的,那么JVM会将这个类加载器的一个引用作为类型信息的一部分保存在方法区中。当解析一个类型到另一个类型的引用的时候,JVM需要保证这两个类型的类加载器是相同的。

3、类的主动使用与被动使用

Java程序对类的使用方式分为:主动使用和被动使用

  • 主动使用,又分为七种情况:

    • 创建类的实例

    • 访问某个类或接口的静态变量,或者对该静态变量赋值

    • 调用类的静态方法

    • 反射(比如:Class.forName(“com.atguigu.Test”))

    • 初始化一个类的子类

    • Java虚拟机启动时被标明为启动类的类

    • JDK7开始通过动态语言支持:

      java.lang.invoke.MethodHandle实例的解析结果REF_getStatic、REF_putStatic、REF_invokeStatic句柄对应的类没有初始化,则初始化

  • 被动使用:除了上述七种情况,其他使用Java类的方式都被看作是对类的被动使用,都不会导致类的初始化


3、运行时数据区概述及线程

1、概述

1、经典的JVM内存布局

内存是非常重要的系统资源,是硬盘和CPU的中间仓库及桥梁,承载着操作系统和应用程序的实时运行。JVM内存布局规定了Java在运行过程中内存申请、分配、管理的策略,保证了JVM的高效稳定运行。

不同的JVM对于内存的划分方式和管理机制存在着部分差异。结合JVM虚拟机规范,来探讨一下经典的JVM内存布局:

image-20210421112837894

其中:方法区在JVM规范中是一个逻辑概念,由虚拟机自己进行具体实现。

  • HotSpot7和以前的版本用的是堆上的永久代实现方法区
  • HotSpot8之后使用元数据区实现方法区
  • 常量池在jdk8以后也被放到了堆中
2、进程同步与线程同步:

Java虚拟机定义了若干种程序运行期间会使用到的运行时数据区,其中有一些会随着虚拟机启动而创建,随着虚拟机退出而销毁(进程同步)**。另外一些则是与线程一一对应的,这些与线程对应的数据区域会随着线程开始和结束而创建和销毁**。

灰色的为单独线程私有的,红色的为多个线程共享的。即:

  • 每个线程:独立包括程序计数器、栈、本地栈。
  • 每个进程:线程间共享,堆、堆外内存(永久代或元空间、代码缓存)(问题:怎么保证线程安全)

image-20210421113146594

第03章_线程共享和私有的结构

​ 其中:(一个线程一份)

  • PC:程序计数器
  • VMS:虚拟机栈
  • NMS:本地方法栈
3、关于线程间共享的说明:

image-20210421114556021

每个JVM只有一个Runtime实例。即为运行时环境,相当于内存结构的中间的那个框框(运行时数据区(Runtime Data Area)):运行时环境。

2、线程

1、关于线程

线程是一个程序里的运行单元。JVM允许一个应用有多个线程并行的执行。

在Hotspot JVM里,每个线程都与操作系统的本地线程直接映射

  • 当一个Java线程准备好执行以后,此时一个操作系统的本地线程也同时创建。Java线程执行终止后,本地线程也会回收。

操作系统负责所有线程的安排调度到任何一个可用的CPU上。一旦本地线程初始化成功,它就会调用Java线程中的run()方法。

2、线程的分类
  • 普通线程
  • 守护线程
3、JVM的系统线程

如果你使用jconsole或者是任何一个调试工具,都能看到在后台有许多线程在运行。

这些后台线程不包括调用public static void main (String[])的main线程以及所有这个main线程自己创建的线程

这些主要的后台系统线程在HotSpot JVM里主要是以下几个:

  • 虚拟机线程

    这种线程的操作是需要JVM达到安全点才会出现。这些操作必须在不同的线程中发生的原因是他们都需要JVM达到安全点,这样堆才不会变化。这种线程的执行类型包括**”stop-the-world”的垃圾收集,线程栈收集,线程挂起以及偏向锁撤销**。

  • 周期任务线程

    这种线程是时间周期事件的体现(比如中断)**,他们一般用于周期性操作的调度执行**。

  • GC线程

    这种线程对在JVM里不同种类的垃圾收集行为提供了支持

  • 编译线程

    这种线程在运行时会将字节码编译成到本地代码

  • 信号调度线程

    这种线程接收信号并发送给JVM在它内部通过调用适当的方法进行处理


4、程序计数器(PC寄存器)

1、PC Register介绍

image-20210422000432697

JVM中的程序计数寄存器(Program Counter Register) 中,Register 的命名源于CPU的寄存器,寄存器存储指令相关的现场信息。CPU只有把数据装载到寄存器才能够运行

这里,并非是广义上所指的物理寄存器,或许将其翻译为PC计数器(或指令计数器)会更加贴切(也称为程序钩子),并且也不容易引起一些不必要的误会。JVM中的PC寄存器是对物理PC寄存器的一种抽象模拟

作用

PC寄存器用来存储指向下一条指令的地址,也即将要执行的指令代码。由执行引擎读取下一条指令

image-20210421201053065

  • 它是一块很小的内存空间,几乎可以忽略不记。也是运行速度最快的存储区域
  • 在JVM规范中,每个线程都有它自己的程序计数器,是线程私有的生命周期与线程的生命周期保持一致
  • 任何时间一个线程都只有一个方法在执行,也就是所谓的当前方法程序计数器会存储当前线程正在执行的Java方法的JVM指令地址;或者,如果是在执行native方法,则是未指定值(undefned)
  • 它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
  • 字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令
  • 不存在垃圾回收问题。
  • 它是唯一一个在Java虚拟机规范中没有规定任何OutOtMemoryError情况的区域
  • 即:无GC,无OOM

2、举例说明

image-20210422001738599

image-20210422001753431

针对5进行举例:(PC寄存器的意义或者作用)

  1. 指令地址5就是PC寄存器里面存放的值
  2. 执行引擎会在PC寄存器里面获取指令地址对应的操作指令(istore_2)
  3. 执行引擎得到操作指令后会执行下面两个操作:
    1. 操作虚拟机栈(如局部变量表、操作数栈等等),实现数据的存取操作以及一些求和运算等等。
    2. 把字节码指令翻译为机器指令
  4. 机器指令可以让对应的CPU做运算

3、两个常见问题

  1. 使用PC寄存器存储字节码指令地址有什么用呢?为什么使用PC寄存器记录当前线程的执行地址呢?

    因为CPU需要不停的切换各个进程,这时候切换回来以后,就得知道从哪开始继续执行。

    JVM的字节码解释器就需要通过改变PC寄存器的值来明确下一条应该执行什么样的字节码指令。

    第04章_PC寄存器

  2. PC寄存器为什么会被设定为线程私有?

    我们都知道所谓的多线程在一个特定的时间段内只会执行其中某一个线程的方法,CPU会不停地做任务切换,这样必然导致经常中断或恢复,如何保证分毫无差呢?

    为了能够准确地记录各个线程正在执行的当前字节码指令地址,最好的办法自然是为每一个线程都分配一个PC寄存器,这样一来各个线程之间便可以进行独立计算,从而不会出现相互干扰的情况

    由于CPU时间片轮限制,众多线程在并发执行过程中,任何一个确定的时刻,一个处理器或者多核处理器中的一个内核,只会执行某个线程中的一条指令

    这样必然导致经常中断或恢复,如何保证分毫无差呢?

    每个线程在创建后,都会产生自己的程序计数器和栈帧,程序计数器在各个线程之间互不影响

4、CPU时间片

CPU时间片即CPU分配给各个程序的时间,每个线程被分配一个时间段,称作它的时间片

宏观上:我们可以同时打开多个应用程序,每个程序并行不悖,同时运行

但在微观上:由于只有一个CPU,一次只能处理程序要求的一部分,如何处理公平?一种方法就是引入时间片,每个程序轮流执行

并行与并发:

  • 并行就是两个核同时算
  • 并发就是一个核算两个一人一段。

image-20210421202826450


5、虚拟机栈

1、虚拟机栈概述

1、虚拟机栈出现的背景

由于跨平台性的设计,Java的指令都是根据栈来设计的。不同平台CPU架构不同,所以不能设计为基于寄存器的。

优点

  • 跨平台
  • 指令集小
  • 编译器容易实现

缺点

  • 性能下降
  • 实现同样的功能需要更多的指令
2、内存中的栈与堆

栈:

  • 栈是运行时的单位
  • 栈解决程序的运行问题,即程序如何执行,或者说如何处理数据

堆:

  • 堆是存储的单位
  • 堆解决的是数据存储的问题,即数据怎么放、放在哪里。

image-20210422005102514

3、虚拟机栈基本内容

Java虚拟机栈是什么?

Java虚拟机栈(Java Virtual Machine Stack) ,早期也叫Java栈。每个线程在创建时都会创建一个虚拟机栈,其内部保存一个个的栈帧(Stack Frame) (栈存储数据的基本单位),对应着一次次的Java方法调用

  • 是线程私有的

image-20210422012357234

Java虚拟机栈生命周期:

  • 生命周期和线程一致

Java虚拟机栈作用:

  • 主管Java程序的运行,它保存方法的**局部变量(8种基本数据类型、对象的引用)**、部分结果,并参与方法的调用和返回。

栈的特点(优点):

  • 栈是一种快速有效的分配存储方式,访问速度仅次于程序计数器。

  • JVM直接对Java栈的操作只有两个:

    • 每个方法执行,伴随着进栈(入栈、压栈)
    • 执行结束后的出栈工作
  • 对于栈来说不存在垃圾回收问题,但是存在内存溢出的情况。

  • 即:无GC,有OOM

    image-20210422013552022

栈中可能存在的异常:

Java虚拟机规范允许Java栈的大小是动态的或者是固定不变的。

  • 如果采用固定大小的Java虚拟机栈,那每一个线程的Java虚拟机栈容量可以在线程创建的时候独立选定。如果线程请求分配的栈容量超过Java虚拟机栈允许的最大容量,Java虚拟机将会抛出一个StackOverflowError异常

    image-20210422013326303

  • 如果Java虚拟机栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的虚拟机栈,那Java虚拟机将会抛出一个OutOfMemoryError异常

设置栈内存大小:

我们可以使用参数-Xss选项来设置线程的最大栈空间,**栈的大小直接决定了函数调用的最大可达深度**。

image-20210422014055436

设置步骤:

  1. 在IDEA点开Run

    image-20210422014347776

  2. 在Run下面有选项Edit Configurations...

    image-20210422014445720

  3. 在当前类下的VM options中进行参数设置。(参数参考上-Xss的设置)程序调优的一种方案:参数调优

    image-20210422014706947

参数设置后的测试方法:

image-20210422013442292

2、栈的存储单位

1、栈中存储什么?
  • 每个线程都有自己的栈,栈中的数据都是以栈帧(Stack Frame)为基本存储单位的格式存在。
  • 在这个线程上正在执行的每个方法都各自对应一个栈帧(Stack Frame)
  • 栈帧是一个内存区块,是一个数据集维系着方法执行过程中的各种数据信息
2、栈的运行原理
  • JVM直接对Java栈的操作只有两个,就是对栈帧的压栈和出栈遵循“先进后出”/“后进先出”原则

  • 在一条活动线程中,一个时间点上,只会有一个活动的栈帧。即只有当前正在执行的方法的栈帧(栈项栈帧)是有效的,这个栈帧被称为当前栈帧(Current Frame) ,与当前栈帧相对应的方法就是当前方法(Current Method)**,定义这个方法的类就是当前类(Current Class)** 。

  • 执行引擎运行的所有字节码指令只针对当前栈帧进行操作

  • 如果在该方法中调用了其他方法,对应的新的栈帧会被创建出来,放在栈的顶端,成为新的当前帧。

    第05章_方法与栈桢

  • 不同线程中所包含的栈帧是不允许存在相互引用的,即不可能在一个栈帧之中引用另外一个线程的栈帧

  • 如果当前方法调用了其他方法,方法返回之际,当前栈帧会传回此方法的执行结果给前一个栈帧,接着,虚拟机会丢弃当前栈帧,使得前一个栈帧重新成为当前栈帧。

  • Java方法有两种返回函数的方式:

    • 正常的函数返回,使用return指令
    • 抛出异常

    不管使用哪种方式,都会导致栈帧被弹出

3、栈帧的内部结构

每个栈帧中存储着:

  • 局部变量表(Local Variables:LV)
  • 操作数栈(Operand Stack) (或表达式栈)
  • 动态链接(Dynamic Linking) ( 或指向运行时常量池的方法引用)
  • 方法返回地址(Return Address) (或方法正常退出或者异常退出的定义)
  • 一些附加信息

第05章_栈桢内部结构

image-20210422173049395

3、局部变量表( Local Variables)

1、局部变量表的概述
  • 局部变量表也被称之为局部变量数组本地变量表
  • 定义为一个数字数组,主要用于存储方法参数和定义在方法体内的局部变量,这些数据类型包括各类基本数据类型对象引用(reference) ,以及
    return Address类型。(因为各类数据类型都可以通过数字来表示)
  • 由于局部变量表是建立在线程的栈上,是线程的私有数据,因此不存在数据安全问题
  • 局部变量表所需的容量大小是在编译期确定下来的,并保存在方法的Code属性的maximum local variables数据项中。 在方法运行期间是不会改变局部变量表的大小的。
  • 方法嵌套调用的次数由栈的大小决定。一般来说,栈越大,方法嵌套调用次数越多。对一个函数而言,它的参数和局部变量越多,使得局部变量表膨胀,它的栈帧就越大,以满足方法调用所需传递的信息增大的需求。进而函数调用就会占用更多的栈空间,导致其嵌套调用次数就会减少。
  • 局部变量表中的变量只在当前方法调用中有效。在方法执行时,虚拟机通过使用局部变量表完成参数值到参数变量列表的传递过程。当方法调用结束后,随着方法栈帧的销毁,局部变量表也会随之销毁
2、对程序编译后的字节码文件的查看方法

程序代码:(以main方法为例,其他方法类似)

1
2
3
4
5
6
7
8
public class LocalVariablesTest {
public static void main(String[] args) {
LocalVariablesTest test = new LocalVariablesTest();
int num = 10;
test.test1();
}
public void test1() {/* ... */}
}

image-20210422180804881

image-20210422181213595

image-20210422181755767

image-20210422182334511

image-20210422193112649

image-20210422193542032

3、关于Slot的理解
  • 参数值的存放总是在局部变量数组的index0开始,到数组长度-1的索引结束。

  • 局部变量表的最基本的存储单元是Slot (变量槽)

  • 局部变量:表中存放编译期可知的各种基本数据类型(8种),引用类型(reference),returnAddress类型的变量。

  • 在局部变量表里,32位以内的类型只占用一个slot (包括returnAddress类型),64位的类型(long和double)占用两个slot

    • byte、short 、char、在存储前被转换为int,boolean 也被转换为int,0表示false,非0表示true,float、引用数据类型的引用
    • long和double则占据两个Slot。
  • JVM会为局部变量表中的每一个Slot都分配一个访问索引,通过这个索引即可成功访问到局部变量表中指定的基本变量值

  • 当一个实例方法被调用的时候,它的方法参数和方法体内部定义的局部变量将会按照顺序被复制到局部变量表中的每一个Slot上

  • 如果需要访问局部变量表中一个64bit的基本变量值时,只需要使用前一个索引即可。(比如:访问long或double类型变量)

    image-20210422200200175

  • 如果当前帧是由构造方法或者实例方法创建的,那么对象引用this将会存放在index为0的slot处,其余的参数按照参数表顺序继续排列。

  • 在静态(static)方法中不能引用this:因为this变量不存在于静态方法的局部变量表中!!

    image-20210422201450476

4、Slot的重复利用

栈帧中的局部变量表中的槽位是可以重用的,如果一个局部变量过了其作用域,那么在其作用域之后申明的新的局部变量就很有可能会复用过期局部变量的槽位,从而达到节省资源的目的

变量c使用之前已经销毁的变量b占据的slot的位置:

image-20210422202145932

5、静态变量与局部变量的对比

变量的分类

  • 按照数据类型分:
    • 基本数据类型
    • 引用数据类型
  • 按照在类中声明的位置分:
    • 成员变量:在使用前,都经历过默认初始化赋值
      • 类变量(static修饰): linking的prepare阶段:给类变量默认赋值 —> initial阶段:给类变量显式赋值即静态代码块赋值
      • 实例变量(没有static修饰):随着对象的创建,会在堆空间中分配实例变量空间,并进行默认赋值
    • 局部变量:在使用前,必须要进行显式赋值的!否则,编译不通过。

静态变量(类变量)与局部变量对比

  • 参数表分配完毕之后,再根据方法体内定义的变量的顺序和作用域分配

  • 我们知道类变量表有两次初始化的机会,第一次是在“准备阶段”,执行系统初始化,对类变量设置零值,另一次则是在“初始化”阶段,赋予程序员在代码中定义的初始值

  • 和类变量初始化不同的是,局部变量表**不存在系统初始化的过程,这意味着一旦定义了局部变量则必须人为的初始化,否则无法使用

    1
    2
    3
    4
    5
    public void test(){
    int i;
    // 报错:没有赋值不能够使用。
    System.out.println(i).
    }
6、补充说明:
  • 在栈帧中,与性能调优关系最为密切的部分就是局部变量表。在方法执行时,虚拟机使用局部变量表完成方法的传递。
  • 局部变量表中的变量也是重要的垃圾回收根节点(根搜索算法\可达性分析),只要被局部变量表中直接或间接引用的对象都不会被回收

4、操作数栈(Operand Stack)

  • 每一个独立的栈帧中除了包含局部变量表以外,还包含一个后进先出(Last- In-First-Out:LIFO)的操作数栈,也可以称之为表达式栈(Expression Stack)

  • 操作数栈,在方法执行过程中,根据字节码指令,往栈中写入数据或提取数据,即入栈(push) /出栈(pop)。

    • 某些字节码指令将值压入操作数栈,其余的字节码指令将操作数取出栈。使用它们后再把结果压入栈。

    • 比如:执行复制、交换、求和等操作

      image-20210422204325546

  • 操作数栈,主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间

  • 操作数栈就是JVM执行引擎的一个工作区,当一个方法刚开始执行的时候,一个新的栈帧也会随之被创建出来,这个方法的操作数栈是空的

  • 每一个操作数栈都会拥有一个明确的栈深度用于存储数值,其所需的最大深度在编译期就定义好了,在方法运行期间是不会改变操作数栈的大小的。保存在方法的Code属性中,为max_ stack的值。

  • 栈中的任何一个元素都是可以任意的Java数据类型。

    • 32bit的类型占用一个栈单位深度
    • 64bit的类型占用两个栈单位深度
  • 操作数栈并非采用访问索引的方式来进行数据访问的,而是只能通过标准的入栈(push)和出栈(pop) 操作来完成一次数据访问。

  • 如果被调用的方法带有返回值的话,其返回值将会被压入当前栈帧的操作数栈中,并更新PC寄存器中下一条需要执行的字节码指令。

    image-20210422211053093

  • 操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,这由编译器在编译器期间进行验证,同时在类加载过程中的类检验阶段的数据流分析阶段要再次验证。

  • 另外,我们说Java虚拟机的解释引擎是基于栈的执行引擎,其中的栈指的就是操作数栈

5、代码跟踪

1、对操作数栈相关知识点的代码分析

image-20210422210316458

image-20210422210545629

image-20210422210620080

image-20210422210641231

image-20210422210655579

2、面试问题:i++ VS ++i
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
public void add(){
//第1类问题:
int i1 = 10;
i1++;

int i2 = 10;
++i2;

//第2类问题:
int i3 = 10;
int i4 = i3++;

int i5 = 10;
int i6 = ++i5;

//第3类问题:
int i7 = 10;
i7 = i7++;

int i8 = 10;
i8 = ++i8;

//第4类问题:
int i9 = 10;
int i10 = i9++ + ++i9;
}

第一类问题:两种没什么区别,都是实现变量的加1操作

6、栈顶缓存技术

前面提过,基于栈式架构的虛拟机所使用的零地址指令更加紧凑,但完成一项操作的时候必然需要使用更多的入栈和出栈指令,这同时也就意味着将需要更多的指令分派( instruction dispatch) 次数和内存读/写次数。

由于操作数是存储在内存中的,因此频繁地执行内存读/写操作必然会影响执行速度。为了解决这个问题,HotSpot JVM的设计者们提出了栈顶缓存(ToS,Top-of-stack Cashing) 技术将栈顶元素全部缓存在物理CPU的寄存器中,以此降低对内存的读/写次数,提升执行引擎的执行效率

7、动态链接(Dynamic Linking)(指向运行时常量池的方法引用)(帧数据区之一)

  • 每一个栈帧内部都包含一个指向**运行时常量池该栈帧所属方法的引用。包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接(Dynamic Linking)** 。比如: invokedynamic指令

  • 在Java源文件被编译到字节码文件中时,所有的变量和方法引用都作为符号引用(Symbolic Reference) 保存在class文件的常量池里。比如:描述一个方法调用了另外的其他方法时,就是通过常量池中指向方法的符号引用来表示的,那么动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用。(多态也是通过动态链接实现的)

  • 类被加载之后,Class文件中的常量池会被复制一份到方法区,成为“运行时常量池”

    image-20210422213416813

  • 为什么需要常量池呢? 常量池的作用,就是为了提供一些符号和常量,便于指令的识别。

8、方法的调用:解析与分派

1、静态链接与动态链接

在JVM中,将符号引用转换为调用方法的直接引用与方法的绑定机制相关。

  • 静态链接:
    当一个字节码文件被装载进JVM内部时,如果被调用的目标方法在编译期可知,且运行期保持不变时。这种情况下将调用方法的符号引用转换为直接引用的过程称之为静态链接。

  • 动态链接:

    如果被调用的方法在编译期无法被确定下来,也就是说,只能够在程序运行期将调用方法的符号引用转换为直接引用,由于这种引用转换过程具备动态性,因此也就被称之为动态链接。

2、方法的绑定机制

对应的方法的绑定机制为:早期绑定(Early Binding) 和晚期绑定(Late Binding)绑定是一个字段、方法或者类在符号引用被替换为直接引用的过程,这仅仅发生一次

  • 早期绑定:

    早期绑定就是指被调用的目标方法如果在编译期可知,且运行期保持不变时,即可将这个方法与所属的类型进行绑定,这样一来,由于明确了被调用的目标方法究竟是哪一个,因此也就可以使用静态链接的方式将符号引用转换为直接引用。

  • 晚期绑定:

    如果被调用的方法在编译期无法被确定下来,只能够在程序运行期根据实际的类型绑定相关的方法,这种绑定方式也就被称之为晚期绑定。

3、虚方法与非虚方法

随着高级语言的横空出世,类似于Java一样的基于面向对象的编程语言如今越来越多,尽管这类编程语言在语法风格上存在一定的差别,但是它们彼此之间始终保持着一个共性,那就是都支持封装、继承和多态等面向对象特性,既然这一类的编程语言具备多态特性,那么自然也就具备早期绑定和晚期绑定两种绑定方式

Java中任何一个普通的方法其实都具备虚函数的特征,它们相当于C++语言中的虚函数(C++中则需要使用关键字virtual来显式定义)。如果在Java程序中不希望某个方法拥有虛函数的特征时,则可以使用关键字final来标记这个方法。(通过final修饰不能重写)

子类对象的多态性的使用前提(多态 <–> 虚方法)

  1. 类的继承关系
  2. 方法的重写

非虚方法:

  • 如果方法在编译期就确定了具体的调用版本,这个版本在运行时是不可变的。这样的方法称为非虚方法
  • 静态方法、私有方法、final方法、实例构造器、父类方法(因为java没有多继承所以调用父类的方法是非虚方法:super.xxx()可以找到调用的是哪个方法)都是非虚方法
  • 其他方法称为虚方法。

虚拟机中提供了以下几条方法调用指令

  • 普通调用指令:
    1. invokestatic:调用静态方法,解析阶段确定唯一方法版本
    2. invokespecial:调用方法、 私有及父类方法,解析阶段确定唯一方法版本
    3. invokevirtual:调用所有虛方法
    4. invokeinterface:调用接口方法
  • 动态调用指令:
    1. invokedynamic: 动态解析出需要调用的方法,然后执行

前四条指令固化在虚拟机内部,方法的调用执行不可人为干预,而invokedynamic指令则支持由用户确定方法版本。其中invokestatic指令和invokespecial指令调用的方法称为非虛方法,其余的(final修饰的除外)称为虚方法

4、关于invokedynamic
  • JVM字节码指令集一直比较稳定,一直到Java7中才增加了一个invokedynamic指令,这是Java为了实现「动态类型语言」支持而做的一种改进
  • 但是在Java7中并没有提供直接生成invokedynamic指令的方法,需要借助ASM这种底层字节码工具来产生invokedynamic指令。直到Java8的Lambda表达式的出现,invokedynamic指令的生成,在Java中才有了直接的生成方式
  • Java7中增加的动态语言类型支持的本质是对Java虚拟机规范的修改,而不是对Java语言规则的修改,这一块相对来讲比较复杂,增加了虚拟机中的方法调用,最直接的受益者就是运行在Java平台的动态语言的编译器
  • 动态类型语言和静态类型语言:
    • 动态类型语言和静态类型语言两者的区别就在于对类型的检查是在编译期还是在运行期,满足前者(编译期)就是静态类型语言,反之(运行期)是动态类型语言。
    • 说的再直白一点就是,静态类型语言是判断变量自身的类型信息动态类型语言是判断变量值的类型信息,变量没有类型信息,变量值才有类型信息,这是动态语言的一个重要特征。
5、方法重写的本质

Java语言中方法重写的本质

  1. 找到操作数栈顶的第一个元素所执行的对象的实际类型,记作C
  2. 如果在类型C中找到与常量中的描述符合简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;如果不通过,则返回java.lang.illegalAccessError异常
  3. 否则,按照继承关系从下往上依次对C的各个父类进行第2步的搜索和验证过程
  4. 如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常

IllegalAccessError介绍

程序试图访问或修改一个属性或调用一个方法,这个属性或方法,你没有权限访问。一般的,这个会引起编译器异常。这个错误如果发生在运行时,就说明一个类发生了不兼容的改变。

6、虚方法表
  • 在面向对象的编程中,会很频繁的使用到动态分派,如果在每次动态分派的过程中都要重新在类的方法元数据中搜索合适的目标的话就可能影响到执行效率。因此,为了提高性能JVM采用在类的方法区建立一个虚方法表(virtual method table) (非虚方法不会出现在表中)来实现。使用索引表来代替查找

  • 每个类中都有一个虚方法表,表中存放着各个方法的实际入口。

  • 那么虚方法表什么时候被创建?

    虚方法表会在类加载的链接阶段(解析Resolve)被创建并开始初始化,类的变量初始值准备完成之后,JVM会把该类的方法表也初始化完毕。

每个类有一个虚方法表,使用某方法时直接在这表里查找该方法在哪个类里了。

没有虚方法表的情况下,需要在当前类查找,找不到再去父类查找。

image-20210422225120851

image-20210422225328056

image-20210422225533000

image-20210422225747189

9、方法返回地址(Return Address)

  • 存放调用该方法的pc寄存器的值
  • 一个方法的结束,有两种方式:
    • 正常执行完成
    • 出现未处理的异常,非正常退出
  • 无论通过哪种方式退出,在方法退出后都返回到该方法被调用的位置。方法正常退出时,调用者的pc计数器的值作为返回地址,即调用该方法的指令的下一条指令的地址。而通过异常退出的,返回地址是要通过异常表来确定,栈帧中一般不会保存这部分信息

当一个方法开始执行后,只有两种方式可以退出这个方法:

  1. 执行引擎遇到任意一个方法返回的字节码指令(return) ,会有返回值传递给上层的方法调用者,简称正常完成出口;

    • 一个方法在正常调用完成之后究竟需要使用哪一个返回指令还需要根据方法返回值的实际数据类型而定。
    • 在字节码指令中,返回指令包含ireturn (当返回值是boolean、 byte、char、short和int类型时使用)、lreturnfreturndreturn以及areturn,另外还有一个return指令供声明为void的方法、实例初始化方法、类和接口的初始化方法使用。
  2. 在方法执行的过程中遇到了异常(Exception),并且这个异常没有在方法内进行处理,也就是只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出。简称异常完成出口

    方法执行过程中抛出异常时的异常处理,存储在一个异常处理表,方便在发生异常的时候找到处理异常的代码。

    image-20210423004042185

本质上,方法的退出就是当前栈帧出栈的过程。此时,需要恢复上层方法的局部变量表、操作数栈、将返回值压入调用者栈帧的操作数栈、设置PC寄存器值等,让调用者方法继续执行下去。

正常完成出口和异常完成出口的区别在于:通过异常完成出口退出的不会给他的上层调用者产生任何的返回值。

10、一些附加信息

栈帧中还允许携带与Java虚拟机实现相关的一些附加信息。例如,对程序调试提供支持的信息

11、栈的相关面试题

  • 举例栈溢出的情况? (StackOverflowError)

    • 通过-Xss设置栈的大小OOM
  • 调整栈大小,就能保证不出现溢出吗?

    • 不能
  • 分配的栈内存越大越好吗?

    • 不是!
  • 垃圾回收是否会涉及到虚拟机栈?

    • 不会的!
  • 方法中定义的局部变量是否线程安全?

    • 具体问题具体分析
    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
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    /**
    * 面试题:
    * 方法中定义的局部变量是否线程安全?具体情况具体分析
    *
    * 何为线程安全?
    * 如果只有一个线程才可以操作此数据,则必是线程安全的。
    * 如果有多个线程操作此数据,则此数据是共享数据。如果不考虑同步机制的话,会存在线程安全问题。
    * @author shkstart
    * @create 2020 下午 7:48
    */
    public class StringBuilderTest {
    //s1的声明方式是线程安全的
    public static void method1(){
    //StringBuilder:线程不安全
    StringBuilder s1 = new StringBuilder();
    s1.append("a");
    s1.append("b");
    //...
    }
    //sBuilder的操作过程:是线程不安全的
    public static void method2(StringBuilder sBuilder){
    sBuilder.append("a");
    sBuilder.append("b");
    //...
    }
    //s1的操作:是线程不安全的
    public static StringBuilder method3(){
    StringBuilder s1 = new StringBuilder();
    s1.append("a");
    s1.append("b");
    return s1;
    }
    //s1的操作:是线程安全的
    public static String method4(){
    StringBuilder s1 = new StringBuilder();
    s1.append("a");
    s1.append("b");
    return s1.toString();
    }
    public static void main(String[] args) {
    StringBuilder s = new StringBuilder();
    new Thread(() -> {
    s.append("a");
    s.append("b");
    }).start();

    method2(s);
    }
    }

12、关于运行时数据区的五大部分的OOM与GC问题

运行时数据区 GC OOM
程序计数器(PC寄存器) × ×
虚拟机栈 ×
本地方法栈 ×
方法区

6、本地方法接口

image-20210423090739894

1、什么是本地方法?

简单地讲,一个Native Method就是一个Java调用非Java代码的接口。一个Native Method是这样一个Java方法:该方法的实现由非Java语言实现,比如C。这个特征并非Java所特有,很多其它的编程语言都有这一机制,比如在C++中,你可以用extern “C”告知C++编译器去调用一个C的函数。

“A native method is a Java method whose implementation is provided by non-java code.”

在定义一个native method时, 并不提供实现体(有些像定义一个Java interface),因为其实现体是由非java语言在外面实现的。

本地接口的作用是融合不同的编程语言为Java所用,它的初衷是融合C/C++程序

标识符native可以与所以其它的java标识符连用,但是abstract除外

1
2
3
4
5
6
7
8
9
10
public class IHaveNatives {
// native 与 public、void
public native void Native1(int x);
// native 与 static
public native static long Native2();
// native 与 private、synchronized
private native synchronized float Native3(Object o);
// native 与 默认
native void Native4(int[] ary) throws Exception;
}

2、为什么要使用Native Method?

Java使用起来非常方便,然而有些层次的任务用Java实现起来不容易,或者我们对程序的效率很在意时,问题就来了。

  • 与Java环境外交互:
    有时Java应用需要与Java外面的环境交互,这是本地方法存在的主要原因。你可以想想Java需要与一些底层系统,如操作系统或某些硬件交换信息时的情况。本地方法正是这样一种交流机制:它为我们提供了一个非常简洁的接口,而且我们无需去了解Java应用之外的繁琐的细节。
  • 与操作系统交互:
    JVM支持着Java语言本身和运行时库,它是Java程序赖以生存的平台,它由一个解释器(解释字节码)和一些连接到本地代码的库组成。然而不管怎样,它毕竟不是一个完整的系统,它经常依赖于一些底层系统的支持。这些底层系统常常是强大的操作系统。通过使用本地方法我们得以用Java实现了jre的与底层系统的交互甚至JVM的一些部分就是用c写的。还有,如果我们要使用一些Java语言本身没有提供封装的操作系统的特性时,我们也需要使用本地方法。
  • Sun’s Java
    Sun的解释器是用C实现的,这使得它能像一些普通的C一样与外部交互。jre大部分是用Java实现的,它也通过一些本地方法与外界交互。例如:类java.lang.Thread的setPriority()方法是用Java实现的,但是它实现调用的是该类里的本地方法setPriority0()。这个本地方法是用C实现的,并被植入JVM内部,在windows 95的平台上,这个本地方法最终将调用win32 SetPriority() API。 这是一个本地方法的具体实现由JVM直接提供,更多的情况是本地方法由外部的动态链接库(external dynamic link library)提供,然后被JVM调用。

3、现状

目前该方法使用的越来越少了,除非是与硬件有关的应用,比如通过Java程序驱动打印机或者Java系统管理生产设备,在企业级应用中已经比较少见。因为现在的异构领域间的通信很发达,比如可以使用Socket通信,也可以使用Web Service等等,不多做介绍。


7、本地方法栈(Native Method Stack)

  • Java虚拟机栈用于管理Java方法的调用,而本地方法栈用于管理本地方法的调用

  • 本地方法栈,也是线程私有的。

  • 允许被实现成固定或者是可动态扩展的内存大小。(在内存溢出方面是相同的)

    • 如果线程请求分配的栈容量超过本地方法栈允许的最大容量,Java虚拟机将会抛出一个StackoverflowError异常。
    • 如果本地方法栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的本地方法栈,那么Java虛拟机将会抛出一个OutOfMemoryError异常。
  • 本地方法是使用C语言实现的。

  • 它的具体做法是Native Method Stack中登记native方法,在Execution Engine执行时加载本地方法库。

    image-20210423094103986

  • 当某个线程调用一个本地方法时,它就进入了一个全新的并且不再受虚拟机限制的世界。它和虚拟机拥有同样的权限

    • 本地方法可以通过本地方法接口访问虛拟机内部的运行时数据区
    • 它甚至可以直接使用本地处理器中的寄存器
    • 直接从本地内存的堆中分配任意数量的内存
  • 并不是所有的JVM都支持本地方法。因为Java虚拟机规范并没有明确要求本地方法栈的使用语言、具体实现方式、数据结构等。如果JVM产品不打算支持native方法,也可以无需实现本地方法栈。(这里存在本地方法栈只是对于HotSpot JVM而言)

  • 在HotSpot JVM中,直接将本地方法栈和虚拟机栈合二为一


8、堆(Heap)

1、堆的核心概述

  • 一个JVM实例只存在一个堆内存,堆也是Java内存管理的核心区域

  • Java堆区在JVM启动的时候即被创建,其空间大小也就确定了。是JVM管理的最大一块内存空间

    • 堆内存的大小是可以调节的。
  • 《Java虚拟机规范》规定,堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的。

    这里涉及到对象实例在堆内存中的存储方式,物理内存连续的采用指针碰撞不连续的采用动态链表

  • 所有的线程共享Java堆,在这里还可以划分线程私有的缓冲区(Thread Local Al location Buffer, TLAB) 。

  • 《Java虛拟机规范》中对Java堆的描述是:所有的对象实例以及数组都应当在运行时分配在堆上。

    • The heap is the run-time data area from which memory for all class instances and arrays is allocated.
    • 我要说的是:“几乎”所有的对象实例都在这里分配内存。——从实际使用角度看的。
  • 数组和对象可能永远不会存储在栈上,因为栈帧中保存引用这个引用指向对象或者数组在堆中的位置

  • 在方法结束后,堆中的对象不会马上被移除,仅仅在垃圾收集的时候才会被移除。

  • 堆,是GC ( Garbage Collection, 垃圾收集器)执行垃圾回收的重点区域

    image-20210423155400525

  • 内存细分:

    现代垃圾收集器大部分都基于分代收集理论设计,堆空间细分为:

    • Java 7及之前堆内存逻辑上分为三部分:新生区 + 养老区 + 永久区
      • Young Generation Space 新生区 Young/New
        • 又被划分 为Eden区和Survivor区
      • Tenure generation space 养老区 Old/Tenure
      • Permanent Space 永久区 Perm
    • Java 8及之后堆内存逻辑上分为三部分:新生区 + 养老区 + 元空间
      • Young Generation Space 新生区 Young/New
        • 又被划分为Eden区和Survivor区
      • Tenure generation space 养老区 Old/ Tenure
      • Meta Space 元空间 Meta

    约定:

    • 新生区 <=> 新生代 <=> 年轻代
    • 养老区 <=> 老年区 <=> 老年代
    • 永久区 <=> 永久代

    第08章_堆和方法区图

    堆空间的内部结构:

    image-20210423160441505

2、设置堆内存大小与OOM

  • Java堆区用于存储Java对象实例,那么堆的大小在JVM启动时就已经设定好了,大家可以通过选项”-Xmx“和”``-Xms`”来进行设置。
    • -Xms“用于表示堆区(年轻代+老年代)的起始内存,等价于-XX: InitialHeapSize
      • -X是JVM的运行参数
      • ms是memory start
    • -Xmx”则用于表示堆区(年轻代+老年代)的最大内存,等价于-XX :MaxHeapSize
  • 查看设置的参数:
    1. jps / jstat -gc 进程id
    2. -XX:+PrintGCDetails
  • 一旦堆区中的内存大小超过“-Xmx”所指定的最大内存时,将会抛出OutOfMemoryError异常。
  • 开发中建议将初始堆内存和最大的堆内存设置成相同的值。即将-Xms和-Xmx两个参数配置相同的值,其目的是为了能够在java垃圾回收机制清理完堆区后不需要重新分隔计算堆区的大小,从而提高性能,避免系统压力
  • 默认情况下,初始内存大小:物理电脑内存大小 / 64;最大内存大小:物理电脑内存大小 / 4
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
32
33
34
35
/**
* 1. 设置堆空间大小的参数
* -Xms 用来设置堆空间(年轻代+老年代)的初始内存大小
* -X 是jvm的运行参数
* ms 是memory start
* -Xmx 用来设置堆空间(年轻代+老年代)的最大内存大小
*
* 2. 默认堆空间的大小
* 初始内存大小:物理电脑内存大小 / 64
* 最大内存大小:物理电脑内存大小 / 4
* 3. 手动设置:-Xms600m -Xmx600m
* 开发中建议将初始堆内存和最大的堆内存设置成相同的值。
*
* 4. 查看设置的参数:方式一: jps / jstat -gc 进程id
* 方式二:-XX:+PrintGCDetails
*/
public class HeapSpaceInitial {
public static void main(String[] args) {
//返回Java虚拟机中的堆内存总量
long initialMemory = Runtime.getRuntime().totalMemory() / 1024 / 1024;
//返回Java虚拟机试图使用的最大堆内存量
long maxMemory = Runtime.getRuntime().maxMemory() / 1024 / 1024;
// 手动设置600M之后:575M
System.out.println("-Xms : " + initialMemory + "M");
System.out.println("-Xmx : " + maxMemory + "M");

// System.out.println("系统内存大小为:" + initialMemory * 64.0 / 1024 + "G");
// System.out.println("系统内存大小为:" + maxMemory * 4.0 / 1024 + "G");
try {
Thread.sleep(1000000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}

image-20210423165450302

结论:新生代的存储总量为一个伊甸园区加一个幸存者区(1或2,不能并存),所以虽然设置了600M,但是实际上为575M。

关于异常(Exception)与错误(Error)

Java 语言规范将派生于 Error 类或 RuntimeException 类的所有异常称为非检查型(unchecked)异常,所有其他的异常称为检查型(checked)异常

3、年轻代与老年代

  • 存储在JVM中的Java对象可以被划分为两类:

    • 一类是生命周期较短的瞬时对象,这类对象的创建和消亡都非常迅速
    • 另外一类对象的生命周期却非常长,在某些极端的情况下还能够与JVM的生命周期保持一致。
  • Java堆区进一步细分的话,可以划分为年轻代(YoungGen)和老年代(OldGen)

  • 其中年轻代又可以划分为Eden空间Survivor0空间Survivor1空间(有时也叫做from区to区)

    第08章_堆空间细节

  • 相关的参数设置与默认值(在开发中一般不会改变)

    • -XX:NewRatio:配置新生代与老年代在堆结构的占比。
      • 默认-XX:NewRatio=2,表示新生代占1,老年代占2,新生代占整个堆的1/3
      • 可以修改- XX: NewRatio=4,表示新生代占1,老年代占4,新生代占整个堆的1/5
    • -XX:SurvivorRatio:设置新生代中Eden区与Survivor区的比例。默认值是8
      • 在HotSpot中, Eden空间和另外两个Survivor空间缺省所占的比例是8:1:1。
      • 当然开发人员可以通过选项“-XX:SurvivorRatio””调整这个空间比例。比如-XX: SurvivorRatio=8
      • 但在实际测试当中发现其实JVM分配Eden空间和两个Survivor空间的时候比例为6:1:1,即默认值为6而不是8,但是java官方表示的默认值就是8。如果要将比例修改为8:1:1,需要设置:
        • -XX:-UseAdaptiveSizePolicy :关闭自适应的内存分配策略 (暂时用不到)(但发现没用,还是6:1:1)
        • -XX: SurvivorRatio=8,手动设置SurvivorRatio为8。(这还算什么默认值。。。)
    • -Xmn:设置新生代的空间的大小。(一般不设置)
      • 但一般新生代与老年代的空间分配是-XX:NewRatio设置的,默认为2。
      • 当设置了-Xmn的时候,就与-XX:NewRatio冲突了
      • 这时候JVM使用的是-Xmn设置的值(JDK8下测试)
  • 几乎所有的Java对象都是在Eden区被new出来的

  • 绝大部分的Java对象的销毁都在新生代进行了。

    • IBM公司的专门研究表明,新生代中80%的对象都是“朝生夕死”的。
  • 可以使用选项”-Xmn”设置新生代最大内存大小。

    • 这个参数一般使用默认值就可以了。

    image-20210423173418189

4、图解大小分配过程

为新对象分配内存是一件非常严谨和复杂的任务,JVM的设计者们不仅需要考虑内存如何分配、在哪里分配等问题,并且由于内存分配算法与内存回收算法密切相关,所以还需要考虑GC执行完内存回收后是否会在内存空间中停生内存碎片。

  1. new的对象先放伊甸园区。此区有大小限制。

  2. 伊甸园的空间填满时,程序又需要创建对象,JVM的垃圾回收器**(Minor GC)将对伊甸园区进行垃圾回收**,将伊甸园区中的不再被其他对象所引用的对象进行销毁。再加载新的对象放到伊甸园区。

  3. 然后将伊甸园中的剩余对象移动到幸存者0区(to区)。

  4. 如果再次触发垃圾回收,此时JVM的垃圾回收器(Minor GC)对伊甸园区进行垃圾回收(主动:伊甸园区满即触发),会放到幸存者1区(to区)。以及同时上次幸存下来的放到幸存者0区(from区)的,如果没有回收,也会放到幸存者1区(to区)。(被动:就算幸存者1区(to区)满了也不触发Minor GC垃圾回收器)

  5. 如果再次经历垃圾回收,此时会重新放回幸存者0区,接着再去幸存者1区。

  6. 啥时候能去养老区呢?可以设置次数。默认是15

    可以设置参数: -XX:MaxTenuringThreshold=进行设置。

  7. 在养老区,相对悠闲。当养老区内存不足时,再次触发GC: Major GC, 进行养老区的内存清理。

  8. 若养老区执行了Major GC之后发现依然无法进行对象的保存,就会产生OOM异常:
    java. lang.OutOfMemoryError:Java heap space

    image-20210423181733858

总结:

  • 针对幸存者s0,s1区的总结:复制之后有交换,谁空谁是to。
  • 关于垃圾回收:频繁在新生区收集,很少在养老区收集几乎不在永久区/元空间收集

对象分配流程图(含特殊情况):

image-20210423191247043

VisualVM状态图分析:

image-20210423192127797

常用的调优工具:

  • JDK命令行
  • Eclipse : Memory Analyzer Tool
  • Jconsole
  • VisualVM
  • Jprofiler
  • Java Flight Recorder
  • GCViewer
  • GC Easy

5、minor GC、Major GC、Full GC

1、GC的分类
  • JVM在进行GC时,并非每次都对上面三个内存(新生代、老年代、方法区)区域一起回收的,大部分时候回收的都是指新生代。
  • 针对HotSpot VM的实现,它里面的GC按照回收区域又分为两大种类型:一种是部分收集(Partial GC),一种是整堆收集(Full GC)
    • 部分收集:不是完整收集整个Java堆的垃圾收集。其中又分为:
      • 新生代收集(Minor GC / Young GC):只是新生代(Eden/S0、S1)的垃圾收集
      • 老年代收集(MajorGC/Old GC):只是老年代的垃圾收集。
        • 目前,只有CMS GC会有单独收集老年代的行为。
        • 注意,很多时候Major GC会和Fu1l GC混淆使用,需要具体分辨是老年代回收还是整堆回收
      • 混合收集(Mixed GC):收集整个新生代以及部分老年代的垃圾收集。
        • 目前,只有G1 GC会有这种行为
    • 整堆收集(Full GC):收集整个java堆和方法区的垃圾收集。
2、最简单的分代式GC策略的触发条件
  • 年轻代GC(Minor GC)触发机制:

    • 当年轻代空间不足时, 就会触发Minor GC, 这里的年轻代满指的是Eden代满,Survivor满不会引发GC。( 每次Minor GC会清理年轻代的内存。)
    • 因为Java对象大多都具备朝生夕灭的特性,所以MinorGC非常频繁,一般回收速度也比较快。这一定义既清晰又易于理解。
    • Minor GC会引发STW,暂停其它用户的线程,等垃圾回收结束,用户线程才恢复运行。

    image-20210423195836487

  • 老年代GC (Major GC/Full GC)触发机制:

    • 指发生在老年代的GC,对象从老年代消失时,我们说“Major GC”或“Full GC”发生了。
    • 出现了Major GC,经常会伴随至少一次的Minor GC (但非绝对的,在Parallel Scavenge收集器的收集策略里就有直接进行Major GC的策略选择过程)。
      • 也就是在老年代空间不足时,会先尝试触发Minor GC。如果之后空间还不足,则触发Major GC
    • MajorGC的速度一般会比MinorGC慢10倍以上,STW的时间更长。
    • 如果Major GC后,内存还不足,就报OOM了。
    • Major GC的速度一般会比Minor GC慢10倍以上。
  • Full GC触发机制:(后面细讲)

    • 触发Full GC执行的情况有如下五种:
      1. 调用System.gc()时,系统建议执行Full GC,但是不必然执行
      2. 老年代空间不足
      3. 方法区空间不足
      4. 通过Minor GC后进入老年代的平均大小大于老年代的可用内存
      5. 由Eden区、survivor space0 (From Space)区向survivor space1 (To Space)区复制时,对象大小大于To Space可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小
    • 说明: full gc是开发或调优中尽量要避免的。这样暂时时间会短一些

6、堆空间分代思想

为什么需要把java堆分代?不分代就不能正常工作了吗?

经研究,不同对象的生命周期不同。70%-99%的对象是临时对象。

  • 新生代:有Eden、两块大小相同的Survivor (又称为from/to, s0/s1)构成,to总为空。
  • 老年代:存放新生代中经历多次GC仍然存活的对象。

其实不分代完全可以,分代的唯一理由就是优化GC性能。如果没有分代,那所有的对象都在一块,就如同把一个学校的人都关在一个教室。GC的时候要找到哪些对象没用,这样就会对堆的所有区域进行扫描。

而很多对象都是朝生夕死的,如果分代的话,把新创建的对象放到某一地方, 当GC的时候先把这块存储“朝生夕死”对象的区域进行回收,这样就会腾出很大的空间出来。

7、内存分配策略(或对象提升(Promotion)规则)

如果对象在Eden出生并经过第一次MinorGC后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,并将对 象年龄设为1。对象在Survivor区中每熬过一次MinorGC ,年龄就增加1 岁,当它的年龄增加到一定程度(默认为15岁,其实每个JVM、 每个GC都有所不同)时,就会被晋升到老年代中。

对象晋升老年代的年龄阈值,可以通过选项-XX: MaxTenuringThreshold来设置。

针对不同年龄段的对象分配原则如下所示:

  • 优先分配到Eden
  • 大对象直接分配到老年代
    • 尽量避免程序中出现过多的大对象(特别是朝生夕死的大对象),防止过多的STW
  • 长期存活(15岁)的对象分配到老年代
  • 动态对象年龄判断
    • 如果Survivor 区中相同年龄的所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄。
  • 空间分配担保
    • -XX: HandlePromotionFailure

8、为对象分配内存:TLAB

1、为什么有TLAB ( Thread Local Allocation Buffer ) ?
  • 堆区是线程共享区域,任何线程都可以访问到堆区中的共享数据
  • 由于对象实例的创建在JVM中非常频繁,因此在并发环境下从堆区中划分内存空间是线程不安全
  • 为避免多个线程操作同一地址,需要使用加锁等机制,进而影响分配速度。
2、什么是TLAB?
  • 从内存模型而不是垃圾收集的角度,对Eden区域继续进行划分,JVM为每个线程分配了一个私有缓存区域,它包含在Eden空间内。
  • 多线程同时分配内存时,使用TLAB可以避免一系列的非线程安全问题,同时还能够提升内存分配的吞吐量,因此我们可以将这种内存分配方式称之为快速分配策略
  • 据我所知所有OpenJDK衍生出来的JVM都提供了TLAB的设计。

第08章_TLAB

3、TLAB相关说明
  • 尽管不是所有的对象实例都能够在TLAB中成功分配内存,但JVM确实是将TLAB作为内存分配的首选
  • 在程序中,开发人员可以通过选项“-XX:UseTLAB”设置是否开启TLAB空间。默认为开启
  • 默认情况下,TLAB空间的内存非常小,**仅占有整个Eden空间的1%**,当然我们可以通过选项“-XX:TLABWasteTargetPercent”设置TLAB空间所占用Eden空间的百分比大小。
  • 一旦对象在TLAB空间分配内存失败时,JVM就会尝试着通过使用加锁机制确保数据操作的原子性,从而直接在Eden空间中分配内存

第08章_对象分配过程

9、小结堆空间的常用的JVM参数设置

官网说明

  • -XX: +PrintFlagsInitial:查看所有的参数的默认初始值
  • -XX: +PrintFlagsFinal:查看所有的参数的最终值(可能会存在修改,不再是初始值)
  • -Xms:初始堆空间内存 (默认为物理内存的1/64)
  • -Xmx:最大堆空间内存(默认为物理内存的1/4)
  • -Xmn:设置新生代的大小。(初始值及最大值)
  • -XX:NewRatio: 配置新生代与老年代在堆结构的占比
  • -XX: SurvivorRatio:设置新生代中Eden和S0/S1空间的比例
  • -XX: MaxTenuringThreshold:设置新生代垃圾的最大年龄
  • -XX: +PrintGCDetails:输出详细的GC处理日志
    • 打印gc简要信息:
    • -XX: +PrintGC
    • -verbose: gc
  • XX: HandlePromotionFailure:是否设置空间分配担保

在发生MinorGC之前,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象的总空间

  • 如果大于,则此次Minor GC是安全的
  • 如果小于,则虚拟机会查看-XX: HandlePromotionFailure设置值是否允许担保失败。
    • 如果HandlePromotionFailure=true,那么会继续检查老年代最大可用连续空间是否大于历次晋升到老年代的对象的平均大小
      • 如果大于,则尝试进行一次Minor GC,但这次Minor GC依然是有风险的;
      • 如果小于,则改为进行一次Full GC。
    • 如果HandlePromotionFailure=false,则改为进行一次Full GC。

在JDK6 Update24(JDK7)之后,HandlePromotionFailure参数不会再影响到虛拟机的空间分配担保策略,观察0penJDK中的源码变化,虽然源码中还定义了HandlePromotionFailure参数,但是在代码中已经不会再使用它。JDK6 Update24之后的规则变为只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小就会进行Minor GC, 否则将进行Full GC。

10、堆是分配对象的唯一选择吗?

1、堆是分配对象的唯一选择吗?

在《深入理解Java虚拟机》中关于Java堆内存有这样一段描述:

随着JIT编译期的发展逃逸分析技术逐渐成熟,栈上分配标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么“绝对”了。

在Java虚拟机中,对象是在Java堆中分配内存的,这是一个普遍的常识。但是,有一种特殊情况,那就是如果经过逃逸分析(Escape Analysis) 后发现,一个对象并没有逃逸出方法的话,那么就可能被优化成栈上分配。这样就无需在堆上分配内存,也无须进行垃圾回收了。这也是最常见的堆外存储技术

此外,前面提到的基于openJDK深度定制的TaoBaoVM,其中创新的GCIH (GC invisible heap)技术实现off-heap,将生命周期较长的Java对象从heap中移至heap外,并且GC不能管理GCIH内部的Java对象,以此达到降低GC的回收频率和提升GC的回收效率的目的。

2、逃逸分析概述
1、什么是逃逸分析?
  • 如何将堆上的对象分配到栈,需要使用逃逸分析手段

  • 这是一种可以有效减少Java程序中同步负载内存堆分配压力跨函数全局数据流分析算法

  • 通过逃逸分析,Java HotSpot编译器能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上。

  • 逃逸分析的基本行为就是分析对象动态作用域:

    • 一个对象在方法中被定义后,对象只在方法内部使用,则认为没有发生逃逸

      1
      2
      3
      4
      5
      public void my_method() {
      V v = new V();
      //use v
      v = null;
      }

      没有发生逃逸的对象,则可以分配到栈上,随着方法执行的结束,栈空间就被移除。

    • 一个对象在方法中被定义后,它被外部方法所引用,则认为发生逃逸。例如作为调用参数传递到其他地方中。

      1
      2
      3
      4
      5
      6
      public static StringBuffer createStringBuffer(String s1, String s2) {
      StringBuffer sb = new StringBuffer() ;
      sb.append(s1);
      sb.append (s2);
      return sb;
      }

      发生了逃逸的对象StringBuffer,作为返回值被返回回去了,在方法外可以被调用

      改进代码,让StringBuffer sb逃不出去:(转换为String类型)

      1
      2
      3
      4
      5
      6
      public static String createStringBuffer(String s1, String s2) {
      StringBuffer sb = new StringBuffer();
      sb.append(s1) ;
      sb.append(s2) ;
      return sb.toString() ;
      }
2、逃逸分析的几种情况:

如何快速的判断是否发生了逃逸分析,大家就看**new的对象实体是否有可能在方法外被调用**。

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
32
33
34
/**
* 逃逸分析
* 如何快速的判断是否发生了逃逸分析,大家就看new的对象实体是否有可能在方法外被调用。
*/
public class EscapeAnalysis {
public EscapeAnalysis obj;
/*
方法返回EscapeAnalysis对象,发生逃逸
*/
public EscapeAnalysis getInstance(){
return obj == null? new EscapeAnalysis() : obj;
}
/*
为成员属性赋值,发生逃逸
//思考:如果当前的obj引用声明为static的?
仍然会发生逃逸。
*/
public void setObj(){
this.obj = new EscapeAnalysis();
}
/*
对象的作用域仅在当前方法中有效,没有发生逃逸
*/
public void useEscapeAnalysis(){
EscapeAnalysis e = new EscapeAnalysis();
}
/*
引用成员变量的值,发生逃逸
*/
public void useEscapeAnalysis1(){
//getInstance().xxx()同样会发生逃逸
EscapeAnalysis e = getInstance();
}
}
3、逃逸分析相关的参数设置:
  • 在JDK 6u23(JDK7)版本之后,HotSpot中默认就已经开启了逃逸分析
  • 如果使用的是较早的版本,开发人员则可以通过:
    • 选项“-XX: +DoEscapeAnalysis“显式开启逃逸分析
    • 通过选项“-XX: +PrintEscapeAnalysis“查看逃逸分析的筛选结果。
4、结论:

开发中能使用局部变量的,就不要使用在方法外定义。

5、逃逸分析的代码优化

使用逃逸分析,编译器可以对代码做如下优化:

  1. 栈上分配将堆分配转化为栈分配。如果一个对象在子程序中被分配,要使指向该对象的指针永远不会逃逸,对象可能是栈分配的候选,而不是堆分配。
  2. 同步省略。如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步
  3. 分离对象或标量替换。有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在CPU寄存器中。
6、代码优化之栈上分配
  • JIT编译器在编译期间根据逃逸分析的结果,发现如果一个对象并没有逃逸出方法的话,就可能被优化成栈上分配。分配完成后,继续在调用栈内执行,最后线程结束,栈空间被回收,局部变量对象也被回收。这样就无须进行垃圾回收了。
  • 常见的栈上分配的场景
    • 在逃逸分析中,已经说明了。分别是给成员变量赋值方法返回值实例引用传递

代码:

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
/**
* 栈上分配测试
* -Xmx256 -Xms256 -XX:-DoEscapeAnalysis -XX:+PrintGCDetails
* 先关闭逃逸分析:-XX:-DoEscapeAnalysis
* 在打开逃逸分析:-XX:+DoEscapeAnalysis
* 观察对比
*/
public class StackAllocation {
public static void main(String[] args) {
long start = System.currentTimeMillis();
for (int i = 0; i < 10000000; i++) {
alloc();
}
// 查看执行时间
long end = System.currentTimeMillis();
System.out.println("花费的时间为: " + (end - start) + " ms");
// 为了方便查看堆内存中对象个数,线程sleep
try {
Thread.sleep(1000000);
} catch (InterruptedException e1) {
e1.printStackTrace();
}
}
private static void alloc() {
User user = new User();//未发生逃逸
}
static class User {}
}

结果分析:

在关闭逃逸分析的时候:代码执行时间:55ms,发生了GC

在开启逃逸分析的时候:代码执行时间:4ms,并没有发生GC

结论:

逃逸分析优化了对非逃逸对象的内存分配,实现了栈上分配。加快了程序的执行效率,并消除了GC,没有了STW,用户线程不会被阻碍。

7、代码优化之同步省略(锁消除)
  • 线程同步的代价是相当高的,同步的后果是降低并发性和性能。
  • 在动态编译同步块的时候,JIT编译器可以借助逃逸分析来判断同步块所使用的锁对象是否只能够被一个线程访问而没有被发布到其他线程。如果没有,那么JIT编译器在编译这个同步块的时候就会取消对这部分代码的
    同步。这样就能大大提高并发性和性能。这个取消同步的过程就叫同步省略,也叫锁消除

代码:

1
2
3
4
5
6
7
8
public class SynchronizedTest {
public void f() {
Object hollis = new Object();
synchronized(hollis) {
System.out.println(hollis);
}
}
}

代码中对hollis这个对象进行加锁,但是hollis对象的生命周期只在f()方法中,并不会被其他线程所访问到,所以在JIT编译阶段就会被优化掉。(在字节码文件下依旧存在synchronized的身影,即:字节码当中的monitorenter与monitorexit中间包裹的部分,只是在运行阶段进行了代码优化)

优化成:

1
2
3
4
public void f() {
Object hollis = new Object() ;
System.out.println(hollis) ;
}
8、代码优化之标量替换

**标量(Scalar)**是指一个无法再分解成更小的数据的数据。Java中的原始数据类型就是标量。

相对的,那些还可以分解的数据叫做**聚合量(Aggregate)**,Java中的对象就是聚合量,因为他可以分解成其他聚合量和标量。

在JIT阶段,如果经过逃逸分析,发现一个对象不会被外界访问的话,那么经过JIT优化,就会把这个对象拆解成若干个其中包含的若干个成员变量来代替。这个过程就是标量替换

代码:

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
/**
* 标量替换测试
* -Xmx100m -Xms100m -XX:+DoEscapeAnalysis -XX:+PrintGC -XX:-EliminateAllocations
* 先关闭标量替换:-XX:-EliminateAllocations
* 在打开标量替换:-XX:+EliminateAllocations
* 观察对比
*/
public class ScalarReplace {
public static class User {
public int id;
public String name;
}
public static void alloc() {
User u = new User();//未发生逃逸
u.id = 5;
u.name = "www.atguigu.com";
}
public static void main(String[] args) {
long start = System.currentTimeMillis();
for (int i = 0; i < 10000000; i++) {
alloc();
}
long end = System.currentTimeMillis();
System.out.println("花费的时间为: " + (end - start) + " ms");
}
}

结果分析:

在关闭标量替换的时候:代码执行时间:57ms,发生了GC

在开启标量替换的时候:代码执行时间:4ms,并没有发生GC

结论:

  • 代码优化:(在alloc()方法中)

    1
    2
    3
    4
    public static void alloc() {
    public int id = 5;
    public String name = "www.atguigu.com";
    }

    可以看到,Uesr这个聚合量经过逃逸分析后,发现它并没有逃逸,就被替换成两个标量了。

    那么标量替换有什么好处呢?

    就是可以大大减少堆内存的占用。因为一旦不需要创建对象了,那么就不再需要分配堆内存了。

    标量替换为栈上分配提供了很好的基础。

  • 另外,在开启-XX:+DoEscapeAnalysis即逃逸分析,同时关闭标量替换的时候为什么还会存在GC?

    可以理解为(弹幕,不一定对):

    • 栈上分配是要基于标量替换,即使开启了逃逸分析但是没有开启标量替换,他还是会在堆上分配。
    • 对象未发生逃逸 + 开启标量替换 = 栈上分配
    • 可以将“栈上分配”理解为1个概念,具体要通过逃逸分析和标量替换两个参数决定
  • 标量替换参数设置:

    参数-XX: +EliminateAllocations:开启了标量替换(默认打开),允许将对象打散分配在栈上。

  • 上述代码在主函数中进行了1亿次alloc。调用进行对象创建,由于User对象实例需要占据约16字节的空间,因此累计分配空间达到将近1.5GB。如果堆空间小于这个值,就必然会发生GC。 使用如下参数运行上述代码:(逃逸分析在JDK7之后已经自动添加,这里针对的是服务器端)

    • -server
    • -Xmx100m
    • -Xms100m
    • -XX: +DoEscapeAnalysis
    • -XX: +PrintGC
    • -XX: +EliminateAllGcations

    这里使用参数如下:

    • 参数-server:启动Server模式,因为在Server模式下,才可以启用逃逸分析。(java的JVM默认就是一个Server模式,不用我们手动开启)
    • 参数-XX:+DoEscapeAnalysis:启用逃逸分析
    • 参数-Xmx10m:指定了堆空间最大为10MB
    • 参数-xx: +PrintGC:将打印GC日志。
    • 参数-XX:+E1 iminateAllocations:开启了标量替换(默认打开),允许将对象打散分配在栈上,比如对象拥有id和name两个字段,那么这两个字段将会被视为两个独立的局部变量进行分配。
9、逃逸分析总结
  • 关于逃逸分析的论文在1999年就已经发表了,但直到JDK 1.6才有实现,而且这项技术到如今也并不是十分成熟的。
  • 其根本原因就是无法保证逃逸分析的性能消耗一定能高于他的消耗。虽然经过逃逸分析可以做标量替换、栈上分配、和锁消除。但是逃逸分析自身也是需要进行一系列复杂的分析的,这其实也是一个相对耗时的过程
  • 一个极端的例子,就是经过逃逸分析之后,发现没有一个对象是不逃逸的。那这个逃逸分析的过程就白白浪费掉了。
  • 虽然这项技术并不十分成熟,但是它也是即时编译器优化技术中一个十分重要的手段
  • 注意到有一些观点,认为通过逃逸分析,JVM会在栈上分配那些不会逃逸的对象,这在理论上是可行的,但是取决于JVM设计者的选择。据我所知,Oracle Hotspot JVM中并未这么做,这一点在逃逸分析相关的文档里已经说明,所以可以明确所有的对象实例都是创建在堆上。
  • 目前很多书籍还是基于JDK 7以前的版本,JDK已经发生了很大变化,intern字符串的缓存和静态变量曾经都被分配在永久代上,而永久代已经被元数据区取代。但是,intern字符串缓存和静态变量并不是被转移到元数据区,而是直接在堆上分配,所以这一点同样符合前面一点的结论:对象实例都是分配在堆上

11、堆总结

  • 年轻代是对象的诞生、成长、消亡的区域,一个对象在这里产生、应用,最后被垃圾回收器收集、结束生命。
  • 老年代放置长生命周期的对象,通常都是从Survivor区域筛选拷贝过来的Java对象。当然,也有特殊情况,我们知道普通的对象会被分配在TLAB上;如果对象较大,JVM会试图直接分配在Eden其他位置上;如果对象太大,完全无法在新生代找到足够长的连续空闲空间,JVM就会直接分配到老年代。
  • 当GC只发生在年轻代中,回收年轻代对象的行为被称为MinorGC。当GC发生在老年代时则被称为MajorGC或者Ful1GC。一般的,MinorGC 的发生频率要比MajorGC高很多,即老年代中垃圾回收发生的频率将大大低于年轻代。

9、方法区(Method Area)

1、栈、堆、方法区的交互关系

从线程是否共享的角度来看

image-20210424010349347

从代码看出栈、堆、方法区的交互关系:

1
Person person = new Person();

image-20210424010520101

2、方法区的理解

1、官方文档

image-20210424010921229

2、方法区的位置

《Java虚拟机规范》中明确说明:“尽管所有的方法区在逻辑上是属于堆的一部分,但一些简单的实现可能不会选择去进行垃圾收集或者进行压缩。”但对于HotSpot JVM而言,方法区还有一个别名叫做**Non-Heap (非堆)**,目的就是要和堆分开。

所以,方法区看作是一块独立于Java堆的内存空间

3、方法区和基本理解
  • 方法区(Method Area) 与Java堆一样,是各个线程共享的内存区域
  • 方法区在JVM启动的时候被创建,并且它的实际的物理内存空间中和Java堆区一样都可以是不连续的。
  • 方法区的大小,跟堆空间一样,可以选择固定大小或者可扩展。
  • 方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类,导致方法区溢出,虛拟机同样会抛出内存溢出错误:java.lang.OutOfMemoryError:PermGen space (JDK7)或者 java.lang.OutOfMemoryError: Metaspace(JDK8)
    • 加载大量的第三方的jar包;Tomcat 部署的工程过多(30-50个),大量动态的生成反射类
  • 关闭JVM就会释放这个区域的内存。
4、Hotspot中方法区的演进
  • JDK7及以前,习惯上把方法区,称为永久代JDK8开始,使用元空间取代了永久代。

    In JDK8,classes metadata is now stored in the native heap and this space is called Metaspace.

  • 本质上,方法区和永久代并不等价。仅是对hotspot而言的。《Java虚拟机规范》对如何实现方法区,不做统一要求。 例如: BEA JRockit/ IBM J9中不存在永久代的概念。

    • 现在来看,当年使用永久代,不是好的idea。导致Java程序更容易OOM (超过-XX : MaxPermSize上限)

    第08章_堆和方法区图

  • 而到了JDK 8,终于完全废弃了永久代的概念,改用与JRockit、J9一样在本地内存中实现的元空间( Metaspace)来代替。

    image-20210424014427319

  • 元空间的本质和永久代类似,都是对JVM规范中方法区的实现。不过元空间与永久代最大的区别在于:元空间不在虛拟机设置的内存中,而是使用本地内存

  • 永久代、元空间二者并不只是名字变了,内部结构也调整了。

    • 根据《Java虚拟机规范》的规定,如果方法区无法满足新的内存分配需求时,将抛出OOM异常

3、设置方法区大小与OOM

  • 方法区的大小不必是固定的,JVM可以根据应用的需要动态调整。

  • JDK7及以前:

    • 通过-XX:PermSize来设置永久代初始分配空间。默认值是20.75M

    • -XX:MaxPermSize来设定永久代最大可分配空间。32位机器默认是64M64位机器模式是82M

    • 当JVM加载的类信息容量超过了这个值,会报异常OutofMemoryError : PermGen space

      image-20210424014940690

  • jdk8及以后:

    • 元数据区大小可以使用参数-XX :MetaspaceSize-XX :MaxMetaspaceSize指定,替代上述原有的两个参数。

    • 默认值依赖于平台windows下,-XX:MetaspaceSize是21M, -XX: MaxMetaspaceSize的值是-1, 即没有限制

    • 参数设置方法:

      • jdk7及以前:-XX:PermSize=100m -XX:MaxPermSize=100m
      • jdk8及以后:-XX:MetaspaceSize=100m -XX:MaxMetaspaceSize=100m(这个一般不会改)
    • 与永久代不同,如果不指定大小,默认情况下,虚拟机会耗尽所有的可用系统内存。如果元数据区发生溢出,虚拟机一样会抛出异常OutOfMemoryError: Metaspace

    • -XX : MetaspaceSize
      设置初始的元空间大小。对于一个64位的服务器端JVM来说,其默认的-XX :MetaspaceSize值为21MB。这就是初始的高水位线,一 旦触及这个水位线,Full GC将会被触发并卸载没用的类(即这些类对应的类加载器不再存活),然后这个高水位线将会重置。新的高水位线的值取决于GC后释放了多少元空间。如果释放的空间不足,那么在不超过MaxMetaspaceSize时,适当提高该值。如果释放空间过多,则适当降低该值。

      image-20210424015216603

    • 如果初始化的高水位线设置过低,上述高水位线调整情况会发生很多次。通过垃圾回收器的日志可以观察到Full GC多次调用。为了避免频繁地GC,建议将-XX:MetaspaceSize设置为一个相对较高的值

4、如何解决OOM

  1. 要解决OOM异常或heap space的异常,一般的手段是首先通过内存映像分析工具(如Eclipse Memory Analyzer) 对dump出来的堆转储快照进行分析,重点是确认内存中的对象是否是必要的,也就是要先分清楚到底是出现了内存泄漏(MemoryLeak)**还是内存溢出(Memory Overflow)内存泄漏堆积会导致内存溢出,所以判断内存溢出第一步是查看内存是否泄漏**。
  2. 如果是内存泄漏,可进一步通过工具查看泄漏对象到GC Roots的引用链。于是就能找到泄漏对象是通过怎样的路径与GC Roots相关联并导致垃圾收集器无法自动回收它们的。掌握了泄漏对象的类型信息,以及GC Roots 引用链的信息,就可以比较准确地定位出泄漏代码的位置。
  3. 如果不存在内存泄漏,换句话说就是内存中的对象确实都还必须存活着,那就应当检查虚拟机的堆参数(-Xmx与-Xms) ,与机器物理内存对比看是否还可以调大,从代码上检查是否存在某些对象生命周期过长、持有状态时间过长的情况,尝试减少程序运行期的内存消耗。

5、方法区的内部结构

image-20210424020826940

1、方法区存储的内容

《深入理解Java虚拟机》书中对方法区(Method Area)存储内容描述如下:

它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等。

image-20210424020942369

2、方法区和内部结构
1、类型信息

对每个加载的类型( 类calss、接口interface、枚举enum、注解annotation),JVM必须在方法区中存储以下类型信息:

  1. 这个类型的完整有效名称(全名=包名.类名)
  2. 这个类型直接父类的完整有效名(对于interface或是java. lang.object, 都没有父类)
  3. 这个类型的修饰符(public, abstract, final的某个子集)
  4. 这个类型直接接口的一个有序列表
2、域(Field)信息

JVM必须在方法区中保存类型的所有域的相关信息以及域的声明顺序。

域的相关信息包括:

  • 域名称
  • 域类型
  • 域修饰符:public,private,protected,static,final,volatile,transient的某个子集
3、方法(Method)信息

JVM必须保存所有方法的以下信息,同域信息一样包括声明顺序:

  • 方法名称
  • 方法的返回类型(或void)
  • 方法参数的数量和类型(按顺序)
  • 方法的修饰符(public,private,protected,static,final,synchronized,native,abstract的一个子集)
  • 方法的字节码 (bytecodes)、操作数栈、局部变量表及大小(abstract和native方法除外)
  • 异常表(abstract和native方法除外)
    • 每个异常处理的开始位置、结束位置、代码处理在程序计数器中的偏移地址、被捕获的异常类的常量池索引
4、non-final的类变量
  • 静态变量和类关联在一起,随着类的加载而加载,它们成为类数据在逻辑上的一部分。
  • 类变量被类的所有实例共享,即使没有类实例时你也可以访问它
  • javac编译器自动搜集字节码中的类变量的赋值动作和静态代码块组成的语句
5、全局常量:static final

被声明为final的类变量的处理方法则不同,每个全局常量在编译的时候就会被分配

image-20210424024056293

3、运行时常量池 VS 常量池

image-20210424024352733

  • 方法区,内部包含了运行时常量池

  • 字节码文件,内部包含了常量池

  • 方法区的运行时常量池就是class字节码文件中的常量池经过类加载器进行加载之后存放进内存之后得到。

    但由于方法区的运行时常量池是具备动态性,所以可能比字节码文件里的常量池要大。

  • 要弄清楚方法区,需要理解清楚ClassFile,因为加载类的信息都在方法区。

  • 要弄清楚方法区的运行时常量池,需要理解清楚ClassFile中的常量池。

  • 官网描述如下

    image-20210424030341079

  • 一个有效的字节码文件中除了包含类的版本信息、字段、方法以及接口等描述信息外,还包含一项信息那就是常量池表(Constant Pool Table) ,**包括各种字面量和对类型、域和方法的符号引用**。

    image-20210424030544494

4、为什么需要常量池?

一个java源文件中的类、接口,编译后产生一个字节码文件。而Java中的字节码需要数据支持,通常这种数据会很大以至于不能直接存到字节码里,换另一种方式,可以存到常量池,这个字节码包含了指向常量池的引用**。在动态链接的时候会用到运行时常量池**。

比如如下的代码:

1
2
3
4
5
6
public class SimpleClass {
public void sayHel1o() {
System.out.println("hello");
Object obj = new Object();
}
}

Object obj = new Object();经过编译之后会生成如下字节码文件:

1
2
3
0:	new #2				// Class java/ lang/ object
1: dup
2: invokespecial #3 // Method java/ lang/object "<init>"( ) V

虽然编译过后的class文件只有194字节,但是里面却使用了String、System、 PrintStream及object等结构。这里代码量其实已经很小了。如果代码多,引用到的结构会更多!这里就需要常量池了!

5、常量池中的内容

几种在常量池内存储的数据类型包括:

  • 数量值
  • 字符串值
  • 类引用
  • 字段引用
  • 方法引用
6、常量池小结

常量池,可以看做是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等类型

7、运行时常量池
  • 运行时常量池( Runtime Constant Pool) 是方法区的一部分
  • 常量池表( Constant Pool Table) 是Class文件的一部分用于存放编译期生成的各种字面量与符号引用**,这部分内容将在类加载后存放到方法区的运行时常量池中**。
  • 运行时常量池,在加载类和接口到虚拟机后,就会创建对应的运行时常量池。
  • JVM为每个已加载的类型(类或接口)都维护一个常量池。池中的数据项像数组项一样,是通过**索引访问**的。
  • 运行时常量池中包含多种不同的常量,包括编译期就已经明确的数值字面量,也包括到运行期解析后才能够获得的方法或者字段引用。此时不再是常量池中的符号地址了,这里换为真实地址
    • 运行时常量池,相对于Class文件常量池的另一重要特征是:**具备动态性**。
      • String. intern( )
  • 运行时常量池类似于传统编程语言中的符号表(symboltable),但是它所包含的数据却比符号表要更加丰富一些。
  • 当创建类或接口的运行时常量池时,如果构造运行时常量池所需的内存空间超过了方法区所能提供的最大值,则JVM会抛OutOfMemoryError异常。

6、方法区使用举例

image-20210424165338137

image-20210424165459908

7、方法区的演进细节

1、方法区的演进

首先明确:只有HotSpot才有永久代

BEA JRockit、 IBM J9等来说,是不存在永久代的概念的。原则上如何实现方法区属于虚拟机实现细节,不受《Java虚拟机规范》管束,并不要求统一。

Hotspot中方法区的变化:

版本 描述
jdk1.6及之前 有永久代(permanent generation),静态变量存放在永久代上
jdk1.7 有永久代,但已经逐步“去永久代”,字符串常量池、静态变量移除,保存在堆中
jdk1.8及之后 无永久代,类型信息、字段、方法、常量保存在本地内存的元空间,但字符串常量池、静态变量仍在堆

第08章_方法区的演进细节-hotspot

2、元空间 VS 永久代

永久代为什么要被元空间替换

  • 随着Java8 的到来,HotSpot VM中再也见不到永久代了。但是这并不意味着类的元数据信息也消失了。这些数据被移到了一个与堆不相连的本地内存区域,这个区域叫做元空间(Metaspace)。

  • 由于类的元数据分配在本地内存中,元空间的最大可分配空间就是系统可用内存空间

  • 这项改动是很有必要的,原因有:

    1. 为永久代设置空间大小是很难确定的

      在某些场景下,如果动态加载类过多,容易产生Perm区的OOM。比如某个实际Web工程中,因为功能点比较多,在运行过程中,要不断动态加载很多类,经常出现致命错误。

      “Exception in thread ‘dubbo client x.x connector’ java.lang OutOfMemoryError: PermGen space

      而元空间和永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存限制。

    2. 永久代进行调优是很困难的。

3、StringTable的调整

StringTable为什么要调整?

jdk7中将StringTable放到了堆空间中。因为永久代的回收效率很低,在full gc的时候才会触发。而full gc是老年代的空间不足、永久代不足时才会触发。这就导致StringTable回收效率不高。而我们开发中会有大量的字符串被创建,回收效率低,导致永久代内存不足。放到堆里,能及时回收内存。

4、静态变量的位置

结论:静态引用对应的对象实体始终都存在堆空间

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
* 《深入理解Java虚拟机》中的案例:
* staticObj、instanceObj、localObj存放在哪里?
*/
public class StaticObjTest {
static class Test {
static ObjectHolder staticObj = new ObjectHolder();
ObjectHolder instanceObj = new ObjectHolder();

void foo() {
ObjectHolder localObj = new ObjectHolder();
System.out.println("done");
}
}

private static class ObjectHolder {
}

public static void main(String[] args) {
Test test = new StaticObjTest.Test();
test.foo();
}
}

staticObj(静态变量)随着Test的类型信息存放在方法区instanceObj(实例变量)随着Test的对象实例存放在Java堆localObject(局部变量)则是存放在foo( )方法栈帧的局部变量表中。

三个对象的数据在内存中的地址都落在Eden区范围内

所以结论:只要是对象实例必然会在Java堆中分配

接着,找到了一个引用该staticObj对象的地方,是在一个java.lang.Class的实例里,并且给出了这个实例的地址,通过Inspector查看该对象实例,可以清楚看到这确实是一个java.lang.Class类型的对象实例,里面有一个名为staticObj的实例字段:

image-20210424172219770

从《Java虚拟机规范》所定义的概念模型来看,所有Class相关的信息都应该存放在方法区之中,但方法区该如何实现,《Java虚拟机规范》 并未做出规定,这就成了一件允许不同虚拟机自己灵活把握的事情。

JDK 7及其以后版本的HotSpot虚拟机选择把静态变量与类型在Java语言一端的映射Class对象存放在一起,存储于Java堆之中,从我们的实验中也明确验证了这一点。

8、方法区的垃圾回收

有些人认为方法区如HotSpot虚拟机中的元空间或者永久代)是没有垃圾收集行为的,其实不然。《Java虚拟机规范》 对方法区的约束是非常宽松的,提到过可以不要求虚拟机在方法区中实现垃圾收集。事实上也确实有未实现或未能完整实现方法区类型卸载的收集器存在(如JDK11时期的ZGC收集器就不支持类卸载)。

一般来说这个区域的回收效果比较难令人满意,尤其是类型的卸载,条件相当苛刻。但是这部分区域的回收有时又确实是必要的。以前Sun公司的Bug列表中,曾出现过的若干个严重的Bug就是由于低版本的HotSpot虛拟机对此区域未完全回收而导致内存泄漏。

**方法区的垃圾收集主要回收两部分内容:常量池中废弃的常量不再使用的类型**。

  • 先来说说方法区内**常量池之中主要存放的两大类常量:字面量符号引用**。
    字面量比较接近Java语言层次的常量概念,如文本字符串、被声明为final的常量值等。
  • 而符号引用则属于编译原理方面的概念,包括下面三类常量:
    1. 类和接口的全限定名
    2. 字段的名称和描述符
    3. 方法的名称和描述符
  • HotSpot虚拟机对常量池的回收策略是很明确的,只要常量池中的常量没有被任何地方引用,就可以被回收
  • 回收废弃常量与回收Java堆中的对象非常类似。
  • 判定一个常量是否“废弃”还是相对简单,而要判定一个类型是否属于“不再被使用的类”的条件就比较苛刻了。需要同时满足下面三个条件:
    1. 该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例
    2. 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如OSGi、JSP的重加载等,否则通常是很难达成的。
    3. 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法
  • Java虚拟机被允许对满足上述三个条件的无用类进行回收,这里说的仅仅是“被允许”,而并不是和对象一样,没有引用了就必然会回收。关于是否要对类型进行回收,HotSpot虚拟机提供了-Xnoclassgc参数进行控制,还可以使用-verbose:class以及-XX: +TraceClass-Loading-XX: +TraceClassUnLoading查看类加载和卸载信息
  • 在大量使用反射、动态代理、CGLib等字节码框架,动态生成JSP以及OSGi这类频繁自定义类加载器的场景中,通常都需要Java虚拟机具备类型卸载的能力,以保证不会对方法区造成过大的内存压力。

9、总结

第09章_小结

10、常见面试题

  • 百度:
    • 三面:说一下JVM内存模型吧,有哪些区?分别干什么的?
  • 蚂蚁金服:
    • Java8的内存分代改进
    • JVM内存分哪几个区,每个区的作用是什么?
    • 一面: JVM内存分布/内存结构?栈和堆的区别?堆的结构?为什么两个survivor区?
    • 二面: Eden和Survior的比例分配
  • 小米:
    • jvm内存分区,为什么要有新生代和老年代
  • 字节跳动:
    • 二面: Java的内存分区
    • 二面:讲讲jvm运行时数据库区
    • 什么时候对象会进入老年代?
  • 京东:
    • JVM的内存结构,Eden和Survivor比例。
    • JVM内存为什么要分成新生代,老年代,持久代。新生代中为什么要分为Eden和Survivor。
  • 天猫:
    • 一面: Jvm内存模型以及分区,需要详细到每个区放什么。
    • 一面: JVM的内存模型,Java8做了什么修改
  • 拼多多:
    • JVM 内存分哪几个区,每个区的作用是什么?
  • 美团:
    • java内存分配
    • jvm的永久代中会发生垃圾回收吗?
    • 一面: jvm内存分区,为什么要有新生代和老年代?

10、对象的实例化、内存布局与访问定位

1、对象的实例化

1、大厂面试题
  • 美团:
    • 对象在JVM中是怎么存储的?
    • 对象头信息里面有哪些东西?
  • 蚂蚁金服:
    • 二面: java对象头里有什么
2、对象实例化

第10章_对象的实例化

创建对象的字节码解析:

image-20210424193206898

创建步骤:

  1. 判断对象对应的类是否加载、链接、初始化

    虚拟机遇到一条new指令,首先去检查这个指令的参数能否在Metaspace的常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载、解析和初始化。( 即判断类元信息是否存在)。如果没有,那么在双亲委派模式下,使用当前类加载器以ClassLoader+包名+类名为Key进行查找对应的.class文件。如果没有找到文件,则抛出ClassNotFoundException异常,如果找到,则进行类加载,并生成对应的Class类对象。

  2. 为对象分配内存

    首先计算对象占用空间大小,接着在堆中划分一块内存给新对象。

    如果实例成员变量是引用变量,仅分配引用变量空间即可,即4个字节大小

    • 如果内存规整,使用指针碰撞

      如果内存是规整的,那么虚拟机将采用的是**指针碰撞法(Bump The Pointer)**来为对象分配内存。意思是所有用过的内存在一边,空闲的内存在另外一边,中间放着一个指针作为分界点的指示器,分配内存就仅仅是把指针向空闲那边挪动一段与对象大小相等的距离罢了。如果垃圾收集器选择的是Serial、ParNew这种基于压缩算法的,虚拟机采用这种分配方式。一般使用带有compact(整理)过程的收集器时,使用指针碰撞。

    • 如果内存不规整,虚拟机需要维护-一个列表,使用空闲列表分配

      如果内存不是规整的,已使用的内存和未使用的内存相互交错,那么虚拟机将采用的是空闲列表法来为对象分配内存。意思是虚拟机维护了一个列表,记录上哪些内存块是可用的,再分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的内容。这种分配方式成为“空闲列表(Free List)“。

    说明:选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。

  3. 处理并发安全问题

    在分配内存空间时,另外一个问题是及时保证new对象时候的线程安全性:创建对象是非常频繁的操作,虚拟机需要解决并发问题。虚拟机采用了两种方式解决并发问题:

    1. CAS ( Compare And Swap )失败重试、区域加锁:保证指针更新操作的原子性;
    2. TLAB把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲区,(TLAB ,Thread Local Allocation Buffer) 虚拟机是否使用TLAB,可以通过-XX: +/-UseTLAB参数来设定(’+’:打开 ‘-‘:关闭)。
  4. 初始化分配到的空间

    内存分配结束,虚拟机将分配到的内存空间都**初始化为零值(不包括对象头)**。这一步保证了对象的实例字段在Java代码中可以不用赋初始值就可以直接使用,程序能访问到这些字段的数据类型所对应的零值。

  5. 设置对象的对象头

    将对象的所属类(即类的元数据信息)、对象的HashCode和对象的GC信息、锁信息等数据存储在对象的对象头中。这个过程的具体设置方式取决于JVM实现。

  6. 执行init方法进行初始化

    在Java程序的视角看来,初始化才正式开始。初始化成员变量,执行实例化代码块,调用类的构造方法,并把堆内对象的首地址赋值给引用变量。

    因此一般来说(由字节码中是否跟随有invokespecial指令所决定),new指令之后会接着就是执行方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全创建出来。

2、对象的内存布局

第10章_内存布局

小结:图示

第10章_图示对象的内存布局

3、对象的访存定位

第10章_对象访问定位

图示:JVM是如何通过栈帧中的对象引用访问到其内部的对象实例的呢?

image-20210424190106896

1、句柄访问

图示:

第10章_方式1:句柄访问

好处:reference中存储稳定句柄地址,对象被移动(垃圾收集时移动对象很普遍)时只会改变句柄中实例数据指针即可,reference本身不需要被修改。

2、直接指针(HotSpot采用)

图示:

第10章_方式2:使用直接指针访问


11、直接内存(Direct Memory)

1、直接内存概述

  • 不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域。
  • 直接内存是在Java堆外的、直接向系统申请的内存区间
  • 来源于NIO,通过存在堆中的DirectByteBuffer操作Native内存
  • 通常,访问直接内存的速度会优于Java堆。即读写性能高。
    • 因此出于性能考虑,读写频繁的场合可能会考虑使用直接内存
    • Java的NIO库允许Java程序使用直接内存,用于数据缓冲区

2、IO VS NIO

IO NIO (New IO / Non-Blocking IO:非阻塞IO)
实现 byte[] / char[] Buffer(缓存区)
形式 Stream(流) Channel(管道)

3、直接缓存区(IO) VS 非直接缓存区(NIO)

读写文件,需要与磁盘交互,需要由用户态切换到内核态。在内核态时,需要内存如下图的操作。

使用IO,见下图。这里需要两份内存存储重复数据,效率低

image-20210424225618054

使用NIO时,如下图。

操作系统划出的直接缓存区可以被java代码直接访问,只有一份。 NIO适合对大文件的读写操作

image-20210424225837934

4、直接内存的OOM异常

  • 直接内存也可能导致OutOfMemoryError异常

    image-20210424231245461

  • 由于直接内存在Java堆外,因此它的大小不会直接受限于-Xmx指定的最大堆大小,但是系统内存是有限的,Java堆和直接内存的总和依然受限于操作系统能给出的最大内存。

  • 缺点

    • 分配回收成本较高
    • 不受JVM内存回收管理
  • 直接内存大小可以通过MaxDirectMemorySize设置

  • 如果不指定,默认与堆的最大值-Xmx参数值一致

简单理解:

java process memory = java heap + native memory

image-20210424231440926


12、执行引擎

1、执行引擎概述

1、执行引擎的作用
  • 执行引擎是Java虛拟机核心的组成部分之一。
  • “虚拟机”是一个相对于“物理机”的概念,这两种机器都有代码执行能力,其区别是物理机的执行引擎是直接建立在处理器、缓存、指令集和操作系统层面上的,而虚拟机的执行引擎则是由软件自行实现的,因此可以不受物理条件制约地定制指令集与执行引擎的结构体系,能够执行那些不被硬件直接支持的指令集格式
  • JVM的主要任务是负责装载字节码到其内部,但字节码并不能够直接运行在操作系统之上,因为字节码指令并非等价于本地机器指令,它内部包含的仅仅只是一些能够被JVM所识别的字节码指令、符号表,以及其他辅助信息。
  • 那么,如果想要让一个Java程序运行起来,执行引擎(Execution Engine)的任务就是将字节码指令解释/编译为对应平台上的本地机器指令才可以。简单来说,JVM中的执行引擎充当了将高级语言翻译为机器语言的译者。
2、执行引擎的工作过程
  • 执行引擎在执行的过程中究竟需要执行什么样的字节码指令完全依赖于PC寄存器
  • 每当执行完一项指令操作后,PC寄存器就会更新下一条需要被执行的指令地址
  • 当前方法在执行的过程中,执行引擎有可能会通过存储在局部变量表中的对象引用准确定位到存储在Java堆区中的对象实例信息,以及通过对象头中的元数据指针定位到目标对象的类型信息

从外观上来看,所有的Java虚拟机的执行引擎输入、输出都是一致的:输入的是字节码二进制流,处理过程是字节码解析执行的等效过程输出的是执行结果

2、Java代码编译和执行过程

程序执行过程

大部分的程序代码转换成物理机的目标代码或虚拟机能执行的指令集之前,都需要经过上图中的各个步骤。

Java代码编译是由Java源码编译器来完成,流程图如下所示:

image-20210425005150269

Java字节码的执行是由JVM执行引擎来完成,流程图如下所示:

image-20210425005213179

问题1:什么是解释器( Interpreter),什么是JIT编译器?

  • 解释器:当Java虛拟机启动时会根据预定义的规范对字节码采用逐行解释的方式执行,将每条字节码文件中的内容“翻译”为对应平台的本地机器指令执行。
  • JIT (Just In Time Compiler) 编译器:就是虚拟机将源代码直接编译成和本地机器平台相关的机器语言
  • 解释器:边逐行翻译边运行
  • 编译器:一起编译好再执行

问题2:为什么说Java是半编译半解释型语言?

  • JDK1.0时代,将Java语言定位为“解释执行”还是比较准确的。再后来,Java也发展出可以直接生成本地代码的编译器。

  • 现在JVM在执行Java代码的时候,通常都会将解释执行与编译执行二者结合起来进行。

  • 经过编译器编译之后可以在方法区中进行缓存(热点代码)

    image-20210425005819291

3、机器码、指令、汇编语言

1、理解执行引擎

第12章_理解执行引擎

2、机器码
  • 各种用二进制编码方式表示的指令,叫做机器指令码。开始,人们就用它采编写程序,这就是机器语言。
  • 机器语言虽然能够被计算机理解和接受,但和人们的语言差别太大,不易被人们理解和记忆,并且用它编程容易出差错。
  • 用它编写的程序一经输入计算机,CPU直接读取运行,因此和其他语言编的程序相比,执行速度最快
  • 机器指令与CPU紧密相关,所以不同种类的CPU所对应的机器指令也就不同
3、指令与指令集
  • 指令
    • 由于机器码是有0和1组成的二进制序列,可读性实在太差,于是人们发明了指令。指令就是把机器码中特定的0和1序列,简化成对应的指令(一般为英文简写,如mov,inc等),可读性稍好
    • 由于不同的硬件平台,执行同一个操作,对应的机器码可能不同,所以不同的硬件平台的同一种指令(比如mov),对应的机器码也可能不同。
  • 指令集
    • 不同的硬件平台,各自支持的指令,是有差别的。因此每个平台所支持的指令,称之为对应平台的指令集。
    • 如常见的
      • x86指令集,对应的是x86架构的平台
      • ARM指令集,对应的是ARM架构的平台
4、汇编语言
  • 由于指令的可读性还是太差,于是人们又发明了汇编语言。
  • 在汇编语言中,用助记符(Mnemonics)代替机器指令的操作码,用地址符号(Symbol) 或标号(Label)代替指令或操作数的地址
  • 在不同的硬件平台,汇编语言对应着不同的机器语言指令集,通过汇编过程转换成机器指令。
    • 由于计算机只认识指令码,所以用汇编语言编写的程序还必须翻译成机器指令码,计算机才能识别和执行。
5、高级语言
  • 为了使计算机用户编程序更容易些,后来就出现了各种高级计算机语言。高级语言比机器语言、汇编语言更接近人的语言
  • 当计算机执行高级语言编写的程序时,仍然需要把程序解释和编译成机器的指令码。完成这个过程的程序就叫做解释程序编译程序

第12章_机器语言、汇编、高级语言

6、字节码
  • 字节码是一种**中间状态(中间码)的二进制代码(文件)**,它比机器码更抽象,需要直译器转译后才能成为机器码
  • 字节码主要为了实现特定软件运行和软件环境、与硬件环境无关。(实现跨平台
  • 字节码的实现方式是通过编译器和虚拟机器。编译器将源码编译成字节码,特定平台上的虚拟机器将字节码转译为可以直接执行的指令。
    • 字节码的典型应用为Java bytecode。

第01章_Java语言的跨平台性

7、C/C++源程序执行过程

编译过程又可以分成两个阶段:编译和汇编。

  • 编译过程:是读取源程序(字符流),对之进行词法和语法的分析,将高级语言指令转换为功能等效的汇编代码
  • 汇编过程:实际上指把汇编语言代码翻译成目标机器指令的过程。

image-20210425020914156

4、解释器

1、解释器概述

JVM设计者们的初衷仅仅只是单纯地为了满足Java程序实现跨平台特性,因此避免采用静态编译的方式直接生成本地机器指令,从而诞生了实现解释器在运行时采用逐行解释字节码执行程序的想法。

为什么需要字节码文件作为中间过渡,而不是采用将java源文件直接编译成对应的不同操作系统的机器指令的方式(此方式也可以实现跨平台)?

字节码文件是为了提高编译器的效率,同时也是Java虚拟机被称为跨语言的平台的基础。

image-20210425021622259

2、解释器的工作机制(或工作任务)
  • 解释器真正意义上所承担的角色就是一个运行时“翻译者”,将字节码文件中的内容“翻译”为对应平台的本地机器指令执行。
  • 当一条字节码指令被解释执行完成后,接着再根据PC寄存器中记录的下一条需要被执行的字节码指令执行解释操作。
3、解释器分类

在Java的发展历史里,一共有两套解释执行器,即古老的字节码解释器、现在普遍使用的模板解释器

  • 字节码解释器在执行时通过纯软件代码模拟字节码的执行,效率非常低下。
  • 模板解释器将每一条字节码和一个模板函数相关联,模板函数中直接产生这条字节码执行时的机器码,从而很大程度上提高了解释器的性能。
    • 在HotSpot VM中,解释器主要由Interpreter模块Code模块构成。
      • Interpreter模块:实现了解释器的核心功能
      • Code模块:用于管理HotSpot VM在运行时生成的本地机器指令
4、解释器现状
  • 由于解释器在设计和实现上非常简单,因此除了Java语言之外,还有许多高级语言同样也是基于解释器执行的,比如Python、Perl、Ruby等。但是在今天,基于解释器执行已经沦落为低效的代名词,并且时常被一些
    C/C++程序员所调侃。
  • 为了解决这个问题,JVM平台支持一种叫作即时编译的技术。即时编译的目的是避免函数被解释执行,而是将整个函数体编译成为机器码,每次函数执行时,只执行编译后的机器码即可,这种方式可以使执行效率大幅度提升。
  • 不过无论如何,基于解释器的执行模式仍然为中间语言的发展做出了不可磨灭的贡献。

5、JIT编译器

1、Java代码的执行分类
  1. 第一种是将源代码编译成字节码文件,然后在运行时通过解释器将字节码文件转为机器码执行

  2. 第二种是编译执行(直接编译成机器码)。现代虚拟机为了提高执行效率,会使用即时编译技术(JIT, Just In Time)将方法编译成机器码后再执行

    程序执行过程

  • HotSpot VM是目前市面上高性能虚拟机的代表作之一。它采用解释器与即时编译器并存的架构。在Java虚拟机运行时,解释器和即时编译器能够相互协作,各自取长补短,尽力去选择最合适的方式来权衡编译本地代
    码的时间和直接解释执行代码的时间。
  • 在今天,Java程序的运行性能早已脱胎换骨,已经达到了可以和C/C++程序一 较高下的地步。
2、为什么需要解释器?

有些开发人员会感觉到诧异,既然HotSpot VM中 已经内置JIT编译器了,那么为什么还需要再使用解释器来“拖累”程序的执行性能呢?比如JRockit VM内部就不包含解释器,字节码全部都依靠即时编译器编译后执行。

首先明确:

当程序启动后,解释器可以马上发挥作用,省去编译的时间,立即执行。编译器要想发挥作用,把代码编译成本地代码,需要一定的执行时间。但编译为本地代码后,执行效率高。

总结成一句话:解释器的响应速度快,执行速度慢;而编译器的响应速度慢,执行速度快。

所以:

尽管JRockit VM中程序的执行性能会非常高效,但程序在启动时必然需要花费更长的时间来进行编译。对于服务端应用来说,启动时间并非是关注重点,但对于那些看中启动时间的应用场景而言,或许就需要采用解释器与即时编译器并存的架构来换取一个平衡点。在此模式下,当Java 虚拟器启动时,解释器可以首先发挥作用,而不必等待即时编译器全部编译完成后再执行,这样可以省去许多不必要的编译时间。随着时间的推移,编译器发挥作用,把越来越多的代码编译成本地代码,获得更高的执行效率

同时,解释执行在编译器进行激进优化不成立的时候,作为编译器的“逃生门”

3、HotSpot JVM 的执行方式

当虚拟机启动的时候,解释器可以首先发挥作用,而不必等待即时编译器全部编译完成再执行,这样可以省去许多不必要的编译时间。并且随着程序运行时间的推移,即时编译器逐渐发挥作用,根据热点探测功能,将有价值的字节码编译为本地机器指令,以换取更高的程序执行效率

相关案例:

注意解释执行与编译执行在线上环境微妙的辩证关系。机器在热机状态可以承受的负载要大于冷机状态。如果以热机状态时的流量进行切流,可能使处于冷机状态的服务器因无法承载流量而假死。

热机状态:已经启动很长时间;冷机状态:刚刚启动

在生产环境发布过程中,以分批的方式进行发布,根据机器数量划分成多个批次,每个批次的机器数至多占到整个集群的1/8。曾经有这样的故障案例:某程序员在发布平台进行分批发布,在输入发布总批数时,误填写成分为两批发布。如果是热机状态,在正常情况下一半的机器可以勉强承载流量,但由于刚启动的JVM均是解释执行,还没有进行热点代码统计和JIT动态编译,导致机器启动之后,当前1/2发布成功的服务器马上全部宕机,此故障说明了JIT的存在。——阿里团队

image-20210425024022961

4、JIT即时编译器
1、概念解释
  • Java语言的“编译期”其实是一段“不确定”的操作过程,因为它可能是指一个前端编译器(其实叫“ 编译器的前端”更准确一些)把. java文件转变成.class文件的过程;
  • 也可能是指虚拟机的后端运行期编译器(JIT编译器,Just In Time Compiler)把字节码转变成机器码的过程。
  • 还可能是指使用静态提前编译器 (AOT编译器,Ahead Of Time Compiler) 直接把.java文件编译成本地机器代码的过程。

相关的编译器:

  • 前端编译器:Sun的Javac、Eclipse JDT中的增量式编译器( ECJ)
  • JIT编译器:HotSpot VM的C1、C2编译器
  • AOT编译器:GNU Compiler for the Java (GCJ)、Excelsior JET。
2、是否选择使用编译器

热点代码及探测方式

当然是否需要启动JIT编译器将字节码直接编译为对应平台的本地机器指令,则需要根据代码被调用执行的频率而定。关于那些需要被编译为本地代码的字节码,也被称之为“热点代码”,JIT编译器在运行时会针对那些频繁被调用的“热点代码”做出深度优化,将其直接编译为对应平台的本地机器指令,以此提升Java程序的执行性能。

3、热点代码及探测方式
  • 一个被多次调用的方法,或者是一个方法体内部循环次数较多的循环体都可以被称之为“热点代码”,因此都可以通过JIT编译器编译为本地机器指令。由于这种编译方式发生在方法的执行过程中,因此也被称之为栈上替换,或简称为OSR (On Stack Replacement)编译

  • 一个方法究竟要被调用多少次,或者一个循环体究竟需要执行多少次循环才可以达到这个标准?

    必然需要一个明确的阈值,JIT编译器才会将这些“热点代码”编译为本地机器指令执行。这里主要依靠热点探测功能

  • 目前HotSpot VM所采用的热点探测方式是基于计数器的热点探测

  • 采用基于计数器的热点探测,HotSpot VM将会为每一个方法都建立2个不同类型的计数器,分别为方法调用计数器(Invocation Counter)和回边计数器(BackEdge Counter) 。

    • 方法调用计数器用于统计方法的调用次数
    • 回边计数器则用于统计循环体执行的循环次数
4、方法调用计数器
  • 这个计数器就用于统计方法被调用的次数,它的默认阈值在Client模式下是1500次在Server 模式下是10000 次。超过这个阈值,就会触发JIT编译。

  • 这个阈值可以通过虛拟机参数-XX:CompileThreshold来人为设定。

  • 当一个方法被调用时,会先检查该方法是否存在被JIT编译过的版本,如果存在,则优先使用编译后的本地代码来执行。如果不存在已被编译过的版本,则将此方法的调用计数器值加1,然后判断方法调用计数器与回边计数器值之和是否超过方法调用计数器的阈值。如果已超过阈值,那么将会向即时编译器提交一个该方法的代码编译请求。

  • 方法调用计数器的工作流程图:

    image-20210425024857559

    简化版本:

    第12章_方法调用计数器

  • 热度衰减

    • 如果不做任何设置,方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的执行频率,即一段时间之内方法被调用的次数。当超过一定的时间限度,如果方法的调用次数仍然不足以让它提交给即时编译器编译,那这个方法的调用计数器就会被减少一半,这个过程称为方法调用计数器热度的衰减(Counter Decay) ,而这段时间就称为此方法统计的半衰周期(Counter Half Life Time) 。
    • 进行热度衰减的动作是在虚拟机进行垃圾收集时顺便进行的,可以使用虚拟机参数-XX:UseCounterDecay来关闭热度衰减,让方法计数器统计方法调用的绝对次数,这样,只要系统运行时间足够长,绝大部分方法都会被编译成本地代码。
    • 另外,可以使用-XX:CounterHalfLifeTime 参数设置半衰周期的时间,单位是秒。
5、回边计数器
  • 它的作用是统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为“回边” (Back Edge) 。显然,建立回边计数器统计的目的就是为了触发OSR编译

  • 回边计数器的执行流程图:

    第12章_回边计数器

6、HotSpot VM可以设置程序执行方式

缺省情况下HotSpot VM是采用解释器与即时编译器并存的架构,当然开发人员可以根据具体的应用场景,通过命令显式地为Java虚拟机指定在运行时到底是完全采用解释器执行,还是完全采用即时编译器执行。如下所示:

  • -Xint:完全采用解释器模式执行程序;

  • -Xcomp: 完全采用即时编译器模式执行程序。如果即时编译出现问题,解释器会介入执行。

  • -Xmixed:采用解释器+即时编译器的混合模式共同执行程序。(默认)

    image-20210425031931789

7、HotSpot VM中JIT分类

在HotSpot VM中内嵌有两个JIT编译器,分别为Client Compiler和Server Compiler,但大多数情况下我们简称为C1编译器C2编译器。开发人员可以通过如下命令显式指定Java虚拟机在运行时到底使用哪一种即时编译器,如下所示:

  • -client:指定Java虚拟机运行在Client模式下,并使用C1编译器;

    • C1编译器会对字节码进行简单和可靠的优化耗时短。以达到更快的编译速度。
  • -server:指定Java虚拟机运行在Server模式下,并使用C2编译器。(对于84位的操作系统默认就是Srver模式,不能修改)

  • 官方

    image-20210425030403484

    image-20210425030645445

    • C2进行耗时较长的优化,以及激进优化。但优化的代码执行效率更高。

分层编译(Tiered Compilation)策略:程序解释执行(不开启性能监控)可以触发C1编译,将字节码编译成机器码,可以进行简单优化,也可以加上性能监控,C2编译会根据性能监控信息进行激进优化。

不过在Java7版本之后,一旦开发人员在程序中显式指定命令“-server”时,默认将会开启分层编译策略,由C1编译器和C2编译器相互协作共同来执行编译任务。

C1和C2编译器不同的优化策略:

  • 在不同的编译器上有不同的优化策略,C1骗译器上主要有方法内联去虚拟化冗余消除
    • 方法内联:将引用的函数代码编译到引用点处,这样可以减少栈帧的生成,减少参数传递以及跳转过程
    • 去虚拟化:对唯一的实现类进行内联
    • 冗余消除:在运行期间把一些不会执行的代码折叠掉
  • C2的优化主要是在全局层面,逃逸分析是优化的基础。基于逃逸分析在C2上有如下几种优化:
    • 标量替换:用标量值代替聚合对象的属性值
    • 栈上分配:对于未逃逸的对象分配对象在栈而不是堆
    • 同步消除:清除同步操作,通常指synchronized

总结:

  • 一般来讲,JIT编译出来的机器码性能比解释器高
  • C2编译器启动时长比C1编译器慢,系统稳定执行以后,C2编译器执行速度远远快于C1编译器。
8、最后补充
  • 关于C1与C2:
    • 自JDK10起,HotSpot又加入一个全新的即时编译器:Graal编译器
    • 编译效果短短几年时间就追评了C2编译器。未来可期。
    • 目前,带着“实验状态”标签,需要使用开关参数去激活,才可以使用。
      • -XX: +UnlockExperimentalVMOptions
      • -XX: +UseJVMCICompiler
  • 关于AOT编译器:(AOT VS JIT)
    • jdk9引入了AOT编译器(静态提前编译器,Ahead Of Time Compiler)
    • Java 9引入了实验性AOT编译工具jaotc。它借助了Graal编译器,将所输入的Java类文件转换为机器码,并存放至生成的动态共享库之中。
    • 所谓AOT编译,是与即时编译相对立的一个概念。我们知道,即时编译指的是在程序的运行过程中,将字节码转换为可在硬件上直接运行的机器码,并部署至托管环境中的过程。而AOT编译指的则是,在程序运行之前,便将字节码转换为机器码的过程。
      • .java -> .class -(jaotc)-> .so(机器指令)
    • 最大好处:Java虛拟机加载已经预编译成二进制库,可以直接执行。不必等待即时编译器的预热,减少Java应用给人带来“第一次运行慢”的不良体验
    • 缺点:
      • 破坏了java“一次编译,到处运行”,必须为每个不同硬件、os编译对应的发行包。
      • 降低了Java链接过程的动态性,加载的代码在编译期就必须全部已知。
      • 还需要继续优化中,最初只支持Linux x64 java base

13、String Table

1、String的基本特性

  • String:字符串,使用一对""引起来表示。

    • // 字面量的方式
      String str = "Hello";
      // new的方式
      String string = new String("Hi");
      
      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
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44

      - String声明为final的,**不可被继承**

      - String实现了Serializable接口:表示字符串是**支持序列化**的

      - String实现了Comparable接口:表示String可以**比较大小**

      - String在JDK8及以前内部定义了final char[] value用于存储字符串数据。**JDK9时改为byte[]**

      - [官方](http://openjdk.java.net/jeps/254
      ):

      ![image-20210425194526787](JVM/image-20210425194526787.png)

      ![image-20210425201622470](JVM/image-20210425201622470.png)

      - String:代表不可变的字符序列。简称:不可变性

      - 当对字符串重新赋值时,需要重写指定内存区域赋值,不能使用原有的value进行赋值
      - 当对现有的字符串进行连接操作时,也需要重新指定内存区域赋值,不能使用原有的value进行赋值
      - 当调用String的replace()方法修改指定字符或字符串时,也需要重新指定内存区域赋值,不能使用原有的value进行赋值。

      - 通过字面量的方式(区别与new)给一个字符串赋值,此时的字符串值声明在字符串常量池中。

      - 字符串常量池中是不会存储相同内容的字符串的

      - String的String Pool时一个固定大小的Hashtable,默认值大小长度是1009。如果放进String Pool的String非常多,就会造成Hash冲突严重,从而导致链表会很长。而链表长了后直接会造成的影响就是当调用String.intern时性能会大幅下降。
      - 使用`-XX: StringTableSize`可设置StringTable的长度
      - 在JDK6中StringTable是固定的,就是1009的长度,所以如果常量池中的字符串过多的就会导致效率下降很快。StringTableSize设置没有要求
      - 在JDK7中,StringTable的长度默认值是60013,StringTableSize设置没有要求
      - 在JDK8开始,设置StringTable的长度的话,1009是可设置的最小值。



      #### 2、String的内存分配

      - 在Java语言中有8种基本数据类型和一种比较特殊的类型String。这些类型为了使它们在运行过程中速度更快、更节省内存,都提供了一种常量池的概念。

      - 常量池就类似一个Java系统级别提供的缓存。8种基本数据类型的常量池都是系统协调的,**String类型的常量池比较特殊。它的主要使用方法有两种**:

      - 直接使用双引号`""`声明出来的String对象会直接存储在常量池中。

      - ```java
      String info = "Hello"
    • 如果不是用双引号""声明的String对象,可以使用String提供的intern()方法。

  • Java6及以前,字符串常量池存放在永久代

  • Java7中Oracle的工程师对字符串池的逻辑做了很大的改变,即将字符串常量池的位置调整到Java堆内

    • 所有的字符串都保存在堆(Heap)中,和其他普通对象一样,这样可以让你在进行调优应用时仅需要调整堆大小就可以了。

    • 字符串常量池概念原本使用比较多,但是这个改动使得我们有足够的理由让我们重新考虑在Java7中使用String.intern()。

    • Java8元空间,字符串常量池在堆

      image-20210425213811970

  • 为什么StringTable要调整?

    • permSize默认比较小
    • 永久代垃圾回收的频率低

    官网

    image-20210425214013347

3、String的基本操作

Java语言规范里要求完全相同的字符串字面量,应该包含同样的Unicode字符序列(包含同一份码点序列的常量),并且必须是指向同一个String类实例

代码:

1
2
3
4
5
6
7
8
9
10
11
12
class Memory {
public static void main(String[] args) {//line 1
int i = 1;//line 2
Object obj = new Object();//line 3
Memory mem = new Memory();//line 4
mem.foo(obj);//line 5
}//line 9
private void foo(Object param) {//line 6
String str = param.toString();//line 7
System.out.println(str);
}//line 8
}

image-20210425215451317

A string is created in line 7.it goes in the String Pool in the heap space and a reference is created in the foo() stack space for it.

4、字符串拼接操作

  1. 常量与插入的拼接结果在常量池,原理是编译期优化
  2. 常量池中不会存在相同内容的常量
  3. 只要其中有一个是变量,结果就在堆(非字符串常量池)中。变量拼接原理是StringBuilder(底层新建了一个StringBuilder对象进行字符串拼接)
  4. 如果拼接的结果调用intern()方法,则主动将常量池中还没有的字符串对象放入池中,并返回此对象地址。

相关案例:

总结:

  • 如果拼接符号的前后出现了变量,则相当于在堆空间中new String(),具体的内容为拼接的结果

  • intern():判断字符串常量池中是否存在javaEEhadoop值,如果存在,则返回常量池中javaEEhadoop的地址;如果字符串常量池中不存在javaEEhadoop,则在常量池中加载一份javaEEhadoop,并返回次对象的地址。

  • 如下的s1 + s2 的执行细节:(变量s是我临时定义的)

    • StringBuilder s = new StringBuilder();

    • s.append(“a”)

    • s.append(“b”)

    • s.toString() –> 约等于 new String(“ab”)

      StringBuilder的toString()方法的new里面放的是char数组,不会在常量池创建对,而new String(“ab”)这里放的是字面量,会先在常量池创对象

    补充:在jdk5.0之后使用的是StringBuilder,在jdk5.0之前使用的是StringBuffer

  • 字符串拼接操作不一定使用的是StringBuilder!

    • 如果拼接符号左右两边都是字符串常量或常量引用,则仍然使用编译期优化,即非StringBuilder的方式。
  • 针对于final修饰类、方法、基本数据类型、引用数据类型的量的结构时,能使用上final的时候建议使用上。

    final修饰的变量一旦赋值后就不能再次赋值,所以可以做编译期优化,但是如果使用final String s = new String(“a”);则不会做编译期优化,必须运行时才能确定。

  • 体会执行效率:通过StringBuilder的append()的方式添加字符串的效率要远高于使用String的字符串拼接方式!

    • 详情:

      • StringBuilder的append()的方式:自始至终中只创建过一个StringBuilder的对象
      • 使用String的字符串拼接方式:创建过多个StringBuilder和String的对象
      • 使用String的字符串拼接方式:内存中由于创建了较多的StringBuilder和String的对象,内存占用更大;如果进行GC,需要花费额外的时间。
    • 改进的空间:在实际开发中,如果基本确定要前前后后添加的字符串长度不高于某个限定值highLevel的情况下,建议使用构造器实例化:

      StringBuilder s = new StringBuilder(highLevel);//new char[highLevel]

代码:

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
public class StringTest5 {
@Test
public void test1(){
String s1 = "a" + "b" + "c";//编译期优化:等同于"abc"
String s2 = "abc"; //"abc"一定是放在字符串常量池中,将此地址赋给s2
/*
* 最终.java编译成.class,再执行.class
* String s1 = "abc";
* String s2 = "abc"
*/
System.out.println(s1 == s2); //true
System.out.println(s1.equals(s2)); //true
}

@Test
public void test2(){
String s1 = "javaEE";
String s2 = "hadoop";

String s3 = "javaEEhadoop";
String s4 = "javaEE" + "hadoop";//编译期优化
//如果拼接符号的前后出现了变量,则相当于在堆空间中new String(),具体的内容为拼接的结果:javaEEhadoop
String s5 = s1 + "hadoop";
String s6 = "javaEE" + s2;
String s7 = s1 + s2;

System.out.println(s3 == s4);//true
System.out.println(s3 == s5);//false
System.out.println(s3 == s6);//false
System.out.println(s3 == s7);//false
System.out.println(s5 == s6);//false
System.out.println(s5 == s7);//false
System.out.println(s6 == s7);//false
//intern():判断字符串常量池中是否存在javaEEhadoop值,如果存在,则返回常量池中javaEEhadoop的地址;
//如果字符串常量池中不存在javaEEhadoop,则在常量池中加载一份javaEEhadoop,并返回次对象的地址。
String s8 = s6.intern();
System.out.println(s3 == s8);//true
}
@Test
public void test3(){
String s1 = "a";
String s2 = "b";
String s3 = "ab";
/*
如下的s1 + s2 的执行细节:(变量s是我临时定义的)
① StringBuilder s = new StringBuilder();
② s.append("a")
③ s.append("b")
④ s.toString() --> 约等于 new String("ab")

补充:在jdk5.0之后使用的是StringBuilder,在jdk5.0之前使用的是StringBuffer
*/
String s4 = s1 + s2;//
System.out.println(s3 == s4);//false
}
/*
1. 字符串拼接操作不一定使用的是StringBuilder!
如果拼接符号左右两边都是字符串常量或常量引用,则仍然使用编译期优化,即非StringBuilder的方式。
2. 针对于final修饰类、方法、基本数据类型、引用数据类型的量的结构时,能使用上final的时候建议使用上。
*/
@Test
public void test4(){
final String s1 = "a";
final String s2 = "b";
String s3 = "ab";
String s4 = s1 + s2;
System.out.println(s3 == s4);//true
}
//练习:
@Test
public void test5(){
String s1 = "javaEEhadoop";
String s2 = "javaEE";
String s3 = s2 + "hadoop";
System.out.println(s1 == s3);//false

final String s4 = "javaEE";//s4:常量
String s5 = s4 + "hadoop";
System.out.println(s1 == s5);//true

}
/*
体会执行效率:通过StringBuilder的append()的方式添加字符串的效率要远高于使用String的字符串拼接方式!
详情:① StringBuilder的append()的方式:自始至终中只创建过一个StringBuilder的对象
使用String的字符串拼接方式:创建过多个StringBuilder和String的对象
② 使用String的字符串拼接方式:内存中由于创建了较多的StringBuilder和String的对象,内存占用更大;如果进行GC,需要花费额外的时间。

改进的空间:在实际开发中,如果基本确定要前前后后添加的字符串长度不高于某个限定值highLevel的情况下,建议使用构造器实例化:
StringBuilder s = new StringBuilder(highLevel);//new char[highLevel]
*/
@Test
public void test6(){
long start = System.currentTimeMillis();
// method1(100000);//4014
method2(100000);//7

long end = System.currentTimeMillis();

System.out.println("花费的时间为:" + (end - start));
}
public void method1(int highLevel){
String src = "";
for(int i = 0;i < highLevel;i++){
src = src + "a";//每次循环都会创建一个StringBuilder、String
}
// System.out.println(src);

}
public void method2(int highLevel){
//只需要创建一个StringBuilder
StringBuilder src = new StringBuilder();
for (int i = 0; i < highLevel; i++) {
src.append("a");
}
// System.out.println(src);
}
}

image-20210425221328973

image-20210425221607026

image-20210425221622886

image-20210425221651006

StringBuilder执行拼接操作:

  • 好处:从始至终就创建了一个stringBuilder对象去执行append操作
  • 改进空间:
    • 可以使用StringBuilder的带参数的构造器,指定大小
    • 如果调用默认构造器,初始容量16,进行大量存储操作时,会导致频繁扩容(数组大小是不可变的,所以得新建数组,然后进行数组间的copy,中间也会产生垃级对象,耗时耗力。

5、intern()的使用

1、Java.lang.String.intern()的相关解释

image-20210426012219855

2、intern()的使用

如果不是用双引号声明的String对象,可以使用String提供的intern方法: intern方法会从字符串常量池中查询当前字符串是否存在,若不存在就会将当前字符串放入常量池中。

  • 比如:

    • String myInfo = new String("I love atguigu").intern();
      
      1
      2
      3
      4
      5

      - 也就是说,如果在任意字符串上调用String.intern方法,那么其返回结果所指向的那个类实例,必须和直接以常量形式出现的字符串实例完全相同。因此,下列表达式的值必定是true:

      - ```java
      ("a" + "b" + "c").intern() == "abc"
  • 通俗点讲,Interned String就是确保字符串在内存里只有一份拷贝,这样可以节约内存空间,加快字符串操作任务的执行速度。注意,这个值会被存放在字符串内部池(String Intern Pool)。

image-20210426020658942

  • 如何保证变量s指向的是字符串常量池中的数据呢?
    • 有两种方式:
      • 方式一: String s = “shkstart”;//字面量定义的方式
      • 方式二: 调用intern()
        •     String s = new String("shkstart").intern();
        •     String s = new StringBuilder("shkstart").toString().intern();
3、面试题题目: new String(“ab”) 会创建几个对象? 拓展: new String(“a”) + new String (“b”)呢?

代码:

1
2
3
4
5
6
7
public class StringNewTest {
public static void main(String[] args) {
// String str = new String("ab");

String str = new String("a") + new String("b");
}
}

题目: new String(“ab”) 会创建几个对象?

  • new String(“ab”)会创建几个对象?看字节码,就知道是两个。
    • 一个对象是:new关键字在堆空间创建的
    • 另一个对象是:字符串常量池中的对象”ab”。 字节码指令:ldc

拓展: new String(“a”) + new String (“b”)呢?

  • 看字节码,知道是五个对象
    • 对象1:new StringBuilder()
    • 对象2: new String(“a”)
    • 对象3: 常量池中的”a”
    • 对象4: new String(“b”)
    • 对象5: 常量池中的”b”
  • 深入剖析: StringBuilder的toString():
    • 对象6 :new String(“ab”)
  • 强调一下,toString()的调用(底层用的是char[]数组创建的字符串),在字符串常量池中,没有生成”ab”
4、intern()的使用: jdk6 vs jdk7/8

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
* 如何保证变量s指向的是字符串常量池中的数据呢?
* 有两种方式:
* 方式一: String s = "shkstart";//字面量定义的方式
* 方式二: 调用intern()
* String s = new String("shkstart").intern();
* String s = new StringBuilder("shkstart").toString().intern();
*/
public class StringIntern {
public static void main(String[] args) {
String s = new String("1");
s.intern();//调用此方法之前,字符串常量池中已经存在了"1"
String s2 = "1";
System.out.println(s == s2);//jdk6:false jdk7/8:false
String s3 = new String("1") + new String("1");//s3变量记录的地址为:new String("11")
//执行完上一行代码以后,字符串常量池中,是否存在"11"呢?答案:不存在!!
s3.intern();//在字符串常量池中生成"11"。如何理解:jdk6:创建了一个新的对象"11",也就有新的地址。
// jdk7:此时常量中并没有创建"11",而是创建一个指向堆空间中new String("11")的地址
String s4 = "11";//s4变量记录的地址:使用的是上一行代码代码执行时,在常量池中生成的"11"的地址
System.out.println(s3 == s4);//jdk6:false jdk7/8:true
}
}

关于s3 == s4在JDK6 与 JDK7/8中答案不同的解析:

  • JDK6:在JDK6中,字符串常量池是放在永久代中

    • 执行到String s3 = new String(“1”) + new String(“1”);的时候,程序在堆空间创建了一片空间用来存放字符串”11”,局部变量s3里存放着字符串”11”在堆空间当中的地址。

      注意:此时的字符串常量池中并不存在字符串”11”(底层调用了StringBuilder的toString()方法,其中的new String使用char[]数组的方式创建了字符串”11”)

    • 执行到s3.intern();的时候,intern方法会从字符串常量池中查询当前字符串是否存在,若不存在就会将当前字符串放入常量池中。然而字符串常量池当中并不存在字符串”11”。所以,程序在字符串常量池中生成”11”对象。

    • 执行到String s4 = “11”;的时候,由于在字符串常量池中存在了字符串”11”,所以局部变量s4里存放了字符串”11”在字符串常量池当中的引用(即地址)

    • 由于变量s3的引用是堆当中字符串对象”11”的地址,而变量s4的引用是字符串常量池中字符串”11”的地址。两者并不相等。

  • JDK7/8:在JDK7/8中,字符串常量池放在堆中

    • 执行String s3 = new String(“1”) + new String(“1”);与上面描述的一样
    • 执行到s3.intern();的时候,由于字符串常量池是存放在堆空间当中,而字符串”11”对象也在堆空间,所以intern()方法在字符串常量池中创建字符串”11”的时候,直接将堆空间中的字符串”11”的地址存放进了字符串常量池的字符串”11”对象当中,即字符串常量池中存放的是堆空间中字符串”11”的引用。即变量s3的引用也指向了字符串常量池的字符串”11”
    • 代码执行到String s4 = “11”;的时候,由于在字符串常量池中存在了字符串”11”,所以局部变量s4里存放了字符串”11”在字符串常量池当中的引用(即地址),然而字符串常量池中的字符串”11”存放的是堆空间的字符串”11”的地址。即:变量s4的引用也指向了堆空间在的字符串”11”的地址
    • 这样一来堆空间与字符串常量池存放的字符串”11”为同一个对象。因此变量s3与变量s4指向的都是同一个地址,使用两者相等。

JDK6的执行图:

image-20210426022637283

JDK7的执行图:

image-20210426022851422

对上面案例在进行扩展:

代码:将 String s4 = “11”; 与 String s5 = s3.intern(); 执行顺序进行互换,并使用s5接收返回值

1
2
3
4
5
6
7
8
9
10
11
public class StringIntern1 {
public static void main(String[] args) {
//StringIntern.java中练习的拓展:
String s3 = new String("1") + new String("1");//new String("11")
//执行完上一行代码以后,字符串常量池中,是否存在"11"呢?答案:不存在!!
String s4 = "11";//在字符串常量池中生成对象"11"
String s5 = s3.intern();//intern方法会从字符串常量池中查询当前字符串是否存在,若存在就会返回该字符串的引用(地址)
System.out.println(s3 == s4);//false
System.out.println(s5 == s4);//true
}
}
5、总结String的intern()方法的使用
  • jdk1. 6中,将这个字符串对象尝试放入串池。
    • 如果字符串常量池中有,则并不会放入。返回已有的字符串常量池中的对象的地址
    • 如果没有,会把此对象复制一份(新建对象),放入字符串常量池,并返回字符串常量池中的对象地址
  • Jdk1.7起,将这个字符串对象尝试放入串池。
    • 如果字符串常量池中有,则并不会放入。返回已有的字符串常量池中的对象的地址
    • 如果没有,则会把对象的引用地址复制一份(没有新建对象),放入字符串常量池,并返回字符串常量池中的引用地址
6、关于intern()方法的两道练习
1、练习1:

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class StringExer1 {
public static void main(String[] args) {
//String x = "ab";
String s = new String("a") + new String("b");//new String("ab")
//在上一行代码执行完以后,字符串常量池中并没有"ab"

String s2 = s.intern();//jdk6中:在串池中创建一个字符串"ab"
//jdk8中:串池中没有创建字符串"ab",而是创建一个引用,指向new String("ab"),将此引用返回

System.out.println(s2 == "ab");//jdk6:true jdk8:true
System.out.println(s == "ab");//jdk6:false jdk8:true
}
}

图示:

image-20210426032250932

放开 String x = “ab”; 注释之后的图示:

image-20210426032752667

2、练习2

代码:

1
2
3
4
5
6
7
8
9
public class StringExer2 {
public static void main(String[] args) {
String s1 = new String("ab");//执行完以后,会在字符串常量池和堆空间中都会生成"ab",不同对象。s1指向的是堆空间当中的字符串"abc"
// String s1 = new String("a") + new String("b");////执行完以后,在堆空间中会生成字符串"abc",但不会在字符串常量池中会生成"ab"
s1.intern();
String s2 = "ab";
System.out.println(s1 == s2);//在jdk7:false
}
}
7、使用intern()测试执行效率:空间角度的使用上

两种方式创建字符串:

1
2
3
arr[i] = new String(String.valueOf(data[i % data.length]));

arr[i] = new String(String.valueOf(data[i % data.length])).intern();

结论:对于程序中大量存在的字符串,尤其其中存在很多重复字符串时,使用intern()可以节省内存空间

应用场景:

大的网站平台,需要内存中存储大量的字符串。比如社交网站,很多人都存储:北京市、海淀区等信息。这时候如果字符串都调用intern()方法,就会明显降低内存的大小。

6、StringTable的垃圾回收

使用new String()的方式和使用new String().intern()的方式创建字符串都会在堆与字符串常量池创建字符串对象,但是为什么在存在大量重复的字符串的时候使用intern()会更节省内存空间呢? =》 答案:StringTable存在垃圾回收。

  • 使用new String()的方式创建字符串不仅仅会在字符串常量池当中创建字符串对象(不重复),还会在堆空间当中创建大量的字符串对象(存在重复),这些堆空间的字符串对象都有一个变量的引用指向,GC不会进行垃圾回收。
  • 使用new String().intern()的方式创建字符串虽然也会在堆空间和字符串常量池创建字符串对象,但是局部变量的指向的是字符串常量池的字符串对象,堆空间的字符串对象虽然也被创建了,但是没有变量的引用指向,会被GC回收。

image-20210426115438284

7、G1的String去重操作

官网

  • 背景:对许多Java应用(有大的也有小的)做的测试得出以下结果:
    • 堆存活数据集合里面String对象占了25%
    • 堆存活数据集合里面重复的String对象有13.5%
    • String对象的平均长度是45
  • 许多大规模的Java应用的瓶颈在于内存,测试表明,在这些类型的应用里面,Java堆中存活的数据集合差不多25%是String对象。更进一步,这里面差不多一半string对象是重复的,重复的意思是说:string1.equals(string2)=true。堆上存在重复的String对象必然是一种内存的浪费。这个项目将在G1垃圾收集器中实现自动持续对重复的String对象进行去重,这样就能避免浪费内存。
  • 实现
    • 当垃圾收集器工作的时候,会访问堆上存活的对象。对每一个访问的对象都会检查是否是候选的要去重的String对象
    • 如果是,把这个对象的一个引用插入到队列中等待后续的处理。一个去重的线程在后台运行,处理这个队列。处理队列的一个元素意味着从队列删除这个元素,然后尝试去重它引用的String对象。
    • 使用一个hashtable来记录所有的被String对象使用的不重复的char数组。当去重的时候,会查这个hashtable,来看堆上是否已经存在一个一模一样的char数组。
    • 如果存在,String对象会被调整引用那个数组,释放对原来的数组的引用,最终会被垃圾收集器回收掉。
    • 如果查找失败,char数组会被插入到hashtable,这样以后的时候就可以共享这个数组了
  • 命令行选项
    • UseStringDeduplication(bool):开启String去重,默认是不开启的,需要手动开启
    • PrintStringDeduplicationStatistics(bool):打印详细的去重统计信息
    • StringDeduplicationAgeThreshold(uintx):达到这个年龄的String对象被认为是去重的候选对象

14、垃圾回收概述

  • 垃圾收集,不是Java语言的伴生产物。早在1960年,第一门开始使用内存动态分配和垃圾收集技术的Lisp语言诞生。
  • 关于垃圾收集有三个经典问题:
    • 哪些内存需要回收?
    • 什么时候回收?
    • 如何回收?
  • 垃圾收集机制是Java的招牌能力,极大地提高了开发效率。如今,垃圾收集几乎成为现代语言的标配,即使经过如此长时间的发展,Java的垃圾收集机制仍然在不断的演进中,不同大小的设备、不同特征的应用场景,对垃圾收集提出了新的挑战,这当然也是面试的热点。

1、什么是垃圾

  • 什么是垃圾( Garbage) 呢
    • 垃圾是指在运行程序中没有任何指针指向的对象,这个对象就是需要被回收的垃圾。
    • 外文: An object is considered garbage when it can no longer be reached from any pointer in the running program.
  • 如果不及时对内存中的垃圾进行清理,那么,这些垃圾对象所占的内存空间会一直保留到应用程序结束,被保留的空间无法被其他对象使用。甚至可能导致内存溢出
  • 内存溢出:存在引用指向不再被使用的对象,导致该对象无法被回收。比如匿名内部类存在指向外部类的引用等等。

2、为什么需要GC

  • 对于高级语言来说,一个基本认知是如果不进行垃圾回收,内存迟早都会被消耗完,因为不断地分配内存空间而不进行回收,就好像不停地生产生活垃圾而从来不打扫一样。
  • 除了释放没用的对象,垃圾回收也可以清除内存里的记录碎片。碎片整理将所占用的堆内存移到堆的一端,以便JVM将整理出的内存分配给新的对象
  • 随着应用程序所应付的业务越来越庞大、复杂,用户越来越多,没有GC就不能保证应用程序的正常进行。而经常造成STW的GC又跟不上实际的需求,所以才会不断地尝试对GC进行优化。

3、早期的垃圾回收

  • 在早期的C/C++时代,垃圾回收基本上是手工进行的。开发人员可以使用new关键字进行内存申请,并使用delete关键字进行内存释放。比如以下代
    码:

    1
    2
    3
    4
    MibBridge *pBridge = new cmBaseGroupBridge();
    //如果注册失败,使用Delete释放该对象所占内存区域
    if (pBridge->Register(kDestroy) != NO_ERROR)
    delete pBridge;
  • 这种方式可以灵活控制内存释放的时间,但是会给开发人员带来频繁申请和释放内存的管理负担。倘若有一处内存区间由于程序员编码的问题忘记被回
    收,那么就会产生内存泄漏,垃圾对象永远无法被清除,随着系统运行时间的不断增长,垃圾对象所耗内存可能持续上升,直到出现内存溢出并造成应用程序崩溃

  • 在有了垃圾回收机制后,,上述代码块极有可能变成这样:

    1
    2
    MibBridge *pBridge = new cmBaseGroupBridge();
    pBridge->Register(kDestroy);
  • 现在,除了Java以外,C#、Python、Ruby等语言都使用了自动垃圾回收的思想,也是未来发展趋势。可以说,这种自动化的内存分配和垃圾回收的方式己经成为现代开发语言必备的标准。

4、java垃圾回收机制

  • 自动内存管理无需开发人员手动参与内存的分配与回收,这样降低内存泄漏和内存溢出的风险

    • 没有垃圾回收器,java也会和cpp一样,各种悬垂指针,野指针,泄漏问题让你头疼不已。
  • 自动内存管理机制,将程序员从繁重的内存管理中释放出来,可以更专心地专注于业务开发

  • oracle官网关于垃圾回收的介绍

  • 对于Java开发人员而言自动内存管理就像是一个黑匣子,如果过度依赖于“自动”,那么这将会是一场灾难,最严重的就会弱化Java开发人员在程序出现内存溢出时定位问题和解决问题的能力

  • 此时,了解JVM的自动内存分配和内存回收原理就显得非常重要,只有在真正了解JVM是如何管理内存后,我们才能够在遇见OutOfMemoryError时,快速地根据错误异常日志定位问题和解决问题

  • 当需要排查各种内存溢出、内存泄漏问题时,当垃圾收集成为系统达到更高并发量的瓶颈时,我们就必须对这些“自动化”的技术**实施必要的监控调节**。

  • 垃圾回收的区域:**(Heap)与方法区(Method Area)**

    image-20210426180331460

  • 垃圾回收器可以对年轻代回收,也可以对老年代回收,甚至是全堆和方法区的回收。

    • 其中,**Java堆是垃圾收集器的工作重点**。
  • 从次数上讲:

    • 频繁收集Young区
    • 较少收集Old区
    • 基本不动方法区(Perm区或元空间)

15、垃圾回收相关算法

  • 判断对象存活
    • 在堆里存放着几乎所有的Java对象实例,在GC执行垃圾回收之前,首先需要区分出内存中哪些是存活对象,哪些是已经死亡的对象。只有被标记为己经死亡的对象,GC才会在执行垃圾回收时,释放掉其所占用的内存空间,因此这个过程我们可以称为垃圾标记阶段
    • 那么在JVM中究竟是如何标记一个死亡对象呢?简单来说,当一个对象已经不再被任何的存活对象继续引用时,就可以宣判为已经死亡。
    • 判断对象存活一般有两种方式:**引用计数算法可达性分析算法**。
  • 回收垃圾
    • 当成功区分出内存中存活对象和死亡对象后,GC接下来的任务就是执行垃圾回收,释放掉无用对象所占用的内存空间,以便有足够的可用内存空间为新对象分配内存
    • 目前在JVM中比较常见的三种垃圾收集算法是:**标记一清除算法(Mark-Sweep)复制算法(Copying)标记-压缩算法(Mark-Compact)**。

1、标记阶段:引用计数算法

1、引用计数算法概述
  • 引用计数算法(Reference Counting)比较简单,对每个对象保存一个整型的引用计数器属性用于记录对象被引用的情况
  • 对于一个对象A,只要有任何一个对象引用了A,则A的引用计数器就加1;当引用失效时,引用计数器就减1。只要对象A的引用计数器的值为0,即表示对象A不可能再被使用,可进行回收。
  • 优点:
    • 实现简单,垃圾对象便于辨识
    • 判定效率高,回收没有延迟性
  • 缺点:
    • 它需要单独的字段存储计数器,这样的做法增加了存储空间的开销
    • 每次赋值都需要更新计数器,伴随着加法和减法操作,这增加了时间开销
    • 引用计数器有一个严重的问题,即无法处理循环引用的情况。这是一条致命缺陷,导致在Java的垃圾回收器中没有使用这类算法
2、循环引用

image-20210426203337926

代码:

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
/**
* -XX:+PrintGCDetails
* 证明:java使用的不是引用计数算法
*/
public class RefCountGC {
//这个成员属性唯一的作用就是占用一点内存
private byte[] bigSize = new byte[5 * 1024 * 1024];//5MB
Object reference = null;
public static void main(String[] args) {
RefCountGC obj1 = new RefCountGC();
RefCountGC obj2 = new RefCountGC();

obj1.reference = obj2;
obj2.reference = obj1;

// 手动断开各自reference的引用
obj1 = null;
obj2 = null;
//显式的执行垃圾回收行为
//这里发生GC,obj1和obj2能否被回收?能 -> java使用的不是引用计数算法
System.gc();
try {
Thread.sleep(1000000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}

图示:

image-20210426204015295

3、小结
  • 引用计数算法,是很多语言的资源回收选择,例如因人工智能而更加火热的Python,它更是同时支持引用计数和垃圾收集机制。
  • 具体哪种最优是要看场景的,业界有大规模实践中仅保留引用计数机制,以提高吞吐量的尝试。
  • Java并没有选择引用计数,是因为其存在一个基本的难题,也就是很难处理循环引用关系。
  • Python如何解决循环引用?
    • 手动解除:很好理解,就是在合适的时机,解除引用关系。
    • 使用弱引用weakref,weakref是Python提供的标准库,旨在解决循环引用。

2、标记阶段:可达性分析算法(或根搜索算法、追踪性垃圾收集)

  • 相对于引用计数算法而言,可达性分析算法不仅同样具备实现简单和执行高效等特点,更重要的是该算法可以有效地解决在引用计数算法中循环引用的问题,防止内存泄漏的发生

  • 相较于引用计数算法,这里的可达性分析就是Java、C#**选择的。这种类型的垃圾收集通常也叫作追踪性垃圾收集**(Tracing GarbageCollection)。

  • 所谓”GC Roots”根集合就是一组必须活跃的引用

  • 基本思路:

    • 可达性分析算法是以根对象集合(GC Roots) 为起始点,按照从上至下的方式搜索被根对象集合所连接的目标对象是否可达
    • 使用可达性分析算法后,内存中的存活对象都会被根对象集合直接或间接连接着,搜索所走过的路径称为引用链(Reference Chain)
    • 如果目标对象没有任何引用链相连,则是不可达的,就意味着该对象己经死亡,可以标记为垃圾对象。
    • 在可达性分析算法中,只有能够被根对象集合直接或者间接连接的对象才是存活对象。
  • 图示

    image-20210426210515944

  • 在Java语言中,GC Roots包括以下几类元素:

    • 虚拟机栈中引用的对象
      • 比如:各个线程被调用的方法中使用到的参数、局部变量等。
    • 本地方法栈内JNI (通常说的本地方法)引用的对象
    • 方法区中类静态属性引用的对象
      • 比如:Java类的引用类型静态变量
    • 方法区中常量引用的对象
      • 比如:字符串常量池(String Table)里的引用
    • 所有被同步锁synchronized持有的对象
    • Java虚拟机内部的引用。
      • 基本数据类型对应的Class对象一些常驻的异常对象(如:NullPointerException、OutOfMemoryError) ,系统类加载器
    • 反映java虛拟机内部情况的JMXBeanJVMTI中注册的回调本地代码缓存等。
  • 图示:

    image-20210426210824260

  • 除了这些固定的GC Roots 集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象“临时性”地加入,共同构成完整GC Roots集合。比如:分代收集局部回收(Partial GC)

    • 如果只针对Java堆中的某一块区域进行垃圾回收(比如:典型的只针对新生代),必须考虑到内存区域是虚拟机自己的实现细节,更不是孤立封闭的,这个区域的对象完全有可能被其他区域的对象所引用,这时候就需要一并将关联的区域对象也加入GC Roots集合中去考虑,才能保证可达性分析的准确性。
  • 小技巧:

    • 由于Root采用栈方式存放变量和指针,所以如果一个指针,它保存了堆内存里面的对象,但是自己又不存放在堆内存里面,那它就是一个Root
  • 注意:

    • 如果要使用可达性分析算法来判断内存是否可回收,那么分析工作必须在一个能保障一致性的快照中进行。这点不满足的话分析结果的准确性就无法保证。
    • 这点也是导致GC进行时必须”Stop The World“的一个重要原因。
      • 即使是号称(几乎)不会发生停顿的CMS收集器中,枚举根节点时也是必须要停顿的

3、对象的finalization机制

  • Java语言提供了对象终止(finalization)机制来允许开发人员提供对象被销毁之前的自定义处理逻辑
  • 当垃圾回收器发现没有引用指向一个对象,即:垃圾回收此对象之前,总会先调用这个对象的finalize()方法
  • finalize()方法允许在子类中被重写,用于在对象被回收时进行资源释放。通常在这个方法中进行一些资源释放和清理的工作,比如关闭文件、套接字和数据库连接等。
  • 永远不要主动调用某个对象的finalize()方法,应该交给垃圾回收机制调用。理由包括下面三点:
    • 在finalize() 时可能会导致对象复活
    • finalize() 方法的执行时间是没有保障的,它完全由GC线程决定,极端情况下,若不发生GC,则finalize()方法将没有执行机会。
    • 一个糟糕的finalize()会严重影响GC的性能
  • 从功能上来说,finalize()方法与C++中的析构函数比较相似,但是Java采用的是基于垃圾回收器的自动内存管理机制,所以finalize()方法在本质上不同于C++中的析构函数。
  • 由于finalize()方法的存在,虚拟机中的对象一般处于三种可能的状态
  • 如果从所有的根节点都无法访问到某个对象,说明对象已经不再使用了。一般来说,此对象需要被回收。但事实上,也并非是“非死不可”的,这时候它们暂时处于“缓刑”阶段。一个无法触及的对象有可能在某一个条件(finalize()方法)下“复活”自己,如果这样,那么对它的回收就是不合理的,为此,定义虚拟机中的对象可能的三种状态。如下:
    • 可触及的:从根节点开始,可以到达这个对象。
    • 可复活的:对象的所有引用都被释放,但是对象有可能在finalize()中复活。
    • 不可触及的:对象的finalize()被调用,并且没有复活,那么就会进入不可触及状态。不可触及的对象不可能被复活,因为finalize()只会被调用一次
  • 以上3种状态中,是由于finalize()方法的存在进行的区分。只有在对象不可触及时才可以被回收
  • 判定一个对象objA是否可回收,至少要经历两次标记过程,具体过程:
    • 如果对象objA到GC Roots没有引用链,则进行第一次标记
    • 进行筛选,判断此对象是否有必要执行finalize()方法
      • 如果对象objA没有重写finalize()方法,或者finalize()方法已经被虚拟机调用过,则虚拟机视为“没有必要执行”,objA被判定为不可触及的。
      • 如果对象objA重写了finalize()方法,且还未执行过,那么objA会被插入到F-Queue队列中,由一个虚拟机自动创建的、低优先级的Finalizer线程触发其finalize()方法执行。
      • finalize()方法是对象逃脱死亡的最后机会,稍后GC会对F-Queue队列中的对象进行第二次标记。如果objA在finalize()方法中与引用链上的任何一个对象建立了联系,那么在第二次标记时,objA会被移出“即将回收”集合。之后,对象会再次出现没有引用存在的情况。在这个情况下,finalize方法不会被再次调用,对象会直接变成不可触及的状态,也就是说,一个对象的finalize方法只会被调用一次

4、MAT与JProfiler的GC Roots溯源

1、MAT概述

MAT是Memory Analyzer的简称,它是一款功能强大的Java堆内存分析器。用于查找内存泄漏以及查看内存消耗情况。

MAT是基于Eclipse开发的,是一款免费的性能分析工具。

大家可以在官网下载并使用MAT。

2、获取dump文件
  1. 命令行使用jmap

    image-20210426214539114

  2. 使用JVisualVM导出

    • 捕获的heap dump文件是一个临时文件,关闭JVisua1VM后自动删除,若要保留,需要将其另存为文件。
    • 可通过以下方法捕获heap dump:
      • 在左侧”Application”(应用程序)子窗口中右击相应的应用程序,选择Heap Dump(堆Dump)。
      • 在Monitor (监视)子标签页中点击Heap Dump (堆Dump)按钮。
    • 本地应用程序的Heap dumps作为应用程序标签页的一个子标签页打开。同时,heap dump 在左侧的Application (应用程序)栏中对应一个含有时间戳的节点。右击这个节点选择save as (另存为)即可将heap dump保存到本地。
3、使用MAT打开heap dump文件.hprof
  1. File -> Open File -> 找到对应的.hprof文件导入

  2. 导入后图示:

    image-20210426220319212

  3. 在MAT中查看GC Roots的方法

    image-20210426220439764

  4. GC Roots的相关展示:详情可查看官网

    image-20210426221159953

4、使用Profiler进行GC Roots溯源
  1. Live memory -> All Objects -> View -> Mark Current Values (查看当前对象的个数)(光标变绿)

    image-20210426223157917

    image-20210426223406880

  2. 选择其中一个占内存较多的类 -> Show Selection In Heap Walker -> References(查看当前类的相关引用)

    image-20210426223532525

    image-20210426223639192

  3. 查看哪个对象在哪里被关联(用来解决内存泄漏问题:查看内存泄漏的相关对象在哪里被引用)

    image-20210426224059101

    image-20210426224309986

5、使用Profiler分析OOM
  1. 可以在代码中使用参数:-XX: +HeapDumpOnOutOfMemoryError,当程序出现OOM的时候在当前目录下自动生成Heap Dump文件

  2. Heap Walker -> Current Object Set -> Biggest Objects查看是否存在占用内存的超大对象

    image-20210426224832591

  3. 在Thread Dump处查看哪个线程的哪个位置出现了OOM

    image-20210426225204897

5、清除阶段:标记-清除算法

  • 背景:

    • 标记一清除算法( Mark-Sweep )是一种非常基础和常见的垃圾收集算法,该算法被J.McCarthy等人在1960年提出并应用于Lisp语言。
  • 执行过程:

    堆中的有效内存空间(available memory)被耗尽的时候,就会停止整个程序(也被称为stop the world) ,然后进行两项工作,第一项则是标记,第二项则是清除

    • 标记:Collector从引用根节点开始遍历,标记所有被引用的对象**。一般是在对象的Header中记录为可达对象**。
    • 清除:Collector对堆内存从头到尾进行线性的遍历,如果发现某个对象在其Header中没有标记为可达对象,则将其回收
  • 图示:

    第14章_标记-清除算法

  • 缺点:

    • 效率不算高
    • 进行GC的时候,需要停止整个应用程序,导致用户体验差
    • 这种方式清理出来的空闲内存是不连续的产生内存碎片
    • 需要维护一个空闲列表
  • 注意:何为清除(透明覆盖)

    • 这里所谓的清除并不是真的置空,而是把需要清除的对象地址保存在空闲的地址列表里
    • 下次有新对象需要加载时,判断垃圾的位置空间是否够,如果够,就存放。

6、清除阶段:复制算法

  • 背景:

    为了解决标记-清除算法在垃圾收集效率方面的缺陷,M.L.Minsky于1963年发表了著名的论文,”使用双存储区的Lisp语言垃圾收集器CALISP Garbage Collector Algorithm Using Serial Secondary Storage)”M.L.Minsky在该论文中描述的算法被人们称为复制(Copying) 算法,它也被M.L.Minsky本人成功地引入到了Lisp语言的一个实现版本中。

  • 核心思想:

    将活着的内存空间分为两块,每次只使用其中一块,在垃圾回收时将正在使用的内存中的存活对象复制到未被使用的内存块中,之后清除正在使用的内存块中的所有对象,交换两个内存的角色,最后完成垃圾回收。

  • 图示:

    第14章_复制算法

  • 优点:

    • 没有标记和清除的过程,实现简单,运行高效
    • 复制过去以后保证空间的连续性,不会出现”碎片”问题
  • 缺点:

    • 需要两倍的内存空间
    • 对于G1这种分拆成为大量region的GC,复制而不是移动,意味着GC需要维护region之间对象引用关系,不管是内存占用或者时间开销也不小。
  • 特别的:

    • 如果系统中的垃圾对象很多,复制算法不会很理想。复制算法需要复制的存活对象数量要求不要太多,或者说非常少才行。
    • 特别适合垃圾对象很多,存活对象很少的场景;例如:Young区的Survivor0和Survivor1区
  • 应用场景:

    在新生代,对常规应用的垃圾回收,一次通常可以回收70% - 99%的内存空间。

    回收性价比很高。所以现在的商业虚拟机都是用这种收集算法回收新生代。

    image-20210427010211524

7、清除阶段:标记-压缩算法

  • 背景:

    • 复制算法的高效性是建立在存活对象少、垃圾对象多的前提下的。这种情况在新生代经常发生,但是在老年代,更常见的情况是大部分对象都是存活对象。如果依然使用复制算法,由于存活对象较多,复制的成本也将很高。因此,基于老年代垃圾回收的特性,需要使用其他的算法
    • 标记-清除算法的确可以应用在老年代中,但是该算法不仅执行效率低下,而且在执行完内存回收后还会产生内存碎片,所以JVM的设计者需要在此基础之上进行改进。标记-压缩(Mark一Compact) 算法由此诞生。
    • 1970年前后,G.L.Steele、C.J.Chene和D.s.Wise等研究者发布标记-压缩算法。在许多现代的垃圾收集器中,人们都使用了标记-压缩算法或其改进版本。
  • 执行过程:

    • 第一阶段和标记清除算法一样,从根节点开始标记所有被引用对象
    • 第二阶段将所有的存活对象压缩到内存的一端,按顺序排放。
    • 之后,清理边界外所有的空间。
  • 图示:

    第14章_标记-压缩算法

  • 标记-压缩算法的最终效果等同于标记-清除算法执行完成后,再进行一次内存碎片整理,因此,也可以把它称为标记-清除-压缩(Mark- Sweep- Compact)算法

  • 二者的本质差异在于标记-清除算法是一种非移动式的回收算法标记-压缩是移动式的是否移动回收后的存活对象是一项优缺点并存的风险决策。

    • 风险:所有引用到存活对象的引用都需要修改
  • 可以看到,标记的存活对象将会被整理,按照内存地址依次排列,而未被标记的内存会被清理掉。如此一来,当我们需要给新对象分配内存时,JVM只需要持有一个内存的起始地址即可,这比维护一个空闲列表显然少了许多开销。

  • 指针碰撞:

    • 如果内存空间以规整和有序的方式分布,即已用和未用的内存都各自一边,彼此之间维系着一个记录下一次分配起始点的标记指针,当为新对象分配内存时,只需要通过修改指针的偏移量将新对象分配在第一个空闲内存位置上,这种分配方式就叫做指针碰撞(Bump the Pointer) 。
  • 优点:

    • 消除了标记-清除算法当中内存区域分散的缺点,我们需要给新对象分配内存时,JVM只需要持有一个内存的起始地址即可
    • 消除了复制算法当中,内存减半的高额代价
  • 缺点:

    • 从效率上来说,标记-整理算法要低于复制算法
    • 移动对象的同时,如果对象被其他对象引用,则还需要调整引用的地址
    • 移动过程中,需要全程暂停用户应用程序。即: STW

8、小结

对比三种清除算法

Mark-Sweep Mark-Compact Copying
速度 中等 最慢 最快
空间开销 少(但会堆积碎片) 少(不会堆积碎片) 通常需要活对象的两倍大小(不堆积碎片)
移动对象
再分配对象空间使用 空闲列表 指针碰撞 指针碰撞

效率上来说,复制算法是当之无愧的老大,但是却浪费了太多内存。

而为了尽量兼顾上面提到的三个指标,标记-整理算法相对来说更平滑一些,但是效率上不尽如人意,它比复制算法多了一个标记的阶段,比标记-清除多了一个整理内存的阶段。

9、分代收集算法

前面所有这些算法中,并没有一种算法可以完全替代其他算法,它们都具有自己独特的优势和特点。分代收集算法应运而生。

分代收集算法,是基于这样一个事实:不同的对象的生命周期是不一样的。因此,不同生命周期的对象可以采取不同的收集方式,以便提高回收效率。一般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点使用不同的回收算法,以提高垃圾回收的效率。

在Java程序运行的过程中,会产生大量的对象,其中有些对象是与业务信息相关,比如Http请求中的Session对象、线程、Socket连接,这类对象跟业务直接挂钩,因此生命周期比较长。但是还有一些对象,主要是程序运行过程中生成的临时变量,这些对象生命周期会比较短,比如:String对象, 由于其不变类的特性,系统会产生大量的这些对象,有些对象甚至只用一次即可回收。

目前几乎所有的GC都是采用分代收集( Generational Collecting) 算法执行垃圾回收的

在HotSpot中,基于分代的概念,GC所使用的内存回收算法必须结合年轻代和老年代各自的特点。

  • 年轻代(Young Gen)
    • 年轻代特点:区域相对老年代较小,对象生命周期短、存活率低,回收频繁
    • 这种情况复制算法的回收整理,速度是最快的。复制算法的效率只和当前存活对象大小有关,因此很适用于年轻代的回收。而复制算法内存利用率不高的问题,通过hotspot中的两个survivor的设计得到缓解
  • 老年代(Tenured Gen)
    • 老年代特点:区域较大,对象生命周期长、存活率高,回收不及年轻代频繁
    • 这种情况存在大量存活率高的对象,复制算法明显变得不合适。一般是由标记-清除或者是标记-清除与标记-整理的混合实现
      • Mark阶段的开销与存活对象的数量成正比
      • Sweep阶段的开销与所管理区域的大小成正比
      • Compact阶段的开销与存活对象的数量成正比

以HotSpot中的CMS回收器为例,CMS是基于Mark- Sweep实现的,对于对象的回收效率很高。而对于碎片问题,CMS采用基于Mark-Compact算法的Serial Old回收器作为补偿措施:当内存回收不佳(碎片导致的Concurrent Mode Failure时),将采用Serial Old执行Full GC以达到对老年代内存的整理

分代的思想被现有的虚拟机广泛使用。几乎所有的垃圾回收器都区分新生代和老年代。

10、增量收集算法、分区算法

上述现有的算法,在垃圾回收过程中,应用软件将处于一种Stop the World的状态。在Stop the World(SWT)状态下,应用程序所有的线程都会挂起,暂停切正常的工作,等待垃圾回收的完成。如果垃圾回收时间过长,应用程序会被挂起很久,将严重影响用户体验或者系统的稳定性。为了解决这个问题,即对实时垃圾收集算法的研究直接导致了增量收集( Incremental Collecting) 算法的诞生。

基本思想:

  • 如果一次性将所有的垃圾进行处理,需要造成系统长时间的停顿,那么就可以让垃圾收集线程和应用程序线程交替执行每次垃圾收集线程只收集一小片区域的内存空间,接着切换到应用程序线程。依次反复,直到垃圾收集完成。(并发思想
  • 总的来说,增量收集算法的基础仍是传统的标记-清除和复制算法。增量收集算法通过对线程间冲突的妥善处理,允许垃圾收集线程以分阶段的方式完成标记、清理或复制工作

优点

  • 使用这种方式,由于在垃圾回收过程中,间断性地还执行了应用程序代码,所以能减少系统的停顿时间

缺点

  • 但是,因为线程切换和上下文转换的消耗,会使得垃圾回收的总体成本上升,造成系统吞吐量的下降

分区算法

  • 一般来说,在相同条件下,堆空间越大,一次GC时所需要的时间就越长,有关GC产生的停顿也越长

  • 为了更好地控制GC产生的停顿时间,将一块大的内存区域分割成多个小块,根据目标的停顿时间,每次合理地回收若干个小区间,而不是整个堆空间,从而减少一次GC所产生的停顿。

  • 分代算法将按照对象的生命周期长短划分成两个部分,分区算法将整个堆空间划分成连续的不同小区间region。

  • 每一个小区间都独立使用,独立回收。这种算法的好处是可以控制一次回收多少个小区间

  • 图示:

    image-20210427023448226

    第14章_分区算法

注意,这些只是基本的算法思路,实际GC实现过程要复杂的多,目前还在发展中的前沿GC都是复合算法,并且并行和并发兼备。


16、垃圾回收相关概念

1、System.gc()的理解

  • 在目录情况下,通过System.gc()或者runtime.getRuntime().gc()的调用,会显式触发Full GC同时对老年代和新时代进行回收,尝试释放被丢弃对象占用的内存。
  • 然而System.gc()调用附带一个免责声明,无法保证对垃圾收集器的调用。
  • JVM实现者可以通过System.gc()调用来觉得JVM的GC行为。而一般情况下,垃圾回收应该是自动进行的,无须手动触发,否则就太过于麻烦了。在一些特殊情况下,如我们正在编写一个性能基准,我们可以在运行之间调用System.gc()。

System.gc()提醒jvm的垃圾回收器执行gc,但是不确定是否马上执行gc,但是调用System.runFinalization()方法可以强制调用使用引用的对象的finalize()方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class SystemGCTest {
public static void main(String[] args) {
new SystemGCTest();
System.gc();//提醒jvm的垃圾回收器执行gc,但是不确定是否马上执行gc
//与Runtime.getRuntime().gc();的作用一样
System.runFinalization();//强制调用使用引用的对象的finalize()方法
}
@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println("SystemGCTest 重写了finalize()");
}
}

System.gc()的相关案例:

image-20210427112323264

问题:调用System.gc()无法保证对垃圾收集器的调用,为什么上述案例中,每次调用都会有垃圾回收信息输出?是进行了GC吗?

2、内存溢出(OOM)与内存泄漏(Memory Leak)

1、内存溢出(OOM)
  • 内存溢出相对于内存泄漏来说,尽管更容易被理解,但是同样的,内存溢出也是引发程序崩溃的罪魁祸首之一。

  • 由于GC一直在发展,所有一般情况下,除非应用程序占用的内存增长速度非常快,造成垃圾回收已经跟不上内存消耗的速度,否则不太容易出现OOM的情况。

  • 大多数情况下,GC会进行各种年龄段的垃圾回收,实在不行了就放大招,来一次独占式的Full GC操作,这时候会回收大量的内存供应用程序继续使用。

  • javadoc中对OutOfMemoryError的解释是:没有空闲内存,并且垃圾收集器也无法提供更多内存

  • 首先说没有空闲内存的情况:说明Java虛拟机的堆内存不够。原因有二:

    1. Java虚拟机的堆内存设置不够

      比如:可能存在内存泄漏问题;也很有可能就是堆的大小不合理,比如我们要处理比较可观的数据量,但是没有显式指定JVM堆大小或者指定数值偏小。我们可以通过参数-Xms、-Xmx来调整

    2. 代码中创建了大量大对象,并且长时间不能被垃圾收集器收集(存在被引用)

      对于老版本的Oracle JDK, 因为永久代的大小是有限的,并且JVM对永久代垃圾回收(如,常量池回收、卸载不再需要的类型)非常不积极,所以当我们不断添加新类型的时候,永久代出现OutOfMemoryError也非常多见,尤其是在运行时存在大量动态类型生成的场合;类似intern字符串缓存占用太多空间,也会导致OOM问题。对应的异常信息,会标记出来和永久代相关:”java.lang.OutOfMemoryError: PermGen space”。

      随着元数据区的引入,方法区内存已经不再那么窘迫,所以相应的OOM有所改观,出现OOM的异常信息则变成了:”java.lang.OutOfMemoryError: Metaspace”。 直接内存不足,也会导致OOM。

  • 这里面隐含着一层意思是,在抛出OutOfMemoryError之前,通常垃圾收集器会被触发,尽其所能去清理出空间。

    • 例如:在引用机制分析中,涉及到JVM会去尝试回收软引用指向的对象等。
    • 在java.nio.BIts.reserveMemory()方法中,我们能清楚的看到,System.gc()会被调用,以清理空间。
  • 当然,也不是在任何情况下垃圾收集器都会被触发的

    • 比如,我们去分配一个超大对象,类似一个超大数组超过堆的最大值,JVM可以判断出垃圾收集并不能解决这个问题,所以直接拋出OutOfMemoryError。
2、内存泄漏(Memory Leak)
  • 也称作”存储渗漏”。严格来说,只有对象不会再被程序用到了,但是GC又不能回收他们的情况,才叫内存泄漏

  • 但实际情况很多时候一些不太好的实践 (或疏忽)会导致对象的生命周期变得很长甚至导致OOM,也可以叫做**宽泛意义上的”内存泄漏”**。

  • 尽管内存泄漏并不会立刻引起程序崩溃,但是一旦发生内存泄漏,程序中的可用内存就会被逐步蚕食,直至耗尽所有内存,最终出现OutOfMemory异常,导致程序崩溃。

  • 注意,这里的存储空间并不是指物理内存,而是指虚拟内存大小,这个虚拟内存大小取决于磁盘交换区设定的大小

  • 图示:

    image-20210427230217504

  • 举例:

    1. 单例模式:

      单例的生命周期和应用程序是一样长的,所以单例程序中,如果持有对外部对象的引用的话,那么这个外部对象是不能被回收的,则会导致内存泄漏的产生。

    2. 一些提供close的资源未关闭导致内存泄漏:

      数据库连接(dataSourse . getConnection()),网络连接(socket)和io连接必须手动close,否则是不能被回收的。

3、Stop The World

  • Stop-The-World,简称STW,指的是GC事件发生过程中,会产生应用程序的停顿。停顿产生时整个应用程序线程都会被暂停,没有任何响应,有点像卡死的感觉,这个停顿称为STW。
    • 可达性分析算法中枚举根节点(GC Roots)会导致所有Java执行线程停顿
      • 分析工作必须在一个能确保一致性的快照中进行
      • 一致性指整个分析期间整个执行系统看起来像被冻结在某个时间点上
      • 如果出现分析过程中对象引用关系还在不断变化,则分析结果的准确性无法保证
  • 被STW中断的应用程序线程会在完成GC之后恢复,频繁中断会让用户感觉像是网速不快造成电影卡带一样,所以我们需要减少STW的发生。
  • STW事件和采用哪款GC无关,所有的GC都有STW这个事件
  • 哪怕是G1也不能完全避免Stop-the-world情况发生,只能说垃圾回收器越来越优秀,回收效率越来越高,尽可能地缩短了暂停时间。
  • STW是JVM在后台自动发起和自动完成的。在用户不可见的情况下,把用户正常的工作线程全部停掉。
  • 开发中不要用System.gc();会导致Stop-the-world的发生

4、垃圾回收的并行与并发

1、并发(Concurrent)
  • 在操作系统中,是指一个时间段中有几个程序都处于已启动运行到运行完毕之间,且这几个程序都是在同一个处理器上运行。
  • 并发不是真正意义上的”同时进行”,只是CPU把一个时间段分成几个时间片段(时间区间),然后在这几个时间区间之间来回切换,由于CPU处理的速度非常快,主要时间间隔处理得当,即可让用户感觉是多个应用程序在同时运行。

image-20210427231222179

2、并行(Parallel)
  • 当系统有一个以上CPU时,当一个CPU执行一个进程时,另一个CPU可以执行另一个进程,两个进程互不抢占CPU资源,可以同时进行,我们称之为并行(Parallel)。
  • 其实决定并行的因素不是CPU的数量,而是CPU的核心数量,比如一个CPU多个核也可以并行。
  • 适合科学计算,后台处理等弱交互场景

image-20210427231302158

3、并行 VS 并发

二者对比:

  • 并发,指的是多个事情,在同一时间段内同时发生了
  • 并行,指的是多个事情,在同一时间点上同时发生了
  • 并发的多个任务之间是互相抢占资源的。
  • 并行的多个任务之间是不互相抢占资源的。
  • 只有在多CPU或者一个CPU多核的情况中,才会发生并行
  • 否则,看似同时发生的事情,其实都是并发执行的
4、垃圾回收的并发与并行

并发和并行,在谈论垃圾收集器的上下文语境中,它们可以解释如下:

  • 并行(Parallel):指多条垃圾收集线程并行工作,但此时用户线程仍处于等待状态

    • 如并行的垃圾回收器:ParNew、 Parallel Scavenge、 Parallel Old;
  • 串行(Serial)

    • 相较于并行的概念,单线程执行
    • 如果内存不够,则程序暂停,启动JVM垃圾回收器进行垃圾回收。回收完,再启动程序的线程。

    图示:

    image-20210427231437839

  • 并发(Concurrent):指用户线程与垃圾收集线程同时执行(但不一定是并行的,可能会交替执行),垃圾回收线程在执行时不会停顿用户程序的运行

    • 用户程序在继续运行,而垃圾收集程序线程运行于另一个CPU上
    • 如: CMS、G1

    图示:

    image-20210427231919364

5、安全点与安全区域

1、安全点(Safepoint)

程序执行时并非在所有地方都能停顿下来开始GC,只有在特定的位置才能停顿下来开始GC,这些位置称为”安全点(Safepoint)”

Safe Point的选择很重要,如果太少可能导致GC等待的时间太长如果太频繁可能导致运行时的性能问题。大部分指令的执行时间都非常短暂,通常会根据“是否具有让程序长时间执行的特征”为标准。

比如:选择些执行时间较长的指令作为Safe Point,如方法调用循环跳转异常跳转等。

如何在GC发生时,检查所有线程都跑到最近的安全点停顿下来呢?

  • 抢先式中断:(目前没有虚拟机采用了)

    首先中断所有线程。如果还有线程不在安全点,就恢复线程,让线程跑到安全点。

  • 主动式中断

    设置一个中断标志,各个线程运行到Safe Point的时候主动轮询这个标志,如果中断标志为真,则将自己进行中断挂起。

2、安全区域

Safepoint机制保证了程序执行时,在不太长的时间内就会遇到可进入GC的Safepoint 。但是,程序“不执行”的时候呢?

例如线程处于 Sleep 状态或 Blocked 状态,这时候线程无法响应JVM 的中断请求,”走”到安全点去中断挂起,JVM也不太可能等待线程被唤醒。对于这种情况,就需要安全区域(Safe Region)来解决。

安全区域是指在一段代码片段中,对象的引用关系不会发生变化,在这个区域中的任何位置开始GC都是安全的。我们也可以把 Safe Region 看做是被扩展了的Safepoint。

实际执行时:

  1. 当线程运行到Safe Region的代码时,首先标识已经进入了Safe Region,如果这段时间内发生GC,JVM会忽略标识为Safe Region状态的线程;
  2. 当线程即将离开Safe Region时,会检查JVM是否已经完成GC,如果完成了则继续运行,否则线程必须等待直到收到可以安全离开Safe Region的信号为止;

6、在谈引用

我们希望能描述这样一类对象:当内存空间还足够时,则能保留在内存中;如果内存空间在进行垃圾收集后还是很紧张,则可以抛弃这些对象。

[既偏门非常高频的面试题]强引用、软引用、弱引用、虚引用有什么区别? 具体使用场景是什么?

在JDK 1.2版之后,Java对引用的概念进行了扩充,将引用分为强引用(StrongReference)软引用(Soft Reference)弱引用(Weak Reference)虚引用(Phantom Reference) 4种,这4种引用强度依次逐渐减弱。(强软弱虚)

强引用外,其他3种引用均可以在java.lang.ref包中找到它们的身影。如下图,显示了这3种引用类型对应的类,开发人员可以在应用程序中直接使用它们。

image-20210427233208635

Reference子类中只有终结器引用是包内可见的,其他3种引用类型均为public,可以在应用程序中直接使用

  • 强引用(StrongReference)**:最传统的“引用”的定义,是指在程序代码之中普遍存在的引用赋值,即类似“Object obj=new Object()”这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。(不回收**)
  • 软引用(SoftReference)**:在系统将要发生内存溢出之前,将会把这些对象列入回收范围之中进行第二次回收。如果这次回收后还没有足够的内存,才会抛出内存溢出异常。(内存不足即回收**)
  • 弱引用(WeakReference)**:被弱引用关联的对象只能生存到下一次垃圾收集之前。当垃圾收集器工作时,无论内存空间是否足够,都会回收掉被弱引用关联的对象。(发现即回收**)
  • 虚引用(PhantomReference)**:一个对象是否有虛引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来获得一个对象的实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。(对象回收的跟踪**)

7、再谈引用:强引用Strong Reference(不回收)

在Java程序中,最常见的引用类型是强引用(普通系统99%以上都是强引用),也就是我们最常见的普通对象引用,也是默认的引用类型

当在Java语言中使用new操作符创建一个新的对象, 并将其赋值给一个变量的时候, 这个变量就成为指向该对象的一个强引用。

强引用的对象是可触及的,垃圾收集器就永远不会回收掉被引用的对象。

对于一个普通的对象,如果没有其他的引用关系,只要超过了引用的作用域或者显式地将相应(强)引用赋值为null,就是可以当做垃圾被收集了,当然具体回收时机还是要看垃圾收集策略。

相对的,软引用、 弱引用和虚引用的对象是软可触及、弱可触及和虛可触及的,在一定条件下,都是可以被回收的。所以,强引用是造成Java内存泄漏的主要原因之一

强引用例子:

1
StringBuffer str = new StringBuffer ("Hello");

局部变量str指向StringBuffer实例所在堆空间,通过str可以操作该实例,那么str就是StringBuffer实例的强引用

对应内存结构:image-20210428093616812

此时,如果再运行一个赋值语句:

1
StringBuffer str1 = str;

对应内存结构:image-20210428093737403

本例中的两个引用,都是强引用,强引用具备以下特点:

  • 强引用可以直接访问目标对象
  • 强引用所指向的对象在任何时候都不会被系统回收,虚拟机宁愿抛出OOM异常,也不会回收强引用所指向对象
  • 强引用可能导致内存泄漏

8、再谈引用:软引用Soft Reference(内存不足即回收)

软引用是用来描述一些还有用,但非必需的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。

软引用通常用来实现内存敏感的缓存。比如:高速缓存就有用到软引用。如果还有空闲内存,就可以暂时保留缓存,当内存不足时清理掉,这样就保证了使用缓存的同时,不会耗尽内存。实例:Mybatis的一些内部类中就使用了软引用

垃圾回收器在某个时刻决定回收软可达的对象的时候,会清理软引用,并可选地把引用存放到一个引用队列(Reference Queue) 。

类似弱引用,只不过Java虚拟机会尽量让软引用的存活时间长一些,迫不得已才清理。

在java doc中,软引用是这样描述的:

虚拟机在抛出 OutOfMemoryError 之前会保证所有的软引用对象已被清除。此外,没有任何约束保证软引用将在某个特定的时间点被清除,或者确定一组不同的软引用对象被清除的顺序。不过,虚拟机的具体实现会倾向于不清除最近创建或最近使用过的软引用

软引用在我们的日常开发中使用的场景很多,比如商城中商品的信息。某个商品可能会被多人访问,此时我们可以把该商品的信息使用软引用保存。当系统内存足够时,可以实现高速查找,当系统内存不足又会被回收,避免OOM的风险。

注意:

尽管软引用会在OOM之前被清理,但是,这并不表示Full GC会清理软引用对象。在经过Full GC后我们的软引用对象都放入了old区,由于Full GC的存在,程序大多数情况下并不会OOM。由于软引用对象占据了老年代的空间,Full GC将执行的更为频繁。所以还是建议使用弱引用。

当然,我们可以通过参数:-XX:SoftRefLRUPolicyMSPerMB=0来设置当Full GC时回收软引用。其中参数值为Full GC保留的 SoftReference 数量,参数值越大,GC 后保留的软引用对象就越多。设置这个参数值为0时,Full GC就会回收我们的软引用对象了。

在JDK 1.2版之后提供了java.lang.ref.SoftReference类来实现软引用。

1
2
3
Object obj = new Object(); //声明强引用
SoftReference<Object> sf = new SoftReference<Object>(obj);
obj = null; //销毁强引用

9、再谈引用:弱引用Weak Reference(发现即回收)

弱引用也是用来描述那些非必需对象,只被弱引用关联的对象只能生存到下一次垃圾收集发生为止。在系统GC时,只要发现弱引用,不管系统堆空间使用是否充足,都会回收掉只被弱引用关联的对象。

但是,由于垃圾回收器的线程通常优先级很低,因此,并不一定能很快地发现持有弱引用的对象。在这种情况下,弱引用对象可以存在较长的时间

弱引用和软引用一样,在构造弱引用时,也可以指定一个引用队列,当弱引用对象被回收时,就会加入指定的引用队列,通过这个队列可以跟踪对象的回收情况

软引用、弱引用都非常适合来保存那些可有可无的缓存数据。如果这么做,当系统内存不足时,这些缓存数据会被回收,不会导致内存溢出。而当内存资源充足时,这些缓存数据又可以存在相当长的时间,从而起到加速系统的作用。

实例:

  • 三级缓存(内存(弱引用) -> 本地 -> 网络)
  • ThreadLocal的内部实现就是一个ThreadLocalMap,该mapEntrykeyThreadLocal本身,value为我们向ThreadLocal对象set的值,其中的key就是弱引用对象
  • 集合WeakHashMap,都是使用了弱引用实现的

在JDK 1.2版之后提供了java.lang.ref.WeakReference类来实现弱引用。

1
2
3
Object obj = new Object(); //声明强引用
WeakReference<Object> wr = new WeakReference<Object>(obj);
obj = null; //销毁强引用

弱引用对象与软引用对象的最大不同就在于,当GC在进行回收时,需要通过算法检查是否回收软引用对象,而对于弱引用对象,GC总是进行回收弱引用对象更容易、更快被GC回收

面试题:你开发中使用过WeakHashMap吗?

10、再谈引用:虚引用Phantom Reference(对象回收跟踪)

也称为“幽灵引用”或者“幻影引用”,是所有引用类型中最弱的一个

一个对象是否有虚引用的存在,完全不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它和没有引用几乎是一样的,随时都可能被垃圾回收器回收。

不能单独使用,也无法通过虚引用来获取被引用的对象。当试图通过虚引用的get()方法取得对象时,总是null

为一个对象设置虚引用关联的唯一目的在于跟踪垃圾回收过程。比如:能在这个对象被收集器回收时收到一个系统通知。

虚引用必须和引用队列一起使用。虚引用在创建时必须提供一个引用队列作为参数。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象后,将这个虚引用加入引用队列,以通知应用程序对象的回收情况

由于虚引用可以跟踪对象的回收时间,因此,也可以将一些资源释放操作放置在虚引用中执行和记录

在JDK 1.2版之后提供了PhantomReference类来实现虚引用。

1
2
3
4
Object obj = new Object();
ReferenceQueue phantomQueue = new ReferenceQueue( );
PhantomReference<object> pf = new PhantomReference<object>(obj, phantomQueue);
obj = null;

对象回收跟踪的代码实例:

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
import java.lang.ref.PhantomReference;
import java.lang.ref.ReferenceQueue;

/**
* 虚引用的测试
*/
public class PhantomReferenceTest {
public static PhantomReferenceTest obj;//当前类对象的声明
static ReferenceQueue<PhantomReferenceTest> phantomQueue = null;//引用队列

// 守护线程,用来跟踪对象的回收
public static class CheckRefQueue extends Thread {
@Override
public void run() {
while (true) {
if (phantomQueue != null) {
PhantomReference<PhantomReferenceTest> objt = null;
try {
objt = (PhantomReference<PhantomReferenceTest>) phantomQueue.remove();
} catch (InterruptedException e) {
e.printStackTrace();
}
if (objt != null) {
System.out.println("追踪垃圾回收过程:PhantomReferenceTest实例被GC了");
}
}
}
}
}

@Override
protected void finalize() throws Throwable { //finalize()方法只能被调用一次!
super.finalize();
System.out.println("调用当前类的finalize()方法");
obj = this;
}

public static void main(String[] args) {
Thread t = new CheckRefQueue();
t.setDaemon(true);//设置为守护线程:当程序中没有非守护线程时,守护线程也就执行结束。
t.start();

phantomQueue = new ReferenceQueue<PhantomReferenceTest>();
obj = new PhantomReferenceTest();
//构造了 PhantomReferenceTest 对象的虚引用,并指定了引用队列
PhantomReference<PhantomReferenceTest> phantomRef = new PhantomReference<PhantomReferenceTest>(obj, phantomQueue);

try {
//不可获取虚引用中的对象
System.out.println(phantomRef.get());
//将强引用去除
obj = null;
//第一次进行GC,由于对象可复活,GC无法回收该对象
System.gc();
Thread.sleep(1000);
if (obj == null) {
System.out.println("obj 是 null");
} else {
System.out.println("obj 可用");
}
System.out.println("第 2 次 gc");
obj = null;
System.gc(); //一旦将obj对象回收,就会将此虚引用存放到引用队列中。
Thread.sleep(1000);
if (obj == null) {
System.out.println("obj 是 null");
} else {
System.out.println("obj 可用");
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}

11、再谈引用:终结器引用Final reference

  • 它用以实现对象的finalize()方法,也可以称为终结器引用
  • 无需手动编码,其内部配合引用队列使用。
  • 在GC时,终结器引用入队。由Finalizer线程通过终结器引用找到被引用对象并调用它的finalize()方法,第二次GC时才能回收被引用对象

17、垃圾回收器

1、GC分类与性能指标

1、垃圾回收器概述
  • 垃圾收集器没有在规范中进行过多的规定,可以由不同的厂商、不同版本的JVM来实现。
  • 由于JDK的版本处于高速迭代过程中,因此Java发展至今已经衍生了众多的GC版本。
  • 从不同角度分析垃圾收集器,可以将GC分为不同的类型。
2、垃圾回收器分类
1、按线程数分,可以分为串行垃圾回收器并行垃圾回收器

image-20210428111017627

  • 串行回收指的是在同一时间段内只允许有一个CPU用于执行垃圾回收操作,此时工作线程被暂停,直至垃圾收集工作结束。
    • 在诸如单CPU处理器或者较小的应用内存等硬件平台不是特别优越的场合,串行回收器的性能表现可以超过并行回收器和并发回收器。所以,串行回收默认被应用在客户端的Client模式下的JVM中
    • 在并发能力比较强的CPU上,并行回收器产生的停顿时间要短于串行回收器。
  • 和串行回收相反,并行收集可以运用多个CPU同时执行垃圾回收,因此提升了应用的吞吐量,不过并行回收仍然与串行回收一样,采用独占式,使用了“Stop-the-world”机制。
2、按照工作模式分,可以分为并发式垃圾回收器独占式垃圾回收器
  • 并发式垃圾回收器与应用程序线程交替工作,以尽可能减少应用程序的停顿时间。
  • 独占式垃圾回收器(Stop the world)一旦运行,就停止应用程序中的所有用户线程,直到垃圾回收过程完全结束。

image-20210428111338892

3、按碎片处理方式分,可分为压缩式垃圾回收器非压缩式垃圾回收器
  • 压缩式垃圾回收器会在回收完成后,对存活对象进行压缩整理,消除回收后的碎片。
    • 再分配对象空间使用:指针碰撞
  • 非压缩式的垃圾回收器不进行这步操作。
    • 再分配对象空间使用:空闲列表
4、按工作的内存区间分,又可分为年轻代垃圾回收器老年代垃圾回收器
3、评估GC的性能指标
  • 吞吐量:运行用户代码的时间占总运行时间的比例
    • (总运行时间:程序的运行时间十内存回收的时间)
  • 垃圾收集开销:吞吐量的补数,垃圾收集所用时间与总运行时间的比例。
  • 暂停时间:执行垃圾收集时,程序的工作线程被暂停的时间。
  • 收集频率:相对于应用程序的执行,收集操作发生的频率。
  • 内存占用:Java堆区所占的内存大小。
  • 快速:一个对象从诞生到被回收所经历的时间。

关于吞吐量、暂停时间与内存占用:

  • 这三者共同构成一个“不可能三角”。三者总体的表现会随着技术进步而越来越好。一款优秀的收集器通常最多同时满足其中的两项。
  • 这三项里,暂停时间的重要性日益凸显。因为随着硬件发展,内存占用多些越来越能容忍,硬件性能的提升也有助于降低收集器运行时对应用程序的影响,即提高了吞吐量。而内存的扩大,对延迟反而带来负面效果。
  • 简单来说,主要抓住两点:
    • 吞吐量
    • 暂停时间
1、吞吐量(throughput)
  • 吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比值,即吞吐量=运行用户代码时间 / (运行用户代码时间+垃圾收集时间)

    • 比如:虚拟机总共运行了100分钟,其中垃圾收集花掉1分钟,那吞吐量就是99%。
  • 这种情况下,应用程序能容忍较高的暂停时间,因此,高吞吐量的应用程序有更长的时间基准,快速响应是不必考虑的。

  • 吞吐量优先,意味着在单位时间内,STW的时间最短:0.2 + 0.2 = 0.4

    image-20210428111925067

2、暂停时间(pause time)
  • “暂停时间”是指一个时间段内应用程序线程暂停,让GC线程执行的状态

    • 例如,GC期间100毫秒的暂停时间意味着在这100毫秒期间内没有应用程序线程是活动的。
  • 暂停时间优先,意味着尽可能让单次STW的时间最短: 0.1 + 0.1 + 0.1 + 0.1+0.1=0.5

    image-20210428112108819

3、吞吐量 VS 暂停时间
  • 高吞吐量较好因为这会让应用程序的最终用户感觉只有应用程序线程在做“生产性”工作。直觉上,吞吐量越高程序运行越快。
  • 低暂停时间(低延迟)较好因为从最终用户的角度来看不管是GC还是其他原因导致一个应用被挂起始终是不好的。这取决于应用程序的类型,有时候甚至短暂的200毫秒暂停都可能打断终端用户体验。因此,具有低的较大暂停时间是非常重要的,特别是对于一个交互式应用程序
  • 不幸的是”高吞吐量”和”低暂停时间”是一对相互竞争的目标(矛盾)。
    • 因为如果选择以吞吐量优先,那么必然需要降低内存回收的执行频率,但是这样会导致GC需要更长的暂停时间来执行内存回收
    • 相反的,如果选择以低延迟优先为原则,那么为了降低每次执行内存回收时的暂停时间,也只能频繁地执行内存回收,但这又引起了年轻代内存的缩减和导致程序吞吐量的下降
  • 在设计(或使用) GC算法时,我们必须确定我们的目标:一个GC算法只可能针对两个目标之一(即只专注于较大吞吐量或最小暂停时间),或尝试找到一个二者的折衷。
  • 现在标准:在最大吞吐量优先的情况下,降低停顿时间

2、不同的垃圾回收器概述

1、垃圾回收器发展史

有了虚拟机,就一定需要收集垃圾的机制,这就是Garbage Collection, 对应的产品我们称为Garbage Collector(GC)。

  • 1999年随JDK1.3.1一起来的是串行方式的Serial GC,它是第一款GC。ParNew垃圾收集器是Serial收集器的多线程版本
  • 2002年2月26日,Parallel GC和Concurrent Mark Sweep GC跟随JDK1.4.2一起发布
  • Parallel GC在JDK6之后成为HotSpot默认GC。
  • 2012年,在JDK1.7u4版本中,G1可用。
  • 2017年,JDK9中G1变成默认的垃圾收集器,以替代CMS。
  • 2018年3月,JDK10中G1垃圾回收器的并行完整垃圾回收,实现并行性来改善最坏情况下的延迟。
  • —–分水岭——
  • 2018年9月,JDK11发布。引入Epsilon垃圾回收器,又被称为”No-Op (无操作) “回收器。同时引入ZGC:可伸缩的低延迟垃圾回收器(Experimental)。
  • 2019年3月,JDK12发布。增强G1,自动返回未用堆内存给操作系统。同时,引入Shenandoah GC:低停顿时间的GC (Experimental)。
  • 2019年9月,JDK13发布。增强ZGC,自动返回未用堆内存给操作系统。
  • 2020年3月,JDK14发布。删除CMS垃圾回收器。扩展ZGC在macOS和Windows上的应用
2、七款经典的垃圾收集器
  • 串行回收器: Serial、Serial Old
  • 并行回收器: ParNew、Parallel Scavenge、Parallel Old
  • 并发回收器: CMS、G1

image-20210429023423991

七款经典收集器与垃圾分代之间的关系

  • 新生代收集器: Serial、ParNew、Parallel Scavenge;
  • 老年代收集器:Serial Old、Parallel Old、CMS;
  • 整堆收集器:G1;

image-20210429024547757

垃圾收集器的组合关系

image-20210429023609239

  1. 两个收集器间有连线,表明它们可以搭配使用:

    Serial/Serial Old、Serial/CMS、ParNew/Serial Old、ParNew/CMS、Parallel Scavenge/Serial Old、Parallel Scavenge/Parallel Old、G1;

  2. 其中Serial Old作为CMS出现”Concurrent Mode Failure” 失败的后备预案。

  3. (红色虚线)由于维护和兼容性测试的成本,在JDK 8时将Serial+CMS、ParNew+Serial Old这两个组合声明为废弃(JEP 173),并在JDK 9中完全取消了这些组合的支持(JEP214) ,即:移除。

  4. (绿色虚线)JDK 14中:弃用Parallel Scavenge和Serial Old GC组合(JEP366)

  5. (青色虚线)JDK 14中:删除CMS垃圾回收器 (JEP 363)

为什么要有很多收集器,一个不够吗?

因为Java的使用场景很多,移动端,服务器等。所以就需要针对不同的场景,提供不同的垃圾收集器,提高垃圾收集的性能。

虽然我们会对各个收集器进行比较,但并非为了挑选一个最好的收集器出来。没有一种放之四海皆准、任何场景下都适用的完美收集器存在,更加没有万能的收集器。所以我们选择的只是对具体应用最合适的收集器

如何查看默认的垃圾收集器

  • XX:+PrintCommandLineFlags:查看命令行相关参数(包含使用的垃圾收集器)
  • 使用命令行指令:jinfo -flag 相关垃圾回收器参数进程ID

3、Serial回收器:串行回收

  • Serial收集器是最基本、历史最悠久的垃圾收集器了。JDK1. 3之前回收新生代唯一的选择。

  • Serial收集器作为HotSpot中Client模式下的默认新生代垃圾收集器

  • Serial收集器采用复制算法串行回收和**”Stop-the-World”机制**的方式执行内存回收。

  • 除了年轻代之外,Serial收集器还提供用于执行老年代垃圾收集的Serial Old收集器。Serial Old收集器同样也采用了串行回收和”Stop the World”机制, 只不过内存回收算法使用的是标记-压缩算法

    • Serial Old是运行在Client模式下默认的老年代的垃圾回收器
    • Serial Old在Server模式下主要有两个用途:
      • 与新生代的Parallel Scavenge配合使用
      • 作为老年代CMS收集器的后备垃圾收集方案
  • 这个收集器是一个单线程的收集器,但它的“单线程”的意义并不仅仅说明它只会使用一个CPU或一条收集线程去完成垃圾收集工作,更重要的是在它进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束(Stop The World) 。

    image-20210429085028563

  • 优势:简单而高效(与其他收集器的单线程比),对于限定单个CPU的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。

    • 运行在Client模式下的虚拟机是个不错的选择。
  • 用户的桌面应用场景中,可用内存一般不大(几十MB至一两百MB),可以在较短时间内完成垃圾收集(几十ms至一百多ms) , 只要不频繁发生,使用串行回收器是可以接受的。

  • 在HotSpot虚拟机中,使用-XX: +UseSerialGC参数可以指定年轻代和老年代都使用串行收集器

    • 等价于新生代用Serial GC, 且老年代用Serial Old GC
  • 总结:

    • 这种垃圾收集器大家了解,现在已经不用串行的了。而且在限定单核cpu才可以用。现在都不是单核的了。
    • 对于交互较强的应用而言,这种垃圾收集器是不能接受的。一般在Java web应用程序中是不会采用串行垃圾收集器的。

4、ParNew回收器:并行回收

  • 如果说Serial GC是年轻代中的单线程垃圾收集器,那么ParNew收集器则是Serial收集器的多线程版本

    • Par是Parallel的缩写,New:只能处理的是新生代
  • ParNew收集器除了采用并行回收的方式执行内存回收外,两款垃圾收集器之间几乎没有任何区别。ParNew收集器在年轻代中同样也是采用复制算法、”Stop-the-World”机制

  • ParNew是很多JVM运行在Server模式下新生代的默认垃圾收集器

  • 对于新生代,回收次数频繁,使用并行方式高效。

  • 对于老年代,回收次数少,使用串行方式节省资源。 (CPU并行需要切换线程,串行可以省去切换线程的资源)

    image-20210429085347499

  • 由于ParNew收集器是基于并行回收,那么是否可以断定ParNew收集器的回收效率在任何场景下都会比Serial收集器更高效?

    • ParNew
      收集器运行在多CPU的环境下,由于可以充分利用多CPU、多核心等物理硬件资源优势,可以更快速地完成垃圾收集,提升程序的吞吐量。
    • 但是在单个CPU的环境下,ParNew收集器不比Serial收集器更高效。虽然Serial收集器是基于串行回收,但是由于CPU不需要频繁地
      做任务切换,因此可以有效避免多线程交互过程中产生的一些额外开销。
  • 因为除Serial外,目前只有ParNew GC能与CMS收集器配合工作

  • 在程序中,开发人员可以通过选项”-XX: +UseParNewGC“手动指定使用ParNew收集器执行内存回收任务。它表示年轻代使用并行收集器,不影
    响老年代

  • -XX: ParallelGCThreads限制线程数量,默认开启和CPU数据相同的线程数。(一般不超过CPU的数据)

5、Parallel回收器:吞吐量优先

  • HotSpot的年轻代中除了拥有ParNew收集器是基于并行回收的以外,Parallel Scavenge收集器同样也采用了复制算法并行回收和**”Stop the World”机制**。

  • 那么Parallel收集器的出现是否多此一举?

    • 和ParNew收集器不同,Parallel Scavenge收集器的目标则是达至一个可控制的吞吐量(Throughput)**,它也被称为吞吐量优先的垃圾收集器**。
    • 自适应调节策略也是Parallel Scavenge 与ParNew一个重要区别。
  • 高吞吐量则可以高效率地利用CPU时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。因此,常见在服务器环境中使用。例如,那些执行批量处理、订单处理、工资支付、科学计算的应用程序

  • Parallel收集器在JDK1.6时提供了用于执行老年代垃圾收集的Parallel Old收集器,用来代替老年代的Serial Old收集器

  • Parallel Old收集器采用了标记-压缩算法,但同样也是基于并行回收和**”Stop-the-World”机制**。

  • 在程序吞吐量优先的应用场景中,Parallel收集器和Parallel Old收集器的组合,在Server模式下的内存回收性能很不错。

  • 在Java8中,默认是此垃圾收集器

    image-20210429090344976

  • 参数配置:

    • -XX: +UseParallelGC手动指定年轻代使用Parallel并行收集器执行内存回收任务
    • -XX: +UseParallelOldGC手动指定老年代都是使用并行回收收集器
      • 分别适用于新生代和老年代。默认jdk8是开启的
      • 上面两个参数,默认开启一个,另一个也会被开启。 (互相激活)
    • -XX: parallelGCThreads设置年轻代并行收集器的线程数。一般地,最好与CPU数量相等,以避免过多的线程数影响垃圾收集性能
      • 在默认情况下,当CPU数量小于8个, ParallelGCThreads的值等于CPU数量。
      • 当CPU数量大于8个,ParallelGCThreads 的值等于3+[5 * CPU_Count] / 8]
    • -XX:MaxGCPaulseMillis设置垃圾收集器最大停顿时间(即STW的时间)。单位是毫秒
      • 为了尽可能地把停顿时间控制在MaxGCPauseMills以内,收集器在工作时会调整Java堆大小或者其他一些参数。
      • 对于用户来讲,停顿时间越短体验越好。但是在服务器端,我们注重高并发,整体的吞吐量。所以服务器端适合Parallel,进行控制。
      • 该参数使用需谨慎
    • -XX: GCTimeRatio垃圾收集时间占总时间的比例(= 1 / (N + 1))。用于衡量吞吐量的大小
    • 取值范围(0,100)。默认值99,也就是垃圾回收时间不超过1%。
    • 与前一个-XX :MaxGCPauseMillis参数有一定矛盾性。暂停时间越长,Radio参数就容易超过设定的比例。
    • -XX: +UseAdaptiveSizePolilcy:设置Parallel Scavenge收集器具有自适应调节策略
      • 在这种模式下,年轻代的大小、Eden和Survivor的比例、晋升老年代的对象年龄等参数会被自动调整,已达到在堆大小、吞吐量和停顿时间之间的平衡点。
      • 在手动调优比较困难的场合,可以直接使用这种自适应的方式,仅指定虚拟机的最大堆、目标的吞吐量(GCT imeRatio)和停顿时间
        (MaxGCPauseMills),让虚拟机自己完成调优工作。

6、CMS回收器:低延迟

  • 在JDK 1.5时期,HotSpot推出了一款在强交互应用中几乎可认为有划时代意义的垃圾收集器:CMS (Concurrent -Mark -Sweep)收集器,这款收集器是HotSpot虚拟机中第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程同时工作

  • CMS收集器的关注点是尽可能缩短垃圾收集时用户线程的停顿时间。停顿时间越短(低延迟)就越适合与用户交互的程序,良好的响应速度能提升用户体验。

    • 目前很大一部分的Java应用集中在互联网站或者B/S系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。
    • CMS收集器就非常符合这类应用的需求。
  • CMS的垃圾收集算法采用标记-清除算法,并且也会**”Stop-the-world”**

  • 不幸的是,CMS作为老年代的收集器,却无法与JDK 1.4.0中已经存在的新生代收集器Parallel Scavenge 配合工作,所以在JDK 1.5中使用CMS来收集老年代的时候,新生代只能选择ParNew或者Serial收集器中的一个。在G1出现之前,CMS使用还是非常广泛的。一直到今天,仍然有很多系统使用CMS GC。

  • CMS的工作原理:

    image-20210429095659541

  • CMS整个过程比之前的收集器要复杂,整个过程分为4个主要阶段,即初始标记阶段并发标记阶段重新标记阶段并发清除阶段

    • 初始标记(Initial-Mark)阶段:在这个阶段中,程序中所有的工作线程都将会因为Stop-the-World”机制而出现短暂的暂停,这个阶段的主要任务仅仅只是标记出GCRoots能直接关联到的对象。一旦标记完成之后就会恢复之前被暂停的所有应用线程。由于直接关联对象比较小,所以这里的速度非常快
    • 并发标记(Concurrent-Mark)阶段:从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行
    • 重新标记(Remark) 阶段:由于在并发标记阶段中,程序的工作线程会和垃圾收集线程同时运行或者交叉运行,因此为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间通常会比初始标记阶段稍长一些,但也远比并发标记阶段的时间短。
    • 并发清除(Concurrent-Sweep) 阶段:此阶段清理删除掉标记阶段判断的已经死亡的对象,释放内存空间。由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的
  • 尽管CMS收集器采用的是并发回收(非独占式)**,但是在其初始化标记和再次标记这两个阶段中仍然需要执行“Stop-the-World”机制**暂停程序中的工作线程,不过暂停时间并不会太长,因此可以说明目前所有的垃圾收集器都做不到完全不需要“Stop-the-World”,只是尽可能地缩短暂停时间。

  • 由于最耗费时间的并发标记与并发清除阶段都不需要暂停工作,所以整体的回收是低停顿的

  • 另外,由于在垃圾收集阶段用户线程没有中断,所以在CMS回收过程中,还应该确保应用程序用户线程有足够的内存可用。因此,CMS收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,而是当堆内存使用率达到某一阈值时,便开始进行回收,以确保应用程序在CMS工作过程中依然有足够的空间支持应用程序运行。要是CMS运行期间预留的内存无法满足程序需要,就会出现一次”Concurrent Mode Failure“失败,这时虛拟机将启动后备预案:临时启用Serial Old收集器来重新进行老年代的垃圾收集,这样停顿时间就很长了。

  • CMS收集器的垃圾收集算法采用的是标记-清除算法,这意味着每次执行完内存回收后,由于被执行内存回收的无用对象所占用的内存空间极有可能是不连续的一些内存块,不可避免地将会产生一些内存碎片。那么CMS在为新对象分配内存空间时,将无法使用指针碰撞(Bump the Pointer) 技术,而只能够选择空闲列表(Free List) 执行内存分配

  • 既然Mark Sweep会造成内存碎片,那么为什么不把算法换成Mark Compact呢?

    • 答案其实很简答,因为当并发清除的时候,用Compact整理内存的话,原来的用户线程使用的内存还怎么用呢?
    • 要保证用户线程能继续执行,前提的它运行的资源不受影响嘛。Mark Compact更适合“Stop the World”这种场景下使用
  • CMS的优点:

    • 并发收集
    • 低延迟
  • CMS的弊端:

    1. 会产生内存碎片,导致并发清除后,用户线程可用的空间不足。在无法分配大对象的情况下,不得不提前触发Full GC。
    2. CMS收集器对CPU资源非常敏感。在并发阶段,它虽然不会导致用户停顿,但是会因为占用了一部分线程而导致应用程序变慢,总吞吐量会降低。
    3. CMS收集器无法处理浮动垃圾。可能出现”Concurrent Mode Failure“失败而导致另一次Full GC的产生。在并发标记阶段由于程序的工作线程和垃圾收集线程是同时运行或者交叉运行的,那么在并发标记阶段如果产生新的垃圾对象,CMS将无法对这些垃圾对象进行标记,最终会导致这些新产生的垃圾对象没有被及时回收,从而只能在下一次执行GC时释放这些之前未被回收的内存空间。
  • CMS收集器可以设置的参数:

    • -XX: +UseConcMarkSweepGC手动指定使用CMS收集器执行内存回收任务
      • 开启该参数后会自动将-XX: +UseParNewGC打开。 即:ParNew (Young区用) +CMS (Old区用) + Serial Old的组合。
    • -XX:CMSlnitiatingOccupancyFraction设置堆内存使用率的阈值,一旦达到该阈值,便开始进行回收
      • JDK5及以前版本的默认值为68**,即当老年代的空间使用率达到68%时,会执行一次CMS回收。JDK6及 以上版本默认值为92%**
      • 如果内存增长缓慢,则可以设置一个稍大的值,大的阈值可以有效降低CMS的触发频率,减少老年代回收的次数可以较为明显地改善应用程序性能。反之,如果应用程序内存使用率增长很快,则应该降低这个阈值,以避免频繁触发老年代串行收集器。因此通过该选项便可以有效降低Full GC的执行次数
    • -XX: +UseCMSCompactAtFullCollection用于指定在执行完Full GC后对内存空间进行压缩整理,以此避免内存碎片的产生。不过由于内存压缩整理过程无法并发执行,所带来的问题就是停顿时间变得更长了。
    • -XX: CMSFullGCsBeforeCompaction设置在执行多少次Full GC后对内存空间进行压缩整理
    • -XX:Parallel CMSThreads设置CMS的线程数量
    • CMS默认启动的线程数是(ParallelGCThreads+3) / 4ParallelGCThreads是年轻代并行收集器的线程数。当CPU资源比较紧张时,受到CMs收集器线程的影响,应用程序的性能在垃圾回收阶段可能会非常糟糕。
  • 小结:

    • HotSpot有这么多的垃圾回收器,那么如果有人问,Serial GC、Parallel GC、Concurrent Mark Sweep GC这三个GC有什么不同呢?
    • 请记住以下口令:
      • 如果你想要最小化地使用内存和并行开销,请选Serial GC;
      • 如果你想要最大化应用程序的吞吐量,请选Parallel GC;
      • 如果你想要最小化GC的中断或停顿时间,请选CMS GC
  • JDK后续版本中CMS的变化

    • JDK9新特性:CMS被标记为Deprecate了(JEP291)

      • 如果对JDK9及以上版本的HotSpot虚拟机使用参数-XX:+UseConcMarkSweepGC来开启CMS收集器的话,用户会收到一个警告信息,提示CMS未来将会被废弃
    • JDK14新特性:删除CMS垃圾回收器(JEP363)

      • 移除了CMS垃圾收集器,如果在JDK14中使用-XX: +UseConcMarkSweepGC的话,JVM不会报错,只是给出一个warning信息,但是不会exitJVM会自动回退以默认GC方式启动JVM

        1
        OpenJDK 64-Bit Server VM warning: Ignoring option UseConcMarkSweepGC;support was removed in 14. 0 and the VM will continue execution using the default collector.

7、G1回收器:区域化分代式

1、两个问题

问题1:既然我们已经有了前面几个强大的GC,为什么还要发布Garbage First (G1)GC?

  • 原因就在于应用程序所应对的业务越来越庞大、复杂,用户越来越多,没有GC就不能保证应用程序正常进行,而经常造成STW的GC又跟不上实际的需求,所以才会不断地尝试对GC进行优化。G1 (Garbage-First) 垃圾回收器是在Java7 update 4之后引入的一个新的垃圾回收器,是当今收集器技术发展的最前沿成果之一。
  • 与此同时,为了适应现在不断扩大的内存和不断增加的处理器数量,进一步降低暂停时间(pause time) ,同时兼顾良好的吞吐量。
  • 官方给G1设定的目标是在延迟可控的情况下获得尽可能高的吞吐量,所以才担当起”全功能收集器”的重任与期望。

问题2:为什么名字叫做Garbage First (G1) 呢?

  • 因为G1是一个并行回收器,它把堆内存分割为很多不相关的区域(Region) (物理上不连续的)。使用不同的Region来表示Eden、 幸存者0区,幸存者1区,老年代等。
  • G1 GC有计划地避免在整个Java堆中进行全区域的垃圾收集。G1跟踪各个Region里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region
  • 由于这种方式的侧重点在于回收垃圾最大量的区间(Region) ,所以我们给G1一个名字:垃圾优先(Garbage First)
2、G1概述

G1 (Garbage-First)是一款面向服务端应用的垃圾收集器,主要针对配备多核CPU及大容量内存的机器,以极高概率满足GC停顿时间的同时,还兼具高吞吐量的性能特征。

在JDK1.7版本正式启用,移除了Experimental的标识,是JDK 9以后的默认垃圾回收器,取代了CMS回收器以及Parallel + Parallel Old组合。被Oracle官方称为“全功能的垃圾收集器”。

与此同时,CMS已经在JDK 9中被标记为废弃(deprecated) 。在jdk8中还不是默认的垃圾回收器,需要使用-XX: +UseG1GC来启用。

3、G1的特点(优势)与缺点

与其他GC收集器相比,G1使用了全新的分区算法,其特点有如下四点:

  • 并行与并发

    • 并行性:G1在回收期间,可以有多个GC线程同时工作,有效利用多核计算能力。此时用户线程STW
    • 并发性:G1拥有与应用程序交替执行的能力,部分工作可以和应用程序同时执行,因此,一般来说,不会出现在整个回收阶段发生完全阻塞应用程序的情况
  • 分代收集

    • 从分代上看,G1依然属于分代型垃圾回收器,它会区分年轻代和老年代,年轻代依然有Eden区和Survivor区。但从堆的结构上看,它不要求整个Eden区、年轻代或者老年代都是连续的,也不再坚持固定大小和固定数量。

    • 将堆空间分为若千个区域(Region) ,这些区域中包含了逻辑上的年轻代和老年代

    • 和之前的各类回收器不同,它同时兼顾年轻代和老年代。对比其他回收器,或者工作在年轻代,或者工作在老年代

      image-20210429235305962

  • 空间整合

    • CMS:“标记-清除”算法、内存碎片、若干次GC后进行一次碎片整理
    • G1将内存划分为一个个的region。内存的回收是以region作为基本单位的。Region之间是复制算法,但整体上实际可看作是标记-压缩(Mark-Compact )算法,两种算法都可以避免内存碎片。这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次GC。尤其是当Java堆非常大的时候,G1的优势更加明显
  • 可预测的停顿时间模型(即:软实时soft real-time)

    这是G1相对于CMS的另一大优势,G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒。(其中吞吐量 = M-N / M)

    • 由于分区的原因,G1可以只选取部分区域进行内存回收,这样缩小了回收的范围,因此对于全局停顿情况的发生也能得到较好的控制。
    • G1跟踪各个Region里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集
      时间,优先回收价值最大的Region。保证了G1 收集器在有限的时间内可以获取尽可能高的收集效率
    • 相比于CMS GC,G1未必能做到CMS在最好情况下的延时停顿,但是最差情况要好很多。

G1收集器的缺点:

  • 相较于CMS,G1还不具备全方位、压倒性优势。比如在用户程序运行过程中,G1无论是为了垃圾收集产生的内存占用(Footprint) 还是程序运行时的额外执行负载(Overload) 都要比CMS要高。
  • 从经验上来说,在小内存应用上CMS的表现大概率会优于G1,而G1在大内存应用上则发挥其优势平衡点在6-8GB之间
4、G1回收器的参数设置
  • -XX: +UseG1GC**:手动指定使用G1收集器执行内存回收任务**。
  • -XX: G1HeapRegionSize**:设置每个Region的大小**。值是2的幂,范围是1MB到32MB之间,目标是根据最小的Java堆大小划分出约2048个区域。默认是堆内存的1/ 2000。
  • -XX: MaxGCPauseMillis**:设置期望达到的最大GC停顿时间指标(JVM会尽力实现,但不保证达到)。默认值是200ms**
  • -XX: ParallelGCThread设置STW时GC线程数的值。最多设置为8
  • -XX: ConcGCThreads设置并发标记的线程数。将n设置为并行垃圾回收线数(ParallelGCThreads)的1/4左右
  • -XX: InitiatingHeapoccupancyPercent设置触发并发GC周期的Java堆占用率阈值。超过此值,就触发GC。默值是45。堆空间已用占比达到45%,老年代才会并发标记
5、G1回收器的常见操作步骤
  • G1的设计原则就是简化JVM性能调优,开发人员只需要简单的三步即可完成调优:
    1. 第一步:开启G1垃圾收集器
    2. 第二步:设置堆的最大内存
    3. 第三步:设置最大的停顿时间
  • G1中提供了三种垃圾回收模式:YoungGC、 Mixed GC和Full GC,在不同的条件下被触发
6、G1回收器的适用场景
  • 面向服务端应用,针对具有大内存、多处理器的机器。(在普通大小的堆里表现并不惊喜)
  • 最主要的应用是需要低GC延迟,并具有大堆的应用程序提供解决方案;
  • 如:在堆大小约6GB或更大时,可预测的暂停时间可以低于0.5秒;(G1通过每次只清理一部分而不是全部的Region的增量式清理来保证每次GC停顿时间不会过长)。
  • 用来替换掉JDK1.5中的CMS收集器;在下面的情况时,使用G1可能比CMS好:
    1. 超过50%的Java堆被活动数据占用
    2. 对象分配频率或年代提升频率变化很大
    3. GC停顿时间过长(长于0.5至1秒)
  • HotSpot垃圾收集器里,除了G1以外,其他的垃圾收集器使用内置的JVM线程执行GC的多线程操作,而G1 GC可以采用应用线程承担后台运行的GC工作,即当JVM的GC线程处理速度慢时,系统会调用应用程序线程帮助加速垃圾回收过程
7、分区Region:化整为零

使用G1收集器时,它将整个Java堆划分成约2048个大小相同的独立Region块,每个Region块大小根据堆空间的实际大小而定,整体被控制在1MB到32MB
之间,且为2的N次幂,即1MB, 2MB, 4MB, 8MB, 16MB, 32MB。可以通过-XX:G1HeapRegionSize设定。所有的Region大小相同,且在JVM生命周期内
不会被改变

虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分Region (不需要连续)的集合。通过Region的动态分配方式实
现逻辑上的连续

  • 如果设置了Region数量,那么Region大小就不是固定的,但是大小肯定是2的幂次方,并且在1-32M之间
  • 如果设置了Region大小,那么Region数量就不是固定的,但是肯定是2048附近

Region只能是Eden、Survivor、 Humongous中的一种,但是它的身份不是固定的,谁来占用那么这个Region就是谁的

image-20210430001836775

  • 一个region有可能属于Eden,Survivor 或者Old/Tenured 内存区域。但是一个region只可能属于一个角色。图中的E表示该region属于Eden内存区域,s表示属于Survivor内存区域,O表示属于Old内存区域。图中空白的表示未使用的内存空间。
  • G1垃圾收集器还增加了一种新的内存区域,叫做Humongous内存区域,如图中的H块。主要用于存储大对象,如果超过0.5个region,就放到H。region是可以连续分配的,小于0.5的就用两个连续的region分配
  • object that is more than half a region size is considered a “Humongous object” :大于区域大小一半的对象都被视为“巨大对象”

设置Humongous的原因:

对于堆中的大对象,默认直接会被分配到老年代,但是如果它是一个短期存在的大对象,就会对垃圾收集器造成负面影响。为了解决这个问题,G1划分了一个Humongous区,它用来专门存放大对象。如果一个H区装不下一个大对象,那么G1会寻找连续的H区来存储。为了能找到连续的H区,有时候不得不启动Full GC。G1的大多数行为都把H区作为老年代的一部分来看待

关于指针碰撞与空闲列表:

  • Bump:单个Region使用指针碰撞的方式来放数据上面allocated是已经使用的内存空间,top就是指针的位置,unallocate是没有使用的内存空间

    • Bump-the-pointer,即:指针碰撞

    image-20210430002300492

  • TLAB:虽然存在分区Region,但是依然有线程独有的TLAB空间,这样可以保证多个线程对对象修改可以并行操作

    • TLAB,即:空闲列表
8、G1回收器垃圾回收过程

G1 GC的垃圾回收过程主要包括如下三个环节:

  • 年轻代GC (Young GC)
  • 老年代并发标记过程(Concurrent Marking)
  • 混合回收(Mixed GC)
  • (如果需要,单线程、独占式、高强度的Full GC还是继续存在的。它针对GC的评估失败提供了一种失败保护机制,即强力回收。)

顺时针,Young GC -> Young GC +Concurrent Marking -> Mixed GC顺序,进行垃圾回收。

image-20210429185519798

  • 应用程序分配内存,当年轻代的Eden区用尽时开始年轻代回收过程;G1的年轻代收集阶段是一个并行的独占式收集器。在年轻代回收期,G1 GC暂停所有应用程序线程,启动多线程执行年轻代回收。然后从年轻代区间移动存活对象到Survivor区间或者老年区间,也有可能是两个区间都会涉及
  • 当堆内存使用达到一定值(默认45%)时,开始老年代并发标记过程。
  • 标记完成马上开始混合回收过程。对于一个混合回收期,G1 GC从老年区间移动存活对象到空闲区间,这些空闲区间也就成为了老年代的一部分。和年轻代不同,老年代的G1回收器和其他GC不同,G1的老年代回收器不需要整个老年代被回收,一次只需要扫描/回收一小部分老年代的Region就可以了。同时,这个老年代Region是和年轻代一起被回收的
  • 举个例子:一个Web服务器,Java进程最大堆内存为4G,每分钟响应1500个请求,每45秒钟会新分配大约2G的内存。G1会每45秒钟进行一次年轻代回收,每31个小时整个堆的使用率会达到45号%,会开始老年代并发标记过程,标记完成后开始四到五次的混合回收。

G1回收器垃圾回收过程:Remembered Set

  • 一个对象被不同区域引用的问题

  • 一个Region不可能是孤立的,一个Region中的对象可能被其他任意Region中对象引用,判断对象存活时,是否需要扫描整个Java堆才能保证准确?

  • 在其他的分代收集器,也存在这样的问题(而G1更突出)

  • 回收新生代也不得不同时扫描老年代?

    • 这样的话会降低Minor GC的效率;
  • 解决方法:

    • 无论G1还是其他分代收集器,JVM都是使用Remembered Set来避免全局扫描
      • 每个Region都有一个对应的Remembered Set;
      • 每次Reference类型数据写操作时,都会产生一个Write Barrier 暂时中断操作;
      • 然后检查将要写入的引用指向的对象是否和该Reference类型数据在不同的Region(其他收集器:检查老年代对象是否引用了新生代对象);
      • 如果不同,通过CardTable把相关引用信息记录到引用指向对象的所在Region对应的Remembered Set中;
        • 卡表是记忆集的一种具体实现方式。 见《深入理解Java虚拟机》
      • 当进行垃圾收集时,在GC根节点的枚举范围加入Remembered Set;就可以保证不进行全局扫描,也不会有遗漏;

    image-20210430003324501

  • 上面提到的Remebered Set就是上述Reset,上面提到的Reference类型就是引用类型,其中Reset的作用是记录当前Region中哪些对象被外部引用指向,比如OId区中的对象会指向Eden区的对象,然后当我们要回收某个Region的时候,直接遍历遍历当前Region中的所有对象就可以了,然后针对性的去找到那些指向当前对象的其他对象,最终发现当前对象是否是根可达的,如果不是,那就应该被删除,其实之前的垃圾回收器都涉及到这个问题,当进行Minor GC的时候,通过GC Roots查找的时候还需要遍历Old区的对象,毕竟Old区对象也可能会指向Eden区对象,但是G1通过Rset避免了全堆的扫描,当引用类型数据写操作时,先暂时中断,然后判断当前引用类型数据是否被其他对象所指向,如果不被指向,那就直接放在Region中就可以了;如果被其他对象指向,那么还要判断这个对象是在当前要插入的Region中,还是在其他Region中;如果在其他Region中,那就需要使用CardTable把当前引用类型数据的指向信息放在Rset中,也就是形成上面的虚线连线,如果在当前Region中,那就不需要指向了,毕竟到时候我们会进行遍历查找根可达对象,那肯定会找到的,所以这种情况也是直接放在Region中就可以了。

9、G1回收过程一:年轻代GC

JVM启动时,G1先准备好Eden区,程序在运行过程中不断创建对象到Eden区,当Eden空间耗尽时,G1会启动一次年轻代垃圾回收过程。

年轻代垃圾回收只会回收Eden区和Survivor区

首先G1停止应用程序的执行(Stop-The-World),G1创建回收集(Collection Set),回收集是指需要被回收的内存分段的集合,年轻代回收过程的回收集包含年轻代Eden区和Survivor区所有的内存分段。

image-20210430003008242

然后开始如下回收过程:

  1. 第一阶段,扫描根。可以体现Rset作用:避免全堆扫描

    • 根是指static变量指向的对象,正在执行的方法调用链条上的局部变量等。根引用连同RSet记录的外部引用作为扫描存活对象的入口。
  2. 第二阶段,更新RSet。 作用:保证Rset中的数据准确性

    • 处理dirty card queue中的card,更新RSet。此阶段完成后,RSet 可以准确的反映老年代对所在的内存分段中对象的引用

    • 对dirty card queue (脏卡表队列)的解释:

      • 对于应用程序的引用赋值语句object.field=object,JVM会在之前和之后执行特殊的操作以在dirty card queue中入队一个保存了对象引用信息的card。在年轻代回收的时候,G1会对Dirty Card Queue中所有的card进行处理,以更新RSet,保证RSet实时准确的反映引用关系。
        • 其中object.field=object中的第一个object代表老年代中的对象,而第二个object代表Eden区中的对象
      • 那为什么不在引用赋值语句处直接更新RSet呢?
        • 这是为了性能的需要,RSet的处理需要线程同步,开销会很大,使用队列性能会好很多。
    • 脏卡表队列作用:

      Reset更新需要线程同步,所以开销会很大,因此不能实时更新,因此我们需要把引用对象被其他对象引用的关系放在一个脏卡表队列中,当年轻代回收的时候会进行STW,所以我们也正好把脏卡表队列中的值更新到Rset中,这样不仅没有涉及到开销问题,还可以保证Rset中的数据是准确的。

  3. 第三阶段,处理RSet。作用:根可达性遍历的一部分

    • 识别被老年代对象指向的Eden中的对象,这些被指向的Eden中的对象被认为是存活的对象。
  4. 第四阶段,复制对象。说明:新生代使用复制算法

    • 此阶段,对象树被遍历,Eden区内存段中存活的对象会被复制到Survivor区中空的内存分段,Survivor区内存段中存活的对象如果年龄未达阈值,年龄会加1,达到阀值会被会被复制到Old区中空的内存分段。如果Survivor空间不够,Eden空间的部分数据会直接晋升到老年代空间。
  5. 第五阶段,处理引用。空Eden:Eden变成空的,那它就变成了无主Region,因此会被记录到空链表中,等待下一次被分配

    • 处理Soft,Weak,Phantom, Final, JNI Weak 等引用。最终Eden空间的数据为空,GC停止工作,而目标内存中的对象都是连续存储的,没有碎片,所以复制过程可以达到内存整理的效果,减少碎片。
    • 以上回收的都是强引用对象,下面回收软引用对象 (不足回收)、弱引用对象(发现回收)、虚引用对象
10、G1回收过程二:并发标记过程
  1. 初始标记阶段:标记从根节点直接可达的对象。这个阶段是STW的,并且会触发一次年轻代GC
  2. 根区域扫描(Root Region Scanning)**:G1 GC扫描Survivor区直接可达的老年代区域对象,并标记被引用的对象。这一过程必须在Young GC之前完成。主要扫描哪些老年代对象是可达的**,毕竟我们进行Young GC的时候会移动Survivor区,移动之后就找不到哪些老年代对象是可达的了
  3. 并发标记(Concurrent Marking)**:在整个堆中进行并发标记(和应用程序并发执行),此过程可能被Young GC中断。在并发标记阶段,若发现区域对象中的所有对象都是垃圾,那这个区域会被立即回收**(实时回收)。同时,并发标记过程中,会计算每个区域的对象活性(区域中存活对象的比例)。
  4. **再次标记(Remark)**:由于应用程序持续进行,需要修正上一次的标记结果。是STW的。G1中采用了比CMS更快的初始快照算法:snapshot-at-the-beginning (SATB)。
    • 原因:并发标记不准确
  5. 独占清理(cleanup ,STW)**:计算各个区域的存活对象和GC回收比例,并进行排序,识别可以混合回收的区域。为下阶段做铺垫。是STW的其实是一个统计计算过程,不会涉及垃圾清理**
    • 这个阶段并不会实际上去做垃圾的收集
  6. 并发清理阶段:识别并清理完全空闲的区域。
    • 并发清理阶段任务:如果发现区域对象中的所有对象都是垃圾,那么这个区域会被立即回收
11、G1回收过程三:混合回收

当越来越多的对象晋升到老年代Old region时,为了避免堆内存被耗尽,虚拟机会触发一个混合的垃圾收集器,即Mixed GC,该算法并不是一个Old GC,除了回收整个Young Region,还会回收一部分的Old Region。 这里需要注意:是一部分老年代,而不是全部老年代。可以选择哪些Old Region进行收集,从而可以对垃圾回收的耗时时间进行控制。也要注意的是Mixed GC并不是Full GC

image-20210429194133565

  • 并发标记结束以后,老年代中百分百为垃圾的内存分段被回收了,部分为垃圾的内存分段被计算了出来。默认情况下,这些老年代的内存分段会分8次(可以通过-XX: G1MixedGCCountTarget设置)被回收。
  • 混合回收的回收集(Collection Set)包括八分之一的老年代内存分段,Eden区内存分段,Survivor区内存分段。混合回收的算法和年轻代回收的算法完全一样,只是回收集多了老年代的内存分段。具体过程请参考上面的年轻代回收过程。
  • 由于老年代中的内存分段默认分8次回收,G1会优先回收垃圾多的内存分段。垃圾占内存分段比例越高的,越会被先回收。并且有一个阈值会决定内存分段是否被回收,-XX:G1MixedGCLiveThresholdPercent,默认为65%,意思是垃圾占内存分段比例要达到65%才会被回收。如果垃圾占比太低,意味着存活的对象占比高,在复制的时候会花费更多的时间。垃圾占比越多, 回收优先级越高;如果垃圾不足Region空间的65%,那么将不会进行回收。
  • 混合回收并不一定要进行8次。有一个阈值-XX:G1HeapWastePercent,默认值为10%,意思是允许整个堆内存中有10%的空间被浪费,意味着如果发现可以回收的垃圾占堆内存的比例低于10%,则不再进行混合回收。因为GC会花费很多的时间但是回收到的内存却很少。如果垃圾不足Region内存的10%,那么将不会对该老年代Region进行回收,综合上面的来看,只要垃圾占整个老年代Region的比例大于65%,才会对该Region进行回收
12、G1回收可选的过程四:Full GC
  • G1的初衷就是要避免Fu1l GC的出现。但是如果上述方式不能正常工作,G1会停止应用程序的执行(Stop- The-World),使用单线程的内存回收算法进行垃圾回收,性能会非常差,应用程序停顿时间会很长。

  • 要避免Full GC的发生,一旦发生需要进行调整。什么时候会发生Full GC呢?

    • 比如堆内存太小,当G1在复制存活对象的时候没有空的内存分段可用,则会回退到Full GC,这种情况可以通过增大内存解决。
  • 导致G1Full GC的原因可能有三个:

    1. Evacuation的时候没有足够的to-space来存放晋升的对象;
    • 解决:加大堆空间
    1. 并发处理过程完成之前空间耗尽。
    • 解决:调小触发并发GC周期的Java堆占用阈值(默认是45%, 在前面参数页有)
    1. 最大GC暂停时间太短,导致在规定的时间间隔内无法完成垃圾回收,也会导致Full GC
    • 解决:加大最大GC停顿时间
13、G1回收过程:补充

从Oracle官方透露出来的信息可获知,回收阶段( Evacuation)其实本也有想过设计成与用户程序一起并发执行,但这件事情做起来比较复杂,考虑到G1只是回收一部分Region, 停顿时间是用户可控制的,所以并不迫切去实现,而选择把这个特性放到了G1之后出现的低延迟垃圾收集器(即ZGC)中。另外,还考虑到G1不是仅仅面向低延迟,停顿用户线程能够最大幅度提高垃圾收集效率,为了保证吞吐量所以才选择了完全暂停用户线程的实现方案。

14、G1回收器优化建议
  • 年轻代大小
    • 避免使用-Xmn-XX:NewRatio等相关选项显式设置年轻代大小
    • 固定年轻代的大小会覆盖暂停时间目标
      • 原因:年轻代GC是并行独占式的,所以最好让垃圾回收器自己去调节
  • 暂停时间目标不要太过严苛
    • G1 GC的吞吐量目标是90%的应用程序时间和10%的垃圾回收时间
    • 评估G1 GC的吞吐量时,暂停时间目标不要太严苛。目标太过严苛表示你愿意承受更多的垃圾回收开销,而这些会直接影响到吞吐量。
    • 说明:暂停时间和吞吐量是此消彼长的,所以不要把暂停时间设置的太严格,不然因为这个原因引起Full GC也不太好

8、垃圾回收总结

截止JDK 1.8,一共有7款不同的垃圾收集器。每一款不同的垃圾收集器都有不同的特点,在具体使用的时候,需要根据具体的情况选用不同的垃圾收集器。

垃圾收集器 分类 作用位置 使用算法 特点 适用场景
Serial 串行运行 作用于新生代 复制算法 响应速度优先 适用于单CPU
ParNew 并行运行 作用于新生代 复制算法 响应速度优先 多CPU环境Server模式下与CMS配合使用
Parallel 并行运行 作用于新生代 复制算法 吞吐量优先 适用于后台运算而不需要太多交互的场景
Serial Old 串行运行 作用于老年代 标记-压缩算法 响应速度优先 适用于单CPU环境下的Client模式
Parallel Old 并行运行 作用于老年代 标记-压缩算法 吞吐量优先 适用于后台运算而不需要太多交互的场景
CMS 并发运行 作用于老年代 标记-清除算法 响应速度优先 适用于互联网或B/S业务
G1 并发、并行运行 作用于新生代、老年代 标记-压缩算法、复制算法 响应速度优先 面向服务端应用

GC发展阶段:Serial => Parallel (并行) => CMS (并发) => G1 => ZGC

怎么选择垃圾回收器?

  • Java垃圾收集器的配置对于JVM优化来说是一个很重要的选择,选择合适的垃圾收集器可以让JVM的性能有一个很大的提升。
  • 怎么选择垃圾收集器?
    1. 优先调整堆的大小让JVM自适应完成。
    2. 如果内存小于100M, 使用串行收集器
    3. 如果是单核、单机程序,并且没有停顿时间的要求,串行收集器
    4. 如果是多CPU、需要高吞吐量允许停顿时间超过1秒,选择并行或者JVM自己选择.
    5. 如果是多CPU追求低停顿时间,需快速响应(比如延迟不能超过1秒,如互联网应用),使用并发收集器官方推荐G1,性能高。现在互联网的项目,基本都是使用G1。
    6. 最后需要明确一个观点:
      1. 没有最好的收集器,更没有万能的收集器;
      2. 调优永远是针对特定场景、特定需求,不存在一劳永逸的收集器
  • 面试:
    • 对于垃圾收集,面试官可以循序渐进从理论、实践各种角度深入,也未必是要求面试者什么都懂。但如果你懂得原理,一定会成为面试中的加分项。
      这里较通用、基础性的部分如下:
      • 垃圾收集的算法有哪些?
      • 如何判断一个对象是否可以回收?
      • 垃圾收集器工作的基本流程。
    • 另外,大家需要多关注垃圾回收器这一章的各种常用的参数。

9、GC日志分析

通过阅读GC日志,我们可以了解Java虚拟机内存分配与回收策略。

  • 内存分配与垃圾回收的参数列表

    • -XX: +PrintGC:输出Gc日志。类似: -verbose :gc
    • -XX: +PrintGCDetails:输出GC的详细日志
    • -XX: +PrintGCTimeStamjps:输出GC的时间戳(以基准时间的形式)
    • -XX: +PrintGCDateStamps:输出GC的时间戳(以日期的形式,如2013-05-04T21 :53:59.234+0800)
    • -XX: + PrintHeapAtGC :在进行GC的前后打印出堆的信息
    • -Xloggc: ../logs/gc.log:日志文件的输出路径
  • 打开GC日志:

    1
    -verbose: gc
  • 这个只会显示总的GC堆的变化, 如下:

    1
    2
    3
    [GC (Allocation Failure) 80832K->19298K(227840K),0.0084018secs]
    [GC (Metadata GC Threshold) 109499K->21465K (228352K),0.0184066 secs]
    [Full GC (Metadata GC Threshold) 21465K->16716K(201728K) ,0.0619261secs ]
  • 参数解析:

    • GC、Full GC:GC的类型,GC只在新生代上进行,Full GC包括永生代,新生代, 老年代。
    • Allocation Failure: GC发生的原因。
    • 80832K -> 19298K:堆在GC前的大小和GC后的大小。
    • 228840k:现在的堆大小。
    • 0.0084018 secs:GC持续的时间。
  • 打开GC日志:

    1
    -verbose: gc -XX: +PrintGCDetails
  • 输入信息如下:

    1
    2
    3
    4
    5
    6
    7
    [GC (Allocation Failure)[PSYoungGen: 70640K->10116K(141312K)] 80541K->20017K (227328K),0.0172573secs]
    [Times: user=0.03 sys=0.00, real=0.02 secs]
    [GC (Metadata GC Threshold) [PSYoungGen: 98859K->8154K(142336K) ] 108760K->21261K (228352K),0.0151573 secs]
    [Times: user=0.00 sys=0.01, real=0.02 secs]
    [Full GC (Metadata GC Threshold) [PSYoungGen: 8154K->0K(142336K) ] [ParOldGen: 13107K->16809K(62464K)] 21261K -> 16809K (204800K),
    [Metaspace: 20599K->20599K (1067008K)],0.0639732 secs]
    [Times: user=0.14 sys=0.00, real=0.06 secs]
  • 参数解析:

    • GC,Full FC:同样是GC的类型
    • Allocation Failure:GC原因
    • PSYoungGen:使用了Parallel Scavenge并 行垃圾收集器的新生代Gc前后大小的变化
    • ParoldGen:使用了Parallel Old并行垃圾收集器的老年代GC前后大小的变化
    • Metaspace:元数据区GC前后大小的变化,JDK1.8中引入了元数据区以替代永久代
    • XXX secs:指GC花费的时间
    • Times: user: 指的是垃圾收集器花费的所有CPU时间,sys: 花费在等待系统调用或系统事件的时间,real:GC从开始到结束的时间,包括其他进程占用时间片的实际时间。
  • 打开GC日志:

    1
    -verbose:gc -XX: +PrintGCDetails -XX: +PrintGCTimeStamps -XX:+PrintGCDateStamps
  • 输入信息如下:

    1
    2
    3
    4
    5
    2019-09-24T22:15:24.518+0800:3.287: [GC(Allocation Failure) [ PSYoungGen: 136162K->5113K (136192K) ] 141425K->17632K (222208K),0.0248249 secs] [Times: user=0. 05 sys=0.00,real=0.03 secs]
    2019-09-24T22:15:25.559+0800:4.329: [GC (Metadata GC Threshold)[ PSYoungGen:97578K->10068K(274944K) ] 110096K->22658K (360960K),0.0094071 secs] [Times: user=0. 00 sys=0.00,real=0.01 secs]
    2019-09-24T22:15:25.569+0800: 4.338: [Full GC (Metadata GC Threshold) [ PSYoungGen:10068K->0K(274944K) ] [ParOldGen: 12590K->13564K (56320K) ] 22658K->13564K (331264K) ,
    [Metaspace: 20590K->20590K(1067008K)], 0.0494875 secs]
    [Times: user=0.17 sys=0.02,real=0.05 secs]
  • 说明:带上了日期和时间

  • 日志补充说明:
    • “ [GC”和” [Full GC” 说明了这次垃圾收集的停顿类型,如果有”Full”则说明GC发生了”Stop The World”
    • 使用Serial收集器在新生代的名字是Default New Generation,因此显示的是” [DefNew”
    • 使用ParNew收集器在新生代的名字会变成” [ParNew”,意思是”Parallel New Generation”
    • 使用Parallel Scavenge收集器在新生代的名字是” [PSYoungGen”
    • 老年代的收集和新生代道理一样,名字也是收集器决定的
    • 使用G1收集器的话,会显示为”garbage- first heap”
    • Allocation Failure:表明本次引起GC的原因是因为在年轻代中没有足够的空间能够存储新的数据了。
    • [PSYoungGen: 5986K->696K(8704K) ] 5986K- > 704K (9216K)中
      • 括号内:GC回收前年轻代大小,回收后大小,( 年轻代总大小)
      • 括号外:GC回收前年轻代和老年代大小,回收后大小,( 年轻代和老年代总大小)
    • user代表用户态回收耗时,sys 内核态回收耗时,rea实际耗时。由于多核的原因,时间总和可能会超过real时间

GC日志分析

image-20210430011457790

Minor GC日志:

image-20210430011551243

Full GC日志:

image-20210430012224983

如果想把GC日志存到文件的话,是下面这个参数:

  • Xloggc: ./path/to/gc. log

GC日志分析工具:

  • 可以用一些工具去分析这些gc日志。
  • 常用的日志分析工具有:GCViewerGCEasy、GCHisto、GCLogViewer、Hpjmeter、garbagecat等。

10、垃圾回收器的新发展

1、垃圾回收器的发展

GC仍然处于飞速发展之中,目前的默认选项G1 GC在不断的进行改进,很多我们原来认为的缺点,例如串行的Full GC、 Card Table扫描的低效等,都已经被大幅改进,例如,JDK 10以后,Full GC已经是并行运行,在很多场景下,其表现还略优于Parallel GC的并行Full GC实现。

即使是Serial GC,虽然比较古老,但是简单的设计和实现未必就是过时的,它本身的开销,不管是GC相关数据结构的开销,还是线程的开销,都是非常小的,所以随着云计算的兴起,在Serverless等新的应用场景 下,Serial GC找到了新的舞台

比较不幸的是CMS GC,因为其算法的理论缺陷等原因,虽然现在还有非常大的用户群体,但在JDK9中已经被标记为废弃,并在JDK14版本中移除。

2、JDK11的新特性

image-20210429231614446

3、Open JDK12的Shenandoah GC:低停顿时间的GC (实验性)
  • 现在G1回收器已成为默认回收器好几年了。

  • 我们还看到了引入了两个新的收集器:

    • ZGC( JDK11出现)
    • Shenandoah(Open JDK12)
    • 主打特点:低停顿时间
  • Shenandoah,无疑是众多GC中最孤独的一个。是第一款不由Oracle公司团队领导开发的HotSpot垃圾收集器。不可避免的受到官方的排挤。比如号称OpenJDK和OracleJDK没有区别的Oracle公司仍拒绝在OracleJDK12中支持Shenandoah。

  • Shenandoah垃圾回收器最初由RedHat进行的一项垃圾收集器研究项目Pauseless GC的实现,旨在针对JVM上的内存回收实现低停顿的需求。在2014年贡献给OpenJDK。

  • Red Hat研发Shenandoah团队对外宣称,Shenandoah垃圾回收器的暂停时间与堆大小无关,这意味着无论将堆设置为200MB还是200GB,99.9%的目标都可以把垃圾收集的停顿时间限制在十毫秒以内。不过实际使用性能将取决于实际工作堆的大小和工作负载。

    image-20210430015133115

  • 这是RedHat在2016年发表的论文数据,测试内容是使用Es对200GB的维基百科数据进行索引。从结果看:

    • 停顿时间比其他几款收集器确实有了质的飞跃,但也未实现最大停顿时间控制在十毫秒以内的目标。
    • 而吞吐量方面出现了明显的下降,总运行时间是所有测试收集器里最长的。
  • 总结:

    • Shenandoah GC的弱项:高运行负担下的吞吐量下降王
    • Shenandoah GC的强项:低延迟时间。
    • Shenandoah GC的工作过程大致分为九个阶段,这里就不再赘述。在之前Java12新特性视频里有过介绍。

[Java12新特性地址]
http://www.atguigu.com/download_detail.shtml?v=222

https://www.bilibili.com/video/BV1jJ411M7kQ?from=search&seid=12339069673726242866

4、令人震惊、革命性的ZGC(JDK14新特性)

官网地址

  • ZGC与Shenandoah目标高度相似,在尽可能对吞吐量影响不大的前提下,实现在任意堆内存大小下都可以把垃圾收集的停顿时间限制在十毫秒以内的低延迟

  • 《深入理解Java虚拟机》一书中这样定义ZGC:ZGC收集器是一款基于Region内存布局的,(暂时)不设分代的,使用了读屏障染色指针内存多重映射等技术来实现可并发的标记-压缩算法的,以低延迟为首要目标的一款垃圾收集器

  • ZGC的工作过程可以分为4个阶段:

    • 并发标记
    • 并发预备重分配
    • 并发重分配
    • 并发重映射等。
  • ZGC几乎在所有地方并发执行的,除了初始标记的是STW(10ms以内)的。所以停顿时间几乎就耗费在初始标记上,这部分的实际时间是非常少的。

  • 测试数据:

    image-20210430015758548

    低延迟:

    image-20210430015925777

  • 在ZGC的强项停顿时间测试上,它毫不留情的将Parallel、G1拉开了两个数量级的差距。无论平均停顿、958停顿、99%停顿、99. 98停顿,还是最大停顿时间,ZGC 都能毫不费劲控制在10毫秒以内。

  • 虽然ZGC还在试验状态,没有完成所有特性,但此时性能已经相当亮眼,用“令人震惊、革命性”来形容,不为过。

  • 未来将在服务端、大内存、低延迟应用的首选垃圾收集器

  • JEP 364: ZGC应用在macOS上

  • JEP 365: ZGC应用在Windows

    • JDK14之前,ZGC仅Linux才支持。
    • 尽管许多使用ZGC的用户都使用类Linux的环境,但在Windows和macOs.上,人们也需要ZGC进行开发部署和测试。许多桌面应用也可以从ZGC中受益。因此,ZGC特性被移植到了Windows和macOS上。
    • 现在mac或windows.上也能使用zGc了,示例如下:
      **-XX: +Unloc kExperimentalVMOptions -XX: +UseZGC**
  • 其它垃圾回收器: AIiGC

    AliGC是阿里巴巴JVM团队基于G1算法,面向大堆 (LargeHeap)应用场景。指定场景下的对比:

    image-20210429233231323

  • 当然,其他厂商也提供了各种独具一格的GC实现, 例如比较有名的低延迟GC:Zing,有兴趣可以参考提供的链接。


18、垃圾回收的相关大厂面试题

  • 蚂蚁金服:
    • 你知道哪几种垃圾回收器,各自的优缺点,重点讲一下cms和G1
    • 一面: JVM GC算法有哪些,目前的JDK版本采用什么回收算法
    • 一面: G1回收器讲下回收过程
    • GC是什么?为什么要有GC?
    • 一面: GC的两种判定方法? CMS收集器与G1收集器的特点。
  • 百度:
    • 说一下GC算法,分代回收说下
    • 垃圾收集策略和算法
  • 天猫:
    • 一面: jvm GC原理,JVM怎么回收内存
    • 一面: CMS特点,垃圾回收算法有哪些?各自的优缺点,他们共同的缺点是什么?
  • 滴滴:
    • 一面: java的垃圾回收器都有哪些,说下G1的应用场景,平时你是如何搭配使用垃圾回收器的
  • 京东:
    • 你知道哪几种垃圾收集器,各自的优缺点,重点讲下cms和G1,包括原理,流程,优缺点。
    • 垃圾回收算法的实现原理。
  • 阿里:
    • 讲一讲垃圾回收算法。
    • 什么情况下触发垃圾回收?
    • 如何选择合适的垃圾收集算法?
    • JVM有哪三种垃圾回收器?
  • 字节跳动:
    • 常见的垃圾回收器算法有哪些,各有什么优劣?
    • system.gc() 和 runtime.gc() 会做什么事情?
    • 一面: Java GC机制? GC Roots有哪些?
    • 二面: Java对象的回收方式,回收算法。
    • CMS和G1了解么,CMS解决什么问题,说一下回收的过程。
    • CMS回收停顿了几次,为什么要停顿两次。

0、其他

1、label

官方:

Refactor the code to remove this label and the need for it.

label标签,不属于关键字,类似于c的goto(很少用),用于标记跳转。底层就是goto语句,尽量不要用

2、怎么学习Java的不同版本的新特性

Java不同版本的新特性:

  1. 语法层面:Lambda表达式、switch表达式、 自动装箱、自动拆箱、enum关键字、 <>泛式等等
  2. API层面:Stream API、新的日期时间、Optional、 String、 集合框架
  3. 底层优化:JVM的优化,GC的变化、元空间、静态域、字符串常量池等

中篇:字节码与类的加载器

1、class文件结构

1、概述

1、字节码文件的跨平台性
  1. Java语言: 跨平台的语言(write once ,run anywhere)

    • 当Java源代码成功编译成字节码后,如果想在不同的平台上面运行,则无须再次编译
    • 这个优势不再那么吸引人了。Python、 PHP、 Perl、 Ruby、 Lisp等有强大的解释器。
    • 跨平台似乎已经快成为一门语言必选的特性。
  2. Java虚拟机:跨语言的平台

    • Java虚拟机不和包括Java在内的任何语言绑定,它只与“Class文件”这种特定的二进制文件格式所关联

    • 无论使用何种语言进行软件开发,只要能将源文件编译为正确的Class文件,那么这种语言就可以在Java虚拟机上执行。

    • 可以说,统一而强大的Class文件结构,就是Java虚拟机的基石、桥梁。

      第01章_JVM跨语言的平台

    • 所有的JVM全部遵守Java虚拟机规范,也就是说所有的JVM环境都是一样的,这样一来字节码文件可以在各种JVM上运行。

    • 官方文档

  3. 想要让一个Java程序正确地运行在JVM中, Java源码就必须要被编译为符合JVM规范的字节码。

    • 前端编译器的主要任务就是负责将符合Java语法规范的Java代码转换为符合JVM规范的字节码文件

    • javac一种能够将Java源码编译为字节码的前端编译器

    • javac编译器在将Java源码编译为个有效的字节码文件过程中经历了4个步骤,分别是词法解析、语法解析、语义解析以及生成字节码。

      image-20210504004503138

  4. Oracle的JDK软件包括两部分内容:

    • 一部分是将Java源代码编译成Java虚拟机的指令集的编译器
    • 另一部分是用于实现Java虛拟机的运行时环境
2、Java的前端编译器
1、关于前端编译器与后台编译器在程序编译过程中的作用

image-20210504005333757

2、前端编译器vs后端编译器

Java源代码的编译结果是字节码,那么肯定需要有一种编译器能够将Java源码编译为字节码,承担这个重要责任的就是配置在path环境变量中的javac编译器。javac是一种能够将Java源码编译为字节码的前端编译器

HotSpotVM并没有强制要求前端编译器只能使用javac来编译字节码,其实只要编译结果符合JVM规范都可以被JVM所识别即可。在Java的前端编译器领域,除了javac之外, 还有一种被大家经常用到的前端编译器,那就是内置在Eclipse中的ECJ (Eclipse Compiler for Java )编译器。和Javac的全量式编译不同,ECI是一种增量式编译器

  • 在Eclipse中,当开发人员编写完代码后,使用“Ctrl+S”快捷键时,ECI编译器所采取的编译方案是把未编译部分的源码逐行进行编译,而非每次都全量编译。因此ECI的编译效率会比javac更加迅速和高效,当然编译质量和javac相比大致还是一样的。
  • ECI不仅是Eclipse的默认内置前端编译器,在Tomcat中同样也是使用ECJ编译器来编译jsp文件。由于ECJ编译器是采用GPLv2的开源协议进行源代码公开,所以,大家可以登录eclipse官网下载ECJ编译器的源码进行二次开发。
  • 默认情况下,IntelliJ IDEA使用javac编译器。(还可以自己设置为AspectJ编译器 ajc)

前端编译器并不会直接涉及编译优化等方面的技术,而是将这些具体优化细节移交给HotSpot的JIT编译器(后端编译器)负责

复习:

  • JIT(及时编译器)
  • AOT(静态提前编译器,Ahead of Time Compiler)
3、透过字节码指令看代码细节

BAT面试题:

  1. 类文件结构有几个部分?
  2. 知道字节码吗?字节码都有哪些? Integer x = 5;int y = 5;比较x == y都经过哪些步骤?

代码举例1:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class IntegerTest {
public static void main(String[] args) {

Integer x = 5;
int y = 5;
System.out.println(x == y);//true。自动拆箱

Integer i1 = 10;
Integer i2 = 10;
System.out.println(i1 == i2);//true

Integer i3 = 128;
Integer i4 = 128;
System.out.println(i3 == i4);//false
}
}

相关字节码1:

image-20210518205703047

image-20210518205820353

image-20210518205932030

image-20210518205941131

代码举例2:

1
2
3
4
5
6
7
8
9
public class StringTest {
public static void main(String[] args) {
String str = new String("hello") + new String("world");
String str1 = "helloworld";
System.out.println(str == str1);//false
String str2 = new String("helloworld");
System.out.println(str == str2);//false
}
}

相关字节码2:

image-20210518215156842

image-20210518215729244\

image-20210518215755492

代码举例3:

image-20210518220102931

相关说明:

  • 成员变量(非静态的)的赋值过程:
    • 默认初始化(注意这里会先调用所有父类的构造方法(super))
    • 显式初始化 / 代码块中初始化
    • 构造器中初始化
    • 有了对象之后,可以”对象.属性”或”对象.方法”的方式对成员变量进行赋值。

相关字节码3:

对son:

image-20210518221159306

代码的执行过程:

  1. 执行Father f = new Son();
  2. 先初始化父类的构造器,在父类的构造器当中调用了print()方法
  3. 又因为Son重写了父类Father的print()方法,此时又没有到执行显示初始化的步骤(看上面的字节码文件)
  4. 因此打印的是Son.x = 0
  5. 接着初始化Son本身的构造器,Son本身的构造器当中调用了print()方法
  6. 此时已经经历了显示初始化,x被赋予值30(具体看上面的成员变量(非静态的)的赋值过程)
  7. 因此打印的是Son.x = 30
  8. 最后执行System.out.println(f.x);
  9. **因为属性不存在多态性!!!**变量f的声明类型是Father,所以它是Father类型的,不是Son类型。
  10. 所以f.x中的x也是Father的x,因此打印的才是20

2、虚拟机的基石:class文件

  • 字节码文件里是什么?

    源代码经过编译器编译之后便会生成一个字节码文件,字节码是一种二进制的类文件,它的内容是JVM的指令,而不像C、C++经由编译器直接生成机器码

  • 什么是字节码指令(byte code)?
    Java虚拟机的指令由一个字节长度的、代表着某种特定操作含义的操作码(opcode)以及跟随其后的零至多个代表此操作所需参数的操作数(operand)所构成。虚拟机中许多指令并不包含操作数,只有一个操作码。

    比如:操作码 (操作数)

    image-20210504010005360

  • 如何解读供虚拟机解释执行的二进制字节码?

    1. 方式一:一个一个二进制的看。这里用到的是Notepad++,需要安装一个HEX-Editor插件,或者使用Binary Viewer
    2. 方式二:使用javap指令:jdk 自带的反解析工具。eg:javap -v IntegerTest.class >IntegerTest.txt
    3. 方式三:使用IDEA插件:jclasslib或jclasslib bytecode viewer客户端工具。(可视化更好)

3、class文件结构

1、关于Class文件
  • 相关的官方文档

    javase8 JVM-ClassFile的相关资料

  • Class类的本质

    • 任何一个Class文件都对应着唯一个类或接口的定义信息,但反过来说,Class文件实际上它并不一定以磁盘文件的形式存在。
    • Class 文件是一组以8位字节为基础单位的二进制流。(该二进制流可以来自于磁盘,也可以来自于网络)
  • Class文件格式

    • Class的结构不像XML等描述语言,由于它没有任何分隔符号。所以在其中的数据项,无论是字节顺序还是数量,都是被严格限
      定的,哪个字节代表什么含义,长度是多少,先后顺序如何,都不允许改变。
    • 例子:
      • “下雨天留客天留我不留”:
      • “下雨天,留客天,留我不留?”
      • “下雨天,留客天,留我不?留!”
      • “下雨,天留客?天留,我不留!”
    • 目的:压缩字节码文件
  • Class文件格式采用一种类似于C语言结构体的方式进行数据存储,这种结构中只有两种数据类型:**无符号数**。

    • 无符号数属于基本的数据类型,以u1、u2、u4、u8来分别代表1个字节、2个字节、4个字节和8个字节的无符号数,无符号数可以用来描述数字、索引引用、数量值或者按照UTF-8编码构成字符串值。
    • 是由多个无符号数或者其他表作为数据项构成的复合数据类型,所有表都习惯性地以“info”结尾。表用于描述有层次关系的复合结构的数据,整个Class文件本质上就是一张表。由于表没有固定长度,所以通常会在其前面加上个数说明
  • 相关代码举例:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    /**
    * 全类名:com.atguigu.java1.Demo
    * 全限定名:com/atguigu/java1/Demo
    */
    public class Demo {
    private int num = 1;
    public int add(){
    num = num + 2;
    return num;
    }
    }

    经过javac编译过后的16进制的Class文件:

    image-20210519000727861

    经过插件——jclasslib反编译后的Class文件:

    image-20210519001523659

    换句话说,充分理解了每一个字节码文件的细节,自己也可以反编译出Java源文件来

  • Class文件结构概述:Class文件的结构并不是一成不变的,随着Java虛拟机的不断发展,总是不可避免地会对Class文件结构做出一些调整,但是其基本结构和框架是非常稳定的。

    Class文件的总体结构如下:

    • 魔数
      • u4 magic;
    • Class文件版本
      • u2 minor_version;
      • u2 major_version;
    • 常量池
      • u2 constant_pool_count;
      • cp_info constant_pool[constant_pool_count-1];
    • 访问标志
      • u2 access_flags;
    • 类索引,父类索引,接口索引集合
      • u2 this_class;
      • u2 super_class;
      • u2 interfaces_count;
      • u2 interfaces[interfaces_count];
    • 字段表集合
      • u2 fields_count;
      • field_info fields[fields_count];
    • 方法表集合
      • u2 methods_count;
      • method_info methods[methods_count];
    • 属性表集合
      • u2 attributes_count;
      • attribute_info attributes[attributes_count];

    The ClassFile Structure:(来自官网)

    image-20210519001835589

    这是一张Java字节码总的结构表,我们按照上面的顺序逐一进行解读就可以了:

    类型 名称 说明 长度 数量
    u4 magic 魔数,识别Class文件格式 4个字节 1
    u2 minor_version 副版本号(小版本) 2个字节 1
    u2 major_version 主版本号(大版本) 2个字节 1
    u2 constant_pool_count 常量池计数器 2个字节 1
    cp_info constant_pool 常量池表 n个字节 constant_pool_count-1
    u2 access_flags 访问标识 2个字节 1
    u2 this_class 类索引 2个字节 1
    u2 super_class 父类索引 2个字节 1
    u2 interfaces_count 接口计数器 2个字节 1
    u2 interfaces 接口索引集合 2个字节 interfaces_count
    u2 fields_count 字段计数器 2个字节 1
    field_info fields 字段表 n个字节 fields_count
    u2 methods_count 方法计数器 2个字节 1
    method_info methods 方法表 n个字节 methods_count
    u2 attributes_count 属性计数器 2个字节 1
    attribute_info attributes 属性表 n个字节 attributes_count
2、01-魔数:Class文件的标志

**Magic Number (魔数)**:

  • 每个Class文件开头的4个字节的无符号整数称为魔数(Magic Number)

  • 它的唯一作用是确定这个文件是否为一个能被虚拟机接受的有效合法的Class文件。即:魔数是Class文件的标识符。

  • 魔数值固定为**0xCAFEBABE**。不会改变。

  • 如果一个Class文件不以0xCAFEBABE开头,虚拟机在进行文件校验的时候就会直接抛出以下错误:

    1
    2
    Error: A JNI error has occurred, please check your installation and try again
    Exception in thread "main" java.lang.ClassFormatError:Incompatible magic value 1885430635 in class file StringTest
  • 使用魔数而不是扩展名来进行识别主要是基于安全方面的考虑,因为文件扩展名可以随意地改动。

  • 其实魔数不只是在class文件当中有所应用。在图片.png、音乐.mp3等等,里面也有前几位作为魔数,作为进行对应文件的标识符

3、02-Class文件版本号
  • 紧接着魔数的4个字节存储的是Class 文件的版本号。同样也是4个字节。第5个和第6个字节所代表的含义就是编译的副版本号minor_ version, 而第7个和第8个字节就是编译的主版本号major_ version

  • 它们共同构成了class文件的格式版本号。譬如某个 Class文件 的主版本号为 M,副版本号为 m,那么这个 Class文件 的格式版本号就确定为 M.m。

  • 版本号和Java编译器的对应关系如下表:

    主版本(十进制) 副版本(十进制) 编译器版本
    45 3 1.1
    46 0 1.2
    47 0 1.3
    48 0 1.4
    49 0 1.5
    50 0 1.6
    51 0 1.7
    52(十六进制对应的是34) 0 1.8
    53 0 1.9
    54 0 1.10
    55 0 1.11
  • Java的版本号是从**45**开始的,JDK 1.1之后的每个JDK大版本发布主版本号向上加1。

  • 虚拟机JDK版本为1.k (k >= 2) 时,对应的class文件格式版本号的范围为45.0 - 44+k.0 (含两端)

  • 不同版本的Java编译器编译的Class文件对应的版本是不一样的。目前,高版本的Java虚拟机可以执行由低版本编译器生成的Class文件,但是低版本的Java虛拟机不能执行由高版本编译器生成的Class文件。否则JVM会抛出java.lang.UnsupportedClassVersionError异常。( 向下兼容)

  • 在实际应用中,由于开发环境和生产环境的不同,可能会导致该问题的发生。因此,需要我们在开发时,特别注意开发编译的JDK版本和生产环境中的JDK版本是否一致

  • 总结成一句话就是:高版本的虚拟机可以解释运行低版本的字节码文件

4、03-常量池:存放所有常量
1、常量池概述
  • 常量池是Class文件中内容最为丰富的区域之一。常量池对于Class文件中的字段和方法解析也有着至关重要的作用。

  • 随着Java虚拟机的不断发展,常量池的内容也日渐丰富。可以说,常量池是整个Class文件的基石

  • 官方文档:

    image-20210519012259460

  • 在版本号之后,紧跟着的是常量池的数量,以及若干个常量池表项。

  • 常量池中常量的数量是不固定的,所以在常量池的入口需要放置一项u2类型的无符号数,代表常量池容量计数值(constant_ pool_ count) 。与Java中语言习惯不一样的是,这个容量计数是从1而不是0开始的。

    类型 名称 数量
    u2 constant_pool_count 1
    cp_info constant_pool constant_pool_count-1
  • 由上表可见,Class文件使用了一个前置的容量计数器(constant_pool_count) 加若干个连续的数据项(constant_pool) 的形式来描述常量池内容。我们把这一 系列连续常量池数据称为常量池集合。

  • 常量池表项中,用于存放编译时期生成的各种**字面量符号引用,这部分内容将在类加载后进入方法区的运行时常量池**中存放。(其中字符串常量池在jdk7以后被移进堆空间中)

2、常量池计数器(constant_pool_ count)
  • 由于常量池的数量不固定,时长时短,所以需要放置两个字节来表示常量池容量计数值

  • 常量池容量计数值(u2类型) :从1开始,表示常量池中有多少项常量。即 constant_pool_count=1 表示常量池中有0个常量项

  • Demo的值为:

    image-20210519013853054

    其值为0x0016,转换为十进制,也就是22。

    需要注意的是,这实际上只有21项常量。索引为范围是1-21。为什么呢?

    • 通常我们写代码时都是从0开始的,但是这里的常量池却是从1开始,因为它把第0项常量空出来了。
    • 这是为了满足后面某些指向常星池的索引值的数据在特定情况下需要表达“不引用任何一个常量池项目”的含义,这种情况可用索引值0来表示。
3、常量池表
  • constant_ pool是一种表结构, 以1 ~ constant_ pool_ count - 1为索引。表明了后面有多少个常量项。

  • 常量池主要存放两大类常量:**字面量(Literal)****符号引用(Symbolic References )**

  • 它包含了class文件结构及其子结构中引用的所有字符串常量类或接口名字段名其他常量。常量池中的每一项都具备相同的特征。第1个字节作为类型标记,用于确定该项的格式,这个字节称为tag byte(标记字节、标签字节)。

  • tag byte与对应的类型:(最后三个是在jdk7添加的,体现了java对动态语言的支持)

    类型 标志(或标识) 描述
    CONSTANT_utf8_info 1 UTF-8编码的字符串
    CONSTANT_Integer_info 3 整型字面量
    CONSTANT_Float_info 4 浮点型字面量
    CONSTANT_Long_info 5 长整型字面量
    CONSTANT_Double_info 6 双精度浮点型字面量
    CONSTANT_Class_info 7 类或接口的符号引用
    CONSTANT_String_info 8 字符串类型字面量
    CONSTANT_Fieldref_info 9 字段的符号引用
    CONSTANT_Methodref_info 10 类中方法的符号引用
    CONSTANT_InterfaceMethodref_info 11 接口中方法的符号引用
    CONSTANT_NameAndType_info 12 字段或方法的符号引用
    CONSTANT_MethodHandle_info 15 表示方法句柄
    CONSTANT_MethodType_info 16 标志方法类型
    CONSTANT_InvokeDynamic_info 18 表示一个动态方法调用点
4、常量池表——字面量和符号引用
  • 字面量和符号引用:

    • 常量池主要存放两大类常量:

      • 字面量(Literal)
      • 符号引用(Symbolic References) 。
    • 如下表:

      常量 具体的常量 举例
      字面量 文本字符串 String str = “Hello”;
      声明为final的常星值 final int NUM = 10;
      符号引用 类和接口的全限定名 com/atguigu/test/Demo;
      字段的名称和描述符 add、num
      方法的名称和描述符
  • 全限定名

    • com/atguigu/test/Demo这个就是类的全限定名,仅仅是**把包名的”.”替换成”/“**,
    • 为了使连续的多个全限定名之间不产生混淆,在使用时最后一般会加入一个”;”表示全限定名结束
  • 简单名称

    • 简单名称是指没有类型和参数修饰方法或者字段名称,上面例子中的类的add()方法和num字段的简单名称分别是add和num。
  • 描述符

    • 描述符的作用是用来描述字段的数据类型方法的参数列表(包括数量、类型以及顺序)**和返回值**。

    • 根据描述符规则,基本数据类型(byte、char、double、float、int、long、short、boolean) 以及代表无返回值的void类型都用一个大写字符来表示,而对象类型则用字符L加对象的全限定名来表示,详见下表:(基本数据类型long对应的是J;基本数据类型boolean对应的是Z;几个[表示几维数组)

      标志符 含义
      B 基本数据类型byte
      C 基本数据类型char
      D 基本数据类型double
      F 基本数据类型float
      I 基本数据类型int
      J 基本数据类型long
      S 基本数据类型short
      Z 基本数据类型boolean
      V 代表void类型
      L 对象类型,比如:Ljava/lang/Object;
      [ 数组类型,代表一维数组。比如:double[][][] is [[[D
    • 用描述符来描述方法时,按照先参数列表后返回值的顺序描述参数列表按照参数的严格顺序放在一组小括号”()”之内

      • 如方法java.lang.String toString()的描述符为:() Ljava/lang/String;
      • 方法int abc(int[] x, int y)的描述符为([II) I
  • 补充说明:

    • 虚拟机在加载Class文件时才会进行动态链接,也就是说,Class 文件中不会保存各个方法和字段的最终内存布局信息,因此,这些字段和方法的符号引用不经过转换是无法直接被虚拟机使用的。当虚拟机运行时,需要从常量池中获得对应的符号引用,再在类加载过程中的解析阶段将其替换为直接引用,并翻译到具体的内存地址中
    • 这里说明下符号引用直接引用的区别与关联:
      • 符号引用:符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经加载到了内存中。
      • 直接引用:直接引用可以是直接指向目标的指针相对偏移量或是个能间接定位到目标的句柄直接引用是与虚拟机实现的内存布局相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。 如果有了直接引用,那说明引用的目标必定已经存在于内存之中了。
      • 加载前类的方法等信息只是字符串(字面量与符号引用),而加载后会把这个字符串(字面量与符号引用)替换成相对应的内存地址。
5、常量池表——常量类型和结构

常量池中每一项常量都是一个表,JDK1.7之后共有14种不同的表结构数据。如下表格所示:

常量类型和结构细节:

1598773300484

1598773308492

  • 根据上图每个类型的描述我们也可以知道每个类型是用来描述常量池中哪些内容(主要是字面量、符号引用)的。比如:CONSTANT_Integer_info是用来描述常量池中字面量信息的,而且只是整型字面量信息。
  • 标志为15、16、18的常量项类型是用来支持动态语言调用的(jdk1.7时才加入的)。
  • 细节说明:
    • CONSTANT_Class_info:结构用于表示类或接口
    • CONSTANT_Fieldref_info、CONSTANT_Methodref_info和CONSTANT_InterfaceMethodref_info结构:表示字段、方法和接口方法
    • CONSTANT_string_info结构用于表示String类型的常量对象
    • CONSTANT_Integer_info和CONSTANT_Float_info:表示4字节(int 和float)的数值常量
    • CONSTANT_Long_info和ICONSTANT Double_info结构:表示8字节(long和double)的数值常量
      • 在class文件的常量池表中,所有的8字节常量均占两个表成员(项)的空间。如果一个CONSTANT_Long_info或CONSTANT_Double_info结构的项在常量池表中的索引位n,则常量池表中下一个可用项的索引位n+2,此时常量池表中索引为n+1的项仍然有效但必须视为不可用的。
    • CONSTANT_NameAndType_info结构用于表示字段或方法,但是和之前的3个结构不同,CONSTANT_NameAndType_info结构没有指明该字段或方法所属的类或接口。
    • CONSTANT_utf8_info用于表示字符常量的值
    • CONSTANT_MethodHandle_info结构用于表示方法句柄
    • CONSTANT_MethodType_info结构:表示方法类型
    • CONSTANT_InvokeDynamic_info结构用于表示invokedynamic指令所用到的引导方法(bootstrap method)、 引导方法所用到的动态调用名(dynamic invocation name)、 参数和返回类型,并可以给引导方法传入一系列称为静态参数(static argument)的常量。
  • 解析方式:
    • 一个字节一个字节的解析
    • 使用javap命令解析:javap -verbose Demo.class 或 jclasslib工具会更方便。
  • 总结1:
    • 这14种表(或者常量项结构)的共同点是:表开始的第一位是一个u1类型的标志位(tag),代表当前这个常量项使用的是哪种表结构,即哪种常量类型。
    • 在常量池列表中,CONSTANT_Utf8_info常量项是一种使用改进过的UTF - 8编码格式来存储诸如文字字符串、类或者接口的全限定名、字段或者方法的简单名称以及描述符等常量字符串信息。
    • 这14种常量项结构还有一个特点是,其中13个常量项占用的字节固定,只有CONSTANT_Utf8_info占用字节不固定,其大小由length决定。为什么呢?
      • 因为从常量池存放的内容可知,其存放的是字面量和符号引用,最终这些内容都会是一个字符串,这些字符串的大小是在编写程序时才确定,比如你定义一个类, 类名可以取长取短,所以在没编译前,大小不固定,编译后,通过utf-8编码, 就可以知道其长度。
  • 总结2:
    • 常量池:可以理解为Class文件之中的资源仓库,它是Class文件结构中与其他项目关联最多的数据类型(后面的很多数据类型都会指向此处),也是占用Class文件空间最大的数据项目之一。
    • 常量池中为什么要包含这些内容
      • Java代码在进行Javac编译的时候, 并不像C和C++那样有“连接”这一步骤,而是在虚拟机加载Class文件的时候进行动态链接。也就是说,在Class文件中不会保存各个方法、字段的最终内存布局信息,因此这些字段、方法的符号引用不经过运行期转换的话无法得到真正的内存入口地址,也就无法直接被虚拟机使用。当虚拟机运行时,需要从常量池获得对应的符号引用,再在类创建时或运行时解析、翻译到具体的内存地址之中。关于类的创建和动态链接的内容,在虚拟机类加载过程时再进行详细讲解。
5、04-访问标识

访问标识(access_ flag、访问标志、访问标记)

  • 在常量池后,紧跟着访问标记。该标记使用两个字节表示,用于识别一些类或者接口层次的访问信息,包括:

    • 这个Class是类还是接口;
    • 是否定义为public类型;
    • 是否定义为abstract类型;如果是类的话,是否被声明为final等。
  • 各种访问标记如下所示:

    标志名称 标志值 含义
    ACC_PUBLIC 0x0001 标志为public类型
    ACC_FINAL 0x0010 标志被声明为final,只有类可以设置
    ACC_SUPER 0x0020 标志允许使用invokespecial字节码指令的新语义,JDK1.0.2之后编译出来的类的这个标志默认为真。(使用增强的方法调用父类方法)
    ACC_INTERFACE 0x0200 标志这是一个接口
    ACC_ABSTRACT 0x0400 是否为abstract类型,对于接口或者抽象类来说,次标志值为真,其他类型为假
    ACC_SYNTHETIC 0x1000 标志此类并非由用户代码产生(即:由编译器产生的类,没有源码对应)
    ACC_ANNOTATION 0x2000 标志这是一个注解
    ACC_ENUM 0x4000 标志这是一个枚举
  • 类的访问权限通常为ACC_ 开头的常量。

  • 每一种类型的表示都是通过设置访问标记的32位中的特定位来实现的。比如,若是public final的类,则该标记为ACC_PUBLIC | ACC_FINAL。

  • 使用ACC_SUPER可以让类更准确地定位到父类的方法super.method(),现代编译器都会设置并且使用这个标记。

  • 补充说明:

    1. 带有ACC_INTERFACE标志的class文件表示的是接口而不是类,反之则表示的是类而不是接口。
      1. 如果一个class文件被设置了ACC_INTERFACE 标志,那么同时也得设置ACC_ABSTRACT 标志。同时它不能再设置ACC_FINAL、ACC_SUPER或ACC_ENUM标志。
      2. 如果没有设置ACC_INTERFACE标志,那么这个class文件可以具有上表中除ACC_ANNOTATION外的其他所有标志。当然,ACC_FINAL和ACC_ ABSTRACT这类互斥的标志除外。这两个标志不得同时设置。
    2. ACC_SUPER标志用于确定类或接口里面的invokespecial指令使用的是哪一种执行语义。针对Java虚拟机指令集的编译器都应当设置这个标志。对于Java SE 8及后续版本来说,无论class文件中这个标志的实际值是什么,也不管class文件的版本号是多少,Java虛拟机都认为每个class文件均设置了ACC_SUPER标志。
      1. ACC_SUPER标志是为了向后兼容由旧Java编译器所编译的代码而设计的。目前的ACC_SUPER标志在由JDK 1.0.2之前的编译器所生成的access_flags中是没有确定含义的,如果设置了该标志,那么Oracle的Java虛拟机实现会将其忽略。
    3. ACC_SYNTHETIC标志意味着该类或接口是由编译器生成的,而不是由源代码生成的。
    4. 注解类型必须设置ACC_ANNOTATION标志。如果设置了ACC_ANNOTATION标志,那么也必须设置ACC_ INTERFACE标志。
    5. ACC_ENUM标志表明该类或其父类为枚举类型。
6、05-类索引、父类索引、接口索引集合
  • 在访问标记后,会指定该类的类别、父类类别以及实现的接口,格式如下:

    长度 含义
    u2 this_class
    u2 super_class
    u2 interfaces_count
    u2 interfaces[interfaces_count]
  • 这三项数据来确定这个类的继承关系:

    • 类索引用于确定这个类的全限定名
    • 父类索引用于确定这个类的父类的全限定名。由于Java语言不允许多重继承,所以父类索引只有一个,除了java.lang.Object之外,所有的Java类都有父类,因此除了java.lang.Object外,所有Java类的父类索引都不为0。
    • 接口索引集合就用来描述这个类实现了哪些接口,这些被实现的接口将按implements语句(如果这个类本身是一个接口,则应当是extends 语句)后的接口顺序从左到右排列在接口索引集合中。
  1. this_class (类索引)
    • 2字节无符号整数,指向常量池的索引。它提供了类的全限定名, 如com/atguigu/java1/Demo。this_ class的值必须是对常量池表中某项的一个有效索引值。常量池在这个索引处的成员必须为CONSTANT_Class_info类型结构体,该结构体表示这个class文件所定义的类或接口。
  2. super_class (父类索引)
    • 2字节无符号整数,指向常量池的索引。它提供了当前类的父类的全限定名。如果我们没有继承任何类,其默认继承的是java/lang/Object类。同时,由于Java不支持多继承,所以其父类只有一个。
    • superclass指向的父类不能是final。
  3. interfaces
    • 指向常量池索引集合,它提供了一个符号引用到所有己实现的接口
    • 由于一个类可以实现多个接口,因此需要以数组形式保存多个接口的索引,表示接口的每个索引也是一个指向常量池的CONSTANT_Class ( 当然这里就必须是接口,而不是类)。
  • interfaces_count (接口计数器):interfaces_ count 项的值表示当前类或接口的直接超接口数量。
  • interfaces [](接口索引集合):
    interfaces []中每个成员的值必须是对常量池表中某项的有效索引值,它的长度为interfaces_count。每个成员interfaces[i]必须为CONSTANT_Class_ info结构, 其中0 <= i < interfaces_count。 在interfaces []中,各成员所表示的接口顺序和对应的源代码中给定的接口顺序(从左至右)一样,即interfaces [0]对应的是源代码中最左边的接口。
7、06-字段表集合
1、字段表集合

fields:

  • 用于描述接口或类中声明的变量。字段(field)包括类级变量以及实例级变量,但是不包括方法内部、代码块内部声明的局部变量。(local variables)
  • 字段叫什么名字、字段被定义为什么数据类型,这些都是无法固定的,只能引用常量池中的常量来描述。
  • 它指向常量池索引集合,它描述了每个字段的完整信息。比如**字段的标识符、访问修饰符(public、private或protected)、是类变量还是实例变量(static修饰符)、是否是常量(final修饰符)**等。

注意事项:

  • 字段表集合中不会列出从父类或者实现的接口中继承而来的字段,但有可能列出原本Java代码之中不存在的字段。譬如在内部类中为了保持对外部类的访问性,会自动添加指向外部类实例的字段。
  • Java语言中字段是无法重载的,两个字段的数据类型、修饰符不管是否相同,都必须使用不一样的名称, 但是对于字节码来讲,如果两个字段的描述符不一致,那字段重名就是合法的。
2、fields_ count(字段计数器)
  • fields_count的值表示当前class文件fields表的成员个数。 使用两个字节来表示
  • fields表中每个成员都是一个field_info结构, 用于表示该类或接口所声明的所有类字段或者实例字段,不包括方法内部声明的变量,也不包括从父类或父接口继承的那些字段
3、fields [](字段表)
  • fields表中的每个成员都必须是个fields_info结构的数据项,用于表示当前类或接口中某个字段的完整描述。

  • 一个字段的信息包括如下这些信息。这些信息中,各个修饰符都是布尔值,要么有,要么没有

    • 作用域(public、private、protected修饰符)
    • 是实例变量还是类变量(static修饰符)
    • 可变性(final)
    • 并发可见性(volatile修饰符,是否强制从主内存读写)
    • 可否序列化(transient修饰符)
    • 字段数据类型(基本数据类型、对象、数组)
    • 字段名称
  • 字段表作为一个表,同样有他自己的结构:

    类型 名称 含义 数量
    u2 access_flags 访问标志 1
    u2 name_index 字段名索引 1
    u2 descriptor_index 描述符索引 1
    u2 attributes_count 属性计数器 1
    attribute_info attributes 属性集合 attributes_count
  • 字段表访问标识

    • 我们知道,一 个字段可以被各种关键字去修饰,比如:作用域修饰符(public、private、protected) 、static修饰符 、final修饰符、volatile修饰符等等。因此,其可像类的访问标志那样,使用一些标志来标记字段。字段的访问标志有如下这些:

      标志名称 标志值 含义
      ACC_PUBLIC 0x0001 标志为public类型
      ACC_FINAL 0x0010 标志被声明为final,只有类可以设置
      ACC_SUPER 0x0020 标志允许使用invokespecial字节码指令的新语义,JDK1.0.2之后编译出来的类的这个标志默认为真。(使用增强的方法调用父类方法)
      ACC_INTERFACE 0x0200 标志这是一个接口
      ACC_ABSTRACT 0x0400 是否为abstract类型,对于接口或者抽象类来说,次标志值为真,其他类型为假
      ACC_SYNTHETIC 0x1000 标志此类并非由用户代码产生(即:由编译器产生的类,没有源码对应)
      ACC_ANNOTATION 0x2000 标志这是一个注解
      ACC_ENUM 0x4000 标志这是一个枚举
  • 字段名索引:根据字段名索引的值,查询常量池中的指定索引项即可。

  • 描述符索引

    • 描述符的作用是用来描述字段的数据类型、方法的参数列表(包括数量、类型以及顺序)和返回值。根据描述符规则,基本数据类型(byte, char, double, float,int,long, short , boolean)及代表无返回值的void类型都用一个大写字符来表示, 而对象则用字符L加对象的全限定名来表示,如下所示:

      标志符 含义
      B 基本数据类型byte
      C 基本数据类型char
      D 基本数据类型double
      F 基本数据类型float
      I 基本数据类型int
      J 基本数据类型long
      S 基本数据类型short
      Z 基本数据类型boolean
      V 代表void类型
      L 对象类型,比如:Ljava/lang/Object;
      [ 数组类型,代表一维数组。比如:double[][][] is [[[D
  • 属性表集合

    • 一个字段还可能拥有一些属性,用于存储更多的额外信息。比如初始化值、一些注释信息等。属性个数存放在attribute_count中,属性具体内容存放在attributes数组中。

    • 例子:以常量属性为例,结构为:

      1
      2
      3
      4
      5
      ConstantValue_attribute{
      u2 attribute_name_index;
      u4 attribute_length;
      u2 constantvalue_index;
      }

      说明:对于常量属性而言,attribute_length值恒为2。

      根据上面的例子,我们来实际分析一下,如下图:

      img

8、07-方法表集合
1、方法表集合

methods:指向常量池索引集合,它完整描述了每个方法的签名。

  • 在字节码文件中,每一个method_info项都对应着个类或者接口中的方法信息。比如方法的访问修饰符(public、private或protected),方法的返回值类型以及方法的参数信息等。
  • 如果这个方法不是抽象的或者不是native的,那么字节码中会体现出来。
  • 一方面,methods表只描述当前类或接口中声明的方法,不包括从父类或父接口继承的方法。另一方面,methods表有可能会出现由编译器自动添加的方法,最典型的便是编译器产生的方法信息(比如:类(接口)初始化方法()**和实例初始化方法()**)。

使用注意事项:

  • 在Java语言中,要重载(Overload)一个方法,除了要与原方法具有相同的简单名称之外,还要求必须拥有一个与原方法不同的特征签名,特征签名就是一个方法中各个参数在常量池中的字段符号引用的集合,也就是因为返回值不会包含在特征签名之中,因此Java语言里无法仅仅依靠返回值的不同来对一个已有方法进行重载。
  • 但在Class文件格式中, 特征签名的范围更大一些,只要描述符不是完全一致的两个方法就可以共存。也就是说,如果两个方法有相同的名称和特征签名,但返回值不同,那么也是可以合法共存于同一个class文件中。
  • 也就是说,尽管Java语法规范并不允许在一个类或者接口中声明多个方法签名相同的方法,但是和Java语法规范相反,字节码文件中却恰恰允许存放多个方法签名相同的方法,唯一的条件就是这些方法之间的返回值不能相同
2、methods_ count(方法计数器)
  • methods_count的值表示当前class文件methods表的成员个数。使用两个字节来表示
  • methods表中每个成员都是一个method_info结构
3、methods [] (方法表)
  • methods表中的每个成员都必须是一个method_info结构, 用于表示当前类或接口中某个方法的完整描述。如果某个method_info结构的access_flags项既没有设置ACC_NATIVE标志,也没有设置ACC_ABSTRACT标志,那么该结构中也应包含实现这个方法所用的Java虚拟机指令。

  • method_info结构可以表示类和接口中定义的所有方法,包括实例方法、类方法、实例初始化方法和类或接口初始化方法

  • 方法表的结构实际跟字段表是一样的,方法表结构如下:

    类型 名称 含义 数量
    u2 access_flags 访问标志 1
    u2 name_index 字段名索引 1
    u2 descriptor_index 描述符索引 1
    u2 attributes_count 属性计数器 1
    attribute_info attributes 属性集合 attributes_count
  • 方法表访问标志

    • 跟字段表一样,方法表也有访问标志,而且他们的标志有部分相同,部分则不同,方法表的具体访问标志如下:

      标记名 说明
      ACC_PUBLIC 0x0001 public,方法可以从包外访问
      ACC_PRIVATE 0x0002 private,方法只能本类中访问
      ACC_PROTECTED 0x0004 protected,方法在自身和子类可以访问
      ACC_STATIC 0x0008 static,静态方法
9、08-属性表集合
1、属性表集合(attributes)
  • 方法表集合之后的属性表集合,指的是class 文件所携带的辅助信息,比如该class 文件的源文件的名称。以及任何带有RetentionPolicy.CLASS或者RetentionPolicy.RUNTIME的注解。这类信息通常被用于Java虚拟机的验证和运行,以及Java程序的调试,一般无须深入了解
  • 此外,字段表、方法表都可以有自己的属性表。用于描述某些场景专有的信息。
  • 属性表集合的限制没有那么严格,不再要求各个属性表具有严格的顺序,并且只要不与已有的属性名重复,任何人实现的编译器都可以向属性表中写入自己定义的属性信息,但Java虛拟机运行时会忽略掉它不认识的属性
2、attributes_ count(属性计数器)
  • attributes_count的值表示当前class文件属性表的成员个数。属性表中每一项都是个attribute_info结构。
3、attributes [] ( 属性表)
  • 属性表的每个项的值必须是attribute_ info结构。属性表的结构比较灵活,各种不同的属性只要满足以下结构即可。

  • 属性的通用格式:(即只需说明属性的名称以及占用位数的长度即可,属性表具体的结构可以去自定义)

    类型 名称 数量 含义
    u2 attribute_name_index 1 属性名索引
    u4 attribute_length 1 属性长度
    u1 info attribute_length 属性表
  • 属性类型:

    属性表实际上可以有很多类型,上面看到的Code属性只是其中一种,Java8里面定义了23种属性。下面这些是虚拟机中预定义的属性:

    属性名称 使用位置 含义
    Code 方法表 Java代码编译成的字节码指令
    ConstantValue 字段表 final关键字定义的常量池
    Deprecated 类,方法,字段表 被声明为deprecated的方法和字段
    Exceptions 方法表 方法抛出的异常
    EnclosingMethod 类文件 仅当一个类为局部类或者匿名类是才能拥有这个属性,这个属性用于标识这个类所在的外围方法
    InnerClass 类文件 内部类列表
    LineNumberTable Code属性 Java源码的行号与字节码指令的对应关系
    LocalVariableTable Code属性 方法的局部变量描述
    StackMapTable Code属性 JDK1.6中新增的属性.供新的类型检查检验器检查和处理目标方法的局部变量和操作数有所需要的类是否匹配
    Signature 类,方法,字段表 用于支持泛型情况下的方法签名
    Sourcefile 类文件 记录源文件名称
    SourceDebugExtension 类文件 用于存储额外的调试信息
    Synthetic 类,方法,字段表 标志方法或字段为编译器自动生成的
    LocalVariableTypeTable 使用特征签名代替描述符,是为了引入泛型语法之后能描述泛型参数化类型而添加
    RuntimeVisibleAnnotations 类,方法,字段表 为动态注解提供支持
    RuntimeInvisibleAnnotations 类,方法,字段表 用于指明哪些注解是运行时不可见的
    RuntimeVisibleParameterAnnotation 方法表 作用与RuntimeVisibleAnnotations属性类似,只不过作用对象为方法
    RuntimeInvisibleParameterAnnotation 方法表 作用与RuntimeInvisibleAnnotations属性类似,作用对象哪个为方法参数
    AnnotationDefault 方法表 用于记录注解类元素的默认值
    BootstrapMethods 类文件 用于保存invokeddynamic指令引用的引导方式限定符

    或者(查看官网):

    img

  • 部分属性详解

    1. ConstantValue 属性:

      ConstantValue 属性表示一个常量字段的值。位于field_info结构的属性表中。

      1
      2
      3
      4
      5
      6
      ConstantValue_attribute {
      u2 attribute_name_index;
      u4 attribute_length;
      u2 constantvalue_index;//字段值在常量池中的索引,常量池在该索引处的项给出该属性表示的常量值。
      //(例如,值是1ong型的, 在常量池中便是CONSTANT_ Long )
      }
    2. Deprecated 属性:

      Deprecated属性是在JDK 1.1为了支持注释中的关键词@deprecated而引入的。

      1
      2
      3
      4
      Deprecated_attribute {
      u2 attribute name_index;
      u4 attribute_length;
      }
    3. Code 属性:

      Code属性就是存放方法体里面的代码。但是,并非所有方法表都有Code属性。像接口或者抽象方法,他们没有具体的方法体,因此也就不会有Code属性了。

      Code属性表的结构,如下图:

      类型 名称 数量 含义
      u2 attribute_name_index 1 属性名索引
      u4 attribute_length 1 属性长度
      u2 max_stack 1 操作数栈深度的最大值
      u2 max_locals 1 局部变量表所需的存续空间
      u4 code_length 1 字节码指令的长度
      u1 code code_length 存储字节码指令
      u2 exception_table_length 1 异常表长度
      exception_info exception_table exception_length 异常表
      u2 attribute_count 1 属性集合计数器
      attribute_info attributes attribute_count 属性集合

      可以看到:Code属性表的前两项跟属性表是-致的,即Code属性表遵循属性表的结构,后面那些则是他自定义的结构。

    4. InnerClasses 属性:

      • 为了方便说明特别定义一个表示类或接口的Class格式为C。如果C的常量池中包含某个CONSTANT_Class_info成员,且这个成员所表示的类或接口不属于任何一个包,那么C的ClassFile结构的属性表中就必须含有对应的InnerClasses属性。
      • InnerClasses属性是在JDK 1.1中为了支持内部类和内部接口而引入的,位于ClassFile结构的属性表。
    5. LineNumberTable 属性:

      • LineNumberTable属性是可选变长属性,位于Code结构的属性表。

      • LineNumberTable属性是用来描述Java源码行号与字节码行号之间的对应关系。这个属性可以用来在调试的时候定位代码执行的行数。

        • start_pc,即字节码行号;
        • line_number,即Java源代码行号。
      • 在Code属性的属性表中,LineNumberTable属性可以按照任意顺序出现,此外,多个LineNumberTable属性可以共同表示一个行号在源文件中表示的内容, 即LineNumberTable 属性不需要与源文件的行一对应。

      • LineNumberTable属性表结构:

        1
        2
        3
        4
        5
        6
        7
        8
        9
        LineNumberTable_attribute{
        u2 attribute_name_index:
        u4 attribute_length;
        u2 line_number_table_length;
        {
        u2 start_pc;
        u2 line_number;
        } line_number_table[line_number_table_length];
        }
    6. LocalVariableTable 属性:

      • LocalVariableTable 是可选变长属性,位于Code属性的属性表中。它被调试器用于确定方法在执行过程中局部变量的信息

      • 在Code属性的属性表中,LocalVariableTable 属性可以按照任意顺序出现。

      • Code属性中的每个局部变量最多只能有一个LocalVariableTable 属性。

        • startpc + length表示这个变量在字节码中的生命周期起始和结束的偏移位置(this生命周期从头0到结尾10)
        • index就是这个变量在局部变量表中的槽位(槽位可复用)
        • name就是变量名称
        • Descriptor表示局部变量类型描述
      • LocalVariableTable属性表结构:

        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        LocalVariableTable_attribute{
        u2 attribute_name_index;
        u4 attribute_length;
        u2 local_variable_table_length;
        {
        u2 start_pc;
        u2 length;
        u2 name_index;
        u2 descriptor_index;
        u2 index;
        } local_variable_table[local_variable_table_length] ;
        }
    7. Signature 属性

      • Signature属性是可选的定长属性,位于ClassFile,field_info或method_info结构的属性表中。
      • 在Java语言中,任何类、接口、 初始化方法或成员的泛型签名如果包含了类型变量(Type Variables) 或参数化类型 ( Parameterized Types) ,则Signature 属性会为它记录泛型签名信息。
    8. SourceFile 属性

      • SourceFile属性结构:

        类型 名称 数量 含义
        u2 attribute_name_index 1 属性名索引
        u4 attribute_length 1 属性长度
        u2 sourcefile_index 1 源码文件索引

        可以看到,其长度总是固定的8个字节

    9. 其他属性:

      • Java虚拟机中预定义的属性有20多个,这里就不一一介绍了,通过上面几个属性的介绍,只要领会其精髓,其他属性的解读也是易如反掌。
10、class字节码文件总结
  • 主要介绍了Class文件的基本格式。
  • 随着Java平台的不断发展,在将来,Class文件的内容也一定会做进一步的扩充,但是其基本的格式和结构不会做重大调整。
  • 从Java虚拟机的角度看,通过Class文件,可以让更多的计算机语言支持Java虚拟机平台。因此,Class文件结构不仅仅是Java虛拟机的执行入口,更是Java生态圈的基础和核心。

4、使用javap指令解析Class文件

1、解析字节码的作用
  • 自己分析类文件结构太麻烦了! oracle提供了javap工具。
  • javap是jdk自带的反解析工具。它的作用就是根据class字节码文件,反解析出当前类对应的code区(汇编指令)、本地变量表、异常表和代码行偏移量映射表、常量池等等信息。
  • 通过反编译生成的汇编代码,我们可以深入的了解java代码的工作机制。比如我们可以查看i++;这行代码实际运行时是先获取变量i的值,然后将这个值加1,最后再将加1后的值赋值给变量i。
  • 通过局部变量表,我们可以查看局部变量的作用域范围、所在槽位等信息,甚至可以看到槽位复用等信息。
2、javac -g操作
  • 解析字节码文件得到的信息中,有些信息(如局部变量表、指令和代码行偏移量映射表、常量池中方法的参数名称等等)需要在使用javac编译成class文件时,指定参数才能输出。
  • 比如,你直接javac xx. java, 就不会在生成对应的局部变量表等信息,如果你使用**javac -g xx.java**就可以生成所有相关信息了。如果你使用的eclipse或IDEA,则默认情况下,eclipse、 IDEA在编译时会帮你生成局部变量表、指令和代码行偏移量映射表等信息的。
3、javap的用法
  • javap的用法格式:javap

    • 其中,options就是需要输入的参数选项
    • classes就是你要反编译的class文件。
  • 在命令行中直接输入javap或javap -help可以看到javap的options有如下选项:

    image-20210520022957097

  • 相关代码:

    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
    32
    33
    34
    35
    public class JavapTest {
    // 五大字段:private、默认、public、protected、final
    private int num;
    boolean flag;
    protected char gender;
    public String info;
    public static final int COUNTS = 1;
    // 静态代码块
    static{
    String url = "www.atguigu.com";
    }
    // 非静态代码块
    {
    info = "java";
    }
    // 两种构造方法:无参(public)与有参(private)
    public JavapTest(){
    }
    private JavapTest(boolean flag){
    this.flag = flag;
    }
    // 四大方法:private、默认、public、protected
    private void methodPrivate(){
    }
    int getNum(int i){
    return num + i;
    }
    protected char showGender(){
    return gender;
    }
    public void showInfo(){
    int i = 10;
    System.out.println(info + i);
    }
    }
  • 这里重组一下:

    • -help / –help / -?:输出此用法消息

    • -version:版本信息,其实是当前javap所在jdk的版本信息,不是class在哪个jdk下生成的。(即javap -version == javap -version xx.class)

      image-20210520023638002

      以上的options与相关class文件无关,为javap本身的options

    • -public:仅显示公共类和成员

      image-20210520023707476

    • -protected:显示受保护的/公共类和成员

      image-20210520024314713

    • -p / -private:显示所有类和成员

      image-20210520024520373

    • package:显示程序包/受保护的/公共类和成员(默认)

      image-20210520024729855

    • -sysinfo:显示正在处理的类的系统信息(路径,大小,日期,MD5散列,源文件名)

      image-20210520024834421

    • constants:显示静态最终常量

      image-20210520025014700

    • 以下options与代码的细节相关:

    • -s:输出内部类型签名

      image-20210520030031188

    • -l:输出行号和本地变量表

      image-20210520030243299

      注意:如果使用的是javac xx. java编译生成的class字节码文件,里面本来就没有本地变量表。因此就是使用-l也看不到本地变量表的信息。

    • -c:对代码进行反汇编

      image-20210520031140075

    • -v / -verbose:输出附加信息(包括行号、本地变量表,反汇编等详细信息)

      • 注意:就算-v也依旧没有私有private的信息。如果需要加上私有private的信息,得到一份最全的信息,就需要加上-p:

        1
        javap -v -p JavapTest.class

      以下很少用到,了解即可。

    • -classpath :指定查找用户类文件的位置

    • -cp :指定查找用户类文件的位置

  • 总结:

    • 一般常用的是-v、-l、-c三个选项:(重要的还是-v和-p
      • javap -l:会输出行号和本地变量表信息。
      • javap -c:会对当前class字节码进行反编译生成汇编代码。
      • javap -v classxx:除了包含-c内容外,还会输出行号、局部变量表信息、常量池等信息。
4、总结
  1. 通过javap命令可以查看一个java类反汇编得到的Class文件版本号、常量池、访问标识、变量表、指令代码行号表等等信息。不显示类索引、父类索引、接口索引集合、()、 ()等结构。其中()、 ()这两个是因为javap太智能,帮我们反编译成了相关的构造方法和静态代码块,在class字节码文件依旧可以看到这两个结构。
  2. 通过对前面例子代码反汇编文件的简单分析,可以发现,一个方法的执行通常会涉及下面几块内存的操作:
    1. java栈中:局部变量表、操作数栈。
    2. java堆:通过对象的地址引用去操作。
    3. 常量池。
    4. 其他如帧数据区(方法返回地址、动态链接、一些附加信息)、方法区的剩余部分等情况,测试中没有显示出来,这里说明一下。
  3. 平常,我们比较关注的是java类中每个方法的反汇编中的指令操作过程,这些指令都是顺序执行的,可以参考官方文档查看每个指令的含义。

2、字节码指令集与解析举例

1、概述

  • Java字节码对于虚拟机,就好像汇编语言对于计算机,属于基本执行指令。
  • Java虚拟机的指令由一个字节长度的、代表着某种特定操作含义的数字(称为**操作码,Opcode) 以及跟随其后的零至多个代表此操作所需参数(称为操作数,Operands**) 而构成。由于Java虚拟机采用面向操作数栈而不是寄存器的结构,所以大多数的指令都不包含操作数,只有一个操作码。
  • 由于限制了Java虚拟机操作码的长度为一个字节(即0~255),这意味着指令集的操作码总数不可能超过256条
  • 官方文档
  • 熟悉虚拟机的指令对于动态字节码生成、反编译Class文件、Class文件修补都有着非常重要的价值。因此,阅读字节码作为了解Java虚拟机的基础技能,需要熟练掌握常见指令。
1、执行模型

如果不考虑异常处理的话,那么Java虚拟机的解释器可以使用下面这个伪代码当做最基本的执行模型来理解:

1
2
3
4
5
6
do{
自动计算PC寄存器的值加1;
根据PC寄存器的指示位置,从字节码流中取出操作码;
if(字节码存在操作数)从字节码流中取出操作数;
执行操作码所定义的操作;
}while(字节码长度>0);
2、字节码与数据类型
  • 在Java虚拟机的指令集中,大多数的指令都包含了其操作所对应的数据类型信息。例如,iload指令用于从局部变量表中加载int型的数据到操作数栈中,而fload指令加载的则是float类型的数据。
  • 对于大部分与数据类型相关的字节码指令,它们的操作码助记符中都有特殊的字符来表明专门为哪种数据类型服务
    • i代表对int类型的数据操作
    • l代表long
    • s代表short
    • b代表byte
    • c代表char
    • f代表float
    • d代表double
    • Boolean使用的是iconst_0或者iconst1
    • a代表对象
  • 也有一些指令的助记符中没有明确地指明操作类型的字母,如arraylength指令, 它没有代表数据类型的特殊字符,但操作数永远只能是一个数组类型的对象。
  • 还有另外一些指令,如无条件跳转指令goto则是与数据类型无关的
  • 大部分的指令都没有支持整数类型byte、char和short,甚至没有任何指令支持boolean类型。编译器会在编译期或运行期将byte和short类型的数据带符号扩展(Sign-Extend) 为相应的int类型数据,将boolean和char类型数据零位扩展(Zero-Extend) 为相应的int类型数据。
  • 与之类似,在处理boolean、byte、 short和char类型的数组时,也会转换为使用对应的int类型的字节码指令来处理。因此,大多数对于boolean、 byte、 short和char类型数据的操作,实际上都是使用相应的int类型作为运算类型。
3、指令分析
  • 由于完全介绍和学习这些指令需要花费大量时间。为了让大家能够更快地熟悉和了解这些基本指令,这里将JVM中的字节码指令集按用途大致分成9类。
    • 加载与存储指令
    • 算术指令
    • 类型转换指令
    • 对象的创建与访问指令
    • 方法调用与返回指令
    • 操作数栈管理指令
    • 比较控制指令
    • 异常处理指令
    • 同步控制指令
  • (说在前面)在做值相关操作时:
    • 一个指令,可以从局部变量表、常量池、堆中对象、方法调用、系统调用中等取得数据,这些数据(可能是值可能是对象的引用)被压入操作数栈。
    • 一个指令,也可以从操作数栈中取出一到多个值(pop多次),完成赋值、加减乘除、方法传参、系统调用等等操作。

2、加载与存储指令

  • 作用:加载和存储指令用于将数据从栈帧的局部变量表和操作数栈之间来回传递。
    • 其中数据压入操作数栈被称为加载
    • 此时的数据可能来源于局部变量表,也有可能来自于常量池(分成具体的两个类指令)
    • 把数据保存在局部变量表当中则被称为存储指令
  • 常用指令
    1. [局部变量压栈指令]将一个局部变量加载到操作数栈:xload、xload_ (其中x为i、l、f、d、a,n为0到3(不一定都是0~3,需要具体分析))(load
    2. [常量入栈指令]将一个常量加载到操作数栈:bipush、sipush、ldc、ldc_W、ldc2_W、aconst_null、iconst_ m1、iconst_ 、lconst_ 、fconst_ 、 dconst_ 。(push、ldc、const
    3. [出栈装入局部变量表指令]将一个数值从操作数栈存储到局部变量表:xstore、xstore_ (其中x为i、l、f、d、a,n为0到3);xastore ( 其中x为i、l、f、d、a、b、c、s)(store
    4. 扩充局部变量表的访问索引的指令:wide。
  • 上面所列举的指令助记符中,有一部分是以尖括号结尾的 (例如iload_ )。这些指令助记符实际上代表了一组指令(例如 iload_ 代表了iload_0、iload_1、iload_2和iload_3这几个指令) 。这几组指令都是某个带有一个操作数的通用指令(例如iload)的特殊形式,对于这若干组特殊指令来说,它们表面上没有操作数,不需要进行取操作数的动作,但操作数都隐含在指令中
    • 比如:
    • iload_0:将局部变量表中索引为0位置上的数据压入操作数栈中。(占一个字节)
    • iload 0:将局部变量表中索引为0位置上的数据压入操作数栈中。(占三个字节)
    • iload 4:将局部变量表中索引为4位置上的数据压入操作数栈中。(占三个字节)
    • 作用:节约空间,减少内存占用
  • 除此之外,它们的语义与原生的通用指令完全一致(例如iload_0的语义与操作数为0时的iload指令语义完全一致)。在尖括号之间的字母指定了指令隐含操作数的数据类型,代表非负的整数, 代表是int类型数据,代表1ong类型,代表float类型, 代表double类型。
  • 操作byte、char、short 和boolean类型数据时,经常用int类型的指令来表示。
0、复习:再谈操作数栈与局部变量表
1、操作数栈(Operand Stacks )

我们知道,Java字节码是Java虛拟机所使用的指令集。因此,它与Java虚拟机基于栈的计算模型是密不可分的。

在解释执行过程中,每当为Java方法分配栈桢时,Java虚拟机往往需要开辟块额外的空间作为操作数栈, 来存放计算的操作数以及返回结果

具体来说便是:执行每一条指令之前,Java虚拟机要求该指令的操作数已被压入操作数栈中。在执行指令时,Java虚拟机会将该指令所需的操作数弹出,并且将指令的结果重新压入栈中

例子:

image-20210520222930557

以加法指令iadd 为例。假设在执行该指令前,栈顶的两个元素分别为int值1和int值2,那么iadd指令将弹出这两个int, 并将求得的和int值3压入栈中。

image-20210520222944844

由于iadd指令只消耗栈项的两个元素,因此,对于离栈顶距离为2的元素,即图中的问号,iadd 指令并不关心它是否存在,更加不会对其进行修改。

2、局部变量表(Local Variables)

Java方法栈桢的另外一个重要组成部分则是局部变量区,字节码程序可以将计算的结果缓存在局部变量区之中

实际上,Java虚拟机将局部变量区当成一个数组,依次存放this指针(仅非静态方法) ,所传入的参数, 以及字节码中的局部变量。

和操作数栈一样,long类型以及double类型的值将占据两个单元,其余类型仅占据一个单元。一个槽位就是一个单元,占4个字节。

image-20210520223454201

举例:

代码:

1
2
3
4
5
6
7
8
public void foo(long 1, float f) {
{
int i=0;
}
{
String s = "Hello, World";
}
}

对应的图示:(槽位复用)

image-20210520223508191

1、局部变量压栈指令

局部变量压栈指令将给定的局部变量表中的数据压入操作数栈

这类指令大体可以分为:

  • xload_ (x为i、l、f、d、a,n为0到3)
  • xload (x为i、l、f、d、a)

说明:在这里,x的取值表示数据类型。

指令xload_ n表示将第n个局部变量压入操作数栈,比如iload_1、 fload_0、aload_0等指令。其中aload_n表示将一个对象引用压栈。此时该字节码指令占用一个字节(包含一个操作码,一个字节)

指令xload通过指定参数的形式,把局部变量压入操作数栈,当使用这个命令时,表示局部变量的数量可能超过了4个,比如指令iload、fload等。此时该字节码指令占用三个字节(包含一个操作码,一个字节、一个操作数,两个字节)

举例:

代码:

1
2
3
4
5
6
7
8
//1.局部变量压栈指令
public void load(int num, Object obj,long count,boolean flag,short[] arr) {
System.out.println(num);
System.out.println(obj);
System.out.println(count);
System.out.println(flag);
System.out.println(arr);
}

字节码指令执行过程:

image-20210520224306556

2、常量入栈指令

常量入栈指令的功能是将常数压入操作数栈,根据数据类型和入栈内容的不同,又可以分为const系列push系列ldc指令

指令const系列:用于对特定的常量入栈,入栈的常量隐含在指令本身里。

指令有: iconst_ <i> (i从-1到5)lconst_ <l> (l从0到1)fconst_ <f> (f从0到2)dconst_ <d> (d从0到1)aconst_null

比如:

  • iconst_m1将-1压入操作数栈;
  • iconst_x (x为0到5)将x压入栈;
  • lconst_0、lconst_1分别将长整数0和1压入栈;
  • fconst_0、fconst_1、 fconst_2分别将浮点数0、1、2压入栈;
  • dconst_0和dconst_1分别将double型0和1压入栈;
  • aconst_null将null压入操作数栈;

从指令的命名上不难找出规律,指令助记符的第一个字符总是喜欢表示数据类型,i表示整数l表示长整数f表示浮点数d表示双精度浮点,习惯上用a表示对象引用。如果指令隐含操作的参数,会以下划线形式给出。

指令push系列:主要包括bipush和sipush。 它们的区别在于接收数据类型的不同,bipush接收8位整数作为参数,sipush接收16位整数,它们都将参数压入栈。

指令ldc系列:如果以上指令都不能满足需求,那么可以使用万能的ldc指令,它可以接收一个8位的参数,该参数指向常量池中的int、float或者String的索引,将指定的内容压入堆栈。

类似的还有ldc_w,它接收两个8位参数,能支持的索引范围大于ldc。

如果要压入的元素是long或者double类型的,则使用ldc2_w指令,使用方式都是类似的。

总结如下:

image-20210520234851493

举例分析:

img

img

注意:常量入栈指令中的n和局部变量压栈指令中的n不一样,常量入栈的n代表数值或者对象,而不是局部变量表中的下标

3、出栈入局部变量表指令

出栈装入局部变量表指令用于将操作数栈中栈项元素弹出后,装入局部变量表的指定位置,用于给局部变量赋值

这类指令主要以store的形式存在,比如xstore(x为i、l、f、d、a)、xstore_n(x为i、l、f、d、a,n为0至3)。

  • 其中,指令istore_n将从操作数栈中弹出一个整数,并把它赋值给局部变量索引n的位置
  • 指令xstore由于没有隐含参数信息,故需要提供一个byte类型的参数类指定目标局部变量表的位置。

说明:

一般说来,类似像store这样的命令需要带一个参数,用来指明将弹出的元素放在局部变量表的第几个位置。 但是,为了尽可能压缩指令大小,使用专门的istore_1指令表示将弹出的元素放置在局部变量表第1个位置。类似的还有istore_0、istore_2、 istore_3,它们分别表示从操作数栈顶弹出一个元素,存放在局部变量表第0、2、3个位置。

由于局部变量表前几个位置总是非常常用,因此这种做法虽然增加了指令数量,但是可以大大压缩生成的字节码的体积。如果局部变量表很大,需要存储的槽位大于3,那么可以使用istore指令,外加一个参数,用来表示需要存放的槽位位置。

举例分析:

image-20210521000606437

相关分析:

  1. 首先该方法被调用的时候,形式参数k和d都是有确定的值,由于该方法不是静态方法,所以局部变量表中的第一个位置(槽位)存储this,而第二个位置存储k具体的值,由于老师只是分析,没有调用这个方法,所以老师全部使用的变量名称来代替具体的值,所以明白就好。
  2. 然后第三个和第四个位置储存d具体的值,由于d是double类型,所以需要占据两个槽位,数据已经准备好了,那就来看字节码
    1. 首先iload_1是将局部变量表中下标为1的k值取出来压入操作数栈中
    2. 然后iconst_2是将常量池中的整型值2压入操作数栈,iadd让操作数栈弹出的k值和整型值2执行相加操作
    3. 之后将相加的结果值m压入操作数栈中,请注意老师的画法,在执行弹栈和压栈操作之后,老师并没有删除操作数栈中的k值和2,这是因为老师让我们知道具体的操作过程,所以故意为之,不过真正的操作是弹栈之后k值和2就会从操作数栈中弹出,之后操作数栈中就没有k值和2了,只有m值了
  3. 然后istore_4是将操作数栈中的m值弹出栈,然后放在局部变量表中下标为4的位置
  4. idc2_w #13代表将long型值12压入操作数栈,istore5是将值12弹栈之后放入局部变量表中下标为5的位置,由于12是long型,所以占据两个位置(槽位)
  5. ldc #15代表将字符串atguigu压入操作数栈,astore 7代表将字符串atguigu弹栈之后放入局部变量表中下标为7的位置
  6. idc #16代表将float类型数据10.0压入操作数栈,fstore 8代表将10.0弹出栈,然后放入局部变量表中下标为8的位置
  7. idc2_w #17代表将10.0压入操作数栈,dstore2代表将10.0弹出栈,之后将10.0放入下标为2和3的操作,毕竟这是double类型数据。

槽位复用

img

注意:在方法没有运行的时候,根据字节码文件就可以计算出需要几个槽位

3、算术指令

  1. 作用:算术指令用于对两个操作数栈上的值进行某种特定运算,并把结果重新压入操作数栈

  2. 分类

    • 大体上算术指令可以分为两种:
      • 整型数据进行运算的指令
      • 浮点类型数据进行运算的指令
  3. byte、short、char和boolean类型说明:

    在每一大类中, 都有针对Java虚拟机具体数据类型的专用算术指令。但没有直接支持byte、short、char和boolean类型的算术指令,对于这些数据的运算,都使用int类型的指令来处理。此外,在处理boolean、byte、 short 和char类型的数组时,也会转换为使用对应的int类型的字节码指令来处理。

    image-20210521002838645

  4. 运算时的溢出
    数据运算可能会导致溢出,例如两个很大的正整数相加,结果可能是一个负数。其实Java虚拟机规范并无明确规定过整型数据溢出的具体结果,仅规定了在处理整型数据时,只有除法指令以及求余指令中当出现除数为0时会导致虚拟机抛出异常ArithmeticException

  5. 运算模式:

    • 向最接近数舍入模式:JVM要求在进行浮点数计算时,所有的运算结果都必须舍入到适当的精度,非精确结果必须舍入为可被表示的最接近的精确值,如果有两种可表示的形式与该值一样接近, 将优先选择最低有效位为零的;(四舍五入)
    • 向零舍入模式:将浮点数转换为整数时,采用该模式,该模式将在目标数值类型中选择一个最接近但是不大于原值的数字作为最精确的舍入结果。(截断法)
  6. NaN值使用:

    当一个操作产生溢出时,将会使用有符号的无穷大(Infinity)表示,如果某个操作结果没有明确的数学定义的话,将会使用NaN值来表示。而且所有使用NaN值作为操作数的算术操作,结果都会返回NaN。

    对于无穷大Infinity和NaN的举例:

    img

    1、所有算术指令

    所有的算术指令包括:

    • 加法指令:iadd、ladd、fadd、dadd
    • 减法指令:isub、lsub、fsub、dsub
    • 乘法指令:imul、lmul、fmul、dmul
    • 除法指令:idiv、ldiv、fdiv、ddiv
    • 求余指令:irem、lrem、frem、drem //remainder:余数
    • 取反指令:ineg、lneg、fneg、dneg //negation:取反
    • 自增指令:iinc
    • 位运算指令, 又可分为:
      • 位移指令:ishl、 ishr、iushr、lshl、lshr、lushr
      • 按位或指令:ior、lor
      • 按位与指令:iand、land
      • 按位异或指令:ixor、lxor
    • 比较指令:dcmpg、dcmpl、fcmpg、fcmpl、lcmp

相关举例的分析:

  • 对于 i = i + 10 与 i += 10 的区别:(假设i = 100)

    • i = i + 10:

      image-20210521004835960

    • i += 10:

      image-20210521005146519

    结论:

    • 若i一开始为byte类型,与10相加之后转换为int类型;自增10之后编译不报错
    • 如果short i = 10,那么i+=10不是在原位置上加10,而是进行了强转,其中用到了i2s
  • JVM取反(~)操作的具体实现过程:(用异或实现)

    • 先取出操作数压入操作数栈
    • 在将-1压入操作数栈(iconst_m1)
    • 将操作数与-1实现异或(xor)操作
    • 得到的操作数取反后的值在压入操作数栈
1、举例
1
2
3
public static int bar(int i) {
return ((i + 1) - 2) * 3 / 4;
}

字节码指令对应的图示:

img

2、一个曾经的案例

代码:

1
2
3
4
5
6
7
public static void main(String[] args) {
int x = 500;
int y = 100;
int a = x / y;
int b = 50;
System.out.println(a + b);
}

字节码对应的内存解析:(省略了前面的解析,主要看一下println()方法:返回值为void)

img

img

注意:

  • 执行System.out.printin()的时候,会在虚拟机栈当中新建一个System.out.printin方法栈帧,55作为参数传入println()方法当中;
  • println()方法栈帧的本地变量表中会放55,这样该方法就可以使用了。
  • println()方法执行结束之后,由于返回值为void,所以字节码指令直接return
  • 进入main方法栈帧之后,也直接执行return。
3、关于i++与++i

没有其他操作(如赋值)的情况下,i++与++i是一样的,没有区别:

1
2
3
4
5
6
//关于(前)++和(后)++
public void method6(){
int i = 10;
i++;
//++i;
}

字节码:image-20210521011319549

与其他运算符(赋值运算符)结合运算的情况下,i++与++i就有区别了:

  • i++:先赋值后自增
  • ++i:先自增后赋值
1
2
3
4
5
6
7
public void method7(){
int i = 10;
int a = i++;

int j = 20;
int b = ++j;
}

字节码:

image-20210521014725284

与println()方法结合的情况:

1
2
3
4
5
public void method8(){
int i = 10;
i = i++;
System.out.println(i);//10
}

字节码:

image-20210521020635532

2、比较指令的说明
  • 比较指令的作用是比较栈顶两个元素的大小,并将比较结果入栈
  • 比较指令有:dcmpg、dcmpl、 fcmpg、 fcmpl、 lcmp
    • 与前面讲解的指令类似,首字符d表示double类型, f表示float , l表示long。
  • 对于double和float类型的数字,由于NaN的存在,各有两个版本的比较指令。以float为例,有fcmpg和fcmpl两个指令,它们的区别在于在数字比较时,若遇到NaN值,处理结果不同。
  • 指令dcmpl和dcmpg也是类似的,根据其命名可以推测其含义,在此不再赘述。
  • 指令lcmp针对long型整数,由于long型整数没有NaN值,故无需准备两套指令。

举例:

  • 指令fcmpg和fcmpl都从栈中弹出两个操作数,并将它们做比较,设栈顶的元素为v2,栈顶顺位第2位的元素为v1
    • 若v1 = v2,则压入0;
    • 若v1 > v2,则压入1;
    • 若v1 < v2,则压入-1
  • 两个指令的不同之处在于,如果遇到NaN值,fcmpg会压入1,而fcmpl会压入-1

数值类型的数据,才可以谈大小! boolean、引用数据类型不能比较大小。

注意:

  • NaN(Not a Number)表示不是一个数字,比如0.0/0.0得到的可能是1.0(两个数相等),也可能是0.0(0.0是分子),也可能是无穷大(0.0是分母),所以老师给出的解释是NaN代表无法确定是什么数字,只有double和float类型中可能出现NaN的情况,而long类型不会出现NaN,所以只有lcmp
  • 为什么只存在long/float/double的比较指令,而没有char/byte/short/int类型的比较指令?
    • 仔细观察会发现long/float/double的比较指令的最后都会往操作数栈压入一个int类型的值(-1/1/0),而比较指令常常与跳转指令一起使用
    • 而两种指令是通过int类型的值结合在一起使用的
    • 所以也就不必要存在int等类型的比较指令
    • 如果说是有int类型的比较指令的话就是比较条件跳转指令了

4、类型转换指令

类型转换指令说明:

  1. 类型转换指令可以将两种不同的数值类型进行相互转换
  2. 这些转换操作一般用于实现用户代码中的显式类型转换操作,或者用来处理字节码指令集中数据类型相关指令无法与数据类型一一对应的问题。
1、宽化类型转换(Widening Numeric Conversions)
1、转换规则

Java虚拟机直接支持以下数值的宽化类型转换(widening numeric conversion,小范围类型向大范围类型的安全转换)。也就是说,并不需要指令执行,包括:

  • 从int类型到long、float或者double类型。对应的指令为:i2li2fi2d
  • 从long类型到float、double类型。对应的指令为:l2fl2d
  • 从float类型到double类型。对应的指令为:f2d

简化为:int –> long –> float –> double

2、精度损失问题
  • 宽化类型转换是不会因为超过目标类型最大值而丢失信息的,例如,从int转换到long,或者从int转换到double,都不会丢失任何信息,转换前后的值是精确相等的。
  • int、long类型数值转换到float,或者long类型数值转换到double时,将可能发生精度丢失——可能丢失掉几个最低有效位上的值,转换后的浮点数值是根据IEEE754最接近舍入模式所得到的正确整数值。

尽管宽化类型转换实际上是可能发生精度丢失的,但是这种转换永远不会导致Java虚拟机抛出运行时异常

3、补充说明
  • byte、char 和short类型到int类型的宽化类型转换实际上是不存在的。对于byte类型转为int,虚拟机并没有做实质性的转化处理,只是简单地通过操作数栈交换了两个数据。而将byte转为long时,使用的是i2l,可以看到在内部byte在这里已经等同于int类型处理,类似的还有short类型,这种处理方式有两个特点:
    • 一方面可以减少实际的数据类型,如果为short和byte都准备一套指令,那么指令的数量就会大增,而虛拟机目前的设计上,只愿意使用一个字节表示指令,因此指令总数不能超过256个,为了节省指令资源,将short和byte当做int处理也在情理之中
    • 另一方面,由于局部变量表中的槽位固定为32位(4个字节,也是int的长度),无论是byte或者short存入局部变量表,都会占用32位空间。从这个角度说,也没有必要特意区分这几种数据类型。
2、窄化类型转换(Narrowing Numeric Conversion)
1、转换规则

Java虚拟机也直接支持以下窄化类型转换

  • 从int类型至byte、short或者char类型。对应的指令有:i2bi2ci2s
  • 从long类型到int类型。对应的指令有:l2i
  • 从float类型到int或者long类型。对应的指令有:f2if2l
  • 从double类型到int、long或者float类型。 对应的指令有:d2id2ld2f

注意:从float、double、long等类型往byte、short、char类型转换的时候,需要先把前面几种类型转换成int类型,然后在从int类型转换到后面这几种类型,所以int类型相等于一种过渡类型。

2、精度损失问题
  • 窄化类型转换可能会导致转换结果具备不同的正负号、不同的数量级,因此,转换过程很可能会导致数值丢失精度
  • 尽管数据类型窄化转换可能会发生上限溢出、下限溢出和精度丢失等情况,但是Java虚拟机规范中明确规定数值类型的窄化转换指令永远不可能导致虚拟机抛出运行时异常
3、补充说明
  1. 当将一个浮点值窄化转换为整数类型T(T限于int或long类型之一)的时候,将遵循以下转换规则:
    • 如果浮点值是NaN,那转换结果就是int或long类型的0
    • 如果浮点值不是无穷大的话,浮点值使用IEEE 754的向零舍入模式取整,获得整数值v,如果v在目标类型T(int或long)的表示范围之内,那转换结果就是v。否则,将根据v的符号,转换为T所能表示的最大或者最小正数
  2. 当将一个double类型窄化转换为float类型时,将遵循以下转换规则:
    • 通过向最接近数舍入模式舍入一个可以使用float类型表示的数字。最后结果根据下面这3条规则判断:
      • 如果转换结果的绝对值太小而无法使用float来表示, 将返回float类型的正负零
      • 如果转换结果的绝对值太大而无法使用float来表示,将返回float类型的正负无穷大
      • 对于double类型的NaN值将按规定转换为float类型的NaN值。

举例:

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public void downCast5(){
double d1 = Double.NaN; //0.0 / 0.0
int i = (int)d1;
System.out.println(d1); // NaN
System.out.println(i); // 0

double d2 = Double.POSITIVE_INFINITY;
long l = (long)d2;
int j = (int)d2;
System.out.println(l); // 9223372036854775807
System.out.println(Long.MAX_VALUE); // 92233720368547 75807
System.out.println(j); // 2147483647
System.out.println(Integer.MAX_VALUE); // 2147483647

float f = (float)d2;
System.out.println(f); // infinity

float f1 = (float)d1;
System.out.println(f1); // NaN
}

5、对象的创建与访问指令

Java是面向对象的程序设计语言,虚拟机平台从字节码层面就对面向对象做了深层次的支持。有一系列指令专门用于对象操作,可进一步细分为创建指令字段访问指令数组操作指令类型检查指令

1、创建指令

虽然类实例和数组都是对象,但Java虚拟机对类实例和数组的创建与操作使用了不同的字节码指令:

  1. 创建类实例的指令:

    • 创建类实例的指令:new

      • 接收一个操作数,为指向常量池的索引,表示要创建的类型,执行完成后,将对象的引用压入栈

      例子:

      image-20210521031608842

  2. 创建数组的指令:

    • 创建数组的指令:newarray、 anewarray、 multianewarray

      • newarray:创建基本类型数组
      • anewarray:创建引用类型数组
      • multianewarray:创建多维数组

      例子:

      image-20210521032029716

上述创建指令可以用于创建对象或者数组,由于对象和数组在Java中的广泛使用,这些指令的使用频率也非常高。

2、字段访问指令

对象创建后,就可以通过对象访问指令获取对象实例数组实例中的字段或者数组元素

  • 访问类字段(static字段,或者称为类变量)的指令getstaticputstatic
  • 访问类实例字段(非static字段,或者称为实例变量)的指令getfieldputfield

举例1:
以getstatic指令为例,它含有一个操作数,为指向常量池的Fieldref索引,它的作用就是获取Fieldref指定的对象或者值,并将其压入操作数栈。

1
2
3
public void sayHello() {
System.out.println("hello");
}

对应的字节码指令:

1
2
3
4
0 getstatic #8 <java/lang/System.out>
3 ldc #9 <hello>
5 invokevirtual #10 <java/io/PrintStream.println>
8 return

图示:

image-20210521033428524

举例2:

代码:

1
2
3
4
5
6
7
8
9
10
11
12
public void setOrderId(){
Order order = new Order();
order.id = 1001;
System.out.println(order.id);

Order.name = "ORDER";
System.out.println(Order.name);
}
class Order{
int id;
static String name;
}

字节码指令执行过程:

image-20210521034806188

注意:getxxx是入栈,而putxxx是出栈

3、数组操作指令
1、数组操作指令

数组操作指令主要有:xastorexaload指令。具体为:

  • 一个数组元素加载到操作数栈的指令baloadcaloadsaloadialoadlaloadfaloaddaloadaaload
  • 一个操作数栈的值存储到数组元素中的指令bastorecastoresastoreiastorelastorefastoredastoreaastore

即:

image-20210521171308285

  • 取数组长度的指令arraylength
    • 该指令弹出栈顶的数组元素,获取数组的长度,将长度压入栈
2、说明
  • 一个操作数栈的值存储到数组元素中的指令,即xastore指令与xstore指令的区别:

    • xstore指令是将值存放进局部变量表里面
    • xastore指令是将值存放进堆空间中对应的数组元素里面
  • 指令xaload表示将数组的元素压栈,比如saload、caload分别表示压入short数组和char数组。指令xaload在执行时,要求操作数中栈顶元素为数组索引i,栈顶顺位第2个元素为数组引用a,该指令会弹出栈顶这两个元素,并将a[i]重新压入栈。

  • xastore则专门针对数组操作,以iastore为例, 它用于给一个int数组的给定索引赋值。在iastore执行 ,操作数栈顶需要以此准备3个元素:

    • 索引
    • 数组引用

    iastore会弹出这3个值,并将值赋给数组中指定索引的位置。

    image-20210521171926923

4、类型检查指令

检查类实例或数组类型的指令:instanceofcheckcast

  • 指令checkcast用于检查类型强制转换是否可以进行。如果可以进行,那么checkcast指令不会改变操作数栈,否则它会抛出ClassCastException异常
  • 指令instanceof用来判断给定对象是否是某一个类的实例,它会将判断结果压入操作数栈

6、方法调用与返回指令

1、方法调用指令

方法调用指令:invokevirtualinvokeinterfaceinvokespecialinvokestaticinvokedynamic

以下5条指令用于方法调用:

  • invokevirtual指令用于调用对象的实例方法根据对象的实际类型进行分派(虚方法分派),支持多态。(可被子类重写)这也是Java语言中最常见的方法分派方式。
    • invokevirtual是调用类中的非静态普通方法,而这种实例方法可能调用的是子类重写的非静态普通方法,比如A a = new B();a.hello(),其中B类继承A类,并且B类重写了A类中的hello()方法,这种情况下就是invokevirtual了,但是有可能该类没有子类,调用的就是本类中的非静态普通方法,这种情况也是invokevirtual了
  • invokeinterface指令用于调用接口方法,它会在运行时搜索由特定对象所实现的这个接口方法,并找出适合的方法进行调用
    • invokeinterface是对接口而言的,用属于接口类型的对象调用方法的时候就是这个
  • invokespecial指令用于调用一些需要特殊处理的实例方法,包括实例初始化方法(构造器)**、私有方法父类方法。这些方法都是静态类型绑定的不会在调用时进行动态派发。(不能被子类重写**)
    • invokespecial只有构造器、私有方法、super.方法名()调用父类方法这几种情况,其中调用父类方法这种情况可能出现其直接父类没有该方法,那就可以调用其父类继承的父类中的该方法,最终找到一个方法调用就是了。
  • invokestatic指令用于调用命名类中的类方法(static方法)。这是静态绑定的
    • invokestatic是调用static静态方法,无论是使用对象.静态方法名()还是类名.静态方法名()都是invokestatic,也不难理解
  • invokedynamic:调用动态绑定的方法,这个是JDK 1.7后新加入的指令。用于在运行时动态解析出调用点限定符所引用的方法,并执行该方法。前面4条调用指令的分派逻辑都固化在java虚拟机内部,而invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的。具体可看博客:关于invokedynamic

注意:

  • 当一个方法既是私有方法,又是静态方法。使用的是invokestatic指令
  • 关于在接口当中的静态方法与默认方法
    • 静态方法:使用的是invokestatic指令
    • 默认方法:使用的是invokeinterface指令

总结:

  • 除了static的接口的方法都是invokeinterface,如果是static那么都是invokestatic。
  • 用static修饰的方法都是使用invokestatic
  • 如果是用多态的话使用的是invokevirtual
  • 如果是强转成接口类型的方法使用的是invokeinterface
2、方法返回指令

方法调用结束前,需要进行返回。方法返回指令是根据返回值的类型区分的:

  • 包括ireturn (当返回值是boolean、 byte、char、short和int类型时使用)、lreturnfreturndreturnareturn
  • 另外还有一条return指令供声明为void的方法、实例初始化方法以及类和接口的类初始化方法使用。

image-20210521200452707

说明:

  • 通过ireturn指令,将当前函数操作数栈的顶层元素弹出,并将这个元素压入调用者函数的操作数栈中(因为调用者非常关心函数的返回值),所有在当前函数操作数栈中的其他元素都会被丢弃
  • 如果当前返回的是synchronized方法,那么还会执行一个隐含的monitorexit指令,退出临界区(解锁的作用)。最后,会丢弃当前方法的整个帧,恢复调用者的帧,并将控制权转交给调用者。
  • 当返回的变量与返回值不是同一个基本数据类型的话,会有一个使用类型转换指令的过程。

举例:

代码:

1
2
3
4
5
6
public int methodReturn( ){
int i = 500;
int j = 200;
int k = 50;
return(i+j)/k;
}

image-20210521200909546

7、操作数栈管理指令

如同操作一个普通数据结构中的堆栈那样,JVM提供的操作数栈管理指令,可以用于直接操作操作数栈的指令。

这类指令包括如下内容:

  • 将一个或两个元素从栈顶弹出,并且直接废弃:pop, pop2
  • 复制栈顶一个或两个数值并将复制值或双份的复制值重新压入栈项:dup, dup2, dup_x1dup2_x1, dup_x2, dup2_x2;
  • 栈最顶端的两个Slot数值位置交换swap
    • Java虛拟机没有提供交换两个64位数据类型(long、double) 数值的指令。
  • 指令nop,是一个非常特殊的指令,它的字节码为0x00。和汇编语言中的nop一样,它表示什么都不做。这条指令一般可用于调试、占位等。

这些指令属于通用型,对栈的压入或者弹出无需指明数据类型

说明:

  • 不带_x的指令是复制栈顶数据并压入栈顶。包括两个指令,dupdup2
    • dup的系数代表要复制的Slot个数。
    • dup开头的指令用于复制1个Slot的数据。
      • 例如1个int或1个reference类型数据
    • dup2开头的指令用于复制2个Slot的数据。
      • 例如1个long,或2个int,或1个int+1个float类型数据
  • 带_ x的指令是复制栈顶数据并插入栈顶以下的某个位置。共有4个指令,dup_x1dup2_x1dup_x2dup2_x2
    • 对于带x的复制插入指令,只要将指令的dup和x的系数相加,结果即为需要插入的位置。因此:
      • dup_x1插入位置:1+1=2, 即栈顶2个Slot下面
      • dup_x2插入位置:1+2=3, 即栈顶3个Slot下面
      • dup2_x1插入位置:2+1=3, 即栈顶3个Slot下面
      • dup2_x2插入位置:2+2=4, 即栈顶4个Slot下面
  • pop:将栈项的1个Slot数值出栈。例如1个short类型数值
  • pop2:将栈顶的2个Slot数值出栈。例如1个double类型数值,或者2个int类型数值

例子:

image-20210521223947568

8、控制转义指令

程序流程离不开条件控制,为了支持条件跳转,虚拟机提供了大量字节码指令,大体上可以分为:

  1. 比较指令(在算术指令那里)
  2. 条件跳转指令
  3. 比较条件跳转指令
  4. 多条件分支跳转指令
  5. 无条件跳转指令等。
1、条件跳转指令

条件跳转指令通常和比较指令结合使用。在条件跳转指令执行前。一般可以先用比较指令进行栈顶元素的准备,然后进行条件跳转。

条件跳转指令有:ifeqifltifleifneifgtifge, ifnull, ifnonnull

这些指令都接收两个字节的操作数,用于计算跳转的位置(16位符号整数作为当前位置的offset)。

它们的统一含义为:弹出栈顶元素,测试它是否满足某一条件,如果满足条件,则跳转到给定位置

具体说明:

image-20210521230336692

注意:

  1. 与前面运算规则一致:
    • 对于boolean、byte、char、short类型的条件分支比较操作,都是使用int类型的比较指令完成
    • 对于long、float、double类型的条件分支比较操作,则会先执行相应类型的比较运算指令,运算指令会返回一个整型值到操作数栈中,随后再执行int类型的条件分支比较操作来完成整个分支跳转。
  2. 由于各类型的比较最终都会转为int类型的比较操作,所以Java虚拟机提供的int类型的条件分支指令是最为丰富和强大的。

例子:

image-20210521232741411

注意:

  1. 对于float、double、long类型的比较,它们比较之后生成的是int类型的0、1、-1,这个过程可以使用比较指令和条件跳转指令来完成,虽然得到的是int类型的值,但是System.out.println(XXX)中的值是布尔类型,你可以在jclasslib中的常量池信息中看到写的是Z,代表布尔值类型。
  2. int类型值(包含byte、char、short)比较和对象类型值比较需要使用比较条件跳转指令
  3. 在比较当中,跳转指令的选择与代码里面的条件判断恰好相反
    • 如题当中代码比较的是f1 < f2,而字节码当中却使用了ifge指令(f1 >= f2)
    • 原因是该指令是跳转指令:也就是当满足条件才跳转,不满足的话就只是顺序执行。所以与代码执行顺序相反(代码是满足条件就顺序执行,不满足才跳转到相应的执行语句)
2、比较条件跳转指令

比较条件跳转指令类似于比较指令和条件跳转指令的结合体,它将比较和跳转两个步骤合二为一。

这类指令有:if_icmpeqif_icmpneif_icmpltif_icmpgtif_icmpleif_icmpge、(之前的都是与int相关的比较条件跳转指令)if_acmpeqif_acmpne。其中指令助记符加上”if”后,以字符”i”开头的指令针对int型整数操作(也包括short和byte类型), 以字符”a”开头的指令表示对象引用的比较。

具体说明:

image-20210521234455626

这些指令都接收两个字节的操作数作为参数,用于计算跳转的位置。同时在执行指令时,栈顶需要准备两个元素进行比较。
指令执行完成后,栈顶的这两个元素被清空,且没有任何数据入栈。如果预设条件成立,则执行跳转,否则,继续执行下一条语句

注意:

  • 上面所说的后者是栈顶元素,而前者是栈顶下面的元素
  • 对于float、double、long类型的比较,它们比较之后生成的是int类型的0、1、-1,这个过程可以使用比较指令和条件跳转指令来完成。
  • 而int类型值(包含byte、char、short)比较和对象类型值比较需要使用比较条件跳转指令,
  • 其中对象类型值不是比较的地址,就是比较对象中的某些字段值,这又归咎到float、double、long、int类型的比较中比较条件跳转指令。
3、多条件分支跳转

多条件分支跳转指令是专为switch-case语句设计的,主要有tableswitchlookupswitch

image-20210522000649232

从助记符上看,两者都是switch语句的实现,它们的区别:

  • tableswitch要求多个条件分支值是连续的,它内部只存放起始值和终止值,以及若干个跳转偏移量,通过给定的操作数index,可以立即定位到跳转偏移量位置,因此效率比较高
  • lookupswitch内部存放着各个离散的case-offset对,每次执行都要搜索全部的case-offset对,找到匹配的case值,并根据对应的offset计算跳转地址,因此效率较低

指令tableswitch:

指令tableswitch的示意图如下图所示。由于tableswitch的case值是连续的,因此只需要记录最低值和最高值,以及每一项对应的offset偏移量,根据给定的index值通过简单的计算即可直接定位到offset。

image-20210522001143999

举例:

image-20210522002407044

注意:代码的break语句对应的就是字节码指令里的goto指令,无条件跳转到return处。如果代码没有加上break语句的话就会发生switch穿透,其实对应到字节码指令就是缺少goto指令跳转到return,只能往下顺序执行。

指令lookupswitch:

指令lookupswitch处理的是离散的case值,但是出于效率考虑,将case-offset对按照case值大小排序,给定index时需要查找与index相等的case,获得其offset,如果找不到则跳转到default。指令lookupswitch如下图所示。

image-20210522001304745

举例:

image-20210522003748294

关于String的switch语句:(使用的是指令lookupswitch和方法hashcode与equal)

image-20210522004730384

4、无条件跳转

目前主要的无条件跳转指令为goto

  • 指令goto接收两个字节的操作数,共同组成一个带符号的整数,用于指定指令的偏移量,指令执行的目的就是跳转到偏移量给定的位置处
  • 如果指令偏移量太大,超过双字节的带符号整数的范围,则可以使用指令goto_W,它和goto有相同的作用,但是它接收4个字节的操作数,可以表示更大的地址范围。
  • 指令jsrjsr_Wret虽然也是无条件跳转的,但主要用于try-finally语句,且已经被虚拟机逐渐废弃,故不在这里介绍这两个指令。

image-20210522005055555

举例:

通过goto指令与条件比较指令实现循环

image-20210522005743194

注意:

  • 这里使用的i为int类型,在代码执行i++的时候,字节码指令使用的是iinc 1 by 1;直接在局部变量表里面加。
  • 如果使用的i为double类型,在代码执行i++的时候,字节码使用的是 dload_x + dconst_1 + dadd + dastore_x 的指令组合实现的,需要在操作数栈中相加。(注意这里dload_x与dastore_x当中的x是一样的)
  • 如果使用的i为short类型,在代码执行i++的时候,字节码使用的是 iload_x + iconst_1 + iadd + i2s + istore_x 的指令组合实现的,需要在操作数栈中相加,并且在相加之后还需要将值从int窄化为short类型,才能存进局部变量表当中。
  • 所以用于循环遍历的变量尽量使用int,能增加系统的执行速度(调优)

9、异常处理指令

异常及异常的处理:
过程一:异常对象的生成过程 —> throw (手动/自动) —> 指令: athrow
过程二:异常的处理:抓抛模型。try-catch-finally —>使用异常表

1、抛出异常指令
  1. athrow指令

    • 在Java程序中显示抛出异常的操作(throw语句)都是由athrow指令来实现。
    • 除了使用throw语句显示抛出异常情况之外,JVM规范还规定了许多运行时异常会在其他Java虚拟机指令检测到异常状况时自动抛出
      • 例如,在之前介绍的整数运算时,当除数为零时,虚拟机会在idivldiv指令中抛出ArithmeticException异常。
  2. 注意:

    • 正常情况下,操作数栈的压入弹出都是一条条指令完成的。唯一的例外情况是在抛异常时,Java虚拟机会清除操作数栈上的所有内容,而后将异常实例压入调用者操作数栈上
    • 如果使用throw new 异常名称()这种形式来抛出异常,那就会在代码中出现athrow指令,
    • 在方法上面添加throw异常名称这种形式来抛出异常,然后使用jclasslib的时候就会出现在方法下面多出现一个属性Exceptions。
  3. 举例:

    • throw new 异常名称():

      image-20210522014446997

    • 在方法上面添加throw异常名称:

      img

    • 运行时异常没有athrow:

      image-20210522014923019

2、异常处理与异常表
  1. 处理异常:
    • 在Java虚拟机中,处理异常(catch语句)**不是由字节码指令来实现的(早期使用jsr、ret指令),而是采用异常表来完成的**。
  2. 异常表:
    • 如果一个方法定义了一个try-catch或者try-finally的异常处理,就会创建一个异常表。它包含了每个异常处理或者finally块的信息
    • 异常表保存了每个异常处理信息。比如:
      • 起始位置
      • 结束位置
      • 程序计数器记录的代码处理的偏移地址
      • 被捕获的异常类在常量池中的索引
  • 当一个异常被抛出时,JVM会在当前的方法里寻找一个匹配的处理,如果没有找到,这个方法会强制结束并弹出当前栈帧,并且异常会重新抛给上层调用的方法(在调用方法栈帧)。如果在所有栈帧弹出前仍然没有找到合适的异常处理,这个线程将终止。
  • 如果这个异常在最后一个非守护线程里抛出,将会导致JVM自己终止,比如这个线程是个main线程
  • 不管什么时候抛出异常,如果异常处理最终匹配了所有异常类型,代码就会继续执行。在这种情况下,如果方法结束后没有抛出异常,仍然执行finally块,在return前,它直接跳到finally块来完成目标。

异常表如下所示:

img

异常表的含义是如果在Start PC和End PC之间(大于等于Start PC,小于End PC(左闭右开))出现对应的Catch Type异常问题(出现异常就匹配对应的异常),将会在操作数栈中压入相应的异常类对象,之后跳转到Handler PC的位置去执行对应的字节码指令。

注意:

当异常出现的时候也会压入操作数栈,之后还会存储局部变量表中

举例1:

image-20210522020333543

举例2:

image-20210522021858220

10、同步控制指令

组成:

  • java虚拟机支持两种同步结构:
    • 方法级的同步
    • 方法内部一段指令序列的同步
  • 这两种同步都是使用monitor来支持的。
1、方法级的同步(添加synchronized的方法)

方法级的同步:是隐式的,即无须通过字节码指令来控制,它实现在方法调用和返回操作之中。虚拟机可以从方法常量池的方法表结构中的ACC_SYNCHRONIZED访问标志得知一个方法是否声明为同步方法;

当调用方法时,调用指令将会检查方法的ACC_SYNCHRONIZED访 问标志是否设置。

  • 如果设置了,执行线程将先持有同步锁,然后执行方法。最后在方法完成(无论是正常完成还是非正常完成)时释放同步锁
  • 在方法执行期间,执行线程持有了同步锁,其他任何线程都无法再获得同一个锁。
  • 如果一个同步方法执行期间抛出了异常,并且在方法内部无法处理此异常,那这个同步方法所持有的锁将在异常抛到同步方法之外时自动释放

说明:

这段代码和普通的无同步操作的代码没有什么不同,没有使用monitorenter和monitorexit进行同步区控制。这是因为,对于同步方法而言,当虚拟机通过方法的访问标示符判断是一个同步方法时,会自动在方法调用前进行加锁,当同步方法执行完毕后,不管方法是正常结束还是有异常抛出,均会由虚拟机释放这个锁。因此,对于同步方法而言,monitorentermonitorexit指令是隐式存在的,并未直接出现在字节码中。

举例:一个方法无论是否添加synchronized,你都无法在字节码中看出区别

img

是否是同步方法在字节码文件中你是无法看出区别的,但是可以在方法访问标识中看出区别

2、方法内部一段指令序列的同步

同步一段指令集序列:通常是由java中的synchronized语句块来表示的。jvm的指令集有monitorentermonitorexit两条指令来支持synchronized关键字的语义。

  • 当一个线程进入同步代码块时,它使用monitorenter指令请求进入。如果当前对象的监视器计数器为0,则它会被准许进入
  • 若为1,则判断持有当前监视器的线程是否为自己,如果是,则进入,否则进行等待,直到对象的监视器计数器为0,才会被允许进入同步块。
  • 当线程退出同步块时,需要使用monitorexit声明退出。在Java虚拟机中,任何对象都有一个监视器与之相关联,用来判断对象是否被锁定,当监视器被持有后,对象处于锁定状态。
  • 指令monitorentermonitorexit在执行时,都需要在操作数栈项压入对象,之后monitorentermonitorexit的锁定和释放都是针对这个对象的监视器进行的。

下图展示了监视器如何保护临界区代码不同时被多个线程访问,只有当线程4离开临界区后,线程1、2、3才有可能进入。

image-20210522024144096

编译器必须确保无论方法通过何种方式完成,方法中调用过的每条monitorenter指令都必须执行其对应的monitorexit指令,而无论这个方法是正常结束还是异常结束。

为了保证在方法异常完成时monitorenter和monitorexit指令依然可以正确配对执行,编译器会自动产生一个异常处理器,这个异常处理器声明可处理所有的异常,它的目的就是用来执行monitorexit指令

举例:

img

过程分析:

  1. 操作数栈中的对象和monitorenter结合起来可以让线程获取锁,做法就是让对象的监视器标记从0变成1,这就代表该线程上锁了
  2. 然后在操作数栈的aload_1monitorexit结合起来就可以让线程解锁,做法就是让对象的监视器标记从1变成0
  3. 这个解锁需要在方法退出之前完成,如果方法执行过程中出现了任何异常,将会跳到异常处理的字节码处执行相关代码;
  4. 如果异常处理的字节码部分出现了问题,那就重新执行异常处理的字节码

这些内容都在异常表中写的很明确,其中异常表也在上面截图中。


3、类的加载过程详解

1、概述

类的加载过程详解:这里的类指的是Class。泛指java当中的类Class、接口Interface、注解类Annotation、枚举Enum等等。

在Java中数据类型分为基本数据类型和引用数据类型:

  • 基本数据类型由虚拟机预先定义
  • 引用数据类型则需要进行类的加载

按照Java虛拟机规范,从class文件到加载到内存中的类,到类卸载出内存为止,它的整个生命周期包括如下7个阶段:

中篇_第3章:类的生命周期

其中,验证、准备、解析3个部分统称为链接(Linking)

注意:我们所说的加载完毕包括:加载、链接、初始化三个阶段都完成之后类进入方法区中

程序中类的使用过程看:

中篇_第3章:类的加载过程

2、过程一:Loading(加载)阶段

1、加载完成的操作
1、加载的理解

所谓加载,简而言之就是将Java类的字节码文件加载到机器内存中,并在内存中构建出Java类的原型——类模板对象

所谓类模板对象,其实就是Java类在JVM内存中的一个快照,JVM将从字节码文件中解析出的常量池、类字段、类方法等信息存储到类模板中,这样JVM在运行期便能通过类模板而获取Java类中的任意信息,能够对Java类的成员变量进行遍历,也能进行Java方法的调用。

反射的机制即基于这一基础。 如果JVM没有将Java类的声明信息存储起来,则JVM在运行期也无法反射。

2、加载完成的操作

加载阶段,简言之,查找并加载类的二进制数据,生成Class的实例

在加载类时,Java虚拟机必须完成以下3件事情:

  • 通过类的全名,获取类的二进制数据流
  • 解析类的二进制数据流为方法区内的数据结构(Java类模型)
  • 创建java.lang.Class类的实例,表示该类型。作为方法区这个类的各种数据的访问入口
2、二进制流的获取方式

对于类的二进制数据流,虚拟机可以通过多种途径产生或获得:(只要所读取的字节码符合JVM规范即可)

  • 虚拟机可能通过文件系统读入一个class后缀的文件(最常见)
  • 读入jar、zip等归档数据包,提取类文件。
  • 事先存放在数据库中的类的二进制数据
  • 使用类似于HTTP之类的协议通过网络进行加载(序列化与反序列化)
  • 运行时生成段Class的二进制信息等

在获取到类的二进制信息后,Java虛拟机就会处理这些数据,并最终转为一个java.lang.Class的实例

如果输入数据不是ClassFile的结构, 则会抛出ClassFormatError

3、类模型与Class实例的位置
  1. 类模型的位置:加载的类在JVM中创建相应的类结构,类结构会存储在方法区(JDK1.8之前:永久代;JDK1.8及之后:元空间)。

  2. Class实例的位置:

    • 类将.class文件加载至元空间后,会在堆中创建一个Java.lang.Class对象,用来封装类位于方法区内的数据结构;
    • 该Class对象是在加载类的过程中创建的,每个类都对应有一个Class类型的对象。(instanceKlass –> mirror:Class的实例)
  3. 图示:

    中篇_第3章:Class实例

  4. 说明:

    • Class类的构造方法是私有的,只有JVM能够创建
    • java.lang.Class实例是访问类型元数据的接口,也是实现反射的关键数据、入口
    • 通过Class类提供的接口,可以获得目标类所关联的.class文件中具体的数据结构:方法、字段等信息。
4、数组类的加载
  • 创建数组类的情况稍微有些特殊,因为数组类本身并不是由类加载器负责创建,而是由JVM在运行时根据需要而直接创建的
  • 数组的元素类型仍然需要依靠类加载器去创建
  • 创建数组类(下述简称A)的过程:
    1. 如果数组的元素类型是引用类型,那么就遵循定义的加载过程递归加载和创建数组A的元素类型
    2. JVM使用指定的元素类型和数组维度来创建新的数组类
  • 如果数组的元素类型是引用类型,数组类的可访问性就由元素类型的可访问性决定。否则数组类的可访问性将被缺省定义为public

3、过程二:Linking(链接)阶段

1、环节1:链接阶段之Verification(验证)

当类加载到系统后,就开始链接操作,验证是链接操作的第一步。

它的目的是保证加载的字节码是合法、合理并符合规范的

验证的步骤比较复杂,实际要验证的项目也很繁多,大体上Java虚拟机需要做以下检查,如图所示:

中篇_第3章:验证阶段的检查

整体说明:验证的内容则涵盖了类数据信息的格式验证语义检查字节码验证,以及符号引用验证等。

  • 其中格式验证会和加载阶段一起执行。验证通过之后,类加载器才会成功将类的二进制数据信息加载到方法区中。
  • 格式验证之外的验证操作将会在方法区中进行。

链接阶段的验证虽然拖慢了加载速度,但是它避免了在字节码运行时还需要进行各种检查。(磨刀不误砍柴工)

具体说明

  1. 格式验证

    • 是否以魔数0xCAFEBABE开头
    • 主版本和副版本号是否在当前Java虚拟机的支持范围内
    • 据中每一个项是否都拥有正确的长度等。
  2. Java虚拟机会进行字节码的语义检查,但凡在语义上不符合规范的,虚拟机也不会给予验证通过。比如:

    • 是否所有的类都有父类的存在(在Java里, 除了object外, 其他类都应该有父类)
    • 是否一些被定义为final的方法或者类被重写或继承
    • 非抽象类是否实现了所有抽象方法或者接口方法
    • 是否存在不兼容的方法。比如:
      • 方法的签名除了返回值不同,其他都一样,这种方法会让虚拟机无从下手调度
      • abstract情况下的方法,就不能是final的了
  3. Java虚拟机还会进行字节码验证,字节码验证也是验证过程中最为复杂的一个过程。它试图通过对字节码流的分析,判断字节码是否可以被正确地执行。比如:

    • 在字节码的执行过程中,是否会跳转到一条不存在的指令
    • 函数的调用是否传递了正确类型的参数
    • 变量的赋值是不是给了正确的数据类型等

    栈映射帧(StackMapTable)**就是在这个阶段,用于检测在特定的字节码处,其局部变量表和操作数栈是否有着正确的数据类型。但遗憾的是,100%准确地判断一段字节码是否可以被安全执行是无法实现的,因此,该过程只是尽可能地检查出可以预知的明显的问题。如果在这个阶段无法通过检查,虛拟机也不会正确装载这个类。但是,如果通过了这个阶段的检查,也不能说明这个类是完全没有问题的。在前面3次检查中,已经排除子文件格式错误语义错误以及字节码的不正确性但是依然不能确保类是没有问题的**。

    image-20210522144342032

  4. 校验器还将进行符号引用的验证。Class文件在其常量池会通过字符串记录自己将要使用的其他类或者方法。因此,在验证阶段,虚拟机就会检查这些类或者方法确实是存在的,并且当前类有权限访问这些数据,如果一个需要使用类无法在系统中找到,则会抛出NoClassDefFoundError,如果一个方法无法被找到,则会抛出NoSuchMethodError此阶段在解析环节才会执行

2、环节2:链接阶段之Preparation(准备)

准备阶段(Preparation),简言之,**为类的静态变量分配内存,并将其初始化为默认值**。

当一个类验证通过时,虚拟机就会进入准备阶段。在这个阶段,虚拟机就会为这个类分配相应的内存空间,并设置默认初始值

Java虛拟机为各类型变量默认的初始值如表所示:

类型 默认初始值
byte (byte)0
short (short)0
int 0
long 0L
float 0.0f
double 0.0
char \u0000
boolean false
reference null

注意:Java并不支持boolean类型,对于boolean类型,内部实现是int,由于int的默认值是0,故对应的,boolean的默认值就是false

注意:

  1. **这里不包含基本数据类型的字段用static final修饰(常量)的情况, 因为final在编译的时候就会分配了,准备阶段会显式赋值**。
  2. 注意这里不会为实例变量分配初始化,类变量会分配在方法区中,而实例变量是会随着对象一起分配到Java堆中,会在使用类时候才会初始化。
  3. 准备阶段并不会像初始化阶段中那样会有初始化或者代码被执行

对注意中的第1点与第3点分析:

  • 基本数据类型:
    • 非final修饰的变量,在准备环节进行默认初始化赋值
    • **final修饰以后就是常量了,不能在进行赋值,所以在编译阶段初始化赋值,然后在准备阶段就会显示赋值**。
  • 如果使用字面量的方式定义一个字符串的常量的话(public static final String constStr = “CONST”;),也是在编译阶段初始化赋值,然后在准备阶段就会显示赋值
  • 引用数据类型的静态常量,尤其是new String(“XXX”)这种形式,如:public static final String constStr1 = new String(“CONST”);都是在初始化中的中进行显示赋值的(即在方法当中进行初始化的显示赋值,是在初始化阶段使用代码的方式才会进行的显示赋值,然而在准备阶段不会有代码的执行)
  • 如果在static静态代码块中具有显示赋值操作(定义的后面没有赋值),那肯定就是在初始化中的方法<clinit>中显示赋值
3、环节3:链接阶段之Resolution(解析)

在准备阶段完成后,就进入了解析阶段。

解析阶段(Resolution),简言之:将类、接口、字段和方法的符号引用转为直接引用

具体描述

符号引用就是一些字面量的引用,和虚拟机的内部数据结构和和内存布局无关。比较容易理解的就是在Class类文件中,通过常量池进行了大量的符号引用。但是在程序实际运行时,只有符号引用是不够的,比如当如下println()方法被调用时,系统需要明确知道该方法的位置。

举例:输出操作System.out.println()对应的字节码:invokevirtual #24 <java/io/PrintStream. println>

中篇_第3章:输出语句的符号引用

以方法为例,Java虚拟机为每个类都准备了一张方法表,将其所有的方法都列在表中,当需要调用一个类的方法的时候,只要知道这个方法在方法表中的偏移量就可以直接调用该方法。通过解析操作,符号引用就可以转变为目标方法在类中方法表中的位置,从而使得方法被成功调用。

小结

所谓解析就是将符号引用转为直接引用,也就是得到类、字段、方法在内存中的指针或者偏移量。因此,可以说,如果直接引用存在,那么可以肯定系统中存在该类、方法或者字段。但只存在符号引用,不能确定系统中一定存在该结构

不过Java虚拟机规范并没有明确要求解析阶段一定要按照顺序执行。在HotSpotVM中,加载、验证、准备和初始化会按照顺序有条不紊地执行,但链接阶段中的解析操作往往会伴随着JVM在执行完初始化之后再执行

字符串的复习

最后,再来看一下CONSTANT_ String的解析。 由于字符串在程序开发中有着重要的作用,因此,读者有必要了解一下。

String在Java虚拟机中的处理。当在Java代码中直接使用字符串常量时,就会在类中出现CONSTANT_String,它表示字符串常量,并且会引用一个CONSTANT_UTF8的常量项。在Java虚拟机内部运行中的常量池中,会维护一张字符串拘留表(intern),它会保存所有出现过的字符串常量,并且没有重复项。只要以CONSTANT_String形式出现的字符串也都会在这张表中。使用String.intern()方法可以得到一个字符串在拘留表中的引用,因为该表中没有重复项,所以任何字面相同的字符串的String.intern()方法返回总是相等的

4、过程三:Initialization(初始化)阶段

初始化阶段,简言之:为类的静态变量赋于正确的初始值(显示赋值)

具体描述:

类的初始化是类装载的最后一个阶段。如果前面的步骤都没有问题,那么表示类可以顺利装载到系统中。此时,类才会开始执行Java字节码。(即: 到了初始化阶段,才真正开始执行类中定义的Java 程序代码。)

初始化阶段的重要工作是执行类的初始化方法:()方法

  • 该方法仅能由Java编译器生成并由JVM调用, 程序开发者无法自定义一个同名的方法,更无法直接在Java程序中调用该方法,虽然该方法也是由字节码指令所组成。
  • 它是由**类静态成员的赋值语句以及static语句块合并产生的**。

说明

  1. 在加载一个类之前,虚拟机总是会试图加载该类的父类,因此父类的总是在子类之前被调用。也就是说,父类的static块优先级高于子类。口诀:由父及子,静态先行

  2. Java编译器并不会为所有的类都产生()初始化方法。哪些类在编译为字节码后,字节码文件中将不会包含()方法:

    • 一个类中并没有声明任何的类变量,也没有静态代码块时

      1
      2
      //场景1:对应非静态的字段,不管是否进行了显式赋值,都不会生成<clinit>()方法
      public int num = 1;
    • 一个类中声明类变量,但是没有明确使用类变量的初始化语句以及静态代码块来执行初始化操作

      1
      2
      //场景2:静态的字段,没有显式的赋值,不会生成<clinit>()方法
      public static int num1;
    • 一个类中包含static final修饰的基本数据类型的字段,这些类字段初始化语句采用编译时常量表达式

      1
      2
      //场景3:比如对于声明为static final的基本数据类型的字段,不管是否进行了显式赋值,都不会生成<clinit>()方法
      public static final int num2 = 1;
1、static与final的搭配问题(显示初始化问题)

问题研究:使用static + final修饰的字段的显式赋值的操作,到底是在哪个阶段进行的赋值?

  • 情况1:在链接阶段的准备环节赋值
  • 情况2:在初始化阶段()中赋值

实例代码:

1
2
3
4
5
6
7
8
9
10
public class InitializationTest2 {
public static int a = 1;//在初始化阶段<clinit>()中赋值
public static final int INT_CONSTANT = 10;//在链接阶段的准备环节赋值

public static final Integer INTEGER_CONSTANT1 = Integer.valueOf(100);//在初始化阶段<clinit>()中赋值
public static Integer INTEGER_CONSTANT2 = Integer.valueOf(1000);//在初始化阶段<clinit>()中赋值

public static final String s0 = "helloworld0";//在链接阶段的准备环节赋值
public static final String s1 = new String("helloworld1");//在初始化阶段<clinit>()中赋值
}

初步结论:

  • 在链接阶段的准备环节赋值的情况:
    • 对于基本数据类型的字段来说,如果使用static final修饰,则显式赋值(直接赋值常量,而非调用方法进行动态赋值)通常是在链接阶段的准备环节进行
    • 对于String来说,如果使用字面量的方式赋值,使用static final修饰的话,则显式赋值通常是在链接阶段的准备环节进行
  • 在初始化阶段()中赋值的情况:
    • 排除上述的在准备环节赋值的情况之外的情况。

实例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class InitializationTest2 {
public static int a = 1;//在初始化阶段<clinit>()中赋值
public static final int INT_CONSTANT = 10;//在链接阶段的准备环节赋值

public static final Integer INTEGER_CONSTANT1 = Integer.valueOf(100);//在初始化阶段<clinit>()中赋值
public static Integer INTEGER_CONSTANT2 = Integer.valueOf(1000);//在初始化阶段<clinit>()中赋值

public static final String s0 = "helloworld0";//在链接阶段的准备环节赋值
public static final String s1 = new String("helloworld1");//在初始化阶段<clinit>()中赋值

public static String s2 = "helloworld2";

public static final int NUM1 = new Random().nextInt(10);//在初始化阶段<clinit>()中赋值
}

最终结论:

  • 在链接阶段的准备环节赋值的情况:
    • 使用static+final修饰,并且进行显示赋值(定义的时候后面就已经附了确定的初始值),还不涉及到方法或者构造器调用的基本数据类型或者String类型字面量(“XXX”这种形式,而不是new String(“XXX”)这种形式)的字段,将在准备中的链接阶段进行显示赋值
    • 对于准备阶段就完成赋值的,**其字段下面的有属性ConstantValue**,在初始化阶段()中赋值的字段是没有属性ConstantValue的。
  • 在初始化阶段()中赋值的情况:
    • 已经进行显示赋值的静态常量static+final修饰)(包括引用类型,尤其是new String(“XXX”)这种类型的,还有调用其他方法获得的值,比如new Random().nextInt(10)等)或者静态变量(这是肯定在初始化方法中显示赋值)都将在初始化中的方法中进行显示赋值
  • 使用static + final修饰,且显示赋值中不涉及到方法构造器调用的基本数据类型String类型的显式赋值,是在链接阶段的准备环节进行。

补充:

  1. 换个角度思考下,只有在常量池中已经确定的值,才会在链接中的准备阶段赋值,像对象在常量池存储的一般都是符号引用,而并非是对象,仅仅是描述对象一个字符串,真正的对象还需通过字节码进行new,这一new不就得用类构造方法,不就得需要在初始化阶段()中赋值了吗
  2. 计算中1/0,即public static final int INT_CONSTANT = 1/0,也不能在链接阶段的准备环节赋值,因为它会要抛异常,需要使用到代码
  3. 这里说的能够用常量池中数据表示是按照结果论,所以2/2这种的结果是一个int值可以表示
  4. 而new String(“”)是个特例,String的引用是可以在常量池中表示的,但是new String是在初始化阶段赋值
  5. 也不能单纯看是不是字面量,如果是static final Integer a = 1,也是在初始化阶段()中赋值,只能说能尽量能在准备阶段赋值的就在准备阶段,实在不行才在初始化阶段()中赋值
  6. 另外一个角度:在链接阶段的准备环节赋值是不能动用代码的,因为真正开始执行类中定义的Java 程序代码是在到了初始化阶段才开始的。因此:可以在不使用java代码就能进行显示赋值的就在链接阶段的准备环节进行赋值,而赋值需要java代码参与的就只能在初始化阶段()中进行显示赋值
2、()的线程安全性
  • 对于()方法的调用,也就是类的初始化,虚拟机会在内部确保其多线程环境中的安全性。

  • 虚拟机会保证一个类的()方法在多线程环境中被正确地加锁、同步。如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的()方法,其他线程都需要阻塞等待,直到活动线程执行()方法完毕。

  • 正是因为函数( )带锁线程安全的,因此,如果在一个类的()方法中有耗时很长的操作,就可能造成多个线程阻塞,引发死锁。并且这种死锁是很难发现的,因为看起来它们并没有可用的锁信息

    • 函数( )带的锁是隐式的锁,并不是使用sychronized进行加锁的。

      image-20210522204628093

  • 如果之前的线程成功加载了类,则等在队列中的线程就没有机会再执行()方法了。那么,当需要使用这个类时虚拟机会直接返回给它已经准备好的信息。(一个类只需要加载一次

死锁的相关代码:(使用两个进程让A、B交叉加载

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
class StaticA {
static {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
}
try {
Class.forName("com.atguigu.java1.StaticB");
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
System.out.println("StaticA init OK");
}
}
class StaticB {
static {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
}
try {
Class.forName("com.atguigu.java1.StaticA");
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
System.out.println("StaticB init OK");
}
}
public class StaticDeadLockMain extends Thread {
private char flag;

public StaticDeadLockMain(char flag) {
this.flag = flag;
this.setName("Thread" + flag);
}
@Override
public void run() {
try {
Class.forName("com.atguigu.java1.Static" + flag);
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
System.out.println(getName() + " over");
}
public static void main(String[] args) throws InterruptedException {
StaticDeadLockMain loadA = new StaticDeadLockMain('A');
loadA.start();
StaticDeadLockMain loadB = new StaticDeadLockMain('B');
loadB.start();
}
}

由此得出结论:

  • 编写代码的时候要尽量避免让类进行交叉加载或循环加载/依赖
3、类的初始化情况:主动使用vs被动使用

Java程序对类的使用分为两种:

  • 主动使用(调用了()方法)
  • 被动使用(没有调用了()方法)

注意:没有调用了()方法只是没有进入初始化阶段,并不代表该类没有加载

1、主动使用

Class只有在必须要首次使用的时候才会被装载,Java虚拟机不会无条件地装载Class类型。Java虚拟机规定,一个类或接口在初次使用前,必须要进行初始化。这里指的“使用”,是指主动使用,主动使用只有下列几种情况:(即: 如果出现如下的情况,则会对类进行初始化操作。而初始化操作之前的加载、验证、准备已经完成。)

  1. 当创建一个类的实例时,比如使用new关键字,或者通过反射、克隆、反序列化。

    • 举例:

      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
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      49
      50
      51
      52
      public class ActiveUse1 {
      public static void main(String[] args) {
      // 使用new关键字
      Order order = new Order();
      }
      //序列化的过程:
      @Test
      public void test1() {
      ObjectOutputStream oos = null;
      try {
      oos = new ObjectOutputStream(new FileOutputStream("order.dat"));

      oos.writeObject(new Order());
      } catch (IOException e) {
      e.printStackTrace();
      } finally {
      try {
      if (oos != null)
      oos.close();
      } catch (IOException e) {
      e.printStackTrace();
      }
      }

      }
      //反序列化的过程:(验证)
      @Test
      public void test2() {
      ObjectInputStream ois = null;
      try {
      ois = new ObjectInputStream(new FileInputStream("order.dat"));

      Order order = (Order) ois.readObject();
      } catch (IOException e) {
      e.printStackTrace();
      } catch (ClassNotFoundException e) {
      e.printStackTrace();
      } finally {
      try {
      if (ois != null)
      ois.close();
      } catch (IOException e) {
      e.printStackTrace();
      }
      }
      }
      }
      class Order implements Serializable{
      static {
      System.out.println("Order类的初始化过程");
      }
      }
  2. 当调用类的静态方法时,即当使用了字节码invokestatic指令。

    • 举例:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      @Test
      public void test3(){
      Order.method();
      }
      class Order implements Serializable{
      static {
      System.out.println("Order类的初始化过程");
      }
      public static void method(){
      System.out.println("Order method()....");
      }
      }
  3. 当使用类、接口的静态字段时(final修饰特殊考虑),比如,使用getstatic或者putstatic指令。( 对应访问变量武值变量操作)

    • 举例:

      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
      32
      33
      34
      35
      36
      public class ActiveUse2 {
      @Test
      public void test1(){
      // System.out.println(User.num); // User类的初始化过程
      // System.out.println(User.num1); // 1
      System.out.println(User.num2); // User类的初始化过程 8
      }
      @Test
      public void test2(){
      // System.out.println(CompareA.NUM1); // 1
      System.out.println(CompareA.NUM2); // CompareA的初始化 5
      }
      }

      class User{
      static{
      System.out.println("User类的初始化过程");
      }
      public static int num = 1; // 在初始化阶段<clinit>()中赋值
      public static final int num1 = 1; // 在链接阶段的准备环节赋值,不需要调用<clinit>()
      public static final int num2 = new Random().nextInt(10); // 在初始化阶段<clinit>()中赋值

      }

      interface CompareA{
      // 通过一个静态内部方法展示有没有JVM调用<clinit>()方法
      public static final Thread t = new Thread(){
      {
      System.out.println("CompareA的初始化");
      }
      };

      public static final int NUM1 = 1; // 在链接阶段的准备环节赋值,不需要调用<clinit>()
      public static final int NUM2 = new Random().nextInt(10); // 在初始化阶段<clinit>()中赋值

      }
  4. 当使用java.lang.reflect包中的方法反射类的方法时。比如:Class.forName(“com.atguigu.java.Test”);

    • 举例:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      @Test
      public void test1() {
      try {
      Class clazz = Class.forName("com.atguigu.java1.Order");
      } catch (ClassNotFoundException e) {
      e.printStackTrace();
      }
      }
      class Order implements Serializable{
      static {
      System.out.println("Order类的初始化过程");
      }
      }
  5. 当初始化子类时,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。

    • 举例:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      @Test
      public void test2() {
      // Father类的初始化过程
      // Son类的初始化过程
      // 1
      System.out.println(Son.num);
      }
      class Father {
      static {
      System.out.println("Father类的初始化过程");
      }
      }
      class Son extends Father implements CompareB{
      static {
      System.out.println("Son类的初始化过程");
      }
      public static int num = 1;
      }
    • 其实在加载Father类之前,JVM还会加载Father的父类java.lang.Object。但是这里不好展示。可以通过JVM参数-XX:+TraceClassLoading可以追踪类的加载信息并打印出来。在当中可以看到JVM有先加载java.lang.Object。

    • 关于采用Junit进行测试时JVM参数的设置:

      img

  6. 如果一个接口定义了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
      26
      27
      28
      29
      @Test
      public void test4() {
      // Father类的初始化过程
      // CompareB的初始化
      // Son类的初始化过程
      // 1
      System.out.println(Son.num);
      }
      class Father {
      static {
      System.out.println("Father类的初始化过程");
      }
      }
      class Son extends Father implements CompareB{
      static {
      System.out.println("Son类的初始化过程");
      }
      public static int num = 1;
      }
      interface CompareB {
      public static final Thread t = new Thread() {
      {
      System.out.println("CompareB的初始化");
      }
      public default void method1(){
      System.out.println("你好!");
      }
      };
      }
    • 如果Son还被其他类继承的话,当Son的子类初始化的时候,对应的Son,CompareB,Father也会被初始化。

  7. 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。

    • 举例:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      public class ActiveUse3 {
      static{
      System.out.println("ActiveUse3的初始化过程");
      }
      public static void main(String[] args) {
      // ActiveUse3的初始化过程
      // hello
      System.out.println("hello");
      }
      }
  8. 当初次调用MethodHandle实例时,初始化该MethodHandle指向的方法所在的类。( 涉及解析REF_getStatic、REF_putStatic、REF_invokeStatic方法句柄对应的类)

针对5,补充说明:

当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
      @Test
      public void test2() {
      // Father类的初始化过程
      // Son类的初始化过程
      // 1
      System.out.println(Son.num);
      }
      class Father {
      static {
      System.out.println("Father类的初始化过程");
      }
      }
      class Son extends Father implements CompareB{
      static {
      System.out.println("Son类的初始化过程");
      }
      public static int num = 1;
      }
      interface CompareB {
      public static final Thread t = new Thread() {
      {
      System.out.println("CompareB的初始化");
      }
      };
      }
    • 虽然接口CompareB没有初始化,但是它已经被加载进JVM当中了

  • 在初始化一个接口时,并不会先初始化它的父接口

    • 举例:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      @Test
      public void test3(){
      // CompareC的初始化
      // 3
      System.out.println(CompareC.NUM1);
      }
      interface CompareB {
      public static final Thread t = new Thread() {
      {
      System.out.println("CompareB的初始化");
      }
      };
      }
      interface CompareC extends CompareB {
      public static final Thread t = new Thread() {
      {
      System.out.println("CompareC的初始化");
      }
      };
      public static final int NUM1 = new Random().nextInt();
      }

因此,一个父接口并不会因为它的子接口或者实现类的初始化而初始化。只有当程序首次使用特定接口的静态字段时,才会导致该接口的初始化。

针对7,说明:

JVM启动的时候通过引导类加载器加载一个初始类。这个类在调用public static void main(String[])方法之前被链接和初始化。这个方法的执行将依次导致所需的类的加载,链接和初始化。

2、被动使用

除了以上的情况属于主动使用,其他的情况均属于被动使用。被动使用不会引起类的初始化

也就是说:并不是在代码中出现的类,就一定会被加载或者初始化。如果不符合主动使用的条件,类就不会初始化

  1. 当访问一个静态字段时,只有真正声明这个字段的类才会被初始化。

    • 当通过子类引用父类的静态变量,不会导致子类初始化

    • 举例:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      @Test
      public void test1(){
      System.out.println(Child.num);
      }
      class Parent{
      static{
      System.out.println("Parent的初始化过程");
      }

      public static int num = 1;
      }
      class Child extends Parent{
      static{
      System.out.println("Child的初始化过程");
      }
      }
  2. 通过数组定义类引用,不会触发此类的初始化

    • 举例:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      @Test
      public void test2(){
      // [空]
      Parent[] parents = new Parent[10];
      // class [Lcom.atguigu.java1.Parent;
      System.out.println(parents.getClass());
      // class java.lang.Object
      System.out.println(parents.getClass().getSuperclass());
      // Parent的初始化过程
      parents[0] = new Parent();
      parents[1] = new Parent();
      }
      class Parent{
      static{
      System.out.println("Parent的初始化过程");
      }
      }
    • 只是定义不赋值的话,不会触发类初始化。但是只要赋上一次值就会执行类的初始化,之后就不会执行类的初始化了。(()这样一个类构造器方法只会初始化一次)

  3. 引用常量不会触发此类或接口的初始化。因为常量在链接阶段就已经被显式赋值了。

    • 举例:

      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
      @Test
      public void test1(){
      // System.out.println(Person.NUM); // 1
      // SerialA的初始化
      // 3
      System.out.println(Person.NUM1);
      }
      @Test
      public void test2(){
      // System.out.println(SerialA.ID); // 1
      // Person类的初始化
      // 3
      System.out.println(SerialA.ID1);
      }
      class Person{
      static{
      System.out.println("Person类的初始化");
      }
      public static final int NUM = 1;//在链接过程的准备环节就被赋值为1了。
      public static final int NUM1 = new Random().nextInt(10);//此时的赋值操作需要在<clinit>()中执行
      }
      interface SerialA{
      public static final Thread t = new Thread() {
      {
      System.out.println("SerialA的初始化");
      }
      };
      int ID = 1;
      int ID1 = new Random().nextInt(10);//此时的赋值操作需要在<clinit>()中执行
      }
  4. 调用ClassLoader类的loadClass()方法加载一个类,并不是对类的主动使用,不会导致类的初始化。

    • 举例:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      @Test
      public void test3(){
      try {
      Class clazz = ClassLoader.getSystemClassLoader().loadClass("com.atguigu.java1.Person");
      } catch (ClassNotFoundException e) {
      e.printStackTrace();
      }
      }
      class Person{
      static{
      System.out.println("Person类的初始化");
      }
      public static final int NUM = 1;//在链接过程的准备环节就被赋值为1了。
      public static final int NUM1 = new Random().nextInt(10);//此时的赋值操作需要在<clinit>()中执行
      }
3、注意
  • ClassLoader.getSystemClassLoader().loadClass()方法与Class.forName()方法
    • Class.forName()方法:类自动使用
    • ClassLoader.getSystemClassLoader().loadClass()方法:类被动使用

5、过程四:类的Using(使用)

任何一个类型在使用之前都必须经历过完整的加载、链接和初始化3个类加载步骤。一旦一个类型成功经历过这3个步骤之后,就应经加载成功了。便“万事俱备,只欠东风”,就等着开发者使用了。

开发人员可以在程序中访问和调用它的静态类成员信息(比如:静态字段、静态方法),或者使用new关键字为其创建对象实例。

6、过程五:类的Unloading(卸载)

1、类、类的加载器、类的实例之间的引用关系

在类加载器的内部实现中,用一个Java集合来存放所加载类的引用。另一方面,一个Class对象总是会引用它的类加载器,调用Class对象的getClassLoader()方法, 就能获得它的类加载器。由此可见,代表某个类的Class实例与其类的加载器之间为双向关联关系

一个类的实例总是引用代表这个类的Class对象。在Object类中定义了getClass()方法, 这个方法返回代表对象所属类的Class对象的引用。

此外,所有的Java类都有一个静态属性class,它引用代表这个类的Class对象

2、类的生命周期

当Sample类被加载、链接和初始化后,它的生命周期就开始了。当代表Sample类的Class对象不再被引用,即不可触及时,Class对象就会结束生命周期,Sample类在方法区内的数据也会被卸载,从而结束Sample类的生命周期。

一个类何时结束生命周期,取决于代表它的Class对象何时结束生命周期

3、具体例子

image-20210523011825913

loader1变量和obj变量间接引用代表Sample类的Class对象,而objClass变量则直接引用它。

  • 关于方法区的垃圾回收(回顾):方法区的垃圾收集主要回收两部分内容:**常量池中废弃的常量不再使用的类型**。(可对应上面的图)
    • HotSpot虚拟机对常量池的回收策略是很明确的,只要常量池中的常量没有被任何地方引用,就可以被回收
    • 判定一个常量是否“废弃”还是相对简单,而要判定一个类型是否属于 “不再被使用的类”的条件就比较苛刻了。需要同时满足下面三个条件:
      • 该类所有的实例都已经被回收。也就是Java堆中不存在该类及其任何派生子类的实例。
      • 加载该类的类加载器已经被回收。这个条件除非是经过精心设计的可替换类加载器的场景,如OSGiJSP的重加载等,否则通常是很难达成的。
      • 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法
    • Java虚拟机**被允许(不是必然)**对满足上述三个条件的无用类进行回收,这里说的仅仅是“被允许”,而并不是和对象一样,没有引用了就必然会回收。

如果程序运行过程中,将上图左侧三个引用变量都置为null,此时Sample对象结束生命周期,MyClassLoader对象结束生命周期,代表Sample类的Class对象也结束生命周期,Sample类在方法区内的二进制数据被卸载。

当再次有需要时,会检查Sample类的Class对象是否存在,如果存在会直接使用,不再重新加载如果不存在Sample类会被重新加载,在Java 虚拟机的堆区会生成一个新的代表Sample类的Class实例(可以通过哈希码查看是否是同一个实例)。

4、类的卸载
  1. 启动类加载器加载的类型在整个运行期间是不可能被卸载的(jvm和jls规范)
  2. 被系统类加载器和扩展类加载器加载的类型在运行期间不太可能被卸载,因为系统类加载器实例或者扩展类的实例基本上在整个运行期间总能直接或者间接的访问的到,其达到unreachable的可能性极小。
  3. 被开发者自定义的类加载器实例加载的类型只有在很简单的上下文环境中才能被卸载,而且一般还要借助于强制调用虚拟机的垃圾收集功能才可以做到。可以预想,稍微复杂点的应用场景中(比如:很多时候用户在开发自定义类加载器实例的时候采用缓存的策略以提高系统性能),被加载的类型在运行期间也是几乎不太可能被卸载的(至少卸载的时间是不确定的)。

综合以上三点,一个已经加载的类型被卸载的几率很小至少被卸载的时间是不确定的。同时我们可以看的出来,开发者在开发代码时候,不应该对虚拟机的类型卸载做任何假设的前提下,来实现系统中的特定功能

7、相关大厂面试题

  • 蚂蚁金服:
    • 描述一下JVM加载Class文件的原理机制?
    • 一面:类加载过程
  • 百度:
    • 类加载的时机
    • java类加载过程?
    • 简述java类加载机制?
  • 腾讯:
    • JVM中类加载机制,类加载过程?
  • 滴滴:
    • JVM类加载机制
  • 美团:
    • Java类加载过程
    • 描述一下jvm加载class文件的原理机制
  • 京东:
    • 什么是类的加载?
    • 哪些情况会触发类的加载?
    • 讲一下JVM加载一个类的过程
    • JVM的类加载机制是什么?

4、再谈类的加载器

1、概述

类加载器是JVM执行类加载机制的前提

ClassLoader的作用:

ClassLoader是Java的核心组件,所有的Class都是由ClassLoader进行加载的,ClassLoader 负责通过各种方式将Class信息的二进制数据流读入JVM内部,转换为一个与目标类对应的java.lang.Class对象实例。然后交给Java虚拟机进行链接、初始化等操作。因此,ClassLoader在整个装载阶段,只能影响到类的加载,而无法通过ClassLoader去改变类的链接和初始化行为。至于它是否可以运行,则由Execution Engine(执行引擎)决定

中篇_第4章:类的加载器

类加载器最早出现在Java1.0版本中,那个时候只是单纯地为了满足Java Applet应用而被研发出来。但如今类加载器却在OSGi、字节码加解密领域大放异彩。这主要归功于Java虚拟机的设计者们当初在设计类加载器的时候,并没有考虑将它绑定在JVM内部,这样做的好处就是能够更加灵活和动态地执行类加载操作

1、类加载器的分类

类的加载分类:**显式加载** vs 隐式加载

class文件的显式加载与隐式加载的方式是指JVM加载class文件到内存的方式

  • 显式加载:指的是在代码中通过调用ClassLoader加载class对象,如直接使用Class.forName(name)或this.getClass().getClassLoader().loadClass(name)加载class对象。
  • 隐式加载:则是不直接在代码中调用ClassLoader的方法加载class对象,而是通过虚拟机自动加载到内存中,如在加载某个类的class文件时,该类的class 文件中引用了另外一个类的对象,此时额外引用的类将通过JVM自动加载到内存中(例如User user = new User())。(常用)

日常开发以上两种方式一般会混合使用

2、类加载器的必要性

一般情况下,Java开发人员并不需要在程序中显式地使用类加载器,但是了解类加载器的加载机制却显得至关重要。从以下几个方面说:

  • 避免在开发中遇到java.lang.ClassNotFoundException异常或java.lang.NoClassDefFoundError异常时手足无措。只有了解类加载器的加载机制才能够在出现异常的时候快速地根据错误异常日志定位问题和解决问题。
  • 需要支持类的动态加载需要对编译后的字节码文件进行加解密操作时,就需要与类加载器打交道了。
  • 开发人员可以在程序中编写自定义类加载器来重新定义类的加载规则,以便实现一些自定义的处理逻辑。(自己定义的类加载器可以不遵从沙箱安全模型,因为沙箱安全模型有它的缺点)
3、命名空间
  1. 何为类的唯一性:
    对于任意一个类,都需要由加载它的类加载器和这个类本身一同确认其在Java虚拟机中的唯一性。每一个类加载器,都拥有一个独立的类名称空间:比较两个类是否相等,只有在这两个类是由同一个类加载器加载的前提下才有意义。否则,即使这两个类源自同一个Class文件,被同一个虚拟机加载,只要加载他们的类加载器不同,那这两个类就必定不相等。
  2. 命名空间:
    • 每个类加载器都有自己的命名空间,命名空间由该加载器及所有的父加载器所加载的类组成
    • 在同一命名空间中,不会出现类的完整名字(包括类的包名)相同的两个类
    • 在不同的命名空间中,有可能会出现类的完整名字(包括类的包名)相同的两个类

在大型应用中,我们往往借助这一特性,来运行同一个类的不同版本。

代码解释:

img

结果:

img

解释:

rootDir后面的地址是我们使用javac User.class指令生成的class文件地址,然后loader1和loader2是两个用户自定义类加载器(如果自定义的不必理解),之后使用这两个用户自定义类加载器加载同一类型的User类,获得的Class对象不是同一个,可以通过Class对象调用getClassLoader()方法获取对应的类加载器了,最后通过系统类加载器获取的Class对象也是独特的,也可以通过该Class对象获取系统类加载器

4、类加载机制的基本特征

通常类加载机制有三个基本特征:

  • 双亲委派模型
    • 但不是所有类加载都遵守这个模型,有的时候,启动类加载器所加载的类型,是可能要加载用户代码的,比如JDK内部的ServiceProvider/ServiceLoader机制,用户可以在标准API框架上,提供自己的实现,JDK也需要提供些默认的参考实现。
    • 例如,Java 中JNDI、JDBC、文件系统、Cipher 等很多方面,都是利用的这种机制,这种情况就不会用双亲委派模型去加载,而是利用所谓的上下文加载器。
    • 安全 避免重复加载 保护程序 防止核心api被串改
  • 可见性
    • 子类加载器可以访问父加载器加载的类型,但是反过来是不允许的。不然,因为缺少必要的隔离,我们就没有办法利用类加载器去实现容器的逻辑。
  • 单一性
    • 由于父加载器的类型对于子加载器是可见的,所以父加载器中加载过的类型,就不会在子加载器中重复加载。但是注意,类加载器“邻居”间,同一类型仍然可以被加载多次,因为互相并不可见

2、复习:类的加载器分类

JVM支持两种类型的类加载器,分别为引导类加载器(Bootstrap ClassLoader) 和自定义类加载器(User-Defined ClassLoader) 。

从概念上来讲,自定义类加载器一般指的是程序中由开发人员自定义的一类类加载器,但是Java虚拟机规范却没有这么定义,而是将所有派生于抽象类ClassLoader的类加载器都划分为自定义类加载器。无论类加载器的类型如何划分,在程序中我们最常见的类加载器结构主要是如下情况:

image-20210523150158667

  • 除了顶层的启动类加载器外,其余的类加载器都应当有自己的“父类”加载器。
  • 不同类加载器看似是继承(Inheritance)关系,实际上是包含关系。在下层加载器中,包含着上层加载器的引用

父类加载器和子类加载器的关系:

image-20210523150646271

正是由于子类加载器中包含着父类加载器的引用,所以可以通过子类加载器的方法获取对应的父类加载器

注意:

启动类加载器通过C/C++语言编写,而自定义类加载器都是由Java语言编写的,虽然扩展类加载器和应用程序类加载器是被jdk开发人员使用java语言来编写的,但是也是由java语言编写的,所以也被称为自定义类加载器。

1、启动类加载器(引导类加载器,Bootstrap ClassLoader)
  • 这个类加载使用C/C++语言实现的,嵌套在JVM内部
  • 它用来加载Java的核心库(JAVA_HOME/jre/lib/rt.jarsun.boot.class.path路径下的内容)。用于提供JVM自身需要的类。
  • 并不继承自java.lang.ClassLoader,没有父加载器
  • 出于安全考虑,Bootstrap启动类加载器只加载包名为javajavaxsun 等开头的类
  • 加载扩展类和应用程序类加载器,并指定为他们的父类加载器。(聚合)

image-20210523152412581

image-20210523152438385

使用-XX: +TraceClassloading参数得到。

启动类加载器使用C++编写的? Yes!

  • C/C++E 指针函数&函数指针、C++支持多继承、更加高效
  • Java: 由C++演变而来, (C++)–版,单继承

引导类加载器需要加载的jar包文件:

代码:

img

结果:

img

2、扩展类加载器(Extension ClassLoader)
  • Java语言编写,由sun.misc.Launcher$ExtClassLoader实现。
  • 继承于ClassLoader类
  • 父类加载器为启动类加载器。(聚合)
  • java.ext.dirs系统属性所指定的目录中加载类库,或从JDK的安装目录的jre/lib/ext子目录下加载类库。如果用户创建的JAR放在此目录下,也会自动由扩展类加载器加载。

image-20210523152834151

image-20210523155350716

注意:扩展类加载器与系统类加载器是属于同一级的,都是继承与URLClassLoader,只是扩展类加载器当中有系统类加载器的引用,所以才称系统类加载器是扩展类加载器的”父类”加载器。(两者并不是继承关系,而是一种聚合关系

无法通过扩展类加载器获得引导类加载器,因为引导类加载器是用C/C++语言编写的,所以获取的值是null

扩展类加载器:

代码:

img

结果:

img

3、应用程序类加载器(系统类加载器,AppClassLoader)
  • java语言编写,由sun.misc.Launcher$AppClassLoader实现
  • 继承于ClassLoader类
  • 父类加载器为扩展类加载器。(聚合)
  • 它负责加载环境变量classpath或系统属性java.class.path指定路径下的类库
  • 应用程序中的类加载器默认是系统类加载器
  • 它是用户自定义类加载器的默认父加载器
  • 通过ClassLoader的getSystemClassLoader( )方法可以获取到该类加载器

image-20210523155305052

4、用户自定义类加载器
  • 在Java的日常应用程序开发中,类的加载几乎是由上述3种类加载器相互配合执行的。在必要时,我们还可以自定义类加载器,来定制类的加载方式。
  • 体现Java语言强大生命力和巨大魅力的关键因素之一便是:Java开发者可以自定义类加载器来实现类库的动态加载,加载源可以是本地的JAR包,也可以是网络上的远程资源
  • 通过类加载器可以实现非常绝妙的插件机制,这方面的实际应用案例举不胜举。例如,著名的OSGI组件框架,再如Eclipse的插件机制。类加载器为应用程序提供了一种动态增加新功能的机制,这种机制无须重新打包发布应用程序就能实现。
  • 同时,自定义加载器能够实现应用隔离,例如Tomcat,Spring等中间件和组件框架都在内部实现了自定义的加载器,并通过自定义加载器隔离不同的组件模块。这种机制比C/C++程序要好太多,想不修改C/C++程序就能为其新增功能,几乎是不可能的,仅仅一个兼容性便能阻挡住所有美好的设想。
  • 自定义类加载器通常需要继承于ClassLoader.

3、测试不同的类的加载器

每个Class对象都会包含一个定义它的ClassLoader的一个引用

获取ClassLoader的途径

1
2
3
4
5
6
7
8
// 获得当前类的ClassLoader
clazz. getClassLoader();

// 获得当前线程上下文的ClassLoader
Thread.currentThread().getContextClassLoader();

// 获得系统的ClassLoader
ClassLoader.getSystemClassLoader();

说明:
站在程序的角度看,引导类加载器与另外两种类加载器(系统类加载器和扩展类加载器)并不是同一个层次意义上的加载器,引导类加载器是使用C++语言编写而成的,而另外两种类加载器则是使用Java语言编写而成的。由于引导类加载器压根儿就不是一个Java类,因此在Java程序中只能打印出空值

数组类的Class对象,不是由类加载器去创建的,而是在Java运行期JVM根据需要自动创建的。对于数组类的类加载器来说,是通过Class.getClassLoader( )返回的,与数组当中元素类型的类加载器是一样的;如果数组当中的元素类型是基本数据类型,数组类是没有类加载器的

代码:

1
2
3
4
5
6
7
8
9
//关于数组类型的加载:使用的类的加载器与数组元素的类的加载器相同
String[] arrStr = new String[10];
System.out.println(arrStr.getClass().getClassLoader());//null:表示使用的是引导类加载器

ClassLoaderTest1[] arr1 = new ClassLoaderTest1[10];
System.out.println(arr1.getClass().getClassLoader());//sun.misc.Launcher$AppClassLoader@18b4aac2

int[] arr2 = new int[10];
System.out.println(arr2.getClass().getClassLoader());//null:不需要类的加载器

**获取当前线程上下文的ClassLoader的结果就是系统类加载器**,这个可以在Launcher.java中被代码证明:

1
2
3
4
this.loader = Launcher.AppClassLoader.getAppClassLoader(var1);
Thread.currentThread().setContextClassLoader(this.loader);
this.loader = Launcher.AppClassLoader.getAppClassLoader(var1);
Thread.currentThread().setContextClassLoader(this.loader);

代码:

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
32
33
34
public class ClassLoaderTest1 {
public static void main(String[] args) {
//获取系统该类加载器
ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader();
System.out.println(systemClassLoader);//sun.misc.Launcher$AppClassLoader@18b4aac2
//获取扩展类加载器
ClassLoader extClassLoader = systemClassLoader.getParent();
System.out.println(extClassLoader);//sun.misc.Launcher$ExtClassLoader@1540e19d
//试图获取引导类加载器:失败
ClassLoader bootstrapClassLoader = extClassLoader.getParent();
System.out.println(bootstrapClassLoader);//null
//###########################
try {
ClassLoader classLoader = Class.forName("java.lang.String").getClassLoader();
System.out.println(classLoader);
//自定义的类默认使用系统类加载器
ClassLoader classLoader1 = Class.forName("com.atguigu.java.ClassLoaderTest1").getClassLoader();
System.out.println(classLoader1);
//关于数组类型的加载:使用的类的加载器与数组元素的类的加载器相同
String[] arrStr = new String[10];
System.out.println(arrStr.getClass().getClassLoader());//null:表示使用的是引导类加载器

ClassLoaderTest1[] arr1 = new ClassLoaderTest1[10];
System.out.println(arr1.getClass().getClassLoader());//sun.misc.Launcher$AppClassLoader@18b4aac2
// 关于数组当中的元素类型是基本数据类型
int[] arr2 = new int[10];
System.out.println(arr2.getClass().getClassLoader());//null:不需要类的加载器

System.out.println(Thread.currentThread().getContextClassLoader());
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
}
}

结果:

img

4、ClassLoader源码解析

ClassLoader与现有类加载器的关系:

image-20210523193421376

除了以上虚拟机自带的加载器外,用户还可以定制自己的类加载器。Java提供了抽象类java.lang.ClassLoader, 所有用户自定义的类加载器都应该继承ClassLoader类。

1、类加载器之间的关系

Launcher.class:

img

ExtClassLoader和AppClassLoader是Launcher类的两个内部类:

img

img

分析:

  1. 验证扩展类加载器的父类是null
    • 先看:var1 = Launcher.ExtClassLoader.getExtClassLoader();
    • 获取到扩展类加载器,点击该方法往里面追溯,在最后找到:return new Launcher.ExtClassLoader(var0);
    • 我们在点击该方法往里面追溯,在找到:super(getExtURLs(var1), (ClassLoader)null, Launcher.factory);
    • 然后点击super,往里面追溯,在找到:public URLClassLoader(URL[] urls, ClassLoader parent,URLStreamHandlerFactory factory){super(parent)}
    • 点击其中的parent(也就是null值),我们点击super,往里面追溯,在找到:protected SecureClassLoader(ClassLoader parent) { super(parent);}
    • 点击其中的parent就是null,我们点击super,往里面追溯,在找到:protected ClassLoader(ClassLoader parent) { this(checkCreateClassLoader(), parent);}
    • 点击其中的parent就是null,我们点击this,往里面追溯,在找到:private ClassLoader(Void unused, ClassLoader parent) { this.parent = parent;}
    • 点击其中的parent就是null,可以看到是:private final ClassLoader parent;
      • 就是ClassLoader.java定义的属性:父类加载器
    • 由于parent就是null,所以扩展类加载器的父类是null,也就是引导类加载器,因此我们调用获取扩展类加载器父类的方法获得的结果是null
  2. 验证系统类加载器的父类是扩展类加载器
    • 先看:this.loader = Launcher.AppClassLoader.getAppClassLoader(var1);
    • 获取到系统类加载器,点击该方法往里面追溯,在最后找到:return new Launcher.AppClassLoader(var1x, var0);
    • 其中var0就是扩展类加载器,点击AppClassLoader,往里面追溯,在找到:AppClassLoader(URL[] var1, ClassLoader var2) { super(var1, var2, Launcher.factory); this.ucp.initLookupCache(this);}
    • 其中var2就是扩展类加载器,我们点击super,往里面追溯,在找到:public URLClassLoader(URL[] urls, ClassLoader parent,URLStreamHandlerFactory factory) {super(parent);}
    • 里面的parent就是扩展类加载器,我们点击super,往里面追溯,在找到:protected SecureClassLoader(ClassLoader parent) { super(parent);}
    • 里面的parent就是扩展类加载器,我们点击super,往里面追溯,在找到:protected ClassLoader(ClassLoader parent) { this(checkCreateClassLoader(), parent);}
    • 里面的parent就是扩展类加载器,我们点击this,往里面追溯,在找到:private ClassLoader(Void unused, ClassLoader parent) { this.parent = parent;}
    • 点击其中的parent就是扩展类加载器,可以看到是:private final ClassLoader parent;
      • 就是ClassLoader.java定义的属性:父类加载器
    • 由于parent就是扩展类加载器,所以系统类加载器的父类是扩展类加载器,因此我们调用获取系统类加载器父类的方法获得的结果是扩展类加载器
  3. 当前线程上下文的ClassLoader就是系统类加载器
    • Thread.currentThread().setContextClassLoader(this.loader):就是将系统类加载器设置为当前线程的上下文加载器,所以Thread.currentThread().getContextClassLoader()获取到的就是系统类加载器

注意:

  • Launcher源码里定义了static的扩展类加载器ExtClassLoader, static的系统类加载器AppClassLoader。
  • 它们都是默认包级别的,它们都是继承URLClassLoader,这就意味着我们的代码里,不能定义ExtClassLoader laoder = …或AppClassLoader loader = …。我们只能ClassLoader loader = …,而在实际运行时,我们应当能辨别这个loader到底是哪个具体类型。
  • 在ExtClassLoader构造器里,并没有指定parent,或者说ExtClassLoader的parent为null。因为ExtClassLoader的parent是BootstrapLoader,而BootstrapLoader不存在于Java Api里,只存在于JVM里,我们是看不到的,所以请正确理解”ExtClassLoader的parent为null”的含义。
  • 在AppClassLoader构造器里,有了parent。实例化AppClassLoader的时候,传入的parent就是一个ExtClassLoader实例
  • 看看Launcher的构造方法:
    • **先实例化ExtClassLoader,从java.ext.dirs系统变量里获得URL[]**。
    • **用这个ExtClassLoader作为parent去实例化AppClassLoader,从java.class.path系统变量里获得URL[]**。Launcher getClassLoader()就是返回的这个AppClassLoader。
    • 设置AppClassLoader为ContextClassLoader
2、ClassLoader的主要方法

抽象类ClassLoader的主要方法:(内部没有抽象方法)

  • public final ClassLoader getParent():

    • 返回该类加载器的超类加载器
  • public Class<?> loadClass(String name) throws ClassNotFoundException:

    • 加载名称为name的类,返回结果为java.lang.Class类的实例。如果找不到类,则返回ClassNotFoundException异常。该方法中的逻辑就是双亲委派模式的实现
  • protected Class<?> findClass(String name) throws ClassNotFoundException:

    • 查找二进制名称为name的类,返回结果为java.lang.Class类的实例。这是一个受保护的方法, JVM鼓励我们重写此方法,需要自定义加载器遵循双亲委托机制,该方法会在检查完父类加载器之后被loadClass()方法调用。
      • 在JDK1.2之前,在自定义类加载时,总会去继承ClassLoader类并重写loadClass方法,从而实现自定义的类加载类。
      • 但是在JDK1. 2之后已不再建议用户去覆盖loadClass()方法,而是建议把自定义的类加载逻辑写在findClass()方法中,从前面的分析可知,findClass()方法是在loadClass()方法中被调用的,当loadClass()方法中父加载器加载失败后,则会调用自的findClass()方法来完成类加载,这样就可以保证自定义的类加载器也符合双亲委托模式
      • 需要注意的是ClassLoader类中并没有实现findClass()方法的具体代码逻辑,取而代之的是抛ClassNotFoundException异常,同时应该知道的是findClass 方法通常是和defineClass方法起使用的。一般情况下,在自定义类加载器时,会直接覆盖ClassLoader 的findClass()方法并编写加载规则,取得要加载类的字节码后转换成流,然后调用defineClass()方法生成类的Class对象
  • protected final Class<?> defineClass(String name, byte[] b, int off, int len):

    • 根据给定的字节数组b转换为Class的实例,off和len参数表示实际Class信息在byte数组中的位置和长度,其中byte数组b是ClassLoader从外部获取的。这是受保护的方法,只有在自定义ClassLoader子类中可以使用。

      • defineClass()方法是用来将byte字节流解析成JVM能够识别的Class对象(ClassLoader中已实现该方法逻辑),通过这个方法不仅能够通过class文件实例化class对象, 也可以通过其他方式实例化class对象, 如通过网络接收一个类的字节码,然后转换为byte 字节流创建对应的Class对象。

      • defineClass()方法通常与findClass()方法起使用,一般情况下,在自定义类加载器时,会直接覆盖ClassLoader的findClass()方法并编写加载规则, 取得要加载类的字节码后转换成流, 然后调用defineClass()方法生成类的Class对象。

      • 简单举例:

        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        protected Class<?> findClass(String name) throws ClassNotFoundException {
        //获取类的字节数组
        byte[] classData = getClassData(name);
        if (classData == null) {
        throw new ClassNotFoundException();
        } else {
        //使用defineClass生成class对象
        return defineClass(name, classData, 0, classData. length);
        }
        }
  • protected final void resolveClass(Class<?> c):

    • 链接指定的一个Java类。使用该方法可以使用类的Class对象创建完成的同时也被解析。前面我们说链接阶段主要是对字节码进行验证,为类变量分配内存并设置初始值同时将字节码文件中的符号引用转换为直接引用。
  • protected final Class<?> findLoadedClass(String name):

    • 查找名称为name的已经被加载过的类,返回结果为java.lang.Class类的实例。这个方法是final方法,无法被修改
  • private final ClassLoader parent;

    • 它也是一个ClassLoader的实例,这个字段所表示的ClassLoader也称为这个ClassLoader的双亲在类加载的过程中, ClassLoader可能会将某些请求交予自己的双亲处理
关于loadClass()方法的剖析:

loadClass()方法是ClassLoader.java类中的主要方法

测试代码:ClassLoader.getSystemClassLoader().loadClass(“com.atguig.java.User”);

涉及到对如下方法的调用:(模板方法模式的实现,抽象类提供基本的方法框架,子类需要重写具体的方法)

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
32
33
34
35
36
37
38
protected Class<?> loadClass(String name, boolean resolve) //resolve: true-加载class的同时进行解析操作。默认为false
throws ClassNotFoundException{
synchronized (getClassLoadingLock(name)) { //同步操作,保证只能加载一次。
// First, check if the class has already been Loaded
//首先,在缓在中判断是否已经加载同名的类。
Class<?> c = findLoadedClass(name);
if (c == nu1l) {
long t0 = System.nanoTime();
try {
// 获取当前类加载器的父类加载器
if (parent != nu1l) {
// 如果存在父类加载器,则调用父类加载器进行类的加载(递归)(双亲委派机制)
c = parent.loadClass(name, false);
} else { // parent为null:父类加载器是引导类加载器
c = findBootstrapClassOrNull(name);
} catch (ClassNotFoundException e) {
// ClassNotFoundException thrown if class not found
// from the non-null parent cLass Loader
}
}
if (c == nu11) { // 当前类的加载器的父类加载器未加载此类or当前类的加载器未加载此类
// If still not found, then invoke findClass in order
// to find the class.
// 调用当前ClassLoader的findCLass()
long t1 = System.nanoTime();
C =findClass(name);
// this is the defining class Loader; record the stats
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindCLassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) {//是否进行解析操作
resolveClass(c);
}
return c;
}
}

分析:

  1. findLoadedClass(name),查找类是否已经被加载过,如果加载过直接返回该Class类型的对象。如果没有被加载则继续第三的操作!

  2. c = findBootstrapClassOrNull(name);c = parent.loadClass(name, false);如果父加载器不为空,那么调用父加载器的loadClass方法加载类,如果父加载器为空,那么调用虚拟机的加载器来加载类。(此过程当中是通过递归的方法改变c的类型为父类加载器的类型,体现了双亲委派机制)

    如果以上两个步骤都没有成功的加载到类,进入第三;

  3. c = findClass(name);使用自定义的findClass(name)方法来加载类。(递归回最初的那一层)

    这个时候,我们已经得到了加载之后的类,那么就根据resolve的值决定是否调用resolveClass方法。进入第五!

  4. resolveClass(c); 链接指定的类。这个方法给Classloader用来链接一个类,如果这个类已经被链接过了,那么这个方法只做一个简单的返回。否则,这个类将被按照 Java™规范中的Execution描述进行链接……

  5. 其中使用到了设计模式的模板方法模式

    • 模板方法模式用于定义构建某个对象的步骤与顺序,或者定义一个算法的骨架
    • 模板方法模式的使用的方式,给子类足够的自由度,提供一些方法供子类覆盖,去实现一些骨架中不是必须但却可以有自定义实现的步骤。模板方法模式是一种基础继承的代码复用技术。如ClassLoader中的findClass方法!
  6. 总结一下:在ClassLoader中定义的算法顺序是

    1. 首先看是否有已经加载好的类
    2. 如果父类加载器不为空,则首先从父类类加载器加载
    3. 如果父类加载器为空,则尝试从启动加载器加载
    4. 如果两者都失败,才尝试从findClass方法加载
3、SecureClassLoader与URLClassLoader
  • SecureClassLoader:
    • 接着SecureClassLoader扩展了ClassLoader, 新增了几个与使用相关的代码源(对代码源的位置及其证书的验证)和权限定义类验证(主要指对class源码的访问权限)的方法,一般我们不会直接跟这个类打交道,更多是与它的子类URLClassLoader有所关联。
  • URLClassLoader:
    • 前面说过,ClassLoader是一个抽象类, 很多方法是空的没有实现,比如findClass()、findResource()等(模板方法模式)。而URLClassLoader这个实现类为这些方法提供了具体的实现。并新增了URLClassPath类协助取得Class字节码流等功能,在编写自定义类加载器时,如果没有太过于复杂的需求,可以直接继承URLClassLoader类, 这样就可以避免自己去编写findClass()方法及其获取字节码流的方式,使自定义类加载器编写更加简洁

image-20210523221840016

4、ExtClassLoader与AppClassLoader

了解完URLClassLoader后接着看看剩余的两个类加载器,即拓展类加载器ExtClassLoader系统类加载器AppClassLoader, 这两个类都继承自URLClassLoader, 是sun.misc.Launcher的静态内部类

sun.misc.Launcher主要被系统用于启动主应用程序,ExtClassLoader 和AppClassLoader都是由sun.misc.Launcher创建的,其类主要类结构如下:

img

我们发现ExtClassLoader并没有重写loadClass()方法,这足矣说明其遵循双亲委派模式,而AppClassLoader重载了loadClass()方法,但最终调用的还是父类loadClass()方法,因此依然遵守双亲委派模式

5、Class.forName()与ClassLoader.loadClass()
  • Class.forName():是一个静态方法,最常用的是Class.forName(String className);根据传入的类的全限定名返回一个Class对象。该方法在将Class文件加载到内存的同时,会执行类的初始化。如:Class.forName(“com. atguigu. java.HelloWorld”);
  • ClassLoader.loadClass():这是一个实例方法,需要一个ClassLoader 对象来调用该方法。该方法将Class 文件加载到内存时,并不会执行类的初始化,直到这个类第一次使用时才进行初始化(loadClass()方法当中的resolve: true-加载class的同时进行解析操作。默认为false)。该方法因为需要得到一个ClassLoader 对象,所以可以根据需要指定使用哪个类加载器。如:ClassLoader c1=…….;c1. loadClass(“com. atguigu. java . HelloWorld”);

5、双亲委派模型

1、定义与本质

类加载器用来把类加载到Java虚拟机中。从JDK1. 2版本开始,类的加载过程采用双亲委派机制,这种机制能更好地保证Java平台的安全

  1. 定义:

    如果一个类加载器在接到加载类的请求时,它首先不会自己尝试去加载这个类,而是把这个请求任务委托给父类加载器去完成,依次递归,如果父类加载器可以完成类加载任务,就成功返回。只有父类加载器无法完成此加载任务时,才自己去加载

  2. 本质:

    规定了类加载的顺序是:引导类加载器先加载,若加载不到,由扩展类加载器加载,若还加载不到,才会由系统类加载器或自定义的类加载器进行加载

    image-20210421103744316

image-20210524135243252

2、优势与劣势
1、双亲委派机制优势
  • 避免类的重复加载,确保一个类的全局唯一性
    • Java类随着它的类加载器一起具备了一种带有优先级的层次关系,通过这种层级关可以避免类的重复加载,当父亲已经加载了该类时,就没有必要于ClassLoader再加载一次。
  • 保护程序安全,防止核心API被随意篡改
2、代码支持

双亲委派机制在java.lang.ClassLoader.loadClass(String, boolean)接口中体现。该接口的逻辑如下:

  1. 先在当前加载器的缓存中查找有无目标类,如果有,直接返回。
  2. 判断当前加载器的父加载器是否为空,如果不为空,则调用parent.loadClass(name, false)接口进行加载。
  3. 反之,如果当前加载器的父类加载器为空,则调用findBootstrapClassOrNull(name)接口, 让引导类加载器进行加载
  4. 如果通过以上3条路径都没能成功加载,则调用findClass(name)接口进行加载。 该接口最终会调用java.lang.ClassLoader接口的defineClass系列的native接口加载目标Java类。

双亲委派的模型就隐藏在这第2和第3步的递归调用当中

3、举例:

假设当前加载的是java.lang.Object这个类,很显然,该类属于JDK中核心得不能再核心的一个类,因此一定只能由引导类加载器进行加载。当JVM准备加载java.lang.Object时,JVM默认会使用系统类加载器去加载,按照上面4步加载的逻辑,在第1步从系统类的缓存中肯定查找不到该类,于是进入第2步。由于从系统类加载器的父加载器是扩展类加载器,于是扩展类加载器继续从第1步开始重复。由于扩展类加载器的缓存中也一定查找不到该类,因此进入第2步。扩展类的父加载器是null,因此系统调用findClass(String),最终通过引导类加载器进行加载。

4、思考:

如果在自定义的类加载器中重写java.lang.ClassLoader.loadClass(String)或java.lang.ClassLoader.loadClass(String, boolean)方法, 抹去其中的双亲委派机制,仅保留上面这4步中的第1步与第4步,那么是不是就能够加载核心类库了呢?

这也不行!因为JDK还为核心类库提供了一层保护机制。不管是自定义的类加载器,还是系统类加载器抑或扩展类加载器,最终都必须调用java.lang.ClassLoader .defineClass(String, byte[], int, int, ProtectionDomain)方法,而该方法会执行preDefineClass()接口,该接口中提供了对JDK核心类库的保护

5、双亲委派模型的弊端:

检查类是否加载的委托过程是单向的**,这个方式虽然从结构上说比较清晰,使各个ClassLoader的职责非常明确,但是同时会带来一个问题,即顶层的ClassLoader无法访问底层的ClassLoader所加载的类**。

通常情况下,启动类加载器中的类为系统核心类,包括一些重要的系统接口,而在应用类加载器中,为应用类。按照这种模式,应用类访问系统类自然是没有问题,但是系统类访问应用类就会出现问题。比如在系统类中提供了一个接口,该接口需要在应用类中得以实现,该接口还绑定一个工厂方法,用于创建该接口的实例,而接口和工厂方法都在启动类加载器中。这时,就会出现该工厂方法无法创建由应用类加载器加载的应用实例的问题。

由双亲委派模型的优势可以看出:Java类随着它的类加载器一起具备了一种带有优先级的层次关系——双亲委派很好地解决了各个类加载器的基础类的统一问题(越基础的类由越上层的加载器进行加载),基础类之所以称为“基础”,是因为它们总是作为被用户代码调用的API。但世事往往没有绝对的完美,如果基础类又要调用回用户的代码,由于BootstrapClassloader是顶级类加载器,BootstrapClassloader无法委派AppClassLoader来加载类,也就是说BootstrapClassloader中加载的类中无法使用由AppClassLoader加载的类。

举例:(java.sql与驱动接口:com.mysql.jdbc.Driver)

接口:java.sql.Driver,定义在java.sql包中,包所在的位置是:jdk\jre\lib\rt.jar中,java.sql包中还提供了其它相应的类和接口比如管理驱动的类:DriverManager类,很明显java.sql包是由BootstrapClassloader加载器加载的;而接口的实现类com.mysql.jdbc.Driver是由第三方实现的类库,由AppClassLoader加载器进行加载的,我们的问题是DriverManager再获取链接的时候必然要加载到com.mysql.jdbc.Driver类,这就是由BootstrapClassloader加载的类使用了由AppClassLoader加载的类,很明显和双亲委托机制的原理相悖。

6、结论:

由于Java虛拟机规范并没有明确要求类加载器的加载机制一定要使用双亲委派模型,只是建议采用这种方式而已。比如在Tomcat中,类加载器所采用的加载机制就和传统的双亲委派模型有一定区别,当缺省的类加载器接收到一个类的加载任务时,首先会由它自行加载,当它加载失败时,才会将类的加载任务委派给它的超类加载器去执行,这同时也是Servlet规范推荐的一种做法

3、破坏双亲委派机制

双亲委派模型并不是一个具有强制性约束的模型,而是Java设计者推荐给开发者们的类加载器实现方式。

在Java的世界中大部分的类加载器都遵循这个模型,但也有例外的情况,直到Java模块化出现为止,双亲委派模型主要出现过3次较大规模“被破坏”的情况。

1、破坏双亲委派机制1——兼容JDK1.2之前的版本

双亲委派模型的第一次“被破坏”其实发生在双亲委派模型出现之前——即JDK 1.2面世以前的 “远古” 时代。

由于双亲委派模型在JDK 1.2之后才被引入,但是类加载器的概念和抽象类java.lang.ClassLoader则在Java的第一个版本中就已经存在,面对已经存在的用户自定义类加载器的代码,Java设计者们引入双亲委派模型时不得不做出一些妥协,为了兼容这些已有代码,无法再以技术手段避免loadClass()被子类覆盖的可能性,只能在JDK1.2之后的java.lang.ClassLoader中添加一个新的protected方法findClass(),并引导用户编写的类加载逻辑时尽可能去重写这个方法,而不是在loadClass()中编写代码。

上节我们已经分析过loadClass()方法,双亲委派的具体逻辑就实现在这里面,按照loadClass()方法的逻辑, 如果父类加载失败,会自动调用自己的findClass()方法来完成加载,这样既不影响用户按照自己的意愿去加载类,又可以保证新写出来的类加载器是符合双亲委派规则的。

以上简单来说就是jdk1.2之前还没引入双亲委派机制,所以jdk1.2之前就是破坏双亲委派机制的情况。

2、破坏双亲委派机制2——线程上下文类加载器(解决双亲委派机制的弊端:上层类加载器加载的类不能使用由下层类加载器加载的类)

双亲委派模型的第二次“被破坏”是由这个模型自身的缺陷导致的,双亲委派很好地解决了各个类加载器协作时基础类型的一致性问题(越基础的类由越上层的加载器进行加载),基础类型之所以被称为“基础”,是因为它们总是作为被用户代码继承、调用的API存在,但程序设计往往没有绝对不变的完美规则,如果有基础类型又要调用回用户的代码,那该怎么办呢?

这并非是不可能出现的事情,一个典型的例子便是JNDI服务,JNDI现在已经是Java的标准服务,它的代码由启动类加载器来完成加载(在JDK 1.3时加入到rt.jar的),肯定属于Java中很基础的类型了。但JNDI 存在的目的就是对资源进行查找和集中管理,它需要调用由其他厂商实现并部署在应用程序的ClassPath下的JNDI服务提供者接口(Service Provider Interface, SPI) 的代码,现在问题来了,启动类加载器是绝不可能认识、加载这些代码的,那该怎么办?(SPI:在Java平台中, 通常把核心类rt.jar中提供外部服务、可由应用层自行实现的接口称为SPI)

为了解决这个困境,Java的设计团队只好引入了一个不太优雅的设计:线程上下文类加载器(Thread Context ClassLoader)**。这个类加载器可以通过java.lang. Thread类的setContextClassLoader()方法进行设置,如果创建线程时还未设置,它将会从父线程中继承一个,如果在应用程序的全局范围内都没有设置过的话,那这个类加载器默认就是应用程序类加载器**。

有了线程上下文类加载器,程序就可以做一些“舞弊”的事情了。JNDI服务使用这个线程上下文类加载器去加载所需的SPI服务代码,这是一种父类加载器去请求子类加载器完成类加载的行为,这种行为实际上是打通了双亲委派模型的层次结构来逆向使用类加载器,已经违背了双亲委派模型的一般性原则,但也是无可奈何的事情。Java中涉及SPI的加载基本上都采用这种方式来完成,例如JNDI、JDBC、JCE、JAXB和JBl等。不过,当SPI的服务提供者多于一个的时候,代码就只能根据具体提供者的类型来硬编码判断,为了消除这种极不优雅的实现方式,在JDK 6时,JDK提供了java.util.ServiceLoader类,**以META-INF/services中的配置信息,辅以责任链模式**,这才算是给SPI的加载提供了一种相对合理的解决方案。

image-20210524154710225

默认上下文加载器就是应用类加载器,这样以上下文加载器为中介,使得启动类加载器中的代码也可以访问应用类加载器中的类

简单来说就是线程上下文类加载器让启动类加载器和系统类加载器直接联系起来了,中间的扩展类加载器被省略了,所以这破坏了双亲委派机制,其中线程上下文类加载器就是系统类加载器,这个证明在之前的ClassLoader >>> 类加载器之间的关系中 有相关代码的解释。

3、破坏双亲委派机制3——用户对程序动态性的追求:代码热替换(Hot Swap)、模块热部署(Hot Deployment) 等

双亲委派模型的第三次“被破坏”是由于用户对程序动态性的追求而导致的。如:代码热替换(Hot Swap)、模块热部署(Hot Deployment) 等

IBM公司主导的JSR-291 (即OSGi R4.2)实现模块化热部署的关键是它自定义的类加载器机制的实现,每一个程序模块(OSGi中称为Bundle)都有一个自己的类加载器,当需要更换一个Bundle时,就把Bundle连同类加载器一起 换掉以实现代码的热替换。在OSGi环境下,类加载器不再双亲委派模型推荐的树状结构,而是进一步发展为更加复杂的网状结构

当收到类加载请求时,OSGi将按照下面的顺序进行类搜索:

  1. 将以java.*开头的类,委派给父类加载器加载
  2. 否则,将委派列表名单内的类,委派给父类加载器加载
  3. 否则,将Import列表中的类, 委派给Export这个类的Bundle的类加载器加载
  4. 否则,查找当前Bundle的ClassPath, 使用自己的类加载器加载
  5. 否则,查找是否在自己的Fragment Bundle中, 如果在,则委派给Fragment Bundle的类加载器加载
  6. 否则,查找Dynamic Import列表的Bundle, 委派给对应Bundle的类加载器加载
  7. 否则,类查找失败

说明:只有开头两点仍然符合双亲委派模型的原则,其余的类查找都是在平级的类加载器中进行的

小结:

这里,我们使用了“被破坏”这个词来形容上述不符合双亲委派模型原则的行为,但这里“被破坏”并不一定是带有贬义的。只要有明确的目的和充分的理由,突破旧有原则无疑是一种创新

正如:OSGi中的类加载器的设计不符合传统的双亲委派的类加载器架构,且业界对其为了实现热部署而带来的额外的高复杂度还存在不少争议,但对这方面有了解的技术人员基本还是能达成一个共识,认为OSGi中对类加载器的运用是值得学习的,完全弄懂了OSGi的实现,就算是掌握了类加载器的精粹

4、(补充)破坏双亲委派机制4——JDK9引入了Java模块化系统(具体在下文JDK9的新特性详细说明)

JDK9引入了Java模块化系统(Java Platform Module System)来实现可配置的封装隔离机制,同时JVM对类加载的架构也做出了调整,也就是双亲委派模型的第四次破坏。

传统的双亲委派加机制:(图示)这里的敌人就是我们要加载的jar包

img

缺点

通过上面的漫画不言而喻,当真正的敌人来了,靠这种低效的传达机制,怎么可能打一场胜仗呢?

  • 启动类加载器负责加载\jre\lib目录
  • 扩展类加载器负责加载\jre\lib\ext目录
  • 应用类加载器负责加载ClassPath目录。

既然一切都是各司其职,为什么不能加载类的时候一步到位呢?

通过分析JDK9的类加载器源码,我发现最新的类加载器结构在一定程度上是缓解了这种情况的

JDK的模块化

在JDK9之前,JVM的基础类以前都是在rt.jar这个包里,这个包也是JRE运行的基石。这不仅是违反了单一职责原则,同样程序在编译的时候会将很多无用的类也一并打包,造成臃肿

在JDK9中,整个JDK都基于模块化进行构建,以前的rt.jar, tool.jar被拆分成数十个模块,编译的时候只编译实际用到的模块,同时各个类加载器各司其职,只加载自己负责的模块。

img

模块化加载源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Class<?> c = findLoadedClass(cn); 
if (c == null) {
// 找到当前类属于哪个模块
LoadedModule loadedModule = findLoadedModule(cn);
if (loadedModule != null) {
//获取当前模块的类加载器
BuiltinClassLoader loader = loadedModule.loader();
//进行类加载
c = findClassInModuleOrNull(loadedModule, cn);
} else {
// 找不到模块信息才会进行双亲委派
if (parent != null) {
c = parent.loadClassOrNull(cn);
}
}

上面代码就是破坏双亲委派模型的“铁证”,而当我们继续跟进findLoadedModule,会发现是根据路径名找到对应的模块,而维护这一数据结构的就是下面这个Map:

1
Map<String, LoadedModule> packageToModule = new ConcurrentHashMap<>(1024);

可以看到LoadedModule里面不仅有该模块的loader信息,还有用于描述依赖模块,对外暴露模块的信息的mref,LoadedModule也是模块化实现封装隔离机制的一块重要实现

img

每一个module信息都有一个BuiltinClassloader,这个类有三个子类,我们通过源码分析他们的父子关系:

img

在ClassLoaders类中可以发现,PlatformClassLoader的parent是BootClassLoader,而AppClassLoader的parent则是PlatformClassLoader

1
2
3
4
5
6
7
8
9
10
11
12
public class ClassLoaders {
// the built-in class loaders
private static final BootClassLoader BOOT_LOADER;
private static final PlatformClassLoader PLATFORM_LOADER;
private static final AppClassLoader APP_LOADER;
static {
BOOT_LOADER = new BootClassLoader((append != null && !append.isEmpty()) ? new URLClassPath(append, true) : null);
PLATFORM_LOADER = new PlatformClassLoader(BOOT_LOADER);
...;
APP_LOADER = new AppClassLoader(PLATFORM_LOADER, ucp);
}
}

结论

  1. 经过破坏后的双亲委派模型更加高效,减少了很多类加载器之间不必要的委派操作
  2. JDK9的模块化可以减少Java程序打包的体积,同时拥有更好的隔离线与封装性
  3. 每个module拥有专属的类加载器,程序在并发性上也会更加出色
4、热替换的实现

热替换是指在程序的运行过程中,不停止服务,只通过替换程序文件来修改程序的行为。热替换的关键需求在于服务不能中断,修改必须立即表现正在运行的系统之中。基本上大部分脚本语言都是天生支持热替换的,比如:PHP, 只要替换了PHP源文件,这种改动就会立即生效,而无需重启Web服务器。

但对Java来说,热替换并非天生就支持,如果一个类已经加载到系统中,通过修改类文件,并无法让系统再来加载并重定义这个类。因此,在Java中实现这一功能的一个可行的方法就是灵活运用ClassLoader。

注意:由不同ClassLoader加载的同名类属于不同的类型,不能相互转换和兼容。即两个不同的ClassLoader加载同一个类,在虚拟机内部,会认为这两个类是完全不同的。根据这个特点,可以用来模拟热替换的实现,基本思路如下图所示:

中篇_第4章:热替换

每次调用方法之前都要加载字节码文件,然后创建对象,我们可以把字节码文件变成最新的,那么创建的对象肯定是最新的,所以这就完成了热替换

6、沙箱安全机制

沙箱安全机制:

  • 保证程序安全
  • 保护Java原生的JDK代码

**Java安全模型的核心就是Java沙箱(sandbox)**。什么是沙箱?

  • 沙箱是一个限制程序运行的环境

沙箱机制就是将Java代码限定在虚拟机(JVM)特定的运行范围中,并且严格限制代码对本地系统资源访问。通过这样的措施来保证对代码的有限隔离,防止对本地系统造成破坏。

沙箱主要限制系统资源访问,那系统资源包括什么?

  • CPU
  • 内存
  • 文件系统
  • 网络

不同级别的沙箱对这些资源访问的限制也可以不一样。

所有的Java程序运行都可以指定沙箱,可以定制安全策略。

1、JDK1.0时期

在Java中将执行程序分成本地代码远程代码两种:

  • 本地代码默认视为可信任的,对于授信的本地代码,可以访问一切本地资源。
  • 远程代码则被看作是不受信的。 而对于非授信的远程代码在早期的Java实现中, 安全依赖于沙箱(Sandbox)机制。

如下图所示JDK1 .0安全模型:

image-20210524160050558

2、JDK1.1时期

JDK1.0中如此严格的安全机制也给程序的功能扩展带来障碍,比如当用户希望远程代码访问本地系统的文件时候,就无法实现。因此在后续的Java1.1版本中,针对安全机制做了改进,增加了安全策略允许用户指定代码对本地资源的访问权限

如下图所示JDK1.1安全模型:

image-20210524160203739

3、JDK1.2时期

在Java1.2版本中,再次改进了安全机制,增加了代码签名。不论本地代码或是远程代码,都会按照用户的安全策略设定由类加载器加载到虛拟机中权限不同的运行空间,来实现差异化的代码执行权限控制

如下图所示JDK1.2安全模型:

image-20210524160319450

4、JDK1.6时期

当前最新的安全机制实现,则引入了**域(Domain)**的概念。

虚拟机会把所有代码加载到不同的系统域和应用域

  • **系统域部分专门负责与关键资源进行交互**;
  • 各个应用域部分则通过系统域的部分代理来对各种需要的资源进行访问
  • 虚拟机中不同的受保护域(ProtectedDomain),对应不一样的权限(Permission)**。存在于不同域中的类文件就具有了当前域的全部权限**。

如下图所示,最新的安全模型(jdk1.6):

image-20210524160541465

7、自定义类的加载器

1、为什么要自定义类加载器?
  • 隔离加载类
    • 在某些框架内进行中间件与应用的模块隔离,把类加载到不同的环境
    • 比如:阿里内某容器框架通过自定义类加载器确保应用中依赖的jar包不会影响到中间件运行时使用的jar包
    • 再比如:Tomcat这类Web应用服务器,内部自定义了好几种类加载器,用于隔离同一个Web应用服务器上的不同应用程序
  • 修改类加载的方式
    • 类的加载模型并非强制,除Bootstrap外, 其他的加载并非一定要引入,或者根据实际情况在某个时间点进行按需进行动态加载
  • 扩展加载源
    • 比如从数据库、网络、甚至是电视机机顶盒进行加载
  • 防止源码泄漏
    • Java代码容易被编译和篡改,可以进行编译加密。那么类加载也需要自定义,还原加密的字节码
2、常见的场景:
  • 实现类似进程内隔离类加载器实际上用作不同的命名空间,以提供类似容器、模块化的效果。例如,两个模块依赖于某个类库的不同版本,如果分别被不同的容器加载,就可以互不干扰。这个方面的集大成者是Java EEOSGIJPMS等框架。
  • 应用需要从不同的数据源获取类定义信息,例如网络数据源,而不是本地文件系统。或者是需要自己操纵字节码,动态修改或者生成类型
3、注意

在一般情况下,使用不同的类加载器去加载不同的功能模块,会提高应用程序的安全性。但是,如果涉及Java类型转换则加载器反而容易产生不美好的事情。在做Java类型转换时,只有两个类型都是由同一个加载器所加载,才能进行类型转换,否则转换时会发生异常。(两个不同的类加载器加载同一个class文件,得到的两个类,虽然表面上看上去是一样的,但是却是不一样的两个类。当在进行类型转换的时候会抛异常)。

4、自定义类加载器的实现方式

用户通过定制自己的类加载器,这样可以重新定义类的加载规则,以便实现一些自定义的处理逻辑。

1、实现方式:
  • Java提供了抽象类java.lang.ClassLoader,所有用户自定义的类加载器都应该继承ClassLoader类
  • 在自定义ClassLoader的子类时候,我们常见的会有两种做法:
    1. 方式一:重写loadClass()方法(JDK1.2以前)
    2. 方式二:重写findClass()方法 –>推荐(JDK1.2以后)
2、对比

这两种方法本质上差不多,毕竟loadClass()也会调用findClass(), 但是从逻辑上讲我们最好不要直接修改loadClass()的内部逻辑。建议的做法是只在findClass()里重写自定义类的加载方法,根据参数指定类的名字,返回对应的Class对象的引用。

  • loadClass()这个方法是实现双亲委派模型逻辑的地方,擅自修改这个方法会导致模型被破坏,容易造成问题。因此我们最好是在双亲委派模型框架内进行小范围的改动,不破坏原有的稳定结构。同时,也避免了自己重写loadClass()方法的过程中必须写双亲委托的重复代码,从代码的复用性来看,不直接修改这个方法始终是比较好的选择。
  • 当编写好自定义类加载器后,便可以在程序中调用loadClass()方法来实现类加载操作
3、说明
  • 其父类加载器是系统类加载器
  • JVM中的所有类加载都会使用java.lang.ClassLoader.loadClass(String)接口(自定义类加载器并重写java.lang.ClassLoader.loadClass(String)接口的除外),连JDK的核心类库也不能例外。
  • 如果你不想重写findClass()当中流相关的代码,同时也没什么需要改动的地方。可以尝试不去继承ClassLoader抽象类,而去继承抽象类ClassLoader的子类URLClassLoader,里面帮我们重写了findClass()方法。
4、实现代码

自定义类加载器:

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
public class MyClassLoader extends ClassLoader {
// class文件存放的目录
private String byteCodePath;
// 构造方法
public MyClassLoader(String byteCodePath) {
this.byteCodePath = byteCodePath;
}
public MyClassLoader(ClassLoader parent, String byteCodePath) {
super(parent);
this.byteCodePath = byteCodePath;
}
// 自定义类加载器的重点:重写findClass()方法
@Override
protected Class<?> findClass(String className) throws ClassNotFoundException {
BufferedInputStream bis = null;
ByteArrayOutputStream baos = null;
try {
//获取字节码文件的完整路径
String fileName = byteCodePath + className + ".class";
//获取一个输入流
bis = new BufferedInputStream(new FileInputStream(fileName));
//获取一个输出流
baos = new ByteArrayOutputStream();
//具体读入数据并写出的过程
int len;
byte[] data = new byte[1024];
while ((len = bis.read(data)) != -1) {
baos.write(data, 0, len);
}
//获取内存中的完整的字节数组的数据
byte[] byteCodes = baos.toByteArray();
//调用defineClass(),将字节数组的数据转换为Class的实例。
Class clazz = defineClass(null, byteCodes, 0, byteCodes.length);
return clazz;
} catch (IOException e) {
e.printStackTrace();
} finally { // 关闭流的操作
try {
if (baos != null)
baos.close();
} catch (IOException e) {
e.printStackTrace();
}
try {
if (bis != null)
bis.close();
} catch (IOException e) {
e.printStackTrace();
}
}
return null;
}
}

测试自定义类加载器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class MyClassLoaderTest {
public static void main(String[] args) {
// 将class文件放在了D盘下
MyClassLoader loader = new MyClassLoader("d:/");
try {
// 需要加载的class文件名:Demo1
Class clazz = loader.loadClass("Demo1");
// 加载此类的类的加载器为: com.atguigu.java2.MyClassLoader
System.out.println("加载此类的类的加载器为:" + clazz.getClassLoader().getClass().getName());
// 加载当前Demo1类的类的加载器的父类加载器为: sun.misc.Launcher$AppClassLoader(系统类加载器)
System.out.println("加载当前Demo1类的类的加载器的父类加载器为:" + clazz.getClassLoader().getParent().getClass().getName());
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
}
}

注意:

  • 需要将要加载的字节码文件放在一个文件下,或者重新javac编译一下源文件
  • 不然的话JVM依旧会使用系统加载器去加载你的class文件(因为你的class文件在系统加载器的加载的目录下)

8、Java9新特性

为了保证兼容性,JDK 9没有从根本上改变三层类加载器架构和双亲委派模型,但为了模块化系统的顺利运行,仍然发生了一些值得被注意的变动。

  1. 扩展机制被移除扩展类加载器由于向后兼容性的原因被保留,不过被重命名为平台类加载器(platform class loader)。可以通过ClassLoader的新方法getPlatformClassLoader()来获取。(JDK9之前只能获取到系统类加载器,再通过系统类获取扩展类加载器,现在是直接可以获取到平台类加载器)

    • JDK 9时基于模块化进行构建(原来的rt.jar和tools.jar被拆分成数十个JMOD文件),其中的Java类库就已天然地满足了可扩展的需求,那自然无须再保留\lib\ext目录,此前使用这个目录或者java.ext.dirs 系统变量来扩展JDK功能的机制已经没有继续存在的价值了。
  2. 平台类加载器和应用程序类加载器都不再继承自java.net.URLClassLoader

    • 现在**启动类加载器、平台类加载器、应用程序类加载器全都继承于jdk.internal.loader.BuiltinClassLoader**。

      image-20210524172221156

    • 如果有程序直接依赖了这种继承关系,或者依赖了URLClassLoader 类的特定方法,那代码很可能会在JDK 9及更高版本的JDK中崩溃

  3. 在Java 9中,类加载器有了名称。该名称在构造方法中指定,可以通过getName()方法来获取

    • 平台类加载器的名称是platform
    • 应用类加载器的名称是app

    类加载器的名称在调试与类加载器相关的问题时会非常有用

  4. 启动类加载器现在是在jvm内部和java类库共同协作实现的类加载器(以前是C++实现),但为了与之前代码兼容在获取启动类加载器的场景中仍然会返回null,而不会得到BootClassLoader实例

  5. 类加载的委派关系也发生了变动:

    • 当平台及应用程序类加载器收到类加载请求,在委派给父加载器加载前,要先判断该类是否能够归属到某一个系统模块中,如果可以找到这样的归属关系,就要优先委派给负责那个模块的加载器完成加载
  6. 双亲委派模式示意图:

    img

  7. 相关代码:(环境:JDK9)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    public class ClassLoaderTest {
    public static void main(String[] args) {
    // jdk.internal.loader.ClassLoaders$AppClassLoader@726f3b58
    System.out.println(ClassLoaderTest.class.getClassLoader());
    // jdk.internal.loader.ClassLoaders$PlatformClassLoader@e73f9ac
    System.out.println(ClassLoaderTest.class.getClassLoader().getParent());
    // null
    System.out.println(ClassLoaderTest.class.getClassLoader().getParent().getParent());

    //获取系统类加载器
    System.out.println(ClassLoader.getSystemClassLoader());
    //获取平台类加载器
    System.out.println(ClassLoader.getPlatformClassLoader());
    //获取类的加载器的名称 app
    System.out.println(ClassLoaderTest.class.getClassLoader().getName());
    }
    }
  8. 附加:在Java模块化系统明确规定了三个类加载器负责各自加载的模块:

    • 启动类加载器负责加载的模块:

      image-20210524173906468

    • 平台类加载器负责加载的模块:

      image-20210524174130765

    • 应用程序类加载器负责加载的模块:

      image-20210524174026119

9、大厂面试题

  • 蚂蚁金服:
    • 深入分析ClassLoader,双亲委派机制
    • 类加载器的双亲委派模型是什么?
    • 一面:双亲委派机制及使用原因
  • 百度:
    • 都有哪些类加载器,这些类加载器都加载哪些文件?
    • 手写一个类加载器Demo
    • Class的forName(“java.lang.String”)和Class的getClassLoader()的loadClass(“java.lang.String”)有什么区别?
  • 腾讯:
    • 什么是双亲委派模型?
    • 类加载器有哪些?
  • 小米:
    • 双亲委派模型介绍一下
  • 滴滴:
    • 简单说说你了解的类加载器
    • 一面:讲一下双亲委派模型,以及其优点
  • 字节跳动:
    • 什么是类加载器,类加载器有哪些?
  • 京东:
    • 类加载器的双亲委派模型是什么?
    • 双亲委派机制可以打破吗?为什么

下篇:性能监控与调优篇

1、概述篇

1、背景说明

  1. 生产环境中的问题
    • 生产环境发生了内存溢出该如何处理
    • 生产环境应该给服务器分配多少内存合适?
    • 如何对垃圾回收器的性能进行调优?
    • 生产环境CPU负载飙高该如何处理?
    • 生产环境应该给应用分配多少线程合适?
    • 不加log,如何确定请求是否执行了某一行代码?
    • 不加log,如何实时查看某个方法的入参与返回值?
  2. 为什么要调优
    • 防止出现OOM
    • 解决OOM
    • 减少Full GC出现的频率
  3. 不同阶段的考虑
    • 上线前
    • 项目运行阶段
    • 线上出现OOM

2、调优概述

  1. 监控的依据
    • 运行日志
    • 异常堆栈
    • GC日志
    • 线程快照
    • 堆转储快照
  2. 调优的大方向
    • 合理地编写代码
    • 充分并合理的使用硬件资源
    • 合理地进行JVM调优

3、性能优化的步骤

1、第1步(发现问题):性能监控

一种以非强行或者入侵方式收集或查看应用运营性能数据的活动。
监控通常是指一种在生产、 质量评估或者开发环境下实施的带有预防或主动性的活动。
当应用相关干系人提出性能问题却没有提供足够多的线索时,首先我们需要进行性能监控,随后是性能分析。

主要的问题有:

  • GC频繁
  • cpu load过高
  • OOM
  • 内存泄露
  • 死锁
  • 程序响应时间较长
2、第2步(排查问题):性能分析

一种以侵入方式收集运行性能数据的活动,它会影响应用的吞吐量或响应性。
性能分析是针对性能问题的答复结果,关注的范围通常比性能监控更加集中。
性能分析很少在生产环境下进行,通常是在质量评估、系统测试或者开发环境下进行,是性能监控之后的步骤。

主要的手段:

  • 打印GC日志,通过GCviewer或者http://gceasy.io来分析异常信息
  • 灵活运用命令行工具、jstack、jmap、jinfo等
  • dump出堆文件,使用内存分析工具分析文件
  • 使用阿里Arthas、jconsole、JVisualVM来实时查看JVM状态
  • jstack查看堆栈信息
3、第3步(解决问题):性能调优

一种为改善应用响应性或香吐量而更改参数、源代码、属性配置的活动,性能调优
是在性能监控、性能分析之后的活动。

4、性能评价/测试指标

  1. 停顿时间(或响应时间)

    提交请求和返回该请求的响应之间使用的时间,一般比较关注平均响应时间常用操作的响应时间列表:

    image-20210712014649029

    在垃圾回收环节中:暂停时间:执行垃圾收集时,程序的工作线程被暂停的时间(STW)

    • XX: MaxGCPauseMillis
  2. 吞吐量

    对单位时间内完成的工作量(请求)的量度

    在GC中:运行用户代码的事件占总运行时间的比例(总运行时间:程序的运行时间 + 内存回收的时间)

    吞吐量为1-1/(1+n),其中-XX::GCTimeRatio=n(这个参数只有在G1才能设置)

  3. 并发数

    同一时刻,对服务器有实际交互的请求数

    大概的标准:1000个人同时在线,估计并发数在5% - 15%之间,也就是同时并发量: 50 - 150之间。

  4. 内存占用

    Java堆区所占的内存大小

  • 主要的指标有:响应时间和吞吐量
  • 对于一个web应用关注的是:响应时间、吞吐量和并发数
  • 对于GC的时候在意的数据

响应时间、吞吐量和并发数相互间的关系(以高速公路通行状况为例):

  • 吞吐量:每天通过高速公路收费站的车辆的数据(也可以理解为收费站收取的高速费)
  • 并发数:高速公路上正在行驶的车辆的数目
  • 响应时间:车速

一开始的时候,高速公路上的车辆较少,车速较快,高速公路收费站收取的高速费较少——并发数少,响应时间快,吞吐量低

接着高速公路的车辆越来越多,车速变慢,高速公路收费站收取的高速费提高——并发数变多,响应时间变慢,吞吐量升高

随着高速公路的车辆越来越多,车速越来越慢,高速公路收费站收取的高速费降低——并发数越来越多,响应时间越来越慢,吞吐量降低

当高速公路的车辆发生事故,车速为0,高速公路收费站收取的高速费为0——并发数到顶,响应时间为0,吞吐量为0

2、JVM监控及诊断工具——命令行篇

1、概述

性能诊断是软件工程师在日常工作中需要经常面对和解决的问题,在用户体验至上的今天,解决好应用的性能问题能带来非常大的收益。

Java作为最流行的编程语言之一,其应用性能诊断一直受到业界广泛关注。可能造成Java应用出现性能问题的因素非常多,例如线程控制磁盘读写数据库访问网络I/O垃圾收集等。想要定位这些问题,一款优秀的性能诊断工具必不可少。

  • 体会1:使用数据说明问题,使用知识分析问题,使用工具处理问题
  • 体会2:无监控、不调优!

简单命令行工具:

在我们刚接触java学习的时候,大家肯定最先了解的两个命令就是javac、java,那么除此之外,还有没有其他的命令可以供我们使用呢?

我们进入到安装jdk的bin目录,发现还有一系列辅助工具。这些辅助工具用来获取目标JVM不同方面、不同层次的信息,帮助开发人员很好地解决Java应用程序的一些疑难杂症。这些辅助工具都是一个.exe的可执行文件的方式,若想要找到他的来源:jdk > jdk1.8.0_131 > lib > tool.jar包当中(都是一些.class文件)。相关源码:一般不需要我们去查看源码,除非你有特殊需求,需要自己书写修改源码。

mac系统:

image-20210712021836021

windows系统:

image-20210712021909775

2、jps:查看正在运行的Java进程

1、基本介绍

jps(Java Process Status):显示指定系统内所有的HotSpot虚拟机进程(查看虚拟机进程信息),可用于查询正在运行的虚拟机进程。

说明:

  • 对于本地虛拟机进程来说,进程的本地虚拟机ID与操作系统的进程ID是一致的,是唯一的
  • jps只对于在java HotSpot虚拟机运行的进程。
2、基本语法

它的基本使用语法为:jps [options] [hostid]

我们还可以通过追加参数,来打印额外的信息。可以通过 jps -help 来查看对应的参数信息

  • options参数

    • -q仅仅显示LVMID (local virtual machine id), 即本地虚拟机唯一id。不显示主类的名称等

      image-20210712024008753

    • -l:输出应用程序主类的全类名或如果进程执行的是jar包,则输出jar完整路径

      image-20210712024035710

    • -m:输出虚拟机进程启动时传递给主类main()的参数

      image-20210712024528755

      image-20210712024311552

      image-20210712024430556

    • -v:列出虚拟机进程启动时的JVM参数

      • 比如:- Xms20m - Xmx50m是启动程序指定的jvm参数。

      image-20210712024613610

      image-20210712024715127

    • 说明:以上参数可以综合使用:

      • -q指令单独使用(效果与其他三个相反),-lmv可以一起使用
      • jps -l -m 等价于 jps -lm
      • jps -l -m -v 等价于 jps -lmv
      • 如果-q与其他三个指令综合使用的话:(以-l为例,其他两个类似)
        • jps -q -l 等价于 jps -q(所以没什么必要,直接执行jps -q就行)
        • jps -ql:指令错误
      • 如何将信息输出到同级文件中:
        • 语法:命令 > 文件名称
        • 例如:jps -l > a.txt
    • 补充:

      • 如果某Java进程关闭了默认开启的UsePerfData参数(即使用参数-XX: -UsePerfData),那么jps命令(以及下面介绍的jstat)将无法探知该
        Java进程。

        image-20210712025059507

        image-20210712025219254

  • hostid参数

    • RMI注册表中注册的主机名。如果想要远程监控主机上的java 程序,需要安装jstatd。
    • 对于具有更严格的安全实践的网络场所而言,可能使用一个自定义的策略文件来显示对特定的可信主机或网络的访问,尽管这种技术容易受到IP地址欺诈攻击
    • 如果安全问题无法使用一个定制的策略文件来处理,那么最安全的操作是不运行jstatd服务器,而是在本地使用jstat和jps工具。
3、相关测试

image-20210712022534826

  • 10292:后面为空,查看任务管理器得知10292为操作系统为IDEA分配的进程ID,说明jps中后面为空的进程ID代表的就是IDEA进程(说明IDEA也是由java编写,运行在JVM虚拟机当中)
  • 6328 ScannerTest:自己写的测试程序
  • 14604 Jps:Jps本身的进程ID(说明在使用Jps的时候,Jps本身会创建一个进程)
  • 2732 Launcher:JVM虚拟机的进程ID

3、jstat:查看JVM统计信息

1、基本介绍

官方文档

jstat(JVM Statistics Monitoring Tool):用于监视虚拟机各种运行状态信息的命令行工具。它可以显示本地或者远程虚拟机进程中的类装载、内存、垃圾收集、
JIT编译等运行数据

在没有GUI图形界面,只提供了纯文本控制台环境的服务器上,它将是运行期定位虚拟机性能问题的首选工具。常用于检测垃圾回收问题以及内存泄漏问题

2、基本语法

它的基本使用语法为:jstat -

其中vmid是进程id号,也就是jps之后看到的前面的号码,如下:

img

查看命令相关参数:jstat -hjstat -help

  • option参数

    • 类装载相关的:

      • -class显示ClassLoader的相关信息类的装载卸载数量总空间类装载所消耗的时间

        image-20210712225650046

        9000为进程ID(

    • 垃圾回收相关的:

      • -gc显示与GC相关的堆信息。包括Eden区两个Survivor区老年代永久代等的容量已用空间GC时间合计等信息。

        其中设置了JVM参数:-Xms60m -Xmx60m -XX:SurvivorRatio=8

        image-20210712231844604

        • C:总容量\次数、U:使用的容量、S1\0:Survivor0\1区、E:伊甸园区、O:老年代、M:方法区、CCS:压缩类、YG:young GC、FG:full GC、GC:GC、T:时间
        • 由于设置了-Xms60m -Xmx60m -XX:SurvivorRatio=8,即60M = 40M(Old) + 20M(young) 20M(young) = 16M(伊甸园区) + 2M(S0) + 2M(S1)
        • S0C:Survivor0区的容量:2M =2048
        • S1C:Survivor1区的容量:2M = 2048
        • S0U:Survivor0区使用的容量
        • S1U:Survivor1区使用的容量
        • EC:伊甸园区的容量:16M = 16384
        • EU:伊甸园区使用的容量
        • OC:老年代的容量:40M = 40960
        • OU:老年代使用的容量
        • MC:方法区的容量
        • MU:方法区使用的容量
        • CCSC:压缩类的容量
        • CCSU:压缩类使用的容量
        • YGC:young GC发生的次数
        • YGCT:发生young GC花费的时间
        • FGC:full GC发生的次数
        • FGCT:发生full GC花费的时间
        • GCT:用于GC的时间
      • -gccapacity显示内容与-gc基本相同,但输出主要关注Java堆各个区域使用到的最大、最小空间

      • -gcutil显示内容与-gc基本相同,但输出主要关注已使用空间占总空间的百分比

        image-20210712234001136

      • -gccause-gcutil功能一样,但是会额外输出导致最后一次或当前正在发生的GC产生的原因

        image-20210712234235632

      • -gcnew显示新生代GC状况

      • -gcnewcapacity显示内容与-gcnew基本相同,输出主要关注使用到的最大、最小空间

      • -geold显示老年代GC状况

      • -gcoldcapacity显示内容与-gcold基本相同,输出主要关注使用到的最大、最小空间

      • -gcpermcapacity:显示永久代使用到的最大、最小空间

    • JIT相关的:

      • -compiler显示JIT编译器编译过的方法、耗时等信息

        image-20210712231535247

      • -printcompilation输出已经被JIT编译的方法

        image-20210712231557164

  • interval参数:用于指定输出统计数据的周期,单位为毫秒。即:查询间隔。如果没加的话,默认查询1次,如果后面的count没有加的话,默认一直查询

    image-20210712230907909

  • count参数:用于指定查询的总次数。在加上interval参数的情况下,没加count参数,默认一直查询

    image-20210712230944650

    每1s显示一次ClassLoader的相关信息,显示10次。

  • -t参数:可以在输出信息前加上一个Timestamp列,显示程序的运行时间。单位:秒

    image-20210712231131085

    image-20210712231246385

    • 经验:

      • 我们可以比较Java进程的启动时间以及总GC时间(GCT列),或者两次测量的间隔时间以及总GC时间的增量,来得出GC时间占运行时间的比例。

      • 如果该比例超过20%,则说明目前堆的压力较大;如果该比例超过90%,则说明堆里几乎没有可用空间,随时都可能抛出OOM异常。

      • 我们执行jstat -gc -t 13152 1000 10,这代表1秒打印出1行,一共10行,-t代表打印出Timestamp总运行时间,结果如下所示:

        img

        上方红色框框中代表Timestamp,而蓝色框框中代表垃圾回收时间,单位都是秒,如果让红色框框中的某两个值相减,假设这个值是num1,然后让对应行的蓝色框框中的另外两个值相减,假设这个值是num2,之后让num2/num1,得出的差值就是上述所说的GC时间占运行时间的比例

  • h参数:可以在周期性数据输出时,输出多少行数据后输出一个表头信息

    image-20210712231441369

3、补充

jstat还可以用来判断是否出现内存泄漏。

  1. 第1步:在长时间运行的Java程序中,我们可以运行jstat命令连续获取多行性能数据,并取这几行数据中OU列(即己占用的老年代内存)的最小值
  2. 第2步:然后,我们每隔一段较长的时间重复一次上述操作,来获得多组OU最小值。如果这些值呈上涨趋势,则说明该Java程序的老年代内存已使用量在不断上涨,这意味着无法回收的对象在不断增加,因此很有可能存在内存泄漏

4、jinfo:实时查看和修改JVM配置参数

1、基本介绍

官方文档

jinfo(Configuration Info for Java):查看虚拟机配置参数信息,也可用于调整虚拟机的配置参数

在很多情况下,Java应用程序不会指定所有的Java虚拟机参数。而此时,开发人员可能不知道某一个具体的Java虚拟机参数的默认值。在这种情况下,可能需要通过查找文档获取某个参数的默认值。这个查找过程可能是非常艰难的。但有了jinfo工具,开发人员可以很方便地找到Java虛拟机参数的当前值。

image-20210713000744298

2、基本语法

它的基本使用语法为:jinfo [ options ] pid

说明:java进程ID必须要加上。

[options] :

选项 选项说明
no option 输出全部的参数和系统属性
-flag name 输出对应名称的参数
-flag [+-]name 开启或关闭对应名称的参数
只有被标记为manageable的参数才可以被动态修改
-flag name=value 设定对应名称的参数
-flags 输出全部的参数
-sysprops 输出系统属性
  • 查看

    • jinfo -sysprops 进程id:可以查看由System.getProperties()取得的参数

      进程id可以通过jps命令查看,操作结果如下:(其中13152代表进程id)

      img

    • jinfo -flags 进程id:查看曾经赋过值的一些参数

      进程id可以通过jps命令查看,参数赋值的一部分是我们自己设置的,另外一部分是系统自动优化设置的参数信息,具体操作如下:(其中13152代表进程id)

      img

    • jinfo -flag 参数名称 进程id:查看某个java进程的具体参数信息

      进程id可以通过jps命令查看具体操作如下:(其中3540代表进程id)

      image-20210713001051530

  • 修改

    • jinfo不仅可以查看运行时某一个Java虚拟机参数的实际取值,甚至可以在运行时修改部分参数,并使之立即生效。但是,并非所有参数都支持动态修改。参数只有被标记为manageable的flag可以被实时修改其实,这个修改能力是极其有限的。

    • #可以查看被标记为manageable的参数:java -XX:+PrintFlagsFinal -version | grep manageable

      image-20210713001449847

    • 针对boolean类型

      • jinfo -flag [+|-]参数名称 进程id

        image-20210713001553125

    • 针对非boolean类型

      • jinfo -flag 参数名称=参数值 进程id

        image-20210713001642300

3、拓展
  • java -XX:+PrintFlagsInitial

    查看所有JVM参数启动的初始值

  • java -XX:+PrintFlagsFinal

    查看所有JVM参数的最终值

    image-20210713001929389

    值前面添加冒号:的是修改之后的值,没有添加的都是没有发生改变的初始值

  • java -参数名称:+PrintCommandLineFlags

    查看那些已经被用户或者JVM设置过的详细的XX参数的名称和值

5、jmap:导出内存映像文件&内存使用情况

1、基本介绍

官方帮助文档

jmap(JVM Memory Map):作用方面是获取dump文件 (堆转储快照文件,二进制文件),它还可以获取目标Java进程的内存相关信息,包括Java堆各区域的使用情况堆中对象的统计信息类加载信息等。

开发人员可以在控制台中输入命令“jmap -help” 查阅jmap工具的具体使用方式和一些标准选项配置。

image-20210713003354636

2、基本语法

它的基本使用语法为:

  • jmap [option]
  • jmap [option] <executable
  • jmap [option] [server_ id@]

其中的option包括:

选项 作用
-dump 生成dump文件
-finalizerinfo 显示在F-Queue中等待Finalizer线程执行finalize方法的对象
-heap 输出整个堆空间的详细信息,包括GC的使用、堆配置信息,以及内存的使用信息等等
-histo 输出堆空间中对象的统计信息,包括类、实例数量和合计容量
-permstat 以ClassLoader为统计口径输出永久代的内存状态信息
-F 当虚拟机对-dump选项没有任何响应的时候,强制执行生成dump文件
  1. 使用语法可以通过在DOS窗口中使用jmap/jmap -h/jmap -help查看jmap使用语法
  2. 文件名称代表可执行的代码,比如使用> 文件名称来指定生成的dump文件的生成位置
  3. [server_id@]是为远程连接准备的

指令描述:

  • **-dump**:生成Java堆转储快照:dump文件
    • 特别的:-dump:live只保存堆中的存活对象
  • **-heap**:输出整个堆空间的详细信息,包括GC的使用、堆配置信息,以及内存的使用信息等
  • **-histo**:输出堆中对象的同级信息,包括类、实例数量和合计容量
    • 特别的:-histo:live只统计堆中的存活对象
  • -permstat:以ClassLoader为统计口径输出永久代的内存状态信息
    • 仅linux/solaris平台有效
  • -finalizerinfo:显示在F-Queue中等待Finalizer线程执行finalize方法的对象
    • 仅linux/solaris平台有效
  • -F:当虚拟机进程对-dump选项没有任何响应时,可使用此选项强制执行生成dump文件
    • 仅linux/solaris平台有效
  • -h | -help:jamp工具使用的帮助命令
  • -J <flag>:传递参数给jmap启动的jvm
3、基本使用
1、使用1:导出内存映像文件

一般来说,使用jmap指令生成dump文件的操作算得上是最常用的jmap命令之一,将堆中所有存活对象导出至一个文件之中。

Heap Dump又叫做堆存储文件,指一个Java进程在某个时间点的内存快照。Heap Dump在触发内存快照的时候会保存此刻的信息如下:

  • All Objects
    Class,fields,primitive values and references
  • All Classes
    ClassLoader,name,super class,static fields
  • Garbage Collection Roots
    Objects defined to be reachable by the JVM
  • Thread Stacks and Local Variables
    The call-stacks of threads at the moment of the snapshot,and per-frame information about local objects

说明:

  1. 通常在写Heap Dump文件前会触发一次Full GC, 所以heap dump文件里保存的都是Full GC后留下的对象信息。
  2. 由于生成dump文件比较耗时,因此大家需要耐心等待,尤其是大内存镜像生成dump文件则需要耗费更长的时间来完成。

注意:

  1. 对于以上说明中的第1点是自动方式才会这样做,而手动不会在Full GC之后生成Dump
  2. 使用手动方式生成dump文件,一般指令执行之后就会生成,不用等到快出现OOM的时候
  3. 使用自动方式生成dump文件,当出现OOM之前先生成dump文件
  4. 如果使用手动方式,一般使用第2种,毕竟生成堆中存活对象的dump文件是比较小的,便于传输和分析

生成dump文件的方式:

  • 手动的方式

    • jmap -dump:format=b,file=<filename.hprof>
    • jmap -dump:live,format=b,file=<filename.hprof>
      • 小结的内容

    说明:

    • 其中file=后面的是生成的dump文件地址,最后的11696是进程id,可以通过jps查看
    • filename中的filename是文件名称,而.hprof是后缀名,代表该值可以省略
    • format=b表示生成的是标准的dump文件,用来进行格式限定
    • 一般使用的是第二种方式,也就是生成堆中存活对象的快照,毕竟这种方式生成的dump文件更小,我们传输处理都更方便

    具体例子如下:

    image-20210713010601073

  • 自动的方式

    当程序发生OOM退出系统时,一些瞬时信息都随着程序的终止而消失,而重现OOM问题往往比较困难或者耗时。此时若能在OOM时,自动导出dump文件就显得非常迫切。这里介绍一种比较常用的取得堆快照文件的方法,即使用:

    • -XX: +HeapDumpOnOutOfMemoryError在程序发生OOM时,导出应用程序的当前堆快照
    • -XX:HeapDumpPath:可以指定堆快照的保存位置
    • 比如:
      • -Xmx100m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=D: \m. hprof
    • 具体使用如下:
2、使用2:显示堆内存相关信息
  • jmap -heap 进程id

    • jmap -heap 进程id只是时间点上的堆信息,而jstat后面可以添加参数,可以指定时间动态观察数据改变情况,而图形化界面工具,例如jvisualvm等,它们可以用图表的方式动态展示出相关信息,更加直观明了。

    • 使用例子:

      image-20210713012001093

      image-20210713012319564

      image-20210713012602069

  • jmap -histo 进程id

    • 输出堆中对象的同级信息,包括类、实例数量和合计容量,也是这一时刻的内存中的对象信息

    • 使用例子:

      image-20210713012043421

      image-20210713012836602

3、使用3:其他作用
  • jmap -permstat 进程id
    • 查看系统的ClassLoader信息(永久代)
  • jmap -finalizerinfo
    • 查看堆积在finalizer队列中的对象

这两个指令仅linux/solaris平台有效,所以无法在windows操作平台上演示,并且使用比较小众,不在多说

4、小结

由于jmap将访问堆中的所有对象,为了保证在此过程中不被应用线程干扰,jmap需要借助安全点机制,让所有线程停留在不改变堆中数据的状态。也就是说,由jmap导出的堆快照必定是安全点位置的。这可能导致基于该堆快照的分析结果存在偏差

举个例子,假设在编译生成的机器码中,某些对象的生命周期在两个安全点之间,那么-dump:live选项将无法探知到这些对象。

另外,如果某个线程长时间无法跑到安全点,jmap将一直等下去。

与前面讲的jstat则不同,垃圾回收器会主动将jstat所需要的摘要数据保存至固定位置之中,而jstat只需直接读取即可

6、jhat:JDK自带堆分析工具

jhat命令在jdk9及其之后就被移除了,官方建议使用VisualVm代替jhat,所以该指令只需简单了解一下即可

1、基本介绍

jhat(JVM Heap Analysis Tool):Sun JDK提供的jhat命令与jmap命令搭配使用,**用于分析jmap生成的heap dump文件(堆转储快照)**。

jhat内置了一个微型的HTTP/HTML服务器(会CPU造成一定的压力),生成dump文件的分析结果后,用户可以在浏览器中查看分析结果(分析虚拟机转储快照信息)。

使用了jhat命令, 就启动了一个http服务,端口是7000, 即http://localhost:7000/,就可以在浏览器里分析。

说明:jhat 命令在JDK9、JDK10中已经被删除,官方建议用VisualVM代替。

image-20210713015031277

2、基本语法

它的基本使用语法为:jhat [option] [dumpfile]

其中dumpfile代表dump文件的地址以及名称,例如:jhat d:\1.hprof

option参数:

  • -stack false|true:关闭|打开对象分配调用栈跟踪
  • -refs false|true:关闭|打开对象引用跟踪
  • -port port-number:设置jhat HTTP Server的端口号,默认7000。例子:jhat -port 6565
  • -exclude exclude-file:执行对象查询时需要排除的数据成员
  • -debug int:设置debug级别
  • -version:启动后显示版本信息就退出
  • -J<flag>:传入启动参数,比如-J -Xmx512m

例子:

image-20210713020055637

image-20210713020318845

image-20210713020411853

注意:

  • 使用jhat一次只能分析一个.hprof文件,如果要分析另外一个.hprof文件的话,需要将之前打开的jhat关闭。

7、jstack:打印JVM中线程快照

1、基本介绍

官方帮助文档

jstack(JVM Stack Trace):用于生成虚拟机指定进程当前时刻的线程快照(虚拟机堆栈跟踪)。线程快照就是当前虚拟机内指定进程的每一条线程正在执行的方法堆栈的集合。

生成线程快照的作用:可用于定位线程出现长时间停顿的原因,如线程间死锁、死循环、请求外部资源导致的长时间等待等问题。这些都是导致线程长时间停顿的常见原因。当线程出现停顿时,就可以用jstack显示各个线程调用的堆栈情况

在thread dump中,要留意下面几种状态:

  • 死锁:Deadlock (重点关注)
  • 等待资源:Waiting on condition (重点关注)
  • 等待获取监视器:Waiting on monitor entry (重点关注)
  • 阻塞:Blocked (重点关注)
  • 执行中:Runnable
  • 暂停:Suspended
  • 对象等待中:Object.wait() 或 TIMED_WAITING
  • 停止:Parked
2、基本语法

image-20210713022747670

它的基本使用语法为:jstack option pid

jstack管理远程进程的话,需要在远程程序的启动参数中增加:

  • -Djava.rmi.server.hostname=……
  • -Dcom.sun.management.jmxremote
  • -Dcom.sun.management.jmxremote.port=8888
  • -Dcom.sun.management.jmxremote.authenticate=false
  • -Dcom.sun.management.jmxremote.ssl=false

总结:如果程序出现等待问题,可以使用该指令去查看问题所在,结果中也会提示你问题所在

option参数:

  • -F:当正常输出的请求不被响应时,强制输出线程堆栈
  • -l:除堆栈外,显示关于锁的附加信息
  • -m:如果调用本地方法的话,可以显示C/C++的堆栈
  • -h:帮助操作

在java层面实现jstack功能:

1
2
3
4
5
6
7
8
9
10
Map<Thread, StackTraceElement[]> all = Thread.getAllStackTraces(); //追踪当前进程中的所有的线程
Set<Map.Entry<Thread, StackTraceElement[]>> entries = all.entrySet();
for(Map.Entry<Thread, StackTraceElement[]> en : entries){
Thread t = en.getKey();
StackTraceElement[] v = en.getValue();
System.out.println("【Thread name is :" + t.getName() + "】");
for(StackTraceElement s : v){
System.out.println("\t" + s.toString());
}
}

例子:

死锁问题:

image-20210713023139008

image-20210713025908544

image-20210713030226778

使用sleep:

image-20210713030341435

同步问题:

image-20210713030512538

8、jcmd:多功能命令行

1、基本介绍

官方帮助文档

在JDK 1.7以后,新增了一个命令行工具jcmd。

它是一个多功能的工具,可以用来实现前面除了jstat之外的所有命令的功能。比如:用它来导出堆、内存使用、查看java进程、导出线程信息、执行GC、JVM运行时间等等。

jcmd拥有jmap的大部分功能,并且在Oracle的官方网站上也推荐使用jcmd命令代替jmap命令。

2、基本语法

image-20210713034508974

  • jcmd -l:列出所有的JVM进程

  • jcmd 进程号 help:针对指定的进程,列出支持的所有具体命令

    • 执行效果:

      img

  • jcmd 进程号 具体命令:显示指定进程的指令命令的数据

    • 首先通过jcmd 进程号 help得出以下命令列表
    • 根据以上命令来替换之前的那些操作:
      • Thread.print 可以替换 jstack指令
      • GC.class_histogram 可以替换 jmap中的-histo操作
      • GC.heap_dump 可以替换 jmap中的-dump操作
      • GC.run 可以查看GC的执行情况
      • VM.uptime 可以查看程序的总执行时间,可以替换jstat指令中的-t操作
      • VM.system_properties 可以替换 jinfo -sysprops 进程id
      • VM.flags 可以获取JVM的配置参数信息

9、jstatd:远程主机信息收集

之前的指令只涉及到监控本机的Java应用程序,而在这些工具中,一些监控工具也支持对远程计算机的监控(如jps、jstat)。为了启用远程监控,则需要配合使用jstatd 工具。

命令jstatd是一个RMI服务端程序,它的作用相当于代理服务器,建立本地计算机与远程监控工具的通信。 jstatd服务器将本机的Java应用程序信息传递到远程计算机。

jstatd的理解

3、JVM监控及诊断工具——GUI篇

1、工具概述

使用上一章命令行工具或组合能帮您获取目标Java应用性能相关的基础信息,但它们存在下列局限:

  1. 无法获取方法级别的分析数据,如方法间的调用关系各方法的调用次数调用时间等(这对定位应用性能瓶颈至关重要)
  2. 要求用户登录到目标Java应用所在的宿主机上,使用起来不是很方便。
  3. 分析数据通过终端输出,结果展示不够直观。

为此,JDK提供 了一些内存泄漏的分析工具,如jconsole、jvisualvm等, 用于辅助开发人员定位问题,但是这些工具很多时候并不足以满足快速定位的需求。所以这里我们介绍的工具相对多一些、丰富一些。

图形化综合诊断工具

  • JDK自带的工具
    • jconsole:JDK自带的可视化监控工具。查看Java应用程序的运行概况、监控堆信息、永久区(或元空间)使用情况、类加载情况
      • 位置:jdk\bin\jconsole.exe
    • Visual VM:Visual VM是一个工具,它提供了一个可视界面,用于查看Java虚拟机上运行的基于Java技术的应用程序的详细信息
      • 位置: jdk\bin\jvisualvm.exe
    • JMC:Java Mission Control,内置Java Flight Recorder**。能够以极低的性能开销收集Java虚拟机的性能数据**。
  • 第三方工具
    • MAT:MAT(Memory Analyzer Tool) 是基于Eclipse的内存分析工具,是一个快速、功能丰富的Java heap分析工具,它可以帮助我们查找内存泄漏和减少内存消耗
      • Eclipse的插件形式
    • JProfiler:商业软件,需要付费。功能强大。
      • 与VisualVM类似
    • ArthasAlibaba开源的Java诊断工具。深受开发者喜爱。
    • Btrace:Java运行时追踪工具。可以在不停机的情况下,跟踪指定的方法调用、构造函数调用和系统内存等信息

2、JConsole

1、基本概述

官方教程

jconsole:

  • 从Java5开始,在JDK中自带的java监控和管理控制台。
  • 用于对JVM中内存、线程和类等的监控,是一个基于JMX(java management extensions )的GUI性能监控工具。
2、启动
  • 在jdk安装目录中找到jconsole.exe,双击该可执行文件就可以
  • 打开DOS窗口,直接输入jconsole就可以了
3、三种连接方式
  • Local

    • 使用JConsole连接一个正在本地系统运行的JVM,并且执行程序的和运行JConsole的需要是同一个用户

    • JConsole使用文件系统的授权通过RMI连接起链接到平台的MBean的服务器上。这种从本地连接的监控能力只有Sun的JDK具有。

    • 注意:本地连接要求 启动jconsole的用户运行当前程序的用户同一个用户

    • 具体操作如下:

      1. 在DOS窗口中输入jconsole

        img

      2. 在控制台上填写相关信息

        img

      3. 选择“不安全的连接”

        img

      4. 进入控制台页面

        img

  • Remote

    • 使用下面的URL通过RMI连接器连接到一个JMX代理,service:jmx:rmi:///jndi/rmi://hostName:portNum/jmxrmi。
    • JConsole为建立连接,需要在环境变量中设置mx.remote.credentials来指定用户名和密码,从而进行授权。
  • Advanced

    • 使用一个特殊的URL连接JMX代理。
    • 一般情况使用自己定制的连接器而不是RMI提供的连接器来连接JMX代理,或者是一个使用JDK1.4的实现了JMX和JMX Rmote的应用
4、主要作用
  1. 概览

    img

  2. 内存

    image-20210716160243770

  3. 根据线程检测死锁

    img

  4. 线程

    img

  5. VM 概要

    img

3、Visual VM

1、基本概述
  • VisualVM是一个功能强大的多合一故障诊断和性能监控的可视化工具。

  • 它集成了多个JDK命令行工具,使用VisualVM可用于显示虚拟机进程及进程的配置和环境信息(jps、jinfo),监视应用程序的CPU、GC、堆、方法区及线程的信息(jstat、jstack)等, 也可以代替JConsole。

  • 在JDK 6 Update 7以后,Visual VM便作为JDK的一部分发布(VisualVM在JDK/bin目录下)

    • 即:它完全免费。
  • 此外,Visual VM也可以作为独立的软件安装:

  • 使用:

    1. 在jdk安装目录中找到jvisualvm.exe,然后双击执行即可
    2. 打开DOS窗口,输入jvisualvm就可以打开该软件
2、插件的安装
  • Visual VM的一大特点是支持插件扩展,并且插件安装非常方便。我们既可以通过离线下载插件文件*.nbm,然后在Plugin对话框的已下载页面下,添加已下载的插件。也可以在可用插件页面下,在线安装插件。(这里建议安装上:VisualGC)

  • IDEA安装VisualVM Launcher插件:Preferences –> Plugins –> 搜索VisualVM Launcher,安装重启即可。

    1. 在IDEA中安装插件:首先在IDEA中搜索VisualVM Launcher插件并安装

      img

    2. 重启IDEA,然后配置该插件:

      img

    3. 使用两种方式来运行程序:

      img

    4. 运行效果:还是打开jvisualvm界面,只是不需要我们手动打开jvisualvm而已

3、连接方式
  • 本地连接
    • 监控本地Java进程的CPU、类、线程等
  • 远程连接
    1. 确定远程服务器的ip地址
    2. 添加JMX(通过JMX技术具体监控远程服务器哪个Java进程)
    3. 修改bin/catalina.sh文件,连接远程的tomcat
    4. 在…/conf中添加jmxremote.access和jmxremote.password文件
    5. 将服务器地址改成公网ip地址
    6. 设置阿里云安全策略和防火墙策略
    7. 启动tomcat,查看tomcat启动日志和端口监听
    8. JMX中输入端口号、用户名、密码登录
4、主要功能
  1. 生成/读取/对比堆内存快照

    • 生成堆内存快照:

      1. 方式1:

        img

      2. 方式2:

        img

        注意:

        生成堆内存快照如下图:img

        这些快照存储在内存中,当线程停止的时候快照就会丢失,如果还想利用,可以将快照进行另存为操作,如下图:

        img

    • 装入堆内存快照

      img

    • dump文件对比

      image-20210716164337161

  2. 查看JVM参数和系统属性

  3. 查看运行中的虚拟机进程

  4. 生成/读取线程快照

    • 生成线程快照

      1. 方式1:

        img

      2. 方式2:

        • 注意:

          生成线程快照如下图:img

          这些快照存储在内存中,当线程停止的时候快照就会丢失,如果还想利用,可以将快照进行另存为操作,如下图:

          img

    • 装入线程快照

      img

  5. 程序资源的实时监控

  6. 抽样器

    • CPU

      image-20210716162615803

      image-20210716163047115

      image-20210716163251126

    • 内存

      image-20210716163651100

      image-20210716163925557

  7. 其他功能

    1. JMX代理连接
    2. 远程环境监控
    3. CPU分析和内存分析

4、Eclipse MAT

1、基本概述

MAT(Memory Analyzer Tool)工具是一款功能强大的Java堆内存分析器。可以用于查找内存泄漏以及查看内存消耗情况

MAT是基于Eclipse开发的, 不仅可以单独使用,还可以作为插件的形式嵌入在Eclipse中使用。是一款免费的性能分析工具,使用起来非常方便。大家可以在
下载并使用MAT。

image-20210713175646382

只要确保机器上装有JDK并配置好相关的环境变量,MAT可正常启动。还可以在Eclipse中以插件的方式安装:

image-20210713175744891

注意:如果单独使用,那么解压即可用,不需要安装即可

2、获取堆dump文件
1、dump文件内存

MAT可以分析heap dump文件。 在进行内存分析时,只要获得了反映当前设备内存映像的hprof文件,通过MAT打开就可以直观地看到当前的内存信息。

一般说来,这些内存信息包含:

  • 所有的对象信息,包括对象实例、成员变量、存储于栈中的基本类型值和存储于堆中的其他对象的引用值。
  • 所有的类信息,包括classloader、 类名称、父类、静态变量等GCRoot到所有的这些对象的引用路径
  • 线程信息,包括线程的调用栈及此线程的线程局部变量(TLS)
2、两点说明
  • 说明1:缺点:
    MAT 不是一个万能工具,它并不能处理所有类型的堆存储文件。但是比较主流的厂家和格式,例如 Sun,HP,SAP所采用的HPROF二进制堆存储文件,以及IBM的PHD堆存储文件等都能被很好的解析。

  • 说明2:
    最吸引人的还是能够快速为开发人员生成内存泄漏报表,方便定位问题和分析问题。虽然MAT有如此强大的功能,但是内存分析也没有简单到一键完成的程度,很多内存问题还是需要我们从MAT展现给我们的信息当中通过经验和直觉来判断才能发现。

    image-20210713180221126

3、获取dump文件
  1. 方法一:通过前一章介绍的jmap工具生成,可以生成任意一个java进程的dump文件;

  2. 方法二:通过配置JVM参数生成。

    • 选项”-XX:+HeapDumpOnOutOfMemoryError“ 或”-XX:+HeapDumpBeforeFullGC
    • 选项”-XX:HeapDumpPath“所代表的含义就是当程序出现0utofMemory时, 将会在相应的目录下生成一份dump文件。如果不指定选项“-XX:HeapDumpPath“ 则在当前目录下生成dump文件。
    • 对比:考虑到生产环境中几乎不可能在线对其进行分析,大都是采用离线分析,因此使用jmap+MAT工具是最常见的组合
  3. 方法三:使用VisualVM可以导出堆dump文件

  4. 方法四:使用MAT既可以打开一个已有的堆快照,也可以通过MAT直接从活动Java程序中导出堆快照。该功能将借助jps列出当前正在运行的Java进程,以供选择并获取快照。

    image-20210713180452468

4、加载dump文件

image-20210716175656605

image-20210716175803107

image-20210716180242326

相关解释:

image-20210716180320235

  • Leak Suspects Report(堆泄露疑点报告):
    • 自动检查堆转储是否存在泄漏嫌疑。 报告哪些对象保持活动状态以及为什么它们没有被垃圾回收器回收。
  • Component Report(组件报告):
    • 分析一组对象是否存在疑似内存问题:重复字符串、空集合、终结器、弱引用等
  • Re-open previously run reports(重新打开以前运行的报告):
    • 现有报告存储在堆dump同一目录下的 ZIP 文件中
4、分析堆dump文件

相关图例:

imgimgimg

通过分析堆dump文件可以得到:

  • 是否存在类的重复加载:

    image-20210716183006378

  • 相关的报告:

    image-20210716181822026

    • Heap Dump Overview(堆dump的概述):

      image-20210716182152051

    • Leak Suspects(堆泄露疑点):

      image-20210716182746036

    • Top Components(顶级组件):通过图形列举出最大对象的情况

      image-20210716182455029

  • histogram:展示了各个类的实例数目以及这些实例的Shallow heap或者Retained heap的总和

    • 使用:

      • 图标:

        img

      • 具体内容:

        img

      • 查找一个类:

        1. Group by package (根据包进行分组):(默认是Group by class)

          image-20210716223222052

          image-20210716223315860

        2. 排序:

          image-20210716223613860

        3. 正则表达式:(精准搜索)

          image-20210716223803061

      • 若一个对象可能存在内存泄露(内存泄露疑点),怎么查看?

        image-20210716224553500

      • 将两份内存映像文件的直方图进行对比:(以下图片的”树状图”修改为”直方图”)

        image-20210716225637523

        image-20210716230023991

        image-20210716230040409

  • thread overview

    • 查看系统中的Java线程

    • 查看局部变量的信息

    • 使用:

      • 图标:

        img

      • 具体内容:

        img

        image-20210716232006164

  • 获得对象互相引用的关系

    • with outgoing references(出引用)

      • 图示:

        img

      • 结果:

        img

    • with incoming references(入引用)

      • 图示:

        img

      • 结果:

        img

      • 分析:

        • 若发现此时该对象只有一些生命周期较短的线程(方法/方法里的引用变量)去引用它,则该对象就是可以被GC进行回收,不会存在内存泄露问题
        • 若发现此时该对象还有另外一些生命周期较长的线程(方法/方法里的引用变量)去引用它,则该对象就不能被GC回收,就存在了内存泄露问题。
        • 解决方法:可以将该引用从强引用修改为软引用或弱引用。
  • 浅堆与深堆(与浅拷贝与深拷贝一一对应)

    • shallow heap

      • 浅堆(Shallow Heap)是指一个对象所消耗的内存。在32位系统中,一个对象引用会占据4个字节,一个int类型会占据4个字节,long型变量会占据8个字节,每个对象头需要占用8个字节。根据堆快照格式不同,对象的大小可能会向8字节进行对齐。

      • 以String为例:2个int值共占8字节,对象引用占用4字节,对象头8字节,合计20字节,向8字节对齐,故占24字节。(jdk7中)

        image-20210713181653120

      • 这24字节为String对象的浅堆大小。它与String的value实际取值无关,无论字符串长度如何,浅堆大小始终是24字节。

      • 对象头代表根据类创建的对象的对象头,还有对象的大小不是可能向8字节对齐,而是就向8字节对齐(一定)。

      • 注意一下:这里对象头除去类型指针的大小为8字节,然后类型指针看是否启用了引用压缩,如果启用了,对象头总共就是12字节,否则就是16字节。(32位机是不支持指针压缩的)

    • retained heap

      • 保留集(Retained Set):
        • 对象A的保留集指当对象A被垃圾回收后,可以被释放的所有的对象集合(包括对象A本身),即对象A的保留集可以被认为是只能通过对象A被直接或间接访问到的所有对象的集合。通俗地说,就是指仅被对象A所持有的对象的集合
      • 深堆(Retained Heap):
        • 深堆是指对象的保留集中所有的对象的浅堆大小之和
        • 注意:
          • 浅堆指对象本身占用的内存,不包括其内部引用对象的大小一个对象的深堆指只能通过该对象访问到的(直接或间接)所有对象的浅堆之和,即对象被回收后,可以释放的真实空间
          • 当前深堆大小 = 当前对象的浅堆大小 + 对象中所包含对象的深堆大小
    • 补充:对象实际大小

      • 另外一个常用的概念是对象的实际大小。这里,对象的实际大小定义为一个对象所能触及的(这里要与深堆的”只有通过”**相区分)所有对象的浅堆大小之和,也就是通常意义上我们说的对象大小。与深堆相比,似乎这个在日常开发中更为直观和被人接受,但实际上,这个概念和垃圾回收无关**。

      • 下图显示了一个简单的对象引用关系图,对象A引用了C和D,对象B引用了C和E。那么对象A的浅堆大小只是A本身,不含C和D,而A的实际大小为A、C、D三者之和。而A的深堆大小为A与D之和,由于对象C还可以通过对象B访问到,因此不在对象A的深堆范围内。

        image-20210713182436604

    • 练习

      • 看图理解Retained Size

        image-20210713182530946

      • 解答:

        • 上图中,GC Roots直接引用了A和B两个对象。
        • A对象的Retained Size = A对象的Shallow Size
        • B对象的Retained Size = B对象的Shallow Size + C对象的Shallow Size
        • 这里不包括D对象,因为D对象被GC Roots直接引用。
      • 如果GC Roots不引用D对象呢?

        • 那么B对象的Retained Size = B对象的Shallow Size + C对象的Shallow Size + D对象的Shallow Size
        • 因为此时的D对象只有通过B对象进行引用
    • 案例分析:StudentTrace

      • 代码:

        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
        32
        33
        34
        35
        36
        37
        38
        39
        40
        41
        42
        43
        44
        45
        46
        47
        48
        49
        50
        51
        52
        53
        54
        55
        56
        57
        58
        59
        60
        61
        62
        63
        64
        65
        66
        67
        68
        69
        70
        71
        72
        73
        74
        75
        76
        77
        78
        79
        80
        81
        82
        83
        84
        85
        86
        87
        88
        89
        90
        /**
        * 有一个学生浏览网页的记录程序,它将记录 每个学生访问过的网站地址。
        * 它由三个部分组成:Student、WebPage和StudentTrace三个类
        *
        * -XX:+HeapDumpBeforeFullGC -XX:HeapDumpPath=d:\student.hprof
        * @author shkstart
        * @create 16:11
        */
        public class StudentTrace {
        static List<WebPage> webpages = new ArrayList<WebPage>();

        public static void createWebPages() {
        for (int i = 0; i < 100; i++) {
        WebPage wp = new WebPage();
        wp.setUrl("http://www." + Integer.toString(i) + ".com");
        wp.setContent(Integer.toString(i));
        webpages.add(wp);
        }
        }
        public static void main(String[] args) {
        createWebPages();//创建了100个网页
        //创建3个学生对象
        Student st3 = new Student(3, "Tom");
        Student st5 = new Student(5, "Jerry");
        Student st7 = new Student(7, "Lily");
        for (int i = 0; i < webpages.size(); i++) {
        if (i % st3.getId() == 0)
        st3.visit(webpages.get(i));
        if (i % st5.getId() == 0)
        st5.visit(webpages.get(i));
        if (i % st7.getId() == 0)
        st7.visit(webpages.get(i));
        }
        webpages.clear();
        System.gc();
        }
        }
        class Student {
        private int id;
        private String name;
        private List<WebPage> history = new ArrayList<>();

        public Student(int id, String name) {
        super();
        this.id = id;
        this.name = name;
        }

        public int getId() {
        return id;
        }
        public void setId(int id) {
        this.id = id;
        }
        public String getName() {
        return name;
        }
        public void setName(String name) {
        this.name = name;
        }
        public List<WebPage> getHistory() {
        return history;
        }
        public void setHistory(List<WebPage> history) {
        this.history = history;
        }
        public void visit(WebPage wp) {
        if (wp != null) {
        history.add(wp);
        }
        }
        }

        class WebPage {
        private String url;
        private String content;

        public String getUrl() {
        return url;
        }
        public void setUrl(String url) {
        this.url = url;
        }
        public String getContent() {
        return content;
        }
        public void setContent(String content) {
        this.content = content;
        }
        }
      • 图示:

        img

      • 结论:

        • 这里三个学生对象的浅堆大小都是24字节:4(id) + 4(name) + 4(history) + 8(对象头) = 20 –> 24(向8字节对齐)
          • 其实这里如果是32位虚拟机,需要补对齐填充。
          • 但如果不是是32位虚拟机,而且存在类型指针压缩的话:对象头应该有12字节 –> 4 + 4 + 4 + 12 = 24
          • 普通Java对象头的大小为12字节或16字节,默认采用了指针压缩则为12字节,没有采用则为16字节(数组还需要加上数组长度)
          • 同理每一个网页的浅堆大小也都是24字节4(url) + 4(content) + 12(对象头) = 20 –> 24(向8字节对齐)
      • 解释:(为什么elementData数组的深堆为1288个字节)

        • 普通Java对象头的大小为12字节或16字节。默认采用了指针压缩则为12字节,没有采用则为16字节(数组还需要加上数组长度)。详情参考博客
        • 为什么有152字节和144字节:
          • 因为我们的URL和content存在两种情况(个位数与十位数)
          • 第一种URL长度为16,底层的char数组的占用空间为(【】方括号里面整个都属于对象头,分开写方便大家理解)
            • 【普通对象头(12) + 数组长度(4)】 + 16个字符(32) = 48字节,符合8字节对齐
            • 同理content 占用 【普通对象头(12) +数组长度(4)】+ 一个字符(2) = 18字节,八字节对齐 = 24字节
          • 第二种URL长度为17,底层的插入数组的占用空间为
            • 【普通对象头(12) + 数组长度(4)】 + 17个字符(34) = 50字节,不符合8字节对齐,对齐为56
            • 同理content 占用 【普通对象头(12) +数组长度(4)】+ 两个字符(4) = 20字节,八字节对齐 = 24字节
          • 所以第一种总字节为48 + 24 = 72,第二种总字节为56 + 24 = 80。因此第二种比第一种多了8字节,所以是152和144
          • 为什么总大小是152而不是72?
            • 因为我们只计算了String底层的char数组的区别没有计算各变量本身的浅堆,因为结构都相同,所以差别就差在内容的占用上
        • 为什么最终结果是1288?
          • 首先ElementData数组本身的浅堆大小为:【普通对象头(12) + 数组长度(4)】 + 数组内容【15个Obejct引用 = 15 * 4】 = 76,八字节对齐 = 80字节
          • 15个Object分为13个152字节 + 2个144字节,总大小为 = 2264字节
          • 7号和其他student重复的有0、21、42、63、84、35、70总计6个152和1一个144
          • 所以2264 - 6 * 152 - 144 = 1208字节
          • 所以ElementData本身的浅堆80 + 仅能通过它到达的浅堆1208 = 1288
        • 为什么ArrayList的长度是15?(并不是因为ArrayList的内容是15个,对于Jerry同学来说:ArrayList的长度是22,但是ArrayList的内容只有21)
          • 这是和ArrayList的扩容有关
          • ArrayList默认的长度为10,当长度超过10的时候,ArrayList就会自动扩容,扩容系数是0.5
          • 即ArrayList的长度 = 10 * 1.5 = 15
          • 当超过扩容后的长度(15),ArrayList会再次扩容:15 * 1.5 = 22
  • 支配树

    • 支配树(Dominator Tree )(支配树的概念源自图论(统计学))

    • MAT提供了一个称为支配树(Dominator Tree) 的对象图。支配树体现了对象实例间的支配关系。在对象引用图中,所有指向对象B的路径都经过对象A,则认为对象A支配对象B。如果对象A是离对象B最近的一个支配对象,则认为对象A为对象B的直接支配者。支配树是基于对象间的引用图所建立的,它有
      以下基本性质:

      • 对象A的子树(所有被对象A支配的对象集合)表示对象A的保留集(retained set),即深堆。
      • 如果对象A支配对象B,那么对象A的直接支配者也支配对象B。
      • 支配树的边与对象引用图的边不直接对应。
    • 如下图所示:左图表示对象引用图,右图表示左图所对应的支配树。对象A和B由根对象直接支配,由于在到对象C的路径中,可以经过A,也可以经过B,因此对象C的直接支配者也是根对象。对象F与对象D相互引用,因为到对象F的所有路径必然经过对象D,因此,对象D是对象F的直接支配者。而到对象D的所有路径中,必然经过对象C,即使是从对象F到对象D的引用,从根节点出发,也是经过对象C的,所以,对象D的直接支配者为对象C。同理,对象E支配对象G。到达对象H的可以通过对象D,也可以通过对象E,因此对象D和E都不能支配对
      象H,而经过对象C既可以到达D也可以到达E,因此对象C为对象H的直接支配者。

      image-20210713185747798

    • 注意:

      • 跟随我一起来理解如何从“对象引用图—》支配树”,首先需要理解支配者(如果要到达对象B,毕竟经过对象A,那么对象A就是对象B的支配者,可以想到支配者大于等于1),
      • 然后需要理解直接支配者(在支配者中距离对象B最近的对象A就是对象B的直接支配者,你要明白直接支配者不一定就是对象B的上一级,然后直接支配者只有一个),
      • 然后还需要理解支配树是怎么画的,其实支配树中的对象与对象之间的关系就是直接支配关系,也就是上一级是下一级的直接支配者,只要按照这样的方式来作图,肯定能从“对象引用图 —》支配树”
    • 在Eclipse MAT工具中如何查看支配树:

      • 在MAT中,单击工具栏上的对象支配树按钮,可以打开对象支配树视图。

        image-20210714000259424

      • 下图显示了对象支配树视图的一部分。该截图显示部分Lily学生的history队列的直接支配对象。即当Lily对象被回收,也会一并回收的所有对象。显然能被3或者5整除的网页不会出现在该列表中,因为它们同时被另外两名学生对象引用。(15(总) - 7(被其他引用) = 8(可回收))

        image-20210714000337354

4、案例:Tomcat堆溢出分析
  • 说明:

    • Tomcat是最常用的Java Servlet容器之一 , 同时也可以当做单独的Web服务器使用。Tomcat本身使用Java实现,并运行于Java虚拟机之上。在大规模请求时,Tomcat有可能会因为无法承受压力而发生内存溢出错误。这里根据一个被压垮的Tomcat的堆快照文件, 来分析Tomcat在崩溃时的内部情况。
  • 分析过程:

    • 查看大对象(主要分析的对象):

      image-20210717034430939

    • 查看当前最大的对象它到底引用了哪些具体的内部结构:

      image-20210717034529989

    • 查看该大对象中哪一部分占用了大部分内存:

      image-20210717034627788

    • 继续往里查看:

      image-20210717034749850

    • 继续往里查看:

      image-20210717034836025

    • 找到出现问题的对象后,可以通过OOL语句查询出想要的对象

      img

      img

    • 查看该对象的创建时间与结束时间,判断他是不是一个生命周期短的对象:

      img

    • 根据找到的信息进行分析:

      img

5、支持使用OQL语言查询对象信息

image-20210716181538800

  • SELECT子句
  • FROM子句
  • WHERE子句
  • 内置对象与方法

5、JProfiler

1、基本概述
1、介绍

官网下载地址

在运行Java的时候有时候想测试运行时占用内存情况,这时候就需要使用测试工具查看了。在eclipse里面有Eclipse Memory Analyzer tool (MAT)插件可以测试,而在IDEA中也有这么一个插件,就是JProfiler。

JProfiler是由ej-technologies公司开发的一款Java应用性能诊断工具。功能强大,但是收费。

2、特点
  • 使用方便、界面操作友好( 简单且强大)
  • 对被分析的应用影响小(提供模板)
  • CPU, Thread , Memory分析功能尤其强大
  • 支持对jdbc、noSql、jsp、servlet、socket等进行分析
  • 支持多种模式(离线,在线)的分析
  • 支持监控本地、远程的JVM
  • 跨平台,拥有多种操作系统的安装版本

image-20210717185256510

3、主要功能
  1. 方法调用
    • 对方法调用的分析可以帮助您了解应用程序正在做什么,并找到提高其性能的方法
  2. 内存分配
    • 通过分析堆上对象、引用链和垃圾收集能帮您修复内存泄露问题,优化内存使用
  3. 线程和锁
    • JProfiler提供多种针对线程和锁的分析视图助您发现多线程问题
  4. 高级子系统
    • 许多性能问题都发生在更高的语义级别上。例如,对于JDBC调用,您可能希望找出执行最慢的SQL语句。JProfiler支持对这些子系统进行集成分析
2、安装与配置
1、下载与安装

下载

image-20210717190718552

image-20210717195706559

2、JProfiler中配置IDEA
  1. IDE Integrations

    img

  2. 选择合适的IDE版本

    img

  3. 开始集成

    img

  4. 正式集成

    img

  5. 集成成功

    img

  6. 点击OK即可

3、IDEA集成JProfiler
  1. 安装JProfiler插件

    1. 方式1:在线安装

      img

    2. 方式2、离线安装

      1. 首先下载插件:

        img

      2. 准备离线安装:

        img

      3. 正式离线安装:

        img

      注意:无论采用方式1还是方式2都需要重启IDEA

  2. 将JProfiler配置到IDEA中

    img

3、具体使用
  • 启动:

    image-20210717220513329

    • 相关说明:
      • Profile a demo session or a saved session(配置demo会话或保存一个会话):
        • JProfiler附带了几个预先配置的演示会话。你可以让他们开始探索JProfiler的特征。
      • Attach to a running JVM(连接到正在运行的JVM):
        • JProfiler可以连接到本地或远程运行的jvm,并动态地分析它们。一些附加模式下不支持功能。
      • Profile an application server, locally or remotely(本地或远程配置应用程序服务器):
        • JProfiler提供了对所有主要应用服务器的广泛支持。两个应用服务器支持在此计算机和远程计算机上运行。
      • Open a snapshot(打开快照):
        • JProfiler可以保存快照以及以后可以打开的所有分析结果。而且,它可以打开HPROF和PHD快照。
  • 数据采集方式

    image-20210717221829151

    • JProfier数据采集方式分为两种:Sampling(样本采集)和Instrumentation (重构模式)
      • Instrumentation:这是JProfiler全功能模式。在class加载之前,JProfier把相关功能代码写入到需要分析的class的bytecode中,对正在运行的jvm有一定影响。
        • 优点:功能强大。在此设置中,调用堆栈信息是准确的。
        • 缺点:若要分析的class较多,则对应用的性能影响较大,CPU开销可能很高(取决于Filter的控制)。因此使用此模式一般配合Filter使用,只对特定的类或包进行分析
      • Sampling:类似于样本统计,每隔一定时间(5ms )将每个线程栈中方法栈中的信息统计出来。
        • 优点:对CPU的开销非常低,对应用影响小(即使你不配置任何Filter)
        • 缺点:一些数据/特性不能提供(例如:方法的调用次数、执行时间)
      • 注:JProfiler本身没有指出数据的采集类型,这里的采集类型是针对方法调用的采集类型。因为JProfiler的绝大多数核心功能都依赖方法调用采集的数据,所以可以直接认为是JProfiler的数据采集类型。
      • 推荐使用Sampling方式,足够用来分析OOM问题了
  • 遥感监测 Telemetries

    img

    • 其中Telemetries就是遥感监测的意思
  • 内存视图 Live Memory

    • Live memory(内存剖析):class/class instance的相关信息。例如对象的个数, 大小,对象创建的方法执行栈,对象创建的热点。

      • 所有对象All Objects:显示所有加载的类的列表和在堆上分配的实例数。只有Java 1.5 (JVMTI)才会显示此视图。(浅堆)

        image-20210717192307939

      • 记录对象Record Objects:查看特定时间段对象的分配,并记录分配的调用堆栈。

        • 注意:默认关闭,若开启的话,会导致系统的性能急剧的降低。

        • 开启的时机:判断内存泄露的时候开启

        • 使用:

          image-20210717223524175

      • 分配访问树Allocation Call Tree:显示一棵请求树或者方法、 类、包或对己选择类有带注释的分配信息的J2EE组件。

      • 分配热点Allocation Hot Spots:显示一个列表,包括方法、类、包或分配已选类的J2EE组件。你可以标注当前值并且显示差异值。对
        于每个热点都可以显示它的跟踪记录树。
        类追踪器Class Tracker:类跟踪视图可以包含任意数量的图表,显示选定的类和包的实例与时间。

    • 分析:内存中的对象的情况

      • 频繁创建的Java对象:死循环、循环次数过多
      • 存在大的对象:读取文件时,byte[]应该边读边写。–> 如果长时间不写出的话,导致byte[]过大
      • 存在内存泄漏
    • 注意:

      1. All Objects后面的Size大小是浅堆大小
      2. Record Objects在判断内存泄露的时候使用,可以通过观察Telemetries中的Memory,如果里面出现垃圾回收之后的内存占用逐步提高,这就有可能出现内存泄露问题,所以可以使用Record Objects查看,但是该分析默认不开启,毕竟占用CPU性能太多
  • 堆遍历 heap walker

    • 如果通过内存视图 Live Memory已经分析出哪个类的对象不能进行垃圾回收,并且有可能导致内存溢出,如果想进一步分析,我们可以在该对象上点击右键,选择Show Selection In Heap Walker,如下图:

      img

    • 之后进行溯源,操作如下:

      img

    • 查看结果,并根据结果去看对应的图表:

      img

    • 以下是图表的展示情况:

      img

    • 对于堆快照:

      image-20210717224343240

  • cpu视图 cpu views

    image-20210717224827793

    1. 具体使用:

      1. 访问树

        image-20210717225913334

      2. 记录方法统计信息

        img

      3. 方法统计

        img

      4. 具体分析

        img

  • 线程视图 threads

    • JProfiler通过对线程历史的监控判断其运行状态,并监控是否有线程阻塞产生,还能将一个线程所管理的方法以树状形式呈现。对线程剖析。

      • 线程历史Thread History:显示一个与线程活动和线程状态在一起的活动时间表。
      • 线程监控Thread Monitor:显示一个列表,包括所有的活动线程以及它们目前的活动状况。
      • 线程转储Thread Dumps:显示所有线程的堆栈跟踪。
    • 线程分析主要关心三个方面:

      1. web容器的线程最大数。比如: Tomcat的线程容量应该略大于最大并发数。
      2. 线程阻塞
      3. 线程死锁
    • 具体使用:

      1. 查看线程运行情况

        img

      2. 新建线程dump文件

        img

  • 监视器&锁 Monitors&locks

    • 监控和锁Monitors & Locks所有线程持有锁的情况以及锁的信息。
    • 观察JVM的内部线程并查看状态:
      • 死锁探测图表Current Locking Graph:显示JVM中的当前死锁图表。
      • 目前使用的监测器CurrentMonitors:显示目前使用的监测器并且包括它们的关联线程。
      • 锁定历史图表Locking History Graph:显示记录在JVM中的锁定历史。
      • 历史检测记录MonitorHistory:显示重大的等待事件和阻塞事件的历史记录。
      • 监控器使用统计Monitor Usage Statistics:显示分组监测,线程和监测类的统计监测数据
4、案例分析
1、案例1(较为安全)

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class JProfilerTest {
public static void main(String[] args) {
while (true){
ArrayList list = new ArrayList();
for (int i = 0; i < 500; i++) {
Data data = new Data();
list.add(data);
}
try {
TimeUnit.MILLISECONDS.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
class Data{
private int size = 10;
private byte[] buffer = new byte[1024 * 1024];//1mb
private String info = "hello,atguigu";
}
2、案例2(内存泄露)

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class MemoryLeak {
public static void main(String[] args) {
while (true) {
ArrayList beanList = new ArrayList();
for (int i = 0; i < 500; i++) {
Bean data = new Bean();
data.list.add(new byte[1024 * 10]);//10kb
beanList.add(data);
}
try {
TimeUnit.MILLISECONDS.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
class Bean {
int size = 10;
String info = "hello,atguigu";
// ArrayList list = new ArrayList(); // 解决方法
static ArrayList list = new ArrayList();
}

分析:

我们通过JProfiler来看一下,如下:

img

你可以看到内存一个劲的往上涨,但是就是没有下降的趋势,说明这肯定有问题,过不了多久就会出现OOM,我们来到Live memory中,先标记看一下到底是哪些对象在进行内存增长,等一小下看看会不会触发垃圾回收,如果不触发的话,我们自己来触发垃圾回收,之后观察哪些对象没有被回收掉,如下:

img

我上面点击了Mark Current,发现有些对象在持续增长,然后点击了一下Run GC,结果如下所示:

img

可以看出byte[]没有被回收,说明它是有问题的,我们点击Show Selection In Heap Walker,如下:

img

然后看一下该对象被谁引用,如下:

img

结果如下:

img

可以看出byte[]来自于Bean类是的list中,并且这个list是ArrayList类型的静态集合,所以找到了:static ArrayList list = new ArrayList();

发现list是静态的,这不妥,因为我们的目的是while结束之后Bean对象被回收,并且Bena对象中的所有字段都被回收,但是list是静态的,那就是类的,众所周知,类变量随类而生,随类而灭,因此每次我们往list中添加值,都是往同一个list中添加值,这会造成list不断增大,并且不能回收,所以最终会导致OOM

6、Arthas

1、基本概述
1、背景

前面,我们介绍了jdk自带的jvisualvm等免费工具,以及商业化工具Jprofiler。

jvisualvm界面:

image-20210718010802170

Jprofiler界面:

image-20210718010823344

这两款工具在业界知名度也比较高,他们的优点是可以图形界面上看到各维度的性能数据,使用者根据这些数据进行综合分析,然后判断哪里出现了性能问题。

但是这两款工具也有个缺点,都必须在服务端项目进程中配置相关的监控参数。然后工具通过远程连接到项目进程,获取相关的数据。这样就会带来一些不便,比如线上环境的网络是隔离的,本地的监控工具根本连不上线上环境。并且类似于Jprofiler这样的商业工具,是需要付费的。

那么有没有一款工具不需要远程连接,也不需要配置监控参数,同时也提供了丰富的性能监控数据呢?

今天跟大家介绍一款阿里巴巴开源的性能分析神器Arthas (阿尔萨斯)

2、概述

image-20210718011032691

Arthas (阿尔萨斯)是Alibaba开源的Java诊断工具, 深受开发者喜爱。在线排查问题,无需重启;动态跟踪Java代码;实时监控JVM状态。

Arthas支持JDK 6+,支持Linux/Mac/Windows,采用命令行交互模式,同时提供丰富的Tab自动补全功能,进-步方便进行问题的定位和诊断。

当你遇到以下类似问题而束手无策时,Arthas可以帮助你解决:

  • 这个类从哪个jar包加载的?为什么会报各种类相关的Exception?
  • 我改的代码为什么没有执行到?难道是我没commit?分支搞错了?
  • 遇到问题无法在线上debug,难道只能通过加日志再重新发布吗?
  • 线上遇到某个用户的数据处理有问题,但线上同样无法debug,线下无法重现!
  • 是否有一个全局视角来查看系统的运行状况?
  • 有什么办法可以监控到JVM的实时运行状态?
  • 怎么快速定位应用的热点,生成火焰图?
3、基于哪些工具开发而来
  • greys-anatomyArthas代码基于Greys二次开发而来,非常感谢Greys之前所有的工作,以及Greys原作者对Arthas提出的意见和建议!
  • termdArthas的命令行实现基于termd开发,是一款优秀的命令行程序开发框架,感谢termd提供了优秀的框架。
  • crashArthas的文本渲染功能基于crash中的文本渲染功能开发,可以从这里看到源码,感谢crash在这方面所做的优秀工作。
  • cliArthas的命令行界面基于vert.x提供的cli库进行开发,感谢vert. x在这方面做的优秀工作。
  • compiler:Arthas里的内存编绎器代码来源
  • Apache Commons Net:Arthas里的Telnet Client代码来源
  • JavaAgent:运行在main方法之前的拦截器,它内定的方法名叫premain ,也就是说先执行premain方法然后再执行main方法
  • ASM:一个通用的Java字节码操作和分析框架。它可以用于修改现有的类或直接以二进制形式动态生成类。ASM提供了一些常见的字节码转换和分析算法,可以从它们构建定制的复杂转换和代码分析工具。ASM提供了与其他Java字节码框架类似的功能,但是主要关注性能。因为它被设计和实现得尽可能小和快,所以非常适合在动态系统中使用(当然也可以以静态方式使用,例如在编译器中)
4、官方使用文档

https://arthas.aliyun.com/doc/quick-start.html

2、安装与使用
1、安装
  1. 安装方式一:可以直接在Linux 上通过命令下载

    • 可以在官方Github 上进行下载,如果速度较慢,可以尝试国内的码云Gitee 下载。

      • github 下载

        1
        wget https://alibaba.github.io/arthas/arthas-boot.jar
      • Gitee下载

        1
        wget https://arthas.gitee.io/arthas-boot.jar
  2. 安装方式二:

    • 也可以在浏览器直接访问,等待下载成功后,上传到Linux服务器上。(可以放在opt文件目录下)

      image-20210718011406059

卸载:

  • 在Linux/Unix/Mac 平台

  • 删除下面文件:

    1
    2
    rm -rf ~/.arthas/I
    rm -rf ~/logs/arthas
  • Windows平台直接删除user home下面的.arthas和logs/arthas目录

2、工程目录
  • arthas-agent:基于JavaAgent技术的代理
  • bin:一些启动脚本
  • arthas-boot:Java版本的一键安装启动脚本
  • arthas-client:telnet client代码
  • arthas-common:一些共用的工具类和枚举类
  • arthas-core:核心库,各种arthas命令的交互和实现
  • arthas-demo:示例代码
  • arthas-memorycompiler:内存编绎器代码,Fork from https://github.com/skalogs/SkaETL/tree/master/compiler
  • arthas-packaging:maven打包相关的
  • arthas-site:arthas站点
  • arthas-spy:编织到目标类中的各个切面
  • static:静态资源
  • arthas-testcase:测试
3、启动

Arthas只是一个java程序,所以可以直接用java -jar 运行。

执行成功后,arthas提供了一种命令行方式的交互方式,arthas 会检测当前服务器上的Java进程,并将进程列表展示出来,用户输入对应的编号(1、2、3、4……)进行选择,然后回车。

比如:

  1. 方式1:

    1
    java -jar arthas-boot.jar

    #选择进程(输入[]内编号(不是PID)回车)
    [INFO] arthas-boot version: 3.1.4
    [INFO] Found existing java process, please choose one and hit RETURN.

    * [1]: 11616 com.Arthas

    [2]: 8676

    [3]: 16200 org. jetbrains. jps . cmdline . Launcher

    [4]: 21032 org. jetbrains. idea . maven. server . RemoteMavenServer

  2. 方式2:

    运行时选择Java进程PID:

    1
    java -jar arthas-boot.jar [PID]

image-20210718011816040

4、查看进程

jps

5、查看日志

cat ~/logs/arthas/arthas.log

6、查看帮助

java -jar arthas-boot.jar -h

7、web console

除了在命令行查看外,Arthas目前还支持Web Console。在成功启动连接进程之后就已经自动启动,可以直接访问http://127.0.0.1:8563/ 访问,页面上的操作模式和控制台完全一样。

image-20210718012104569

8、退出

最后一行[arthas@7457]$, 说明打开进入了监控客户端,在这里就可以执行相关命令进行查看了。

  • 使用quit\exit:退出当前客户端
  • 使用stop\shutdown:关闭arthas服务端,并退出所有客户端。
3、相关诊断指令
1、基础指令

image-20210718012307430

2、jvm相关

命令列表:https://arthas.aliyun.com/doc/commands.html#id1

image-20210718012842725

3、class/classloader相关

image-20210718014201542

  • sc

    • 作用:查看JVM已加载的类信息
    • 链接:https://arthas.aliyun.com/doc/sc
    • 常用参数:
      • class- pattern:类名表达式匹配
      • -d:输出当前类的详细信息,包括这个类所加载的原始文件来源、类的声明、加载的ClassLoader等详细信息。如果一个类被多个ClassLoader所
        加载,则会出现多次
      • -E:开启正则表达式匹配,默认为通配符匹配
      • -f:输出当前类的成员变量信息(需要配合参数-d一起使用)
      • -x:指定输出静态变量时属性的遍历深度,默认为0,即直接使用toString输出
    • 补充:
      1. class-pattern支持全限定名, 如com.test.AAA,也支持com/test/AAA这样的格式,这样,我们从异常堆栈里面把类名拷贝过来的时候,不需要在手动把/替换为.了。
      2. sc默认开启了子类匹配功能,也就是说所有当前类的子类也会被搜索出来,想要精确的匹配,请打开options disable-sub-class true开关
  • sm

    • 作用:查看己加载类的方法信息
    • 链接:https://arthas.aliyun.com/doc/sm
    • sm命令只能看到由当前类所声明(declaring) 的方法,父类则无法看到
    • 常用参数:
      • class-pattern:类名表达式匹配
      • method-pattern:方法名表达式匹配
      • -d:展示每个方法的详细信息
      • -E:开启正则表达式匹配,默认为通配符匹配
  • jad

    • 作用:反编译指定己加载类的源码

    • 链接:https://arthas.aliyun.com/doc/jad

    • 在Arthas Console 上,反编译出来的源码是带语法高亮的,阅读更方便

    • 当然,反编译出来的java代码可能会存在语法错误,但不影响你进行阅读理解

    • 编译java.lang.String

      image-20210718004243898

  • mc、redefine

    • mc命令:Memory Compiler/内存编译器,编译.java文件生成.class

    • 链接:https://arthas.aliyun.com/doc/mc

    • 使用:

      1
      mc /tmp/Test.java
    • redefine命令:加载外部的.class文件,redefine jvm已加载的类。

    • 链接:https://arthas.aliyun.com/doc/redefine

    • 推荐使用retransform命令

      1
      2
      redefine /tmp/Test.class 
      redefine -c 327a647b /tmp/Test.class /tmp/Test\$Inner.class
  • classloader

    • 作用:查看classloader 的继承树,urls,类加载信息
    • 链接:https://arthas.aliyun.com/doc/classloader
    • 了解当前系统中有多少类加载器,以及每个加载器加载的类数量,帮助您判断是否有类加载器泄漏。
    • 常用参数:
      • -t:查看ClassLoader的继承树
      • -l:按类加载实例查看统计信息
      • -c:用classloader对应的hashcode来查看对应的jar urls
4、monitor/watch/trace相关

命令列表:https://arthas.aliyun.com/doc/commands.html#id1

image-20210718014942945

  • monitor

    • monitor命令:方法执行监控

    • 对匹配class-pattern / method-pattern的类、方法的调用进行监控。涉及方法的调用次数、执行时间、失败率等

    • 链接:https://arthas.aliyun.com/doc/monitor

    • monitor命令是一个非实时返回命令

    • 常用参数:

      • class-pattern:类名表达式匹配
      • method-pattern:方法名表达式匹配
      • -c:统计周期,默认值为120秒
    • 监控项:

      image-20210718015103331

  • watch

    • watch命令:方法执行数据观测
    • 链接:https://arthas.aliyun.com/doc/watch
    • 作用:让你能方便的观察到指定方法的调用情况。能观察到的范围为:返回值、抛出异常、入参,通过编写groovy表达式进行对应变量的查看。
    • 常用参数:
      • class-pattern:类名表达式匹配
      • method-pattern:方法名表达式匹配
      • express:观察表达式
      • condition-express:条件表达式
      • -b:在方法调用之前观察(默认关闭)
      • -e:在方法异常之后观察(默认关闭)
      • -s:在方法返回之后观察(默认关闭)
      • -f:在方法结束之后(正常返回和异常返回)观察(默认开启)
      • -x:指定输出结果的属性遍历深度,默认为0
      • #cost:方法执行耗时
    • 说明:这里重点要说明的是观察表达式,观察表达式的构成主要由ognl 表达式组成,所以你可以这样写”{params, returnObj}”,只要是一个合法的ognl表达式,都能被正常支持。
    • 举例:watch全限定类名 方法名returnObj
  • trace

    • trace命令:方法内部调用路径,并输出方法路径上的每个节点上耗时
    • 链接:https://arthas.aliyun.com/doc/trace
    • 补充说明:
      • trace命令能主动搜索class-pattern / method- pattern 对应的方法调用路径,渲染和统计整个调用链路上的所有性能开销和追踪调用链路。
      • trace能方便的帮助你定位和发现因RT高而导致的性能问题缺陷,但其每次只能跟踪一级方法的调用链路
      • trace 在执行的过程中本身是会有一定的性能开销,在统计的报告中并未像JProfiler一样预先减去其自身的统计开销。所以这统计出来有些许的不准,渲染路径上调用的类、方法越多,性能偏差越大。但还是能让你看清一些事情的。
    • 参数说明:
      • class-pattern:类名表达式匹配
      • method-pattern:方法名表达式匹配
      • condition-express:条件表达式
      • -n:命令执行次数
      • #cost:方法执行耗时
  • stack

    • stack命令:输出当前方法被调用的调用路径
    • 链接:https://arthas.aliyun.com/doc/stack
    • 常用参数:
      • class-pattern:类名表达式匹配
      • method-pattern:方法名表达式匹配
      • condition-express:条件表达式
      • -n:执行次数限制
      • #cost:方法执行耗时
  • tt

    • tt命令:方法执行数据的时空隧道,记录下指定方法每次调用的入参和返回信息,并能对这些不同的时间下调用进行观测。
    • 链接:https://arthas.aliyun.com/doc/tt
    • TimeTunnel的缩写
    • 常用参数:
      • -t:表明希望记录下类 *Test 的 print 方法的每次执行情况。
      • -n 3:指定你需要记录的次数,当达到记录次数时Arthas会主动中断tt命令的记录过程,避免人工操作无法停止的情况。
      • -s:筛选指定方法的调用信息
      • -i:参数后边跟着对应的INDEX编号查看到它的详细信息
      • -p:重做一次调用,通过--replay-times 指定调用次数,通过--replay- interval 指定多次调用间隔(单位ms,默认1000ms)
5、其他

使用>将结果重写到日志文件,使用&指令命令是后台运行,session断开不影响任务执行(生命周期默认为1天)

  • jobs:列出所有job
  • kill:强制终止任务
  • fg:将暂停的任务拉到前台执行
  • bg:将暂停的任务放到后台执行
  • grep:搜索满足条件的结果
  • plaintext:将命令的结果去除ANSI颜色
  • wc:按行统计输出结果
  • options:查看或设置Arthas全局开关
  • profiler:使用async-profiler对应用采样,生成火焰图

7、Java Misssion Control

1、历史

在Oracle 收购Sun之前,Oracle 的JRockit 虚拟机提供了一款叫做JRockitMission Control的虛拟机诊断工具。

在Oracle收购Sun之后,Oracle公司同时拥有了Sun Hotspot和JRockit两款虚拟机。根据Oracle对于Java的战略,在今后的发展中,会将JRockit的优秀特性移植到Hotspot上。其中一个重要的改进就是在Sun的JDK中加入了JRockit的支持。

在Oracle JDK 7u40之后,Mission Control这款工具已经绑定在Oracle JDK中发布。

自Java 11开始,本节介绍的JFR已经开源。但在之前的Java版本,JFR属于Commercial Feature,可要通过Java虚拟机参数-XX: +UnlockCommercialFeatures开启。

如果你有兴趣请可以查看OpenJDK的Mission Control项目

2、启动

Mission Control位于%JAVA_ HOME%/bin/jmc.exe,打开这款软件。

image-20210718021714996

3、概述

Java Mission Control (简称JMC) Java官方提供的性能强劲的工具,是一个用于对Java 应用程序进行管理、监视、概要分析和故障排除的工具套件。

它包含一个GUI客户端,以及众多用来收集Java虚拟机性能数据的插件,如JMX Console(能够访问用来存放虚拟机各个子系统运行数据的MXBeans),以及虚拟机内置的高效profiling 工具Java Flight Recorder (JFR)

JMC 的另一个优点就是:采用取样,而不是传统的代码植入技术,对应用性能的影响非常非常小,完全可以开着JMC来做压测(唯一影响可能是full gc多了)。

4、功能:实时监控JVM运行时的状态

如果是远程服务器,使用前要开JMX。

-Dcom. sun. management . jmxremote . port=${YOUR PORT}
-Dcom. sun. management . jmxremote
-Dcom. sun. management . imxremote . authenticate=false
-Dcom. sun . management . jmxremote. ss1=false
-Djava. rmi. server . hostname=${YOUR HOST/IP}

文件 -> 连接 ->创建新连接,填入上面JMX参数的host和port

image-20210718022620212

5、Java Flight Recorder

Java Flight Recorder是JMC 的其中一个组件。

Java Flight Recorder能够以极低的性能开销收集Java虚拟机的性能数据。

JFR的性能开销很小,在默认配置下平均低于1%**。与其他工具相比,JFR能够直接访问虚拟机内的数据,并且不会影响虚拟机的优化。因此,它非常适用于生产环境下满负荷运行的Java程序**。

Java Flight Recorder和JDK Mission Control共同创建了一个完整的工具链。JDK Mission Control可对Java Flight Recorder连续收集低水平和详细的运行时信息进行高效详细的分析。

  • 事件类型

    • 当启用时,JFR 将记录运行过程中发生的一系列事件。其中包括Java层面的事件,如线程事件、锁事件,以及Java 虚拟机内部的事件,如新建对象、垃圾回收和即时编译事件。
    • 按照发生时机以及持续时间来划分,JFR 的事件共有四种类型,它们分别为以下四种:
      1. **瞬时事件(Instant Event)**:用户关心的是它们发生与否,例如异常、线程启动事件。
      2. **持续事件(Duration Event)**:用户关心的是它们的持续时间,例如垃圾回收事件。
      3. **计时事件(Timed Event)**:是时长超出指定阈值的持续事件。
      4. **取样事件(Sample Event)**:是周期性取样的事件。
    • 取样事件的其中一个常见例子便是方法抽样(Method Sampling),即每隔一段时间统计各个线程的栈轨迹。如果在这些抽样取得的栈轨迹中存在一个反复出现的方法,那么我们可以推测该方法是热点方法
  • 启动方式

    1. 方式1:使用-XX:StartFlightRecording=参数

      • 比如:下面命令中,JFR将会在Java虚拟机启动5s后(对应delay=5s)收集数据,持续20s (对应duration=20s)。当收集完毕后,JFR 会将收集得到的数据保存至指定的文件中(对应filename=myrecording . jfr)

        1
        2
        java
        -XX:StartFlightRecording=delay=5s,duration=20s,filename=myrecording.jfr,setting s=profile MyApp
      • 由于JFR将持续收集数据,如果不加以限制,那么JFR可能会填满硬盘的所有空间。因此,我们有必要对这种模式下所收集的数据进行限制。

        • 比如:

          1
          java -XX:StartFlightRecording=maxage=10m, maxsize=100m, name=SomeLabel MyApp
    2. 方式2:使用jcmd的JFR.*子命令

      • 通过jcmd来让JFR开始收集数据、停止收集数据,或者保存所收集的数据,对应的子命令分别为JFR.startJFR.stop以及JFR.dump

        1
        $ jcmd <PID> JFR.start settings=profile maxage=10m maxsize=150m name=SomeLabel
      • 上述命令运行过后,目标进程中的JFR已经开始收集数据。此时,我们可以通过下述命令来导出己经收集到的数据:

        1
        $ jcmd <PID> JFR.dump name=SomeLabel filename=myrecording.jfr
      • 最后,我们可以通过下述命令关闭目标进程中的JFR:

        1
        $ jcmd <PID> JFR.stop name=SomeLabel
    3. 方式3:JMC的JFR插件

      img

      • 具体使用:

        1. 启动飞行记录仪

          img

        2. 启动飞行记录

          img

        3. 正式启动

          img

          img

          img

  • Java Flight Recorder 取样分析

    • 要采用取样,必须先添加参数:

      • -XX: +UnlockCommercialFeatures
      • -XX: +Flight Recorder
    • 否则:

      image-20210718170905047

    • 取样时间默认1分钟,可自行按需调整,事件设置选为profiling,然后可以设置取样profile哪些信息,比如:

      • 加上对象数量的统计:Java Virtual Machine -> GC -> Detailed -> Object Count/Object Count after GC
      • 方法调用采样的间隔从10ms改为1ms(但不能低于 1ms,否则会影响性能了):Java Virtual Machine -> Profiling -> Method Profiling Sample/Method Sampling Information
      • Socket与File采样,10ms 太久,但即使改为1ms也未必能抓住什么,可以干脆取消掉:Java Application -> File Read/FileWrite/Socket Read/Socket Write

      image-20210718171442960

    • 然后就开始Profile,到时间后Profile 结束,会自动把记录下载回来,在JMC中展示。

      image-20210718171510051

    • 从展示信息中,我们大致可以读到内存和CPU信息、代码、线程和IO等比较重要的信息展示。

代码:

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
/**
* -XX:+PrintFlagsFinal -Xms600m -Xmx600m
* -XX:SurvivorRatio=8
* 默认情况下,新生代占 1/3 : 200m,老年代占2/3 : 400m
* 其中,Eden默认占新生代的8/10 : 160m ,Survivor0,Survivor1各占新生代的1/10 : 20m
*/
public class OOMTest {
public static void main(String[] args) {
ArrayList<Picture> list = new ArrayList<>();
while(true){
try {
Thread.sleep(5);
} catch (InterruptedException e) {
e.printStackTrace();
}
list.add(new Picture(new Random().nextInt(100 * 50)));
}
}
}
class Picture{
private byte[] pixels;
public Picture(int length) {
this.pixels = new byte[length];
}
public byte[] getPixels() {
return pixels;
}
public void setPixels(byte[] pixels) {
this.pixels = pixels;
}
}

结果:

  1. 一般信息

    img

  2. 内存

    img

  3. 代码

    img

  4. 线程

    img

  5. I/O

    img

  6. 系统

    img

  7. 事件

    img

8、其他工具

1、Flame Graphs(火焰图)

在追求极致性能的场景下,了解你的程序运行过程中cpu在干什么很重要,火焰图就是一种非常直观的展示cpu在程序整个生命周期过程中时间分配的工具。

火焰图对于现代的程序员不应该陌生,这个工具可以非常直观的显示出调用栈中的CPU消耗瓶颈。

网上的关于java火焰图的讲解大部分来自于Brendan Gregg的博客

image-20210718172030880

火焰图简单通过x轴横条宽度来度量时间指标y轴代表线程栈的层次

2、Tprofiler
  • 案例:
    • 使用JDK自身提供的工具进行JVM调优可以将TPS由2.5提升到20(提升了7倍),并准确定位系统瓶颈
    • 系统的瓶颈有:应用里静态对象不是很多、有大量的业务进程在频繁创建一些生命周期很长的临时对象,代码里有问题
    • 那么,如何在海量业务代码里边准确定位这些性能代码?这里使用阿里开源工具Tprofiler来定位这些性能代码,成功解决掉GC过于频繁的性能瓶颈,并最终在上次优化的基础上将TPS在提升了4倍,即提升到100。
      • TProfiler配置部署、远程操作、日志阅读都不太复杂,操作还是很简单的。但是其却是能够起到一针见血、立竿见影的效果,帮我们解决了GC过于频繁的性能瓶颈。
      • TProfiler最重要的特性就是能够统计出你指定时间段内JVM 的top method,这些top method极有可能就是造成你JVM 性能瓶颈的元凶。这是其他大多数JVM调优工具所不具备的,包括JRockit Mission Control。JRokit 首席开发者Marcus Hirt在其私人博客《Low Overhead Method Profiling with Java Mission Control》下的评论中曾明确指出JRMC并不支持TOP方法的统计。
      • TProfiler的下载
3、Btrace
  • Java运行时追踪工具
  • 常见的动态追踪工具有BTrace、HouseMD (该项目已经停止开发)、Greys-Anatomy (国人开发,个人开发者)、Byteman (JBoss出品),注意Java运行时追踪工具并不限于这几种,但是这几个是相对比较常用的。
  • BTrace是SUN Kenai云计算开发平台下的一个开源项目,旨在为java提供安全可靠的动态跟踪分析工具。先看一下BTrace的官方定义:
    • BTrace is a safe, dynamic tracing tool for the Java platform. BTrace can be used to dynamically trace a running Java program (similar to DTrace for OpenSolaris applications and OS). BTrace dynamically instruments the classes of the target application to inject tracing code (“bytecode tracing”)。
  • 简洁明了,大意是一个Java平台的安全的动态追踪工具。可以用来动态地追踪一个运行的Java程序。BTrace动态调整目标应用程序的类以注入跟踪代码(“字节码跟踪”)。
4、YourKit
5、JProbe
6、Spring Insight

9、学习建议

Visual VM -> Arthus -> Jproflier(公司有能购买商业版) -> MAT

10、补充1:再谈内存泄露

1、内存泄露的理解与分析
1、何为内存泄漏( memory leak)

image-20210427230217504

可达性分析算法来判断对象是否是不再使用的对象,本质都是判断一个对象是否还被引用。那么对于这种情况下,由于代码的实现不同就会出现很多种内存泄漏问题(让JVM误以为此对象还在引用中,无法回收,造成内存泄漏)。

  • 是否还被使用?是
  • 是否还被需要?否
2、内存泄漏( memory leak) 的理解

严格来说,只有对象不会再被程序用到了,但是GC又不能回收他们的情况,才叫内存泄漏

但实际情况很多时候一些不太好的实践(或疏忽)会导致对象的生命周期变得很长甚至导致OOM,也可以叫做宽泛意义上的“内存泄漏”。

image-20210717171640356

对象X引用对象Y,X的生命周期比Y的生命周期长;那么当Y生命周期结束的时候,X依然引用着Y,这时候,垃圾回收期是不会回收对象Y的;如果对象X还引用着生命周期比较短的A、B、C,对象A又引用着对象a、b、c,这样就可能造成大量无用的对象不能被回收,进而占据了内存资源,造成内存泄漏,直到内存溢出。

3、内存泄漏与内存溢出的关系
  1. 内存泄漏(memory leak)

    申请了内存用完了不释放,比如一共有1024M的内存,分配了512M 的内存一直不回收,那么可以用的内存只有512M了, 仿佛泄露掉了一部分;通俗点讲的话, 内存泄漏就是 [占着茅坑不拉shi] 。

  2. 内存溢出(out of memory)

    申请内存时,没有足够的内存可以使用;通俗一点儿讲,一个厕所就三个坑,有两个站着茅坑不走的(内存泄漏),剩下最后一个坑,厕所表示接待压力很大,这时候一下子来了两个人,坑位(内存)就不够了,内存泄漏变成内存溢出了。

    可见,内存泄漏和内存溢出的关系:内存泄漏的增多,最终会导致内存溢出。

4、泄漏的分类
  • 经常发生:发生内存泄露的代码会被多次执行,每次执行,泄露一块内存;(坚决杜绝)
  • 偶然发生:在某些特定情况下才会发生;
  • 一次性:发生内存泄露的方法只会执行一次;
  • 隐式泄漏:一直占着内存不释放,直到执行结束;严格的说这个不算内存泄漏,因为最终释放掉了,但是如果执行时间特别长,也可能会导致内存耗尽。
2、Java中内存泄露的8种情况
  • 静态集合类

    • 静态集合类,如HashMap、LinkedList等等。如果这些容器为静态的,那么它们的生命周期与JVM程序一致,则容器中的对象在程序结束之前将不能被释放,从而造成内存泄漏。简单而言,长生命周期的对象持有短生命周期对象的引用,尽管短生命周期的对象不再使用,但是因为长生命周期对象持有它的引用而导致不能被回收。

    • 代码

      1
      2
      3
      4
      5
      6
      7
      public class MemoryLeak {
      static List list = new ArrayList();
      public void oomTests() {
      Object obj = new Object();
      list.add(obj);
      }
      }
  • 单例模式

    • 单例模式,和静态集合导致内存泄露的原因类似,因为单例的静态特性,它的生命周期和JVM的生命周期一样长,所以如果单例对象如果持有外部对象的引用,那么这个外部对象也不会被回收,那么就会造成内存泄漏。
  • 内部类持有外部类

    • 内部类持有外部类,如果个外部类的实例对象的方法返回了一个内部类的实例对象。这个内部类对象被长期引用了,即使那个外部类实例对象不再被使用,但由于内部类持有外部类的实例对象,这个外部类对象将不会被垃圾回收,这也会造成内存泄漏。
  • 各种连接,如数据库连接、网络连接和IO连接等

    • 各种连接,如数据库连接、网络连接和IO连接等。在对数据库进行操作的过程中,首先需要建立与数据库的连接,当不再使用时,需要调用close方法来释放与数据库的连接。只有连接被关闭后,垃圾回收器才会回收对应的对象。否则,如果在访问数据库的过程中,对Connection、 Statement或ResultSet 不显性地关闭,将会造成大量的对象无法被回收,从而引起内存泄漏。

    • 代码:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      public static void main(String[] args) {
      try {
      Connection conn = null;
      Class.forName ("com.mysq1.jdbc.Driver");
      conn = DriverManager . getConnection("url", 11);
      Statement stmt = conn. createStatement();
      ResultSet rs = stmt.executeQuery("....");
      } catch (Exception e) {
      //异常日志
      } finally {
      //1.关闭结果集Statement
      // 2.关闭声明的对象ResultSet
      // 3.关闭连接Connection
      }
  • 变量不合理的作用域

    • 变量不合理的作用域。一般而言,一个变量的定义的作用范围大于其使用范围,很有可能会造成内存泄漏。另一方面,如果没有及时地把对象设置为null,很有可能导致内存泄漏的发生

    • 伪代码:

      1
      2
      3
      4
      5
      6
      7
      public class UsingRandom {
      private String msg;
      public void receiveMsg(){
      readFromNet();//从网络中接受数据保存到msg中
      saveDB();//把msg保存到数据库中
      }
      }
    • 如上面这个伪代码,通过readFromNet方法把接受的消息保存在变量msg中,然后调用saveDB方法把msg的内容保存到数据库中,此时msg已经就没用了,由于msg的生命周期与对象的生命周期相同,此时msg还不能回收,因此造成了内存泄漏。

    • 实际上这个msg变量可以放在receiveMsg方法内部, 当方法使用完,那么msg的生命周期也就结束,此时就可以回收了。还有一种方法,在使用完msg后,把msg设置为null,这样垃圾回收器也会回收msg的内存空间。

    • 解决方法1:

      1
      2
      3
      4
      5
      6
      7
      public class UsingRandom {
      public void receiveMsg(){
      private String msg;
      msg = readFromNet();//从网络中接受数据保存到msg中
      saveDB();//把msg保存到数据库中
      }
      }
    • 解决方法2:

      1
      2
      3
      4
      5
      6
      7
      8
      public class UsingRandom {
      private String msg;
      public void receiveMsg(){
      readFromNet();//从网络中接受数据保存到msg中
      saveDB();//把msg保存到数据库中
      msg = null;
      }
      }
  • 改变哈希值

    • 改变哈希值,当一个对象被存储进HashSet集合中以后,就不能修改这个对象中的那些参与计算哈希值的字段了

    • 否则,对象修改后的哈希值与最初存储进HashSet集合中时的哈希值就不同了,在这种情况下,即使在contains方法使用该对象的当前引用作为的参数去HashSet集合中检索对象,也将返回找不到对象的结果,这也会导致无法从HashSet集合中单独删除当前对象,造成内存泄漏。

    • 这也是String为什么被设置成了不可变类型,我们可以放心地把String存入HashSet,或者把String当做HashMap的key值;

    • 当我们想把自己定义的类保存到散列表的时候,需要保证对象的hashCode不可变。

    • 代码:

      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
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      49
      50
      51
      52
      53
      public class ChangeHashCode {
      public static void main(String[] args) {
      HashSet set = new HashSet();
      Person p1 = new Person(1001, "AA");
      Person p2 = new Person(1002, "BB");

      set.add(p1);
      set.add(p2);

      p1.name = "CC";//导致了内存的泄漏
      set.remove(p1); //删除失败

      System.out.println(set);

      set.add(new Person(1001, "CC"));
      System.out.println(set);

      set.add(new Person(1001, "AA"));
      System.out.println(set);
      }
      }
      class Person {
      int id;
      String name;

      public Person(int id, String name) {
      this.id = id;
      this.name = name;
      }
      @Override
      public boolean equals(Object o) {
      if (this == o) return true;
      if (!(o instanceof Person)) return false;

      Person person = (Person) o;

      if (id != person.id) return false;
      return name != null ? name.equals(person.name) : person.name == null;
      }
      @Override
      public int hashCode() {
      int result = id;
      result = 31 * result + (name != null ? name.hashCode() : 0);
      return result;
      }
      @Override
      public String toString() {
      return "Person{" +
      "id=" + id +
      ", name='" + name + '\'' +
      '}';
      }
      }
      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
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      public class ChangeHashCode1 {
      public static void main(String[] args) {
      HashSet<Point> hs = new HashSet<Point>();
      Point cc = new Point();
      cc.setX(10);//hashCode = 41
      hs.add(cc);

      cc.setX(20);//hashCode = 51 此行为导致了内存的泄漏

      System.out.println("hs.remove = " + hs.remove(cc));//false
      hs.add(cc);
      System.out.println("hs.size = " + hs.size());//size = 2

      System.out.println(hs);
      }
      }
      class Point {
      int x;

      public int getX() {
      return x;
      }
      public void setX(int x) {
      this.x = x;
      }
      @Override
      public int hashCode() {
      final int prime = 31;
      int result = 1;
      result = prime * result + x;
      return result;
      }
      @Override
      public boolean equals(Object obj) {
      if (this == obj) return true;
      if (obj == null) return false;
      if (getClass() != obj.getClass()) return false;
      Point other = (Point) obj;
      if (x != other.x) return false;
      return true;
      }
      @Override
      public String toString() {
      return "Point{" +
      "x=" + x +
      '}';
      }
      }
  • 缓存泄露

    • 内存泄漏的另一个常见来源是缓存,一旦你把对象引用放入到缓存中,他就很容易遗忘。比如:之前项目在一次上线的时候,应用启动奇慢直到夯死,就是因为代码中会加载一个表中的数据到缓存(内存)中,测试环境只有几百条数据,但是生产环境有几百万的数据。

    • 对于这个问题,可以使用WeakHashMap代表缓存,此种Map的特点是,当除了自身有对key的引用外,此key没有其他引用,那么此map会自动丢弃此值

    • 代码:

      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
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      49
      50
      51
      52
      53
      54
      55
      56
      public class MapTest {
      static Map wMap = new WeakHashMap();
      static Map map = new HashMap();

      public static void main(String[] args) {
      init();
      testWeakHashMap();
      testHashMap();
      }

      public static void init() {
      String ref1 = new String("obejct1");
      String ref2 = new String("obejct2");
      String ref3 = new String("obejct3");
      String ref4 = new String("obejct4");
      wMap.put(ref1, "cacheObject1");
      wMap.put(ref2, "cacheObject2");
      map.put(ref3, "cacheObject3");
      map.put(ref4, "cacheObject4");
      System.out.println("String引用ref1,ref2,ref3,ref4 消失");
      }

      public static void testWeakHashMap() {
      System.out.println("WeakHashMap GC之前");
      for (Object o : wMap.entrySet()) {
      System.out.println(o);
      }
      try {
      System.gc();
      TimeUnit.SECONDS.sleep(5);
      } catch (InterruptedException e) {
      e.printStackTrace();
      }
      System.out.println("WeakHashMap GC之后");
      for (Object o : wMap.entrySet()) {
      System.out.println(o);
      }
      }

      public static void testHashMap() {
      System.out.println("HashMap GC之前");
      for (Object o : map.entrySet()) {
      System.out.println(o);
      }
      try {
      System.gc();
      TimeUnit.SECONDS.sleep(5);
      } catch (InterruptedException e) {
      e.printStackTrace();
      }
      System.out.println("HashMap GC之后");
      for (Object o : map.entrySet()) {
      System.out.println(o);
      }
      }
      }
    • 结果:

      • String引用ref1,ref2,ref3,ref4 消失
      • WeakHashMap GC之前
      • obejct2=cacheObject2
      • obejct1=cacheObject1
      • WeakHashMap GC之后
      • HashMap GC之前
      • obejct4=cacheObject4
      • obejct3=cacheObject3
      • Disconnected from the target VM, address: ‘127.0.0.1:51628’, transport: ‘socket’
      • HashMap GC之后
      • obejct4=cacheObject4
      • obejct3=cacheObject3
    • 分析:

      image-20210717180541120

      • 上面代码和图示主演演示WeakHashMap如何自动释放缓存对象,当init函数执行完成后,局部变量字符串引用weakd1、weakd2、d1、d2都会消失,此时只有静态map中保存中对字符串对象的引用,可以看到,调用gc之后,HashMap的没有被回收,而WeakHashMap里面的缓存被回收了。
  • 监听器和回调

    • 内存泄漏另一个常见来源是监听器和其他回调,如果客户端在你实现的API中注册回调,却没有显示的取消,那么就会积聚。
    • 需要确保回调立即被当作垃圾回收的最佳方法是只保存它的弱引用,例如将他们保存成为WeakHashMap中的键。
3、内存泄露案例分析
1、案例代码
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 Stack {
private Object[] elements;
private int size = 0;
private static final int DEFAULT_INITIAL_CAPACITY = 16;

public Stack() {
elements = new Object[DEFAULT_INITIAL_CAPACITY];
}

public void push(Object e) { //入栈
ensureCapacity();
elements[size++] = e;
}
//存在内存泄漏
public Object pop() { //出栈
if (size == 0)
throw new EmptyStackException();
return elements[--size];//只是将指针下移,没有回收内存
}
private void ensureCapacity() {
if (elements.length == size)
elements = Arrays.copyOf(elements, 2 * size + 1);
}
}
2、分析

上述程序并没有明显的错误,但是这段程序有一个内存泄漏,随着GC活动的增加,或者内存占用的不断增加,程序性能的降低就会表现出来,严重时可导致内存泄漏,但是这种失败情况相对较少。

代码的主要问题在pop函数, 下面通过这张图示展现

假设这个栈一直增长,增长后如下图所示:

image-20210717181048650

当进行大量的pop操作时,由于引用未进行置空,gc是不会释放的,如下图所示:

image-20210717181439315

从上图中看以看出,如果栈先增长,在收缩,那么从栈中弹出的对象将不会被当作垃圾回收,即使程序不再使用栈中的这些队象,他们也不会回收,因为栈中仍然保存这对象的引用,俗称过期引用,这个内存泄露很隐蔽。

3、解决办法

将代码中的pop()方法变成如下方法:

1
2
3
4
5
6
7
public Object pop() {
if (size == 0)
throw new EmptyStackException();
Object result = elements[--size];
elements[size] = null;
return result;
}

一旦引用过期,清空这些引用,将引用置空。

4、案例代码(与移动端的开发有关)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class TestActivity extends Activity {
private static final object key = new 0bject();
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
new Thread(){//匿名线程
public void run() {
synchronized (key) {
try {
key.wait();
} catch (InterruptedException e) {
e.printStackTrace( );
}
}
}
}.start();
}
}
5、分析

image-20210717182307112

image-20210717182357403

内部类持有外部类:当GC要回收TestActivity的时候,发现内部类(匿名线程)内部持有了外部类(key对象),不能将TestActivity顺利回收,导致了内存泄露。

6、解决方法
  1. 使用线程时,一定要确保线程在周期性对象(如Activity) 销毁时能正常结束, 如能正常结束,但是Activity销毁后还需执行一段时间,也可能造成泄露,此时可采用WeakReference方法来解决,另外在使用Handler的时候,如存在Delay操作,也可以采用WeakReference;
  2. 使用Handler + HandlerThread时, 记住在周期性对象销毁时调用looper.quit()方法;

11、补充2:支持使用OQL语言查询对象信息

1、介绍

MAT支持一种类似于SQL的查询语言OQL (Object Query Language) 。OQL使用类SQL语法,可以在堆中进行对象的查找和筛选。

2、在Eclipse MAT中如何用

img

3、例子
  1. select * from java.util.ArrayList(列出所有的ArrayList对象信息)
  2. select v.elementData from java.util.ArrayList v(注意:elementData代表ArrayList底层的数组,结果最终以数组形式将结果呈现出来)
  3. select objects v.elementData from java.util.ArrayList v(注意:elementData代表ArrayList底层的数组,objects代表对象类型,所以最终以对象形式将结果呈现出来,同时展示出来的还有浅堆、深堆)
  4. select as retained set * from com.atguigu.mat.Student(得到对象的保留级)
  5. select * from 0x6cd57c828(0x6cd57c828是Student类的地址值)
  6. select * from char[] s where s.@length > 10(char型数组长度大于10的数组)
  7. select * from java.lang.String s where s.value != null(字符串值不为空的字符串信息)
  8. select toString(f.path.value) from java.io.File f(列出文件的路径值)
  9. select v.elementData.@length from java.util.ArrayList v(列出Arraylist对象中ArrayList中的数组长度)
4、SELECT子句

在MAT中,Select子句的格式与SQL基本一致,用于指定要显示的列。Select子句中可以使用*,查看结果对象的引用实例(相当于outgoing references) 。

SELECT * FROM java.util.Vector v

使用”OBJECTS“关键字,可以将返回结果集中的项以对象的形式显示。

  • SELECT objects v.elementData FROM java.util.Vector v
  • SELECT OBJECTS s.value FROM java.lang.String s

在Select子句中,使用”AS RETAINED SET“关键字可以得到所得对象的保留集。

  • SELECT AS RETAINED SET * FROM com.atguigu.mat.Student

“DISTINCT”关键字用于在结果集中去除重复对象。

  • SELECT DISTINCT OBJECTS classof(s) FROM java.lang.String s
5、FROM子句

From子句用于指定查询范围,它可以指定类名、正则表达式或者对象地址。

SELECT * FROM java.lang.StrIng s

下例使用正则表达式,限定搜索范围,输出所有com. atguigu包下所有类的实例

  • SELECT * FROM “com\.atguigu\..*”

也可以直接使用类的地址进行搜索。使用类的地址的好处是可以区分被不同ClassLoader加载的同一种类型

  • select * from 0x37a0b4d
6、WHERE子句

Where子句用于指定OQL的查询条件。OQL查询将只返回满足Where子句指定条件的对象。Where子句的格式与传统SQL极为相似。

下例返回长度大于10的char数组。

  • SELECT * FROM char[] s WHERE s.@length>10

下例返回包含”java”子字符串的所有字符串,使用”LIKE”操作符,”LIKE”操作符的操作参数为正则表达式。

  • SELECT * FROM java.lang.String s WHERE toString(s) LIKE “. *java. *”

下例返回所有value域不为null的字符串,使用”=”操作符。

  • SELECT * FROM java.lang.String s where s. value!=null

Where子句支持多个条件的AND、OR运算。下例返回数组长度大于15,并且深堆大于1000字节的所有Vector对象。

  • SELECT * FROM java.util.Vector v WHERE v.elementData.@length>15 AND v. @retainedHeapSize>1000
7、内置对象与方法

OQL中可以访问堆内对象的属性,也可以访问堆内代理对象的属性。访问堆内对象的属性时,格式如下:

  • [ . ] . .
  • 其中alias为对象名称。

访问java.io.File对象的path属性,并进一步访问path的value属性:

  • SELECT toString(f.path.value) FROM java.io.File f

下例显示了String对象的内容、objectid和objectAddress。

  • SELECT s.toString(), s.@objectId, s.@objectAddress FROM java.lang.String s

下例显示java.util.Vector内部数组的长度。

  • SELECT v.elementData.@length FROM java.util.Vector v

下例显示了所有的java.util.Vector对象及其子类型

  • select * from INSTANCEOF java.util.Vector

4、JVM运行时参数

1、JVM参数选项

参数来源

1、类型一:标准参数选项
1、特点
  • 比较稳定,后续版本基本不会变化
  • -开头
2、各种选项

直接在DOS窗口中运行java或者java -help可以看到所有的标准选项

image-20210718175015752

image-20210718175046637

3、补充内容:-server与-client

Hotspot JVM有 两种模式,分别是server(C2编译器)和client(C1编译器),分别通过-server和-client模式设置

  1. 在32位Windows系统上,默认使用Client类型的JVM。要想使用Server模式,则机器配置至少有2个以上的CPU和2G以上的物理内存。client模式适用于对内存要求较小的桌面应用程序,默认使用Serial串行垃圾收集器

  2. 64位机器上只支持server模式的JVM,适用于需要大内存的应用程序,默认使用并行垃圾收集器

  3. 关于server和client的官网介绍

对于以上第2点,我们可以打开DOS窗口,输入java -version就可以看到64位机器上用的server模式,如下所示:

img

2、类型二:-X参数选项
1、特点
  • 非标准化参数
  • 功能还是比较稳定的。但官方说后续版本可能会变更
  • -X开头
2、各种选项

直接在DOS窗口中运行java -X命令可以看到所有的X选项

image-20210718175909651

其中

  • -Xmixed 混合模式执行 (默认)
  • -Xint 仅解释模式执行
  • -Xcomp 仅采用即时编译器模式
3、JVM的JIT编译模式相关的选项
  • -Xint

    • 只使用解释器:所有字节码都被解释执行,这个模式的速度是很慢的
  • -Xcomp

    • 只使用编译器:所有字节码第一次使用就被编译成本地代码,然后在执行
  • -Xmixed(默认)

    • 混合模式:这是默认模式,刚开始的时候使用解释器慢慢解释执行,后来让JIT即时编译器根据程序运行的情况,有选择地将某些热点代码提前编译并缓存在本地,在执行的时候效率就非常高了。

    • 默认使用的就是这种模式,证明如下:

      img

4、特别地:-Xmx -Xms -Xss属于XX参数?
  • 单位分别是:k/K、m/M、g/G

  • 设置:-Xmx、-Xms最好设置成一样的值,避免扩容带来的损耗

    • -Xms

      • 设置初始Java堆大小,等价于-XX:InitialHeapSize

      • 查看该参数值的时候,应该使用InitialHeapSize,例如jinfo flag InitialHeapSize 进程id

      • 等价证明:

        img

        img

    • -Xmx

      • 设置最大Java堆大小,等价于-XX:MaxHeapSize

      • 查看该参数值的时候,应该使用MaxHeapSize,例如jinfo flag InitialHeapSize 进程id

      • 等价证明:

        image-20210718181620379

    • -Xss

      • 设置Java线程堆栈大小,等价于-XX:ThreadStackSize
      • 查看该参数值的时候,应该使用ThreadStackSize,例如jinfo flag InitialHeapSize 进程id
3、类型三:-XX参数选项(重要)
1、特点
  • 非标准化参数
  • 使用的最多的参数类型
  • 这类选项属于实验性,不稳定
  • -XX开头
2、作用

用于开发和调试JVM

3、分类
  • Boolean类型格式
    • -XX:+
    • -XX:-
    • 举例:
      • -XX:+UseParallelGC:选择垃圾收集器为并行收集器
      • -XX:+UseG1GC:表示启用G1收集器
      • -XX:+UseAdaptiveSizePolicy:自动选择年轻代区大小和相应的Survivor区比例
    • 说明:因为有的指令默认是开启的,所以可以使用-关闭
  • 非Boolean类型格式(key-value类型)
    • 子类型1:数值型格式-XX:
    • 子类型2:非数值型格式-XX:=
      • 例如:
        • -XX:HeapDumpPath=/usr/local/heapdump.hprof:用来指定heap转存文件的存储路径。
4、特别地:-XX:+PrintFlagsFinal
  • 输出所有参数的名称和默认值
  • 默认不包括Diagnostic和Experimental的参数
  • 可以配合-XX:+UnlockDiagnosticVMOptions-XX:UnlockExperimentalVMOptions使用

2、添加JVM参数选项

1、Eclipse
  1. 在空白处单击右键,选择Run As,在选择Run Configurations……

    img

  2. 设置虚拟机参数

    img

2、IDEA
  1. Edit Configurations…

    img

  2. 设置虚拟机参数

    img

3、运行jar包
  • 这是在java -jar demo.jar中的java -jar之间添加了虚拟机配置信息
    • java -Xms50m -Xmx50m -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -jar demo.jar
4、通过Tomcat运行war包
  • Linux系统下可以在tomcat/bin/catalina.sh中添加类似如下配置:
    • JAVA_OPTS=”-Xms512M -Xmx1024M”
  • Windows系统下载catalina.bat中添加类似如下配置:
    • set “JAVA_OPTS=-Xms512M -Xmx1024M”
5、程序运行过程中

jinfo不仅可以查看运行时某一个Java虛拟机参数的实际取值,甚至可以在运行时修改部分参数,并使之立即生效。

但是,并非所有参数都支持动态修改。参数只有被标记为manageable的flag可以被实时修改。其实,这个修改能力是极其有限的。

#可以查看被标记为manageable的参数:java -XX:+PrintFlagsFinal -version | grep manageable

image-20210713001449847

  • 使用jinfo -flag = 设置非Boolean类型参数
  • 使用jinfo -flag [+|-] 设置Boolean类型参数

3、常用的JVM参数选项

1、打印设置的XX选项及值
  • -XX:+PrintCommandLineFlags:可以让程序运行前打印出用户手动设置或者JVM自动设置的XX选项

  • -XX:+PrintFlagsInitial

  • **-XX:+PrintFlagsFinal**:表示打印出XX选项在运行程序时生效的值

    • 如果值的前面加上了:=,说明该值不是初始值,该值可能被jvm自动改变了,也可能被我们设置的参数改变了,如下所示:

      img

    • 有一些被改变的值是项目在启动过程中,系统帮我们修改的

    • 注意区别:

      • -XX:+PrintFlagsFinal是打印出所有XX选项在运行程序时生效的值

      • jinfo -flag 参数名称 进程id**:查看某个java进程的具体**参数信息

        • 进程id可以通过jps命令查看具体操作如下:(其中3540代表进程id)

          image-20210713001051530

  • -XX:+PrintVMOptions:打印JVM的参数

2、堆、栈、方法区等内存大小设置
1、栈
  • -Xss128k
    • 设置每个线程的栈大小为128k
    • 等价于-XX:ThreadStackSize
2、堆内存
  • -Xms3550m
    • 等价于-XX:InitialHeapSize,设置JVM初始堆内存为3500M
  • -Xmx3550m
    • 等价于-XX:MaxHeapSize,设置JVM最大堆内存为3500M
  • -Xmn2g
    • 设置年轻代大小为2G,即等价于-XX:NewSize=2g -XX:MaxNewSize=2g,也就是设置年轻代初始值和年轻代最大值都是2G
    • 官方推荐配置为整个堆大小的3/8
  • -XX:NewSize=1024m
    • 设置年轻代初始值为1024M
  • -XX:MaxNewSize=1024m
    • 设置年轻代最大值为1024M
  • -XX:SurvivorRatio=8
    • 设置年轻代中Eden区与一个Survivor区的比值,默认为8
    • 只有显示使用Eden区和Survivor区的比例,才会让比例生效,否则比例都会自动设置,至于其中的原因,请看下面的-XX:+UseAdaptiveSizePolicy中的解释,最后推荐使用默认打开的-XX:+UseAdaptiveSizePolicy设置,并且不显示设置-XX:SurvivorRatio
  • -XX:+UseAdaptiveSizePolicy
    • 自动选择各区大小比例,默认开启
    • 分析
      • 默认开启,将会导致Eden区和Survivor区的比例自动分配,因此也会引起我们默认值-XX:SurvivorRatio=8失效,所以真实比例可能不是8,比如可能是6等
    • 如何设置Eden区和Survivor区的比例:-XX:SurvivorRatio=8
      1. 显示使用显示使用Eden区和Survivor区的比例,那就使用我自己的
      2. 没有显示使用Eden区和Survivor区的比例,无论打开或者关闭-XX:+UseAdaptiveSizePolicy,都会自动设置Eden区和Survivor区的比例
    • 结论:
      • 只有显示使用Eden区和Survivor区的比例,才会让比例生效,否则比例都会自动设置,最后推荐使用默认打开的-XX:+UseAdaptiveSizePolicy设置,并且不显示设置-XX:SurvivorRatio
  • -XX:NewRatio=2
    • 设置老年代与年轻代(包括1个Eden区和2个Survivor区)的比值,默认为2
    • 根据实际情况进行设置,主要根据对象生命周期来进行分配,如果对象生命周期很长,那么让老年代大一点,否则让新生代大一点
  • -XX:PretenureSizeThreadshold=1024
    • 设置让大于此阈值的对象直接分配在老年代,单位为字节
    • 只对Serial、ParNew收集器有效
    • 不好控制
  • -XX:MaxTenuringThreshold=15
    • 默认值为15
    • 新生代每次MinorGC后,还存活的对象年龄+1,当对象的年龄大于设置的这个值时就进入老年代
  • -XX:+PrintTenuringDistribution
    • 让JVM在每次MinorGC后打印出当前使用的Survivor中对象的年龄分布
  • -XX:TargetSurvivorRatio
    • 表示MinorGC结束后Survivor区域中占用空间的期望比例
3、方法区
  • 永久代
    • -XX:PermSize=256m
      • 设置永久代初始值为256M
    • -XX:MaxPermSize=256m
      • 设置永久代最大值为256M
  • 元空间
    • -XX:MetaspaceSize
      • 初始空间大小
    • -XX:MaxMetaspaceSize
      • 最大空间,默认没有限制
    • -XX:+UseCompressedOops
      • 使用压缩对象指针
    • -XX:+UseCompressedClassPointers
      • 使用压缩类指针
    • -XX:CompressedClassSpaceSize
      • 设置Klass Metaspace的大小,默认1G
4、直接内存
  • -XX:MaxDirectMemorySize
    • 指定DirectMemory容量,若未指定,则默认与Java堆最大值一样
3、OutOfMemory相关的选项
  • -XX:+HeapDumpOnOutMemoryError

    • 表示在内存出现OOM的时候,生成Heap转储文件,以便后续分析
    • -XX:+HeapDumpBeforeFullGC-XX:+HeapDumpOnOutMemoryError只能设置1个
  • -XX:+HeapDumpBeforeFullGC

    • 表示在出现FullGC之前,生成Heap转储文件,以便后续分析
    • -XX:+HeapDumpBeforeFullGC-XX:+HeapDumpOnOutMemoryError只能设置1个
    • 请注意FullGC可能出现多次,那么dump文件也会生成多个,而OOM只能有一次,所以-XX:+HeapDumpOnOutMemoryError生成的dump文件只有一个
  • -XX:HeapDumpPath=<path>

    • 指定heap转存文件的存储路径,如果不指定,就会将dump文件放在当前目录中
  • -XX:OnOutOfMemoryError

    • 指定一个可行性程序或者脚本的路径,当发生OOM的时候,去执行这个脚本

    • 对OnOutOfMemoryError的运维处理:

      • 以部署在linux系统/opt/Server目录下的Server.jar为例:

        1. 在run. sh启动脚本中添加jvm参数:-XX:OnOutOfMemoryError=/opt/Server/restart.sh

        2. restart.sh脚本:

          • linux环境:

            1
            2
            3
            4
            #!/bin/bash
            pid=$(ps -eflgrep Server.jar|awk '{if($8=="java") {print $2}}')
            kill -9 $pid
            cd /opt/Server/;sh run.sh
          • Windows环境:

            1
            2
            3
            4
            echo off
            wmic process where Name='java.exe' delete
            cd D:\Server
            start run.bat
4、垃圾收集器相关选项

7款经典收集器与垃圾分代之间的关系:

image-20210429024547757

垃圾收集器的组合关系:

image-20210429023609239

  1. 两个收集器间有连线,表明它们可以搭配使用:

    Serial/Serial Old、Serial/CMS、ParNew/Serial Old、ParNew/CMS、Parallel Scavenge/Serial Old、Parallel Scavenge/Parallel Old、G1;

  2. 其中Serial Old作为CMS出现”Concurrent Mode Failure” 失败的后备预案。

  3. (红色虚线)由于维护和兼容性测试的成本,在JDK 8时将Serial+CMS、ParNew+Serial Old这两个组合声明为废弃(JEP 173),并在JDK 9中完全取消了这些组合的支持(JEP214) ,即:移除。

  4. (绿色虚线)JDK 14中:弃用Parallel Scavenge和Serial Old GC组合(JEP366)

  5. (青色虚线)JDK 14中:删除CMS垃圾回收器 (JEP 363)

1、查看默认的垃圾回收器
  • -XX:+PrintCommandLineFlags:查看命令行相关参数(包含使用的垃圾收集器)
  • 使用命令行指令: jinfo - flag 相关垃圾回收器参数进程ID

以上两种方式都可以查看默认使用的垃圾回收器,第一种方式更加准备,但是需要程序的支持;第二种方式需要去尝试,如果使用了,返回的值中有+号(使用),否则就是-号(没使用)。

2、Serial回收器
  • Serial收集器作为HotSpot中Client模式下的默认新生代垃圾收集器。
  • Serial Old是运行在Client模式下默认的老年代的垃圾回收器。
  • -XX:+UseSerialGC
    • 指定年轻代和老年代都使用串行收集器。等价于新生代用Serial GC,且老年代用Serial Old GC。 可以获得最高的单线程收集效率。
3、Parnew回收器
  • -XX: +UseParNewGC
    • 手动指定使用ParNew收集器执行内存回收任务。它表示年轻代使用并行收集器,不影响老年代。
  • -XX:ParallelGCThreads
    • 设置年轻代并行收集器的线程数。一般地, 最好与CPU数量相等,以避免过多的线程数影响垃圾收集性能。
    • 在默认情况下,当CPU数量小于8个, ParallelGCThreads 的值等于CPU数量。
    • 当CPU数量大于8个,ParallelGCThreads的值等于3 + [5 * CPU_Count] / 8]

注意:根据下图可知,该回收器最终将会没有搭档,那就相当于被遗弃了(JDK14以后)

image-20210429023609239

4、Parallel回收器
  • -XX:+UseParallelGC
    • 手动指定年轻代使用Parallel并行收集器执行内存回收任务。
  • -XX: +UseParallelOldGC
    • 手动指定 =老年代都是使用并行回收收集器。
      • 分别适用于新生代和老年代。默认jdk8是开启的。
      • 上面两个参数,默认开启一个, 另一个也会被开启。(互相激活)
  • -XX:ParallelGCThreads
    • 设置年轻代并行收集器的线程数。一般地,最好与CPU数量相等,以避免过多的线程数影响垃圾收集性能。
    • 在默认情况下,当CPU 数量小于8个,ParallelGCThreads 的值等于CPU数量
    • 当CPU数量大于8个,ParallelGCThreads的值等于3 + [5 * CPU_Count] / 8]
  • -XX:MaxGCPauseMillis
    • 设置垃圾收集器最大停顿时间(即STW的时间)。单位是毫秒。
    • 为了尽可能地把停顿时间控制在MaxGCPauseMills以内,收集器在工作时会调整Java堆大小或者其他一些参数。
    • 对于用户来讲,停顿时间越短体验越好。但是在服务器端,我们注重高并发,整体的吞吐量。所以服务器端适合Parallel,进行控制。
    • 该参数使用需谨慎。
  • -XX:GCTimeRatio
    • 垃圾收集时间占总时间的比例(= 1 / (N + 1))。用于衡量吞吐量的大小。
    • 取值范围(0,100) 。默认值99,也就是垃圾回收时间不超过1%。
    • 与前一个-XX:MaxGCPauseMillis参数有一定矛盾性。暂停时间越长,Radio参数就容易超过设定的比例。
  • -XX: +UseAdaptiveSizePolicy
    • 设置Parallel Scavenge收集器具有自适应调节策略
    • 在这种模式下,年轻代的大小、Eden 和Survivor的比例、晋升老年代的对象年龄等参数会被自动调整,已达到在堆大小、吞吐量和停顿时间之间的平衡点。
    • 在手动调优比较困难的场合,可以直接使用这种自适应的方式,仅指定虚拟机的最大堆、目标的吞吐量(GCTimeRatio) 和停顿时间(MaxGCPauseMills) ,让虚拟机自已完成调优工作。

注意:

  1. Parallel回收器主打吞吐量,而CMS和G1主打低延迟如果主打吞吐量,那么就不应该限制最大停顿时间,所以-XX:MaxGCPauseMills不应该设置
  2. -XX:MaxGCPauseMills中的调整堆大小通过默认开启的-XX:+UseAdaptiveSizePolicy来实现
  3. -XX:GCTimeRatio用来衡量吞吐量,并且和-XX:MaxGCPauseMills矛盾,因此不会同时使用
5、CMS回收器
  • -XX:+UseConcMarkSweepGC
    • 手动指定使用CMS收集器执行内存回收任务。
    • 开启该参数后会自动将-XX:+UseParNewGC打开。
    • 即:ParNew(Young区用)+CMS(Old区用)+Serial Old的组合。
  • -XX:CMS1nitiatingOccupanyFraction
    • 设置堆内存使用率的阅值,一旦达到该阈值,便开始进行回收。
    • JDK5及以前版本的默认值为68,即当老年代的空间使用率达到68%时,会执行一次CMS回收。**JDK6及以上版本默认值为92%**。
    • 如果内存增长缓慢,则可以设置一个稍大的值,大的阈值可以有效降低CMS的触发频率,减少老年代回收的次数可以较为明显地改善应用程序性能。反之,如果应用程序内存使用率增长很快,则应该降低这个阅值,以避免频繁触发老年代串行收集器。因此通过该选项便可以有效降低Full GC的执行次数
  • -XX:+UseCMSCompactAtFullCollection
    • 用于指定在执行完Full GC后对内存空间进行压缩整理,以此避免内存碎片的产生。不过由于内存压缩整理过程无法并发执行,所带来的问题就是停顿时
      间变得更长了。
  • -XX:CMSFullGCsBeforeCompaction
    • 设置在执行多少次Full GC后对内存空间进行压缩整理。
  • -XX:ParallelCMSThreads
    • 设置CMS的线程数量。
    • CMS默认启动的线程数是**(ParallelGCThreads + 3) / 4**,ParallelGCThreads 是年轻代并行收集器的线程数。当CPU 资源比较紧涨时,受到CMS收集器线程的影响,应用程序的性能在垃圾回收阶段可能会非常糟糕。

-XX:ParallelCMSThreads和ParallelGCThreads有关系,ParallelGCThreads在上面Parnew回收器中有提到

另外,CMS收集器还有如下常用参数:

  • -XX:ConcGCThreads
    • 设置并发垃圾收集的线程数,默认该值是基于ParallelGCThreads计算出来的;
  • -XX:+UseCMSInitiatingOccupancyOnly
    • 是否动态可调,用这个参数可以使CMS直按CMSInitiatingOccupancyFraction设定的值启动
  • -XX:+CMSScavengeBeforeRemark
    • 强制hotspot 虚拟机在cms remark阶段之前做一次minor gc,用于提高remark阶段的速度
  • -XX:+CMSClassUnloadingEnable
    • 如果有的话,启用回收Perm区(JDK8之前)
  • -XX:+CMSParallelInitialEnabled
    • 用于开启CMS initial-mark阶段采用多线程的方式进行标记,用于提高标记速度,在Java8开始已经默认开启;
  • -XX:+CMSParallelRemarkEnbled
    • 用户开启CMS remark阶段采用多线程的方式进行重新标记。默认开启;
  • -XX:+ExplicitGCInvokesConcurrent-XX:+ExplicitGCInvokesConcurrentAndUnloadsClasses
    • 这两个参数用户指定hotspot虚拟在执行System.gc()时使用CMS周期;
  • -XX:+CMSPrecleaningEnabled
    • 指定CMS是否需要进行Pre cleaning这个阶段

特别说明:

  • JDK9新特性:CMS被标记为Deprecate 了(JEP291)
    • 如果对JDK 9及以上版本的HotSpot虚拟机使用参数-XX:+UseConcMarkSweepGC来开启CMS收集器的话,用户会收到一个警告信息,提示
      CMS未来将会被废弃。
  • JDK14新特性:删除CMS垃圾回收器(JEP363)
    • 移除了CMS垃圾收集器,如果在JDK14中使用-XX:+UseConcMarkSweepGC的话JVM不会报错,只是给出一个warning信息,但是不会exit。JVM会自动回退以默认GC方式启动JVM
    • OpenJDK 64-Bit Server VM warning: Ignoring option UseConcMarkSweepGc;support was removed in 14.0 and the VM will continue execution using the default collector.
6、G1回收器
  • -XX:+UseG1GC
    • 手动指定使用G1收集器执行内存回收任务。
  • -XX:G1HeapRegionSize
    • 设置每个Region的大小。值是2的幂,范围是1MB到32MB之间,目标是根据最小的Java堆大小划分出约2048个区域。默认是堆内存的1/2000
  • -XX: MaxGCPauseMillis
    • 设置期望达到的最大GC停顿时间指标(JVM会尽力实现,但不保证达到)。默认值是200ms
    • -XX: ParallelGCThread
      • 设置STW时GC线程数的值。最多设置为8
  • -XX:ConcGCThreads
    • 设置并发标记的线程数。将n设置为并行垃圾回收线程数(ParallelGCThreads)的1/4左右
  • -XX:InitiatingHeapoccupancyPercent
    • 设置触发并发GC周期的Java堆占用率阙值。超过此值,就触发GC。默认值是45
  • -XX: G1NewSizePercent-XX:G1MaxNewSizePercent
    • 新生代占用整个堆内存的最小百分比(默认5%)、最大百分比( 默认60%)
  • -XX:G1ReservePercent=10
    • 保留内存区域,防止to space (Survivor中的to区)溢出

注意:

  • 如果使用G1垃圾收集器,不建议设置-Xmn-XX:NewRatio,毕竟可能影响G1的自动调节

Mixed GC调优参数:

  • 注意:
    • G1收集器主要涉及到Mixed GC,Mixed GC 会回收young区和部分old区
  • G1关于MixedGC调优常用参数:
    • -XX:InitiatingHeapOccupancyPercent
      • 设置堆占用率的百分比(0到100)达到这个数值的时候触发global concurrent marking (全局并发标记),默认为45%**。值为0表示间断进行全局并发标记**。
    • -XX:G1MixedGCLiveThresholdPercent
      • 设置Old区的region被回收时候的对象占比,**默认占用率为85%**。只有Old区的region中存活的对象占用达到了这个百分比才会在Mixed GC中被回收。
    • -XX:G1HeapWastePercent
      • 在global concurrent marking (全局并发标记)结束之后,可以知道所有的区有多少空间要被回收,在每次young GC之后和再次发生Mixed GC之前,会检查垃圾占比是否达到此参数,只有达到了,下次才会发生Mixed GC。
    • -XX:G1MixedGCCountTarget
      • 一次global concurrent marking (全局并发标记)之后,最多执行Mixed GC的次数,默认是8
    • -XX:G1OldCSetRegionThresholdPercent
      • 设置Mixed GC收集周期中要收集的Old region数的 上限。默认值是Java堆的10%
7、怎么选择垃圾收集器
  • 优先调整堆的大小让JVM自适应完成。
  • 如果内存小于100M,使用串行收集器
  • 如果是单核、单机程序,并且没有停顿时间的要求,串行收集器
  • 如果是多CPU、需要高吞吐量、允许停顿时间超过1秒,选择并行或者JVM自己选择
  • 如果是多CPU、追求低停顿时间,需快速响应(比如延迟不能超过1秒,如互联网应用),使用并发收集器。官方推荐G1,性能高。现在互联网的项目,基本都是使用G1

特别说明:

  1. 没有最好的收集器,更没有万能的收集;
  2. 调优永远是针对特定场景、特定需求,不存在一劳永逸的收集器
5、GC日志相关选项
1、常用参数
  • -verbose:gc

    • 输出日志信息,默认输出的标准输出

    • 可以独立使用

      image-20210719003357660

  • -XX:+PrintGC

    • 等同于-verbose:gc表示打开简化的日志

    • 可以独立使用

      image-20210719003357660

  • **-XX:+PrintGCDetails**:

    • 在发生垃圾回收时打印内存回收详细的日志,并在进程退出时输出当前内存各区域的分配情况

    • 可以独立使用

      image-20210719003601948

  • **-XX:+PrintGCTimeStamps**:

    • 程序启动到GC发生的时间秒数

    • 不可以独立使用,需要配合-XX:+PrintGCDetails使用

      img

  • -XX:+PrintGCDateStamps

    • 输出GC发生时的时间戳(以日期的形式,例如:2013-05-04T21:53:59.234+0800)

    • 不可以独立使用,可以配合-XX:+PrintGCDetails使用

      img

  • -XX:+PrintHeapAtGC

    • 每一次GC前和GC后,都打印堆信息

    • 可以独立使用

    • 若配合**-XX:+PrintGCDetails**一起使用的话,可以将两个命令的结果结合在一起:每一次GC前和GC后,都打印堆信息+在进程退出时输出当前内存各区域的分配情况

      img

  • **-XIoggc:<file>**:

    • 把GC日志写入到一个文件中去,而不是打印到标准输出中

      img

2、其他参数
  • -XX:TraceClassLoading

    • 监控类的加载
  • -XX:PrintGCApplicationStoppedTime

    • 打印GC时线程的停顿时间

      image-20210719004842828

  • -XX:+PrintGCApplicationConcurrentTime

    • 垃圾收集之前打印出应用未中断的执行时间
  • -XX:+PrintReferenceGC

    • 记录回收了多少种不同引用类型的引用
  • -XX:+PrintTenuringDistribution

    • 让JVM在每次MinorGC后打印出当前使用的Survivor中对象的年龄分布
  • -XX:+UseGCLogFileRotation

    • 启用GC日志文件的自动转储
  • -XX:NumberOfGCLogFiles=1

    • GC日志文件的循环数目
  • -XX:GCLogFileSize=1M

    • 控制GC日志文件的大小
6、其他参数
  • -XX:+DisableExplicitGC
    • 禁用hotspot执行System.gc(),默认禁用
  • -XX:ReservedCodeCacheSize=<n>[g|m|k]-XX:InitialCodeCacheSize=<n>[g|m|k]
    • 指定代码缓存的大小
  • -XX:+UseCodeCacheFlushing
    • 使用该参数让jvm放弃一些被编译的代码,避免代码缓存被占满时JVM切换到interpreted-only的情况
  • -XX:+DoEscapeAnalysis
    • 开启逃逸分析
  • -XX:+UseBiasedLocking
    • 开启偏向锁
  • -XX:+UseLargePages
    • 开启使用大页面
  • -XX:+PrintTLAB
    • 打印TLAB的使用情况
  • -XX:TLABSize
    • 设置TLAB大小

4、通过Java代码获取JVM参数

Java提供了java.lang.management包用于监视和管理Java虚拟机和Java运行时中的其他组件,它允许本地和远程监控和管理运行的Java虛拟机。其中ManagementFactory这个类还是挺常用的。另外还有Runtime类也可以获取一些内存、CPU核数等相关的数据。

通过这些api可以监控我们的应用服务器的堆内存使用情况,设置一些阈值进行报警等处理。

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
*
* 监控我们的应用服务器的堆内存使用情况,设置一些阈值进行报警等处理
*/
public class MemoryMonitor {
public static void main(String[] args) {
MemoryMXBean memorymbean = ManagementFactory.getMemoryMXBean();
MemoryUsage usage = memorymbean.getHeapMemoryUsage();
System.out.println("INIT HEAP: " + usage.getInit() / 1024 / 1024 + "m");
System.out.println("MAX HEAP: " + usage.getMax() / 1024 / 1024 + "m");
System.out.println("USE HEAP: " + usage.getUsed() / 1024 / 1024 + "m");
System.out.println("\nFull Information:");
System.out.println("Heap Memory Usage: " + memorymbean.getHeapMemoryUsage());
System.out.println("Non-Heap Memory Usage: " + memorymbean.getNonHeapMemoryUsage());

System.out.println("=======================通过java来获取相关系统状态============================ ");
System.out.println("当前堆内存大小totalMemory " + (int) Runtime.getRuntime().totalMemory() / 1024 / 1024 + "m");// 当前堆内存大小
System.out.println("空闲堆内存大小freeMemory " + (int) Runtime.getRuntime().freeMemory() / 1024 / 1024 + "m");// 空闲堆内存大小
System.out.println("最大可用总堆内存maxMemory " + Runtime.getRuntime().maxMemory() / 1024 / 1024 + "m");// 最大可用总堆内存大小

}
}

在上篇可以通过Runtime获取:

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class HeapSpaceInitial {
public static void main(String[] args) {
//返回Java虚拟机中的堆内存总量
long initialMemory = Runtime.getRuntime().totalMemory() / 1024 / 1024;
//返间Java虚拟机试图使用的最大堆内存量
long maxMemory = Runtime.getRuntime().maxMemory() / 1024 / 1024;

System.out.print1n("-Xms :" + initialMemory + "M");
System.out.println("-Xmx :" + maxMemory + "M");

System.out.println("系统内存大小为:" + maxMemory * 4.0 / 1024 + "G");
System.out.println("系统内存大小为:" + initialMemory * 64.0 / 1024 + "G");
}
}

5、分析GC日志

1、GC日志参数

同上面第4、JVM运行时参数中的第5点、GC日志相关选项一致

2、GC日志格式

1、复习:GC分类

针对 HotSpot VM 的实现,它里面的 GC 按照回收区域又分为两大种类型:一种是部分收集(Partial GC),一种是整堆收集(Full GC)

  • 部分收集(Partial GC):不是完整收集整个 Java 堆的垃圾收集。其中又分为:
    • 新生代收集(Minor GC / Young GC):只是新生代(Eden / S0, S1)的垃圾收集
    • 老年代收集(Major GC / Old GC):只是老年代的垃圾收集。
      • 目前,只有 CMS GC 会有单独收集老年代的行为。
      • 注意:
        • 在进行Major GC之前,系统会先进行一次Minor GC
        • 很多时候 Major GC 会和 Full GC 混淆使用,需要具体分辨是老年代回收还是整堆回收。
    • 混合收集(Mixed GC):收集整个新生代以及部分老年代的垃圾收集。
      • 目前,只有 G1 GC 会有这种行为
  • 整堆收集(Full GC):收集整个 java 堆和方法区的垃圾收集。

那些情况会触发Full GC呢?

  • 老年代空间不足
  • 方法区空间不足
  • 显式调用System.gc()
  • Minor GC进入老年代的数据的平均大小 大于 老年代的可用内存
  • 大对象直接进入老年代,而老年代的可用空间不足
2、GC日志分类
1、Minor GC

MinorGC(或 young GC 或 YGC)日志:

1
[GC (Allocation Failure) [PSYoungGen: 31744K->2192K (36864K) ] 31744K->2200K (121856K), 0.0139308 secs] [Times: user=0.05 sys=0.01, real=0.01 secs]

image-20210719011748465

image-20210719011823127

2、Full GC
1
[Full GC (Metadata GC Threshold) [PSYoungGen: 5104K->0K (132096K) ] [Par01dGen: 416K->5453K (50176K) ]5520K->5453K (182272K), [Metaspace: 20637K->20637K (1067008K) ], 0.0245883 secs] [Times: user=0.06 sys=0.00, real=0.02 secs]

image-20210719011929852

image-20210719012024393

3、GC日志结构剖析
1、透过日志看垃圾收集器
  • 使用Serial收集器新生代的名字是Default New Generation,因此显示的是”[DefNew
  • 使用ParNew收集器新生代的名字会变成”[ParNew“,意思是”Parallel New Generation’
  • 使用Parallel Scavenge收集器新生代的名字是”[PSYoungGen“,这里的JDK1.7使用的就是PSYoungGen
  • 使用Parallel Old Generation收集器老年代的名字是”[ParOldGen
  • 使用G1收集器的话,会显示为”garbage-first heap
2、透过日志看 GC 原因
  • Allocation Failure:表明本次引起GC的原因是因为在年轻代中没有足够的空间能够存储新的数据了
  • Metadata GCThreshold:Metaspace 区不够用了
  • FErgonomics:JVM 自适应调整导致的 GC
  • System:调用了 System.gc()方法
3、透过日志看 GC 前后情况

通过图示,我们可以发现 GC 日志格式的规律一般都是:GC 前内存占用 -> GC 后内存占用(该区域内存总大小)

1
[PSYoungGen: 5986K->696K (8704K) ] 5986K->704K (9216K)
  • 中括号内:GC 回收前年轻代堆大小,回收后大小,(年轻代堆总大小)
  • 括号外:GC 回收前年轻代和老年代大小,回收后大小,(年轻代和老年代总大小)

注意:

  • Minor GC 堆内存总容量 = 9/10 年轻代 + 老年代。
    • 原因是 Survivor 区只计算 from 部分,而 JVM 默认年轻代中 Eden 区和 Survivor 区的比例关系,Eden:S0:S1=8:1:1。
4、透过日志看 GC 时间

GC 日志中有三个时间:usersysreal(结果采用四舍五入的形式)

  • user:进程执行用户态代码(核心之外)所使用的时间。这是执行此进程所使用的实际 CPU 时间,其他进程和此进程阻塞的时间并不包括在内。在垃圾收集的情况下,表示 GC 线程执行所使用的 CPU 总时间
  • sys:进程在内核态消耗的 CPU 时间,即在内核执行系统调用或等待系统事件所使用的 CPU 时间
  • real程序从开始到结束所用的时钟时间。这个时间包括其他进程使用的时间片和进程阻塞的时间(比如等待 I/O 完成)。对于并行 gc,这个数字应该接近(用户时间+系统时间)除以垃圾收集器使用的线程数

由于多核的原因,一般的 GC 事件中,real time 是小于 sys time + user time 的,因为一般是多个线程并发的去做 GC,所以 real time 是要小于 sys + user time 的。如果 real > sys + user 的话,则你的应用可能存在下列问题:IO 负载非常重或 CPU 不够用。

4、Minor GC日志解析

-XX:+PrintGCTimeStamps + -XX:+PrintGCDateStamps + -XX:+PrintGCDetails

1
2020-11-20T17:19:43.265-0800:0.822: [GC (ALLOCATION FAILURE) [PSYOUNGGEN:76800K->8433K(89600K)] 76800K -> 8449K(294400K), 0.0088371 SECS] [TIMES:USER=0.02 SYS=0.01, REAL=0.01 SECS]
  • 2020-11-20T17:19:43.265-0800
    • 日志打印时间日期格式如:2013-05-04T21:53:59.234+0800
  • 0.822
    • gc发生时,Java虛拟机启动以来经过的秒数
  • [GC (Allocation Failure)
    • 发生了一次垃圾回收,这是一次Minor GC。它不区分新生代GC还是老年代GC,括号里的内容是gc发生的原因,这里的Allocation Failure的原因是新生代中没有足够区域能够存放需要分配的数据而失败
    • 除此之外还有:
      • Metadata GCThreshold:Metaspace 区不够用了
      • FErgonomics:JVM 自适应调整导致的 GC
      • System:调用了 System.gc()方法
  • [PSYoungGen:76800K->8433K(89600K)]
    • PSYoungGen:表示GC发生的区域,区域名称与使用的GC收集器是密切相关的
      • Serial收集器:Default New Generation显示DefNew
      • ParNew收集器:ParNew
      • Parallel Scanvenge收集器:PSYoungGen
      • 老年代和新生代同理,也是和收集器名称相关
    • 76800K->8433K(89600K):GC前该内存区域已使用容量 -> GC后该区域容量(该区域总容量)
      • 如果是新生代,总容量则会显示整个新生代内存的9/10,即eden + from区(默认SurvivorRatio = 8)
      • 如果是老年代,总容量则是全部内存大小,无变化
  • 76800K -> 8449K(294400K)
    • 在显示完区域容量GC的情况之后,会接着显示整个堆内存区域的GC情况:GC前堆内存已使用容量 -> GC堆内存容量(堆内存总容量)
    • 堆内存总容量 = 9/10新生代+老年代 < 初始化的内存大小.
  • 0.0088371 secs]
    • 整个GC所花费的时间,单位是秒
  • [Times:user=0.02 sys=0.01, real=0.01 secs]
    • user:指的是CPU工作在用户态所花费的时间
    • sys:指的是CPU工作在内核态所花费的时间
    • real:指的是在此次GC事件中所花费的总时间
5、Full GC日志解析

-XX:+PrintGCTimeStamps + -XX:+PrintGCDateStamps + -XX:+PrintGCDetails

1
2
3
2020-11-20T17:19:43.794-0800:1.351:[FULL GC (METADATA GC THRESHOLD)[PSYOUNGGEN:10082K -> 0K(89600K)][PAROLDGEN:32K -> 9638K(204800K)] 
10114K -> 9638K(294400K),[METASPACE:20158K -> 20156K(1067008K)], 0.0285388 SECS]
[TIMES: USER=0.11 SYS=0.00, REAL=0.03 SECS]
  • 2020-11-20T17:19:43.794-0800
    • 日志打印时间日期格式如:2013-05-04T21:53:59.234+0800
  • 1.351
    • gc发生时,Java虛拟机启动以来经过的秒数
  • [Full GC (Metadata GC Threshold)
    • 发生了一次垃圾回收,这是一次FULL GC。它不区分新生代GC还是老年代GC
    • 括号里的内容是gc发生的原因,这里的MetadataGC Threshold的原因是Metaspace区不够用了。
      • Full GC (Ergonomics):JVM自适应调整导致的GC
      • Full GC (System):调用了System.gc()方法
  • [PSYoungGen:10082K -> 0K(89600K)]
    • PSYoungGen:表示GC发生的区域,区域名称与使用的GC收集器是密切相关的
      • Serial收集器:Default New Generation显示DefNew
      • ParNew收集器:ParNew
      • Parallel Scanvenge收集器:PSYoungGen
      • 老年代和新生代同理,也是和收集器名称相关
    • 10082K -> 0K(89600K):GC前该内存区域已使用容量 -> GC后该区域容量(该区域总容量)
      • 如果是新生代,总容量则会显示整个新生代内存的9/10,即eden + from区(默认SurvivorRatio = 8)
      • 如果是老年代,总容量则是全部内存大小,无变化
  • [ParOldGen:32K -> 9638K(204800K)]
    • 老年代区域没有发生GC,因为本次GC是metaspace引起的
  • 10114K -> 9638K(294400K)
    • 在显示完区域容量GC的情况之后,会接着显示整个堆内存区域的GC情况:GC前堆内存已使用容量 -> GC堆内存容量(堆内存总容量)
    • 堆内存总容量 = 9/10新生代+老年代 < 初始化的内存大小.
  • [Metaspace:20158K -> 20156K(1067008K)]
    • metaspace GC回收2K空间
  • 0.0285388 secs]
    • 整个GC所花费的时间,单位是秒
  • [Times:user=0.11 sys=0.00, real=0.03 secs]
    • user:指的是CPU工作在用户态所花费的时间
    • sys:指的是CPU工作在内核态所花费的时间
    • real:指的是在此次GC事件中所花费的总时间
6、G1 GC的日志分析

参考博客

参考博客

3、GC日志分析工具

1、GCEasy
  • 基本概述:
    • GCEasy 是一款在线的 GC 日志分析器,可以通过 GC 日志分析进行内存泄露检测、GC 暂停原因分析、JVM 配置建议优化等功能,大多数功能是免费的。(当然有一些服务还是收费的)
  • 下载安装:官网地址

选择需要分析的log文件 -> 点击Analyze -> 可以点击Download将分析结果下载下来进行离线分析

image-20210719024156110

相关分析:

  • 案例1:MetaspaceOOM

    image-20210719025413453

  • 案例2:老年代满了导致堆OOM

    image-20210719025515161

2、GCViewer
  • 基本概述
    • GCViewer是一个免费的、开源的分析小工具,用于可视化查看由SUN/Oracle、IBM、HP和BEA Java虚拟机产生的垃圾收集器的日志。
    • GCViewer 是一款离线的 GC 日志分析器,用于可视化 Java VM 选项 -verbose:gc 和 .NET 生成的数据 -Xloggc:。还可以计算与垃圾回收相关的性能指标(吞吐量、累积的暂停、最长的暂停等)。
    • 当通过更改世代大小或设置初始堆大小来调整特定应用程序的垃圾回收时,此功能非常有用
  • 下载安装:
    • 源码下载
    • 运行版本下载
    • 下载之后只需双击gcviewer-1.3x. jar或运行java -jar gcviewer-1.3x.jar (它需要运行java1.8 vm),即可启动GCViewer (gui)
3、其他工具
  • GChisto
    • 基本概述:GChisto是一款专业分析gc日志的工具,可以通过gc日志来分析:MinorGC、Full GC的次数、频率、持续时间等,通过列表、报表、图表等不同形式来反应gc的情况。
    • 虽然界面略显粗糙,但是功能还是不错的。
    • 官网上没有下载的地方,需要自己从 SVN 上拉下来编译
    • 不过这个工具似乎没怎么维护了,存在不少 bug
  • HPjmeter
    • 工具很强大,但是只能打开由以下参数生成的 GC log,-verbose:gc -Xloggc:gc.log。添加其他参数生成的 gc.log 无法打开
    • HPjmeter 集成了以前的 HPjtune 功能,可以分析在 HP 机器上产生的垃圾回收日志文件

6、OOM常见各种场景及解决方案

1、案例1:堆溢出

2、案例2:元空间溢出

3、案例3:GC overhead limit exceeded

4、案例4:线程溢出

7、性能优化案例

1、性能测试工具:Jmeter

2、案例1:调整堆大小提高服务的吞吐量

3、案例2:调整垃圾回收器提高服务的吞吐量

4、案例3:JVM优化之JIT优化

5、案例4:G1并发执行的线程数对性能的影响

6、案例5:合理配置堆内存

7、特殊问题:新生代与老年代的比例

1、参数设置
2、参数AdaptiveSizePolicy
  • 补充

8、案例6:CPU占用很高排查方案

9、日均百万级订单交易系统如何设置JVM参数

1、现状
2、解决思路
3、参数配置

8、Java代码层及其它层面调优

9、大厂面试题

  • 支付宝:
    • 支付宝三面:JVM性能调优都做了什么?
  • 小米:
    • 有做过JVM内存优化吗?
    • 从SQL、JVM、 架构、数据库四个方面讲讲优化思路
  • 蚂蚁金服:
    • JVM的编译优化
    • JVM性能调优都做了什么
    • JVM诊断调优工具用过哪些?
    • 二面:jvm怎样调优,堆内存、栈空间设置多少合适
    • 三面:JVM相关的分析工具使用过的有哪些?具体的性能调优步骤如何
  • 阿里:
    • 如何进行JVM调优?有哪些方法?
    • 如何理解内存泄漏问题?有哪些情况会导致内存泄漏?如何解决?
  • 字节跳动:
    • 三面: JVM如何调优、参数怎么调?
  • 拼多多:
    • 从SQL、JVM、架构、数据库四个方面讲讲优化思路
  • 京东:
    • JVM诊断调优工具用过哪些?
    • 每秒几十万并发的秒杀系统为什么会频繁发生GC?
    • 日均百万级交易系统如何优化JVM?
    • 线上生产系统00M如何监控及定位与解决?
    • 高并发系统如何基于G1垃圾回收器优化性能?

参考资料:

Java引用类型:强引用,软引用,弱引用,虚引用

浅谈双亲委派机制的缺陷及打破双亲委派机制

谈谈双亲委派模型的第四次破坏——模块化

相关网站:

参数查找

SpringCloud

微服务

1、微服务架构概述

微服务架构是一种架构模式,它提倡将单一应用程序划分成一组小的服务。服务之间互相协调、互相配合,为用户提供最终价值。每个服务运行在其独立的进程中,服务与服务间采用轻量级的通信机制互相协作(通常是基于HTTP协议的RESTful API)。每个服务都围绕着具体业务进行构建,并且能够被独立的部署到生产环境、类生产环境等。另外,应当尽量避免统一的、集中式的服务管理机制,对具体的一个服务而言,应根据业务上下文,选择合适的语言、工具对其进行构建。

2、微服务架构编码构建

Rest微服务工程搭建:

  1. 约定>配置>编码

  2. IDEA新建project工作空间

    • 微服务cloud整体聚合工程
      • 父工程步骤
        • New Project
        • 聚合总父工程名字
        • Maven选版本
        • 工程名字
        • 字符编码
        • 注解生效激活
        • java编译版本选8
        • File Type过滤
      • 父工程POM
        • DependencyManagement(版本统一管理)
        • maven中跳过单元测试
      • 父工程创建完成执行mvn:insall将父工程发布到仓库方便子工程继承
  3. Rest微服务工程搭建

    • 创建公共的模块

      • 实体类emtities
        • 主实体
        • Json封装体
      • 常量类
      • 枚举类
      • 创建步骤
        • 建module
        • 改pom
        • 编写
        • 执行mvn:insall将公共工程发布到仓库方便需要的工程继承
    • 微服务提供者Module模块

      • 建module
      • 改POM
      • 写YML
      • 主启动
      • 业务类
        • 建表sql
        • dao
          • 接口
          • mybatis的映射文件(src\main\resources\mapper\XxxxxMapper.xml)
        • service
          • 接口
          • 实现类
        • controller
      • 测试(postman)
    • 热部署Devtools

    • 微服务消费者订单Module模块

      • 建module
      • 改POM
      • 写YML
      • 主启动
      • 业务类
        • config配置类
        • controller
      • 测试

3、SpringCloud简介

SpringCloud:分布式微服务架构的一站式解决方案,是多种微服务架构落

地技术的集合体,俗称微服务全家桶。

1

2

4、SpringCloud技术栈

image-20210305205540848

image-20210305205647548

3

5、版本选择

springboot:2.2.RELEASE

springcloud:Hoxton.SR1

springcloud Alibaba:2.1.0.RELEASE

java:8

Maven:3.5及以上

Mysql:5.7及以上

6、访问方式

注册中心

7、Eureka注册中心

7.1、Eureka基础知识:

  • 服务治理:SpringCloud封装了Netflix公司开发的Eureka模块来实现服

    务治理。

    在传统的rpc远程调用框架中,管理每个服务与服务之间的依赖关系比

    较复杂,管理比较复杂,所以需要使用服务治理,管理服务与服务之间

    的依赖关系,可以实现服务调用、负载均衡、容错等,实现服务的发现

    与注册。

  • 服务发现与注册:Eureka采用了CS的设计架构,Eureka Server作为服

    务注册功能的服务器,它是服务注册中心。而系统中的其他微服务,使

    用Eureka的客户端连接到Eureka Server并维持心跳连接。这样系统的

    维护人员就可以通过Eureka Server来监控系统中各个微服务是否正常

    运行。

    在服务注册与发现中,有一个注册中心。但服务器启动的时候,会把当

    前自己的服务的信息。比如:服务地址、通讯地址等以别名方式注册到

    注册中心上。另一方(消费者|服务提供者),以该别名的方式去注册

    中心上获取到实际的服务通讯地址,然后在实现本地的RPC调用。远程

    RPC调用的框架核心设计思想:在于注册中心,因为使用注册中心管理

    每个服务与服务之间的一个依赖关系(服务治理概念)。在任何远程

    RPC框架中,都会有一个注册中心(存放服务地址相关信息(接口地

    址))

  • Eureka系统架构与Dubbo架构的对比

    image-20210309193119872

    image-20210309193236970

  • Eureka的两大组件

    • Eureka Server:提供服务注册服务

      各个微服务结点通过配置启动后,会在Eureka Server中进行注

      册,这样Eureka Server中的服务注册表中将会存储所有可用服务

      注册节点的信息,服务节点的信息可以在界面中直观看到。

      主启动加注解:@EnableEurekaServer

      pom:

      1
      2
      3
      4
      <dependency>
      <groupId>org.springframework.cloud</groupId>
      <artifactId>spring-cloud-starter-netflix-eureka-server</artifactId>
      </dependency>
    • Eureka Client:通过注册中心进行访问

      是一个java客户端,用于简化Eureka Server的交互,客户端同时也

      具备一个内置的,使用轮询(round-robin)负载算法的负载均衡

      器。在启动应用后,将会向Eureka Server发送心跳(默认周期

      30s)。如果Eureka Server在多个心跳周期内没有接收到某个节点

      的心跳,Eureka Server将会从访问注册表中把这个服务节点移除

      (默认90s)

      主启动加注解:@EnableEurekaClient

      pom:

      1
      2
      3
      4
      <dependency>
      <groupId>org.springframework.cloud</groupId>
      <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
      </dependency>

7.2、工作流程:

img

解决办法: 搭建Eureka注册中心集群,实现负载均衡+故障容错

7.3、集群搭建

相互依赖,相互守望(在application.yml中配置文件互相配置)

7.4、eureka自我保护

概述:保护模式主要用于一组客户端和Eureka Server之间存在网络分区场

景下的保护。一旦进入保护模式,Eureka Server将会尝试保护其服务注册

表中的信息,不在删除服务注册表中的数据,也就是不会注销任何微服务。

image-20210309203401792

导致原因:

img

img

img

img

一句话总结:某时刻 一个微服务不可用了,Eureka不会立刻清理,依旧会对该

服务的信息进行保存。属于CAP里面的AP分支

如何禁用Eureka的保护模式:

在Eureka服务端(Eureka Server):

使用eureka.server.enable-self-preservation=false 可以禁用自我保护模式

在Eureka客户端(Eureka Client)(修改时间):

  • eureka.instance.lease-renewal-interval-in-seconds=1

    Eureka客户端向服务端发送心跳的时间间隔,单位为秒。默认是30秒

  • eureka.instance.lease-expiration-duration-in-seconds=90

    Eureka服务端在收到最后一次心跳后等待时间上限 ,单位为秒(默认是90

    秒),超时删除服务

8、Zookeeper注册中心

Zookeeper是一个分布式协调工具,可以实现注册中心功能。

依赖导入:

pom:

1
2
3
4
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-zookeeper-discovery</artifactId>
</dependency>

思考:

服务节点是临时节点还是持久节点?

临时节点。所以Zookeeper属于CAP中里的CP分支。

img

9、Consul注册中心

9.1、什么是consul

Consul是一套开源的分布式服务发现和配置管理系统,由HashiCorp公司用

Go语言开发。Consul提供了微服务系统中的服务治理、配置中心、控制总

线等功能。这些功能中的每一个都可以根据需要单独使用,也可以一起使用

以构建全方位的服务网络,总之Consul提供了一种完整服务网络解决方

案。

优点:

  • 基于raft协议,比较简洁
  • 支持健康检查,同时支持HTTP和DNS协议支持跨数据中心的WAN集群
  • 提供图形界面
  • 跨平台
  • 支持Linux、Mac、Windows

image-20210309210438031

9.2、功能

  • 服务发现:提供HTTP/DNS两种发现方式
  • 健康检测:支持多种方式,HTTP、TCP、Docker、shell脚本定制化
  • KV存储:Key、Value的存储方式
  • 多数据中心:Consul支持多数据中心
  • 可视化界面

9.3、依赖导入

pom:

1
2
3
4
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-consul-discovery</artifactId>
</dependency>

10、三个注册中心异同点

img

img

CP(Zookeeper/Consul):

当网络分区出现后,为了保证一致性,就必须拒绝请求,否则无法保证一致性

结论:违背了可用性A的要求,只满足一致性和分区容错,即CP。

image-20210307031636572

AP(Eureka):

img

CAP(分区容错性要保证,所以要么是CP,要么是AP):

  • C: Consistency(强一致性)
  • A: Availability(可用性)
  • P: Parttition tolerance(分区容错性)

CAP理论关注粒度是否是数据,而不是整体系统设计的策略

经典CAP图:

img

img

负载均衡

11、Ribbon负载均衡调用

11.1、是什么

Spring Cloud Ribbon是基于Netflix Ribbon实现的一套客户端负载均衡的

工具。

简单的说,Ribbon是Netflix发布的开源项目,主要功能是提供客户端的软

件负载均衡算法和服务调用。Ribbon客户端组件提供一系列完善的配置项

如连接超时,重试等。简单的说,就是在配置文件中列出Load

Balancer(LB)后面所有机器,Ribbon会自动的帮助你基于某种规则(如简

单轮询,随机连接等)去连接这些机器。我们很容易使用Ribbon实现自定义

的负载均衡算法。

总结: Ribbon其实就是一个软负载均衡的客户端组件, 他可以和其他所需请

求的客户端结合使用,和eureka结合只是其中一个实例.

11.2、作用(负载均衡+RestTemplate调用)

LB(负载均衡):

简单的说就是将用户的请求平摊的分配到多个服务上,从而达到系统的

HA(高可用)。

常见的负载均衡有软件Nginx,LVS,硬件F5等。

Ribbon本地负载均衡客户端 VS Nginx服务端负载均衡的区别:

  • Nginx是服务器负载均衡,客户端所有请求都会交给nginx,然后由

    nginx实现转发请求。即负载均衡是由服务端实的。

  • Ribbon本地负载均衡,在调用微服务接口的时候,会在注册中心上获

    取注册信息服务列表之后缓存到JVM本地,从而在本地实现RPC远程服务调用技术。

LB(负载均衡)可分为:

  • 集中式LB

    即在服务的消费方和提供方之间提供独立的LB设施(可以是硬件如F5,

    也可以是软件,如nginx),由该设施负责把访问请求通过某种策略转发

    至服务的提供方。

  • 进程内LB

    将LB逻辑集成到消费方,消费方从服务注册中心获知有哪些地址可用,

    然后自己再从这些地址中选择出一台合适的服务器。Ribbon就属于进

    程内LB,它只是一个类库,集成与消费方进程,消费方通过它来获取服

    务提供方的地址。

11.3、框架说明

img

Ribbon在工作时分成两步:

  • 第一步:

    先选择Eureka Server,它优先选择在同一区域内负载较少的server

  • 第二步:

    再根据用户指定的策略,再从server取到的服务注册列表中选择一个地

    址。其中Ribbon提供了多种策略:比如轮询、随机和根据响应时间加

    权。

RestTemplate的使用:

  • getForObject(String url,class)方法:返回对象为响应体中数据转化成的

    对象,基本上可以理解成Json

  • getForEntity(String url,class)方法:返回对象为ResponseEntity对象,

    包含响应中的一些重要信息,比如响应头、响应状态码、响应体等。

11.4、Ribbon核心组件IRule

11.4.1、IRule是什么

IRule:根据特定算法从服务列表中选取一个要访问的服务

img

  • com.netflix.loadbalancer.RoundRobinRule:轮询(默认)

  • com.netflix.loadbalancer.RandomRule:随机

  • com.netflix.loadbalancer.RetryRule:先按照RoundRobinRule的策略

    获取服务,如果获取服务失败则在指定时间内进行重试,获取可用的服务

  • WeightedResponseTimeRule:对RoundRobinRule的扩展,响应速度

    越快的实例选择权重越多大,越容易被选择

  • BestAvailableRule:会先过滤掉由于多次访问故障而处于断路器跳闸

    状态的服务,然后选择一个并发量最小的服务

  • AvailabilityFilteringRule:先过滤掉故障实例,再选择并发较小的实例

  • ZoneAvoidanceRule:默认规则,复合判断server所在区域的性能和

    server的可用性选择服务器

11.4.2、替换:

注意配置细节

官方文档明确给出了警告:

这个自定义配置类不能放在@ComponentScan所扫描的当前包以及子包

下,否则我们自定义的这个配置类就会被所有的Ribbon客户端所共享,达

不到特殊化定制的目的了。

img

img

@ComponentScan所扫描的当前包以及子包:因为

@SpringBootApplication注解包含了@ComponentScan注解,所以该自定

义配置类不能放在当前包(springcloud)下。

新建package:com.atguigu.myrule

上面包下新建MySelfRule规则类

主启动类添加@RibbonClient

11.4.3、Ribbon负载均衡算法

RoundRobinRule:轮询的算法思想

img

12、OenFeign

12.1、是什么?

官方解释:https://cloud.spring.io/spring-cloud-static/Hoxton.SR1/reference/htmlsingle/#spring-cloud-openfeign

Feign是一个声明式WebService客户端。使用Feign能让编写Web Service客

户端更加简单。它的使用方法是定义一个服务接口然后在上面添加注解

Feign也支持可拔插式的编码器和解码器。Spring Cloud对Feign进行了封

装,使其支持了Spring MVC标准注解和HTTPMessageConverters。Feign

可以与Eureka和Ribbon组合使用以支持负载均衡。

12.2、作用:

Feign旨在使编写Java Http客户端变得更容易。

前面在使用Ribbon+RestTemplate时,利用RestTemplate对http请求的封装处

理,形成了一套模版化的调用方法。但是在实际开发中,由于对服务依赖的调用

可能不止一处,往往一个接口会被多处调用,所以通常都会针对每个微服务自行

封装一些客户端类来包装这些依赖服务的调用。所以,Feign在此基础上做了进

一步封装,由他来帮助我们定义和实现依赖服务接口的定义。在Feign的实现下

我们只需创建一个接口并使用注解的方式来配置它(以前是Dao接口上面标

注Mapper注解,现在是一个微服务接口上面标注一个Feign注解即可),即可完

成对服务提供方的接口绑定,简化了使用Spring cloud Ribbon时,自动封装服

务调用客户端的开发量。

Feign集成了Ribbon

利用Ribbon维护了Payment的服务列表信息,并且通过轮询实现了客户端的负

载均衡。而与Ribbon不同的是,通过feign只需要定义服务绑定接口且以声明式

的方法,优雅而简单的实现了服务调用。

12.3、Feign和OpenFeign两者区别

img

12.4、使用步骤

  • 接口+注解:微服务调用接口+@FeignClient

  • Feign在消费端使用

  • pom:

    1
    2
    3
    4
    <dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-openfeign</artifactId>
    </dependency>
  • 主启动加上@EnableFeignClients注解开启Feign

  • 业务逻辑接口+@FeignClient配置调用provider服务

    @FeignClient(value = “xxx-xxx-xxx”)value的值要写服务提供方在Eureka注册的名称

  • 控制层Controller

img

12.5、OpenFeign超时控制

12.5.1、是什么?

默认Feign客户端只等待1秒钟,但是服务端处理需要超过1秒钟,导致Feign客

户端不想等待了,直接返回报错。

12.5.2、解决方法(OpenFeign默认支持Ribbon)

为了避免这样的情况,有时候我们需要设置Feign客户端的超时控制。

yml文件中开启配置

1
2
3
4
5
6
#设置feign客户端超时时间(OpenFeign默认支持ribbon)
ribbon:
#指的是建立连接所用的时间,适用于网络状况正常的情况下,两端连接所用的时间
ReadTimeout: 5000
#指的是建立连接后从服务器读取到可用资源所用的时间
ConnectTimeout: 5000

12.6、OpenFeign日志打印功能

12.6.1、是什么?

Feign提供了日志打印功能,我们可以通过配置来调整日志级别,从而了解

Feign中Http请求的细节。说白了就是对Feign接口的调用情况进行监控和

输出。

熔断器

13、Hystrix熔断器(豪猪哥)

13.1、分布式系统面临的问题

复杂分布式体系结构中的应用程序 ,有数10个依赖关系,每个依赖关系在某些时候将不可避免地失败。

img

服务雪崩:

多个微服务之间调用的时候,假设微服务A调用微服务B和微服务C,微服务B和

微服务C又调用其它的微服务,这就是所谓的“扇出”。如果扇出的链路上某个微

服务的调用响应时间过长或者不可用,对微服务A的调用就会占用越来越多的系

统资源,进而引起系统崩溃,所谓的“雪崩效应”。

对于高流量的应用来说,单一的后端依赖可能会导致所有服务器上的所有资源都

在几秒钟内饱和。比失败更糟糕的是,这些应用程序还可能导致服务之间的延迟

增加,备份队列,线程和其他系统资源紧张,导致整个系统发生更多的级联故

障。这些都表示需要对故障和延迟进行隔离和管理,以便单个依赖关系的失败,

不能取消整个应用程序或系统。所以,通常当你发现一个模块下的某个实例失败

后,这时候这个模块依然还会接收流量,然后这个有问题的模块还调用了其他的

模块,这样就会发生级联故障,或者叫雪崩。

13.2、Hystrix是什么

Hystrix是一个用于处理分布式系统的延迟容错的开源库,在分布式系统里,

许多依赖不可避免的会调用失败,比如超时、异常等,Hystrix能够保证在一个

依赖出问题的情况下,不会导致整体服务失败,避免级联故障,以提高分布式系

统的弹性

“断路器”本身是一种开关装置,当某个服务单元发生故障之后,通过断路器的故

障监控(类似熔断保险丝),向调用方返回一个符合预期的、可处理的备选响应

(FallBack),而不是长时间的等待或者抛出调用方无法处理的异常,这样就保证

了服务调用方的线程不会被长时间、不必要地占用,从而避免了故障在分布式系

统中的蔓延,乃至雪崩。

官网:https://github.com/Netflix/hystrix/wiki

13.3、作用

  • 服务降级
  • 服务熔断
  • 接近实时的监控

13.4、HyStrix重要概念

13.4.1、服务降级

服务器忙,请稍后再试,不让客户端等待并立刻返回一个友好提示,fallback

哪些情况会发出降级:

  • 程序运行异常
  • 超时
  • 服务熔断触发服务降级
  • 线程池/信号量也会导致服务降级
13.4.2、服务熔断

类比保险丝达到最大服务访问后,直接拒绝访问,拉闸限电,然后调用服务降级的方

法并返回友好提示

服务熔断的过程:

服务的降级->进而熔断->恢复调用链路

12.4.3、服务限流

秒杀高并发等操作,严禁一窝蜂的过来拥挤,大家排队,一秒钟N个,有序进行

13.5、依赖

pom:

1
2
3
4
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-hystrix</artifactId>
</dependency>

13.6、高压访问的解决方法

超时导致服务器变慢(转圈):超时不再等待

出错(宕机或程序运行出错):出错要有兜底

13.7、访问降级

降级配置@HystrixCommand

13.7.1、对于服务提供方:

设置自身调用超时时间的峰值,峰值内可以正常运行, 超过了需要有兜底的方法处

理,做服务降级fallback

一旦调用服务方法失败并抛出了错误信息后,会自动调用@HystrixCommand标‘

注好的fallbckMethod调用类中的指定方法

主启动类激活@EnableCircuitBreaker

下图故意制造两个异常:

  • int age = 10/0;计算异常
  • 我们能接受3秒钟,它运行5秒钟,超时异常。

当前服务不可用了,做服务降级,兜底的方案都是paymentInfo_TimeOutHandler

img

13.7.2、对于服务消费方:

主启动类激活@EnableHystrix

业务类

img

13.7.3、目前问题
  • 每个业务方法对应一个兜底的方法,代码膨胀
  • 统一和自定义的分开

解决方法:

  • 解决第一个问题:代码膨胀

    feign接口系列

    @DefaultProperties(defaultFallback=””)

    每个方法配置一个服务降级方法,技术上可以,实际上导致代码膨胀。

    除了个别重要核心业务有专属,其它普通的可以通过

    @DefaultProperties(defaultFallback =”)统一跳转到统一处理结果页面

    通用的和独享的各自分开,避免了代码膨胀,合理减少了代码量

    img

    img

  • 解决第二个问题:和业务逻辑混在一起

    服务降级,客户端去调用服务端,碰上服务端宕机或关闭。只需要为Feign客户

    端定义的接口添加一个服务降级处理的实现类即可实现解耦。

    我们可能面临的异常:

    • 运行
    • 超时
    • 宕机

    重新新建一个类(PaymentFallbackService)实现PaymentHystrixService接

    口(OpenFeign的服务接口),统一为接口里面的方法进行异常处理

    img

    PaymentHystrixService接口:

    image-20210315011150047

13.8、服务熔断

13.8.1、是什么?

https://martinfowler.com/bliki/CircuitBreaker.html

熔断机制概述

熔断机制是应对雪崩效应的一种微服务链路保护机制。当扇出链路的某个微服务

出错不可用或者响应时间太长时,会进行服务的降级,进而熔断该节点微服务的

调用,快速返回错误的响应信息。

检测到该节点微服务调用响应正常后,恢复调用链路

在Spring Cloud框架里,熔断机制通过Hystrix实现。Hystrix会监控微服务间调

用的状况,当失败的调用到一定國值,缺省是5秒内20次调用失败,就会启动熔

断机制。熔断机制的注解是@HystrixCommand。

12.8.2、服务熔断的注解@HystrixCommand(Service层)
1
2
3
4
5
6
@HystrixCommand(fallbackMethod = "paymentCircuitBreaker_fallback",commandProperties = {
@HystrixProperty(name = "circuitBreaker.enabled",value = "true"),// 是否开启断路器
@HystrixProperty(name = "circuitBreaker.requestVolumeThreshold",value = "10"),// 请求次数
@HystrixProperty(name = "circuitBreaker.sleepWindowInMilliseconds",value = "10000"), // 时间窗口期
@HystrixProperty(name = "circuitBreaker.errorThresholdPercentage",value = "60"),// 失败率达到多少后跳闸
})
12.8.3、大神结论

img

熔断类型:

  • 熔断打开(Open):

    请求不再调用当前服务,内部设置一般为MTTR(平均故障处理时间),当打开长达导所设时钟则进入半熔断状态

  • 熔断关闭(Closed):

    熔断关闭后不会对服务进行熔断

  • 熔断半开(Half Open):

    部分请求根据规则调用当前服务,如果请求成功且符合规则则认为当前服务恢复正常,关闭熔断

12.8.4、断路流程图

img

步骤:

img

断路器在什么情况下开始起作用

image-20210310003224243

涉及到断路器的三个重要参数:快照时间窗、请求总数阀值、错误百分比

阀值

  • 快照时间窗:断路器确定是否打开需要统计一些请求和错误数据,而统

    计的时间范围就是快照时间窗,默认为最近的10秒。

  • 请求总数阀值:在快照时间窗内,必须满足请求总数阀值才有资格熔

    断。默认为20,意味着在10秒内,如果该hystrix命令的调用次数不足

    20次,即使所有的请求都超时或其他原因失败,断路器都不会打开。

  • 错误百分比阀值:当请求总数在快照时间窗内超过了阀值,比如发生了

    30次调用,如果在这30次调用中,有15次发生了超时异常,也就是超

    过50%的错误百分比,在默认设定50%阀值情况下,这时候就会将断路

    器打开。

断路器开启或者关闭的条件:

  1. 当满足一定的阈值的时候(默认10秒钟超过20个请求次数)

  2. 当失败率达到一定的时候(默认10秒内超过50%的请求次数)

  3. 到达以上阈值,断路器将会开启

  4. 当开启的时候,所有请求都不会进行转发

  5. 一段时间之后(默认5秒),这个时候断路器是半开状态,会让其他一个请求

    进行转发. 如果成功,断路器会关闭,若失败,继续开启.重复4和5

断路器打开之后:

  1. 再有请求调用的时候,将不会调用主逻辑,而是直接调用降级

    fallback。通过断路器,实现了自动地发现错误并将降级逻辑切换为主

    逻辑,减少响应延迟的效果。

  2. 原来的主逻辑要如何恢复呢?

    对于这一问题,hystrix也为我们实现了自动恢复功能。

    当断路器打开,对主逻辑进行熔断之后,hystrix会启动一个休眠时间

    窗,在这个时间窗内,降级逻辑是临时的成为主逻辑,当休眠时间窗到

    期,断路器将进入半开状态,释放一次请求到原来的主逻辑上,如果此

    次请求正常返回,那么断路器将继续闭合,主逻辑恢复,如果这次请求

    依然有问题,断路器继续进入打开状态,休眠时间窗重新计时。

ALL配置:

img

img

img

img

13.9、服务限流

alibaba的Sentinel说明

13.10、Hystrix的工作流程

https://github.com/Netflix/Hystrix/wiki/How-it-Works

官网图例

img

步骤说明

img

13.11、服务监控hystrixDashboard

13.11.1、概述

除了隔离依赖服务的调用以外,Hystrix还提供了准实时的调用监控

**(Hystrix Dashboard)**,Hystrix会持续地记录所有通过Hystrix发起的请求

的执行信息,并以统计报表和图形的形式展示给用户,包括每秒执行多少

请求多少成功,多少失败等。Netflix通过hystrix-metrics-event-stream项

目实现了对以上指标的监控。Spring Cloud也提供了Hystrix Dashboard的

整合,对监控内容转化成可视化界面。

13.11.2、依赖

pom:

1
2
3
4
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-hystrix-dashboard</artifactId>
</dependency>
13.11.3、步骤
  1. 主启动类加注解激活@EnableHystrixDashboard

  2. 所有Provider微服务提供类都需要监控依赖部署

    1
    2
    3
    4
    <dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
    </dependency>
  3. 访问网址http://localhost:9001/hystrix(9001为端口名)

13.11.4、断路器演示(服务监控hystrixDashboard)

新版本Hystrix需要在主启动MainAppHystrix8001(微服务提供方)中指

定监控路径

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
@SpringBootApplication
@EnableEurekaClient
@EnableCircuitBreaker
public class PaymentHystrixMain8001
{
public static void main(String[] args) {
SpringApplication.run(PaymentHystrixMain8001.class, args);
}


/**
*此配置是为了服务监控而配置,与服务容错本身无关,springcloud升级后的坑
*ServletRegistrationBean因为springboot的默认路径不是"/hystrix.stream",
*只要在自己的项目里配置上下面的servlet就可以了
*/
@Bean
public ServletRegistrationBean getServlet() {
HystrixMetricsStreamServlet streamServlet = new HystrixMetricsStreamServlet();
ServletRegistrationBean registrationBean = new ServletRegistrationBean(streamServlet);
registrationBean.setLoadOnStartup(1);
registrationBean.addUrlMappings("/hystrix.stream");
registrationBean.setName("HystrixMetricsStreamServlet");
return registrationBean;
}
}

启动一个eureka或者3个eureka集群进行监控测试

填写监控地址http://localhost:8001/hystrix.stream

img

13.11.5、如何看hystrixDashboard服务监控图
  • 七色

    img

  • 一圈:

    实心圆:共有两种含义。它通过颜色的变化代表了实例的健康程度,它

    的健康度从绿色<黄色<橙色<红色递减。

    该实心圆除了颜色的变化之外,它的大小也会根据实例的请求流量发生

    变化,流量越大该实心圆就越大。所以通过该实心圆的展示,就可以在

    大量的实例中快速的发现故障实例和高压力实例

  • 一线:

    曲线:用来记录2分钟内流量的相对变化,可以通过它来观察到流量的上升与下降趋势

  • 整图说明:

    img

    img

    img

  • 搞懂一个才能看懂复杂的

    img

路由网关

14、Gateway新一代网关

14.1、是什么?

一句话:

SpringCloud Gateway使用的是Webflux中的reactor-netty响应式编程组

件,底层使用了Netty通讯框架。

https://cloud.spring.io/spring-cloud-static/spring-cloud-gateway/2.2.1.RELEASE/reference/html/

Cloud全家桶中有个很重要的组件就是网关,在1.x版本中都是采用的Zuul

网关;但在2.x版本中,zuul的升级一直跳票,SpringCloud最后自己研发

了一个网关替代Zuul,那就是SpringCloud Gateway一句话:

gateway是原zuul1.x版的替代

img

Gateway是在Spring生态系统之上构建的API网关服务,基于Spring

5,Spring Boot 2和Project Reactor等技术。

Gateway旨在提供一种简单而有效的方式来对API进行路由,以及提供一些

强大的过滤器功能,例如:熔断、限流、重试等

image-20210310010754927

SpringCloud Gateway 是 Spring Cloud 的一个全新项目,基于 Spring

5.0+Spring Boot 2.0 和 Project Reactor 等技术开发的网关,它旨在为微

服务架构提供一种简单有效的统一的API路由管理方式。

SpringCloud Gateway 作为 Spring Cloud 生态系统中的网关,目标是替代

Zuul,在Spring Cloud 2.0以上版本中,没有对新版本的Zuul 2.0以上最新

高性能版本进行集成,仍然还是使用的Zuul 1.x非Reactor模式的老版本。

而为了提升网关的性能,SpringCloud Gateway是基于WebFlux框架实现

的,而WebFlux框架底层则使用了高性能的Reactor模式通信框架Netty。

Spring Cloud Gateway的目标提供统一的路由方式且基于 Filter 链的方式

提供了网关基本的功能,例如:安全,监控/指标,和限流。

源码架构:

img

14.2、作用

  • 反向代理
  • 鉴权
  • 流量控制
  • 熔断
  • 日志监控
  • ……

14.3、微服务架构中网关在哪里

img

14.4、Zool与Gateway

  1. 一方面因为Zuul1.0已经进入了维护阶段,而且Gateway是

    Spring Cloud团队研发的,是亲儿子产品,值得信赖。而且很多功能

    Zuul都没有用起来也非常的简单便捷。

Gateway是基于异步非阻塞模型上进行开发的,性能方面不需要担

心。虽然Netfix早就发布了最新的Zuul 2.x,但 Spring Cloud貌似没有

整合计划。而且Netflix相关组件都宣布进入维护期;不知前景如何?

多方面综合考虑Gateway是很理想的网关选择。

  1. Spring Cloud Gateway 具有如下特性:

    基于Spring Framework 5,Project Reactor和 Spring Boot 2.0 进行构建

    • 动态路由:能够匹配任何请求属性;
    • 可以对路由指定 Predicate (断言)和 Filter (过滤器);
    • 集成Hystrix的断路器功能;
    • 集成 Spring Cloud 服务发现功能;
    • 易于编写的Predicate (断言)和Filter (过滤器);
    • 请求限流功能;
    • 支持路径重写。
  2. Spring Cloud Gateway 与 Zuul的区别:

    在SpringCloud Finchley正式版之前,Spring Cloud 推荐的网关是

    Netflix 提供的Zuul:

    • Zuul 1.x,是一个基于阻塞I/O的API Gateway

    • Zuul 1.x 基于Servlet 2.5使用阻塞架构,它不支持任何长连接(如

      WebSocket)。 Zuul 的设计模式和Nginx较像,每次I/ O 操作都

      从工作线程中选择一个执行,请求线程被阻塞到工作线程完成

      但是差别是:Nginx 用C++ 实现,Zuul 用 Java 实现,而 JVM 本身

      会有第一次加载较慢的情况,使得Zuul的性能相对较差。

    • Zuul 2.x理念更先进,想基于Netty非阻塞和支持长连接,但

      SpringCloud目前还没有整合。Zuul 2.x的性能较 Zuul 1.x有较大提升。在性能方面,根据官方提供的基准测试,Spring Cloud Gateway的RPS(每秒请求数)是Zuul的1.6倍。

    • Spring Cloud Gateway 建立在 Spring Framework 5、Project

      Reactor和 Spring Boot 2之上,使用非阻塞API

    • Spring Cloud Gateway 还支持WebSocket,并且与Spring紧密集

      成拥有更好的开发体验。

  3. springcloud中所集成的Zuul版本,采用的是Tomcat容器,使用的是传

    统的Servlet IO处理模型。

    Servlet的生命周期:servlet由servlet container进行生命周期管理。

    • container启动时构造servlet对象并调用servlet init()进行初始化;

    • container运行时接受请求,并为每个请求分配一个线程(一般从线

      程池中获取空闲线程)然后调用service();

    • container关闭时调用servlet destory()销毁servlet;

    image-20210310012934921

  4. 上述模式的缺点:

    servlet是一个简单的网络IO模型,当请求进入servlet container时,

    servlet container就会为其绑定一个线程,在并发不高的场景下这种模

    型是适用的。但是一旦高并发(比如抽风用jemeter压),线程数量就会

    上涨,而线程资源代价是昂贵的(上线文切换,内存消耗大)严重影响请

    求的处理时间。在一些简单业务场景下,不希望为每个request分配一

    个线程,只需要1个或几个线程就能应对极大并发的请求,这种业务场

    景下servlet模型没有优势。

    而Zuul 1.X是基于servlet之上的一个阻塞式处理模型,即spring实现

    了处理所有request请求的一个servlet(DispatcherServlet)并由该

    servlet阻塞式处理处理。所以Springcloud Zuul无法摆脱servlet模型

    的弊端。

  5. WebFlux:

    https://docs.spring.io/spring/docs/current/spring-framework-reference/web-reactive.html#spring-webflux

    img

    img

    传统的Web框架,此如说:struts2,springmvc等都是基于Servlet

    API与Servlet容器基础之上运行的。

    但是,在Servlet3.1之后有了异步非阻赛的支持。而WebFlux是一个典

    型非阻塞异步的框架,它的核心是基于Reactor的相关API实现的。相

    对于传统的web框架来说,它可以运行在诸如Netty,Undertow及支

    持Servlet3.1的容器上。非阻塞式+函数式编程(Spring5必须让你使用

    java8)

    Spring WebFlux 是Spring 5.0 引入的新的响应式框架,区别于 Spring

    MVC,它不需要依赖Servlet API,它是完全异步非阻塞的,并且基于

    Reactor 来实现响应式流规范

    14.5、三大核心概念

  • Route(路由):

    路由是构建网关的基本模块,它由ID,目标URI,一系列的断言和过滤器组

    成,如断言为true则匹配该路由。

  • Predicate(断言):

    参考的是Java8的java.util.function.Predicate

    开发人员可以匹配HTTP请求中的所有内容(例如请求头或请求参数),如

    果请求与断言相匹配则进行路由。

  • Filter(过滤):

    指的是Spring框架中GatewayFilter的实例,使用过滤器,可以在请求被路

    由前或者之后对请求进行修改。

web请求,通过一些匹配条件,定位到真正的服务节点。并在这个转发过

程的前后,进行一些精细化控制。

predicate就是我们的匹配条件;而filter,就可以理解为一个无所不能的拦截器。有了这两个元素,再加上目标uri,就可以实现一个具体的路由了。

image-20210310014051292

14.6、Gateway工作流程(路由转发+执行过滤器链)

官网总结

img

img

客户端向Spring Cloud Gateway发出请求。然后在Gateway Handler

Mapping中找到与请求相匹配的路由,将其发送到Gateway Web

Handler。

Handler 再通过指定的过滤器链来将请求发送到我们实际的服务执行业务

逻辑,然后返回。

过滤器之间用虚线分开是因为过滤器可能会在发送代理请求之前(“pre”)或之

后(“post”)执行业务逻辑。

Filter在“pre”类型的过滤器可以做参数校验、权限校验、流量监控、日志输

出、协议转换等,

在“post”类型的过滤器中可以做响应内容、响应头的修改,日志的输出,流

量监控等有着非常重要的作用。

14.7、依赖

pom:

1
2
3
4
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>

application.yml配置:

img

访问:

添加网关前:http://localhost:8001/payment/get/31

添加网关后:http://localhost:9527/payment/get/31

14.8、Gateway网关路由有两种配置方式

1、在配置文件yml中配置

2、代码中注入RouteLocator的Bean

示例:

百度国内新闻网站,需要外网https://news.baidu.com/guonei

业务需求:通过9527网关访问到外网的百度新闻网址

实现:在cloud-gateway-gateway9527编写配置类

img

img

14.9、通过服务名实现动态

默认情况下Gatway会根据注册中心注册的服务列表, 以注册中心上微服务

名为路径创建动态路由进行转发,从而实现动态路由的功能

启动:一个eureka+两个服务提供者

application.yml配置:

需要注意的是uri的协议lb,表示启用Gateway的负载均衡功能。

lb://serverName是spring cloud gatway在微服务中自动为我们创建的负载均衡uri

img

14.10、Predicate(断言)

说白了,Predicate就是为了实现一组匹配规则, 让请求过来找到对应的

Route进行处理。

Spring Cloud Gateway将路由匹配作为Spring WebFlux HandlerMapping

基础架构的一部分。

Spring Cloud Gateway包括许多内置的Route PredicateI工厂。所有这些

Predicate都与HTTP请求的不同属性匹配。多个RoutePredicate工厂可以

进行组合。

Spring Cloud Gateway 创建 Route 对象时,使用 RoutePredicateFactory

创建 Predicate 对象,Predicate 对象可以赋值给Route。Spring Cloud

Gateway 包含许多内置的Route Predicate Factories。

所有这些谓词都匹配HTTP请求的不同属性。多种谓词工厂可以组合,并通

过逻辑and。

img

常用的Route Predicate

img

  • After Route Predicate:在设置时间之后

  • Before Route Predicate:在设置时间之前

  • Between Route Predicate:在设置时间中间

  • Cookie Route Predicate:请求要带有cookie

  • Header Route Predicate:请求要带有请求头,且请求头的值要符合要求

  • Host Route Predicate:要使用符合要求的主机进行访问

  • Method Route Predicate:请求方式要是符合要求

  • Path Route Predicate:路径相匹配的进行路由

  • Query Route Predicate:要有参数名并且值还要是符合要求的才能路由

  • RemoteAddr Route Predicate:通过无类别域间路由(IPv4 or IPv6)列

    表匹配路由(- RemoteAddr=192.168.1.1/24)(不常用)

  • Weight Route Predicate:接收一个[组名,权重], 然后对于同一个组内

    的路由按照权重转发(-Weight= group3, 9)(不常用)

application.yml配置:

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
server:
port: 9527

spring:
application:
name: cloud-gateway
cloud:
gateway:
discovery:
locator:
enabled: true #开启从注册中心动态创建路由的功能,利用微服务名进行路由
routes:
- id: payment_routh #payment_route #路由的ID,没有固定规则但要求唯一,建议配合服务名
#uri: http://localhost:8001 #匹配后提供服务的路由地址
uri: lb://cloud-payment-service #匹配后提供服务的路由地址
predicates:
- Path=/payment/get/** # 断言,路径相匹配的进行路由

- id: payment_routh2 #payment_route #路由的ID,没有固定规则但要求唯一,建议配合服务名
#uri: http://localhost:8001 #匹配后提供服务的路由地址
uri: lb://cloud-payment-service #匹配后提供服务的路由地址
predicates:
- Path=/payment/lb/** # 断言,路径相匹配的进行路由
#- After=2021-03-09T01:19:24.226+08:00[Asia/Shanghai]
#- Before=2021-03-09T01:19:24.226+08:00[Asia/Shanghai]
#- Between=2021-03-09T01:19:24.226+08:00[Asia/Shanghai],2022-03-09T01:19:24.226+08:00[Asia/Shanghai]
#- Cookie=username,xgh #请求要带有cookie(URL + -- cookie "username=xgh")
#- Header=X-Request-Id, \d+ # 请求头要有X-Request-Id属性并且值为整数的正则表达式(URL + --H "X-Request-Id:123")
#- Host=**.atguigu.com #主机要带有atguigu.com
#- Method=GET #请求方式要是get
#- Query=username,\d+ #要有参数名并且值还要是整数的才能路由
#filters:
#- AddRequestHeader=X-Request-Id,1024 #过滤器工厂会在匹配的请求头上加上一对请求头,名称为X-Request-Id,值为1024

eureka:
instance:
hostname: cloud-gateway-service
instance-id: gateway9527
#访问路径可以显示IP地址
prefer-ip-address: true
client: #服务提供者provider注册进eureka服务列表内
#表示是否将自己注册进EurekaServer默认为true。
register-with-eureka: true
#是否从EurekaServer抓取已有的注册信息,默认为true。单节点无所谓,集群必须设置为true才能配合ribbon使用负载均衡
fetchRegistry: true
service-url:
#单机版
defaultZone: http://localhost:7001/eureka
# 集群版
#defaultZone: http://eureka7001.com:7001/eureka,http://eureka7002.com:7002/eureka

14.11、Filter(过滤器)

14.11.1、是什么?

路由过滤器可用于修改进入的HTTP请求和返回的HTTP响应,路由过滤器

只能指定路由进行使用。

Spring Cloud Gateway内置了多种路由过滤器,他们都由GatewayFilter的

工厂类来产生。

image-20210310021404546

14.11.2、生命周期
  • post
  • pre
14.11.3、种类

常用的GatewayFilter:

在yml里面配置:

img

自定义过滤器:

自定义全局GlobalFilter

两个主要接口

  • GlobalFilter

  • Ordered

作用:

  • 全局日志记录
  • 统一网关鉴权

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Component
public class MyGateWayFilter implements GlobalFilter, Ordered {

@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
System.out.println("**************come in MylogGateWayGilter: " + new Date());
String uname = exchange.getRequest().getQueryParams().getFirst("uname");
if (uname == null) {
System.out.println("********用户名为空,非法用户。");
exchange.getResponse().setStatusCode(HttpStatus.NOT_ACCEPTABLE);
return exchange.getResponse().setComplete();
}
return chain.filter(exchange);
}

/**
* 拦截的级别,值越小,级别越大
* @return
*/
@Override
public int getOrder() {
return 0;
}
}

分布式配置中心

15、SpringCloud config

15.1、分布式系统面临的问题

配置问题:

到目前为止,我们对 Eureka、Robbin、OpenFeign、Hystrix、Gateway

等有了相应的了解,每个微服务都是单独一个模块,微服务彼此还支持集群

环境。

但是在微服务项目的开发中,还面临着一个严重的配置问题。每一个微服务

都需要一个配置文件,如果有几个微服务需要连接数据库,name就需要进

行 4 次数据库的配置。当数据库发生改变,那么就需要同时修改 4 个微服

务的配置文件才可以。那么如果有40台呢?如果是集群模式呢??

如果能够做到:一处修改、处处生效,这样就可以减轻修改配置压力,从而

增强配置管理方面的功能,此时就需要 Spring Cloud Config 和 Spring

Cloud Bus 上场了。

使用 Config + Bus,可以实现 :

  • 一处修改、处处生效

  • 灵活的对版本(dev/test/prod)进行切换,这样就足够方便了

img

15.2、是什么:

https://cloud.spring.io/spring-cloud-static/spring-cloud-config/2.2.1.RELEASE/reference/html/

Springcloud config为微服务架构中的微服务提供集中化的外部配置支持,

配置服务器为各个不同微服务应用的所有环境提供一个中心化的外部配

置。各个不同微服务应用 Springcloud config为微服务架构中的微服务提

供集中化的外部配置支持,配置服务器为各个不同微服务应用的所有环境

提供一个中心化的外部配置。中心化的外部配置。

15.3、怎么用:

Springcloud Config分为服务端客户端两部分。

  • 服务端(Config Server):也称分布式配置中心,它是一个独立的微服

    务应用,用来连接配置服务器并未客户端提供获取配置信息,加密、解

    密信息等访问接口。

  • 客户端:通过指定的 配置中心(Config Server) 来管理应用资源,以及与业务相关的配置内容,并在启动的时候从 配置中心 获取和加载配置信息。

15.4、作用

  • 集中管理配置文件

  • 不同环境不同配置,动态化的配置更新,分环境部署比如

    dev/test/prod/beta/release

  • 运行期间动态调整配置,不再需要字啊每个服务器的机器上编写配置文

    件,服务会向配置中心同意拉去配置自己的信息

  • 当配置发生变动时,服务不需要重启即可感知到配置的变化并应用新的

    配置

  • 将配置信息以REST接口的形式暴露

    • post,curl访问刷新均可

15.5、与GitHub整合配置

由于SpringCloud Config默认使用Git来存储配置文件(也有其他方式,比

如注册SVN和本地文件),但最推荐的还是Git,而且使用的是http/https服

务的形式。

步骤:

  1. 创建存储 Config 的新 Repository

  2. 将新建的GitHub远程仓库克隆到本地

    Repository 创建成功,即可获取自己的仓库地址,将项目克隆到本

    地,方便对数据的修改。(GitHub 也支持直接修改,你也可以不克

    隆,此处克隆只是为了更方便处理数据。)

  3. 进入克隆目录,新建三个配置文件,分别是 config-dev.yml**、config-test.ymlconfig-prod.yml**。然后通过命令将其推送到远程GitHub仓库。

    1
    2
    3
    git add *.yml(将提交的文件加入暂存区,为git commit做准备)
    git commit -m “first commit” (完成对文件内容提交至Git版本库)
    git push -u origin master(将本地仓库内容,推送至GitHub远程仓库)
  4. 对配置修改后,通过以上三个命令,便可以再次将修改后的内容推送至 GitHub。你也可以使用 IDEA 等工具进行

    在这里插入图片描述

  5. Github 远程仓库内容

    在这里插入图片描述

  6. 服务端配置测试 (Config 结构图中的 Config Server)

    pom:

    1
    2
    3
    4
    5
    <!--引入spring-cloud-config-server依赖-->
    <dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-config-server</artifactId>
    </dependency>

    配置文件 application.yml:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    server:
    port: 3344 #端口号

    spring:
    application:
    name: cloud-config-center #注册进Eureka 服务器的微服务名
    cloud:
    config:
    server:
    git:
    uri: https://github.com/Liuzebiao/springcloud-config.git #GitHub远程仓库地址
    # 搜索目录
    search-paths:
    - springcloud-config
    #读取分支
    label: master

    #服务注册到eureka
    eureka:
    client:
    service-url:
    defaultZone: http://localhost:7001/eureka

    主启动类 配置**@EnableConfigServer**注解

    启动测试:http://localhost:3344/master/config-dev.yml

  7. GitHub配置文件读取规则

    远程 GitHub 仓库,配置文件的命名也是有具体规则的。Spring Cloud

    Config 官方共支持 5 种方式的配置(https://cloud.spring.io/spring-cloud-static/spring-cloud-config/2.2.1.RELEASE/reference/html/#_quick_start

    参数说明:

    1. label:GitHub 分支(branch)名称
    2. application:服务名
    3. profile:环境(dev/test/prod)
    • /{application}/{profile}/{label}:

      返回的是 Json 对象,需要自己解析所要的内容

      在这里插入图片描述

    • /{application}-{profile}.yml:

      (这种不带label方式,默认使用application.yml 配置)因为

      applicaiton.yml 文件已经有配置过 label,不带label 方式,默认走

      的就是 yml 配置的 label,返回的是配置内容

      在这里插入图片描述

    • /{label}/{application}-{profile}.yml:

      (推荐使用第三种)这种方式简明扼要,条理清晰,返回的是配置内

      在这里插入图片描述

    • /{application}-{profile}.properties:

      同第2种

    • /{label}/{application}-{profile}.properties:

      同第3种

  8. 客户端配置测试 (Config 结构图中的 Client A、Client B、Client C)

    依赖pom:

    1
    2
    3
    4
    5
    <!--引入spring-cloud-starter-config依赖-->
    <dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-config</artifactId>
    </dependency>

    配置文件 bootstrap.yml:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    server:
    port: 3355 #端口

    spring:
    application: #名称
    name: config-client
    cloud:
    #config客户端配置
    config:
    label: master #分支名称
    name: config #配置文件名称
    profile: dev # 读取后缀名称 上述3个综合:master分支上config-dev.yml 的配置文件被读取(http://config-3344.com:3344/master/fongig-dev.yml)
    uri: http://localhost:3344 #配置中心地址

    #服务注册到eureka地址
    eureka:
    client:
    service-url:
    defaultZone: http://eureka7001.com:7001/eureka

    主启动类 配置**@EnableConfigServer**注解

    controller业务类:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    @RestController
    public class ConfigClientController {

    @Value("${config.info}") //通过这种方式,可以直接读取ConfigServer中的配置信息
    private String configInfo;

    @GetMapping("/configInfo")
    public String getConfigInfo(){
    return configInfo;
    }
    }

    启动测试:http://localhost:3355/configInfo

    15.6、bootstrap.yml与application.yml

applicaiton.yml是用户级的资源配置项

bootstrap.yml是系统级的,优先级更加高

Spring Cloud会创建一个“Bootstrap Context”,作为Spring应用

Application Context父上下文。初始化的时候,

BootstrapContext负责从外部源加载配置属性并解析配置。这两个上下

文共享一个从外部获取的Environment

“Bootstrap”属性有高优先级,默认情况下,它们不会被本地配置覆

盖。Bootstrap contextApplication Context有着不同的约定,所

以新增了一个bootstrap.yml’文件,保证Bootstrap Context

Application Context配置的分离。

要将Client模块下的application.yml文件改为bootstrap.yml,这是很关

键的,因为bootstrap.yml是比application.yml先加载的。bootstrap.yml

优先级高于application.yml。

15.7、分布式配置的动态刷新问题

  1. POM引入actuator监控

    1
    2
    3
    4
    5
    <!--引入actuator监控-->
    <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
    </dependency>
  2. 修改YML,暴露监控接口

    1
    2
    3
    4
    5
    management: 
    endpoints:
    web:
    exposure:
    include: "*"
  3. @RefreshScope业务类Controller修改

  4. 需要运维人员发送Post请求刷新3355客户端

15.8、残留问题

  • 假如有 N 多个台,就需要 N 多次的curl的手动刷新

    解决想法:

    大规模 微服务/集群模式**,我们可以采用广播的方式,一次通知,处处生效。类似于 **消息队列的 Topic ,**微信公众号** 的概念,一次订阅,所有订阅者都能接收到新消息。

  • 无法实现精确通知,只通知集群中的某些服务(精确通知,比如有100台机器,只通知前98台)

解决以上两个问题的方法:bus消息总线

消息总线

16、SpringCloud Bus

16.1、是什么

https://cloud.spring.io/spring-cloud-static/spring-cloud-bus/2.2.1.RELEASE/reference/html/

在微服务架构的系统中,通常会使用轻量级的消息代理来构建一个共用的消

息主题,并让系统中所有的微服务实例都连接上来。由于该主题中产生的消

息会被所有实例监听和消费,所以称它为消息总线在总线上的各个实例,都

可以方便的广播一些需要让其他链接在该主题上的实例都知道的消息。

Spring Cloud Bus 是用来将 分布式系统的节点轻量级消息系统连接起来

的框架,它整合了 Java 的事件处理机制消息中间件的功能。Spring

Cloud Bus 目前仅支持RabbitMQKafka

16.2、作用

Spring Cloud Bus能管理和传播分布式系统间的消息,就像一个分布式执行

器,可用于广播状态更改,事件推送等,也可以作为微服务的通信通道。

img

16.3、基本原理与执行流程

ConfigClient实例都监听MQ中的同一个topic(默认是SpringcloudBus)。

当一个服务刷新数据的时候,它会把这个信息放入到topic中,这样其它监

听同一个topic的服务就能得到通知,然后去更新自身的配置。其实就是

过 MQ 消息队列的 Topic 机制,达到广播的效果。

img

16.4、Bus的两种设计思想

  1. 触发客户端

    利用消息总线触发一个客户端/bus/refresh,而刷新所有客户端的配置

    img

  2. 触发服务端

    利用消息总线触发一个服务端ConfigServer的/bus/refresh端点,而

    刷新所有客户端的配置

    img

如何选型:

根据架构图显然第二种更加合适,所以推荐使用触发服务端 Config Server

的方式。第一种触发客户端方式 不适合的原因如下:

  1. 利用消息总线触发客户端方式,打破了微服务的职责单一性,因为微服

    务本身是业务模块,它本不应该承担配置刷新的职责;

  2. 触发客户端方式,破坏了微服务各个节点之间的对等性(比如说:

    3355/3366/3377 集群方式提供服务,此时 3355 还需要消息通知,影

    响节点的对等性)

  3. 有一定的局限性。当微服务迁移时,网络地址会经常发生变化,如果此

    时需要做到自动刷新,则会增加更多的修改。

16.5、Bus 动态刷新全局广播配置

  • 集群版客户端组建(搭建两个或多个客户端)

  • 服务端配置中心/客户端 pom 引入Bus总线依赖

    1
    2
    3
    4
    5
    <!--添加消息总线支持-->
    <dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-bus-amqp</artifactId>
    </dependency>
  • 服务端配置中心 application.yml 修改 (添加 rabbitmq 相关配置)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    #添加rabbitmq相关支持(新加)
    spring:
    rabbitmq:
    host: localhost
    port: 5672
    username: guest
    password: guest

    #rabbitmq相关配置,暴露bus舒心配置的端点
    management:
    endpoints:
    web:
    exposure:
    include: 'bus-refresh' #为什么配置 bus-refresh,看传染病那张图
  • 客户端 application.yml 修改 (同样添加 rabbitmq 相关配置)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    #添加rabbitmq相关支持
    spring:
    rabbitmq:
    host: localhost
    port: 5672
    username: guest
    password: guest

    #暴露监控端点
    management:
    endpoints:
    web:
    exposure:
    include: "*" #此处有很多选项可以配置,为了省事 ,直接配置 *
  • 启动模块,开始测试

    启动:服务端配置中心 Config Server (3344)、客户端集群

    (3355/3366)、Eureka Server(7001)。修改 GitHub 参数配置,然后向

    服务端 发送 Post 请求,命令:curl -X POST "http://localhost:3344/actuator/bus-refresh"

    当向 Config Server 发送 Post 请求后,总线上的各个实例(客户端

    3355/3366 )都能够及时 监听和消费 配置的变更。使用广播的方式,真

    正的实现 **一处通知,处处生效**。

    使用 MQ 广播的方式,实现一处通知,处处生效的效果。此时我们登陆

    Rabbit MQ 客户端,在Exchanges模块,就能够看到一个叫做

    springCloudBusTopic

    与本文 2.2 Bus 原理 中介绍吻合:Config 客户端示例,都去监听 MQ

    中的同一个 topic(默认是 springCloudBus)。当一个服务刷新数据

    **的时候,它会把这个消息放入到 Topic 中,这样其他监听同一 Topic **

    的服务就能够得到通知,然后去更新自身的配置

16.6、Bus 动态刷新定点通知配置

如果需要差异化通知,并不想进行全局广播,此时就用到了 Bus 的定点通

功能。

此次我们通过客户端集群(3344/3355)演示。GitHub 远程配置修改后 ,进

行差异化定点通知,只通知 3355,不通知 3366。此处命令和全局广播有点

不同,命令为:http://配置中心IP:配置中心的端口号/actuator/bus-

refresh/{destination}

通过指定 /bus/refresh请求不再发送到具体的服务实例上,而是发给

Config Server 并通过 {destination} 参数来指定需要更新配置的服务或实

例。

{destination} 参数 = 微服务名 :端口号。3355 微服务名为:config-

client。此处最终发送的 Post 请求命令为:curl -X POST http://localhost:3

344/actuator/bus-refresh/config-client:3355,真正的实现精确通知

能。

消息驱动

17、Spring Cloud Stream消息驱动

17.1、目前微服务面临的问题

在项目开发中,常用的四种消息中间件:ActiveMQRabbitMQ

RocketMQKafka。由于每个项目需求的不同,在消息中间件的选型

上也就会不同。

在项目开发中,你会遇到以下一些问题:

  • 自己学的是 RabbitMQ,公司用的却是 Kafka 。再学 Kafka?学习成本

    太高,负担太重;

  • 多部门配合,MQ差异化带来的联调问题。A部门使用 RabbitMQ 进行

    消息发送,大数据部门却用 Kafka, MQ 选型的不同,MQ 切换、维

    护、开发等困难随之而来。

有没有一种技术,可以让我们不再关注 MQ 的细节,只需要用一种适配绑

的方式,就可以帮助我们自动的在各种 MQ 之间切换呢?答案就是

Spring Cloud Stream 消息驱动。

Spring Cloud Stream 消息驱动,它可以屏蔽底层 MQ 之间的细节差异。我

们只需要操作Spring Cloud Stream 就可以操作底层多种多样的MQ。从而

解决我们在 MQ 切换、维护、开发方面的难度问题。

17.2、是什么

https://spring.io/projects/spring-cloud-stream#overview

Spring Cloud Stream 是一个构建消息驱动微服务的框架。应用程序通过

inputs或者outputs来与 Spring Cloud Stream 中的 binder 对象交互。通

过我们的配置来进行 binding(绑定), 然后 Spring Cloud Stream 通过

binder 对象与消息中间件交互。所以,我们只需要搞清楚如何与 Spring

Cloud Stream 交互,就可以方便使用消息驱动的方式。

Spring Cloud Stream 通过使用 Spring Integration 来连接消息代理中间

件,以实现消息时间驱动。Spring Cloud Stream 为一些供应商的消息中间

件产品提供了个性化的自动配置发现,引用了发布-订阅消费组分区

个核心概念。目前仅支持RabbitMQKafka

一句话总结: Spring Cloud Stream 屏蔽了底层消息中间件的差异,降低

MQ 切换成本,统一消息的编程模型。开发中使用的就是各种xxxBinder

img

17.3、设计思想

17.3.1、标志MQ

生产者/消费者 之间通过 消息媒介 传递消息内容

生产者/消费者之间靠消息媒介传递信息内容:Message

消息必须走特定的通道:MessageChannel

消息通道MessageChannel的子接口SubscribeChannel,由

MessageHandler消息处理器所订阅

结构图:

在这里插入图片描述

17.3.2、Spring Cloud Stream

比如说我们用到了RabbitMQ和 Kafka,由于这两个消息中间件的架构上的

不同。像RabbitMQ 有exchange、Kafka有TopicPartions分区的概念。

这些中间件的差异性,给我们实际项目的开发造成了一定的困扰。我们如果

用了两个消息队列中的其中一个,后面的业务需求如果向往另外一种消息队

列进行迁移,这需求简直是灾难性的。因为它们之间的耦合性过高,导致一

大堆东西都要重新推到来做,这时候 Spring Cloud Stream 无疑是一个好的

选择,它为我们提供了一种解耦合的方式。

结构图:

在这里插入图片描述

17.4、Spring Cloud Stream如何统一底层差异

在没有绑定器这个概念的情况下,我们的 Spring Boot 应用直接与消息中间

件进行信息交互时,由于个消息中间件构建的初衷不同,它们的实现细节上

会有较大的差异性。

通过定义绑定器(Binder)作为中间层,就可以完美的实现应用程序与消息中

间件细节的隔离。 通过向应用程序暴露统一的 Channel 通道,使得应用程

序不需要在考虑各种不同的消息中间件的实现。

17.5、Binder

在没有绑定器这个概念的情况下,我们的SpringBoot应用要直接与消息中

间件进行信息交互的时候,由于各消息中间件构建的初衷不同,它们的实现

细节上会有较大的差异性,通过定义绑定器作为中间层,完美地实现了应用

程序与消息中间件细节之间的隔离。Stream对消息中间件的进一步封装,

可以做到代码层面对中间件的无感知,甚至于动态的切换中间件(rabbitmq

切换为kafka),使得微服务开发的高度解耦,服务可以关注更多自己的业务

流程。

img

  • INPUT:适用于消费者
  • OUTPUT:适用于生产者

默认情况下,RabbitMQ Binder实现将每个目标映射到TopicExchange

对于每个使用者组,队列都绑定到该 TopicExchange。 每个使用者实例在

其组的队列中都有一个对应的 RabbitMQ使用者实例。 对于分区的生产者

和使用者,队列以分区索引为后缀,并使用分区索引作为路由键。 对于匿

名使用者(没有组属性的使用者),将使用自动删除队列(具有随机的唯一

名称)。

17.6、Spring Cloud Stream 执行流程

在这里插入图片描述

说明:

  1. Source/Sink:Source 输入消息,Sink 输出消息
  2. Channel:通道,是队列 Queue 的一种抽象,在消息通讯系统中就是实现存储和转发的媒介,通过Channel 对队列进行配置;
  3. Binder:很方便的 **连接中间件**,屏蔽 MQ 之间的差异

17.7、编码API和常用注解

在这里插入图片描述

17.8、详细配置与代码

选用 RabbitMQ,在不需要任何 RabbitMQ 包依赖的基础上,使用 Spring

Cloud Stream 消息驱动来实现消息的发送&接收。

步骤:

  1. 生产者配置

    依赖pom:

    1
    2
    3
    4
    5
    <!--引入stream-rabbit依赖-->
    <dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-stream-rabbit</artifactId>
    </dependency>

    applicaiton.yml 配置:

    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
    32
    33
    34
    server:
    port: 8801

    spring:
    application:
    name: cloud-stream-provider
    cloud:
    stream:
    binders: # 在此处配置要绑定的rabbitmq的服务信息;
    defaultRabbit: # 表示定义的名称,用于binding整合(可以自定义名称)
    type: rabbit # 消息组件类型
    environment: # 设置rabbitmq的相关的环境配置
    spring:
    rabbitmq:
    host: localhost
    port: 5672
    username: guest
    password: guest
    bindings: # 服务的整合处理
    output: # 这个名字是一个通道的名称
    destination: studyExchange # 表示要使用的Exchange名称定义
    content-type: application/json # 设置消息类型,本次为json,文本则设置“text/plain”
    default-binder: defaultRabbit
    binder: defaultRabbit # 设置要绑定的消息服务的具体设置(需与自定义名称一致)(飘红:Settings->Editor->Inspections->Spring->Spring Boot->Spring Boot application.yml 对勾去掉)

    eureka:
    client: # 客户端进行Eureka注册的配置
    service-url:
    defaultZone: http://localhost:7001/eureka
    instance:
    lease-renewal-interval-in-seconds: 2 # 设置心跳的时间间隔(默认是30秒)
    lease-expiration-duration-in-seconds: 5 # 如果现在超过了5秒的间隔(默认是90秒)
    instance-id: send-8801.com # 在信息列表时显示主机名称
    prefer-ip-address: true # 访问的路径变为IP地址

    业务类:

    • controller

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      @RestController
      public class SendMessageController {
      @Resource
      private IMessageProvider messageProvider;

      @GetMapping(value = "/sendMessage")
      public String sendMessage() {
      return messageProvider.send();
      }
      }
    • interface 接口

      1
      2
      3
      public interface IMessageProvider {
      public String send();
      }
    • service

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      //@EnableBinding 指信道channel和exchange绑定在一起
      //@EnableBinding(Source.class) 就是将 Source(源) 放到 Channel 的意思
      @EnableBinding(Source.class) //定义消息的推送管道
      public class MessageProviderImpl implements IMessageProvider {

      @Resource
      private MessageChannel output; // 消息发送管道

      @Override
      public String send() {
      String serial = UUID.randomUUID().toString();
      output.send(MessageBuilder.withPayload(serial).build());
      System.out.println("发送消息: "+serial);
      return null;
      }
      }

    测试启动:http://localhost:8801/sendMessage

    可以看到后台有显示发送消息,进入 RabbitMQ 可视化界面,可以看到

    有发送消息波峰出现。

  2. 消费者配置

    依赖pom:

    1
    2
    3
    4
    5
    <!--引入stream-rabbit依赖-->
    <dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-stream-rabbit</artifactId>
    </dependency>

    applicaiton.yml 配置

    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
    32
    server:
    port: 8802

    spring:
    application:
    name: cloud-stream-consumer
    cloud:
    stream:
    binders: # 在此处配置要绑定的rabbitmq的服务信息;
    defaultRabbit: # 表示定义的名称,用于于binding整合(可以自定义名称)
    type: rabbit # 消息组件类型
    environment: # 设置rabbitmq的相关的环境配置
    spring:
    rabbitmq:
    host: localhost
    port: 5672
    username: guest
    password: guest
    bindings: # 服务的整合处理
    input: # 这个名字是一个通道的名称
    destination: studyExchange # 表示要使用的Exchange名称定义
    content-type: application/json # 设置消息类型,本次为json,文本则设置“text/plain”
    binder: defaultRabbit # 设置要绑定的消息服务的具体设置(需与自定义名称一致)(飘红:Settings->Editor->Inspections->Spring->Spring Boot->Spring Boot application.yml 对勾去掉)
    eureka:
    client: # 客户端进行Eureka注册的配置
    service-url:
    defaultZone: http://localhost:7001/eureka
    instance:
    lease-renewal-interval-in-seconds: 2 # 设置心跳的时间间隔(默认是30秒)
    lease-expiration-duration-in-seconds: 5 # 如果现在超过了5秒的间隔(默认是90秒)
    instance-id: receive-8802.com # 在信息列表时显示主机名称
    prefer-ip-address: true # 访问的路径变为IP地址

    业务类:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    @RestController
    @EnableBinding(Sink.class)
    public class ReceiveMessageListenerController {
    @Value("${server.port}")
    private String serverPort;

    @StreamListener(Sink.INPUT)
    public void input(Message<String> message) {
    System.out.println("1号消费者,接收:"+message.getPayload()+"\t port:"+serverPort);
    }
    }

    启动测试:启动 RabbitMQ,调用http://localhost:880/sendMessage

    进行消息发送,可以看到消费者后台在实时接收消息。

这样,我们并没有引入任何相关 RabbitMQ 包,也并不了解 Rabbit MQ。

便能够使用 Rabbit MQ 进行 **消息发送 & 接收**。这就是Spring Cloud

Stream 消息驱动的优越之处。

17.9、Stream 重复消费/持久化问题

17.9.1、重复消费问题

当集群方式进行消息消费时,就会存在消息的重复消费问题。比如支付微服

务,购物支付完成后,消息重复消费就会导致支付多次的问题出现,这显然

是不能接受的。

这是因为没有进行分组的原因,不同组就会出现重复消费同一组内会发生

竞争关系只有一个可以消费。 如果我们不指定(8802、8803)集群分组信

息,它会默认将其当做两个分组来对待。这个时候,如果发送一条消息到

MQ,不同的组就都会收到消息,就会造成消息的重复消费。

解决方法:

只需要用到 Stream 当中 group 属性对消息进行分组即可。将8802、8803

分到一个组即可。(项目中,是否分组就视业务情况而定吧)

在这里插入图片描述

17.9.2、持久化问题

服务端发送消息时,此时客户端断开服务(宕机):若客户端没有分组,此

时客户端不会接收到服务端发送的消息,导致消息丢失

解决方法:

加一个 group 分组属性就行了。如果有客户端有进行分组,重启之后则可

以消费待消费的消息。特别的,但多个客户端都为同一组时,既使其中有一

个客户端宕机,其同组的一个客户端也可以接收到消息,不会导致消息丢失

(无需重启)。

分布式链路追踪

18、Spring Cloud Sleuth + Zipkin 分布式链路追踪

18.1、目前微服务面临的问题

在微服务框架中,一个由客户端发起的请求,在后端系统中会经过多个不同

的微服务节点调用,协同操作产生最后的请求结果。每一个前端请求都会形

成一条复杂的分布式服务调用链路,链路中的任何一环出现高延时或者

,都会引起整个请求最后的失败。

18.2、是什么?

https://spring.io/projects/spring-cloud-sleuth#overview

Spring Cloud Sleuth 提供了分布式系统中一套完整的服务跟踪的解决方

案,并且兼容支持了zipkin,完美的解决了多个微服务之间链路调用的问

题。

一句话总结: 就是用来处理服务之间调用关系的。

18.3、调用结构图

在这里插入图片描述

18.4、搭建链路监控步骤

18.4.1、环境准备

Zipkin 是 Twitter 的一个开源项目,允许开发者收集 Twitter 各个服务上的

监控数据,并提供查询接口。

我们需要先准备一个 Zipkin 环境。Spring Cloud 从F版起已不需要自己构

建Zipkin server了,只需要调用jar包即可。当前使用版本为 H版。我们只

需要下载 Zipkin jar包,在安装目录的路径下使用 java -jar xxx的方式启动

即可。点击链接:https://dl.bintray.com/openzipkin/maven/io/zipkin/java/zipkin-server ,下载 zipkin-server-2.12.9-exec.jar 。启动就OK了,如

图所示。

在这里插入图片描述

通过 http://loclahost:9411 就能进入到 Zipkin 为我们提供的可视化界面(中文)

在这里插入图片描述

一次请求完整的调用链路:

img

简单概述上图:

img

术词:

  • Trace:类似于树结构的Span集合,表示一条调用链路,存在唯一标识
  • span:表示调用链路来源,通俗的理解span就是一次请求信息
18.4.2、Sleuth测试环境搭建
  1. 服务端/客户端 进行相同配置

    引入 zipkin + sleuth pom 依赖:

    1
    2
    3
    4
    5
    <!--引入sleuth+zipkin依赖-->
    <dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-zipkin</artifactId>
    </dependency>
  2. 在 applicaiton.yml 添加 zipkin、sleuth 相同配置:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    spring:
    # 应用名
    application:
    name: cloud-payment-service
    zipkin:
    base-url: http://localhost:9411 #监控数据要打到9411zipkin上
    sleuth:
    sampler:
    probability: 1 #采样率值介于0到1,1则表示全部采集
  3. 调用测试:

    通过http://localhost/consumer/payment/get/31进行服务调用,

    调用成功后,我们打开 http://localhost:9411 Zipkin 控制台就可

    以看到具体服务调用情况。

    点击相对应请求,还可以看到 模块间调用情况**、调用耗时** 等更详细的

    信息。点击导航栏中的 依赖 项,还可以查看模块(调用、被调用)的依

    赖关系等,链路调用关系一目了然。

    image-20210311024420119

    点进每个具体的请求:

    image-20210311024741448

    点进依赖:(查看微服务间的依赖关系)

    image-20210311024859419

Spring Cloud Alibaba

1、什么是维护模式

随着 Spring Cloud Netflix 项目进入维护模式(Maintenance Mode),Eureka、Hystrix、Ribbon、Zuul 等项目都进入了维护模式。

将模块置于维护模式意味着 Spring Cloud 团队将不再向该模块添加新功能。我们将修复block级别的bug和安全性问题,还将考虑并审查社区中的小请求。自 Spring Cloud Greenwich 版本发行(2018.12.12)以来,Spring Cloud 打算继续为这些模块提供至少一年的支持。(摘自:官网)

现在针对 Spring Cloud Netflix 相关模块已经不再提供支持。我们都知道 Spring Cloud 版本迭代算是比较快的,因而出现了很多重大ISSUE都还来不及Fix就又推出另一个 Release 版本了。进入维护模式意味着:以后一段时间 Spring Cloud Netflix 提供的服务和功能就这么多了,不再开发新的组件和功能了,这显然无法满足接下来微服务的开发要求。

伴随着 Spring Cloud Netflix 倒下,停更的组件自然就需要寻找替代者来继续下去。Alibaba 为了能够在微服务领域占据一定的话语权,此时便趁虚而入,将其代替,于2018.10.31 Spring Cloud Alibaba 正式入驻 Spring Cloud 官方孵化器,并在 Maven Spring Cloud for Alibaba 0.2.0 released。(附:Spring Cloud Alibaba 官方介绍)

3

2、Spring Cloud Alibaba

Spring Cloud Alibaba 致力于提供微服务开发的一站式解决方案。此项目包含开发分布式应用微服务的必需组件,方便开发者通过 Spring Cloud 编程模型轻松使用这些组件来开发分布式应用服务。

依托 Spring Cloud Alibaba,您只需要添加一些注解和少量配置,就可以将 Spring Cloud 应用接入阿里微服务解决方案,通过阿里中间件来迅速搭建分布式应用系统。

2.1、Spring Cloud Alibaba包含的组件
  1. **Sentinel**:阿里巴巴开源产品,把流量作为切入点,从流量控制、熔断降级、系统负载保护等多个维度保护服务的稳定性。
  2. **Nacos**:阿里巴巴开源产品,一个更易于构建云原生应用的动态服务发现、配置管理和服务管理平台。
  3. **RocketMQ**:Apache RocketMQ™ 基于 Java 的高性能、高吞吐量的分布式消息和流计算平台。
  4. **Dubbo**:Apache Dubbo™ 是一款高性能 Java RPC 框架。
  5. **Seata**:阿里巴巴开源产品,一个易于使用的高性能微服务分布式事务解决方案。
  6. **Alibaba Cloud OSS**:阿里云对象存储服务(Object Storage Service,简称 OSS),是阿里云提供的海量、安全、低成本、高可靠的云存储服务。您可以在任何应用、任何时间、任何地点存储和访问任意类型的数据。
  7. **Alibaba Cloud SchedulerX**:阿里中间件团队开发的一款分布式任务调度产品,支持周期性的任务与固定时间点触发任务。
  8. **Alibaba Cloud SMS**:覆盖全球的短信服务,友好、高效、智能的互联化通讯能力,帮助企业迅速搭建客户触达通道。
2.2、主要功能
  1. **服务限流降级**: 默认支持 WebServlet、WebFlux, OpenFeign、RestTemplate、Spring Cloud Gateway, Zuul, Dubbo 和 RocketMQ 限流降级功能的接入,可以在运行时通过控制台实时修改限流降级规则,还支持查看限流降级 Metrics 监控。
  2. **服务注册与发现**: 适配 Spring Cloud 服务注册与发现标准,默认集成了 Ribbon 的支持。
  3. **分布式配置管理**: 支持分布式系统中的外部化配置,配置更改时自动刷新。
  4. **消息驱动能力**: 基于 Spring Cloud Stream 为微服务应用构建消息驱动能力。
  5. **分布式事务**: 使用 @GlobalTransactional 注解, 高效并且对业务零侵入地解决分布式事务问题。。
  6. **阿里云对象存储**: 阿里云提供的海量、安全、低成本、高可靠的云存储服务。支持在任何应用、任何时间、任何地点存储和访问任意类型的数据。
  7. **分布式任务调度**: 提供秒级、精准、高可靠、高可用的定时(基于 Cron 表达式)任务调度服务。同时提供分布式的任务执行模型,如网格任务。网格任务支持海量子任务均匀分配到所有 Worker(schedulerx-client)上执行。
  8. **阿里云短信服务**: 覆盖全球的短信服务,友好、高效、智能的互联化通讯能力,帮助企业迅速搭建客户触达通道。

3、学习组件

Alibaba Cloud OSS**、Alibaba Cloud SchedulerXAlibaba Cloud SMS** 是阿里云相关的付费业务。接下来,我们主要介绍 Nacos、Sentinel、Seata 这三个模块。

4、官网资料

  1. Spring Cloud Alibaba官网
  2. Github英文文档
  3. Spring英文文档
  4. Github中文文档

19、Spring Cloud Alibaba Nacos服务注册中心与配置中心

19.1、什么是 Nacos

Nacos(Dynamic Naming and Configuration Service):一个更易于构建云原生应用的动态服务发现,配置管理和服务管理中心。

我们可以理解为:Nacos = 服务注册中心 + 配置中心;等价于 Nacos = Eureka + Spring Cloud Config + Spring Cloud Bus。

19.2、能干嘛

Nacos 可以替代 Eureka 来实现服务注册中心、可以替代 Spring Cloud Config 来实现服务配置中心、可以替代 Spring Cloud Bus 来实现配置的全局广播。Nacos 是更强调云原生时代支持 “服务治理、服务沉淀、共享、持续发展” 理念的注册中心和配置中心。(附:Nacos 官网官方文档)

与各种注册中心比较(粗劣):

img

19.3、Nacos 安装运行

本地环境:java8+maven环境

官网下载,你也可以选择指定版本下载:选择指定版本下载。此处以 window 版进行演示,后续 Nacos 集群环境会在 Linux 环境配置。

下载完成后,解压缩,直接运行 bin 目录下的startup.cmd此处需注意:若你下载的nacos为较新版本,nacos默认是集群方式开启,会出现:nacos is starting with cluster,无法正常启动(此时nacos并未集群)。

img

此时需以单机方式启动,执行以下命令startup.cmd -m standalone即可启动Nacos服务,我们可以看到它使用的是 8848 端口,启动结果如图所示:

在这里插入图片描述

运行成功后,直接访问 http://localhost:8848/nacos 就可以进入 Nacos 的为我们提供的 web 控制台。**用户名、密码默认为 nacos**(1.2.0 版本不需要输入密码),控制台还是挺清新的哈,还提供中文支持。

在这里插入图片描述

19.4、Nacos与其他注册中心对比

Nacos和CAP:

在这里插入图片描述

CAP:

C一致性A高可用P容错性。参考:CAP原则,主流选用的都是 AP 模式,保证系统的高可用。

何时选择使用何种模式?

一般来说,如果不需要存储服务级别的信息,且服务实例是通过Nacos-client注册,并能够保证心跳上报,那么就可以选择 AP 模式。当前主流的服务如 Spring Cloud 和 Dubbo 服务,都适用于 AP 模式,**AP模式为了服务的可用行而减弱了一致性,因此 AP 模式下只支持注册临时实例**。

如果需要在服务级别编辑或者存储配置信息,那么 CP 是必须的,K8S服务DNS服务则适用于 CP 模式。CP模式下则支持注册**持久化实例,此时则是以Raft协议为集群运行模式,该模式下注册实例之前必须先注册服务,如果服务不存在,则会返回错误。**

而Nacos支持AP和CP模式的切换:

在这里插入图片描述

Nacos的全景图:

img

Nacos AP/CP模式切换:

Nacos 集群默认支持的是CAP原则中的 AP原则,但是也可切换为CP原则,切换命令如下:

1
curl -X PUT '$NACOS_SERVER:8848/nacos/v1/ns/operator/switches?entry=serverMode&value=CP'

同时微服务的 bootstrap.yml需配置如下选项指明注册为临时/永久实例,AP模式不支持数据一致性,所以只支持服务注册的临时实例CP模式支持服务注册的永久实例,满足配置文件的一致性。

1
2
3
4
5
6
#false为永久实例,true表示临时实例开启,注册为临时实例
spring:
cloud:
nacos:
discovery:
ephemeral: false

19.5、Nacos用作服务注册中心

Nacos 可以替代 Eureka 来作为 **服务注册中心**。附:Nacos 服务注册中心官方文档

  • 基于Nacos的服务提供者(provider)

    1. 父pom引入spring-cloud-alibaba 依赖:

      1
      2
      3
      4
      5
      6
      7
      8
      <!--spring cloud alibaba 2.1.0.RELEASE-->
      <dependency>
      <groupId>com.alibaba.cloud</groupId>
      <artifactId>spring-cloud-alibaba-dependencies</artifactId>
      <version>2.1.0.RELEASE</version>
      <type>pom</type>
      <scope>import</scope>
      </dependency>

      当前模块pom引入 nacos-discovery 依赖:

      1
      2
      3
      4
      5
       <!--引入 nacos-discovery 依赖-->
      <dependency>
      <groupId>com.alibaba.cloud</groupId>
      <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
      </dependency>
    2. applicaiton.yml 文件配置:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      server:
      port: 9021
      spring:
      application:
      name: nacos-payment-provider
      cloud:
      nacos:
      discovery:
      server-addr: localhost:8848
      management:
      endpoints:
      web:
      exposure:
      include: "*"
    3. 主启动加上@EnableDiscoveryClient

    4. 业务类:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      @RestController
      @Slf4j
      @RequestMapping("/payment")
      public class PaymentController {

      @Value("${server.port}")
      private String serverPort;

      @GetMapping("/nacos/{id}")
      public String getPayment(@PathVariable("id") Integer id){
      return "Alibaba Nacos registry,server "+ serverPort+"----- id:"+id;
      }
      }
    5. 启动服务模块,查看服务是否注册到 Nacos

      启动服务模块,进入 Nacos 控制台,在 服务管理 → 服务列表 中可以看到,我们定义的服务名 nacos-payment-provider 已经成功注册到 Nacos 注册中心。

      在这里插入图片描述

  • 可以多来个服务端,与 9021 组成集群(9022、9023等等,做法与上一致)

    提示:9022 和 9023 除端口外,其他配置都相同。在测试环境(只能在测试环境里用,可能出现未知的bug)使用时,此处还有个取巧的方法,**可以通过直接拷贝虚拟端口映射,来创建 9002 模块**。我们使用 9021 来创建9022/9023,(实质端口为9021)如下图所示:

    在这里插入图片描述

  • 基于Nacos的服务消费者(consumer)

    1. 当前模块pom引入 nacos-discovery 依赖:

      1
      2
      3
      4
      5
      <!--SpringCloud ailibaba nacos -->
      <dependency>
      <groupId>com.alibaba.cloud</groupId>
      <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
      </dependency>
    2. Nacos 默认支持负载均衡

      spring-cloud-starter-alibaba-nacos-discovery包里就整合有ribbon

      在这里插入图片描述

      也就是说:我们可以使用ribbon的负载均衡与RestTemplate

    3. applicaiton.yml 文件配置:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      server:
      port: 83

      spring:
      application:
      name: cloud-nacos-order
      cloud:
      nacos:
      discovery:
      server-addr: localhost:8848

      #消费者将要去访问的微服务名称
      server-url:
      nacos-user-service: http://nacos-payment-provider
    4. 主启动加注解@EnableDiscoveryClient

    5. 业务类:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      @RestController
      @RequestMapping("/consumer/payment")
      public class OrderNacosController {

      @Resource
      private RestTemplate restTemplate;

      @Value("${server-url.nacos-user-service}")
      private String serverURL;

      @GetMapping("/nacos/{id}")
      public String paymentInfo(@PathVariable("id") Long id) {
      return restTemplate.getForObject(serverURL+"/payment/nacos/"+id,String.class);
      }
      }
    6. RestTemplate配置类(注意加上@LoadBalanced实现负载均衡):

      1
      2
      3
      4
      5
      6
      7
      8
      9
      @Configuration
      public class ApplicationContextConfig {

      @Bean
      @LoadBalanced
      public RestTemplate getRestTemplate(){
      return new RestTemplate();
      }
      }
    7. 启动服务消费者,查看服务是否注册到 Nacos

    8. 服务调用测试,是否实现负载均衡。通过服务端对客户端服务进行调用:http://localhost:83/consumer/payment/nacos/31。采用轮询的方式,实现了负载均衡。

  • 也可以用OpenFeign+Nacos实现服务消费者(consumer)

    1. 引入 spring-cloud-openfeign 依赖:

      1
      2
      3
      4
      5
      <!-- 引入 spring-cloud-openfeign 依赖-->
      <dependency>
      <groupId>org.springframework.cloud</groupId>
      <artifactId>spring-cloud-starter-openfeign</artifactId>
      </dependency>
    2. application.yml配置同上(改端口也行)

    3. 主启动类加上@EnableDiscoveryClient与@EnableFeignClients两个注解。

    4. 编写OpenFeign的服务接口:

      1
      2
      3
      4
      5
      6
      7
      @Service
      @FeignClient(value = "nacos-payment-provider")
      public interface OrderNacosService {

      @GetMapping(value = "/payment/nacos/{id}")
      public String getPayment(@PathVariable("id") Long id);
      }

      此处注意:注解@FeignClient里的服务名”nacos-payment-provider”必须和服务提供端的yml配置一致,大小写敏感(Eureka 大小写不敏感,Nacos 不同,大小写会导致调用失败)

      具体可参考ISSUE

    5. 业务类

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      @RestController
      @RequestMapping("/consumer/payment")
      public class OrderNacosController {

      @Resource
      private OrderNacosService service;

      @GetMapping("/nacos/{id}")
      public String paymentInfo(@PathVariable("id") Long id){
      return service.getPayment(id);
      }
      }
    6. 启动测试

19.6、Nacos用作服务配置中心

19.6.1、Nacos作为配置中心–基础配置
  1. 当前模块 pom 引入 nacos-config 依赖:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    <!--引入nacos-config配置-->
    <dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
    </dependency>
    <!--注册到 Nacos,需引入nacos-discovery配置-->
    <!--引入nacos-discovery配置-->
    <dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
    </dependency>
  2. 进行yml配置:

    此处需要配置bootstrap.ymlapplication.yml两个文件bootstrap.yml用作系统级资源配置项,application.yml用作用户级的资源配置项。在项目中两者配合共同生效,bootstrap.yml优先级更高。
    bootstrap.yml:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    # nacos配置
    server:
    port: 3377

    spring:
    application:
    name: nacos-config-client
    cloud:
    nacos:
    discovery:
    server-addr: localhost:8848 #Nacos服务注册中心地址
    config:
    server-addr: localhost:8848 #Nacos作为配置中心地址
    file-extension: yaml #指定yaml格式的配置


    # ${spring.application.name}-${spring.profile.active}.${spring.cloud.nacos.config.file-extension}
    # nacos-config-client-dev.yaml

    # nacos-config-client-test.yaml ----> config.info

    application.yml:

    1
    2
    3
    4
    5
    spring:
    profiles:
    active: dev # 表示开发环境
    #active: test # 表示测试环境
    #active: info
  3. 主启动类添加 @EnableDiscoveryClient 注解

  4. 业务类:(添加 @RefreshScope 实现配置自动更新)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    @RestController
    @RefreshScope//支持Nacos的动态刷新功能
    public class ConfigClientController {

    @Value("${config.info}")
    private String configInfo;

    @GetMapping("/config/info")
    public String getConfigInfo() {
    return configInfo;
    }
    }
  5. Nacos中添加配置项:

    进入 Nacos → 配置管理 → 配置列表 → + 号 添加配置项。Data ID 按规则编写,Group 在接下来的 分类配置 会有介绍。

    在这里插入图片描述

    在这里插入图片描述

  6. 启动测试,是否能够获取Nacos配置:

    通过 http://localhost:3377/config/info 测试,发现可以正确获取 Nacos 配置中心的配置信息

    在这里插入图片描述

  7. Nacos 自带动态刷新:

    在使用 Spring Cloud Config 时,需要配合 Spring Cloud Bus + RabbitMQ 中间件,用curl进行广播方式才能 实现动态刷新**。Nacos则自带动态刷新,修改下Nacos中的yaml配置文件,再次调用查看配置的接口,就会发现配置已经刷新。**

19.6.2、dataId 命名规则

在 Nacos Spring Cloud 中, dataId 有明确的配置规则,官方也有说明。进入链接查看:官网链接

dataId 的完整格式如下:

1
${prefix}-${spring.profile.active}.${file-extension}
  • prefix 默认为 spring.application.name 的值,也可以通过配置项 spring.cloud.nacos.config.prefix来配置。
  • spring.profile.active 即为当前环境对应的 profile。注意:当 spring.profile.active 为空时,对应的连接符 - 也将不存在,dataId 的拼接格式变成 ${prefix}.${file-extension}(建议:不要让 spring.profile.active 为空,或许会有一些意外的问题,未知的bug)
  • file-exetension 为配置内容的数据格式,可以通过配置项 spring.cloud.nacos.config.file-extension 来配置。目前只支持 properties 和 yaml 类型。

img

img

此处注意:在Nacos网页中进行添加配置项时,在填写Data ID需要注意,若你采用的是yaml格式的话,如nacos-config-dev.yaml中的yaml不能省略成yml,即nacos-config-dev.yml。若省略,程序启动会报错(找不到配置项config.info)。这是Nacos的一个小坑(bug)。

19.6.3、Nacos作为配置中心–分类配置

项目开发中,一定会遇到多环境多项目管理问题。遇到下面问题时,Nacos 基础配置显然无法解决这些问题,接下来就对Nacos 命名空间Group相关概念的了解。

  • 问题1: 实际开发中,通常一个系统会准备dev开发环境test测试环境prod生产环境,如何保证指定环境启动时服务能够正确读取到 Nacos 上相应环境的配置文件?
  • 问题2: 一个大型的分布式微服务系统会有很多个微服务子项目,每个微服务项目又都会有相应的dev开发环境test测试环境prod生产环境 等,那怎么对这些微服务配置进行管理呢?

这是就可以用到Nacos的分类功能了。

19.6.3.1、Nacos的命名规则说明

Nacos 命名由Namespace(命名空间) + Group(分组) + Data ID(实例ID) 三部分组成,类似于 Java 中的 package(报名) + class(类名) 方式。最外层 Namespace 用于区分部署环境Group 和 Data ID 逻辑上用于区分两个目标对象

在这里插入图片描述

默认情况下:

Namespace = public,Group = DEFAULT_GROUP,Cluster=DEFAULT

Namespace**主要用来实现隔离**,Nacos 默认的命名空间是public。比方说我们现在有三个环境:开发、测试、生产环境,我们就可以创建三个 Namespace,不同的 Namespace 之间是隔离的;

Group**是一组配置集,是组织配置的维度之一。默认是DEFAULT_GROUP。通过一个有意义的名称对配置集进行分组,从而区分 Data ID 相同的配置集。配置分组的常见场景:不同的应用或组件使用了相同的配置类型,就可以把不同的微服务划分到同一个分组里面去,从而解决问题2;如 database_url 配置和 MQ_topic 配置。**

Service**微服务;一个 Service 可以包含多个Cluster(集群),Nacos 默认 Cluster 是DEFAULT,Cluster 是对指定微服务的一个虚拟划分比方说为了容灾,将 Service 微服务分别部署在了杭州机房和广州机房,这是就可以给杭州机房的 Service 微服务起一个集群名称(HZ),给广州机房的 Service 微服务起一个集群名称(GZ),还可以尽量让同一个机房的微服务互相调用,以提升性能。**

Instance**,就是一个个微服务实例**。

img

保留空间public是不能被删除的。。

img

19.6.3.2、新建Namespace

选择 命名空间 → 新建命名空间**,进行命名空间的设置。在 Nacos 1.1.4 版本,还不支持自定义命名空间ID,Nacos 1.2.0 版本后开始支持自定义命名空间ID 了。更推荐你使用自定义命名空间**。

在这里插入图片描述

在这里插入图片描述

19.6.3.3、新建 Group

新建配置自定义Group名称。Group 就是根据需求的不同,将微服务划分到同一个分组里面去,来解决问题2

在这里插入图片描述

19.6.3.4、将 namespace 和 Group 应用到项目中

只需要在bootstrap.yml中添加 namespacegroup 两个属性即可。**namespace** 属性:此处配置为 namespace **命名空间 ID**,自定义namespace时,推荐还是自定义名称,否则就是一串很长的字符串流水号,而且还语意不明。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
server:
port: 3377

spring:
application:
name: nacos-config-client
cloud:
nacos:
discovery:
server-addr: localhost:8848 #Nacos服务注册中心地址
config:
server-addr: localhost:8848 #Nacos服务配置中心地址
file-extension: yaml #指定yaml格式的配置
# 指定 namespace 和 group
namespace: dev-nsid #指定Namespace命名空间
group: Group_A #指定Group分组
19.6.3.5、指定 namespace 和 group 后,读取的便是对应配置内容

在这里插入图片描述

19.7、Nacos 集群搭建和持久化配置(Linux + Mysql)

19.7.1、Nacos集群官方架构图

在这里插入图片描述

VIP:此处的 VIP 指代的是 Virtual IP(虚拟IP)的意思,通常情况下指代的是Nginx

说明: 开源时,推荐用户把所有服务列表放到一个vip下面,然后挂到一个域名下面:

http://ip1:port/openAPI 直连ip模式,机器挂则需要修改ip才可以使用。
http://VIP:port/openAPI 挂载VIP模式,直连vip即可,下面挂server真实ip,可读性不好。
http://nacos.com:port/openAPI 域名 + VIP模式,可读性好,而且换ip方便,推荐模式。

19.7.2、Nacos集群真实架构图

在这里插入图片描述

19.7.3、Nacos在linux下安装下载

1、在官网下载xxx.tar.gz文件,并转移进Linux服务器。

2、nacos 安装目录:/usr/local/nacos/

3、使用tar -zxvf 命令解压

19.7.4、Nacos数据库支持(derby )

手动将Nacos服务关闭再启动。存储在Nacos中的配置信息并不会丢失。这是因为 Nacos 默认内置DerBy数据库。 嵌入式数据库,nacos pom.xml 有引入derby依赖。以下摘自Nacos的github源码

1
2
3
4
<dependency>
<groupId>org.apache.derby</groupId>
<artifactId>derby</artifactId>
</dependency>

Nacos的derby数据库记录的数据放在nacos安装目录下的/conf/nacos-mysql.sql的sql文件里。

Nacos 0.7版本之前,在单机模式时nacos使用嵌入式数据库(derby)实现数据的存储,不方便观察数据存储的基本情况。0.7 版本增加了支持 mysql 数据源能力。 具体的操作步骤:

  1. 安装数据库,版本要求:5.6.5+
  2. 初始化mysql数据库,数据库初始化文件:nacos-mysql.sql
  3. 修改conf/application.properties文件,增加支持mysql数据源配置(目前只支持mysql),添加mysql数据源的url、用户名和密码。
  4. 再启动nacos,nacos所有写嵌入式数据库的数据都写到了mysql。
19.7.5、Nacos 集群部署搭建

Nacos支持三种部署模式

  • 单机模式 - 用于测试和单机试用。
  • 集群模式 - 用于生产环境,确保高可用。
  • 多集群模式 - 用于多数据中心场景。

  此处附:Nacos集群模式部署官方文档

若单机要集群Nacos的话要删除其中的data文件夹。资料

19.7.5.1、节点部署情况
服务器IP 部署服务 端口 备注
192.168.204.202 MySQL 5.7.28 3306 测试,使用单机 MySQL,高可用参考:MySQL 5.7.28 主从复制实现
192.168.204.202 Nginx 1.4.1 8807 测试,使用单机 Nginx,Nginx集群搭建请自行了解(Nginx默认端口为80,此处负载均衡使用8087端口)
192.168.204.202 nacos 8848 集群节点01:nacos 01
192.168.204.203 nacos 8848 集群节点02:nacos 02
192.168.204.204 nacos 8848 集群节点03:nacos 03

提示: 三台机器配置相同,此处对一台进行配置。使用命令 scp 发送到其他两台机器即可,此处以192.168.204.202为例说明。

19.7.5.2、derby 切换 mysql 数据库配置
  1. 执行nacos-mysql.sql脚本:

    进入 nacos 安装目录 conf 文件下,找到 nacos-mysql.sql 脚本

    创建 nacos_config 数据库,并执行 nacos-mysql.sql 脚本。

    在这里插入图片描述

    在这里插入图片描述

  2. 修改application.properties,添加mysql支持:

    进入nacos安装目录 conf 文件下,application.properties 配置文件添加 mysql 支持。

    其中:

    • 在修改所有文件之前建议保存副本

      img

    • db.user与db.password填写本机的用户名与密码

    1
    2
    3
    4
    5
    6
    spring.datasource.platform=mysql

    db.num=1
    db.url.0=jdbc:mysql://192.168.204.202:3306/nacos_config?characterEncoding=utf8&connectTimeout=1000&socketTimeout=3000&autoReconnect=true
    db.user=root
    db.password=root

    img

  3. cluster.conf 配置:

    进入 conf 目录,使用命令:cp cluster.conf.example cluster.conf拷贝一份,重命名为cluster.conf,在 cluster.conf 中进行配置,说明哪几台机器组成集群(填写的是 nacos 集群3个节点所在 IP:端口号,不要写127.0.0.1,必须是Linux的真实IP),可以通过命令hostname -i查看Linux的真实IP。

    1
    2
    3
    192.168.204.202:8848
    192.168.204.203:8848
    192.168.204.204:8848
  4. 修改 nacos 启动堆栈大小:

    (nacos 启动时,默认 -Xms2g -Xmx2g。如果你是在多台虚拟机测试,配置紧张,这一步就比较重要了。如果服务器配置很优秀,这一步可以绕过。)

    配置紧张会导致以下情况的出现:

    1. nacos 服务启动很慢很慢的情况;
    2. nacos 服务注册中心,有3个提供服务,你可能只能看到 2个、1个、0个服务节点,还会来回跳动的问题。
    3. 反正还是会出现一些意想不到的问题,视情况而配置。

    Xms 是指设定程序启动时占用内存大小。一般来讲,大点,程序会启动的快一点,但是也可能会导致机器暂时间变慢。
    Xmx 是指设定程序运行期间最大可占用的内存大小。如果程序运行需要占用更多的内存,超出了这个设置值,就会抛出OutOfMemory异常。

    我们进入 bin 目录,使用 vim startup.sh 对其进行修改,将其按照配置修改到指定大小即可。(好像可以通过启动时添加 Xms 参数方式修改,我忘了怎么搞了,此处就直接修改 .sh 启动脚本了)(建议修改之前进行备份)

    在这里插入图片描述

  5. 使用scp命令,进行nacos配置分发:

    192.168.204.202 一台 nacos 集群环境配置完成,使用 scp 命令,将 nacos 目录分发到 203/204两台机器。scp 命令的使用如下:(scp命令使用介绍,请参考:Linux命令—scp),不使用 scp 命令,你也可以 rz、sz 以打包的方式进行上传。

    scp -r /usr/local/env/nacos root@192.168.204.203:/usr/local/nacos/
    scp -r /usr/local/env/nacos root@192.168.204.204:/usr/local/nacos/

  6. 以上第5步也可以通过编辑Nacos的启动脚本startup.sh,使他能够接受不同的启动端口(建议备份):

    img

    img

    img

    img

    之后就可以通过./startup.sh -p 端口号执行:

    img

19.7.5.2、Nginx的配置,由他作为负载均衡器

在此处,已经默认 Nginx 服务已经OK,Nginx 服务跑在192.168.204.202。如需 Nginx 的搭建过程,请自行。

进入nginx/conf 目录,对 nginx.conf添加 nacos 集群配置,配置如下图所示:

在这里插入图片描述

配置完成,进入 sbin 目录,使用./nginx -c /usr/local/nginx/nginx-1.16.0/conf/nginx.conf启动 nginx,使用-c加载指定配置文件,路径为 nginx.conf 所在路径。启动完成,通过命令:ps aux | grep nginx查看 nginx 是否启动。如图已经启动成功。

在这里插入图片描述

19.7.5.3、启动nacos集群

启动集群中的3台nacos。可以通过命令ps -ef|grep nacos|grep -v grep grep |wc -l查看nacos集群启用的端口数量,也可以通过nacos安装路径logs目录,使用 tail -f nacos.log 查看日志。

启动成功提示:

如果虚拟机资源紧张,此处会一直很长时间在 nacos is starting... 状态,一定注意自己服务器的配置。

在这里插入图片描述

19.7.5.4、进入Nacos控制台

已经配置 Nginx 负载均衡,所以我们使用 Nginx 8087 端口进入Nacos 控制台:**http://192.168.204.202:8087/nacos/**

在这里插入图片描述

19.7.5.5、查看集群节点启动情况

在这里插入图片描述

19.7.5.6、Nacos集群环境,项目application.yml中nacos地址需写 Nginx 地址
1
2
3
4
5
6
7
spring:
application:
name: nacos-payment-provider
cloud:
nacos:
discovery:
server-addr: 192.168.204.202:8087 #配置Nacos地址(集群使用Nginx,此处需配置Nginx地址)

通过访问8087端口(Ngnix)来实际访问三个nacos端口。

在这里插入图片描述

20、Sentinel实现服务降级、服务熔断、服务限流

资料查询:

  附官网:Sentinel GitHub 官网

  中文介绍文档:Sentinel Wiki中文介绍文档

  Sentinel 使用介绍:Spring Cloud 关于 Sentinel 使用文档

20.1、是什么

(本段内容摘自:Sentinel Wiki 中文文档,一句话解释Sentinel,就是之前介绍过的:Hystrix 实现服务降级、服务熔断、服务限流,Sentinel 后起之秀,更优秀)

随着微服务的流行,服务和服务之间的稳定性变得越来越重要。Sentinel 以流量为切入点,从流量控制熔断降级系统负载保护等多个维度保护服务的稳定性。

img

20.2、Sentinel的特征

  • 丰富的应用场景:Sentinel 承接了阿里巴巴近 10 年的双十一大促流量的核心场景,例如秒杀(即突发流量控制在系统容量可以承受的范围)、消息削峰填谷、集群流量控制、实时熔断下游不可用应用等。
  • 完备的实时监控:Sentinel 同时提供实时的监控功能。您可以在控制台中看到接入应用的单台机器秒级数据,甚至 500 台以下规模的集群的汇总运行情况。
  • 广泛的开源生态:Sentinel 提供开箱即用的与其它开源框架/库的整合模块,例如与 Spring Cloud、Dubbo、gRPC 的整合。您只需要引入相应的依赖并进行简单的配置即可快速地接入 Sentinel。
  • 完善的 SPI 扩展点:Sentinel 提供简单易用、完善的 SPI 扩展接口。您可以通过实现扩展接口来快速地定制逻辑。例如定制规则管理、适配动态数据源等。

20.3、Sentinel的作用

在这里插入图片描述

Sentinel 的开源生态:

在这里插入图片描述

Sentinel 分为两个部分:

  • 核心库(Java 客户端):不依赖任何框架/库,能够运行于所有 Java 运行时环境,同时对 Dubbo / Spring Cloud 等框架也有较好的支持。
  • 控制台(Dashboard):基于 Spring Boot 开发,打包后使用 java -jar xxx.jar 方式可以直接运行,不需要额外的 Tomcat 等应用容器。

20.4、安装Sentinel控制台(Dashboard)

  1. Sentinel Dashboard 下载地址:Sentinel Dashboard 下载地址

  2. 环境:JDK 8,端口:8080 不被占用。

  3. 在安装目录进入 cmd 控制台,使用 java -jar sentinel-dashboard-1.7.2.jar 方式直接运行。

    image-20210313024314111

  4. 使用 http://localhost:8080 访问 Sentinel 图形管理界面。

    登陆账号、密码均为:**sentinel**

    image-20210313024508488

  5. 至此,Sentinel控制台(Dashboard)安装成功。

20.5、微服务项目整合Sentinel

使用 Sentinel 最好配好 Nacos 一起使用。

启动Sentinel与Nacos的微服务,并通过http://localhost:8080与http://localhost:8848/nacos/#/login进行访问。

新建微服务模块:

  1. 添加pom依赖:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    <!--引入 sentinel 依赖-->
    <dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-sentinel</artifactId>
    </dependency>

    <!--nacos服务注册依赖-->
    <dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
    </dependency>

    <!--sentinel持久化需要的依赖(后续持久化会用到,此处可有可无)-->
    <dependency>
    <groupId>com.alibaba.csp</groupId>
    <artifactId>sentinel-datasource-nacos</artifactId>
    </dependency>
  2. application.yml 配置:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    server:
    port: 8401

    spring:
    application:
    name: cloudalibaba-sentinel-service
    cloud:
    nacos:
    discovery:
    server-addr: localhost:8848 #Nacos服务注册中心地址
    # 添加sentinel相关配置
    sentinel:
    transport:
    dashboard: localhost:8080 #配置sentinel dashboard地址
    port: 8719 #sentinel默认8719,假如被占用了会自动从8719开始依次+1扫描。直至找到未被占用的端口

    #暴露,用于监控等
    management:
    endpoints:
    web:
    exposure:
    include: '*'
  3. 主启动类添加 @EnableDiscoveryClient 注解

  4. 业务类:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    @RestController
    public class FlowLimitController {

    @GetMapping("/testA")
    public String testA() {
    return "-----testA";
    }

    @GetMapping("/testB")
    public String testB(){
    return "-----testB";
    }
    }
  5. 启动项目,查看Sentinel是否成功监控:

    此时进入 Sentinel 图形管理界面,并没有看到关于微服务任何信息。这是因为 Sentinel 采用的懒加载机制,只有执行一次方法调用,才能被Sentinel监控到。 然后多次调用 /testA 接口,在实时监控便能够看到接口 调用时间、QPS、响应时间 等内容。 说明:Sentinel 8080 已经在监控微服务 8401,监控会有一丁点的延迟。服务一段时间不调用,实时监控会消失。

20.6、Spring Cloud Alibaba Sentinel 流控、降级、热点、系统规则

image-20210313190605946

20.6.1、流控规则

流控规则,即:流量控制规则。可自行参考官网介绍:GitHub 流量控制。具体配置有 资源名**、针对来源阈值类型是否集群流控模式单机阈值流控效果** 这几项,它们配合进行使用。

可以通过簇点链路的方式添加,也可以通过流控规则方式添加。

image-20210313190901838

**资源名**:唯一路径,默认为请求路径(也可以是后续介绍的 @SentinelResource 注解的 value 属性值)
**针对来源**:Sentinel 可以针对调用者进行限流,填写微服务名。默认为 default(不区分来源)
**是否集群**:本文为单机测试,是否集群不选

20.6.1.1、阈值类型:QPS

QPS(每秒钟的请求数量):当调用该 API 的 QPS 达到阈值的时候,进行限流。

配置(默认流控模式为直接,流控效果为快速失败):

在这里插入图片描述

效果:/testA 服务,每秒只允许调用 1 次,超出阈值后,直接失败(流控 Sentinel 默认提示:**Blocked by Sentinel(flow limiting)**)

20.6.1.2、阈值类型:线程数

线程数:当调用该 API 的 线程数 达到阈值的时候,进行限流。

配置:

在这里插入图片描述

效果

/testA 服务,单个线程只允许调用 1 次,超出阈值后,直接失败(流控 Sentinel 默认提示:**Blocked by Sentinel(flow limiting)**)

20.6.1.3、流控模式:直接

效果:超出阈值后,直接失败。

20.6.1.4、流控模式:关联

关联:当关联的资源达到阈值时,就限流自己。**当与 A 资源关联的 B 资源达到阈值时,就限流自己(A)**,即:B惹事,A挂了

应用场景:

双十一,支付接口下单接口关联。当支付接口达到阈值,就限流下单接口。

配置:

在这里插入图片描述

效果

/testA 服务关联/testB 服务,1s 调用 1次,服务正常。当狂点刷新调用 /testB 服务,超出阈值 QPS = 1 后,此时 /testA 被限流了,这就是 **B惹事,A挂了**。

20.6.1.5、流控模式:链路

链路:当链路中的资源达到阈值时,就会对使用到该资源的链路进行流控。当A01 资源达到设定阈值时,所有调用该服务的链路,都会被限流,即:A01 挂了,用到我的链路都得挂。

此处会用到 @SentinelResource 注解 value 属性值 作为资源名。此处只是使用一下。

模拟两条请求链路:

A链路: A → A01 → A04 → A05
B链路: B → A01 → A02 → A03

在这里插入图片描述

配置:

在这里插入图片描述

效果

testA01 服务进行 链路 流控,该服务关联有 A 和 B 两条链路。当 A 链路1s 调用 1次,服务正常。当该链路调用 **超出阈值 QPS = 1 后,此时A链路都会被限流,同时因为B链路也调用 testA01,所以B链路也会同时被限流调用**。

20.6.1.6、用postman进行循环访问
  1. postman里新建多线程集合组:

    img

  2. 访问地址添加进新线程组:

    img

  3. run:

    img

20.6.1.7、流控效果:快速失败

效果:

直接失败。(流控 Sentinel 默认提示:**Blocked by Sentinel(flow limiting)**)

20.6.1.8、流控效果:Warm Up

Warm Up:某个服务,日常访问量很少,基本为 0,突然1s访问量 10w,这种极端情况,会直接将服务击垮。所以通过配置 流控效果:Warm Up**,允许系统慢慢呼呼的进行预热**,经预热时长逐渐升至设定的QPS阈值。以下图片来自官网

image限限流 冷启动:(以下来自官网

当流量突然增大的时候,我们常常会希望系统从空闲状态到繁忙状态的切换的时间长一些。即如果系统在此之前长期处于空闲的状态,我们希望处理请求的数量是缓步的增多,经过预期的时间以后,到达系统处理请求个数的最大值。Warm Up(冷启动,预热)模式就是为了实现这个目的的。

这个场景主要用于启动需要额外开销的场景,例如建立数据库连接等。

它的实现是在 Guava 的算法的基础上实现的。然而,和 Guava 的场景不同,Guava 的场景主要用于调节请求的间隔,即 Leaky Bucket,而 Sentinel 则主要用于控制每秒的 QPS,即我们满足每秒通过的 QPS 即可,我们不需要关注每个请求的间隔,换言之,我们更像一个 Token Bucket

默认 coldFactor 为 3,即请求 QPS 从 threshold / 3 开始,经预热时长逐渐升至设定的 QPS 阈值。

公式:阈值/coldFactor(默认值为3)

源码:

img

应用场景:

秒杀系统。秒杀系统在开启的瞬间,会有很多的流量上来,很有可能将系统打死。预热方式就是为了保护系统,可以慢慢的将流量放进来,最终将阈值增长到指定的数值。

配置:

在这里插入图片描述

效果:

/testA 服务,设置 QPS 单机阈值为 10,采用 Warm Up 预热的方式,预热时长为 5s。根据计算公式 **10 / 3 = 3**,前 5s 的阈值为 3,预热 5s 后阈值增长到 10。

即:前5s内,访问超过 3 次便会被限流;5s 后,阈值增长到 10,此时访问超过 3 次也不会被限流这就是 Warm Up 预热效果。

20.6.1.9、流控效果:排队等待

排队等待:让请求以均匀的速度通过,对应的是漏桶算法。这种方式主要用于处理间隔性突发的流量,例如消息队列。

应用场景:

在某一秒有大量的请求到来,而接下来的几秒则处于空闲状态。我们希望系统能够在接下来的空闲期间逐渐处理这些请求,而不是在第一秒直接拒绝多余的请求。**官网**

注意:匀速排队模式暂时不支持 QPS > 1000 的场景。

image

配置:

在这里插入图片描述

效果

/testA 服务,设置 QPS 单机阈值为 2,每秒只接收 2 个请求。设置超时时间 5s。采用漏斗算法,让后台匀速的处理请求,而不是直接拒绝更多的请求。超时的请求则被抛弃,返回错误信息。(流控 Sentinel 默认提示:**Blocked by Sentinel(flow limiting)**)

20.6.2、降级规则

降级规则。可自行参考官网介绍:GitHub 熔断降级。降级策略有 慢调用比例**、异常比例异常数** 三种。

熔断状态:

熔断有三种状态,分别为OPENHALF_OPENCLOSED

img

image-20210313202500145

**资源名**:唯一路径,默认为请求路径(也可以是后续介绍的 @SentinelResource 注解的 value 属性值)

20.6.2.1、慢调用比例

慢调用比例 (SLOW_REQUEST_RATIO):选择以慢调用比例作为阈值,需要设置允许的慢调用RT(即最大的响应时间),请求的响应时间大于该值则统计为慢调用。当单位统计时长statIntervalMs)内请求数目大于设置的最小请求数目并且慢调用的比例大于阈值,则接下来的熔断时长内请求会自动被熔断。经过熔断时长后熔断器会进入探测恢复状态HALF-OPEN状态),若接下来的一个请求响应时间小于设置的慢调用 RT 则结束熔断,若大于设置的慢调用 RT 则会再次被熔断。(以上来自官网

img

执行逻辑

  • 熔断(OPEN):请求数大于最小请求数并且慢调用的比率大于比例阈值则发生熔断,熔断时长为用户自定义设置。

  • 探测(HALFOPEN):当熔断过了定义的熔断时长,状态由熔断(OPEN)变为探测(HALFOPEN)。

  • 如果接下来的一个请求小于最大RT,说明慢调用已经恢复,结束熔断,状态由探测(HALF_OPEN)变更为关闭(CLOSED)

  • 如果接下来的一个请求大于最大RT,说明慢调用未恢复,继续熔断,熔断时长保持一致

注意Sentinel默认统计的RT上限是4900ms,超出此阈值的都会算作4900ms,若需要变更此上限可以通过启动配置项-Dcsp.sentinel.statistic.max.rt=xxx来配置。

20.6.2.2、异常比例

异常比例QPS >= 5 && 异常比例超过设定的阈值,便会发生服务降级 。

异常比例为 0.0~1.0 范围内值。**时间窗口就是断路器开启时间长短(降级时间)** 。要看官网介绍来这里:异常比例介绍

图示:

在这里插入图片描述

配置:

在这里插入图片描述

效果:

/testA 服务,设置 降级策略为 **异常比例**,异常比例设为 **0.5**,时间窗口为 **5s**。即:1s 发送6个请求,异常比例超过 50%,就会被熔断,断路器打开5s,5s后自动关闭,继续提供服务。

如果1s发送6次请求,前3次网页报错,因为第4次访问后,异常比例 > 50%,第4次便会被熔断,报 **Blocked by Sentinel(flow limiting)**。5s后继续提供服务哦。

20.6.2.3、异常数

异常数:指的是资源 近1分钟 的异常数目,超过阈值之后会进行熔断。官网

重点注意:异常数,统计时间窗口是分钟级别,若 timeWindow 小于 60s,则结束熔断状态后仍可能再次进入熔断状态。推荐 时间窗口一定要>=60s

图示:

在这里插入图片描述

配置:

在这里插入图片描述

效果:

/testA 服务,设置 降级策略为 **异常数**,异常数设为 **5**,时间窗口为 **60s**。即:调用服务,当异常数超过5个时,开启断路器,执行熔断操作。60s 后,断路器关闭,服务恢复正常。

执行 /testA 服务请求,因为每个请求都是异常,前5次调用正常返回,只是报异常到前台(错误页面);第6次服务调用时,便会被降级熔断。报 **Blocked by Sentinel(flow limiting)**。60s后继续提供服务哦。

20.6.3、热点规则

(本段内容摘自:Github 热点规则官方介绍)

20.6.3.1、何为热点?

热点即经常访问的数据。很多时候我们希望统计某个热点数据中访问频次最高的 Top K 数据,并对其访问进行限制。比如:

商品 ID 为参数,统计一段时间内最常购买的商品 ID 并进行限制
用户 ID 为参数,针对一段时间内频繁访问的用户 ID 进行限制

热点参数限流会统计传入参数中的热点参数,并根据配置的限流阈值与模式,对包含热点参数的资源调用进行限流热点参数限流可以看做是一种特殊的流量控制,仅对包含热点参数的资源调用生效。

Sentinel 利用LRU 策略统计最近最常访问的热点参数,结合令牌桶算法来进行参数级别的流控。热点参数限流支持集群模式。

在这里插入图片描述

20.6.3.2、何为热点限流

一句话解释:根据 url 传递进来的参数进行限流。带这个参数就限流,不带就不限流

20.6.3.3、热点规则

共有 资源名**、限流模式(只支持QPS模式)参数索引单机阈值统计窗口时长是否集群** 六种参数;高级选项还有额外一些参数。

在这里插入图片描述

资源名**:唯一路径,默认为请求路径。此处必须是 @SentinelResource 注解的 value 属性值,配置@GetMapping 的请求路径无效)**
参数索引**:参数索引(从0**开始,0表示第一个参数、1表示第二个参数)

配置:

在这里插入图片描述

效果:

/testC 服务,配置热点key限流。当 1.第个参数存在   2.一秒内调用 /testC 服务 > 5次,满足限流规则。服务将被熔断。断路器打开,5s 后服务恢复正常。

参数例外项配置:

需求(当请求参数name的值为Wade时,改变其限流阈值):

当 name 参数值为 Wade 时,限流阈值变更为 100。此时就需要对 参数例外项 进行配置了。

参数类型支持:int、double、String、long、float、char、byte 7种类型,参数值 指 name 参数的值,限流阈值指该参数值允许的阈值。

配置:

在这里插入图片描述

测试:

调用 URL:

http://localhost:8401/testC?id=1&name=Wade,阈值为200;

http://localhost:8401/testC?id=1&name=Jhon,阈值为200;

20.6.4、系统规则(不常用)
20.6.4.1、是什么

系统保护规则是从应用级别的入口流量进行控制,从单台机器的loadCPU 使用率平均 RT入口 QPS并发线程数等几个维度监控应用指标,让系统尽可能跑在最大吞吐量的同时保证系统整体的稳定性。

系统保护规则是应用整体维度的,而不是资源维度的,并且仅对入口流量生效。入口流量指的是进入应用的流量(EntryType.IN),比如 Web 服务或 Dubbo 服务端接收的请求,都属于入口流量。

Sentinel 系统自适应限流从整体维度对应用入口流量进行控制,结合应用的 Load、CPU 使用率、总体平均 RT、入口 QPS 和并发线程数等几个维度的监控指标,通过自适应的流控策略,让系统的入口流量和系统的负载达到一个平衡,让系统尽可能跑在最大吞吐量的同时保证系统整体的稳定性。

官网

20.6.4.2、系统规则支持的模式
  • Load 自适应(仅对 Linux/Unix-like 机器生效):系统的 load1 作为启发指标,进行自适应系统保护。当系统 load1 超过设定的启发值,且系统当前的并发线程数超过估算的系统容量时才会触发系统保护(BBR 阶段)。系统容量由系统的 maxQps * minRt 估算得出。设定参考值一般是 CPU cores * 2.5。
  • CPU usage(1.5.0+ 版本):当系统 CPU 使用率超过阈值即触发系统保护(取值范围 0.0-1.0),比较灵敏。
  • 平均 RT:当单台机器上所有入口流量的平均 RT 达到阈值即触发系统保护,单位是毫秒。
  • 并发线程数:当单台机器上所有入口流量的并发线程数达到阈值即触发系统保护。
  • 入口 QPS:当单台机器上所有入口流量的 QPS 达到阈值即触发系统保护。
20.6.4.3、入口QPS配置

入口QPS,实用性还是比较危险的。 如果 sentinel 密码被修改,将你的整个系统 入口QPS 配置很小,那么整个系统就瘫痪了。

但是 入口QPS 有总控的功能。最终选择是否使用,还是视情况而定吧

配置:

在这里插入图片描述

效果:

整个系统,每个请求 QPS = 1 正常访问,当该请求 QPS >1 就会被限流。

20.6.5、@SentinelResource

@SentinelResource可以说是 Sentinel 学习的突破口,搞懂了这个注解的应用,基本上就搞清楚了 Sentinel 的大部分应用场景。Sentinel 提供了 @SentinelResource 注解用于定义资源,并提供了AspectJ的扩展用于自动定义资源、处理BlockException等。

20.6.5.1、@SentinelResource 属性介绍
属性名 是否必填 说明
value 资源名称(必填项,需要通过 value 值找到对应的规则进行配置)
entryType entry类型,标记流量的方向,取值IN/OUT,默认是OUT
blockHandler **处理BlockException的函数名称(可以理解为对Sentinel的配置进行方法兜底)**。函数要求:
1.必须是public修饰
2.返回类型原方法一致
3. 参数类型需要和原方法相匹配,并在最后加 BlockException类型的参数。
4. 默认需和原方法在同一个类中(耦合度高)。若希望使用其他类的函数,可配置blockHandlerClass,并指定blockHandlerClass里面的方法。
blockHandlerClass 存放blockHandler的类
对应的处理函数必须public static修饰,否则无法解析,其他要求:同blockHandler。
fallback **用于在抛出异常的时候提供fallback处理逻辑(可以理解为对java异常情况方法兜底)**。
fallback函数可以针对所有类型的异常(除了 exceptionsToIgnore里面排除掉的异常类型)进行处理。函数要求:
1.返回类型原方法一致
2.参数类型需要和原方法相匹配,Sentinel 1.6开始,也可在方法最后加Throwable类型的参数。
3.默认需和原方法在同一个类中(耦合度高)。若希望使用其他类的函数,可配置fallbackClass ,并指定fallbackClass里面的方法。
fallbackClass 存放fallback的类
对应的处理函数必须static修饰,否则无法解析,其他要求:同fallback。
defaultFallback 用于通用的 fallback 逻辑
默认 fallback 函数可以针对所有类型的异常(除了exceptionsToIgnore里面排除掉的异常类型)进行处理。若同时配置了 fallback 和 defaultFallback,以fallback为准。函数要求:
1.返回类型原方法一致
2.方法参数列表为,或者有一个Throwable 类型的参数。
3.默认需要和原方法在同一个类中(耦合度高)。若希望使用其他类的函数,可配置fallbackClass ,并指定fallbackClass 里面的方法。
exceptionsToIgnore 指定排除掉哪些异常。
排除的异常不会计入异常统计,也不会进入fallback逻辑,而是原样抛出。
exceptionsToTrace 需要trace的异常

(加深标注属性为常用属性)

img

20.6.5.2、fallback 指定java异常兜底方法

**fallback只用来处理与Java逻辑异常相关的兜底**。比如:NullPointerException、ArrayIndexOutOfBoundsException 等java代码中的异常,fallback 指定的兜底方法便会生效。

兜底方法与业务方法耦合

1
2
3
4
5
6
7
8
9
10
@GetMapping("/testA")
@SentinelResource(value = "testA", fallback = "fallbackMethod")
public String testA() {
int i = 10 / 0;
return "-----testA";
}

public String fallbackMethod(Throwable e) {
return "限流请求连接(Java类异常)的兜底方法:" + e.getMessage();
}

使用 fallbackClass 将兜底方法与业务解耦合

在这里插入图片描述

20.6.5.3、blockHandler 指定 Sentinel 配置兜底方法

**blockHandler 只用来处理 与 Sentinel 配置有关的兜底**。比如:配置某资源 QPS =1,当 QPS >1 时,blockHandler 指定的兜底方法便会生效。

1
2
3
4
5
6
7
8
9
10
11
12
/**
* 业务逻辑
*/
@GetMapping("/testB")
@SentinelResource(value = "testB",blockHandler = "exceptionMethod")
public String testB() {
return "-----testB";
}

public String exceptionMethod(BlockException exception) {
return "限流@SentinelResource value 属性的兜底方法:" + exception;
}

使用 fallbackClass 将兜底方法与业务解耦合

在这里插入图片描述

20.6.5.4、exceptionsToIgnore 用于指定异常不走兜底方法

使用exceptionsTolgnore属性,来指定某些异常不执行兜底方法,直接显示错误信息。配置 ArithmeticException 异常不走兜底方法。java.lang.ArithmeticException: / by zero ,便不会再执行兜底方法,直接显示错误信息给前台页面。

1
2
3
4
5
6
7
8
9
@GetMapping("/testA")
@SentinelResource(value = "testA",
fallback = "fallbackMethod",
fallbackClass = CustomerFallback.class,
exceptionsToIgnore = ArithmeticException.class)
public String testA() {
int i = 10 / 0;
return "-----testA";
}
20.6.5.5、defaultFallback 用于指定通用的 fallback 兜底方法

使用 defaultFallback 来指定通用的 fallback 兜底方法。

  • 如果当前业务配置有 defaultFallback 和 fallback 两个属性,则优先执行 fallback 指定的方法。
  • 如果 fallback 指定的方法不存在,还会执行 defaultFallback 指定的方法。
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
/**
* 业务逻辑
*/
@GetMapping("/testA")
@SentinelResource(value = "testA",
fallback = "fallbackMethod",
fallbackClass = CustomerFallback.class,
defaultFallback = "defaultFallbackMethod" //直接指定即可,使用比较简单
)
public String testA() {
int i = 10 / 0;
return "-----testA";
}

/**
* 单独一个类,存放兜底方法
*/
public class CustomerFallback {

public static String defaultFallbackMethod(Throwable e) {
return "通用的fallback兜底方法";
}

public static String fallbackMethod(Throwable e) {
return "限流请求连接(Java类异常)的兜底方法:" + e.getMessage();
}
}

上面兜底方案面临的问题:

  1. 系统默认的,没有体现我们自己的业务要求。
  2. 依照现有条件,我们自定义的处理方法又和业务代码耦合在一块,不直观。
  3. 每个业务方法都添加一个兜底的,那代码膨胀加剧。
  4. 全局统一的处理方法没有体现。

客户自定义限流处理逻辑:

  1. 创建CustomerBlockHandler类用于自定义限流处理逻辑:

  2. 自定义限流处理类

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    public class CustomerBlockHandler{
    public static CommonResult handlerException(BlockException exception)
    {
    return new CommonResult(4444,"按客戶自定义,global handlerException----1");
    }
    public static CommonResult handlerException2(BlockException exception)
    {
    return new CommonResult(4444,"按客戶自定义,global handlerException----2");
    }
    }
  3. RateLimitController:

    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
    @RestController
    public class RateLimitController
    {
    @GetMapping("/byResource")
    @SentinelResource(value = "byResource",blockHandler = "handleException")
    public CommonResult byResource()
    {
    return new CommonResult(200,"按资源名称限流测试OK",new Payment(2020L,"serial001"));
    }
    public CommonResult handleException(BlockException exception)
    {
    return new CommonResult(444,exception.getClass().getCanonicalName()+"\t 服务不可用");
    }

    @GetMapping("/rateLimit/byUrl")
    @SentinelResource(value = "byUrl")
    public CommonResult byUrl()
    {
    return new CommonResult(200,"按url限流测试OK",new Payment(2020L,"serial002"));
    }


    @GetMapping("/rateLimit/customerBlockHandler")
    @SentinelResource(value = "customerBlockHandler",
    blockHandlerClass = CustomerBlockHandler.class,
    blockHandler = "handlerException2")
    public CommonResult customerBlockHandler()
    {
    return new CommonResult(200,"按客戶自定义",new Payment(2020L,"serial003"));
    }
    }

    img

Sentinel的三个核心API:

image-20210314010417987

注意异常降级仅针对业务异常,对 Sentinel 限流降级本身的异常(BlockException)不生效。为了统计异常比例或异常数,需要通过 Tracer.trace(ex) 记录业务异常。示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Entry entry = null;
try {
entry = SphU.entry(key, EntryType.IN, key);

// Write your biz code here.
// <<BIZ CODE>>
} catch (Throwable t) {
if (!BlockException.isBlockException(t)) {
Tracer.trace(t);
}
} finally {
if (entry != null) {
entry.exit();
}
}

20.7、服务熔断

sentinel整合ribbon+openFeign+fallback

公共:

  1. 启动nacos和启动sentinel服务

  2. 新建两个服务提供端(实现负载均衡)(详情看上面微服务项目整合Sentinel)

  3. 服务提供端的业务类PaymentController:

    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
    @RestController
    public class PaymentController
    {
    @Value("${server.port}")
    private String serverPort;

    public static HashMap<Long,Payment> hashMap = new HashMap<>();
    // 模拟数据库
    static
    {
    hashMap.put(1L,new Payment(1L,"28a8c1e3bc2742d8848569891fb42181"));
    hashMap.put(2L,new Payment(2L,"bba8c1e3bc2742d8848569891ac32182"));
    hashMap.put(3L,new Payment(3L,"6ua8c1e3bc2742d8848569891xt92183"));
    }

    // 输入1、2、3得到数据,输入4报空指针异常
    @GetMapping(value = "/paymentSQL/{id}")
    public CommonResult<Payment> paymentSQL(@PathVariable("id") Long id)
    {
    Payment payment = hashMap.get(id);
    CommonResult<Payment> result = new CommonResult(200,"from mysql,serverPort: "+serverPort,payment);
    return result;
    }

    }
  4. 新建服务消费端

  5. 业务类CircleBreakerController

20.7.1、Ribbon系列
  1. 添加sentinel依赖pom:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    <!--SpringCloud ailibaba nacos -->
    <dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
    </dependency>
    <!--SpringCloud ailibaba sentinel -->
    <dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-sentinel</artifactId>
    </dependency>
  2. application.yml配置文件:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    server:
    port: 84


    spring:
    application:
    name: nacos-order-consumer
    cloud:
    nacos:
    discovery:
    server-addr: localhost:8848
    sentinel:
    transport:
    #配置Sentinel dashboard地址
    dashboard: localhost:8080
    #默认8719端口,假如被占用会自动从8719开始依次+1扫描,直至找到未被占用的端口
    port: 8719

    #消费者将要去访问的微服务名称(注册成功进nacos的微服务提供者)
    service-url:
    nacos-user-service: http://nacos-payment-provider
  3. 主启动添加@EnableDiscoveryClient

  4. 添加RestTemplate配置类ApplicationContextConfig(添加@LoadBalanced):

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    @Configuration
    public class ApplicationContextConfig
    {
    @Bean
    @LoadBalanced
    public RestTemplate getRestTemplate()
    {
    return new RestTemplate();
    }
    }
20.7.2、OpenFeign系列(Feign组件一般是在消费者侧)
  1. 添加OpenFeign依赖pom:

    1
    2
    3
    4
    5
    <!--SpringCloud openfeign -->
    <dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-openfeign</artifactId>
    </dependency>
  2. application.yml配置文件(激活Sentinel对Feign的支持):

    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
    server:
    port: 84


    spring:
    application:
    name: nacos-order-consumer
    cloud:
    nacos:
    discovery:
    server-addr: localhost:8848
    sentinel:
    transport:
    #配置Sentinel dashboard地址
    dashboard: localhost:8080
    #默认8719端口,假如被占用会自动从8719开始依次+1扫描,直至找到未被占用的端口
    port: 8719

    #消费者将要去访问的微服务名称(注册成功进nacos的微服务提供者)
    service-url:
    nacos-user-service: http://nacos-payment-provider

    # 激活Sentinel对Feign的支持
    feign:
    sentinel:
    enabled: true
  3. 主启动类添加注解:@EnableFeignClients

  4. service接口:

    1
    2
    3
    4
    5
    6
    @FeignClient(value = "nacos-payment-provider",fallback = PaymentFallbackService.class)
    public interface PaymentService
    {
    @GetMapping(value = "/paymentSQL/{id}")
    public CommonResult<Payment> paymentSQL(@PathVariable("id") Long id);
    }

    全局fallback实现service接口:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    @Component
    public class PaymentFallbackService implements PaymentService
    {
    @Override
    public CommonResult<Payment> paymentSQL(Long id)
    {
    return new CommonResult<>(44444,"服务降级返回,---PaymentFallbackService",new Payment(id,"errorSerial"));
    }
    }

业务类CircleBreakerController:

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
@RestController
@Slf4j
public class CircleBreakerController
{
public static final String SERVICE_URL = "http://nacos-payment-provider";

@Resource
private RestTemplate restTemplate;

@RequestMapping("/consumer/fallback/{id}")
//@SentinelResource(value = "fallback") //没有配置
//@SentinelResource(value = "fallback",fallback = "handlerFallback") //fallback只负责业务异常
//@SentinelResource(value = "fallback",blockHandler = "blockHandler") //blockHandler只负责sentinel控制台配置违规
@SentinelResource(value = "fallback",fallback = "handlerFallback",blockHandler = "blockHandler",
exceptionsToIgnore = {IllegalArgumentException.class})
public CommonResult<Payment> fallback(@PathVariable Long id)
{
CommonResult<Payment> result = restTemplate.getForObject(SERVICE_URL + "/paymentSQL/"+id,CommonResult.class,id);
空指针异常
// 输入4报非法参数异常,输入其他报空指针异常
if (id == 4) {
throw new IllegalArgumentException ("IllegalArgumentException,非法参数异常,输入其他报....");
}else if (result.getData() == null) {
throw new NullPointerException ("NullPointerException,该ID没有对应记录,空指针异常");
}

return result;
}
//本例是fallback
public CommonResult handlerFallback(@PathVariable Long id,Throwable e) {
Payment payment = new Payment(id,"null");
return new CommonResult<>(444,"兜底异常handlerFallback,exception内容 "+e.getMessage(),payment);
}
//本例是blockHandler
public CommonResult blockHandler(@PathVariable Long id,BlockException blockException) {
Payment payment = new Payment(id,"null");
return new CommonResult<>(445,"blockHandler-sentinel限流,无此流水: blockException "+blockException.getMessage(),payment);
}

//==================OpenFeign
@Resource
private PaymentService paymentService;

@GetMapping(value = "/consumer/paymentSQL/{id}")
public CommonResult<Payment> paymentSQL(@PathVariable("id") Long id)
{
return paymentService.paymentSQL(id);
}
}
20.7.3、熔断框架比较

img

img

20.8、Sentinel 控制台规则持久化(持久化到Nacos比较鸡肋)

Sentinel 控制台规则持久化问题:

在 Sentinel 中,我们会为多个服务进行 流控、限流、热点 等规则 的配置,但是当服务重启后再进入 Sentinel 后,发现之前配置过的规则都不在了,这样子的体验显然不友好,此时就需要我们对 Sentinel 中配置的规则规则进行持久化操作。

目前控制台的规则推送也是通过规则查询更改 HTTP API来更改规则。这也意味着这些规则 仅在内存态生效,应用重启之后,该规则会丢失。

以上是原始模式。当了解了原始模式之后,我们非常鼓励您通过动态规则 并结合各种外部存储来定制自己的规则源。我们推荐通过动态配置源的控制台来进行规则写入和推送,而不是通过 Sentinel 客户端直接写入到动态配置源中。在生产环境中,我们推荐 push 模式,具体可以参考:在生产环境使用 Sentinel。也可以参考博客

规则管理及推送:

在这里插入图片描述

20.8.1、持久化配置(鸡肋)

将限流配置规则持久化进Nacos保存,只要刷新8401某个rest地址,sentinel控制台的流控规则就能看到,只要Nacos里面的配置不删除,针对8401上sentinel上的流控规则持续有效。

步骤:

  1. 添加依赖pom:

    1
    2
    3
    4
    5
    <!--SpringCloud ailibaba sentinel-datasource-nacos 后续做持久化用到-->
    <dependency>
    <groupId>com.alibaba.csp</groupId>
    <artifactId>sentinel-datasource-nacos</artifactId>
    </dependency>
  2. 修改添加application.yml(主要是datasource):

    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
    32
    33
    server:
    port: 8401

    spring:
    application:
    name: cloudalibaba-sentinel-service
    cloud:
    nacos:
    discovery:
    server-addr: localhost:8848 #Nacos服务注册中心地址
    sentinel:
    transport:
    dashboard: localhost:8080 #配置Sentinel dashboard地址
    port: 8719
    #添加nacos数据源配置
    datasource:
    ds1:
    nacos:
    server-addr: localhost:8848
    dataId: cloudalibaba-sentinel-service
    groupId: DEFAULT_GROUP
    data-type: json
    rule-type: flow

    management:
    endpoints:
    web:
    exposure:
    include: '*'

    feign:
    sentinel:
    enabled: true # 激活Sentinel对Feign的支持

    其中dataId的值为${spring.application.name}。

  3. 添加nacos业务配置规则

    img

    其中Data ID的值为${spring.application.name}的值。

  4. 在配置中的选择json,并添加内容:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    [
    {
    "resource":"/rateLimit/byUrl",
    "limitApp":"default",
    "grade":1,
    "count": 1,
    "strategy":0,
    "controlBehavior":0,
    "clusterMode"; false
    }
    ]
    • resource:资源名称;
    • limitApp:来源应用;
    • grade:國值类型,0表示线程数,1表示QPS;
    • count:单机國值;
    • strategy:流控模式,0表示直接,1表示关联,2表示链路;
    • controlBehavior:流控效果,0表示快速失败,1表示Warm Up,2表示排队等待;
    • clusterMode:是否集群。
  5. 重启微服务,刷新sentinel。

    发现业务规则有了

    img

  6. 快速访问测试接口:配置成功。

    image-20210314025406567

  7. 关闭微服务再看sentinel:

    img

  8. 重启微服务再看sentinel:(鸡肋)

    image-20210314025455716

20.8.2、Alibaba AHAS 与 Alibaba Sentinel
20.8.2.1、AHAS是什么:

应用高可用服务AHAS是一款专注于提高应用高可用能力的SaaS产品,提供应用架构自动探测、故障注入式高可用能力演练、一键应用防护和增加功能开关等功能,可以快速低成本地提升应用可用性。

官网

Alibaba AHAS是商业化的(说白了就是要用钱买)

Alibaba Sentinel是开源的,任何人都能用

Alibaba AHAS完成了对配置信息持久化的更深一层的包装。

更多资料:

Alibaba AHAS 与 Alibaba Sentinel的对比

springcloud(11)Alibaba-AHAS 限流方式

Sentinel-集成阿里云AHAS控制台实现集群流控

21、SpringCloud Alibaba Seata处理分布式事务

21.1、目前微服务面临的问题

在之前 单机单库 环境下,针对事务的处理还是比较简单的。尤其是结合 Spring 框架,可以说是一个@Transaction 注解走天下。事务 & Spring 事务相关内容,点击链接去了解吧:事务 & Spring 事务内容介绍

在如今 Spring Cloud 分布式微服务架构体系中,按业务模块划分,一个模块使用一个数据库。多个模块配合来完成一个业务,我们就从 官网 的一个微服务实例开始吧。

21.1.1、 用例

用户购买商品的业务逻辑。整个业务逻辑由3个微服务提供支持:

  • 仓储服务:对给定的商品扣除仓储数量。
  • 订单服务:根据采购需求创建订单。
  • 帐户服务:从用户帐户中扣除余额。
21.1.2、架构图

在这里插入图片描述

21.1.3、分布式事务解决方案

单体应用被拆分成微服务应用,原来的三个模块被拆分成三个独立的应用,分别使用三个独立的数据源,业务操作需要调用三个服务来完成。此时每个服务内部的数据一致性由本地事务来保证,但是全局数据一致性问题是无法保证的。所以,Alibaba Seata来处理分布式事务

21.2、是什么

Seata 是一款开源的分布式事务解决方案,致力于提供高性能和简单易用的分布式事务服务。 Seata 将为用户提供了**AT**、TCCSAGAXA事务模式,为用户打造一站式的分布式解决方案。

在 Seata 开源之前,Seata 对应的内部版本在阿里经济体内部一直扮演着分布式一致性中间件的角色,帮助经济体平稳的度过历年的双11,对各部门业务进行了有力的支撑。经过多年沉淀与积累,商业化产品先后在阿里云、金融云进行售卖。2019.1 为了打造更加完善的技术生态和普惠技术成果,Seata 正式宣布对外开源,未来 Seata 将以社区共建的形式帮助其技术更加可靠与完备。

此处重点介绍 **AT模式**,在工作中也最常用,用起来也比较简单。

Seata的四个模式官网资料

  • AT 模式:

    提供无侵入自动补偿的事务模式,目前已支持 MySQL、 Oracle 、PostgreSQL和 TiDB的AT模式,H2 开发中

  • TCC 模式资料

    支持 TCC 模式并可与 AT 混用,灵活度更高

  • SAGA 模式资料

    为长事务提供有效的解决方案

  • XA 模式资料

    支持已实现 XA 接口的数据库的 XA 模式

21.3、Seata 术语表

(术语即:名词介绍,以下内容摘自:Seata 官方文档 http://seata.io/zh-cn/docs/overview/terminology.html)

  • TC (Transaction Coordinator) - 事务协调者
    维护全局和分支事务的状态,驱动全局事务提交或回滚。
  • TM (Transaction Manager) - 事务管理器
    定义全局事务的范围:开始全局事务、提交或回滚全局事务。
  • RM (Resource Manager) - 资源管理器
    管理分支事务处理的资源,与TC交谈以注册分支事务和报告分支事务的状态,并驱动分支事务提交或回滚。

其中,TC 为单独部署的 Server 服务端,TM 和 RM 为嵌入到应用中的 Client 客户端。

在这里插入图片描述

Seata管理的分布式事务的典型生命周期(执行过程):

  1. TM 向 TC 申请开启一个全局事务,全局事务创建成功并生成一个全局唯一的 XID;
  2. XID 在微服务调用链路的上下文中传播;
  3. RM 向 TC 注册分支事务,将其纳入 XID 对应全局事务的管辖;
  4. TM 向 TC 发起针对 XID 的全局提交或回滚决议;
  5. TC 调度 XID 下管辖的全局分支事务,完成分支提交或回滚请求。

21.4、AT模式

21.4.1、AT 前提
  • 基于支持本地 ACID 事务的关系型数据库。
  • Java 应用,通过 JDBC 访问数据库。
21.4.2、AT 整体机制

两阶段提交协议的演变:

  • 一阶段:业务数据和回滚日志记录在同一个本地事务中提交,释放本地锁和连接资源。
  • 二阶段:
    • 提交异步化,非常快速地完成。
    • 回滚通过一阶段的回滚日志进行反向补偿
21.4.3、写隔离
  • 一阶段本地事务提交前,需要确保先拿到 全局锁
  • 拿不到 全局锁 ,不能提交本地事务。
  • 全局锁 的尝试被限制在一定范围内,超出范围将放弃,并回滚本地事务,释放本地锁。

以一个示例来说明:

两个全局事务 tx1 和 tx2,分别对 a 表的 m 字段进行更新操作,m 的初始值 1000。

tx1 先开始,开启本地事务,拿到本地锁,更新操作 m = 1000 - 100 = 900。本地事务提交前,先拿到该记录的 全局锁 ,本地提交释放本地锁。 tx2 后开始,开启本地事务,拿到本地锁,更新操作 m = 900 - 100 = 800。本地事务提交前,尝试拿该记录的 全局锁 ,tx1 全局提交前,该记录的全局锁被 tx1 持有,tx2 需要重试等待 全局锁

img

tx1 二阶段全局提交,释放 全局锁 。tx2 拿到 全局锁 提交本地事务。

img

如果 tx1 的二阶段全局回滚,则 tx1 需要重新获取该数据的本地锁,进行反向补偿的更新操作,实现分支的回滚。

此时,如果 tx2 仍在等待该数据的 全局锁,同时持有本地锁,则 tx1 的分支回滚会失败。分支的回滚会一直重试,直到 tx2 的 全局锁 等锁超时,放弃 全局锁 并回滚本地事务释放本地锁,tx1 的分支回滚最终成功。

因为整个过程 全局锁 在 tx1 结束前一直是被 tx1 持有的,所以不会发生 脏写 的问题。

21.4.4、读隔离

在数据库本地事务隔离级别 读已提交(Read Committed) 或以上的基础上,Seata(AT 模式)的默认全局隔离级别是 读未提交(Read Uncommitted)

如果应用在特定场景下,必需要求全局的 读已提交 ,目前 Seata 的方式是通过 SELECT FOR UPDATE 语句的代理。

img

SELECT FOR UPDATE 语句的执行会申请 全局锁 ,如果 全局锁 被其他事务持有,则释放本地锁(回滚 SELECT FOR UPDATE 语句的本地执行)并重试。这个过程中,查询是被 block 住的,直到 全局锁 拿到,即读取的相关数据是 已提交 的,才返回。

出于总体性能上的考虑,Seata 目前的方案并没有对所有 SELECT 语句都进行代理,仅针对 FOR UPDATE 的 SELECT 语句。

21.4.5、工作机制

以一个示例来说明整个 AT 分支的工作过程。

业务表:product

Field Type Key
id bigint(20) PRI
name varchar(100)
since varchar(100)

AT 分支事务的业务逻辑:

1
update product set name = 'GTS' where name = 'TXC';
21.4.5.1、一阶段

过程:

  1. 解析 SQL:得到 SQL 的类型(UPDATE),表(product),条件(where name = ‘TXC’)等相关的信息。
  2. 查询前镜像:根据解析得到的条件信息,生成查询语句,定位数据。
1
select id, name, since from product where name = 'TXC';

得到前镜像:

id name since
1 TXC 2021
  1. 执行业务 SQL:更新这条记录的 name 为 ‘GTS’。
  2. 查询后镜像:根据前镜像的结果,通过 主键 定位数据。
1
select id, name, since from product where id = 1`;

得到后镜像:

id name since
1 GTS 2021

插入回滚日志:把前后镜像数据以及业务 SQL 相关的信息组成一条回滚日志记录,插入到 UNDO_LOG 表中。

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
32
33
34
35
36
37
38
39
40
41
42
43
{
"branchId": 641789253,
"undoItems": [{
"afterImage": {
"rows": [{
"fields": [{
"name": "id",
"type": 4,
"value": 1
}, {
"name": "name",
"type": 12,
"value": "GTS"
}, {
"name": "since",
"type": 12,
"value": "2021"
}]
}],
"tableName": "product"
},
"beforeImage": {
"rows": [{
"fields": [{
"name": "id",
"type": 4,
"value": 1
}, {
"name": "name",
"type": 12,
"value": "TXC"
}, {
"name": "since",
"type": 12,
"value": "2021"
}]
}],
"tableName": "product"
},
"sqlType": "UPDATE"
}],
"xid": "xid:xxx"
}
  1. 提交前,向 TC注册分支:申请 product 表中,主键值等于 1 的记录的全局锁.
  2. 本地事务提交:业务数据的更新和前面步骤中生成的 UNDO LOG 一并提交。
  3. 将本地事务提交的结果上报给 TC。
21.4.5.2、二阶段-回滚
  1. 收到 TC 的分支回滚请求,开启一个本地事务,执行如下操作。

  2. 通过 XID 和 Branch ID 查找到相应的 UNDO LOG 记录。

  3. 数据校验:拿 UNDO LOG 中的后镜与当前数据进行比较,如果有不同,说明数据被当前全局事务之外的动作做了修改(脏写)。这种情况,需要根据配置策略来做处理,详细的说明在另外的文档中介绍。

  4. 根据 UNDO LOG 中的前镜像和业务 SQL 的相关信息生成并执行回滚的语句:

    1
    update product set name = 'TXC' where id = 1;
  5. 提交本地事务。并把本地事务的执行结果(即分支事务回滚的结果)上报给 TC。

21.4.5.3、二阶段-提交
  1. 收到 TC 的分支提交请求,把请求放入一个异步任务的队列中,马上返回提交成功的结果给 TC。
  2. 异步任务阶段的分支提交请求将异步和批量地删除相应UNDO LOG记录。

回滚事务表:

UNDO_LOG Table:不同数据库在类型上会略有差别。

以 MySQL 为例:

Field Type
branch_id bigint PK
xid varchar(100)
context varchar(128)
rollback_info longblob
log_status tinyint
log_created datetime
log_modified datetime
1
2
3
4
5
6
7
8
9
10
11
12
13
-- 注意此处0.7.0+ 增加字段 context
CREATE TABLE `undo_log` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`branch_id` bigint(20) NOT NULL,
`xid` varchar(100) NOT NULL,
`context` varchar(128) NOT NULL,
`rollback_info` longblob NOT NULL,
`log_status` int(11) NOT NULL,
`log_created` datetime NOT NULL,
`log_modified` datetime NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `ux_undo_log` (`xid`,`branch_id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;
21.4.5.4、总结
  • 是什么

img

  • 一阶段加载:

img

img

  • 二阶段提交:

img

  • 二阶段回滚:

img

img

21.5、Spring Cloud 整合 Nacos 1.3.1 + Seata 1.2.0 集群部署(Windows版)

21.5.1、环境说明

部署环境+版本: MySQL 5.7.x + Nacos 1.3.1 + Seata 1.2.0 + **Windows 环境演示**,Linux 部署类似。

21.5.2、Seata 1.2.0 下载

进入 Seata 官网下载地址 或者 Seata Github下载地址,选择 Seata 1.2.0 版本下载。

21.5.3、官网新人文档中的资源目录介绍

在这里插入图片描述

详细说明

在这里插入图片描述

21.5.4、Seata Server 端配置
21.5.4.1、解压

将下载的 seata-server-1.2.0.zip 解压到某个路径下(注意该路径不要有中文或空格)。

21.5.4.2、MySQL 数据库配置

1、执行MySQL数据库操作前,需要我们手动创建一个名称为 seata 的数据库,然后在该数据库下建表。建库命令如下:

1
CREATE DATABASE seata DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;

image-20210314232019841

进入 资源目录 seata/script/server/db/mysql.sql ,执行SQL语句。建表语句如下,你也可以点击链接获取。

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
-- -------------------------------- The script used when storeMode is 'db' --------------------------------
-- the table to store GlobalSession data
CREATE TABLE IF NOT EXISTS `global_table`
(
`xid` VARCHAR(128) NOT NULL,
`transaction_id` BIGINT,
`status` TINYINT NOT NULL,
`application_id` VARCHAR(32),
`transaction_service_group` VARCHAR(32),
`transaction_name` VARCHAR(128),
`timeout` INT,
`begin_time` BIGINT,
`application_data` VARCHAR(2000),
`gmt_create` DATETIME,
`gmt_modified` DATETIME,
PRIMARY KEY (`xid`),
KEY `idx_gmt_modified_status` (`gmt_modified`, `status`),
KEY `idx_transaction_id` (`transaction_id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8;

-- the table to store BranchSession data
CREATE TABLE IF NOT EXISTS `branch_table`
(
`branch_id` BIGINT NOT NULL,
`xid` VARCHAR(128) NOT NULL,
`transaction_id` BIGINT,
`resource_group_id` VARCHAR(32),
`resource_id` VARCHAR(256),
`branch_type` VARCHAR(8),
`status` TINYINT,
`client_id` VARCHAR(64),
`application_data` VARCHAR(2000),
`gmt_create` DATETIME(6),
`gmt_modified` DATETIME(6),
PRIMARY KEY (`branch_id`),
KEY `idx_xid` (`xid`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8;

-- the table to store lock data
CREATE TABLE IF NOT EXISTS `lock_table`
(
`row_key` VARCHAR(128) NOT NULL,
`xid` VARCHAR(96),
`transaction_id` BIGINT,
`branch_id` BIGINT NOT NULL,
`resource_id` VARCHAR(256),
`table_name` VARCHAR(32),
`pk` VARCHAR(36),
`gmt_create` DATETIME,
`gmt_modified` DATETIME,
PRIMARY KEY (`row_key`),
KEY `idx_branch_id` (`branch_id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8;

在这里插入图片描述

21.5.4.3、Server端参数项配置

解压后,进入 conf 目录开始参数的配置。我们修改 file.confregistry.conf 这两个文件。

在这里插入图片描述

  1. 对 file.conf 配置:

    在这里插入图片描述

  2. 对 registry.conf 配置:

    在这里插入图片描述

21.5.4.4、启动Seata Server端

进入 bin 目录,双击 seata-server.bat 启动。(Linux环境请选择 seata-server.sh 启动)

在这里插入图片描述

到此为止,Seata Server 端启动完成。

21.5.5、config-center 配置中心配置

配置中心的配置,本文使用 nacos 作为配置中心。

21.5.5.1、获取要配置的参数信息

进入 资源目录 seata/script/config-center/config.txt ,展示的是 Seata 1.2.0 版本所有配置中心的内容,全部配置点击链接查看。本文使用db方式,故选择db相关配置,需要用到的配置如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
service.vgroupMapping.my_test_tx_group=default  
service.default.grouplist=127.0.0.1:8091
service.enableDegrade=false
service.disableGlobalTransaction=false
store.mode=db /*此处修改为db*/
store.db.datasource=druid
store.db.dbType=mysql
store.db.driverClassName=com.mysql.jdbc.Driver /*自定义修改*/
store.db.url=jdbc:mysql://192.168.204.201:3306/seata?useUnicode=true /*自定义修改*/
store.db.user=root /*自定义修改*/
store.db.password=root /*自定义修改*/
store.db.minConn=5
store.db.maxConn=30
store.db.globalTable=global_table
store.db.branchTable=branch_table
store.db.queryLimit=100
store.db.lockTable=lock_table
store.db.maxWait=5000
21.5.5.2、将参数配置到Nacos配置中心

进入 资源目录 seata/script/config-center/nacos/nacos-config.sh ,该配置会将 seata 相关配置批量添加到 nacos 服务器。

该脚本可以随便放在某个位置,只要脚本 nacos-config.sh 能够读取到 config.txt 文件即可。本文放在如下为止

在这里插入图片描述

你自己打开nacos-config.sh脚本 看看它查找 config.txt 的逻辑就可以了,只要能够读取到 config.txt 文件即可。nacos-config.sh 脚本支持传入四个参数

  • -h nacos 所在服务器的IP地址,默认为 localhost
  • -p nacos 端口号,默认为 8848
  • -g nacos 配置所属 group 名称,默认为 SEATA_GROUP
  • -t 将 nacos 配置保存到指定的命名空间,默认为 “”,代表 public 命名空间(注意:-t 参数值接收的是 命名空间ID,不是 命名空间名称)

使用 git 命令框 执行 sh nacos-config.sh ,就可以将配置批量保存到 nacos 服务器。如下图所示:

在这里插入图片描述

到此为止,Config Center 配置中心参数,配置完成。

21.5.6、client 客户端配置

21.5.6.1、业务场景

用户购买商品的业务逻辑。整个业务逻辑由3个微服务提供支持:

  • 订单服务A:根据采购需求创建订单。
  • 仓储服务B:对给定的商品扣除仓储数量。
  • 帐户服务C:从用户帐户中扣除余额。

用户A购买商品,调用A服务创建订单完成,调用B服务扣减库存,然后调用C服务扣减账户余额。每个服务内部的数据一致性由本地事务来保证,多个服务调用来完成业务,全局事务数据一致性则由 Seata 来保证。

21.5.6.2、业务数据库准备

配置三个业务分别对应各自的数据库。

  • A服务 对应数据库:seata_order ;表:t_order
  • B服务 对应数据库:seata_storage ;表:t_storage
  • C服务 对应数据库:seata_account ;表:t_account

建库,建表语句如下:

1
2
3
4
5
6
7
8
9
10
11
12
# 创建seata_order数据库
CREATE DATABASE seata_order;

# 创建t_order表
CREATE TABLE seata_order.t_order(
`id` BIGINT(11) NOT NULL AUTO_INCREMENT PRIMARY KEY,
`user_id` BIGINT(11) DEFAULT NULL COMMENT '用户id',
`product_id` BIGINT(11) DEFAULT NULL COMMENT '产品id',
`count` INT(11) DEFAULT NULL COMMENT '数量',
`money` DECIMAL(11,0) DEFAULT NULL COMMENT '金额',
`status` INT(1) DEFAULT NULL COMMENT '订单状态:0:创建中; 1:已完结'
) ENGINE=INNODB AUTO_INCREMENT=7 DEFAULT CHARSET=utf8;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 创建seata_storage数据库
CREATE DATABASE seata_storage;

# 创建t_storage表
CREATE TABLE seata_storage.t_storage(
`id` BIGINT(11) NOT NULL AUTO_INCREMENT PRIMARY KEY,
`product_id` BIGINT(11) DEFAULT NULL COMMENT '产品id',
`total` INT(11) DEFAULT NULL COMMENT '总库存',
`used` INT(11) DEFAULT NULL COMMENT '已用库存',
`residue` INT(11) DEFAULT NULL COMMENT '剩余库存'
) ENGINE=INNODB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8;

# 插入一条数据
INSERT INTO seata_storage.t_storage(`id`,`product_id`,`total`,`used`,`residue`)
VALUES('1','1','100','0','100');
1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 创建seata_account数据库
CREATE DATABASE seata_account;

# 创建t_account表
CREATE TABLE seata_account.t_account(
`id` BIGINT(11) NOT NULL AUTO_INCREMENT PRIMARY KEY COMMENT 'id',
`user_id` BIGINT(11) DEFAULT NULL COMMENT '用户id',
`total` DECIMAL(10,0) DEFAULT NULL COMMENT '总额度',
`used` DECIMAL(10,0) DEFAULT NULL COMMENT '已用余额',
`residue` DECIMAL(10,0) DEFAULT '0' COMMENT '剩余可用额度'
) ENGINE=INNODB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8;

# 插入一条数据
INSERT INTO seata_account.t_account(`id`,`user_id`,`total`,`used`,`residue`) VALUES('1','1','1000','0','1000');
21.5.6.3、创建 undo_log 表

进入 资源目录 seata/script/client/at/db/mysql.sql ,展示的就是 undo_log 表的建表语句,该表需要在涉及到事务处理的每个库中都添加以下。undo_log 表建表语句如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
-- for AT mode you must to init this sql for you business database. the seata server not need it.
CREATE TABLE IF NOT EXISTS `undo_log`
(
`id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT 'increment id',
`branch_id` BIGINT(20) NOT NULL COMMENT 'branch transaction id',
`xid` VARCHAR(100) NOT NULL COMMENT 'global transaction id',
`context` VARCHAR(128) NOT NULL COMMENT 'undo_log context,such as serialization',
`rollback_info` LONGBLOB NOT NULL COMMENT 'rollback info',
`log_status` INT(11) NOT NULL COMMENT '0:normal status,1:defense status',
`log_created` DATETIME NOT NULL COMMENT 'create datetime',
`log_modified` DATETIME NOT NULL COMMENT 'modify datetime',
PRIMARY KEY (`id`),
UNIQUE KEY `ux_undo_log` (`xid`, `branch_id`)
) ENGINE = InnoDB
AUTO_INCREMENT = 1
DEFAULT CHARSET = utf8 COMMENT ='AT transaction mode undo table';
21.5.6.4、库表创建完成图示

在这里插入图片描述

21.5.6.5、添加 pom 依赖

pom.xml 部分的注意事项,可参考:部署指南-注意事项

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<!--seata-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-seata</artifactId>
<exclusions>
<exclusion>
<groupId>io.seata</groupId>
<artifactId>seata-spring-boot-starter</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>io.seata</groupId>
<artifactId>seata-spring-boot-starter</artifactId>
<version>1.2.0</version>
</dependency>
21.5.6.7、application.yml 针对 seata 进行配置

进入资源目录seata/script/client/spring/,展示的就是 seata 整合 Spring 的全部配置内容,提供了.properties.yml两种格式的配置。详细的配置项还挺多,此处就不粘贴了,你可以点击 资源目录 查看。此处挑选了本案例需要的部分内容进行配置,配置如下所示:(项目application.yml完整配置,请参考文末项目完整代码)

该配置在每个服务模块都需要配置一份,你也可以通过 nacos 配置中心的方式配置使用。

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
seata:
enabled: true
application-id: ${spring.application.name}
tx-service-group: my_test_tx_group
enable-auto-data-source-proxy: true
service:
vgroup-mapping:
my_test_tx_group: default # 此处key需要与tx-service-group的value一致,否则会报 no available service 'null' found, please make sure registry config correct 异常
grouplist:
default: 192.168.41.113:8091
enable-degrade: false
disable-global-transaction: false
config:
type: nacos
nacos:
namespace:
serverAddr: 192.168.41.113:8848
group: SEATA_GROUP
userName: ""
password: ""
registry:
type: nacos
nacos:
application: seata-server # 此处名称需和 seata server 服务端 application一致,否则会报 no available service 'null' found, please make sure registry config correct 异常
server-addr: 192.168.41.113:8848
namespace:
userName: ""
password: ""

业务代码略

21.5.7、Seata 高可用集群部署

21.5.7.1、集群部署

Seata 集群部署比较简单,只需将已配置好的 seata-server 再启动一个即可。seata 默认使用 8091 端口,此次我在 Windows 部署,所以 seata-server 第二个节点选用 8092 端口,进入 cmd 命令行,使用命令:seata-server.bat -p 8092启动seata-server 第二台节点。(Linux 正式环境,多机器的话,只需要 scp 到另一台机器,启动即可)

通过 nacos 服务列表,seata-server 实例数由 1 变为 2。端口分别为 8091、8092 。集群搭建完成,挺简单的。想要几个节点就来几个节点,so easy。
在这里插入图片描述

21.5.7.2、服务注册成功

3个服务启动成功后,均会通过 RPC 的方式注册到 Seata 集群的两个节点上来,如下图所示:

在这里插入图片描述

21.5.7.3、Seata集群测试

使用 postman 发送 50 个请求,中途关闭 8091 节点。由于集群之间通过 Nacos 通信原因,一个节点的突然宕机,会导致部分请求失败,但是服务很快便会恢复正常。

当再次将 8091 节点启动后,服务还是能够正常请求,8091 节点也有事务相应的日志显示,说明Seata 集群能够正常提供服务。测试如图所示:
在这里插入图片描述

21.5.8、全局事务服务 GTS-阿里云

21.5.8.1、是什么

官网

全局事务服务(Global Transaction Service,简称 GTS)是一款高性能、高可靠、接入简单的分布式事务中间件,用于解决分布式环境下的事务一致性问题。 在单机数据库下很容易维持事务的 ACID(Atomicity、Consistency、Isolation、Durability)特性,但在分布式系统中并不容易,GTS 可以保证分布式系统中的分布式事务的 ACID 特性。 GTS 支持 DRDS、RDS、MySQL 等多种数据源,可以配合 EDAS 和 Dubbo 等微服务框架使用, 兼容 MQ 实现事务消息。通过各种组合,可以轻松实现分布式数据库事务、多库事务、消息事务、服务链路级事务等多种业务需求。

21.5.8.2、GTS与Seata
  • Seata:开源框架
  • GTS:付费的商用框架

背景:springboot2为为主体搭建的项目,直接打成jar包,上传到linux上面

启动项目:java -jar xx.jar 这样很方便,但是不能关闭窗口,否则项目就停了

后台启动: nohup java -jar xx.jar &

这样就能后台启动了

有时候我们并不是部署单机版的,需要部署多个,可能部署到一台机器上,但是端口肯定得不一样吧,要是再重新打包一份就太麻烦了,我们可以在启动命令上加上启动端口参数

命令:nohup java -jar xx.jar –server.port=8083 &

此时你会发现在当前目录下多了nohup.out 的文件,这个文件就是你项目的日志文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
    <!-- 打包可执行jar包 -->
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<includeSystemScope>true</includeSystemScope>
<mainClass>com.sanro.test.CMApplication</mainClass>
</configuration>
<executions>
<execution>
<goals>
<goal>repackage</goal>
</goals>
</execution>
</executions>
</plugin>

在之前写过一篇博客关于ssm项目远程部署
地址:在这里

该教程是基于上篇,只是有部分修改而已。

Springboot 远程部署需要修改一下几点:

1、POM文件

(1)打包方式,这里将jar —> war
1
(2)关于依赖
springboot由于内置了tomcat,所以在使用war包部署是需要移除内置tomcat,并添加servlet容器支持。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<!--移除嵌入式tomcat插件-->
<exclusions>
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-tomcat</artifactId>
</exclusion>
</exclusions>
</dependency>
<!--添加servlet-api的依赖,使用war包部署这个必须要有-->
<dependency>
<groupId>org.apache.tomcat</groupId>
<artifactId>tomcat-servlet-api</artifactId>
<version>8.0.36</version>
<scope>provided</scope>
</dependency>
</dependencies>

二、SpringbootApplication启动类

启动类需要继承 SpringBootServletInitializer 类,并且重写configure 方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.boot.web.support.SpringBootServletInitializer;

@SpringBootApplication
public class HelloSpringbootApplication extends SpringBootServletInitializer {
@Override
protected SpringApplicationBuilder configure(SpringApplicationBuilder builder) {
return builder.sources(HelloSpringbootApplication.class);
}
public static void main(String[] args) throws Exception {
SpringApplication.run(HelloSpringbootApplication.class, args);
}
}

只需以上两部就可以完成Springboot项目远程部署。

转载自:https://blog.csdn.net/android_ztz/article/details/79262416

一、环境

本教程使用的环境如下

  • idea 2017

  • tomcat 8.5

  • centos 7

  • maven 3.5

    注意:tomcat6 和 tomcat7、8、9会有一些区别,下面会详细介绍

二、配置【只需完成下面三步】

1、Tomcat 服务器配置

如果不了解 Tomcat 的安装,参考 http://blog.csdn.net/android_ztz/article/details/79249467

找到[tomcat安装的根路径]/conf/tomcat-users.xml 文件,编辑如下:

1
2
3
4
5
6
7
8
9
10
11
12
<tomcat-users xmlns="http://tomcat.apache.org/xml"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://tomcat.apache.org/xml tomcat-users.xsd"
version="1.0">
<role rolename="admin-gui"/>
<role rolename="admin-script"/>
<role rolename="manager-gui"/>
<role rolename="manager-script"/>
<role rolename="manager-jmx"/>
<role rolename="manager-status"/>
<user username="tomcat" password="tomcat" roles="manager-gui,manager-script,manager-jmx,manager-status,admin-gui,admin-script"/>
</tomcat-users>

role标签都是定义权限,user标签定义的是用户,usernme和password都是自定义的,roles是赋予该用户的权限。

测试配置是否成功

在浏览器中访问 http://yourIp:port/manager/text (tomcat 7、8、9) ;
http://yourIp:port/manager/html (tomcat 6)
如果出现输入账户和密码的弹框,表示成功。

但是有可能会出现 【管理页面403 Access Denied】错误,详情见下面问题2:

【修改Tomcat端口号-默认是8080】
如果需要修改,找到[tomcat安装的根路径]/conf/server.xml ,找到这一段。

1
2
3
<Connector port="8080" protocol="HTTP/1.1"
connectionTimeout="20000"
redirectPort="8443" />

只需修改port的值即可。

二、配置Maven

打开[Maven的安装路径]/conf/setting.xml文件 ,找到这一段

1
2
3
4
5
6
7
<servers>
<server>
<id>tomcat8</id>
<username>tomcat</username>
<password>tomcat</password>
</server>
</servers>

id : tomcat+版本号
username : 在Tomcat配置的用户名
password : 在Tomcat配置的密码

三、使用idea新建一个Maven项目,打开自动生成的pom文件,配置如下:

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.ztz</groupId>
<artifactId>deploy-project</artifactId>
<packaging>war</packaging>
<version>1.0-SNAPSHOT</version>
<name>deploy-project Maven Webapp</name>
<url>http://maven.apache.org</url>
<dependencies>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>3.8.1</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<defaultGoal>compile</defaultGoal>
<finalName>deploy-project</finalName>
<plugins>
<!-- maven项目插件运行配置 -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.3</version>
<configuration>
<source>1.8</source>
<target>1.8</target>
<encoding>UTF-8</encoding>
</configuration>
</plugin>
<!-- maven远程项目部署插件 -->
<plugin>
<groupId>org.apache.tomcat.maven</groupId>
<artifactId>tomcat7-maven-plugin</artifactId>
<version>2.2</version>
<configuration>
<!-- 填写自己服务器的IP地址和端口号,并且其他的不变,Tomcat7、8、9使用这个,Tomcat6使用另一个-->
<url>http://192.168.199.133/manager/text</url>
<server>tomcat8</server>
<!-- 这个账户和密码是自定义的,和Maven,Tomcat中配置要保持一致-->
<username>tomcat</username>
<password>tomcat</password>
<port>80</port>
<!-- 若tomcat项目中已存在,且使"mvn tomcat7:deploy"命令必须要设置下面的代码 -->
<!-- 更新项目时,仅需要执行"mvn tomcat7:redeploy"命令即可 -->
<!-- 上述命令无论服务器是tomcat7、8或9,均是使用"mvn tomcat7:deploy"或"mvn tomcat7:redeploy" -->
<update>true</update>
<!-- 项目路径 -->
<path>/deploy-project</path>
</configuration>
</plugin>
</plugins>
</build>
</project>

最主要的配置就是标签中,关于详解:
finalName:项目名
server: 服务器名和版本
username: 和tomcat、maven中保持一致
password:和tomcat、maven中保持一致
port:需要部署到的端口,要和服务器配置的端口一致
path:访问项目的路径

其他就可以和上面保持一致。

现在已经完成了所有的配置,那么就开始测试一下能否部署成功,在idea 中点击右栏[Maven标签]

双击【tomcat7:deploy】完成部署,查看控制台信息
2

—————————————- 华丽 ——————————————–

遇到的问题1:

  • Tomcat已启动,内网可以访问,但是外网不可以访问。

    答 : 这很有可能是防火墙阻止了浏览器的访问。

  • 解决方式:

    centos7使用的防火墙是 firewall 而不是 iptables 可以开要访问的端口,先查看一下防火墙状态: firewall-cmd –state , 结是running 或者 not running , 建议running状态下添加开放端口。 比如需要访问的是8080端口,则可以这样

1
2
3
4
firewall-cmd --permanent --zone=public --add-port=8080/tcp     # --permanent 表示永久添加;去掉标识临时,重启后恢复 
firewall-cmd --reload # 加载配置,使得修改有效。
查看端口是否开放成功?
firewall-cmd --permanent --zone=public --list-ports #查看开启的端口

如果出现 8080/tcp 表示成功 。

需要了解firewall更多,点击这里。


  • 遇到的问题2:

    管理页面403 Access Denied

    这说明你没有权限访问,

  • 解决方式:

    打开/webapps/manager/META-INF/目录下context.xml文件,将下面这段注释掉或者修改为下面这段。

1
2
<Valve className="org.apache.catalina.valves.RemoteAddrValve"  
allow="127\.\d+\.\d+\.\d+|::1|0:0:0:0:0:0:0:1|\d+\.\d+\.\d+\.\d+" />12

原因是: \d+.\d+.\d+.\d+ 标识任访问,如果不添加这个,则只允许前面两种类型IP访问,allow中是用正则表达式来表示的。


  • 遇到的问题3 :

    [ERROR] Failed to execute goal org.apache.tomcat.maven:tomcat7-maven-plugin: 2.0-SNAPSHOT:deploy (default-cli) on project helloworld: Cannot invoke Tomcat manager: Server returned HTTP response code: 401 for URL: http://localhost:8080/manager/text/deploy?path=%2Fhelloworld -> [Help 1]

该错误是在部署时出现的,是由于权限问题,可能是你的 tomcat-users.xml中user的权限不够,也可能是你的idea中pom文件的用户名和密码错了。

转载自:https://blog.csdn.net/android_ztz/article/details/79249335

需要掌握的内容:

  • 标识符

  • 关键字

  • 字面值

  • 变量

  • 数据类型

  • 运算符:

    • 算术运算符
    • 关系运算符
    • 逻辑运算符
    • 赋值类运算符
    • 字符串连接运算符
    • 三元运算符
    • 赋值运算符
    • 字符串连接运算符
  • 控制语句

    • 选择结构
      • if,if..else
      • switch
    • 循环结构
      • for
      • while
      • do..while()
    • 控制循环的语句
      • break
      • continue

1、标识符:

1、在java程序当中,使用EditPlus工具进行代码编写的时候,

有一些单词是蓝色,有的是红色,有的绿色,有的是黑色,有

的是紫色,有的是粉色….

2、注意:在java源代码当中,在EditPlus工具中显示的高亮颜色为黑色时,
这个单词属于标识符。

3、标识符可以标识什么?
可以标识:

  • 类名
  • 方法名
  • 变量名
  • 接口名
  • 常量名
  • ……

4、到底什么是标识符呢?
一句话搞定:凡是程序员自己有权利命名的单词都是标识符。

5、标识符可以随意编写吗,有命名规则吗?有
什么是命名规则?
命名规则属于语法机制,必须遵守,不遵守命名规则标识不符合语法,
这样,编译器会报错。

  • 规则1:标识符只能由数字、字母(包括中文)、下划线_、美元符号$组成,
    不能含有其它符号。

  • 规则2:标识符不能以数字开头

  • 规则3:关键字不能做标识符。例如:public class static void 这些蓝色的字体

    都是关键字,关键字是不能做标识符的。

  • 规则4:标识符是严格区分大小写的。大写A和小写a不一样。

  • 规则5:标识符理论上是没有长度限制的。

6、注意的点:

class 123ABC{
}
编译报错,错误信息是:
错误: 需要<标识符>
错误原因:编译器检测到class这个单词,那么编译器会从class这个
单词后面找类名,而类名是标识符,编译器找了半天没有找到标识符,
因为123ABC不是标识符,所以编译器提示的错误信息是:需要<标识符>
解决办法:
将123ABC修改为合法的标识符。

class Hello World{
}
类名是标识符,标识符“中”不能有空格
编译器错误信息是:
错误: 需要’{‘
编译器检测到class,然后找class后面的标识符,编译器找到了一个合法的标识符
叫做“Hello”,然后编译器继续往后找“{”,结果没有找到“{”,所以报错了。
解决办法:
办法1:是把World删除
办法2:把空格删除

class public {
}
关键字不能做标识符
编译器错误信息是:
错误: 需要<标识符>

这个可以,因为 public1 不是关键字,可以用
class public1 {
}

* 虽然java中的标识符严格区分大小写
* 但是对于类名来说,如果一个java源文件中同时出现了:A类和a类
* 那么谁在前就生成谁。大家以后最好不要让类名“相同”。
* 最好类名是不同的。

7、题目:
创建一个java文件,起名 123.java可以吗?
可以,完全可以,在windows操作系统中文件名叫做:123.java没毛病。
123其实并不是标识符。只是一个文件名。
只不过在123.java文件中无法定义public的类。
8、命名规范:
1、命名规则和命名规范有什么区别?

命名规则是语法,不遵守就会编译报错。

命名规范只是说,大家尽量按照统一的规范来进行命名,不符合规范也行,

代码是可以编译通过的,但是你的代码风格和大家不一样,这个通常也是不允许的。

规则类似于:现实世界中的法律。

规范类似于:现实世界中的道德。

统一按照规范进行的话,代码的可读性很好。代码很容易让其它开发人员理解。

2、具体的命名规范是哪些?

  • 规范1:见名知意(这个标识符在起名的时候,最好一看这个单词就知道啥意思。)
  • 规范2:遵循驼峰命名方式,什么是驼峰(一高一低,一高一低…)
    驼峰有利于单词与单词之间很好的进行分隔
    BiaoShiFuTest,这个很好,一眼就能看出来是4个单词。
  • 规范3:类名、接口名有特殊要求
    类名和接口名首字母大写,后面每个单词首字母大写。
    StudentTest、UserTest ,这是类名、接口名。
  • 规范4:变量名、方法名有特殊要求
    变量名和方法名首字母小写,后面每个单词首字母大写。
    nianLing/age(NianLing这样就不符合了。)
    mingZi/name(MingZi这样也不符合了。)
  • 规范5:所有“常量”名:全部大写,并且单词和单词之间采用下划线衔接。
    USER_AGE :用户年龄
    MATH_PI:固定不变的常量3.1415926…..

2、关键字:

关键字:

在SUN公司开发Java语言的时候,提前定义好了一些具有特殊含义的单词,这些单词全部小写,具有特殊含义,不能用作标识符。凡是在EditPlus中以蓝色字体形式存在的都是关键字,具有特殊含义。

切记:

  • java语言中的所有关键字都是全部小写。

  • 注意:java语言中是严格区分大小写的。

    public和Public不一样。

    Class和class不一样。

    static和Static也不一样。

  • 重点:string不是java关键字!!!

Java关键字(50个)的大致含义(运用记忆):

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
关键字			含义
abstract 表明类或者成员方法具有抽象属性
assert 用来进行程序调试
boolean 基本数据类型之一,布尔类型
break 提前跳出一个块
byte 基本数据类型之一,字节类型
case 用在switch语句之中,表示其中的一个分支
catch 用在异常处理中,用来捕捉异常
char 基本数据类型之一,字符类型
class
const 保留关键字,没有具体含义
continue 回到一个块的开始处
default 默认,例如,用在switch语句中,表明一个默认的分支
do 用在do-while循环结构中
double 基本数据类型之一,双精度浮点数类型
else 用在条件语句中,表明当条件不成立时的分支
enum 枚举
extends 表明一个类型是另一个类型的子类型,这里常见的类型有类和接口
final 用来说明最终属性,表明一个类不能派生出子类,或者成员方法不能被覆盖,或者成员 域的值不能被改变,用来定义常量
finally 用于处理异常情况,用来声明一个基本肯定会被执行到的语句块
float 基本数据类型之一,单精度浮点数类型
for 一种循环结构的引导词
goto 保留关键字,没有具体含义
if 条件语句的引导词
implements 表明一个类实现了给定的接口
import 表明要访问指定的类或包
instance of 用来测试一个对象是否是指定类型的实例对象
int 基本数据类型之一,整数类型
interface 接口
long 基本数据类型之一,长整数类型
native 用来声明一个方法是由与计算机相关的语言(如C/C++/FORTRAN语言)实现的
new 用来创建新实例对象
package
private 一种访问控制方式:私用模式
protected 一种访问控制方式:保护模式
public 一种访问控制方式:共用模式
return 从成员方法中返回数据
short 基本数据类型之一,短整数类型
static 表明具有静态属性
strictfp 用来声明FP_strict(单精度或双精度浮点数)表达式遵循IEEE 754算术规范
super 表明当前对象的父类型的引用或者父类型的构造方法
switch 分支语句结构的引导词
synchronized 表明一段代码需要同步执行
this 指向当前实例对象的引用
throw 抛出一个异常
throws 声明在当前定义的成员方法中所有需要抛出的异常
transient 声明不用序列化的成员域
try 尝试一个可能抛出异常的程序块
void 声明当前成员方法没有返回值
volatile 表明两个或者多个变量必须同步地发生变化
while 用在循环结构中

3、字面量:

关于程序当中的数据
开发软件是为了解决现实世界中的问题。
而现实世界当中,有很多问题都是使用数据进行描述的。
所以软件执行过程中最主要就是对数据的处理。

软件在处理数据之前需要能够表示数据,在java代码中怎么去表示数据呢?在java中有这样的一个概念:字面量。

注意:在java语言中“数据”被称为“字面量”。
10
1.23
true
false
‘a’
“abc”
以上这些都是数据,在程序中都被叫做“字面量”。

字面量可以分为很多种类:
整数型字面量:1 2 3 100 -100 -20 ….
浮点型字面量:1.3 1.2 3.14…..
布尔型字面量:true、false没有其它值了,表示真和假,true表示真,false表示假
字符型字面量:’a’、’b’、’中’
字符串型字面量:”abc”、”a”、”b”、”中国”
其中字符型和字符串型都是描述了现实世界中的文字:
需要注意的是:
所有的字符型只能使用单引号括起来。
所有的字符串型只能使用双引号括起来。
字符型一定是单个字符才能成为“字符型”
在语法级别上区分字符型和字符串型:
主要看是双引号还是单引号。
单引号的一定是字符型。
双引号的一定是字符串型。

4、变量:

变量的定义:
变量其实就是内存当中存储数据的最基本的单元。
变量就是一个存储数据的盒子。

在java语言当中任何数据都是有数据类型的,其中整数型是:int
当然,在java中除了数据类型int之外,还有其它的类型,例如带小数的:double等
数据类型有什么用呢?
重点:不同的数据类型,在内存中分配的空间大小不同。
也就是说,Java虚拟机到底给这个数据分配多大的空间,主要还是看这个变量的数据类型。
根据不同的类型,分配不同大小的空间。
对于int这种整数类型,JVM会自动给int分配4个字节大小的空间。

1个字节=8个比特位
1个比特位就是一个1或0. 注意:比特位是二进制位。
int是占用多少个二进制位?1个int占有32个二进制位(bit位)
int i = 1; 实际上在内存中是这样表示的:
00000000 00000000 00000000 00000001
int i = 2;
00000000 00000000 00000000 00000010
二进制位就是:满2进1位(0 1 10 11 100 101….)
十进制位就是:满10进1位(1 2 3 4 5 6 7 8 9 10)

对于一个变量来说,包括三要素:

  • 变量的数据类型
  • 变量的名字
  • 变量中保存的值

类型+名字+值
类型决定空间的大小。
起个名字是为了以后方便访问。(以后在程序中访问这个数据是通过名称来访问的。)
值是变量保存的数据。

变量名属于标识符

  • 变量名命名规范
  • 首字母小写,后面每个单词首字母大写,遵循驼峰命名方式,见名知意。

变量声明/定义的语法格式
数据类型 变量名;
例如:
int nianLing;

在java语言中有一个规定,变量必须先声明,再赋值才能访问。(没有值相当于这个空间没有开辟。)
在java语言中给一个变量赋值呢的语法格式
重点:使用一个运算符,叫做“=”,这个运算符被称为赋值运算符。
赋值运算符“=”的运算特点是:等号右边先执行,执行完之后赋值给左边的变量。
变量声明的时候可以同时进行赋值。
一行同时声明多个变量。
重点:在同一个域当中,变量名不能重名,不能重复声明(和类型没有关系。不能同名。)。
变量可以重新赋值,但在同一个域当中,不能重复声明。

关于变量的一个分类:
变量根据出现的位置进行划分:
在方法体当中声明的变量:局部变量。
在方法体之外,类体内声明的变量:成员变量。
重点依据是:声明的位置。
注意:局部变量只在方法体当中有效,方法体执行结束该变量的内存就释放了。

变量的作用域:

  • 作用域的定义:
    变量的有效范围。
  • 关于变量的作用域,可以记住一句话:
    出了大括号就不认识了。
  • java中有一个很重要的原则:
    就近原则。(不仅java中是这样,其它编程语言都有这个原则。)
    哪个离我近,就访问哪个。

5、数据类型:

数据类型有什么用?
数据类型用来声明变量,程序在运行过程中根据不同的数据类型分配不同大小的空间。
int i = 10;
double d = 1.23;
i变量和d变量类型不同,空间大小不同。

总述

  • 数据类型在java语言中包括两种:
    • 基本数据类型
      基本数据类型又可以划分为4大类8小种:
      • 整数型
        byte,short,int,long (没有小数的)
      • 浮点型
        float,double (带有小数的)
      • 布尔型
        boolean:只有两个值true和false,true表示真,false表示假
      • 字符型
        char:java中规定字符型字面量必须使用单引号括起来。属于文字。
      • 8小种:
        • byte,short,int,long
        • float,double
        • boolean
        • char
    • 引用数据类型
      字符串型String属于引用数据类型。
      String字符串不属于基本数据类型范畴。
      java中除了基本数据类型之外,剩下的都是引用数据类型。
      引用数据类型后期面向对象的时候才会接触。

8种基本数据类型中
整数型:byte short int long有什么区别?
浮点型:float和double有什么区别?
区别:占用的空间大小不同。

关于计算机存储单位?
计算机只能识别二进制。(1001101100…)
1字节 = 8bit(8比特)–> 1byte = 8bit
1bit就是一个1或0.
1KB = 1024byte
1MB = 1024KB
1GB = 1024MB
1TB = 1024GB

? byte b = 2; 在计算机中是这样表示的:00000010
? short s = 2; 在计算机中是这样表示的:00000000 00000010
? int i = 2;在计算机中是这样表示的:00000000 00000000 00000000 00000010
? …

? 类型 占用字节数量(byte)

? byte 1
? short 2
? int 4
? long 8
? float 4
? double 8
? boolean 1 (1byte的1或0,00000001(true)或00000000(false))
? char 2

关于二进制:
1 2 3 4 5 6 7
1 10 11 100 101 110 111 ….
十进制转换成二进制
125 转换成二进制:
办法:除以2,然后余数逆序输出:1111101
二进制转换成十进制
2的2次方 2的1次方 2的0次方
1 1 1
4 2 1
14 + 12 + 1*1 = 7

? 2的2次方 2的1次方 2的0次方
? 1 0 1
? 4 2 1
? 14 + 02 + 1*1 = 5

byte类型的取值范围:
byte是 [-128 ~ 127] 共可以标识256个不同的数字。
byte类型的最大值是怎么计算出来的?
byte是1个字节,是8个比特位,所以byte可以存储的最大值是:01111111
注意:在计算机当中,一个二进制位最左边的是符号位,当为0时表示正数,
当为1时表示负数。所以byte类型最大值是:01111111
那么是不是2的7次方-1呢?
是不是:10000000(前边是一个二进制) - 1
byte类型最大值是:2的7次方 - 1.

有几个取值范围需要大家记住:
(1个字节)byte: [-128 ~ 127]
(2个字节)short:[-32768 ~ 32767] 可以表示65536个不同的数字
(4个字节)int: [-2147483648 ~ 2147483647]
(2个字节)char: [0~65535] 可以表示65536个不同的数字
short和char实际上容量相同,不过char可以表示更大的数字。
因为char表示的是文字,文件没有正负之分,所以char可以表示更大的数字。

对于8种基本数据类型来说:
其中byte,short,int,long,float,double,boolean,这7种类型计算机表示起来比较容易,因为他们都是数字。其中布尔类型只有两个值true和false,实际上true和false分别在C++中对应的是1和0,1为true,false为0。
对于char类型来说计算机表示起来比较麻烦,因为char对应的是文字,每一个国家的文字不一样,文字不能直接通过“自然算法”转换成二进制。这个时候怎么办?字符编码诞生了。

什么是字符编码?
字符编码是人为的定义的一套转换表。
在字符编码中规定了一系列的文字对应的二进制。
字符编码其实本质上就是一本字典,该字段中描述了文字与二进制之间的对照关系。
字符编码是人为规定的。(是某个计算机协会规定的。)
字符编码涉及到编码和解码两个过程,编码和解码的时候必须采用同一套字符编码方式,不然就会出现乱码。

关于字符编码的发展过程?
起初的时候计算机是不支持文字的,只支持科学计算。实际上计算机起初是为了
战争而开发的,计算导弹的轨道….

? 后来随着计算机的发展,计算机开始支持文字,最先支持的文字是英文,英文
? 对应的字符编码方式是:ASCII码。

? ASCII码采用1byte进行存储,因为英文字母是26个。(键盘上所有的键全部算上也
? 超不过256个。1byte可以表示256种不同的情况。所以英文本身在计算机方面就占有
? 优势。)
? ‘a’ –(采用ASCII码进行编码)-> 01100001
? 01100001 –(采用ASCII码进行解码)-> ‘a’
? 如果编码和解码采用的不是同一个编码方式,会出现乱码。
? ‘b’ —> 98
? ‘c’ —> 99…
? ‘a’ —> 97

? ‘A’ —> 65
? ‘B’ —> 66
? …

? ‘0’ —> 48 (这个’0’不是那个0,是文字’0’)
? ‘1’ —> 49
?
? 随着计算机语言的发展,后来国际标准组织制定了ISO-8859-1编码方式,
? 又称为latin-1编码方式,向上兼容ASCII码。但不支持中文。
? 后来发展到亚洲,才支持中文,日文,韩文….
? 中文这块的编码方式:GB2312<GBK<GB18030 (容量的关系)
? 以上编码方式是简体中文。
? 繁体中文:big5(台湾使用的是大五码。)
? 在java中,java语言为了支持全球所有的文字,采用了一种字符编码方式
? 叫做unicode编码。unicode编码统一了全球所有的文字,支持所有文字。
? 具体的实现包括:UTF-8 UTF-16 UTF-32….

需要记住:
ASCII(’a’是97 ‘A’是65 ‘0’是48…)
ISO-8859-1(latin-1)
GB2312
GBK
GB18030
Big5
unicode(utf8 utf16 utf32)

布尔型bollean:

1、在java语言中boolean类型只有两个值,没有其他值:
true和false。
不像C或者C++,C语言中1和0也可以表示布尔类型
2、boolean类型在实际开发中使用在哪里呢?
使用在逻辑判断当中,通常放到条件的位置上(充当条件)

字符型char:

1、char占用2个字节。
char可以存储1个汉字吗?
可以的,汉字占用2个字节,java中的char类型占用2个字节,正好。
2、char的取值范围:[0-65535]
3、char采用unicode编码方式。
4、char类型的字面量使用单引号括起来。
5、char可以存储一个汉字。
6、转义字符:
java语言中“\”负责转义。
\t 表示制表符tab
\n 表示制表符回车
System.out.println(); 换行
System.out.print(); 不换行
' 表示一个普通不能再普通的单引号字符 ‘('联合起来表示一个普通的 ‘)
\ 表示一个“普通的反斜杠字符
// 双引号括起来的是字符串
System.out.println(“test”);
// 希望输出的结果是:”test”
// 错误: 需要’)’
//System.out.println(“”test””);
System.out.println(““test””); //内部的双引号我用中文的行吗?可以。
7、整数能否直接赋值给char
char x = 97;
这个java语句是允许的,并且输出的结果是’a’
经过这个测试得出两个结论:

  • 当一个整数赋值给char类型变量的时候,会自动转换成char字符型,最终的结果是一个字符。
  • 当一个整数没有超出byte short char的取值范围的时候,这个整数可直接赋值给byte short char类型的变量。

浮点型float/double:

浮点型包括:
float 4个字节
double 8个字节
float是单精度
double是双精度
double更精确
比如说:
10.0 / 3 如果采用float来存储的话结果可能是:3.33333
10.0 / 3 如果采用double来存储的话结果可能是:3.3333333333333
但是需要注意的是,如果用在银行方面或者说使用在财务方面,double
也是远远不够的,在java中提供了一种精度更高的类型,这种类型专门
使用在财务软件方面:java.math.BigDecimal (不是基本数据类型,属于
引用数据类型。)
float和double存储数据的时候都是存储的近似值。
因为现实世界中有这种无线循环的数据,例如:3.3333333333333….
数据实际上是无限循环,但是计算机的内存有限,用一个有限的资源
表示无限的数据,只能存储近似值。

long类型占用8个字节。
float类型占用4个字节。
注意:任意一个浮点型都比整数型空间大。
float容量 > long容量。
java中规定,任何一个浮点型数据默认被当做double来处理。
如果想让这个浮点型字面量被当做float类型来处理,那么
请在字面量后面添加F/f。
1.0 那么1.0默认被当做double类型处理。
1.0F 这才是float类型。(1.0f)

整型byte/short/int/long:

整数型在java语言中共4种类型:
byte 1个字节 最大值127
short 2个字节 最大值32767
int 4个字节 2147483647是int最大值,超了这个范围可以使用long类型。
long 8个字节
1个字节 = 8个二进制位
1byte = 8bit
对于以上的四个类型来说,最常用的是int。

在java语言中整数型字面量有4种表示形式:
十进制:最常用的。
二进制
八进制
十六进制

在java中有一条非常重要的结论,必须记住:
在任何情况下,整数型的“字面量/数据”默认被当做int类型处理。
如果希望该“整数型字面量”被当做long类型来处理,需要在“字面量”后面添加L/l
建议使用大写L,因为小写l和1傻傻分不清。

1
2
// 错误: 整数太大
long e = 2147483648;

在java中,整数型字面量一上来编译器就会将它看做int类型
而2147483648已经超出了int的范围,所以在没有赋值之前就出错了。
记住,不是e放不下2147483648,e是long类型,完全可以容纳2147483648
只不过2147483648本身已经超出了int范围。
解决问题:
long e = 2147483648L;

  • 小容量可以直接赋值给大容量,称为自动类型转换。
  • 大容量不能直接赋值给小容量,需要使用强制类型转换符进行强转。

但需要注意的是:
加强制类型转换符之后,虽然编译通过了,但是运行的时候可能会损失精度。
大容量转换成小容量,要想编译通过,必须加强制类型转换符,进行强制类型转换。
但是要记住:编译虽然过了,但是运行时可能损失精度。

java中有一个语法规则:
当这个整数型字面量没有超出byte的取值范围,那么这个整数型字面量可以直接赋值给byte类型的变量。
当整数型字面量没有超出short类型取值范围的时候,该字面量可以直接赋值给short。
这种语法机制是为了方便写代码,而存在的。
byte、char、short做混合运算的时候,各自先转换成int再做运算。

计算机的数据存储方式:

  • 计算机在任何情况下都只能识别二进制
  • 计算机在底层存储数据的时候,一律存储的是“二进制的补码形式”
    计算机采用补码形式存储数据的原因是:补码形式效率最高。
  • 补码:
    实际上是这样的,二进制有:原码 反码 补码
  • 记住:
    对于一个正数来说:二进制原码、反码、补码是同一个,完全相同。
  • 分析 byte b = (byte)150; //-106
    这个b是多少?
    int类型的4个字节的150的二进制码是什么?
    00000000 00000000 00000000 10010110
    将以上的int类型强制类型转为1个字节的byte,最终在计算机中的二进制码是:
    10010110

千万要注意:计算机永远存储的都是二进制补码形式。也就是说上面
10010110 这个是一个二进制补码形式,你可以采用逆推导的方式推算出
这个二进制补码对应的原码是啥!!!!!!
10010110 —> 二进制补码形式
10010101 —> 二进制反码形式
11101010 —> 二进制原码形式

计算机数据的自动转换:
在类型转换的时候需要遵循规则

  • 八种基本数据类型中,除 boolean 类型不能转换,剩下七种类型之间都可以进行转换;
  • 如果整数型字面量没有超出 byte,short,char 的取值范围,可以直接将其赋值给byte,short,char 类型的变 量;
  • 小容量向大容量转换称为自动类型转换,容量从小到大的排序为:
    byte < short(char) < int < long < float < double,其中 short和 char
    都占用两个字节,但是char 可以表示更大的正整数;
  • 大容量转换成小容量,称为强制类型转换,编写时必须添加“强制类型转换符”,但运行时可能出现精度损失,谨慎使用;
  • byte,short,char 类型混合运算时,先各自转换成 int 类型再做运算;
  • 多种数据类型混合运算,各自先转换成容量最大的那一种再做运算;

多种数据类型做混合运算的时候,最终的结果类型是“最大容量”对应的类型。
boolean < (char/short/byte)int < float < double
char+short+byte 这个除外。
因为char + short + byte混合运算的时候,会各自先转换成int再做运算。
java中规定,int类型和int类型最终的结果还是int类型(最终取整)。

6、java中的输入和输出:
输出信息到控制台:
System.out.println(…);
在java中怎么接收键盘的输入呢?
前提:java.util.Scanner s = new java.util.Scanner(System.in);
接收一个整数:
int num = s.nextInt();
接收一个字符串:
String str = s.next();
import java.util.Scanner;
Scanner s = new Scanner(System.in);

7、运算符:

1、算术运算符:

+ : 求和

- : 相减

* : 乘积

/ : 商

% : 求余数(求模)

++ : 自加1

-- : 自减1

对于++运算符来说:
可以出现在变量前,也可以出现在变量后。
不管出现在变量前还是后,总之++执行结束之后,变量的值一定会自加1。
* ++出现在变量后:
 语法:当++出现在变量后,会先做赋值运算,再自加1
* ++出现在变量前:
 语法:当++出现在变量前的时候,会先进行自加1的运算,然后再赋值。 

2、关系运算符:
    >    大于
    >=    大于等于
    <    小于
    <=    小于等于
    ==    等于
    !=    不等于

    一定要记住一个规则:
        所有的关系运算符的运算结果都是布尔类型,
        不是true就是false,不可能是其他值。

    在java语言中:
        = : 赋值运算符
        == :关系运算符,判断是否相等。

    注意:关系运算符中如果有两个符号的话,两个符号之间不能有空格。
        >= 这是对的, > = 这是不对的。
        == 这是对的,= = 这是不对的。

3、逻辑运算符:
    &    逻辑与(可以翻译成并且)
    |    逻辑或(可以翻译成或者)
    !    逻辑非(取反)
    &&    短路与
    ||    短路或

    用普通话描述的话:100 大于 99 并且 100 大于 98 ,有道理
    用代码描述的话:100 > 99 & 100 > 98 --> true

    true & true --> true

    非常重要:
        逻辑运算符两边要求都是布尔类型,并且最终的运算结果也是布尔类型。
        这是逻辑运算符的特点。

    100 & true 不行,语法错误。
    100 & 200 不行,没有这种语法。
    true & false 这样可以。

    100 > 90 & 100 > 101 --> false

    & 两边都是true,结果才是true
    | 有一边是true,结果就是true

    关于短路与 &&,短路或 ||(以短路与为例)

        * 短路与&& 和 逻辑与& 的区别
            首先这两个运算符的运算结果没有任何区别,完全相同。
            只不过“短路与&&”会发生短路现象。

        * 短路现象
            右边表达式不执行,这种现象叫做短路现象。

        * 什么时候使用&&,什么时候使用& ?
            从效率方面来说,&&比&的效率高一些。
            因为逻辑与&不管第一个表达式结果是什么,第二个表达式一定会执行。(&&更智能)

            以后的开发中,短路与&&和逻辑与还是需要同时并存的。
                大部分情况下都建议使用短路与&&
                只有当既需要左边表达式执行,又需要右边表达式执行的时候,才会
                选择逻辑与&。
        * 什么时候会发生短路?
            int x = 10;
            int y = 11;
            // 逻辑与&什么时候结果为true(两边都是true,结果才是true)
            // 左边的 x>y 表达式结果已经是false了,其实整个表达式的结
            // 果已经确定是false了,按道理来说右边的表达式不应该执行。
            System.out.println(x > y & x > y++); 

            // 通过这个测试得出:x > y++ 这个表达式执行了。
            System.out.println(y); // 12

            //测试短路与&&
            int m = 10;
            int n = 11;
            // 使用短路与&&的时候,当左边的表达式为false的时候,右边的表达式不执行
            // 这种现象被称为短路。
            System.out.println(m > n && m > n++);
            System.out.println(n); // 11

            什么时候发生短路或现象(|| 短路或)?
            // “或”的时候只要有一边是true,结果就是true。
            // 所以,当左边的表达式结果是true的时候,右边的表达式不需要执行,此时会短路。

4、赋值运算符:
    1、赋值运算符包括“基本赋值运算符”和“扩展赋值运算符”:基本的、扩展的。
    2、基本赋值运算符
        =
    3、扩展的赋值运算符
        +=
        -=
        *=
        /=
        %=
        注意:扩展赋值运算符在编写的时候,两个符号之间不能有空格。
            + =  错误的。
            += 正确的。
    4、很重要的语法机制:
        使用扩展赋值运算符的时候,永远都不会改变运算结果类型。
        byte x = 100;
        x += 1;
        x自诞生以来是byte类型,那么x变量的类型永远都是byte。不会变。
        不管后面是多大的数字。

    5、赋值运算符“=”右边优先级比较高,先执行右边的表达式
       然后将表达式执行结束的结果放到左边的“盒子”当中。(赋值)

    6、研究:
        // i += 10 和 i = i + 10 真的是完全一样吗?
        // 答案:不一样,只能说相似,其实本质上并不是完全相同。
        byte x = 100; // 100没有超出byte类型取值范围,可以直接赋值
        System.out.println(x); // 100

        // 分析:这个代码是否能够编译通过?
        // 错误: 不兼容的类型: 从int转换到byte可能会有损失
        //x = x + 1; // 编译器检测到x + 1是int类型,int类型可以直接赋值给byte类型的变量x吗?

        // 使用扩展赋值运算符可以吗?
        // 可以的,所以得出结论:x += 1 和 x = x + 1不一样。
        // 其实 x += 1 等同于:x = (byte)(x + 1);
        x += 1;
        System.out.println(x); // 101

        // 早就超出byte的取值范围了。
        x += 199; // x = (byte)(x + 199);
        System.out.println(x); // 44 (当然会自动损失精度了。)

5、条件运算符(三目运算符):
    语法格式:
        布尔表达式 ? 表达式1 : 表达式2

    执行原理
        布尔表达式的结果为true时,表达式1的执行结果作为整个表达式的结果。
        布尔表达式的结果为false时,表达式2的执行结果作为整个表达式的结果。

6、+ 运算符:
    1、+ 运算符在java语言中有两个作用:
        作用1:求和
        作用2:字符串拼接

    2、什么时候求和?什么时候进行字符串的拼接呢?
        当 + 运算符两边都是数字类型的时候,求和。
        当 + 运算符两边的“任意一边”是字符串类型,那么这个+会进行字符串拼接操作。

    3、一定要记住:字符串拼接完之后的结果还是一个字符串。

    4、示例:
        int a = 100;
        int b = 200;
        // 注意:当一个表达式当中有多个加号的时候
        // 遵循“自左向右”的顺序依次执行。(除非额外添加了小括号,小括号的优先级高)
        // 第一个+先运算,由于第一个+左右两边都是数字,所以会进行求和。
        // 求和之后结果是300,代码就变成了:System.out.println(300 + "110");
        // 那么这个时候,由于+的右边是字符串"110",所以此时的+会进行字符串拼接。
        System.out.println(a + b + "110"); // 最后一定是一个字符串:"300110"

        // 先执行小括号当中的程序:b + "110",这里的+会进行字符串的拼接,
        // 拼接之后的结果是:"200110",这个结果是一个字符串类型。
        // 代码就变成了:System.out.println(a + "200110");
        // 这个时候的+还是进行字符串的拼接。最终结果是:"100200110"
        System.out.println(a + (b + "110"));

        // 在控制台上输出"100+200=300"
        System.out.println(a + "+" + b + "=" + c);

        在java语言中定义字符串类型的变量
        // String是字符串类型,并且String类型不属于基本数据类型范畴,属于引用类型。
        // name是变量名,只要是合法的标识符就行。
        // "jack" 是一个字符串型字面量。
        String name = "jack";
        // 口诀:加一个双引号"",然后双引号之间加两个加号:"++",然后两个加号中间加变量名:"+name+"
        System.out.println("登录成功欢迎"+name+"回来");

8、控制语句:

选择结构
- if,if..else
if语句的语法结构以及运行原理
if语句是分支语句,也可以叫做条件语句。
if语句的语法格式:
第一种写法:
int a = 100;
int b = 200;
if(布尔表达式){
java语句;
java语句;
}
这里的一个大括号{} 叫做一个分支。
if 这个单词翻译为如果,所以又叫做条件语句。
该语法的执行原理是:
如果布尔表达式的结果是true,则执行大括
号中的程序,否则大括号中代码不执行。

            第二种写法:
                if(布尔表达式){  // 分支1
                    java语句;     
                }else{            // 分支2
                    java语句;
                }
                执行原理:
                如果布尔表达式的结果是true,则执行分支1,分支2不执行。
                如果布尔表达式的结果是false, 分支1不执行,执行分支2.
                以上的这个语句可以保证一定会有一个分支执行。
                else表示其它。

            第三种写法:
                if(布尔表达式1){ // 分支1
                    java语句;
                }else if(布尔表达式2){ // 分支2
                    java语句;
                }else if(布尔表达式3){
                    java语句;
                }else if(布尔表达式4){
                    java语句;
                }....
                以上if语句的执行原理:
                    先判断“布尔表达式1”,如果“布尔表达式1”为true,则执行分支1,
                    然后if语句结束了。
                    当“布尔表达式1”结果是false,那么会继续判断布尔表达式2的结果,
                    如果布尔表达式2的结果是true,则执行分支2,然后整个if就结束了。

                    从上往下依次判断,主要看第一个true发生在哪个分支上。
                    第一个true对应的分支执行,只要一个分支执行,整个if结束。

            第四种写法:
                if(布尔表达式1){ // 分支1
                    java语句;
                }else if(布尔表达式2){ // 分支2
                    java语句;
                }else if(布尔表达式3){
                    java语句;
                }else if(布尔表达式4){
                    java语句;
                }else{
                    java语句; // 以上条件没有一个成立的。这个else就执行了。
                }

?
注意:
1、对于if语句来说,在任何情况下只能有1个分支执行,不可能
存在2个或者更多个分支执行。if语句中只要有1个分支执行了,
整个if语句就结束了。(对于1个完整的if语句来说的。)

            2、以上4种语法机制中,凡是带有else分支的,一定可以保证会有
            一个分支执行。以上4种当中,第一种和第三种没有else分支,这样
            的语句可能会导致最后一个分支都不执行。第二种和第四种肯定会有
            1个分支执行。

            3、当分支当中“java语句;”只有1条,那么大括号{}可以省略,但为了
            可读性,最好不要省略。

            4、控制语句和控制语句之间是可以嵌套的,但是嵌套的时候大家最好
            一个语句一个语句进行分析,不要冗杂在一起分析。
                if(true){
                    //窍门:分析外面if语句的时候,里面的这个if语句可以看做是普通的一堆java代码。
                    if(true){
                        if(false){

                        }else{
                            ....最终走这里了。
                        }
                    }else{

                    }
                }else{

                }

                if(){
                    // 窍门:分析外面if时,里面的for循环当做普通java代码来看。
                    for(){
                        if(){
                            for(){

                            }
                        }
                    }
                }else{
                    while(){
                        if(){
                            for(){

                            }
                        }
                    }
                }

        示例1:
            业务要求:
                1、从键盘上接收一个人的年龄。
                2、年龄要求为[0-150],其它值表示非法,需要提示非法信息。
                3、根据人的年龄来动态的判断这个人属于生命的哪个阶段?
                    [0-5] 婴幼儿
                    [6-10] 少儿
                    [11-18] 少年
                    [19-35] 青年
                    [36-55] 中年
                    [56-150] 老年
                4、请使用if语句完成以上的业务逻辑。

            String str = "老年"; // 字符串变量默认值是“老年”
            if(age < 0 || age > 150){
                System.out.println("对不起,年龄值不合法");
                //return;
            } else if(age <= 5){
                str = "婴幼儿";
            } else if(age <= 10){
                str = "少儿";
            } else if(age <= 18){
                str = "少年";
            } else if(age <= 35){
                str = "青年";
            } else if(age <= 55){
                str = "中年";
            } 
            System.out.println(str);

        示例2:
            业务要求:
                1、系统接收一个学生的考试成绩,根据考试成绩输出成绩的等级。

                2、等级:
                    优:[90~100]
                    良:[80~90) 
                    中:[70-80)
                    及格:[60~70)
                    不及格:[0-60)

                3、要求成绩是一个合法的数字,成绩必须在[0-100]之间,成绩可能带有小数。

            java.util.Scanner s = new java.util.Scanner(System.in);
            System.out.print("请输入您的考试成绩:");
            // 考试成绩带有小数
            double score = s.nextDouble();
            // 判断考试成绩
            String str = "优";
            if(score < 0 || score > 100){
                str = "成绩不合法!!!";
            }else if(score < 60){
                str = "不及格";
            }else if(score < 70){
                str = "及格";
            }else if(score < 80){
                str = "中";
            }else if(score < 90){
                str = "良";
            }
            System.out.println(str);

    - switch
        switch语句:
            1、switch语句也是选择语句,也可以叫做分支语句。
            2、switch语句的语法格式

                switch(值){
                case 值1:
                    java语句;
                    java语句;...
                    break;
                case 值2:
                    java语句;
                    java语句;...
                    break;
                case 值3:
                    java语句;
                    java语句;...
                    break;
                default:
                    java语句;
                }

                以上是一个完整的switch语句:
                    其中:break;语句不是必须的。default分支也不是必须的。

                switch语句支持的值:
                    支持int类型以及String类型。
                    但一定要注意JDK的版本,JDK8之前不支持String类型,只支持int。
                    在JDK8之后,switch语句开始支持字符串String类型。

                    switch语句本质上是只支持int和String,但是byte,short,char也可以
                    使用在switch语句当中,因为byte short char可以进行自动类型转换。

                    switch语句中“值”与“值1”、“值2”比较的时候会使用“==”进行比较。

            3、switch语句的执行原理
                拿“值”与“值1”进行比较,如果相同,则执行该分支中的java语句,
                然后遇到"break;"语句,switch语句就结束了。

                如果“值”与“值1”不相等,会继续拿“值”与“值2”进行比较,如果相同,
                则执行该分支中的java语句,然后遇到break;语句,switch结束。

                注意:如果分支执行了,但是分支最后没有“break;”,此时会发生case
                穿透现象。

                所有的case都没有匹配成功,那么最后default分支会执行。

                switch中的case可以合并。

            示例:
                1、系统接收一个学生的考试成绩,根据考试成绩输出成绩的等级。

                2、等级:
                    优:[90~100]
                    良:[80~90) 
                    中:[70-80)
                    及格:[60~70)
                    不及格:[0-60)

                3、要求成绩是一个合法的数字,成绩必须在[0-100]之间,成绩可能带有小数。

                必须使用switch语句来完成。

                // 提示用户输入学生成绩
                java.util.Scanner s = new java.util.Scanner(System.in);
                System.out.print("请输入学生成绩:");
                double score = s.nextDouble();
                //System.out.println(score);
                if(score < 0 || score > 100){
                    System.out.println("您输入的学生成绩不合法,再见!");
                    return; 
                }

                // 程序能够执行到这里说明成绩一定是合法的。
                // grade的值可能是:0 1 2 3 4 5 6 7 8 9 10
                // 0 1 2 3 4 5 不及格
                // 6 及格
                // 7 中
                // 8 良
                // 9 10 优

                int grade = (int)(score / 10); // 95.5/10结果9.55,强转为int结果是9
                String str = "不及格";
                switch(grade){
                case 10: case 9:
                    str = "优";
                    break;
                case 8: 
                    str = "良";
                    break;
                case 7:
                    str = "中";
                    break;
                case 6:
                    str = "及格";
                }
                System.out.println("该学生的成绩等级为:" + str);

* 循环结构(循环语句的出现就是为了解决代码的复用性)
    - for
        1、for循环的语法机制以及运行原理:
            语法机制:
                for(初始化表达式; 条件表达式; 更新表达式){
                    循环体; // 循环体由java语句构成
                    java语句;
                    java语句;
                    java语句;
                    java语句;
                    ....
                }
                注意:
                    第一:初始化表达式最先执行,并且在整个循环中只执行一次。
                    第二:条件表达式结果必须是一个布尔类型,也就是:true或false
                执行原理:
                    先执行初始化表达式,并且初始化表达式只执行1次。
                    然后判断条件表达式的结果,如果条件表达式结果为true,
                    则执行循环体。
                    循环体结束之后,执行更新表达式。
                    更新完之后,再判断条件表达式的结果,
                    如果还是true,继续执行循环体。

                    直到更新表达式执行结束之后,再次判断条件时,条件为false,
                    for循环终止。

                更新表达式的作用是:控制循环的次数,换句话说,更新表达式会更新
                某个变量的值,这样条件表达式的结果才有可能从true变成false,从而
                终止for循环的执行,如果确实更新表达式,很有可能会导致死循环。

            2、嵌套使用:
                * 所有合法的“控制语句”都可以嵌套使用。
                * for循环嵌套一个for循环执行原理:
                        for(){
                            //在分析外层for循环的时候,把里面的for就当做一段普通的java语句/代码.
                            for(){}
                        }

            示例:
                九九乘法表

                1*1=1
                1*2=2 2*2=4
                1*3=3 2*3=6 3*3=9
                1*4=4 2*4=8 3*4=12 4*4=16
                ....
                ......
                1*9=9 2*9=18.............................9*9=81

                以上九九乘法表的特点:
                    第一个特点:共9行。
                    第二个特点:第1行1个。第2行2个。第n行n个。

                // 9行,循环9次。
                for(int i = 1; i <= 9; i++){ // 纵向循环
                    //System.out.println(i); // i是行号(1~9)
                    // 负责输出一行的。(内部for循环负责将一行上的全部输出。)
                    for(int j = 1; j <= i; j++){ // i是行号
                        System.out.print(j + "*" + i + "=" + i * j + " ");
                    }
                    // 换行
                    System.out.println();
                }

    - while
        1、while循环的语法机制以及执行原理
            语法机制:
                while(布尔表达式){
                    循环体;
                }
            执行原理:
                判断布尔表达式的结果,如果为true就执行循环体,
                循环体结束之后,再次判断布尔表达式的结果,如果
                还是true,继续执行循环体,直到布尔表达式结果
                为false,while循环结束。

        2、while循环有没有可能循环次数为0次?(与do...while区分开)
            可能。
            while循环的循环次数是:0~n次。

    - do...while()
        do..while循环语句的执行原理以及语法机制:
        语法机制:
            do {
                循环体;
            }while(布尔表达式);

        注意:do..while循环最后的时候别漏掉“分号”

        执行原理:
            先执行循环体当中的代码,执行一次循环体之后,
            判断布尔表达式的结果,如果为true,则继续执行
            循环体,如果为false循环结束。

        对于do..while循环来说,循环体至少执行1次。循环体的执行次数是:1~n次。
        对于while循环来说,循环体执行次数是:0~n次。

* 控制循环的语句
    - break
        1、break;语句比较特殊,特殊在:break语句是一个单词成为一个完整的java语句。
        另外:continue也是这样,他俩都是一个单词成为一条语句。

        2、break 翻译为折断、弄断。

        3、break;语句可以用在哪里呢?
            用在两个地方,其它位置不行
            第一个位置:switch语句当中,用来终止switch语句的执行。
                用在switch语句当中,防止case穿透现象,用来终止switch。

            第二个位置:break;语句用在循环语句当中,用来终止循环的执行。
                用在for当中
                用在while当中
                用在do....while..当中。

        4、break;语句的执行并不会让整个方法结束,break;语句主要是用来终止离它最近
        的那个循环语句。

        5、怎么用break;语句终止指定的循环呢?(很少用)
            第一步:你需要给循环起一个名字,例如:
                a: for(){
                    b:for(){

                    }
                }
            第二步:终止:break a;

    - continue
        1、continue翻译为:继续
        2、continue语句和break语句要对比着学习
        3、continue语句的作用是:
            终止当前"本次"循环,直接进入下一次循环继续执行。
            for(){
                if(){ // 当这个条件成立时,执行continue语句
                    continue; //当这个continue语句执行时,continue下面的代码不执行,直接进入下一次循环执行。
                }
                // 以上的continue一旦执行,以下代码不执行,直接执行更新表达式。
                code1;
                code2;
                code3;
                code4;
            }

        4、continue语句后面也可以指定循环
            a:for(;;更新表达式1){
                b:for(;;更新表达式2){
                    if(){
                        continue a;
                    }
                    code1;
                    code2;
                    code3;
                }
            }

    break;语句和return;语句的区别:
    不是一个级别。
    break;用来终止switch和离它最近的循环。
    return;用来终止离它最近的一个方法。

需要掌握的内容:

  • 理解java的加载与执行
  • 能够自己搭建java的开发环境
  • 能够独立编写HelloWorld程序,编译并运行
  • 掌握环境变量path的原理以及如何配置
  • 掌握环境变量classpath的原理以及如何配置
  • java中的注释
  • public class 和 class 的区别

1、计算机结构:

计算机包括:

  • 硬件
    • CPU:中央处理器,负责计算机的核心运算,它是计算机的最核心部件,指挥官。 1 + 1 = 2
    • 内存:临时存储区域,程序在运行的过程当中,一些数据的临时存储区域。
    • 主板:链接各个部件
    • 显卡
    • 声卡
    • 鼠标
    • 键盘
    • 硬盘【外存】:永久性保存,断电之后再启动,数据仍然存在。
      …..
  • 软件

    • 系统软件

      • windows系列的

      • winxp

      • win7
        ….

      • Linux系列的

      • Red Hat

      • Fedora

      • SUN Solaris
        ….

    • 应用软件

      • QQ

      • 百度云管家

      • Office办公软件

      …..

  • 总结:

    • 应用软件是运行在系统软件当中的,系统软件和底层硬盘交互。

    • Java编程语言可以:完成应用软件的开发。

    • 可以用一个功能比记事本强大的文本编辑器进行java程序的编写。

    • windows操作系统默认情况下是不显示文件扩展名的,作为程序员必须将文件的扩展名显示出来:

      • 计算机 –> 组织 –> 文件夹和搜索选项 –> 查看 –> 隐藏已知文件类型的扩展名【对勾去掉】 win7
      • 计算机 –> 查看 –> 隐藏已知文件类型的扩展名【对勾去掉】 win10

2、windows操作系统当中常用的DOS命令:

  • 什么是DOS命令呢?

    在DOS命令窗口中才可以输入并执行DOS命令。
    在最初的windows计算机中没有图形界面的,只有DOS命令窗口
    也就是说通过执行DOS命令窗口可以完全完成文件的新建、编辑、保存、删除等一系列操作。

  • 不使用UI界面,使用DOS命令可以完成所有的操作。

  • 在DOS命令窗口中可以执行DOS命令

  • 打开DOS命令窗口:

    • 快捷键:win + r,打开运行窗口
    • 输入cmd回车
  • 查看IP地址:

    • ipconfig
    • ipconfig /all 可以查看更详细的IP信息,这种查看方式可以看到网卡的物理地址。
      物理地址具有全球唯一性。是在生产网卡的时候,嵌入的编号。
  • 清屏:cls

  • DOS窗口当中也可以设置字体和屏幕以及文字的颜色。

  • 退出DOS命令窗口:exit

  • 怎么从DOS命令窗口当中复制文本:
    -任意位置点击鼠标右键–>标记 –> 选择你要复制的文本 –> 点击鼠标右键 (此时已经到剪贴板当中了)
    找一个位置粘贴即可。
    -左键选择你要复制的文本 –> ctrl+c进行复制 (此时已经到剪贴板当中了)–> x选择一个位置ctrl+v粘贴即可。

  • 查看两台计算机之间是否可以正常通信:

    • ping 192.168.27.23 【发送和接收数据包4次】
    • ping 192.168.27.23 -t 【一直不停的发送和接收数据包】
    • ping www.baidu.com
  • 强行终止DOS命令窗口中正在运行的程序:ctrl + c

  • 打开DOS命令窗口默认所在的路径是:C:\Users\Administrator???

  • 创建目录:mkdir abc【表示在当前所在目录下新建一个目录,起名abc】

  • 关于目录切换命令:cd

    • cd 命令的语法格式:

      cd 路径

    • 路径分为:

      • 绝对路径:
        C:\Users\Administrator
        D:\用户目录\收藏夹
        ……

        从硬盘的根路径作为出发点。

      • 相对路径:
        从当前所在的位置作为起点的路径。

    • 自动补全:
      cd e 【然后按tab键,当前所在的目录下所有以e开始的目录自动补全路径,当这个自动补全的路径不是自己想要的路径,可以继续使用tab键】

  • 回到上级目录:cd .. 【..是一个路径,代表当前路径的上级路径】
    cd ../../../

  • 直接回到根路径:cd \

  • 查看当前目录下所有的子文件和子目录:dir

  • del:
    删除一个或者多个文件
    删除T1.class文件
    C:\Users\Administrator>del T1.class
    删除所有.class结尾的文件,支持模糊匹配
    C:\Users\Administrator>del *.class

    T1.class
    T1.glass
    del *ass 这个命令就会将T1.class和T1.glass都删除。
    删除的一定是能匹配上的。

    del *.class 这个命令中的那个“.”不要特殊化,这个“.”其实就是一个普通的字母

  • 不要把相关重要的资料放到桌面上,因为桌面是属于C盘系统盘。

  • 怎么切换盘符:【不需要使用cd命令】
    c: 回车
    d: 回车
    e: 回车
    f: 回车

  • 打开注册表:regedit

3、关于windows操作系统当中常用的快捷键:

  • win + r 打开运行窗口
  • win + d 显示桌面
  • win + e 打开资源管理器
  • win + L 锁屏
  • alt + tab 应用之间的切换

4、“通用的”文本编辑快捷键:

  • ctrl + a 全选
  • ctrl + c 复制
  • ctrl + v 粘贴
  • ctrl + s 保存
  • ctrl + x 剪切
  • ctrl + z 撤销
  • ctrl + y 重做
  • tab 缩进/多行缩进
  • shift + tab 取消缩进
  • HOME 回到行首
  • END 回到行尾
  • shift + home 选中一行
  • shift + end 选中一行
  • ctrl + shift + 向右或者向左的箭头 选中一个单词
  • 鼠标双击: 选中一个单词
  • 鼠标三击: 选中一行
  • ctrl + end 回到文件末尾
  • ctrl + home 回到文件头

5、什么是JDK?

Java Development Kits
Java开发工具包【Java开发必备】
可以从Oracle的官网上下载。http://www.oracle.com
下载JDK的时候需要注意:JDK的版本,不同的操作系统需要安装不同版本的JDK。

6、Java分三大块:1999年

  • J2SE【Java的标准版本】:

    基础,无论是以后走EE还是ME,SE是必须要精通的。
    J2SE是SUN公司为java程序员准备的一套“基础类库”,这套基础类库学习之后,可以完成最基本的操作,
    例如,文件的读写、线程的控制….

  • J2EE【Java的企业版本】:

    这是SUN公司为程序员专门准备的一套“类库”,这套类库可以协助程序员完成企业级软件的开发
    企业级软件:OA办公系统、进销存系统、超市系统…….

  • J2ME【Java的微型版本】

    这是SUN公司为java程序员专门准备的另一套“类库”,这套类库可以协助程序员完成微型设备的嵌入式开发,
    Java最初就是做微型设备嵌入式开发的。

  • 2005年,java诞生十周年的时候,以上的三大模块改名了:

    • JavaSE
    • JavaEE
    • JavaME

7、关键术语:

  • JDK【Java开发工具箱】
  • JRE【Java的运行时环境】
  • JVM【Java虚拟机】

三者之间的关系:

  • JDK 中包含JRE
  • JRE中包含JVM

8、Java语言特性:

  • 跨平台/可移植

    • 有一种特殊的机制:JVM
    • Java程序并没有和底层的操作系统直接交互,java程序实际上运行在jvm当中,JVM屏蔽了操作系统之间的差异。
    • 但是有一个前提:不同的操作系统中必须安装不同版本的JVM。
    • 在可移植性方面表现非常好,一次编译,到处运行。
    • 但是为了达到可移植,必须提前在操作系统中安装JRE,JRE有了之后才会有JVM。【JVM不能单独安装】
      这方面体验不是特别好。
    • java语言只要编写一次,可以做到到处运行。
      例如:java程序编写完之后,可以运行在windows操作系统上,
      不需要做任何改动可以直接运行在Linux操作系统上,同样也
      可以运行到MaC OS上面。
      一次编写,到处运行。(平台改变了,程序不需要改。)
  • JVM这种机制实现了跨平台,那么这种机制优点和缺点分别是什么?

    • 优点:一次编写到处运行,可以跨平台。

    • 缺点:麻烦。对于运行java程序来说必须先有一个JVM。
      就像你要想在网页上看视频,你必须先安装一个flash是一样的。

    • Java语言可以编写病毒吗?

      可以,没问题。但是很难让用户中毒。
      中毒的一般都是java程序员。所以很少有人编写java的病毒脚本。

  • Java号称:开源、免费、跨平台、纯面向对象。

    • 开源:开发源代码,SUN公司编写的java类库的源代码普通程序员能看到。众人拾柴火焰高。
      这样java程序会很健壮。很少的BUG【漏洞/陷阱】

    • 免费

    • 跨平台:依靠JVM机制【java程序不和操作系统交互,java程序运行在JVM中,JVM和操作系统交互。】
      不同的操作系统有不同版本的JVM。

    • 面向对象:人类在认识现实世界的时候多数是以面向对象的方式认知的。

  • 简单性:

    • 这里的简单说的是相对于C语言来说的。
    • 例如:C语言当中有指针,C++中多继承
    • java取消了指针的概念,取消了多继承,只支持单继承。
      …..
  • java支持多线程

  • java中还有一种特殊的机制:自动垃圾回收机制。GC机制。
    【java运行过程当中有一个“垃圾回收器”一直在守护着。】

9、Java的加载与执行:一个完整的java程序

1、Java开发的整个生命周期,包括两个重要的阶段,分别是:编译阶段和运行阶段

2、编译生成的程序被称为:字节码程序。编译生成的文件是:xxx.class文件

3、编译和运行可以在不同的操作系统中完成。

4、程序员在xxx.java文件中编写源代码,源代码必须符合java的语法,这些源代码就是高级语言。
存放源代码的文件被称为源文件。

5、过程:

  • 编译期:【在windows环境中完成】

    安装JDK,配置环境

    在硬盘的某个位置创建一个xxx.java源文件

    打开源文件,在该文件当中编写符合java语法的源程序,然后保存。

    使用JDK中自带的javac.exe命令对以上的java源程序进行编译。

    编译通过:说明语法没有问题

    在硬盘上生成一个或者多个字节码文件【xxx.class】

    编译失败:说明源程序某个位置不符合java语法格式。

    编译的语法格式:打开DOS命令窗口,输入:javac 源文件路径

  • 注意:

    • 源文件路径可以是绝对路径,也可以是相对路径。
    • 编译之后,其实java源文件删除不会影响程序的执行。
    • 最好不要将java源文件删除,因为程序最终运行效果不是预期效果的时候,需要重新修改java源代码,然后进行重新编译生成全新的class字节码文件,再重新运行字节码程序。

  • 运行期:【可以不在windows中完成,可以换一个操作系统,但前提是该操作系统中已经安装java的运行时环境】

    • 打开命令窗口,在命令窗口中使用java.exe命令运行java程序,语法格式:java 类名
    • 注意:java这个命令使用的时候,java命令后面不是文件的路径。必须是一个“类名”。
      • 例如:
        • java Hello
        • java Student
        • java User
        • java Product
    • 以下程序的执行原理:
      • java.exe命令执行会启动:JVM
      • JVM启动之后,马上启动“类加载器-Class Loader”
      • ClassLoader负责去硬盘的“某个位置”上搜索“类名.class”字节码文件。
      • 找不到这个.class文件,一定会出现程序异常现象。
      • 找到了这个.class文件之后将.class文件转换成”二进制”,操作系统可以直接识别二进制,
        操作系统执行二进制码和底层的硬件平台进行交互。

10、什么是类名?

  • 假设硬盘上有一个文件,叫做Hello.class,那么类名就叫做:Hello
  • 假设硬盘上有一个文件,叫做Student.class,那么类名就叫做:Student
  • 假设硬盘上有一个文件,叫做User.class,那么类名就叫做:User
  • 假设硬盘上有一个文件,叫做Product.class,那么类名就叫做:Product

11、开始第一个java程序的开发

1、JDK下载

2、JDK安装

  • 只安装了JDK,独立的JRE没有安装

3、在硬盘的某个位置上新建一个java源文件:HelloWorld.java

4、在HelloWorld.java文件中编写源代码

5、打开命令窗口,使用javac命令进行编译:

  • javac 源文件路径

  • 出现以下错误:

    C:\Users\Administrator>javac
    ‘javac’ 不是内部或外部命令,也不是可运行的程序
    或批处理文件。

  • 怎么解决?

    • 第一种方案:切换到javac.exe文件所在的目录,这个时候使用javac.exe不会出问题,但是这种方式比较麻烦。
    • 第二种方案:配置环境变量path
  • 原理:windows操作系统在查找某个命令的时候是怎么查找的?

    • 首先会从当前目录下找这个命令
    • 当前目录下不存在这个命令的话,会去环境变量path指定的路径当中查找该命令。
    • 还是找不到则出现错误提示信息。
    • path环境变量隶属于windows操作系统,和java无关,这个环境变量主要用来指定命令的搜索路径。

6、配置环境变量

  • 计算机 –> 点击右键 –> 属性 –> 高级系统设置 –> 环境变量

  • 环境变量配置包括用户级别和系统级别

  • 任何一个环境变量都有变量名和变量值,例如path环境变量:
    变量名是:path值:路径【多个路径之间必须采用分号隔开,而且要求分号必须是半角分号】

    path=C:\Program Files (x86)\Java\jdk1.7.0_75\bin;otherpath;otherpath…..

编译1【绝对路径】:D:\course\JavaProjects>javac D:\course\JavaProjects\02-JavaSE\day01\HelloWorld.java
编译2【相对路径】:D:\course\JavaProjects>javac 02-JavaSE\day01\HelloWorld.java
编译3【相对路径】:D:\course\JavaProjects\02-JavaSE\day01>javac HelloWorld.java

7、运行:

  • 必须将路径切换到“D:\course\JavaProjects\02-JavaSE\day01”目录下

  • 执行:java HelloWorld

  • D:\course\JavaProjects\02-JavaSE\day01>java HelloWorld
    Hello World!

12、JDK、JRE、JVM三者之间的关系?

  • JDK:Java开发工具箱
  • JRE:java运行环境
  • JVM:java虚拟机

JDK包括JRE,JRE包括JVM。

JVM是不能独立安装的。

JRE和JDK都是可以独立安装的。

有单独的JDK安装包。

也有单独的JRE安装包。

没有单独的JVM安装包。

安装JDK的时候:JRE就自动安装了,同时JRE内部的JVM也就自动安装了。

安装JRE的时候:JVM也就自动安装了。

13、java程序从开发到最终运行经历了什么?

  • 编译期:(可以在windows上)

    • 第一步:在硬盘的某个位置(随意),新建一个xxx.java文件
    • 第二步:使用记事本或者其它文本编辑器例如EditPlus打开xxx.java文件
    • 第三步:在xxx.java文件中编写“符合java语法规则的”源代码。
    • 第四步:保存(一定要将xxx.java文件保存一下)
    • 第五步:使用编译器(javac【JDK安装后自带】)对xxx.java文件进行编译。
    • 第六步:如果xxx.java文件中编写的源代码是符合语法规则的,编译会通过,如果xxx.java文件中编写的源代码违背了语法规则,那么编译器会报错,编译器报错之后class文件是不会生成的,只有编译通过了才会生成class字节码文件。并且一个java源文件是可以生成多个class文件的。(编译实质上是检查语法)
  • 运行期(JRE在起作用):(可以在windows上,也可以在其他的OS上。)

    • 第七步:如果是在Linux上运行,需要将windows上生成的class文件拷贝过去不需要拷贝源代码,真正运行的是字节码。(但是源代码也不要删除,有用)
    • 第八步:使用JDK自带的一个命令/工具:java(负责运行的命令/工具)执行字节码
    • 第九步:往下的步骤就全部交给JVM了,就不需要程序员干涉了。
      JVM会将字节码文件装载进去,然后JVM对字节码进行解释(解释器负责将字节码解释为1010101010..等的二进制)
    • 第十步:JVM会将生成的二进制码交给OS操作系统,操作系统会执行二进制码和硬件进行交互。
  • 注意:在以上的过程中,需要使用两个非常重要的命令?

    • javac 命令,负责编译
    • java 命令,负责运行
  • 以上是一个复杂的过程,那么缩减一下,程序员到底要干啥?(编写、编译、运行)

    • 新建java文件
    • 打开java文件
    • 写java源代码
    • 保存
    • javac命令编译
    • java命令运行

14、注释

1、注释是对java源代码的解释说明。

注释可以帮程序员更好的理解程序。

2、注释信息只保存在java源文件当中,java源文件编译生成的字节码class文件,class文件中是没有这些注释信息的。

3、在实际的开发中,一般项目组都要求积极的编写注释。这也是一个java软件工程师的基本素养。

4、注释不是写的越多越好,精简,主线清晰,每个注释都应该是点睛之笔。

5、注释方法(3种):

  • 单行注释
    // 这种注释属于单行注释,只注释两个斜杠后面的

  • 多行注释
    /*
    在这里可以编写多行注释
    这是第一行注释
    这是第二行注释
    这是第三行注释
    …….
    */

  • javadoc注释
    /**

    *javadoc注释

    *javadoc注释

    *javadoc注释

    *javadoc注释

    *javadoc注释
    */
    注意:这种注释是比较专业的注释,该注释信息会被javadoc.exe工具解析提取并生成帮助文档。

15、HelloWorld程序的执行原理

  1. java.exe命令会启动JVM
  2. JVM启动之后会启动类加载器ClassLoader
  3. ClassLoader会在硬盘上的某个位置搜索HelloWorld.class字节码文件
  4. 找到该文件则执行
  5. 找不到该文件则报错

ClassLoader是在哪个位置上搜索HelloWorld.class字节码文件的?

默认情况下,ClassLoader从当前路径下加载xxx.class字节码文件

当然,也可以让ClassLoader去某个指定的路径下加载字节码文件,这时需要配置环境变量classpath

classpath环境变量属于java语言中的环境变量,不属于windows操作系统【PATH环境变量属于操作系统】

classpath是给ClassLoader类加载器指路的。

设置这样的环境变量:classpath=D:\course\JavaProjects\02-JavaSE\day02

打开dos命令窗口在任意位置,都可以执行:java HelloWorld

classpath环境变量没有配置的话,类加载器默认从当前路径下找字节码文件,
当classpath环境变量配置为某个指定的路径之后,类加载器只去指定的路径当中加载字节码文件。

综上所述,环境变量classpath不再配置,这样类加载器会自动从当前路径下加载class字节码文件。
所以,每一次执行.class程序的时候,需要在DOS命令窗口中先切换到.class字节码文件所在的路径下。
然后运行。

当然,classpath也可以这样配置:classpath=.

注意:

  • 路径中“..”表示上级目录
  • 路径中“.”表示当前目录

对HelloWorld程序进行解释:
需要记忆:

  • public
  • class
  • static
  • void
  • System.out.println(“”); 向控制台输出消息
  • 类体
  • 方法体
  • 类体中不能直接编写java语句【除声明变量之外】
  • 一个java语句必须以“;”结束
  • 方法体中可以编写多条java语句
  • 主方法是程序的入口,固定写法,SUN规定的。

16、关于java代码的编写

在java中任何有效的代码必须写到“类体”当中,最外层必须是一个类的定义。

public表示公开的,class表示定义一个类,Test是一个类名。类名后面必须是一对大括号,这一对大括号被称为“类体”

括号和引号必须是成对的。并且建议都要成对编写,这样才不会丢掉。

代码缩进:

  • 我包着你,你就比我低一级。你就需要缩进。
  • 没有合理的缩进,代码可读性很差。
  • 或者也可以这样说,大括号里的都需要缩进。
  • 缩进就是可读性问题,不缩进也不影响程序的编译和执行。

类体/public static void main(String[] args){}:

  • 程序的入口,SUN公司java语言规定的
  • 也就是说:JVM在执行程序的时候,会主动去找这样一个方法。没有这个规格的方法,程序是无法执行的。
  • main方法也可以叫做主方法。
  • 注意:
    • 方法必须放到“类体”中,不能放到“类体”外面。
    • 类体当中应该是方法,而不是直接的java语句。
    • 任何一个程序都要有一个入口,没有入口进不来,无法执行。
    • 程序的入口只有一个,若有一个一模一样的入口,编译器会报错。
    • 一个程序如果没有入口但是没有语法错误的话,编译器不会报错,但运行会报错。

方法体/System.out.println():
- 注意:
- 方法体由一行一行的“java语句”构成
- 并且非常重要的是:任何一条java语句必须以“;”结尾,并且这个分号还得是英文的,不能用中文分号。
- “;” 代表一条语句的结束。
- 非常非常重要的是:方法体中的代码遵循自上而下的顺序依次逐行执行。
- System.out.println();这行代码的作用是向控制台输出一句话。就是这个作用。
- 注意:如果println后面小括号里的内容是一个“字符串”的话,必须使用英文双引号括起来。
- 双引号也要成对儿写。

注意的小点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 这个不加双引号行吗?
// 可以,因为它是数字。
System.out.println(100);

// 是数字,加双引号行吗?
System.out.println("100");

// 以上性质一样吗?
// 不一样:一个是字符串,一个是数字。
// 但最终输出到控制台上一个样子,没啥区别。

// 这里扩展一下:对于数字来说能进行加减乘除吗?
// + 能用吗?
// - 能用吗?
// / 能用吗?
// * 能用吗?
// 可以
System.out.println(100 + 200); // 300
System.out.println(200 - 100); // 100
System.out.println(200 * 100); // 20000
System.out.println(200 / 100); // 2

17、public class 和 class的区别:

  • 一个java源文件当中可以定义多个class

  • 一个java源文件当中public的class不是必须的

  • 一个class会定义生成一个xxx.class字节码文件

  • 一个java源文件当中定义公开的类的话,只能有一个,并且该类名称必须和java源文件名称一致。

  • 每一个class当中都可以编写main方法,都可以设定程序的入口,想执行B.class中的main方法:java B,
    想执行X.class当中的main方法:java X

  • 注意:

  • 当在命令窗口中执行java Hello,那么要求Hello.class当中必须有主方法。没有主方法会出现运行
    阶段的错误:

    D:\course\JavaProjects\02-JavaSE\day02>java Hello
    错误: 在类 B 中找不到主方法, 请将主方法定义为:
    public static void main(String[] args)