互联网开发文档

互联网开发文档


面试准备随笔

<p>[TOC]</p> <h1>0.基础知识</h1> <h2>IO</h2> <h3>IO模型</h3> <p>IO:用户进程想要执行 IO 操作的话,必须通过 <strong>系统调用</strong> 来间接访问内核空间</p> <p>我们在平常开发过程中接触最多的就是 <strong>磁盘 IO(读写文件)</strong> 和 <strong>网络 IO(网络请求和响应)</strong>。<strong>从应用程序的视角来看的话,我们的应用程序对操作系统的内核发起 IO 调用(系统调用),操作系统负责的内核执行具体的 IO 操作。也就是说,我们的应用程序实际上只是发起了 IO 操作的调用而已,具体 IO 的执行是由操作系统的内核来完成的</strong> 应用程序发起IO调用后:</p> <ol> <li>内核等待 I/O 设备准备好数据</li> <li>内核将数据从内核空间拷贝到用户空间。</li> </ol> <p>Java中3种常见的IO模型</p> <ul> <li>BIO(Blocking IO):同步阻塞IO模型,应用程序发起 read 调用后,会一直阻塞,直到内核把数据拷贝到用户空间 <img src="https://www.showdoc.com.cn/server/api/attachment/visitFile?sign=158333aafc6a866fd83cc0ef42f56dcb&amp;file=file.png" alt="" /></li> <li>NIO(Non-Blocking IO): <ul> <li>同步非阻塞IO模型: <img src="https://www.showdoc.com.cn/server/api/attachment/visitFile?sign=5d3f21654e2522d8100c2aab34f0cc02&amp;file=file.png" alt="" /> <ul> <li>应用程序会一直发起 read 调用,等待数据从内核空间拷贝到用户空间的这段时间里,线程依然是阻塞的,直到在内核把数据拷贝到用户空间</li> <li>通过轮训操作,避免了一直阻塞</li> </ul></li> <li>IO多路复用模型: <img src="https://www.showdoc.com.cn/server/api/attachment/visitFile?sign=8d2ceee9204c81a7832fdc84ce17a4fc&amp;file=file.png" alt="" /> <ul> <li>线程首先发起 select 调用,询问内核数据是否准备就绪,等内核把数据准备好了,用户线程再发起 read 调用。read 调用的过程(数据从内核空间 -&gt; 用户空间)还是阻塞的。</li> <li>通过减少无效的系统调用,减少了对 CPU 资源的消耗</li> </ul></li> </ul></li> <li>AIO(Asynchronous IO):异步IO模型 <img src="https://www.showdoc.com.cn/server/api/attachment/visitFile?sign=c988a2da8d8a6ebbf6075563c762de55&amp;file=file.png" alt="" /> <ul> <li>异步 IO 是基于事件和回调机制实现的,也就是应用操作之后会直接返回,不会堵塞在那里,当后台处理完成,操作系统会通知相应的线程进行后续的操作。</li> </ul></li> </ul> <h3>Netty</h3> <p>Netty是什么</p> <ol> <li>Netty 是一个 基于 NIO 的 client-server(客户端服务器)框架,使用它可以快速简单地开发网络应用程序。 </li> <li>它极大地简化并优化了 TCP 和 UDP 套接字服务器等网络编程,并且性能以及安全性等很多方面甚至都要更好。 </li> <li>支持多种协议 如 FTP,SMTP,HTTP 以及各种二进制和基于文本的传统协议。</li> </ol> <p>为什么用Netty:因为 Netty 具有下面这些优点,并且相比于直接使用 JDK 自带的 NIO 相关的 API 来说更加易用。</p> <ul> <li>统一的API,支持多种传输类型,阻塞和非阻塞</li> <li>简单而强大的线程模型。</li> <li>自带编解码器解决 TCP 粘包/拆包问题。 </li> <li>自带各种协议栈。 </li> <li>真正的无连接数据包套接字支持。 </li> <li>比直接使用 Java 核心 API 有更高的吞吐量、更低的延迟、更低的资源消耗和更少的内存复制。</li> <li>安全性不错,有完整的 SSL/TLS 以及 StartTLS 支持。 </li> <li>社区活跃 </li> <li>成熟稳定,经历了大型项目的使用和考验,而且很多开源项目都使用到了 Netty, 比如我们经常接触的 Dubbo、RocketMQ 等等。</li> </ul> <p>Netty应用场景:主要用来做网络通信</p> <ol> <li><strong>作为 RPC 框架的网络通信工具</strong> : 我们在分布式系统中,不同服务节点之间经常需要相互调用,这个时候就需要 RPC 框架了。不同服务之间的通信是如何做的呢?可以使用 Netty 来做。比如我调用另外一个节点的方法的话,至少是要让对方知道我调用的是哪个类中的哪个方法以及相关参数吧!</li> <li><strong>实现一个自己的 HTTP 服务器</strong> :通过 Netty 我们可以自己实现一个简单的 HTTP 服务器,这个大家应该不陌生。说到 HTTP 服务器的话,作为 Java 后端开发,我们一般使用 Tomcat 比较多。一个最基本的 HTTP 服务器可要以处理常见的 HTTP Method 的请求,比如 POST 请求、GET 请求等等。 </li> <li><strong>实现一个即时通讯系统</strong> : 使用 Netty 我们可以实现一个可以聊天类似微信的即时通讯系统,这方面的开源项目还蛮多的,可以自行去 Github 找一找。 </li> <li><strong>实现消息推送系统</strong> :市面上有很多消息推送系统都是基于 Netty 来做的。</li> </ol> <p>Netty的核心组件 <img src="https://www.showdoc.com.cn/server/api/attachment/visitFile?sign=e31ceceddb0fd136557d56efb02444cf&amp;file=file.png" alt="" /></p> <ul> <li>Bytebuf(字节容器):字节容器,其内部是一个字节数组</li> <li>Bootstrap(客户端启动引导类)和ServerBootstrap(服务端启动引导类): <ul> <li>Bootstrap通常使用connect()方法(返回ChannelFuture)连接到远程的主机和端口,作为一个Netty TCP协议通信中的客户端</li> <li>ServerBootstrap通常使用bind()方法(返回ChannelFuture)绑定本地的端口上,然后等待客户端的连接</li> <li>Bootstrap 只需要配置一个线程组— EventLoopGroup ,而 ServerBootstrap需要配置两个线程组 ,一个用于接收连接,一个用于具体的 IO 处理</li> </ul></li> <li> <p>ChannelFuture(操作执行结果):Netty 中所有的 I/O 操作都为异步的,我们不能立刻得到操作是否执行成功。 不过,你可以通过 <code>ChannelFuture</code> 接口的 <code>addListener()</code> 方法注册一个<code>ChannelFutureListener</code>,当操作执行成功或者失败时,监听就会自动触发返回结果。</p> <pre><code class="language-java">public interface ChannelFuture extends Future&lt;Void&gt; { Channel channel();//获取连接相关联的Channel ChannelFuture addListener(GenericFutureListener&lt;? extends Future&lt;? super Void&gt;&gt; var1);//注册一个ChannelFutureListener,当操作执行成功或者失败,监听就会自动触发返回结果 ...... ChannelFuture sync() throws InterruptedException;//让异步的操作变成同步的 }</code></pre> </li> <li>Channel(网络操作抽象类):是Netty对网络操作抽象类;通过Channel我们可以进行IO操作。一旦客户端成功连接服务端,就会新建一个 Channel 同该用户端进行绑定。接口实现类: <ul> <li>NioServerSocketChannel(服务端)--ServerSocket(对应)</li> <li>NioSocketChannel(客户端)--Socket(对应)</li> </ul></li> <li>EventLoop:主要作用实际就是负责监听网络事件并调用事件处理器进行相关 I/O 操作(读写)的处理 <img src="https://www.showdoc.com.cn/server/api/attachment/visitFile?sign=53e5106c979bdb3879d34eb45b30f8f7&amp;file=file.png" alt="" /> <ul> <li>Channel 为 Netty 网络操作(读写等操作)抽象类,EventLoop 负责处理注册到其上的Channel 的 I/O 操作,两者配合进行 I/O 操作</li> <li>EventLoop 处理的 I/O 事件都将在它专有的 Thread 上被处理,即 Thread 和 EventLoop 属于 1 : 1 的关系,从而保证线程安全</li> </ul></li> <li>ChannelHandler(消息处理器)和ChannelPipeline(ChannelHandler对象链表):ChannelHandler 是消息的具体处理器,主要负责处理客户端/服务端接收和发送的数据。 当 Channel 被创建时,它会被自动地分配到它专属的 ChannelPipeline。 一个Channel包含一个 ChannelPipeline。 ChannelPipeline 为 ChannelHandler 的链,一个 pipeline 上可以有多个 ChannelHandler</li> </ul> <p>Netty线程模型:基于Reactor模式,主要靠NioEventLoopGroup线程池来实现具体的线程模型。服务端一般会初始化两个线程组:</p> <ul> <li>bossGroup:接收连接</li> <li>workerGroup:负责具体的处理,交由对应的Handler处理 线程模式</li> <li>单线程模式:一个线程需要执行处理所有的 accept、read、decode、process、encode、send 事件。对于高负载、高并发,并且对性能要求比较高的场景不适用。 <pre><code class="language-java">//1.eventGroup既用于处理客户端连接,又负责具体的处理。 EventLoopGroup eventGroup = new NioEventLoopGroup(1); //2.创建服务端启动引导/辅助类:ServerBootstrap ServerBootstrap b = new ServerBootstrap(); b.group(eventGroup, eventGroup) //......</code></pre></li> <li>多线程模式:一个 Acceptor 线程只负责监听客户端的连接,一个 NIO 线程池负责具体处理: accept、read、decode、process、encode、send 事件。满足绝大部分应用场景,并发连接量不大的时候没啥问题,但是遇到并发连接大的时候就可能会出现问题,成为性能瓶颈。 <pre><code class="language-java">// 1.bossGroup 用于接收连接,workerGroup 用于具体的处理 EventLoopGroup bossGroup = new NioEventLoopGroup(1); EventLoopGroup workerGroup = new NioEventLoopGroup(); try { //2.创建服务端启动引导/辅助类:ServerBootstrap ServerBootstrap b = new ServerBootstrap(); //3.给引导类配置两大线程组,确定了线程模型 b.group(bossGroup, workerGroup) //......</code></pre> <p><img src="https://www.showdoc.com.cn/server/api/attachment/visitFile?sign=93d31ff6fecb24b1cd484be8116fa666&amp;file=file.png" alt="" /></p></li> <li>主从多线程模式:从一个 主线程 NIO 线程池中选择一个线程作为 Acceptor 线程,绑定监听端口,接收客户端连接的连接,其他线程负责后续的接入认证等工作。连接建立完成后,Sub NIO 线程池负责具体处理 I/O 读写。如果多线程模型无法满足你的需求的时候,可以考虑使用主从多线程模型 。 <pre><code class="language-java">// 1.bossGroup 用于接收连接,workerGroup 用于具体的处理 EventLoopGroup bossGroup = new NioEventLoopGroup(); EventLoopGroup workerGroup = new NioEventLoopGroup(); try { //2.创建服务端启动引导/辅助类:ServerBootstrap ServerBootstrap b = new ServerBootstrap(); //3.给引导类配置两大线程组,确定了线程模型 b.group(bossGroup, workerGroup) //......</code></pre> <p><img src="https://www.showdoc.com.cn/server/api/attachment/visitFile?sign=2bcb31e66c25ffa1f03a71458d1eeb19&amp;file=file.png" alt="" /></p></li> </ul> <p>Netty长连接和心跳机制</p> <ul> <li>TCP长连接和短连接 <ul> <li>短连接:短连接说的就是 server 端 与 client 端建立连接之后,读写完成之后就关闭掉连接,如果下一次再要互相发送消息,就要重新连接。短连接的优点很明显,就是管理和实现都比较简单,缺点也很明显,每一次的读写都要建立连接必然会带来大量网络资源的消耗,并且连接的建立也需要耗费时间。</li> <li>长连接:长连接说的就是 client 向 server 双方建立连接之后,即使 client 与 server 完成一次读写,它们之间的连接并不会主动关闭,后续的读写操作会继续使用这个连接。长连接的可以省去较多的 TCP 建立和关闭的操作,降低对网络资源的依赖,节约时间。对于频繁请求资源的客户来说,非常适用长连接。</li> </ul></li> <li>心跳机制:在 client 与 server 之间在一定时间内没有数据交互时, 即处于 idle 状态时, 客户端或服务器就会发送一个特殊的数据包给对方, 当接收方收到这个数据报文后, 也立即发送一个特殊的数据报文, 回应发送方, 此即一个 PING-PONG 交互。所以, 当某一端收到心跳消息后, 就知道了对方仍然在线, 这就确保 TCP 连接的有效性.</li> </ul> <p>Netty中的零拷贝</p> <ol> <li>使用 Netty 提供的 CompositeByteBuf 类, 可以将多个ByteBuf 合并为一个逻辑上的 ByteBuf, 避免了各个 ByteBuf 之间的拷贝。</li> <li>ByteBuf 支持 slice 操作, 因此可以将 ByteBuf 分解为多个共享同一个存储区域的 ByteBuf, 避免了内存的拷贝。 </li> <li>通过 FileRegion 包装的FileChannel.transferTo 实现文件传输, 可以直接将文件缓冲区的数据发送到目标 Channel, 避免了传统通过循环 write 方式导致的内存拷贝问题.</li> </ol> <h3>序列化与反序列化</h3> <ul> <li>定义: <ul> <li>序列化:将数据结构或对象转换成二进制字节流的过程</li> <li>反序列化:将在序列化过程中所生成的二进制字节流的过程转换成数据结构或者对象过程</li> </ul></li> <li> <p>场景:</p> <ol> <li>对象在进行<strong>网络传输(比如远程方法调用 RPC 的时候)</strong>之前需要先被序列化,接收到序列化的对象之后需要再进行反序列化;</li> <li>将对象<strong>存储到文件</strong>中的时候需要进行序列化,将对象从文件中读取出来需要进行反序列化。</li> <li>将对象<strong>存储到缓存数据库(如 Redis)时</strong>需要用到序列化,将对象从缓存数据库中读取出来需要反序列化。</li> </ol> </li> <li>对应TCP/IP 4层模型中的哪一层 <img src="https://www.showdoc.com.cn/server/api/attachment/visitFile?sign=4b4c5abafbee6bc67dcb54283e8236c6&amp;file=file.png" alt="" /> 如上图所示,OSI 七层协议模型中,表示层做的事情主要就是对应用层的用户数据进行处理转换为二进制流。反过来的话,就是将二进制流转换成应用层的用户数据。因为,OSI 七层协议模型中的应用层、表示层和会话层对应的都是 TCP/IP 四层模型中的应用层,所以序列化协议属于 TCP/IP 协议应用层的一部分。</li> </ul> <h2>操作系统</h2> <h3>死锁</h3> <p>定义:多个进程/线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于进程/线程被无限期地阻塞,因此程序不可能正常终止。</p> <p>四个必要条件</p> <ul> <li><strong>互斥</strong>:资源必须处于非共享模式,即一次只有一个进程可以使用。如果另一进程申请该资源,那么必须等待直到该资源被释放为止。</li> <li><strong>请求和保持</strong>:一个进程至少应该占有一个资源,并等待另一资源,而该资源被其他进程所占有。</li> <li><strong>不可剥夺</strong>:资源不能被抢占。只能在持有资源的进程完成任务后,该资源才会被释放。</li> <li><strong>循环等待</strong>:有一组等待进程 <code>{P0, P1,..., Pn}</code>, <code>P0</code> 等待的资源被 <code>P1</code> 占有,<code>P1</code> 等待的资源被 <code>P2</code> 占有,......,<code>Pn-1</code> 等待的资源被 <code>Pn</code> 占有,<code>Pn</code> 等待的资源被 <code>P0</code> 占有。</li> </ul> <p>解决方法</p> <ul> <li>预防:是采用某种策略,<strong>限制并发进程对资源的请求</strong>,从而使得死锁的必要条件在系统执行的任何时间上都不满足。(破坏必要条件中任何一个) <ul> <li><strong>静态分配策略</strong>:破坏请求和保持条件。一个进程必须在执行前就申请到它所需要的全部资源,并且知道它所要的资源都得到满足之后才开始执行。<strong>进程要么占有所有的资源然后开始执行,要么不占有资源,不会出现占有一些资源等待一些资源的情况。</strong></li> <li><strong>层次分配策略</strong>:破坏了循环等待条件。在层次分配策略下,所有的资源被分成了多个层次,<strong>一个进程得到某一次的一个资源后,它只能再申请较高一层的资源;当一个进程要释放某层的一个资源时,必须先释放所占用的较高层的资源</strong>,按这种策略,是不可能出现循环等待链的,因为那样的话,就出现了已经申请了较高层的资源,反而去申请了较低层的资源,不符合层次分配策略,证明略。</li> </ul></li> <li>避免:是系统在分配资源时,根据资源的使用情况提前做出预测,从而避免死锁的发生。 <ul> <li>将系统的状态分为 安全状态 和 不安全状态 ,每当在未申请者分配资源前先测试系统状态,若把系统资源分配给申请者会产生死锁,则拒绝分配,否则接受申请,并为它分配资源。</li> <li>如果操作系统能够保证所有的进程在有限的时间内得到需要的全部资源,则称系统处于安全状态,否则说系统是不安全的。很显然,系统处于安全状态则不会发生死锁,系统若处于不安全状态则可能发生死锁。</li> <li><strong>银行家算法</strong>:当一个进程申请使用资源的时候,银行家算法 通过先 试探 分配给该进程资源,然后通过 安全性算法 判断分配后系统是否处于安全状态,若不安全则试探分配作废,让该进程继续等待,若能够进入到安全的状态,则就 真的分配资源给该进程。</li> </ul></li> <li>检测:是指系统设有专门的机构,当死锁发生时,该机构能够检测死锁的发生,并精确地确定与死锁有关的进程和资源。 <ol> <li>如果进程-资源分配图中无环路,则此时系统没有发生死锁</li> <li>如果进程-资源分配图中有环路,且每个资源类仅有一个资源,则系统中已经发生了死锁。</li> <li>如果进程-资源分配图中有环路,且涉及到的资源类有多个资源,此时系统未必会发生死锁。如果能在进程-资源分配图中找出一个 <strong>既不阻塞又非独立的进程</strong> ,该进程能够在有限的时间内归还占有的资源,也就是把边给消除掉了,重复此过程,直到能在有限的时间内 <strong>消除所有的边</strong> ,则不会发生死锁,否则会发生死锁。(消除边的过程类似于 <strong>拓扑排序</strong>)</li> </ol></li> <li>解除:是与检测相配套的一种措施,用于将进程从死锁状态下解脱出来。 <ul> <li> <ol> <li><strong>立即结束所有进程的执行,重新启动操作系统</strong> :这种方法简单,但以前所在的工作全部作废,损失很大。</li> <li><strong>撤销涉及死锁的所有进程,解除死锁后继续运行</strong> :这种方法能彻底打破<strong>死锁的循环等待</strong>条件,但将付出很大代价,例如有些进程可能已经计算了很长时间,由于被撤销而使产生的部分结果也被消除了,再重新执行时还要再次进行计算。</li> <li><strong>逐个撤销涉及死锁的进程,回收其资源直至死锁解除。</strong></li> <li><strong>抢占资源</strong> :从涉及死锁的一个或几个进程中抢占资源,把夺得的资源再分配给涉及死锁的进程直至死锁解除。</li> </ol> </li> </ul></li> </ul> <h2>组成原理</h2> <h3>中断和轮训的区别:</h3> <p>中断方式:</p> <ol> <li>在CPU正在调度的某个进程需要数据时,发出指令启动输入输出设备,准备要处理的数据;</li> <li>在进程发出指令启动设备之后,该进程放弃处理器,等待相关I/O操作完成。此时,进程调度程序会调度其他就绪进程使用处理器。</li> <li>当I/O操作完成时,输入输出设备控制器通过中断请求线向处理器发出中断信号,处理器收到中断信号之后,转向预先设计好的中断处理程序,对数据传送工作进行相应的处理。</li> <li>得到了数据的进程,转入就绪状态。在随后的某个时刻,进程调度程序会选中该进程继续工作。</li> </ol> <p>轮训方式:轮询是一种CPU决策如何提供周边设备服务的方式。在轮询过程中,由CPU定时发出询问,依序询问每一个周边设备是否需要其服务。每个设备都有一个指示命令就绪的位,指示该设备的状态。当此状态就绪即给予服务,服务结束后再问下一个周边,接着不断周而复始。</p> <p>区别:</p> <ol> <li><strong>中断时,设备会通知CPU引起注意;而在轮询中,CPU会稳定地检查设备是否需要注意。</strong></li> <li>中断不是协议,而是一种硬件机制;轮询反之。</li> <li>在中断中,该设备由中断处理程序提供服务;轮询时,该设备由CPU维修。</li> <li>中断可以随时发生;轮询时,CPU会以固定的间隔稳定地对设备进行投票。</li> <li>在中断中,中断请求线用作指示设备需要维修的指示;在轮询时,命令就绪位用作指示,表明设备需要维修。</li> <li>在中断中,一旦任何设备将其中断,处理器就会受到干扰;在轮询中,处理器通过重复检查每个设备的命令就绪位来浪费无数的处理器周期。</li> </ol> <h2>Java</h2> <h3>反射机制</h3> <p>反射:通过反射可以在程序运行时获取和调用任意一个类的所有属性和方法。</p> <p>Class.forname和xxxClassLoader.loadClass()的区别</p> <ul> <li>class.forName()前者除了将类的.class文件加载到jvm中之外,还会对类进行解释,执行类中的static块。而classLoader只干一件事情,就是将.class文件加载到jvm中,不会执行static中的内容,只有在newInstance才会去执行static块。</li> <li>Class.forName得到的class是已经初始化完成的,Classloder.loaderClass得到的class是还没有链接的。</li> </ul> <h3>僵尸进程和孤儿进程的区别</h3> <ul> <li>僵尸进程:一个进程使用 fork 创建子进程,如果子进程退出,而父进程并没有调用 wait 或 waitpid 获取子进程的状态信息,那么子进程的进程描述符仍然保存在系统中,这种进程称之为僵死进程。</li> <li>孤儿进程:一个父进程退出,而它的一个或多个子进程还在运行,那么这些子进程将成为孤儿进程。孤儿进程将被 init 进程(进程号为1)所收养,并由 init 进程对它们完成状态收集工作。</li> </ul> <h3>静态代理和动态代理</h3> <ul> <li>静态代理:需要针对每个目标类都创建一个代理类,接口一旦新增加方法,目标对象和代理对象都要进行修改,非常麻烦。<strong>在编译时就将接口、实现类、代理类这些都变成了一个个实际的 class 文件。</strong></li> <li>动态代理:在运行时动态生成类字节码,并加载到 JVM 中的。</li> </ul> <h3>Java基础知识</h3> <h4>泛型</h4> <ul> <li>定义:是指在<strong>定义方法、接口或类</strong>的时候,不预先指定具体的类型,而使用的时候再指定一个类型的一个特性。</li> <li>好处: <ul> <li>在编译时会有更强的类型检查:使用泛型时,编译器会对输入的类型的进行检查,类型与声明的类型不一致时就会报错。而不使用泛型,编译器可能就检测不到这个类型错误,就会在运行的时候报错。</li> <li>不需要对类型进行转换</li> <li>可以实现更通用的算法</li> </ul></li> <li>特性: <ul> <li>类型擦除:编译器在编译时,内部永远把所有类型<code>T</code>视为<code>Object</code>处理,在需要时,编译器会根据T的类型自动为我们实行安全地强制类型转换</li> </ul></li> <li>局限: <ul> <li><code>&lt;T&gt;</code>不能是基本类型,例如<code>int</code>,因为实际类型是<code>Object</code>,<code>Object</code>类型无法持有基本类型</li> <li>无法取得带泛型的Class</li> <li>无法判断带泛型的类型</li> </ul></li> <li><strong>项目中泛型的使用场景</strong> <ul> <li>构建<code>Collection</code>集合的工具类</li> <li>定义<code>Excel</code>处理类<code>ExcelUtil&lt;T&gt;</code>用于动态指定<code>Excel</code>导出的数据类型</li> <li>自定义接口通用返回结果类<code>CommonResult&lt;T&gt;</code>通过参数<code>T</code>可根据具体的返回类型动态指定结果的数据类型</li> </ul></li> </ul> <h3>Java8新特性</h3> <h4>functional interface 函数式接口</h4> <p>只要接口只包含一个抽象方法,虚拟机会自动判断该接口为函数式接口。一般建议在接口上使用@FunctionalInterface 注解进行声明</p> <h4>Lambda表达式</h4> <ul> <li>定义:Lambda 表达式是一个匿名函数,java 8 允许把方法(必须实现函数式接口)作为参数传递进方法中。</li> <li>好处: <ul> <li>简洁:只保留实际用到的代码,把无用的代码全部省略</li> <li>容易进行并行计算</li> </ul></li> <li>缺点: <ul> <li>如不用并行计算,性能可能比不上传统for循环</li> <li>不容易调试</li> <li>其他没学过Lambda表达式的程序员看不懂 <h4>Stream</h4></li> </ul></li> <li>定义:数据流,不存储数据,可以检索(Retrieve)和逻辑处理集合数据、包括过滤、排序、统计、计数、归纳等。</li> <li>流类型: <ul> <li>stream串行流</li> <li>parallelStream并行流,可多线程执行</li> </ul></li> <li>特性: <ul> <li>操作类型: <ul> <li><strong>最终操作:返回一特定类型的计算结果</strong>,如filter、sorted、map等</li> <li>中间操作:返回Stream本身,这样你就可以将多个操作依次串起来,如match、count等</li> </ul></li> <li><strong>延迟执行</strong>:在执行中间操作的方法时,并不立刻执行,而是等最终操作的方法后才执行。因为拿到 Stream 并不能直接用,而是需要处理成一个常规类型。</li> </ul></li> </ul> <h3>HashMap原理</h3> <h4>put操作:</h4> <p>①. 如果定位到的数组位置没有元素 就直接插入。 ②. 如果定位到的数组位置有元素就和要插入的key比较,如果key相同就直接覆盖,如果key不相同,就判断p是否是一个树节点,如果是就调用e = ((TreeNode&lt;K,V&gt;)p).putTreeVal(this, tab, hash, key, value)将元素添加进入。如果不是就遍历链表插入(插入的是链表尾部)。 ③. 判断链表长度是否大于8,大于8的话把链表转换为红黑树,在红黑树中执行插入操作,否则进行链表的插入操作;遍历过程中若发现key已经存在直接覆盖value即可; ④. 插入成功后,判断实际存在的键值对数量size是否超多了最大容量threshold,如果超过,进行扩容。</p> <h4>resize中的位运算:</h4> <ul> <li>定位运算在旧HashMap中位置时用与运算替代取余运算,取余(%)操作中如果除数是2的幂次则等价于与其除数减一的与(&amp;)操作(也就是说 hash%length==hash&amp;(length-1)的前提是 length 是2的 n 次方;)</li> <li>根据元素在旧表中位置和旧表容量(新表容量每次扩充为原表的2倍)定位其在新表中的位置:元素的hash值与旧表长度与运算,结果为0则,位置不表,结果为1则新位置=原位置+旧表容量</li> </ul> <h1>1.计算机网络</h1> <h2>层次</h2> <p>应用层:</p> <p>应用程序之间通信-报文</p> <p>协议:HTTP,SSH,DNS,FTP,SMTP</p> <p>传输层:</p> <p>进程之间通信-数据报</p> <p>TCP协议:提供<strong>面向连接</strong>的,<strong>可靠的</strong>数据传输服务</p> <p>UDP协议:提供<strong>无连接</strong>的,尽最大努力的数据传输服务(<strong>不保证数据传输的可靠性</strong>)</p> <p>网络层:</p> <p>交换机上主机之间通信-IP数据报</p> <p>协议:IP,ARP,NAT,RIP,OSPF,GRP</p> <p>数据链路层:</p> <p>两台主机之间通信-数据帧</p> <p>协议:CSMA/CD,MAC</p> <p>物理层:</p> <p>实现相邻计算机节点之间比特流的透明传送,尽可能屏蔽掉具体传输介质和物理设备的差异</p> <h2>网络层</h2> <h3>ARQ(Automitic Repeat reQuest)协议</h3> <ul> <li>停止等待ARQ:发送方每发送一个分组立即停止等待接收方的确认,若确认超时则重传请求。</li> <li>连续ARQ:发送方维持一个发送窗口,窗口内分组无需等待确认可连续发送,接收方累计确认。</li> </ul> <h2>传输层</h2> <h3>TCP和UDP</h3> <h4>TCP为什么三次握手</h4> <p>客户端和服务器端能够确认自己发送正常,对方接收正常,对方发送正常,自己接收正常至少需要3次握手。</p> <h4>Tcp三次握手,第三次请求丢包会怎样</h4> <p>服务端:会处于SYN-RCVD状态;客户端:会进入ESTABLISHED状态</p> <ul> <li>客户端丢包后发送请求:当客户端在 ESTABLISHED 状态下,开始发送数据包时,会携带上一个「ACK」的确认序号,所以哪怕客户端响应的「ACK」包丢了,服务端在收到这个数据包时,能够通过包内 ACK 的确认序号,正常进入 ESTABLISHED 状态。</li> <li>客户端丢包后不发送请求:这个连接就成为了一个死连接,会一直处于ESTABLISHED状态。只能等待http keepalive超时或者tcp keepalive超时</li> </ul> <h4>TCP四次挥手客户端为什么等待2MSL(Maximum Segment Life)</h4> <p>1.保证客户端发送的连接释放确认报文到达了服务器端。因为如果没有到达,在2MSL(最长报文段寿命)内,服务器端会重传连接释放报文的。</p> <p>2.保证在本次TCP连接持续的时间内,所产生的所有报文段都从网络中消失。</p> <h4>TCP和UDP区别</h4> <p>TCP:面向连接,可靠,效率慢,应用于要求通信数据可靠的场景,首部长20-60字节 UDP:无连接,不可靠,效率快,应用于要求通信速度快的场景,首部长固定8字节</p> <h4>TCP可靠性保证</h4> <p>发送方对包编号,接收方对包排序;差错控制:首部和数据部分校验和;流量控制:当接收方来不及处理时,让发送方降低发送速率;拥塞控制:当网络拥塞时,发送方减少数据发送;ARQ协议;超时重传。</p> <h4>流量控制和拥塞控制</h4> <ul> <li> <p>流量控制:端到端的通信量控制,由接收方滑动窗口大小决定,接收方通过TCP首部的window字段通知发送方自己可以处理的大小</p> </li> <li>拥塞控制:全局性的通信量控制,涉及所有的主机、路由器。 <ul> <li>算法(cwnd(拥塞窗口),ssthresh(slow start threshold)):</li> <li>慢启动:建立连接时,cwnd=1;每经过1个RTT,cwnd翻倍(指数增长)</li> <li>拥塞避免:当cwnd&gt;=ssthresh时,每经过1个RTT,cwnd=cwnd+1(线性增长)</li> <li>超时重传:当发送方超时未收到确认包:ssthresh设为cwnd的一半(乘性减)+重新开始慢启动过程:cwnd设为1</li> <li>快速恢复:当发送方收到 3 个 duplicate ACK 时,就立刻开始重传,而不必继续等待到计时器超时。快速重传会配合快速恢复算法:ssthresh设为cwnd的一半(乘性减)+重新开始拥塞避免过程:cwnd设为ssthresh</li> </ul></li> </ul> <h2>应用层</h2> <h3>HTTP</h3> <h4>Http报文结构</h4> <ul> <li>请求报文: <ul> <li>请求行:请求方法+URL+协议名称/版本号</li> <li>报文头:一些属性(域名、连接状态、客户端操作系统和浏览器信息、请求来源等)</li> <li>报文体:请求参数、数据</li> </ul></li> <li>响应报文: <ul> <li>状态行:协议名称/版本号+状态码及状态描述</li> <li>报文头:</li> <li>报文体:响应数据</li> </ul></li> </ul> <h4>常见状态码</h4> <ul> <li>1XX Informational(信息性状态码):请求正在处理(一般不会遇到)</li> <li>2xx Success(成功状态码):请求正常处理完毕 <ul> <li>200 OK :请求被成功处理。</li> <li>204 No Content : 服务端已经成功处理了请求,但是没有返回任何内容。</li> </ul></li> <li>3xx Redirection(重定向状态码):需要完成附加操作以完成请求 <ul> <li>302 Found :资源被临时重定向了。</li> </ul></li> <li>4xx Client Error(客户端错误状态码):无法处理请求 <ul> <li>400 Bad Request :发送的HTTP请求存在问题。</li> <li>401 Unauthorized :请求未认证或授权</li> <li>403 Forbidden :非法请求</li> <li>404 Not Found :请求资源不存在</li> </ul></li> <li>5xx Server Error(服务端错误状态码):处理请求出错 <ul> <li>500 Internal Server Error : 服务端抛出异常,未被正确处理。</li> <li>502 Bad Gateway :网关将请求转发到服务端,但是服务端返回的却是一个错误的响应</li> </ul></li> </ul> <h4>在浏览器中输入 url 地址 -&gt;&gt; 显示主页的过程</h4> <ol> <li>DNS解析:浏览器缓存-&gt;系统缓存-&gt;路由器缓存-&gt;本地域名服务器-&gt;根域名服务器-&gt;顶级域名服务器-&gt;主域名服务器</li> <li>建立TCP连接(IP协议:传输数据;OSPF协议:开放最短路径优先协议;ARP协议:ip地址转换为mac地址)</li> <li>客户端发送HTTP请求</li> <li>服务器端处理请求,并返回HTTP响应报文</li> <li>浏览器解析渲染页面</li> <li>连接结束</li> </ol> <h4>Http/1.0 vs Http/1.1</h4> <ol> <li><strong>连接方式</strong>:Http/1.1支持长连接(TCP),在客户端与服务器完成一次请求/响应之后,允许不断开 TCP 连接, 下次Http请求就直接使用这个 TCP 连接而不需要重新握手建立新连接,这也被称为长连接。(Connection:keep-alive) <ul> <li>优点:当网站中有大量静态资源被请求时可以开启长连接,通过一次TCP传送</li> <li>缺点:当客户端请求一次就不再请求时,服务器却一直开着长连接,造成资源浪费</li> </ul></li> <li><strong>Host属性</strong>:HTTP/1.1在请求头中加入了Host字段,允许同一ip地址绑定多个主机名</li> <li><strong>缓存处理</strong>:HTTP/1.1引入了更多的缓存控制策略</li> <li>响应状态码:HTTP/1.1新加入大量状态码</li> <li>带宽优化:范围请求(请求数据的一部分),数据预压缩</li> </ol> <h4>Http vs Https</h4> <ol> <li> <p>介绍</p> <ul> <li>HTTPS协议(Hyper Text Transfer Protocol Secure),是 HTTP 的加强安全版本。HTTP 默认是 80,HTTPS 默认是 443。</li> <li>HTTPS是运行在 SSL/TLS 之上的 HTTP 协议,SSL/TLS 运行在 TCP 之上。</li> <li>HTTPS所有传输的内容都经过加密,加密采用对称加密,但对称加密的密钥用服务器方的证书进行了非对称加密。</li> </ul> </li> <li>SSL/TLS工作原理 <ul> <li>非对称加密:</li> <li>两个秘钥:一个公钥,一个私钥;私钥仅由解密者保存,公钥由任何一个想与解密者通信的发送者(加密者)所知。</li> <li><strong>用于约定后续通信使用的对称加密的秘钥</strong>,<strong>通信双方只需要一次非对称加密,交换对称加密的密钥</strong>,在之后的信息通信中,使用绝对安全的密钥,对信息进行对称加密,即可保证传输消息的保密性。</li> <li>对称加密:一个秘钥用于加解密。SSL/TLS 实际通信时对消息的加密使用的是对称加密。</li> <li>公钥传输的信赖性:</li> <li>防止攻击者发送给客户端假的服务器端公钥,骗取客户端发送的报文数据</li> <li>证书颁发机构(CA,Certificate Authority):给各个服务器颁发证书,证书存储在服务器上,并附有 CA 的数字签名。当客户端(浏览器)向服务器发送 HTTPS 请求时,一定要先获取目标服务器的证书,并根据证书上的信息,检验证书的合法性。证书上包含着服务器的公钥信息,客户端就可以放心的信任证书上的公钥就是目标服务器的公钥。</li> <li>数字签名:</li> <li>CA颁发给服务器端的证书包含服务器公钥和对公钥散列处理后摘要的加密(CA的私钥)后得到的签名</li> <li>客户端对比服务器端给的证书中的签名解密(CA的私钥)后的结果和对证书中服务器公钥相同散列处理后得到的摘要,一致则身份验证成功</li> </ul></li> </ol> <h1>2.数据库</h1> <h2>数据库基础知识</h2> <h3>三大范式</h3> <ol> <li>1NF(第一范式):列不可再分</li> <li>2NF(第二范式):在1NF的基础上,消除非主属性对码的部分函数依赖(例:(学号,身份证号)-&gt;(姓名),(学号)-&gt;(姓名),(身份证号)-&gt;(姓名);所以姓名部分函数依赖与(学号,身份证号);)</li> <li>3NF(第三范式):在2NF的基础上,消除非主属性对码的传递函数依赖(比如在关系 R(学号 , 姓名, 系名,系主任)中,学号 → 系名,系名 → 系主任,所以存在非主属性系主任对于学号的传递函数依赖。)</li> </ol> <h3>索引</h3> <h4>主键索引和非主键索引</h4> <ul> <li> <p><strong>主键索引(聚簇索引):</strong></p> <ul> <li>对象:显式定义的主键列,没有则检查有唯一索引且不允许存在null值的字段为主键,否则自动创建6字节的自增主键</li> <li>特点:叶子结点存储索引和索引对应的数据,查询速度快</li> <li>缺点: <ul> <li>依赖于有序的数据 :如果索引的数据不是有序的,那么就需要在插入时排序,如果数据是又长又难比较的数据,插入或查找的速度肯定比较慢。</li> <li>更新代价大 : 如果对索引列的数据被修改时,那么对应的索引也将会被修改,而且聚集索引的叶子节点还存放着数据,修改代价肯定是较大的,所以对于主键索引来说,主键一般都是不可被修改的</li> </ul></li> </ul> </li> <li><strong>非主键索引(二级索引):</strong> <ul> <li>对象:非主键列</li> <li>特点:叶子结点存储索引和主键,更新代价小</li> <li>缺点:可能会二次查询, 当查到索引对应的指针或主键后,可能还需要根据指针或主键再到数据文件或表中查询。</li> </ul></li> </ul> <h4>B+树索引优势</h4> <p>Hash索引缺点:不支持范围查找,不能保证逻辑上连续的数据在物理上是连续存储的        </p> <p>B+树相比于B树:数据全部保存在叶子节点,相邻叶子节点之间通过指针连接起来,方便范围查找</p> <h4>索引建议</h4> <p>1.选择合适的字段创建索引:不为NULL的字段;被频繁查询的字段;被频繁用于链接的字段</p> <p>2.被频繁更新的字段应该慎重建立索引,维护开销大</p> <p>3.尽可能的考虑建立联合索引而非单列索引,节约磁盘空间</p> <h4>B+树能存多少数据</h4> <p><img src="https://www.showdoc.com.cn/server/api/attachment/visitFile?sign=410181393297e877e700ba1505c301ff&amp;file=file.png" alt="" /> 计算方式(假设页面大小为16KB,一条记录大小约为1KB,主键int类型4B,指针6B): 1.单个叶子结点(1页)中记录数=16K/1K==16 2.非叶子节点能存放多少指针=16K/(4+6)=1638 3.如果树的高度为3,可以存放的记录数=1638*1638*16=42928704</p> <h2>Mysql</h2> <h3>事务</h3> <h4>事务隔离级别,可重复读级别如何避免幻读</h4> <hr /> <p>读未提交(READ-UNCOMMITTED):最低级别</p> <p>读已提交(READ-COMMITTED):解决脏读</p> <p>可重复读(REPEADTABLE-READ):解决脏读和不可重复读</p> <p>可串行化(SERIALIZABLE):解决脏读、不可重复读以及幻读</p> <p>可重复读级别解决幻读问题(核心思想就是一个事务在操作某张表数据的时候,另外一个事务不允许新增或者删除这张表中的数据了):</p> <p>1.快照读</p> <p>2.给事务操作的这张表添加表锁</p> <p>3.给事务操作的这张表添加Next-Key Locks(行锁+间隙锁)</p> <h4>InnoDB的可重复读实现</h4> <ul> <li>一致性非锁定读(Consistent Nonlocking Reads):select</li> <li>锁定读(Locking Reads):当前读(current read),读取的是数据的最新版本 <ul> <li>select ... lock in share mode</li> <li>select ... for update</li> <li>insert、update、delete 操作</li> </ul></li> <li>RR级别下实现非一致性锁定读的可重复读: <ul> <li>MVCC(多版本并发控制):如果读取的行正在执行 DELETE 或 UPDATE 操作,这时读取操作不会去等待行上锁的释放。相反地,InnoDB 存储引擎会去读取行的一个快照数据,对于这种读取历史数据的方式,我们叫它快照读 (snapshot read)。</li> <li>undo-log:当读取记录时,若该记录被其他事务占用或当前版本对该事务不可见,则可以通过 undo log 读取之前的版本数据,以此实现非锁定读</li> </ul></li> </ul> <h4>事务两阶段提交</h4> <ul> <li>redo日志 <ul> <li>类型:物理日志,记录哪个数据页上做了哪些数据修改</li> <li>写入方式:循环写,全部写满则从头开始</li> <li>适用场景:崩溃恢复</li> </ul></li> <li>bin日志 <ul> <li>类型:逻辑日志,记录sql语句</li> <li>写入方式:追加写,写满则创建一个新文件继续写</li> <li>适用场景:主从同步与误删操作</li> </ul></li> <li>undo日志 <ul> <li>类型:</li> <li>写入方式:</li> <li>适用场景:回滚 mysq更新一条记录过程: <img src="https://www.showdoc.com.cn/server/api/attachment/visitFile?sign=7ed8c98e8584e471149c8320e1fdd94c" alt="" /></li> </ul></li> </ul> <h4>为什么要写两次redo log,写一次不行吗?</h4> <p>redo log与binlog都写一次的话,也就是存在以下两种情况:</p> <ul> <li> <p>先写binlog,再写redo log:当前事务提交后,写入binlog成功,之后主节点崩溃。在主节点重启后,由于没有写入redo log,因此不会恢复该条数据。而从节点依据binlog在本地回放后,会相对于主节点多出来一条数据,从而产生主从不一致。</p> </li> <li>先写redo log,再写binlog:当前事务提交后,写入redo log成功,之后主节点崩溃。在主节点重启后,主节点利用redo log进行恢复,就会相对于从节点多出来一条数据,造成主从数据不一致。</li> </ul> <p>因此,只写一次redo log与binlog,无法保证这两种日志在事务提交后的一致性。也就是无法保证主节点崩溃恢复与从节点本地回放数据的一致性。</p> <h4>在两阶段提交的情况下,是怎么实现崩溃恢复的呢?</h4> <p>首先比较重要的一点是,在写入redo log时,会顺便记录XID,即当前事务id。在写入binlog时,也会写入XID。</p> <ul> <li>如果在写入redo log之前崩溃,那么此时redo log与binlog中都没有,是一致的情况,崩溃也无所谓。</li> <li>如果在写入redo log prepare阶段后立马崩溃,之后会在崩恢复时,由于redo log没有被标记为commit。于是拿着redo log中的XID去binlog中查找,此时肯定是找不到的,那么执行回滚操作。</li> <li>如果在写入binlog后立马崩溃,在恢复时,由redo log中的XID可以找到对应的binlog,这个时候直接提交即可。</li> </ul> <p>总的来说,在崩溃恢复后,只要redo log不是处于commit阶段,那么就拿着redo log中的XID去binlog中寻找,找得到就提交,否则就回滚。</p> <h3>MyISAM和InnoDB区别</h3> <ul> <li><strong>是否支持行级锁</strong>:MyISAM只有表级锁</li> <li><strong>是否支持事务</strong>:</li> <li>是否支持外键:</li> <li>是否支持崩溃恢复:</li> <li><strong>是否支持MVCC</strong>:</li> <li><strong>索引实现不同</strong>:MyISAM的B+树叶节点的data域存放的是数据记录的地址(非聚簇索引);InnoDB的B+树叶节点data域保存了完整的数据记录(聚簇索引)</li> </ul> <p>InnoDB锁</p> <ul> <li>锁模式 <ul> <li>行级别: <ul> <li>共享锁(S):允许一个事务去读一行,阻止其他事务获得相同数据集的排他锁。</li> <li>排他锁(X):允许获得排他锁的事务更新数据,阻止其他事务取得相同数据集的共享读锁和排他写锁。</li> </ul></li> <li>表级别:为了允许行锁和表锁共存,实现多粒度锁机制,InnoDB 还有两种内部使用的意向锁(Intention Locks),这两种意向锁都是表锁 <ul> <li>意向共享锁(IS):事务打算给数据行加行共享锁,事务在给一个数据行加共享锁前必须先取得该表的 IS 锁。</li> <li>意向排他锁(IX):事务打算给数据行加行排他锁,事务在给一个数据行加排他锁前必须先取得该表的 IX 锁。</li> </ul></li> </ul></li> <li>锁模式的兼容情况: <img src="https://www.showdoc.com.cn/server/api/attachment/visitFile?sign=021f0be42b9934d16fac9f92ed681aff&amp;file=file.png" alt="" /> <ul> <li>意向锁是 InnoDB 自动加的, 不需用户干预。</li> <li>对于 UPDATE、 DELETE 和 INSERT 语句, InnoDB会自动给涉及数据集加排他锁(X);</li> <li>对于普通 SELECT 语句,InnoDB 不会加任何锁; 事务可以通过以下语句显式给记录集加共享锁或排他锁:</li> <li>共享锁(S):<strong>SELECT * FROM table_name WHERE ... LOCK IN SHARE MODE</strong>。 其他 session 仍然可以查询记录,并也可以对该记录加 share mode 的共享锁。但是如果当前事务需要对该记录进行更新操作,则很有可能造成死锁。</li> <li>排他锁(X):<strong>SELECT * FROM table_name WHERE ... FOR UPDATE</strong>。其他 session 可以查询该记录,但是不能对该记录加共享锁或排他锁,而是等待获得锁。</li> </ul></li> <li>行级锁实现方式: <ul> <li>InnoDB 行锁是<strong>通过给索引上的索引项加锁来实现的</strong>,这一点 MySQL 与 Oracle 不同,后者是通过在数据块中对相应数据行加锁来实现的。InnoDB 这种行锁实现特点意味着:<strong>只有通过索引条件检索数据,InnoDB 才使用行级锁,否则,InnoDB 将使用表锁!</strong></li> <li>不论是使用主键索引、唯一索引或普通索引,InnoDB 都会使用行锁来对数据加锁。</li> <li>只有执行计划真正使用了索引,才能使用行锁:即便在条件中使用了索引字段,但是否使用索引来检索数据是由 MySQL 通过判断不同执行计划的代价来决定的,如果 MySQL 认为全表扫描效率更高,比如对一些很小的表,它就不会使用索引,这种情况下 InnoDB 将使用表锁,而不是行锁。因此,在分析锁冲突时,别忘了检查 SQL 的执行计划(可以通过 explain 检查 SQL 的执行计划),以确认是否真正使用了索引。</li> <li>由于 MySQL 的行锁是针对索引加的锁,不是针对记录加的锁,所以虽然多个session是访问不同行的记录, 但是如果是使用相同的索引键, 是会出现锁冲突的(后使用这些索引的session需要等待先使用索引的session释放锁后,才能获取锁)。 应用设计的时候要注意这一点。</li> </ul></li> <li>间隙锁 <ul> <li>定义:当我们用范围条件而不是相等条件检索数据,并请求共享或排他锁时,InnoDB会给符合条件的已有数据记录的索引项加锁;<strong>对于键值在条件范围内但并不存在的记录,叫做“间隙(GAP)”,InnoDB也会对这个“间隙”加锁,这种锁机制就是所谓的间隙锁(Next-Key锁)。</strong>很显然,在使用范围条件检索并锁定记录时,<strong>InnoDB这种加锁机制会阻塞符合条件范围内键值的并发插入,这往往会造成严重的锁等待</strong>。因此,在实际应用开发中,尤其是并发插入比较多的应用,我们要尽量优化业务逻辑,尽量使用相等条件来访问更新数据,避免使用范围条件。</li> <li>目的: <ol> <li>防止幻读,以满足相关隔离级别的要求;</li> <li>满足恢复和复制的需要: MySQL 通过 BINLOG 录入执行成功的 INSERT、UPDATE、DELETE 等更新数据的 SQL 语句,并由此实现 MySQL 数据库的恢复和主从复制。MySQL 的恢复机制(复制其实就是在 Slave Mysql 不断做基于 BINLOG 的恢复)有以下特点:一是 MySQL 的恢复是 SQL 语句级的,也就是重新执行 BINLOG 中的 SQL 语句。二是 MySQL 的 Binlog 是按照事务提交的先后顺序记录的, 恢复也是按这个顺序进行的。 由此可见,MySQL 的恢复机制要求:在一个事务未提交前,其他并发事务不能插入满足其锁定条件的任何记录,也就是不允许出现幻读。</li> </ol></li> </ul></li> <li>乐观锁和悲观锁 <ul> <li><strong>乐观锁(Optimistic Lock)</strong>:假设不会发生并发冲突,只在提交操作时检查是否违反数据完整性。 乐观锁不能解决脏读的问题。乐观锁, 顾名思义,就是很乐观,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号等机制。乐观锁适用于多读的应用类型,这样可以提高吞吐量,像数据库如果提供类似于write_condition机制的其实都是提供的乐观锁。</li> <li><strong>悲观锁(Pessimistic Lock)</strong>:假定会发生并发冲突,屏蔽一切可能违反数据完整性的操作。悲观锁,顾名思义,就是很悲观,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会block直到它拿到锁。<strong>传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。</strong></li> </ul></li> </ul> <h3>Mysql主从同步原理</h3> <p><img src="https://img-blog.csdnimg.cn/img_convert/9ca5a4dec6dfeb5c05bcf55501e1f479.png" alt="" />​</p> <p><img src="data:image/gif;base64,R0lGODlhAQABAPABAP///wAAACH5BAEKAAAALAAAAAABAAEAAAICRAEAOw==" alt="" title="点击并拖拽以移动" /></p> <p> 整个复制过程实际上就是 Slave 从 Master 端获取该日志然后再在自己身上完全顺序的执行日志中所记录的各种操作。如下图所示:</p> <p><img src="https://img-blog.csdnimg.cn/img_convert/0f736e4f156f82e8916f624ed0d4a672.png" alt="" />​</p> <p><img src="data:image/gif;base64,R0lGODlhAQABAPABAP///wAAACH5BAEKAAAALAAAAAABAAEAAAICRAEAOw==" alt="" title="点击并拖拽以移动" /></p> <p>复制的基本过程(基于position)</p> <ol> <li>在从节点上执行 <code>start slave</code> 命令开启主从复制开关,开始进行主从复制。从节点上的 I/O 进程连接主节点,并请求从指定日志文件的指定位置(或者从最开始的日志)之后的日志内容。</li> <li>主节点接收到来自从节点的 I/O 请求后,通过负责复制的 I/O 进程(log Dump Thread)根据请求信息读取指定日志指定位置之后的日志信息,返回给从节点。返回信息中除了日志所包含的信息之外,还包括本次返回的信息的 Binlog file 以及 Binlog position(Binlog 下一个数据读取位置)。</li> <li>从节点的 I/O 进程接收到主节点发送过来的日志内容、日志文件及位置点后,将接收到的日志内容更新到本机的 relay log 文件(Mysql-relay-bin.xxx)的最末端,并将读取到的 Binlog文件名和位置保存到<code>master-info</code> 文件中,以便在下一次读取的时候能够清楚的告诉 Master :“ 我需要从哪个 Binlog 的哪个位置开始往后的日志内容,请发给我”。</li> <li>Slave 的 SQL 线程检测到relay log 中新增加了内容后,会将 relay log 的内容解析成在能够执行 SQL 语句,然后在本数据库中按照解析出来的顺序执行,并在 <code>relay log.info</code> 中记录当前应用中继日志的文件名和位置点。</li> </ol> <p>基于 GTID 的复制:从库会告知主库已经执行的事务的 GTID 的值,然后主库会将所有未执行的事务的 GTID 的列表返回给从库,并且可以保证同一个事务只在指定的从库执行一次,<strong>通过全局的事务 ID 确定从库要执行的事务的方式代替了以前需要用 Binlog 和 位点确定从库要执行的事务的方式</strong>。</p> <p>基于 GTID 的复制过程如下:</p> <ol> <li>master 更新数据时,会在事务前产生 GTID,一同记录到 Binlog 日志中。</li> <li>slave 端的 I/O 线程将变更的 Binlog,写入到本地的 relay log 中,读取值是根据<code>gitd_next变量</code>,告诉我们 slave 下一个执行哪个 GTID。</li> <li>SQL 线程从 relay log 中获取 GTID,然后对比 slave 端的 Binlog 是否有记录。如果有记录,说明该 GTID 的事务已经执行,slave 会忽略。</li> <li>如果没有记录,slave 就会从 relay log 中执行该 GTID 的事务,并记录到 Binlog。</li> <li>在解析过程中会判断是否有主键,如果没有就用二级索引,如果没有二级索引就用全部扫描。</li> </ol> <h3>mysql读写分离</h3> <p>随着业务量的扩展、如果是单机部署的MySQL,会导致I/O频率过高。采用<strong>主从复制、读写分离可以提高数据库的并发性能(读操作)</strong>。主库负责写入、更新操作,从库负责读取操作。</p> <p>读写分离中间件:Apache ShardingSphere 是一套开源的分布式数据库中间件解决方案组成的生态圈,它由 JDBC、Proxy两部分组成。</p> <h4>主从同步延迟:</h4> <p>原因:1.master库写binlog是顺序写入,效率高;slave单个sql线程重放DDL和DML操作是随机的,不是顺序的,效率低。所以sql线程的速度赶不上master库写binlog的速度。2.slave库中有大型query语句产生锁等待。3.网络延迟。</p> <p>造成问题:写入master的数据没能立即同步到从库,读取不到。</p> <p>解决方法:1.slave库并行复制,多个sql线程重放日志。2.降低master库的写入速度。3.在同一个线程的同一个数据库连接内,写入后的读操作均从主库读取,保证数据一致性。</p> <h4>数据不一致问题:</h4> <p>半同步模式(semi-sync),介于异步复制和全同步复制之间,主库在执行完客户端提交的事务后不是立刻返回给客户端,而是等待至少一个从库接收到并写到 relay log 中才返回成功信息给客户端(只能保证主库的 Binlog 至少传输到了一个从节点上),否则需要等待直到超时时间然后切换成异步模式再提交。</p> <h4>分库分表</h4> <hr /> <p>原因:减轻数据库的存储压力</p> <ul> <li>分库:将数据库中的数据库分散到不同的数据库上;例用户表和订单表分别放在不同的数据库</li> <li>分表:对单表的数据进行拆分,可以是垂直拆分,也可以是水平拆分</li> </ul> <p>依据:</p> <ul> <li>单表的数据达到千万级别以上,数据库读写速度比较缓慢(分表)</li> <li>数据库中的数据占用的空间越来越大,备份时间越来越长(分库)</li> <li>应用的并发量太大(分库)</li> </ul> <p>带来的问题:</p> <ul> <li><strong>join 操作</strong> : 同一个数据库中的表分布在了不同的数据库中,导致无法使用 join 操作。这样就导致我们需要手动进行数据的封装,比如你在一个数据库中查询到一个数据之后,再根据这个数据去另外一个数据库中找对应的数据。</li> <li><strong>事务问题</strong> :同一个数据库中的表分布在了不同的数据库中,如果单个操作涉及到多个数据库,那么数据库自带的事务就无法满足我们的要求了。</li> <li><strong>分布式 id</strong> :分库之后, 数据遍布在不同服务器上的数据库,数据库的自增主键已经没办法满足生成的主键唯一了。我们如何为不同的数据节点生成全局唯一主键呢?这个时候,我们就需要为我们的系统引入分布式 id 了。</li> </ul> <p>成熟方案:Sharding-JDBC(分库分表,读写分离)</p> <h3>Mysql中时间类型数据存储建议</h3> <p>Datetime 和 Timestamp 是 MySQL 提供的两种比较相似的保存时间的数据类型。</p> <ul> <li>DateTime 类型:是没有时区信息的(时区无关) ,DateTime 类型保存的时间都是当前会话所设置的时区对应的时间。当你的时区更换之后,比如你的服务器更换地址或者更换客户端连接时区设置的话,就会导致你从数据库中读出的时间错误。</li> <li>Timestamp :和时区有关。Timestamp 类型字段的值会随着服务器时区的变化而变化,自动换算成相应的时间,说简单点就是在不同时区,查询到同一个条记录此字段的值会不一样。</li> </ul> <h3>字符串和整形的隐式转换</h3> <ol> <li>两个参数至少有一个是<code>NULL</code>时,比较的结果也是<code>NULL</code>,特殊的情况是使用<code>&lt;=&gt;</code>对两个<code>NULL</code>做比较时会返回<code>1</code>,这两种情况都不需要做类型转换</li> <li>两个参数都是字符串,会按照字符串来比较,不做类型转换</li> <li>两个参数都是整数,按照整数来比较,不做类型转换</li> <li>十六进制的值和非数字做比较时,会被当做二进制串</li> <li>有一个参数是<code>TIMESTAMP</code>或<code>DATETIME</code>,并且另外一个参数是常量,常量会被转换为<code>timestamp</code></li> <li>有一个参数是<code>decimal</code>类型,如果另外一个参数是<code>decimal</code>或者整数,会将整数转换为<code>decimal</code>后进行比较,如果另外一个参数是浮点数,则会把<code>decimal</code>转换为浮点数进行比较</li> <li><strong>所有其他情况下,两个参数都会被转换为浮点数再进行比较</strong> 其他:</li> <li>不以数字开头的字符串都将转换为0。如'abc'、'a123bc'、'abc123'都会转化为0;</li> <li>以数字开头的字符串转换时会进行截取,从第一个字符截取到第一个非数字内容为止。比如'123abc'会转换为123,'012abc'会转换为012也就是12,'5.3a66b78c'会转换为5.3,其他同理。</li> </ol> <p><strong>后果</strong>: 当 where 查询操作符左边为字符类型时发生了隐式转换,那么会导致索引失效,造成全表扫描效率极低。</p> <h3>Mysql的join原理</h3> <p>示例sql:<code>select * from user tb1 left join level tb2 on tb1.id=tb2.user_id</code></p> <ol> <li> <p><strong>Simple Nested-Loop Join(简单的嵌套循环连接)</strong>:通过循环外层表的行数据,逐个与内层表的所有行数据进行比较来获取结果,匹配次数=外层表行数 * 内层表行数。 <img src="https://www.showdoc.com.cn/server/api/attachment/visitFile?sign=7062bf1101a1a175fd4ed8d63b339527&amp;file=file.png" alt="" /></p> </li> <li> <p><strong>Index Nested-Loop Join(索引嵌套循环连接)</strong>:优化的思路主要是为了减少内层表数据的匹配次数,简单来说就是通过外层表匹配条件<strong>直接与内层表索引</strong>进行匹配,避免和内层表的每条记录去进行比较,这样极大的减少了对内层表的匹配次数,从原来的匹配次数=外层表行数 * 内层表行数,变成了*<em>外层表的行数 </em> 内层表索引的高度**,极大的提升了 join的性能。 <img src="https://www.showdoc.com.cn/server/api/attachment/visitFile?sign=b730cb20e232c0f9cc5841277378e4a1&amp;file=file.png" alt="" /></p> </li> <li><strong>Block Nested-Loop Join(缓存块嵌套循环连接)</strong>:优化思路是减少内层表的扫表次数,所以缓存块嵌套循环连接算法意在通过一次性缓存外层表的多条数据,以此来减少内层表的扫表次数,从而达到提升性能的目的。当level 表的 user_id 不为索引的时候,默认会使用Block Nested-Loop Join算法,匹配的过程类似下图。 <img src="https://www.showdoc.com.cn/server/api/attachment/visitFile?sign=8299ff260fd686e47df5e3a4c5dcdf6e&amp;file=file.png" alt="" /></li> </ol> <h3>慢查询的原因</h3> <ol> <li>Sql字段没加索引,导致全表扫描</li> <li>Sql索引失效 <ol> <li>最左匹配截断 索引:index(col_a,col_b) SQL: <pre><code class="language-sql">select * from my_table where col_b=1 select * from my_table order by col_b</code></pre> <p>组合索引的匹配规则是从左往右匹配,无论是作为查询条件还是排序条件都要遵循这个原则。如果要使用col_b字段走索引,查询条件则必须要携带col_b前面的字段。</p></li> <li>隐式转换(字段为字符串类型,查询条件传了数字,类型不匹配,mysql会做隐式类型转换,转换为浮点数再做比较) 字段类型:col_a(varchar) 索引:index(col_a) SQL: <pre><code class="language-sql">select * from my_table where col_a=1 select * from my_table where col_b=1603296000000</code></pre></li> <li>in + order by 导致排序失效 索引:index(col_a,col_b) SQL: <pre><code class="language-sql">select * from my_table where col_a in (1,2) order by col_b</code></pre></li> <li>范围查询阻断组合索引 索引:index(col_a,col_b) SQL: <pre><code class="language-sql">select * from table where col_a &gt;'2021-12-01' and col_b=10</code></pre> <p>解决方式: 可以调整下索引顺序,col_a放在最后面。index(col_b,col_a)</p></li> <li>后缀匹配(如,like '%abc')不能走索引 索引:index(col_a,col_b) SQL: <pre><code class="language-sql">select * from table where col_a=1 and col_b like '%name%'</code></pre> <p>前缀匹配比如name%是可以走索引的,但是后缀匹配比如%name会导致没办法基于索引树进行二分查找。</p></li> <li>or+没有单独索引的列查询导致失效 索引:index(col_a,col_b) SQL: <pre><code class="language-sql">select * from table where col_a=1 or col_b=''</code></pre> <p>or查询会导致索引失效,可以将col_a和col_b分别建立索引,利用Mysql的index merge(索引合并)进行优化。本质上是分别两个字段分别走各自索引查出对应的数据,再将数据进行合并。</p></li> <li>在索引列上使用内置函数或进行列运算(如,+、-、*、/) 索引:index(col_a,col_b) SQL: <pre><code class="language-sql">select * from table where col_a=1 and DATE_SUB(CURDATE(), INTERVAL 7 DAY) &lt;= date(col_b); select * from table where col_a=1 and col_b+1=10</code></pre></li> <li>在索引列上使用不等于(!=、<>)、不包含(not in)(只用到ICP) 索引:index(col_a,col_b,col_c) SQL: <pre><code class="language-sql">select * from table where col_a=1 and col_b not in (1,2) select * from table where col_a=1 and col_b != 1</code></pre></li> </ol></li> <li>limit深度分页:mysql并不是跳过偏移量行,而是取offset+N行,然后丢弃前offset行,只返回后n行。当偏移量特别大时,MySQL需要花费大量的时间来回表扫描需要丢弃的数据。 优化方案:延迟关联,通过使用覆盖索引查询返回需要的主键,再根据这些主键关联原数据表。避免了对需要丢弃的数据的回表查询(走主键索引) <ul> <li>原sql1:<code>select * from table where type = 2 and level = 9 order by id asc limit 190289,10;</code></li> <li>改进:<code>select a.* from table a, (select id from table where type = 2 and level = 9 order by id asc limit 190289,10 ) b where a.id = b.id</code></li> <li>原sql2:<code>SELECT &lt;cols&gt; FROM profiles WHERE sex='M' ORDER BY rating LIMIT 100000,10;</code></li> <li>改进:建立索引(sex,rating)+<code>SELECT &lt;cols&gt; FROM profiles INNER JOIN(SELECT id FROM profiles  WHERE x.sex = 'M' ORDER BY rating LIMIT 100000,10) AS x WHERE x.id = profiles.id;</code></li> </ul></li> <li>join或者子查询过多:一方面,过多的表连接,会大大增加SQL复杂度。另外一方面,如果可以使用被驱动表的索引那还好,并且使用小表来做驱动表,查询效率更佳。如果被驱动表没有可用的索引,join是在join_buffer内存做的,如果匹配的数据量比较小或者join_buffer设置的比较大,速度也不会太慢。但是,如果join的数据量比较大时,mysql会采用在硬盘上创建临时表的方式进行多张表的关联匹配,这种显然效率就极低 优化方案:最多关联3张表,并且给关联的字段加索引。如果需要关联更多的表,建议从代码层面进行拆分,在业务层先查询一张表的数据,然后以关联字段作为条件查询关联表形成map,然后在业务层进行数据的拼装。</li> <li>order by走文件排序 例:<code> select name,age,city from staff where city = '深圳' order by age limit 10;</code> 文件排序分类: <img src="https://www.showdoc.com.cn/server/api/attachment/visitFile?sign=d25ecd93a6a42c0e5674a6c3ad5243a5&amp;file=file.png" alt="" /> <ul> <li>rowid排序:一般需要回表去找满足条件的数据,所以效率会慢一点 <img src="https://www.showdoc.com.cn/server/api/attachment/visitFile?sign=177e96deaf225a9832631b7936fb3bde&amp;file=file.png" alt="" /></li> <li>全字段排序:当排序的数据大于sort_buffer_size时,需借助磁盘文件来进行排序,效率更慢 <img src="https://www.showdoc.com.cn/server/api/attachment/visitFile?sign=b7f18c69eee41ee1ef53e6e833f34e76&amp;file=file.png" alt="" /> 优化方案:建立索引,索引数据本身有序,就不需要用到文件排序了</li> </ul></li> <li>group by使用临时表(内存临时表,磁盘临时表)和文件排序(默认) 优化方案: <ul> <li>group by后面的字段加索引(数据有序则直接往下扫描统计就好了,就不用临时表来记录并统计结果)</li> <li>order by null不用排序</li> <li>尽量只使用内存临时表</li> </ul></li> <li>单表数据量太大:一个表的数据量达到好几千万或者上亿时,加索引的效果没那么明显啦。性能之所以会变差,是因为维护索引的B+树结构层级变得更高了,查询一条数据时,需要经历的磁盘IO变多,因此查询性能变慢。 优化方案:分库分表</li> </ol> <h3>联合索引(a,b,c),怎么单独检索b用上索引</h3> <p>查询的内容是<strong>联合索引包含的字段</strong>时,可以单独的使用某个字段进行查询走索引(覆盖索引,type=index)</p> <h3>Sql优化</h3> <ol> <li>超大分页场景:mysql并不是跳过偏移量行,而是取offset+N行,然后丢弃前offset行,只返回后n行。当偏移量特别大时,MySQL需要花费大量的时间来回表扫描需要丢弃的数据。 解决方案:延迟关联,通过使用覆盖索引查询返回需要的主键,再根据这些主键关联原数据表。避免了对需要丢弃的数据的回表查询(走主键索引)</li> <li>like语句的优化:</li> <li>避免在sql中对where字段进行函数转换 或表达式计算</li> <li>使用isnull()来判断是否为NULL值:NULL值与任何值直接比较返回都是NULL</li> <li>多表查询:禁止三个表join;多表关联时数据类型必须一致,保证关联字段需要有索引</li> <li>count(<em>)和count(id):count(</em>)会统计值为NULL的行,而count(列名)不会统计此列为NULL值的行</li> </ol> <h4>建立索引的正确姿势</h4> <ol> <li>选择性低的字段不用建立索引。</li> <li>具有唯一性或者高选择性的字段无需与其他字段建立组合索引。</li> <li>除了业务需求上的考虑,尽量将选择性高的索引字段前置。</li> <li>在经过索引过滤后数据量依旧很大的情况下可以考虑通过覆盖索引优化。</li> </ol> <h2>Redis</h2> <h3>Redis和Memcache的区别</h3> <ul> <li>Redis 支持更丰富的数据类型。Memcache只支持K/V数据类型,Redis还支持list、set、zset、hash等数据结构</li> <li>Redis支持数据的持久化,可以将内存中的数据保存在磁盘中</li> <li>Redis有灾难恢复机制。因为可以把缓存中的数据持久化到磁盘上</li> <li>Redis支持集群模式</li> <li>Redis支持发布订阅模型、Lua脚本、事务等功能</li> </ul> <h3>Redis的rehash</h3> <p>dict数据结构:Redis的一个database中所有key到value的映射,就是使用一个dict来维护的。 <img src="https://www.showdoc.com.cn/server/api/attachment/visitFile?sign=2e1fad1d73f01f645e7d0bf6df5027f7&amp;file=file.png" alt="" /></p> <pre><code class="language-cpp">#dict字典的数据结构 typedef struct dict{ dictType *type; //直线dictType结构,dictType结构中包含自定义的函数,这些函数使得key和value能够存储任何类型的数据 void *privdata; //私有数据,保存着dictType结构中函数的 参数 dictht ht[2]; //两张哈希表 long rehashidx; //rehash的标记,rehashidx=-1表示没有进行rehash,rehash时每迁移一个桶就对rehashidx加一 int itreators; //正在迭代的迭代器数量 } #dict结构中ht[0]、ht[1]哈希表的数据结构 typedef struct dictht{ dictEntry[] table; //存放一个数组的地址,数组中存放哈希节点dictEntry的地址 unsingned long size; //哈希表table的大小,出始大小为4 unsingned long sizemask; //用于将hash值映射到table位置的索引,大小为(size-1) unsingned long used; //记录哈希表已有节点(键值对)的数量 }</code></pre> <p>渐进式rehash 如果一次性将这些键值对全部 rehash 到 ht[1] 的话,庞大的计算量(需要重新计算链表在桶中的位置)可能会导致服务器在一段时间内停止服务(redis是单线程的,如果全部移动会引起客户端长时间阻塞不可用)。因此, 为了避免 rehash 对服务器性能造成影响, 服务器不是一次性将 ht[0]里面的所有键值对全部 rehash 到 ht[1], 而是分多次、渐进式地将 ht[0]里面的键值对慢慢地 rehash 到 ht[1]。 步骤:</p> <ol> <li>为ht[1]分配空间,让dict字典同时持有 ht[0] 和 ht[1] 两个哈希表。</li> <li>在字典中维持一个索引计数器变量rehashidx,并将它的值设置为0,表示rehash工作正式开始。</li> <li><strong>在rehash进行期间,每次对字典执行添加、删除、查找或者更新操作时,程序除了执行指定的操作以外,还会顺带将ht[0]哈希表在 rehashidx索引(table[rehashidx]桶上的链表)上的所有键值对rehash到ht[1]上</strong>,当rehash工作完成之后,将rehashidx属性的值增一,表示下一次要迁移链表所在桶的位置。</li> <li>随着字典操作的不断执行,最终在某个时间点上,ht[0]的所有桶对应的键值对都会被rehash至ht[1],这时程序将rehashidx属性的值设为-1,表示rehash操作已完成。</li> </ol> <p>渐进式rehash执行期间的哈希表操作</p> <ol> <li>删除和查找:在进行渐进式rehash的过程中,字典会同时使用ht[0]和ht[1]两个哈希表,所以在渐进式rehash进行期间,字典的删除、查找、更新等操作会在两个哈希表上进行。比如说,要在字典里面查找一个键的话,程序会先在ht[0]里面进行查找,如果没找到的话,就会继续到ht[1]里面进行查找,诸如此类。</li> <li>新增数据:在渐进式 rehash 执行期间,新添加到字典的键值对一律会被保存到ht[1]里面,而ht[0]则不再进行任何添加操作。这一措施保证了ht[0]包含的键值对数量会只减不增,并随着rehash操作的执行而最终变成空表。</li> </ol> <h3>Redis有序集合底层?实现为什么使用跳表而不用红黑树</h3> <p>底层原理:字典(dict)和跳跃表(zsl)相结合;字典查找一个元素的复杂度是O(1),跳跃表插入、删除和查找的复杂度是O(logn) <img src="https://www.showdoc.com.cn/server/api/attachment/visitFile?sign=76c406b7842cdf8a94e481165a935bb3&amp;file=file.png" alt="" /></p> <ol> <li>插入、删除和查找一个元素的时间复杂度都是O(logn),但是做范围查询时,红黑树比跳跃表更复杂在平衡树上,我们找到指定范围的小值之后,还需要以中序遍历的顺序继续寻找其它不超过大值的节点。如果不对平衡树进行一定的改造,这里的中序遍历并不容易实现。而在skiplist上进行范围查找就非常简单,只需要在找到小值之后,对第1层链表进行若干步的遍历就可以实现。</li> <li>红黑树的插入删除操作可能引发子树的调整,逻辑复杂,而skiplist的插入和删除只需要修改相邻节点的指针。</li> <li>跳表更灵活,可通过改变索引构建策略,有效平衡执行效率和内存消耗</li> </ol> <h3>Redis分区实现方案</h3> <ul> <li>客户端分区:是在客户端就已经决定数据会被存储到哪个redis节点或者从哪个redis节点读取。大多数客户端已经实现了客户端分区 <ul> <li>范围分区:<strong>映射一定范围的对象到特定的Redis实例</strong>。比如,ID从0到10000的用户会保存到实例R0,ID从10001到 20000的用户会保存到R1,以此类推</li> <li>hash一致算法实现分区:hash一致算法实现分区,<strong>对key值进行hash一致性计算后得到结果,最终将数据保存到某一台redis实例中</strong></li> </ul></li> <li>代理分区:客户端将请求发送给代理,然后代理决定去哪个节点写数据或者读数据。代理根据分区规则决定请求哪些Redis实例,然后根据Redis的响应结果返回给客户端。redis和memcached的一种代理实现就是Twemproxy</li> </ul> <h3>Redis持久化方式</h3> <ul> <li>RDB持久化(默认方式):通过创建快照来获得存储在内存里面的数据在某个时间点上的副本。</li> <li>AOF持久化:开启 AOF 持久化后每执行一条会更改 Redis 中的数据的命令,Redis 就会将该命令写入到内存缓存 server.aof_buf 中,然后再根据 appendfsync 配置来决定何时将其同步到硬盘中的 AOF 文件。</li> </ul> <h3>Redis缓存穿透和缓存雪崩</h3> <ul> <li> <p><strong>缓存穿透</strong>:指查询一个根本不存在的数据,缓存层和持久层都不会命中,大量请求key根本不在缓存中,导致请求直接落在数据库上</p> <ul> <li>参数校验:一些不合法的参数请求直接抛出异常信息返回给客户端。</li> <li>缓存无效的Key设置短期有效:如果缓存和数据库都查不到某个key的数据(一定不存在的数据)就写一个到Redis中并设置过期时间,并尽量将无效的key的过期时间设置的短一些</li> <li><strong>布隆过滤器(常方便地判断一个给定数据是否存在于海量数据中,当布隆过滤器说,某种东西存在时,这种东西可能不存在;当布隆过滤器说,某种东西不存在时,那么这种东西一定不存在。)</strong>:把所有可能存在的请求的值都存放在布隆过滤器中,当用户请求过来,先判断用户发来的请求的值是否存在于布隆过滤器中。不存在的话,直接返回请求参数错误信息给客户端,存在的话才会走下面的流程。 <ul> <li>原理:布隆过滤器是一种比较巧妙的概率型数据结构,本质是一个 bit 向量或者说 bit 数组,长这样<img src="https://www.showdoc.com.cn/server/api/attachment/visitFile?sign=78d20e13aabf709cab6f2dc34b3723ce&amp;file=file.png" alt="" />向布隆过滤器中添加key时,会使用多个hash函数对key进行hash,算得一个整数索引值,然后对位数组进行取模运算得到一个位置,每个hash函数都会算得一个不同的位置。在把位数组的这几个位置都置为1。向布隆过滤器询问key是否存在时,也会把hash的几个位置都算出来,看看位数组中这几个位置是否都为1,只要有一个位为0,那么说明布隆过滤器中这个key不存在。如果这几个位置都是1,并不能说明这个key就一定存在,只是极有可能存在,因为这些位被置为1可能是因为其他的key存在所致。如果这个位数组比较稀疏,判断正确的概率就会很大,如果这个位数组比较拥挤,判断正确的概率就会降低。</li> </ul></li> </ul> </li> <li> <p><strong>缓存击穿</strong>:是指缓存中没有但数据库中有的数据(一般是缓存时间到期),这时由于并发用户特别多,同时读缓存没读到数据,又同时去数据库去取数据,造成数据库短时间内承受大量请求。</p> <ol> <li>缓存永不失效</li> <li>向获取分布式锁,再查数据库: 就是在缓存失效的时候(判断拿出来的值为空),不是立即去查数据库,先使用缓存工具的某些带成功操作返回值的操作。比如redis的setnx去set一个mutex key,当操作返回成功时(拿到分布式锁),再去查数据库,并回设缓存,最后删除mutex key;当操作返回失败,证明有线程在查询数据库,当前线程睡眠一段时间在重s试整个get缓存的方法</li> </ol> </li> <li><strong>缓存雪崩</strong>:缓存在同一时间大面积的失效,后面的请求都直接落到了数据库上,造成数据库短时间内承受大量请求。 <ol> <li>针对 Redis 服务不可用的情况 <ul> <li>采用 Redis 集群,避免单机出现问题整个缓存服务都没办法使用</li> <li>限流,避免同时处理大量的请求</li> </ul></li> <li>针对热点缓存失效的情况 <ul> <li>设置不同的失效时间比如随机设置缓存的失效时间。</li> <li>缓存永不失效</li> </ul></li> </ol></li> </ul> <h1>3.并发编程</h1> <h2>进程和线程的区别</h2> <p>进程是系统资源分配的最小单位,线程是CPU调度的最小单位,同类的多个线程共享进程的<strong>堆</strong>和<strong>方法区</strong>资源,但每个线程有自己的<strong>程序计数器</strong>、<strong>虚拟机栈</strong>和<strong>本地方法栈。总结:</strong> <strong>线程是进程划分成的更小的运行单位。线程和进程最大的不同在于基本上各进程是独立的,而各线程则不一定,因为同一进程中的线程极有可能会相互影响。线程执行开销小,但不利于资源的管理和保护;而进程正相反。</strong></p> <ul> <li>程序计数器私有:为了线程切换后能回到正确的位置</li> <li>虚拟机栈私有:保证线程中的局部变量不被其他线程访问到</li> </ul> <h2>什么是上下文切换</h2> <p>正在运行的线程让出CPU时,需要保存该线程的运行条件和状态信息(程序计数器、虚拟机栈),即上下文,待线程下次占用 CPU 的时候恢复现场。并加载下一个将要占用 CPU 的线程上下文。</p> <h2><strong>用户态和内核态的区别</strong></h2> <ul> <li>内核空间(Kernal Space),只有内核程序可以访问的内存空间;</li> <li>用户空间(User Space),应用程序可以使用的内存空间。</li> </ul> <p>用户态:工作在用户空间的程序;内核态:工作在内核空间的程序。</p> <p>用户态程序执行系统调用时,会发生用户态=&gt;内核态=&gt;用户态的切换(trap中断)。</p> <p>用户态线程调度完全由进程负责,通常就是由进程的主线程负责。使用的是操作系统分配给进程主线程的时间片段。内核线程由内核维护,由操作系统调度。用户态线程无法跨核心,一个进程的多个用户态线程不能并发,阻塞一个用户态线程会导致进程的主线程阻塞,直接交出执行权限。这些都是用户态线程的劣势。内核线程可以独立执行,操作系统会分配时间片段。因此内核态线程更完整,也称作轻量级进程。</p> <h2>虚拟内存</h2> <p><strong>虚拟内存为每个进程提供了一个一致的、私有的地址空间,它让每个进程产生了一种自己在独享主存的错觉(每个进程拥有一片连续虚拟地址空间)。</strong></p> <p>虚拟内存主要提供了如下三个重要的能力:</p> <ul> <li> <p>它把主存看作为一个存储在硬盘上的虚拟地址空间的高速缓存,并且只在主存中缓存活动区域(按需缓存)。</p> </li> <li> <p>它为每个进程提供了一个一致的地址空间,从而降低了程序员对内存管理的复杂性。(内存管理)</p> </li> <li>它还保护了每个进程的地址空间不会被其他进程破坏。(内存保护)</li> </ul> <p><img src="https://www.showdoc.com.cn/server/api/attachment/visitFile?sign=c27f91004004ff73c7d94861b83c1438" alt="" /> MMU将虚拟地址映射为物理地址的详细过程:CPU产生虚拟地址,然后从虚拟地址中得到虚拟页号,接下来通过将虚拟页号作为索引去查找页表,通过得到对应PTE上的有效位来判断当前虚拟页是否在主存中,若命中则将对应PTE上的物理页号和虚拟地址中的虚拟页偏移进行串联从而构造出主存中的物理地址,否则未命中(专业名词称为“缺页”),此时MMU将引发缺页异常,从CPU传递到操作系统内核处理缺页异常处理程序,此时将选择一个牺牲页并将对应所缺虚拟页调入并更新页表上的PTE,缺页处理程序再次返回到原来的进程,再次执行缺页指令,CPU重新将虚拟地址发给MMU,此时虚拟页已存在物理内存中,所以命中,最终将请求的字返回给处理器</p> <h2>进程间通信方式</h2> <ol> <li> <p>管道</p> <p><img src="https://www.showdoc.com.cn/server/api/attachment/visitFile?sign=dd7a4a26d0046c6556043e2695e4e391" alt="" title="父子进程之间管道通信" /></p> <p><img src="https://www.showdoc.com.cn/server/api/attachment/visitFile?sign=c28ca3645af39c994520c44a18531e75" alt="" title="兄弟进程之间管道通信" /> 特点:本质是内核里面的一串缓存;匿名管道的通信范围是存在父子关系的进程,命名管道可以在不相关的进程间也能相互通信;单向通信;面向字节流;生命周期随进程的创建而建立,随进程的结束而销毁。</p> </li> <li> <p>消息队列 特点:本质是保存在内核中的消息链表;双向通信;面向消息块(用户自定义的数据类型);生命周期随内核,如果没有释放消息队列或者没有关闭操作系统,消息队列会一直存在。</p> <ul> <li> <p>好处:</p> <ul> <li>通过异步处理减少响应时间,提高系统的性能(请求数据存储到消息队列之后就立即返回结果)</li> <li>削峰/限流:先将短时间高并发产生的事务消息存储在消息队列中,然后后端服务慢慢消费这些消息,将用户请求压力转移到消息队列上</li> <li>降低系统的耦合性:使用发布-订阅模式,生产者和消费者之间不存在直接调用,新增模块或者修改模块对其他模块影响较小,系统的可扩展性更好一些</li> </ul> </li> <li>不足: <ul> <li>消息队列不适合比较大数据的传输,因为在内核中每个消息体都有一个最大长度的限制,同时所有队列所包含的全部消息体的总长度也是有上限。</li> <li>消息队列通信过程中,存在用户态与内核态之间的数据拷贝开销。</li> </ul></li> </ul> </li> <li> <p>共享内存 <img src="https://www.showdoc.com.cn/server/api/attachment/visitFile?sign=96bd2d6af353ca608bd9a41c47ade7c2" alt="" /> 共享内存的机制:就是拿出一块虚拟地址空间来,映射到相同的物理内存中。这样这个进程写入的东西,另外一个进程马上就能看到了,都不需要拷贝来拷贝去,传来传去,大大提高了进程间通信的速度。不需要进行内核态用户态的切换。</p> </li> <li> <p>信号量 <strong>信号量其实是一个整型的计数器,表示资源的数量,主要用于实现进程间的互斥与同步,而不是用于缓存进程间通信的数据。解决了多个进程同时修改同一个共享内存可能发生冲突的情况</strong>。</p> <p>控制信号量的方式有两种原子操作:</p> <ul> <li>一个是 <strong>P 操作</strong>,这个操作会把信号量减去 -1,相减后如果信号量 &lt; 0,则表明资源已被占用,进程需阻塞等待;相减后如果信号量 &gt;= 0,则表明还有资源可使用,进程可正常继续执行。</li> <li>另一个是 <strong>V 操作</strong>,这个操作会把信号量加上 1,相加后如果信号量 &lt;= 0,则表明当前有阻塞中的进程,于是会将该进程唤醒运行;相加后如果信号量 &gt; 0,则表明当前没有阻塞中的进程;</li> </ul> <p>P 操作是用在进入共享资源之前,V 操作是用在离开共享资源之后,这两个操作是必须成对出现的。信号量初始化为1,可以实现线程互斥;信号量初始化为0,可以实现线程同步。</p> </li> <li> <p>信号 信号 ( sinal ) : 信号是一种比较复杂的通信方式,用于通知接收进程某个事件已经发生。</p> </li> <li> <p>Socket 前面提到的管道、消息队列、共享内存、信号量和信号都是在同一台主机上进行进程间通信,那要想跨网络与不同主机上的进程之间通信,就需要 Socket 通信了。 针对 TCP 协议通信的 socket 编程模型 <img src="https://www.showdoc.com.cn/server/api/attachment/visitFile?sign=299be0eb509776daca18f9106e9804c6" alt="" /></p> <ul> <li>服务端和客户端初始化 <code>socket</code>,得到文件描述符;</li> <li>服务端调用 <code>bind</code>,将绑定在 IP 地址和端口;</li> <li>服务端调用 <code>listen</code>,进行监听;</li> <li>服务端调用 <code>accept</code>,等待客户端连接;</li> <li>客户端调用 <code>connect</code>,向服务器端的地址和端口发起连接请求;</li> <li>服务端 <code>accept</code> 返回用于传输的 <code>socket</code> 的文件描述符;</li> <li>客户端调用 <code>write</code> 写入数据;服务端调用 <code>read</code> 读取数据;</li> <li>客户端断开连接时,会调用 <code>close</code>,那么服务端 <code>read</code> 读取数据的时候,就会读取到了 <code>EOF</code>,待处理完数据后,服务端调用 <code>close</code>,表示连接关闭。</li> </ul> <p><strong>这里需要注意的是,服务端调用 accept 时,连接成功了会返回一个已完成连接的 socket,后续用来传输数据。所以,监听的 socket 和真正用来传送数据的 socket,是「两个」 socket,一个叫作监听 socket,一个叫作已完成连接 socket。</strong></p> </li> </ol> <p>服务端代码:ServerSocket 的 accept() 方法是阻塞方法,也就是说 ServerSocket 在调用 accept()等待客户端的连接请求时会阻塞,直到收到客户端发送的连接请求才会继续往下执行代码。</p> <pre><code class="language-java">public class HelloServer { private static final Logger logger = LoggerFactory.getLogger(HelloServer.class); public void start(int port) { //1.创建 ServerSocket 对象并且绑定一个端口 try (ServerSocket server = new ServerSocket(port);) { Socket socket; //2.通过 accept()方法监听客户端请求 while ((socket = server.accept()) != null) { logger.info("client connected"); try (ObjectInputStream objectInputStream = new ObjectInputStream(socket.getInputStream()); ObjectOutputStream objectOutputStream = new ObjectOutputStream(socket.getOutputStream())) { //3.通过输入流读取客户端发送的请求信息 Message message = (Message) objectInputStream.readObject(); logger.info("server receive message:" + message.getContent()); message.setContent("new content"); //4.通过输出流向客户端发送响应信息 objectOutputStream.writeObject(message); objectOutputStream.flush(); } catch (IOException | ClassNotFoundException e) { logger.error("occur exception:", e); } } } catch (IOException e) { logger.error("occur IOException:", e); } } public static void main(String[] args) { HelloServer helloServer = new HelloServer(); helloServer.start(6666); } }</code></pre> <p>缺点:ServerSocket 的 accept() 方法是阻塞方法,也就是说 ServerSocket 在调用 accept()等待客户端的连接请求时会阻塞,直到收到客户端发送的连接请求才会继续往下执行代码。 优化:使用线程池 客户端代码:</p> <pre><code class="language-java">/** * @author shuang.kou * @createTime 2020年05月11日 16:56:00 */ public class HelloClient { private static final Logger logger = LoggerFactory.getLogger(HelloClient.class); public Object send(Message message, String host, int port) { //1. 创建Socket对象并且指定服务器的地址和端口号 try (Socket socket = new Socket(host, port)) { ObjectOutputStream objectOutputStream = new ObjectOutputStream(socket.getOutputStream()); //2.通过输出流向服务器端发送请求信息 objectOutputStream.writeObject(message); //3.通过输入流获取服务器响应的信息 ObjectInputStream objectInputStream = new ObjectInputStream(socket.getInputStream()); return objectInputStream.readObject(); } catch (IOException | ClassNotFoundException e) { logger.error("occur exception:", e); } return null; } public static void main(String[] args) { HelloClient helloClient = new HelloClient(); helloClient.send(new Message("content from client"), "127.0.0.1", 6666); System.out.println("client receive message:" + message.getContent()); } }</code></pre> <h2>Kafka</h2> <h3>Kafka中Producer、Consumer、Broker、Topic、Partition</h3> <p><img src="https://www.showdoc.com.cn/server/api/attachment/visitFile?sign=c6c02adfb1e8c7f9266872c4951956da" alt="" /></p> <ol> <li><strong>Producer(生产者)</strong> : 产生消息的一方。</li> <li><strong>Consumer(消费者)</strong> : 消费消息的一方。</li> <li><strong>Broker(代理)</strong> : 可以看作是一个独立的 Kafka 实例。多个 Kafka Broker 组成一个 Kafka Cluster。</li> <li><strong>Topic(主题)</strong> : Producer 将消息发送到特定的主题,Consumer 通过订阅特定的 Topic(主题) 来消费消息。</li> <li><strong>Partition(分区)</strong> : Partition 属于 Topic 的一部分。一个 Topic 可以有多个 Partition ,并且同一 Topic 下的 Partition 可以分布在不同的 Broker 上,这也就表明一个 Topic 可以横跨多个 Broker。</li> </ol> <h3>Kafka多分区(Partition)和多副本(Replica)机制的好处</h3> <ul> <li>Kafka 通过给特定 Topic 指定多个 Partition, 而各个 Partition 可以分布在不同的 Broker 上, 这样便能提供比较好的并发能力(负载均衡)。</li> <li>Partition 可以指定对应的 Replica 数, 这也极大地提高了消息存储的安全性, 提高了容灾能力,不过也相应的增加了所需要的存储空间。(容灾能力)</li> </ul> <h3>Kafka如何保证消息顺序</h3> <p><img src="https://www.showdoc.com.cn/server/api/attachment/visitFile?sign=56cd17629ae4575ebcfc1fbd2202ed74" alt="" /> <strong>Kafka 只能为我们保证 Partition(分区) 中的消息有序。</strong>消息在被追加到 Partition(分区)的时候都会分配一个特定的偏移量(offset)。Kafka 通过偏移量(offset)来保证消息在分区内的顺序性。</p> <p>保证 Kafka 中消息消费的顺序,有了下面两种方法:</p> <ul> <li>1 个 Topic 只对应一个 Partition。</li> <li>(推荐)发送消息的时候指定 key/Partition。(同一个 key 的消息可以保证只发送到同一个 partition,这个我们可以采用表/对象的 id 来作为 key)</li> </ul> <h3>Kafka如何保证消息不丢失</h3> <ul> <li>生产者丢失消息的情况:send操作后添加回调函数+异常重试</li> <li>消费者丢失消息的情况:关闭消费者拉到消息后的自动提交,但可能会造成消息的重复消费</li> <li>Kafka丢失消息的情况:设置 acks = all(所有副本都要接收到该消息之后该消息才算真正成功被发送);设置 replication.factor &gt;= 3(保证每个 分区(partition) 至少有 3 个副本);设置 min.insync.replicas &gt; 1(消息至少要被写入到 2 个副本才算是被成功发送);设置 unclean.leader.election.enable = false(当 leader 副本发生故障时就不会从 follower 副本中和 leader 同步程度达不到要求的副本中选择出 leader ,这样降低了消息丢失的可能性)。</li> </ul> <h2>线程池<code>ThreadPoolExecutor</code></h2> <h3>线程池好处和底层实现</h3> <p>好处:</p> <ul> <li>降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。</li> <li>提高响应速度。当任务到达时,任务可以不需要等到线程创建就能立即执行。</li> <li>提高线程的可管理性。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。</li> </ul> <h3>创建方式</h3> <p>方式1. <strong><code>ThreadPoolExecutor</code>构造函数</strong></p> <ul> <li><strong><code>ThreadPoolExecutor</code> 3 个最重要的参数:</strong> <ul> <li><strong><code>corePoolSize</code> :</strong> 核心线程数线程数定义了最小可以同时运行的线程数量。</li> <li><strong><code>maximumPoolSize</code> :</strong> 当队列中存放的任务达到队列容量的时候,当前可以同时运行的线程数量变为最大线程数。</li> <li><strong><code>workQueue</code>:</strong> 当新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中。</li> </ul></li> <li> <p><code>ThreadPoolExecutor</code>其他常见参数 :</p> <ol> <li><strong><code>keepAliveTime</code></strong>:当线程池中的线程数量大于 <code>corePoolSize</code> 的时候,如果这时没有新的任务提交,核心线程外的线程不会立即销毁,而是会等待,直到等待的时间超过了 <code>keepAliveTime</code>才会被回收销毁;</li> <li><strong><code>unit</code></strong> : <code>keepAliveTime</code> 参数的时间单位。</li> <li><strong><code>threadFactory</code></strong> :executor 创建新线程的时候会用到。</li> <li><strong><code>handler</code></strong> :饱和策略。关于饱和策略下面单独介绍一下。 <img src="https://www.showdoc.com.cn/server/api/attachment/visitFile?sign=183723702dd91c0945277f772694515a" alt="" /></li> </ol> </li> <li><strong><code>ThreadPoolExecutor</code> 饱和策略定义:</strong>:如果当前同时运行的线程数量达到最大线程数量并且队列也已经被放满了任务时,<code>ThreadPoolTaskExecutor</code> 定义一些策略: <ul> <li><strong><code>ThreadPoolExecutor.AbortPolicy</code></strong> :抛出 <code>RejectedExecutionException</code>来拒绝新任务的处理。</li> <li><strong><code>ThreadPoolExecutor.CallerRunsPolicy</code></strong> :调用执行自己的线程运行任务,也就是直接在调用<code>execute</code>方法的线程中运行(<code>run</code>)被拒绝的任务,如果执行程序已关闭,则会丢弃该任务。因此这种策略会降低对于新任务提交速度,影响程序的整体性能。如果您的应用程序可以承受此延迟并且你要求任何一个任务请求都要被执行的话,你可以选择这个策略。</li> <li><strong><code>ThreadPoolExecutor.DiscardPolicy</code></strong> :不处理新任务,直接丢弃掉。</li> <li><strong><code>ThreadPoolExecutor.DiscardOldestPolicy</code></strong> : 此策略将丢弃最早的未处理的任务请求。</li> </ul></li> </ul> <p>方式2. <strong>Executor 框架的工具类 Executors</strong> 我们可以创建三种类型的 ThreadPoolExecutor:</p> <ul> <li><strong>FixedThreadPool</strong> : 该方法返回一个<strong>固定线程数量的线程池</strong>。该线程池中的线程数量始终不变。当有一个新的任务提交时,线程池中若有空闲线程,则立即执行。若没有,则新的任务会被暂存在一个任务队列中,待有线程空闲时,便处理在任务队列中的任务。</li> <li><strong>SingleThreadExecutor:</strong> 方法返回一个<strong>只有一个线程的线程池</strong>。若多余一个任务被提交到该线程池,任务会被保存在一个任务队列中,待线程空闲,按先入先出的顺序执行队列中的任务。</li> <li><strong>CachedThreadPool:</strong> 该方法返回一个<strong>可根据实际情况调整线程数量的线程池。</strong>线程池的线程数量不确定,但若有空闲线程可以复用,则会优先使用可复用的线程。若所有线程均在工作,又有新的任务提交,则会创建新的线程处理任务。所有线程在当前任务执行完毕后,将返回线程池进行复用。</li> </ul> <h3>线程池大小确定</h3> <p><strong>线程池数量太小:</strong>如果同一时间有大量任务/请求需要处理,可能会导致大量的请求/任务在任务队列中排队等待执行,甚至会出现任务队列满了之后任务/请求无法处理的情况,或者大量任务堆积在任务队列导致 OOM。这样很明显是有问题的! CPU 根本没有得到充分利用。</p> <p><strong>线程数量太大:</strong>大量线程可能会同时在争取 CPU 资源,这样会导致大量的上下文切换,从而增加线程的执行时间,影响了整体执行效率。**</p> <p>公式(CPU密集型任务则线程池设小点,每个线程分配到的时间片多点避免频繁上下文切换;IO密集型任务则线程池设大点,因为该类任务不会占用CPU太长时间):</p> <ul> <li><strong>CPU 密集型任务(N+1):</strong> 这种任务消耗的主要是 CPU 资源,可以将线程数设置为 N(CPU 核心数)+1,比 CPU 核心数多出来的一个线程是为了防止线程偶发的缺页中断,或者其它原因导致的任务暂停而带来的影响。一旦任务暂停,CPU 就会处于空闲状态,而在这种情况下多出来的一个线程就可以充分利用 CPU 的空闲时间。</li> <li><strong>I/O 密集型任务(2N):</strong> 这种任务应用起来,系统会用大部分的时间来处理 I/O 交互,而线程在处理 I/O 的时间段内不会占用 CPU 来处理,这时就可以将 CPU 交出给其它线程使用。因此在 I/O 密集型任务的应用中,我们可以多配置一些线程,具体的计算方法是 2N。</li> </ul> <h2>CopyOnWrite底层原理,不适用什么场景</h2> <p>原理:CopyOnWriteArrayList 类的所有写操作(add,set 等等)都是通过创建底层数组的新副本来实现的。当 List 需要被修改的时候,我并不修改原有内容,而是对原有数据进行一次复制,将修改的内容写入副本。写完之后,再将修改完的副本替换原来的数据,这样就可以保证写操作不会影响读操作了。写操作时,加了可重入锁。</p> <p>不适用场景: <strong>数据量大的场景:</strong>由于写操作的时候,需要拷贝数组,会消耗内存,如果原数组的内容比较多的情况下,可能导致young gc或者full gc。 <strong>实时读场景:</strong>CopyOnWrite容器只能保证数据的最终一致性,不能保证数据的实时一致性。所以如果你希望写入的的数据,马上能读到,请不要使用CopyOnWrite容器。【当执行add或remove操作没完成时,get获取的仍然是旧数组的元素】</p> <h2>java中线程中断机制</h2> <p>调用某个线程的interrupt方法,将修改该线程的中断状态。因调用sleep,join,await方法而进入等待状态的线程被中断时将抛出InterruptedException异常</p> <h2>Synchronized和Volatile关键字</h2> <p>volatile关键字主要用于解决变量在多个线程之间的可见性以及代码指令执行的有序性,而 synchronized 关键字解决的是代码片段执行的原子性和多个线程之间访问资源的同步性。</p> <h3>Volatile读写的内存语义</h3> <ul> <li>当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。(注意:是所有的共享变量,不光是volatile变量)</li> <li>当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存 (注意:这里也是所有的共享变量)</li> </ul> <h3>ThreadLocal内存泄漏问题</h3> <p>ThreadLocalMap中使用的key为ThreadLocal的弱引用,而value是强引用。垃圾回收时,key到ThreadLocal对象的引用将会断开,如果ThreadLocal没有被外部强引用的情况下,在垃圾回收时,key会被清理掉,而value不会被清理掉。这样一来,ThreadLocalMap 中就会出现 key 为 null 的 Entry。假如我们不做任何措施的话,value 永远无法被 GC 回收,这个时候就可能会产生内存泄露。ThreadLocalMap 实现中已经考虑了这种情况,在调用 set()、get()、remove() 方法的时候,会清理掉 key 为 null 的记录。使用完 ThreadLocal方法后 最好手动调用remove()方法</p> <h3>Reentrantlock</h3> <p>java内存模型(JMM)</p> <ul> <li> <p>底层:CPU缓存模型 <img src="https://www.showdoc.com.cn/server/api/attachment/visitFile?sign=7702faba977d655f4ba5a687a5d018e7&amp;file=file.png" alt="" /> 目标:CPU Cache 缓存的是内存数据用于解决 CPU 处理速度和内存不匹配的问题,内存缓存的是硬盘数据用于解决硬盘访问速度过慢的问题。 工作方式:先复制一份数据到 CPU Cache 中,当 CPU 需要用到的时候就可以直接从 CPU Cache 中读取数据,当运算完成后,再将运算得到的数据写回 Main Memory 中。但是,这样存在 <strong>内存缓存不一致性的问题</strong> !比如我执行一个 i++操作的话,如果两个线程同时执行的话,假设两个线程从 CPU Cache 中读取的 i=1,两个线程做了 1++运算完之后再写回 Main Memory 之后 i=2,而正确结果应该是 i=3。</p> </li> <li>JMM: <img src="https://www.showdoc.com.cn/server/api/attachment/visitFile?sign=5b965ae787466e63d3f87954ecfde87c&amp;file=file.png" alt="" /> <ul> <li>主内存 :所有线程创建的实例对象都存放在主内存中,不管该实例对象是成员变量还是方法中的本地变量(也称局部变量)</li> <li>本地内存 :<strong>每个线程都有一个私有的本地内存(对应寄存器)来存储共享变量的副本</strong>,并且,每个线程只能访问自己的本地内存,无法访问其他线程的本地内存。本地内存是 JMM 抽象出来的一个概念,存储了主内存中的共享变量副本。</li> </ul></li> </ul> <p>禁止指令重排序的规则:</p> <ul> <li>第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。确保volatile写操作刷新内存里共享变量的值时,程序员希望发生的变动都能够正确的刷新到内存中</li> <li>第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。确保volatile读操作读取内存里的最新值是程序员希望读到的、操作的值</li> </ul> <h2>AQS原理</h2> <p>AQS 核心思想是:如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制 AQS 是用 CLH 队列锁实现的,即将暂时获取不到锁的线程加入到队列中。 <img src="https://www.showdoc.com.cn/server/api/attachment/visitFile?sign=53fc34edfe88ccb8aaea0ee2c6e82054" alt="" /> AQS 使用一个volatile int 成员变量state来表示同步状态,通过内置的 FIFO 队列来完成获取资源线程的排队工作。AQS 使用 CAS(compareAndSetState方法) 对该同步状态进行原子操作实现对其值的修改。 <strong>两种资源共享方式</strong></p> <ul> <li><strong>Exclusive</strong>(独占):只有一个线程能执行,如 <code>ReentrantLock</code>。又可分为公平锁和非公平锁: <ul> <li>公平锁:按照线程在队列中的排队顺序,先到者先拿到锁</li> <li>非公平锁:当线程要获取锁时,无视队列顺序直接去抢锁,谁抢到就是谁的</li> </ul></li> <li><strong>Share</strong>(共享):多个线程可同时执行,如 <code>CountDownLatch</code>、<code>Semaphore</code>、 <code>CyclicBarrier</code>、<code>ReadWriteLock</code> 我们都会在后面讲到。</li> </ul> <p> 不同的自定义同步器争用共享资源的方式也不同。<strong>自定义同步器在实现时只需要实现共享资源state的获取与释放方式即可</strong>,至于具体线程等待队列的维护(如获取资源失败入队/唤醒出队等),AQS已经在顶层实现好了。自定义同步器实现时主要实现以下几种方法:</p> <ul> <li>isHeldExclusively():该线程是否正在独占资源。只有用到condition才需要去实现它。</li> <li>tryAcquire(int):独占方式。尝试获取资源,成功则返回true,失败则返回false。</li> <li>tryRelease(int):独占方式。尝试释放资源,成功则返回true,失败则返回false。</li> <li>tryAcquireShared(int):共享方式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。</li> <li>tryReleaseShared(int):共享方式。尝试释放资源,如果释放后允许唤醒后续等待结点返回true,否则返回false。</li> </ul> <h3>acquire(int)</h3> <p>  此方法是独占模式下线程获取共享资源的顶层入口。如果获取到资源,线程直接返回,否则进入等待队列,直到获取到资源为止,且整个过程忽略中断的影响。这也正是lock()的语义,当然不仅仅只限于lock()。下面是acquire()的源码:</p> <pre><code class="language-java">public final void acquire(int arg) { if (!tryAcquire(arg) &amp;&amp; acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) selfInterrupt(); }</code></pre> <p>函数流程如下: <img src="https://www.showdoc.com.cn/server/api/attachment/visitFile?sign=feda6cb8b87444db6c5f4822b9094d8e" alt="" /></p> <ol> <li>调用自定义同步器的tryAcquire()尝试直接去获取资源,如果成功则直接返回;</li> <li>没成功,则addWaiter()将该线程加入等待队列的尾部,并标记为独占模式;</li> <li>acquireQueued()使线程在等待队列中休息,有机会时(轮到自己,会被unpark())会去尝试获取资源。获取到资源后才返回。如果在整个等待过程中被中断过,则返回true,否则返回false。</li> <li>如果线程在等待过程中被中断过,它是不响应的。只是获取资源后才再进行自我中断selfInterrupt(),将中断补上。</li> </ol> <h3>release(int)</h3> <p>  此方法是独占模式下线程释放共享资源的顶层入口。它会释放指定量的资源,如果彻底释放了(即state=0),它会唤醒等待队列里的其他线程来获取资源。这也正是unlock()的语义,当然不仅仅只限于unlock()。下面是release()的源码:</p> <pre><code class="language-java">public final boolean release(int arg) { if (tryRelease(arg)) { Node h = head;//找到头结点 if (h != null &amp;&amp; h.waitStatus != 0) unparkSuccessor(h);//唤醒等待队列里的下一个线程 return true; } return false; }</code></pre> <h3>acquireShared(int)</h3> <p>  此方法是共享模式下线程获取共享资源的顶层入口。它会获取指定量的资源,获取成功则直接返回,获取失败则进入等待队列,直到获取到资源为止,整个过程忽略中断。下面是acquireShared()的源码:</p> <pre><code class="language-java">public final void acquireShared(int arg) { if (tryAcquireShared(arg) &lt; 0) doAcquireShared(arg); }</code></pre> <p>函数流程如下:</p> <ol> <li>tryAcquireShared()尝试获取资源,成功则直接返回;</li> <li>失败则通过doAcquireShared()进入等待队列park(),直到被unpark()/interrupt()并成功获取到资源才返回。整个等待过程也是忽略中断的。 其实跟acquire()的流程大同小异,只不过多了个自己拿到资源后,还会去唤醒后继队友的操作(这才是共享嘛)。</li> </ol> <h3>releaseShared()</h3> <p>  此方法是共享模式下线程释放共享资源的顶层入口。它会释放指定量的资源,如果成功释放且允许唤醒等待线程,它会唤醒等待队列里的其他线程来获取资源。下面是releaseShared()的源码:</p> <pre><code class="language-java">public final boolean releaseShared(int arg) { if (tryReleaseShared(arg)) {//尝试释放资源 doReleaseShared();//唤醒后继结点 return true; } return false; }</code></pre> <p>此方法的流程也比较简单,一句话:释放掉资源后,唤醒后继。跟独占模式下的release()相似,但有一点稍微需要注意:独占模式下的tryRelease()在完全释放掉资源(state=0)后,才会返回true去唤醒其他线程,这主要是基于独占下可重入的考量;而共享模式下的releaseShared()则没有这种要求,共享模式实质就是控制一定量的线程并发执行,那么拥有资源的线程在释放掉部分资源时就可以唤醒后继等待结点。</p> <h1>4. JVM</h1> <h2>java堆中对象的创建过程</h2> <p>Step1:类加载检查:虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否能<strong>在常量池中定位到这个类的符号引用</strong>,并且<strong>检查这个符号引用代表的类是否已被加载过、链接和初始化过</strong>。如果没有,那必须先执行相应的<strong>类加载过程</strong>。</p> <ul> <li>加载:通过全类名获取定义此类的二进制字节流;将字节流所代表的静态存储结构转换为方法区的运行时数据结构;<strong>在内存中生成一个代表该类的 Class 对象,作为方法区这些数据的访问入口</strong></li> <li>链接 <ul> <li>验证:文件格式、元数据、字节码等验证</li> <li>准备:为<strong>类变量(被 static 关键字修饰的静态变量)</strong>分配内存并设置初始值(数据类型默认的零值,除非该静态变量是final修饰的常量)的阶段,这些内存都将在方法区中分配。而在 JDK 7 及之后,HotSpot 已经把原本放在永久代的字符串常量池、静态变量等移动到堆中,这个时候类变量则会随着 Class 对象一起存放在 Java 堆中。注:方法区是逻辑上的概念,JDK8具体实现时,字符串常量池、静态变量已经是放在堆中。</li> <li>解析:虚拟机将常量池内的符号引用替换为直接引用的过程(得到类或者字段、方法在内存中的指针或者偏移量)</li> </ul></li> <li>初始化:执行初始化方法 <clinit> ()方法的过程,真正执行类中定义的代码</li> </ul> <p>Step2:分配内存:虚拟机为新生对象分配内存 分配方式:</p> <ul> <li>指针碰撞 : <ul> <li>适用场合 :堆内存规整(即没有内存碎片)的情况下。</li> <li>原理 :用过的内存全部整合到一边,没有用过的内存放在另一边,中间有一个分界指针,只需要向着没用过的内存方向将该指针移动对象内存大小位置即可。</li> <li>使用该分配方式的 GC 收集器(标记-整理):Serial, ParNew</li> </ul></li> <li>空闲列表 : <ul> <li>适用场合 : 堆内存不规整的情况下。</li> <li>原理 :虚拟机会维护一个列表,该列表中会记录哪些内存块是可用的,在分配的时候,找一块儿足够大的内存块儿来划分给对象实例,最后更新列表记录。</li> <li>使用该分配方式的 GC 收集器(标记-清除):CMS</li> </ul></li> </ul> <p>Step3:初始化零值:内存分配完成后,虚拟机需要将分配到的堆内存空间都初始化为零值(不包括对象头)</p> <p>Step4:设置对象头:对象头包括两部分信息,第一部分用于存储<strong>对象自身的运行时数据(哈希码、GC 分代年龄、锁状态标志等等)</strong>,另一部分是<strong>类型指针,即对象指向它的类元数据的指针</strong>,虚拟机通过这个指针来确定这个对象是哪个类的实例。</p> <p>Step5:执行 init 方法:执行 <init> 方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来</p> <h3>哪些情况下,必须对类进行初始化</h3> <p>原则:只有主动去使用类(访问类的静态变量、调用静态方法)才会初始化类,有且只有一下6种情况</p> <ol> <li>Jvm遇到new、getsatic、putstatic或invokestatic这4条直接码指令时 <ul> <li>new:即当程序创建一个类的实例对象</li> <li>getsatic:即程序访问类的静态变量(不是静态常量,常量会被加载到运行时常量池)</li> <li>putstatic:即程序给类的静态变量赋值</li> <li>invokestatic:即程序调用类的静态方法</li> </ul></li> <li><strong>使用 java.lang.reflect 包的方法对类进行反射调用</strong>时(如 Class.forname(&quot;...&quot;), newInstance() 等等)</li> <li><strong>初始化一个类时,如果其父类还未初始化</strong>,则先触发该父类的初始化</li> <li><strong>当虚拟机启动时</strong>,虚拟机会先初始化主类(包含 main 方法的那个类)</li> <li>MethodHandle 和 VarHandle 可以看作是轻量级的反射调用机制,而要想使用这 2 个调用, 就必须先使用 findStaticVarHandle 来初始化要调用的类。</li> <li>当一个接口中定义了 JDK8 新加入的默认方法(被 default 关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。</li> </ol> <h3>卸载类需要满足的3个要求</h3> <ul> <li>该类的所有的实例对象都已被 GC,也就是说堆不存在该类的实例对象。</li> <li>该类没有在其他任何地方被引用</li> <li>该类的类加载器的实例已被 GC</li> </ul> <h2>双亲委托机制</h2> <h3>介绍+好处</h3> <h4>类加载器:</h4> <ol> <li><code>BootStrapClassLoader</code>(启动类加载器):负责加载 %JAVA_HOME%/lib目录下的 jar 包和类或者被 -Xbootclasspath参数指定的路径中的所有类</li> <li><code>ExtenstionClassLoader</code>(扩展类加载器):主要负责加载 %JRE_HOME%/lib/ext 目录下的 jar 包和类,或被 java.ext.dirs 系统变量所指定的路径下的 jar 包</li> <li><code>AppClassLoader</code>(应用程序类加载器):负责加载当前应用 classpath 下的所有 jar 包和类</li> </ol> <h4>介绍</h4> <p><img src="https://www.showdoc.com.cn/server/api/attachment/visitFile?sign=9fad2671a66a92361fcc28257b4eecf6&amp;file=file.png" alt="" /></p> <ul> <li>检查类是否已加载时,自底向上检查,若确认已加载过,则直接返回</li> <li>尝试加载类时,首先把该请求委派给父类加载器的<code>loadClass()</code>处理,父类加载器无法处理时,才由自己处理。当父类加载器为 null 时,会使用启动类加载器 BootstrapClassLoader 作为父类加载器。 <h4>好处</h4></li> <li>保证了 Java 程序的稳定运行,可以避免类的重复加载</li> <li>保证了 Java 的核心 API 不被篡改</li> </ul> <h3>自定义类加载器</h3> <ul> <li>需要继承 ClassLoader</li> <li>不想打破双亲委托机制:重写<code>ClassLoader</code>类中的<code>findClass()</code>方法即可</li> <li>想打破双亲委托机制:重写<code>loadClass()</code>方法</li> </ul> <h2>垃圾收集</h2> <h3>HotSpot VM中GC分类</h3> <ul> <li>部分收集 (Partial GC): <ul> <li>新生代收集(Minor GC / Young GC):只对新生代进行垃圾收集;</li> <li>老年代收集(Major GC / Old GC):只对老年代进行垃圾收集。需要注意的是 Major GC 在有的语境中也用于指代整堆收集;</li> <li>混合收集(Mixed GC):对整个新生代和部分老年代进行垃圾收集。</li> </ul></li> <li>整堆收集 (Full GC):收集整个 Java 堆和方法区。</li> </ul> <h3>空间分配担保机制</h3> <p>在发生Minor GC之前,虚拟机必须先检查老年代最大可用的连续空间,若大于新生代对象总大小或者历次晋升的平均大小,就会进行 Minor GC,否则将进行 Full GC</p> <h3>判断对象是否死亡</h3> <ul> <li>引用计数法:给对象中添加一个引用计数器,每当有一个地方引用它,计数器就加 1;当引用失效,计数器就减 1;任何时候计数器为 0 的对象就是不可能再被使用的。<strong>缺陷:</strong>无法解决对象之间相互引用的问题。</li> <li>可达性分析:当一个对象到<strong>GC Roots(虚拟机栈中引用的对象,方法区中类静态属性引用的对象,方法区中常量引用的对象,所有被同步锁持有的对象)</strong> 没有任何引用链相连的话,则证明此对象是不可用的,需要被回收。如果该对象覆盖了finalize 方法与引用链上的任何一个对象建立关联,可以避免被回收。</li> </ul> <h3>引用分类</h3> <ul> <li>强引用(Object obj=new Object()):无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。</li> <li>软引用(SoftReference):如果内存空间足够,垃圾回收器就不会回收它,如果内存空间不足了,就会回收这些对象的内存。只要垃圾回收器没有回收它,该对象就可以被程序使用。</li> <li>弱引用(WeakReference):在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。</li> <li>虚引用(PhantomReference):一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。</li> </ul> <h3>HotSpot 为什么要分为新生代和老年代</h3> <p>虚拟机根据对象存货周期不同一般将 java 堆分为新生代和老年代,这样我们就可以根据各个年代的特点选择合适的垃圾收集算法。</p> <ul> <li>新生代:每次收集都会有大量对象死去,所以可以选择”标记-复制“算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集</li> <li>老年代:对象存活几率是比较高的,而且没有额外的空间对它进行分配担保,所以我们必须选择“标记-清除”或“标记-整理”算法进行垃圾收集</li> </ul> <h3>垃圾收集器</h3> <ul> <li>Serial 收集器(新生代): <img src="https://www.showdoc.com.cn/server/api/attachment/visitFile?sign=0b0ead16518cbc7b74b1255673db35b5" alt="" /> <ul> <li>特点:单线程完成垃圾收集工作+垃圾收集时需要暂停其他的工作线程(Stop The World)</li> <li>算法:标记-复制</li> <li>老年代版本:Serial Old收集器(标记-整理)</li> </ul></li> <li>ParNew 收集器(新生代): <img src="https://www.showdoc.com.cn/server/api/attachment/visitFile?sign=ec3f7bff68a115dcc314f49625477bf7" alt="" /> <ul> <li>特点:多线程并行完成垃圾收集工作+垃圾收集时需要暂停其他的工作线程(Stop The World)</li> <li>算法:标记-复制</li> <li>现状:并入CMS,成为它专门处理新生代的组成部分</li> </ul></li> <li>Parallel Scavenge 收集器(新生代,JDK1.8 默认收集器): <ul> <li>特点:关注点是吞吐量(CPU 中用于运行用户代码的时间与 CPU 总消耗时间的比值),旨在高效率的利用CPU;可配合自适应调节策略,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整参数(新生代的大小(-Xmn)、Eden与Survivor区 的比例(-XX:SurvivorRatio)、晋升老年代对象大小(-XX:PretenureSizeThreshold)等)以提供最合适的停顿时间或者最大的吞吐量</li> <li>算法:标记-复制</li> <li>老年代版本:Parallel Old收集器(标记-整理)</li> </ul></li> <li>CMS 收集器(老年代): <img src="https://www.showdoc.com.cn/server/api/attachment/visitFile?sign=ccb668f66d24ca0a69e5cde2935fab62" alt="" /> <ul> <li>特点:关注点是响应速度,旨在获取最短回收停顿时间;它第一次实现了让垃圾收集线程与用户线程(基本上)并发执行 <ul> <li><strong>初始标记:</strong> 暂停所有的其他线程,并记录下直接与 root 相连的对象,速度很快 ;</li> <li><strong>并发标记:</strong> 同时开启 GC 和用户线程,用一个闭包结构去记录可达对象。但在这个阶段结束,这个闭包结构并不能保证包含当前所有的可达对象。因为用户线程可能会不断的更新引用域,所以 GC 线程无法保证可达性分析的实时性。所以这个算法里会跟踪记录这些发生引用更新的地方。</li> <li><strong>重新标记:</strong> 重新标记阶段就是为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段的时间稍长,远远比并发标记阶段时间短</li> <li><strong>并发清除:</strong> 开启用户线程,同时 GC 线程开始对未标记的区域做清扫。</li> </ul></li> <li>算法:标记-清除</li> <li>不足:对 CPU 资源敏感;无法处理浮动垃圾(并发清理过程中用户线程仍在运行所产生的垃圾),有可能出现“Con-current Mode Failure”失败(由于CMS运行期间所预留的内存不够满足用户线程对象分配的需要)进而导致另一次完全“Stop The World”的Full GC的产生(Serial Old);“标记-清除”算法会导致收集结束时会有大量空间碎片产生。</li> </ul></li> <li>G1收集器(Mixed:新生代+老年代) <img src="https://www.showdoc.com.cn/server/api/attachment/visitFile?sign=f587113aaafeb25ff84afdc593b9b117" alt="" /> <ul> <li>特点:关注点是在有限时间内可以尽可能高的收集效率;G1 收集器在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值(即回收所获得的空间大小以及回收所需时间的经验值)最大的 Region。 <ul> <li><strong>初始标记:</strong>暂停所有的其他线程,并记录下直接与 root 相连的对象,速度很快 </li> <li><strong>并发标记:</strong>从GC Root开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。</li> <li><strong>最终标记:</strong>重新标记阶段就是为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录</li> <li><strong>筛选回收:</strong>对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧Region的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多条收集器线程并行完成的。</li> </ul></li> <li>算法:整体是基于“标记-整理”算法,但局部(两个Region之间)又是基于“标记-复制”算法</li> <li>不足:内存占用高,额外执行负载高</li> </ul></li> </ul> <h3>从磁盘复制一个文件,再通过socket传到对方机器,发生了几次内存拷贝</h3> <p>示例代码:</p> <pre><code class="language-java">File file = new File("/path"); FileInputStream in = new FileInputStream(file); //假设一次可以读完 byte[] buf = new byte[1024]; //文件数据读取到buf数组 in.read(buf); //开启socket Socket socket = new Socket("local", 9000); OutputStream outputStream = socket.getOutputStream(); //数据写出去 outputStream.write(buf);</code></pre> <p>6次(包含堆内-堆外之间的拷贝);4次(不包含堆内-堆外之间的拷贝)</p> <ol> <li>调用系统函数read读取磁盘文件,用户态切换内核态,底层调用DMA读取<strong>磁盘文件</strong>,把内容拷贝到<strong>内核的读写缓冲区</strong>(不消耗CPU)</li> <li><strong>内核缓冲区</strong>数据拷贝到<strong>堆外内存</strong>,内核态转换为用户态,消耗CPU</li> <li><strong>堆外内存</strong>拷贝到<strong>堆内内存</strong>,消耗CPU</li> <li><strong>堆内</strong>又拷贝到<strong>堆外内存</strong>,消耗CPU</li> <li>调用socket的send,用户态切换到内核态,<strong>堆外</strong>再拷贝到<strong>套接字缓冲区</strong>,消耗CPU</li> <li>send返回,内核态切换回用户态,同时DMA把数据从<strong>套接字缓冲区</strong>拷贝到<strong>协议引擎</strong>进行发送</li> </ol> <h1>5.分布式</h1> <h2>理论,算法和协议</h2> <h3>CAP定理</h3> <p>在理论计算机科学中,CAP 定理(CAP theorem)指出对于一个分布式系统来说,当设计读写操作时,只能同时满足以下三点中的两个:</p> <ul> <li><strong>一致性(Consistency)</strong> : 对某个指定的客户端来说,读操作能返回最新的写操作。对于数据分布在不同节点上的数据上来说,如果在某个节点更新了数据,那么在其他节点如果都能读取到这个最新的数据,那么就称为强一致,如果有某个节点没有读取到,那就是分布式不一致。</li> <li><strong>可用性(Availability)</strong>: 非故障的节点在合理的时间内返回合理的响应(不是错误或者超时的响应)。</li> <li><strong>分区容错性(Partition tolerance)</strong> : 分布式系统出现网络分区(因网络故障,网络被分为几个区域)的时候,仍然能够对外提供服务。 <strong> CAP 理论中分区容错性 P 是一定要满足的,在此基础上,只能满足可用性 A 或者一致性 C。</strong></li> </ul> <h3>BASE理论</h3> <p>CAP理论延伸:AP 方案只是在系统发生分区的时候放弃一致性,而不是永远放弃一致性。在分区故障恢复后,系统应该达到最终一致性。</p> <ul> <li>Basically Available(基本可用):分布式系统在出现不可预知故障的时候,允许损失部分可用性。但是,这绝不等价于系统不可用。 <ul> <li>响应时间上的损失: 正常情况下,处理用户请求需要 0.5s 返回结果,但是由于系统出现故障,处理用户请求的时间变为 3 s。</li> <li>系统功能上的损失:正常情况下,用户可以使用系统的全部功能,但是由于系统访问量突然剧增,系统的部分非核心功能无法使用。</li> </ul></li> <li>Soft-state(软状态):允许系统中的数据存在中间状态(CAP 理论中的数据不一致),并认为该中间状态的存在不会影响系统的整体可用性,即允许系统在不同节点的数据副本之间进行数据同步的过程存在延时</li> <li>Eventually Consistent(最终一致性):系统中所有的数据副本,在经过一段时间的同步后,最终能够达到一个一致的状态。</li> </ul> <h3>2PC(两阶段提交)</h3> <p>在两阶段提交中,主要涉及到两个角色,分别是协调者和参与者。 <img src="https://www.showdoc.com.cn/server/api/attachment/visitFile?sign=966ee2ebaa243e020ab735abd31037dd&amp;file=file.png" alt="" /></p> <ul> <li>第一阶段(准备阶段):当要执行一个分布式事务的时候,事务发起者首先向协调者发起事务请求,然后协调者会给所有参与者发送 <code>prepare</code> 请求(其中包括事务内容)告诉参与者你们需要执行事务了,如果能执行我发的事务内容那么就先执行但不提交,执行后请给我回复。然后参与者收到 <code>prepare</code> 消息后,他们会开始执行事务(但不提交),并将 <code>Undo</code> 和 <code>Redo</code> 信息记入事务日志中,之后参与者就向协调者反馈是否准备好了。</li> <li>第二阶段(提交阶段):第二阶段主要是协调者根据参与者反馈的情况来决定接下来是否可以进行事务的提交操作,即提交事务或者回滚事务。</li> </ul> <p>问题:</p> <ul> <li><strong>单点故障问题</strong>,如果协调者挂了那么整个系统都处于不可用的状态了。</li> <li><strong>阻塞问题</strong>,即当协调者发送 <code>prepare</code> 请求,参与者收到之后如果能处理那么它将会进行事务的处理但并不提交,这个时候会一直占用着资源不释放,如果此时协调者挂了,那么这些资源都不会再释放了,这会极大影响性能。</li> <li><strong>数据不一致问题</strong>,比如当第二阶段,协调者只发送了一部分的 <code>commit</code> 请求就挂了,那么也就意味着,收到消息的参与者会进行事务的提交,而后面没收到的则不会进行事务提交,那么这时候就会产生数据不一致性问题。</li> </ul> <h3>3PC(三阶段提交)</h3> <p><img src="https://www.showdoc.com.cn/server/api/attachment/visitFile?sign=a7ea996da12d3cd7e90b96b830c14230&amp;file=file.png" alt="" /></p> <ol> <li><strong>CanCommit(询问)阶段</strong>:协调者向所有参与者发送 <code>CanCommit</code> 请求,参与者收到请求后会根据自身情况查看是否能执行事务,如果可以则返回 YES 响应并进入预备状态,否则返回 NO 。</li> <li><strong>PreCommit(准备)阶段</strong>:协调者根据参与者返回的响应来决定是否可以进行下面的 <code>PreCommit</code> 操作。如果上面参与者返回的都是 YES,那么协调者将向所有参与者发送 <code>PreCommit</code> 预提交请求,<strong>参与者收到预提交请求后,会进行事务的执行操作,并将 <code>Undo</code> 和 <code>Redo</code> 信息写入事务日志中</strong> ,最后如果参与者顺利执行了事务则给协调者返回成功的响应。如果在第一阶段协调者收到了 <strong>任何一个 NO</strong> 的信息,或者 <strong>在一定时间内</strong> 并没有收到全部的参与者的响应,那么就会中断事务,它会向所有参与者发送中断请求(abort),参与者收到中断请求之后会立即中断事务,或者在一定时间内没有收到协调者的请求,它也会中断事务。</li> <li><strong>DoCommit(提交)阶段</strong>:这个阶段其实和 <code>2PC</code> 的第二阶段差不多,如果协调者收到了所有参与者在 <code>PreCommit</code> 阶段的 YES 响应,那么协调者将会给所有参与者发送 <code>DoCommit</code> 请求,<strong>参与者收到 <code>DoCommit</code> 请求后则会进行事务的提交工作</strong>,完成后则会给协调者返回响应,协调者收到所有参与者返回的事务提交成功的响应之后则完成事务。若协调者在 <code>PreCommit</code> 阶段 <strong>收到了任何一个 NO 或者在一定时间内没有收到所有参与者的响应</strong> ,那么就会进行中断请求的发送,参与者收到中断请求后则会 <strong>通过上面记录的回滚日志</strong> 来进行事务的回滚操作,并向协调者反馈回滚状况,协调者收到参与者返回的消息后,中断事务。</li> </ol> <p>好处:</p> <ul> <li>协调者在指定时间内为收到全部的确认消息则进行事务中断的处理,这样能 <strong>减少同步阻塞的时间</strong> 。</li> <li><strong><code>3PC</code> 在 <code>DoCommit</code> 阶段参与者如未收到协调者发送的提交事务的请求,它会在一定时间内进行事务的提交</strong>。是因为这个时候我们肯定<strong>保证了在第一阶段所有的参与者全部返回了可以执行事务的响应</strong>,这个时候我们有理由<strong>相信其他系统都能进行事务的执行和提交</strong>,所以<strong>不管</strong>协调者有没有发消息给参与者,进入第三阶段参与者都会进行事务的提交操作。</li> </ul> <h3>Paxos算法</h3> <p>Paxos 算法是基于消息传递且具有高度容错特性的一致性算法,是目前公认的解决分布式一致性问题最有效的算法之一,其解决的问题就是在分布式系统中如何就某个值(决议)达成一致 。在 Paxos 中主要有三个角色,分别为 Proposer提案者、Acceptor表决者、Learner学习者。</p> <ol> <li>prepare 阶段 <img src="https://www.showdoc.com.cn/server/api/attachment/visitFile?sign=844e538148b91e814686b683de046dbb&amp;file=file.png" alt="" /> <ul> <li><code>Proposer提案者</code>:负责提出 <code>proposal</code>,每个提案者在提出提案时都会首先获取到一个 <strong>具有全局唯一性的、递增的提案编号N</strong>,即在整个集群中是唯一的编号 N,然后将该编号赋予其要提出的提案,在<strong>第一阶段是只将提案编号发送给所有的表决者</strong>。</li> <li><code>Acceptor表决者</code>:每个表决者在 <code>accept</code> 某提案后,会将该提案编号N记录在本地,这样每个表决者中保存的已经被 accept 的提案中会存在一个<strong>编号最大的提案</strong>,其编号假设为 <code>maxN</code>。每个表决者仅会 <code>accept</code> 编号大于自己本地 <code>maxN</code> 的提案,在批准提案时表决者会将以前接受过的最大编号的提案作为响应反馈给 <code>Proposer</code> 。</li> </ul></li> <li>accept 阶段 <img src="https://www.showdoc.com.cn/server/api/attachment/visitFile?sign=f1a3ff234e7ea48cb990e512a23ba7a9&amp;file=file.png" alt="" /> 当一个提案被 <code>Proposer</code> 提出后,如果 <code>Proposer</code> 收到了超过半数的 <code>Acceptor</code> 的批准(<code>Proposer</code> 本身同意),那么此时 <code>Proposer</code> 会给所有的 <code>Acceptor</code> 发送真正的提案(你可以理解为第一阶段为试探),这个时候 <code>Proposer</code> 就会发送提案的内容和提案编号。 表决者收到提案请求后会再次比较本身已经批准过的最大提案编号和该提案编号,如果该提案编号 <strong>大于等于</strong> 已经批准过的最大提案编号,那么就 <code>accept</code> 该提案(此时执行提案内容但不提交),随后将情况返回给 <code>Proposer</code> 。如果不满足则不回应或者返回 NO 。 <img src="https://www.showdoc.com.cn/server/api/attachment/visitFile?sign=34b865fc6f97c0412aab29cecc975853&amp;file=file.png" alt="" /> 当 <code>Proposer</code> 收到超过半数的 <code>accept</code> ,那么它这个时候会向所有的 <code>acceptor</code> 发送提案的提交请求。需要注意的是,因为上述仅仅是超过半数的 <code>acceptor</code> 批准执行了该提案内容,其他没有批准的并没有执行该提案内容,所以这个时候需要<strong>向未批准的 <code>acceptor</code> 发送提案内容和提案编号并让它无条件执行和提交</strong>,而对于前面已经批准过该提案的 <code>acceptor</code> 来说 <strong>仅仅需要发送该提案的编号</strong> ,让 <code>acceptor</code> 执行提交就行了。</li> </ol> <h2>RPC</h2> <h3>为什么要RPC</h3> <ul> <li>两个不同的服务器上的服务提供的方法不在一个内存空间,所以,需要通过网络编程才能传递方法调用所需要的参数。并且,方法调用的结果也需要通过网络编程来接收。</li> <li>通过 RPC 可以帮助我们调用远程计算机上某个服务的方法,这个过程就像调用本地方法一样简单</li> </ul> <h3>RPC原理</h3> <p><img src="https://www.showdoc.com.cn/server/api/attachment/visitFile?sign=4c9dc3622f7c5be6c01568b3b30ba3c4&amp;file=file.png" alt="" /></p> <ol> <li>服务消费端(client)以本地调用的方式调用远程服务;</li> <li>客户端 Stub(client stub) 接收到调用后负责将方法、参数等组装成能够进行网络传输的消息体(序列化):<code>RpcRequest</code>;</li> <li>客户端 Stub(client stub) 找到远程服务的地址,并将消息发送到服务提供端;</li> <li>服务端 Stub(桩)收到消息将消息反序列化为Java对象: <code>RpcRequest</code>;</li> <li>服务端 Stub(桩)根据<code>RpcRequest</code>中的类、方法、方法参数等信息调用本地的方法;</li> <li>服务端 Stub(桩)得到方法执行结果并将组装成能够进行网络传输的消息体:<code>RpcResponse</code>(序列化)发送至消费方;</li> <li>客户端 Stub(client stub)接收到消息并将消息反序列化为Java对象:<code>RpcResponse</code> ,这样也就得到了最终结果。over!</li> </ol> <h2>ZooKeeper</h2> <p><strong>ZooKeeper 为我们提供了高可用、高性能、稳定的分布式数据一致性解决方案,通常被用于实现诸如数据发布/订阅、负载均衡、命名服务、分布式协调/通知、集群管理、Master 选举、分布式锁和分布式队列等功能。</strong>另外,<strong>ZooKeeper 将数据保存在内存中,性能是非常棒的。 在“读”多于“写”的应用程序中尤其地高性能,因为“写”会导致所有的服务器间同步状态。(“读”多于“写”是协调服务的典型场景)。</strong></p> <h3>ZooKeeper典型应用场景</h3> <ol> <li><strong>分布式锁</strong> : 通过创建唯一节点获得分布式锁,当获得锁的一方执行完相关代码或者是挂掉之后就释放锁。</li> <li><strong>命名服务</strong> :可以通过 ZooKeeper 的顺序节点生成全局唯一 ID</li> <li><strong>数据发布/订阅 </strong>:通过 Watcher 机制 可以很方便地实现数据发布/订阅。当你将数据发布到 ZooKeeper 被监听的节点上,其他机器可通过监听 ZooKeeper 上节点的变化来实现配置的动态更新。</li> </ol> <p>Data Model(数据模型): <img src="https://www.showdoc.com.cn/server/api/attachment/visitFile?sign=86ea462ce13cee370fd4add409c4d919&amp;file=file.png" alt="" /> ZooKeeper 数据模型采用层次化的多叉树形结构,每个节点上都可以存储数据,这些数据可以是数字、字符串或者是二级制序列。并且。每个节点还可以拥有 N 个子节点,最上层是根节点以“/”来代表。每个数据节点在 ZooKeeper 中被称为 znode,它是 ZooKeeper 中数据的最小单元。并且,每个 znode 都一个唯一的路径标识。</p> <p>znode4种类型</p> <ul> <li><strong>持久(PERSISTENT)节点</strong> :一旦创建就一直存在即使 ZooKeeper 集群宕机,直到将其删除。</li> <li><strong>临时(EPHEMERAL)节点</strong> :临时节点的生命周期是与 <strong>客户端会话(session)</strong> 绑定的,<strong>会话消失则节点消失</strong> 。并且,<strong>临时节点只能做叶子节点</strong> ,不能创建子节点。</li> <li><strong>持久顺序(PERSISTENT_SEQUENTIAL)节点</strong> :除了具有持久(PERSISTENT)节点的特性之外, 子节点的名称还具有顺序性。比如 <code>/node1/app0000000001</code> 、<code>/node1/app0000000002</code> 。</li> <li><strong>临时顺序(EPHEMERAL_SEQUENTIAL)节点</strong> :除了具备临时(EPHEMERAL)节点的特性之外,子节点的名称还具有顺序性。</li> </ul> <p>ACL(权限控制) ZooKeeper 采用 ACL(AccessControlLists)策略来进行权限控制,类似于 UNIX 文件系统的权限控制。对于 znode 操作的权限,ZooKeeper 提供了以下 5 种:</p> <ul> <li>CREATE : 能创建子节点</li> <li>READ :能获取节点数据和列出其子节点</li> <li>WRITE : 能设置/更新节点数据</li> <li>DELETE : 能删除子节点</li> <li>ADMIN : 能设置节点 ACL 的权限</li> </ul> <p>Watcher(事件监听器) Watcher(事件监听器),是 ZooKeeper 中的一个很重要的特性。ZooKeeper 允许用户在指定节点上注册一些 Watcher,并且在一些特定事件触发的时候,ZooKeeper 服务端会将事件通知到感兴趣的客户端上去,该机制是 ZooKeeper 实现分布式协调服务的重要特性。 <img src="https://www.showdoc.com.cn/server/api/attachment/visitFile?sign=e3dedd8b6aa254a153de0fe9a5731837&amp;file=file.png" alt="" /></p> <h3>ZooKeeper集群角色+Leader选举</h3> <p><img src="https://www.showdoc.com.cn/server/api/attachment/visitFile?sign=513928b623b786638bde742ebf6b6e13&amp;file=file.png" alt="" /> ZooKeeper 集群中的所有机器通过一个 <strong>Leader 选举过程</strong> 来选定一台称为 “<strong>Leader</strong>” 的机器,Leader 既可以为客户端提供写服务又能提供读服务。除了 Leader 外,<strong>Follower</strong> 和 <strong>Observer</strong> 都只能提供读服务。Follower 和 Observer 唯一的区别在于 Observer 机器不参与 Leader 的选举过程,也不参与写操作的“过半写成功”策略,因此 Observer 机器可以在不影响写性能的情况下提升集群的读性能</p> <p>Leader选举过程大致是这样的:</p> <ol> <li><strong>Leader election(选举阶段)</strong>:节点在一开始都处于选举阶段,只要有一个节点得到超半数节点的票数,它就可以当选准 leader。</li> <li><strong>Discovery(发现阶段)</strong> :在这个阶段,followers 跟准 leader 进行通信,同步 followers 最近接收的事务提议。</li> <li><strong>Synchronization(同步阶段)</strong> :同步阶段主要是利用 leader 前一阶段获得的最新提议历史,同步集群中所有的副本。同步完成之后 准 leader 才会成为真正的 leader。</li> <li><strong>Broadcast(广播阶段)</strong> :到了这个阶段,ZooKeeper 集群才能正式对外提供事务服务,并且 leader 可以进行消息广播。同时如果有新的节点加入,还需要对新节点进行同步。</li> </ol> <p>初始化选举:</p> <ol> <li>假设我们集群中有3台机器,那也就意味着我们需要两台以上同意(超过半数)。比如这个时候我们启动了 <code>server1</code> ,它会首先 <strong>投票给自己</strong> ,投票内容为服务器的 <code>myid</code> 和 <code>ZXID</code> ,因为初始化所以 <code>ZXID</code> 都为0,此时 <code>server1</code> 发出的投票为 (1,0)。但此时 <code>server1</code> 的投票仅为1,所以不能作为 <code>Leader</code> ,此时还在选举阶段所以整个集群处于 <strong><code>Looking</code> 状态</strong>。</li> <li>接着 <code>server2</code> 启动了,它首先也会将投票选给自己(2,0),并将投票信息广播出去(<code>server1</code>也会,只是它那时没有其他的服务器了),<code>server1</code> 在收到 <code>server2</code> 的投票信息后会将投票信息与自己的作比较。<strong>首先它会比较 <code>ZXID</code> ,<code>ZXID</code> 大的优先为 <code>Leader</code>,如果相同则比较 <code>myid</code>,<code>myid</code> 大的优先作为 <code>Leader</code></strong>。所以此时<code>server1</code> 发现 <code>server2</code> 更适合做 <code>Leader</code>,它就会将自己的投票信息更改为(2,0)然后再广播出去,之后<code>server2</code> 收到之后发现和自己的一样无需做更改,并且自己的 <strong>投票已经超过半数</strong> ,则 <strong>确定 <code>server2</code> 为 <code>Leader</code></strong>,<code>server1</code> 也会将自己服务器设置为 <code>Following</code> 变为 <code>Follower</code>。整个服务器就从 <code>Looking</code> 变为了正常状态。</li> <li>当 <code>server3</code> 启动发现集群没有处于 <code>Looking</code> 状态时,它会直接以 <code>Follower</code> 的身份加入集群。</li> </ol> <p>Leader挂掉后重新选举:</p> <ol> <li>首先毫无疑问的是剩下的两个 <code>Follower</code> 会将自己的状态 <strong>从 <code>Following</code> 变为 <code>Looking</code> 状态</strong> ,然后每个 <code>server</code> 会向初始化投票一样首先给自己投票(这不过这里的 <code>zxid</code> 可能不是0了,这里为了方便随便取个数字)。</li> <li>假设 <code>server1</code> 给自己投票为(1,99),然后广播给其他 <code>server</code>,<code>server3</code> 首先也会给自己投票(3,95),然后也广播给其他 <code>server</code>。<code>server1</code> 和 <code>server3</code> 此时会收到彼此的投票信息,和一开始选举一样,他们也会比较自己的投票和收到的投票(<code>zxid</code> 大的优先,如果相同那么就 <code>myid</code> 大的优先)。这个时候 <code>server1</code> 收到了 <code>server3</code> 的投票发现没自己的合适故不变,<code>server3</code> 收到 <code>server1</code> 的投票结果后发现比自己的合适于是更改投票为(1,99)然后广播出去,最后 <code>server1</code> 收到了发现自己的投票已经超过半数就把自己设为 <code>Leader</code>,<code>server3</code> 也随之变为 <code>Follower</code>。 崩溃恢复的两种情况: <ul> <li>确保已经被Leader提交的提案最终能够被所有的Follower提交(<code>zxid</code>更大放入节点优先当选): <img src="https://www.showdoc.com.cn/server/api/attachment/visitFile?sign=8968b1159469b228a4a893088f9076be&amp;file=file.png" alt="" />假设 <code>Leader (server2)</code> 发送 <code>commit</code> 请求(忘了请看上面的消息广播模式),他发送给了 <code>server3</code>,然后要发给 <code>server1</code> 的时候突然挂了。这个时候重新选举的时候我们如果把 <code>server1</code> 作为 <code>Leader</code> 的话,那么肯定会产生数据不一致性,因为 <code>server3</code> 肯定会提交刚刚 <code>server2</code> 发送的 <code>commit</code> 请求的提案,而 <code>server1</code> 根本没收到所以会丢弃。这个时候 server1 已经不可能成为 Leader 了,因为 server1 和 server3 进行投票选举的时候会比较 ZXID ,而此时 server3 的 ZXID 肯定比 server1 的大了。(不理解可以看前面的选举算法)</li> <li>跳过那些已经被丢弃的提案: <img src="https://www.showdoc.com.cn/server/api/attachment/visitFile?sign=2f37db94ecd0d30fbd94e047606462f9&amp;file=file.png" alt="" /> 假设 <code>Leader (server2)</code> 此时同意了提案N1,自身提交了这个事务并且要发送给所有 <code>Follower</code> 要 <code>commit</code> 的请求,却在这个时候挂了,此时肯定要重新进行 <code>Leader</code> 的选举,比如说此时选 <code>server1</code> 为 <code>Leader</code> (这无所谓)。但是过了一会,这个 <strong>挂掉的 <code>Leader</code> 又重新恢复了</strong> ,此时它肯定会作为 <code>Follower</code> 的身份进入集群中,需要注意的是刚刚 <code>server2</code> 已经同意提交了提案N1,但其他 <code>server</code> 并没有收到它的 <code>commit</code> 信息,所以其他 <code>server</code> 不可能再提交这个提案N1了,这样就会出现数据不一致性问题了,所以 <strong>该提案N1最终需要被抛弃掉</strong> 。</li> </ul></li> </ol> <h3>Zookeeper典型应用场景:</h3> <ol> <li>选主:利用ZooKeeper的强一致性,在高并发的情况下保证节点创建的全局唯一性(即无法重复创建同样的节点) <ul> <li>让多个客户端创建一个指定的节点,创建成功的就是master</li> <li>watcher机制:让其他salve节点监听master节点的状态,可以通过节点是否已经失去连接来判断master是否挂了</li> </ul></li> <li>分布式锁:利用zk在高并发的情况下<strong>临时节点</strong>创建的全局唯一性 <ul> <li>获取锁:让多个客户端同时创建一个临时节点,创建成功就说明获取到了锁</li> <li>锁释放:没有获取到锁的客户端创建一个 watcher 进行节点状态的监听,如果这个互斥锁被释放了(可能获取锁的客户端宕机了,或者那个客户端主动释放了锁)可以调用回调函数重新获得锁。</li> <li>共享锁(读锁):利用有序节点,当你是读请求(要获取共享锁)的话,如果 没有比自己更小的节点,或比自己小的节点都是读请求 ,则可以获取到读锁,然后就可以开始读了。若比自己小的节点中有写请求 ,则当前客户端无法获取到读锁,只能等待前面的写请求完成。</li> <li>独占锁(写锁):如果你是写请求(获取独占锁),若 没有比自己更小的节点 ,则表示当前客户端可以直接获取到写锁,对数据进行修改。若发现 有比自己更小的节点,无论是读操作还是写操作,当前客户端都无法获取到写锁 ,等待所有前面的操作完成。</li> </ul></li> <li>命名服务:zookeeper 是通过 树形结构 来存储数据节点的,那也就是说,对于每个节点的 全路径,它必定是唯一的,我们可以使用节点的全路径作为命名方式了</li> <li>集群管理和注册中心 <ul> <li>集群管理: <img src="https://www.showdoc.com.cn/server/api/attachment/visitFile?sign=5ceb4e1bef00108d0e18097111325fb5&amp;file=file.png" alt="" />集群中有多少台机器,每台机器的运行状态,对集群机器进行上下线操作;zk天然支持的watcher和临时节点可以很好地实现这些需求。可以为每条机器创建临时节点,并监控其父节点,如果子节点列表有变动(我们可能创建删除了临时节点),那么我们可以使用在其父节点绑定的 watcher 进行状态监控和回调。</li> </ul></li> </ol> <ul> <li>注册中心: <img src="https://www.showdoc.com.cn/server/api/attachment/visitFile?sign=4b4df3be32da772128bf0ef6e971beaa&amp;file=file.png" alt="" /> 让 <strong>服务提供者</strong> 在 <code>zookeeper</code> 中创建一个临时节点并且将自己的 <code>ip、port、调用方式</code> 写入节点,当 <strong>服务消费者</strong> 需要进行调用的时候会 <strong>通过注册中心找到相应的服务的地址列表(IP端口什么的)</strong> ,并缓存到本地(方便以后调用),当消费者调用服务时,不会再去请求注册中心,而是直接通过负载均衡算法从地址列表中取一个服务提供者的服务器调用服务。当服务提供者的某台服务器宕机或下线时,相应的地址会从服务提供者地址列表中移除。同时,注册中心会将新的服务地址列表发送给服务消费者的机器并缓存在消费者本机(当然你可以让消费者进行节点监听,我记得 <code>Eureka</code> 会先试错,然后再更新)。</li> </ul> <h2>分布式事务</h2> <h3>分布式ID</h3> <p>要求</p> <ul> <li><strong>全局唯一</strong> :ID 的全局唯一性肯定是首先要满足的!</li> <li>高性能 : 分布式 ID 的生成速度要快,对本地资源消耗要小。</li> <li>高可用 :生成分布式 ID 的服务要保证可用性无限接近于 100%。</li> <li>方便易用 :拿来即用,使用方便,快速接入!</li> <li><strong>安全</strong> :ID 中不包含敏感信息。</li> <li><strong>有序递增</strong> :如果要把 ID 存放在数据库的话,ID 的有序性可以提升数据库写入速度。并且,很多时候 ,我们还很有可能会直接通过 ID 来进行排序。</li> <li>有具体的业务含义 :生成的 ID 如果能有具体的业务含义,可以让定位问题以及开发更透明化(通过 ID 就能确定是哪个业务)。 独立部署 :也就是分布式系统单独有一个发号器服务,专门用来生成分布式 ID。 常见方案</li> <li>Redis:通过Redis的incr命令即可实现对id原子顺序递增 <ul> <li>优点:性能不错并且生成的 ID 是有序递增的</li> <li>缺点:ID 没有具体业务含义、安全问题(比如根据订单 ID 的递增规律就能推算出每天的订单量,商业机密啊! )、每次获取 ID 都要访问一次数据库(增加了对数据库的压力,获取速度也慢)</li> </ul></li> <li>Snowflake(雪花算法):Snowflake 由 64 bit 的二进制数字组成,这 64bit 的二进制被分成了几部分,每一部分存储的数据都有特定的含义: <ul> <li><strong>第 0 位</strong>: 符号位(标识正负),始终为 0,没有用,不用管。</li> <li><strong>第 1~41 位</strong> :一共 41 位,用来表示时间戳,单位是毫秒,可以支撑 2 ^41 毫秒(约 69 年)</li> <li><strong>第 42~52 位</strong> :一共 10 位,一般来说,前 5 位表示机房 ID,后 5 位表示机器 ID(实际项目中可以根据实际情况调整)。这样就可以区分不同集群/机房的节点。</li> <li><strong>第 53~64 位</strong> :一共 12 位,用来表示序列号。 序列号为自增值,代表单台机器每毫秒能够产生的最大 ID 数(2^12 = 4096),也就是说单台机器每毫秒最多可以生成 4096 个 唯一 ID。</li> <li>优点:生成速度比较快、生成的 ID 有序递增、比较灵活(可以对 Snowflake 算法进行简单的改造比如加入业务 ID)</li> <li>缺点:需要解决重复 ID 问题(依赖时间,当机器时间不对的情况下,可能导致会产生重复 ID)</li> </ul></li> </ul> <h3>分布式事务</h3> <p>概念+目标:微服务架构下,一个系统被拆分为多个小的微服务。每个微服务都可能存在不同的机器上,并且每个微服务可能都有一个单独的数据库供自己使用。分布式事务的终极目标就是保证系统中多个相关联的数据库中的数据的一致性! 分类:</p> <ul> <li>柔性事务:追求最终一致性。如TCC、Saga、MQ事务。</li> <li>刚性事务:追求强一致性。如2PC、3PC。</li> </ul> <p>TCC补偿事务(Try-Confirm-Cancel) <img src="https://www.showdoc.com.cn/server/api/attachment/visitFile?sign=44a5be816b713b085de2f59be662a884&amp;file=file.png" alt="" /></p> <ol> <li>Try(尝试)阶段 : 尝试执行。完成业务检查,并预留好必需的业务资源。--在转账场景下,Try 要做的事情是就是检查账户余额是否充足,预留的资源就是转账资金。</li> <li>Confirm(确认)阶段 :确认执行。当所有事务参与者的 Try 阶段执行成功就会执行 Confirm ,Confirm 阶段会处理 Try 阶段预留的业务资源。否则,就会执行 Cancel 。--如果 Try 阶段执行成功的话,Confirm 阶段就会执行真正的扣钱操作。</li> <li>Cancel(取消)阶段 :取消执行,释放 Try 阶段预留的业务资源。--释放 Try 阶段预留的转账资金。</li> </ol> <p>MQ事务 允许事件流应用将消费、处理和生产消息整个过程定义为一个原子操作 <img src="https://www.showdoc.com.cn/server/api/attachment/visitFile?sign=dc9ea57c4a110b4daa8b13890a52742b&amp;file=file.png" alt="" /></p> <ol> <li>MQ 发送方(比如物流服务)在消息队列上开启一个事务,然后发送一个“半消息”给 MQ Server/Broker。事务提交之前,半消息对于 MQ 订阅方/消费者(比如第三方通知服务)不可见 </li> <li>“半消息”发送成功的话,MQ 发送方就开始执行本地事务。 </li> <li>MQ 发送方的本地事务执行成功的话,“半消息”变成正常消息,可以正常被消费。MQ 发送方的本地事务执行失败的话,会直接回滚。 问题: <ul> <li>如果 MQ 发送方提交或者回滚事务消息时失败怎么办:RocketMQ 中的 Broker 会定期去 MQ 发送方上反查这个事务的本地事务的执行情况,并根据反查结果决定提交或者回滚这个事务。</li> <li>如果正常消息没有被正确消费怎么办呢:消息消费失败的话,RocketMQ 会自动进行消费重试。如果超过最大重试次数这个消息还是没有正确消费,RocketMQ 就会认为这个消息有问题,然后将其放到 死信队列。</li> </ul></li> </ol> <h1>6. 微服务</h1> <h2>熔断和降级</h2> <h3>熔断和降级的区别</h3> <ul> <li>降级:服务降级是指当服务器压力剧增的情况下,根据当前业务情况及流量对一些服务和页面有策略的降级,以此释放服务器资源以保证核心任务的正常运行。</li> <li>熔断:服务熔断是应对雪崩效应的一种微服务链路保护机制。当调用链路的某个微服务不可用或者响应时间太长时,会进行服务熔断,不再有该节点微服务的调用,快速返回错误的响应信息。当检测到该节点微服务调用响应正常后,恢复调用链路。</li> <li>区别:<strong>降级的目的在于应对系统自身的故障,降级的对象是自身;而熔断的目的在于应对当前系统依赖的外部系统或者第三方系统的故障,熔断的对象的外部服务</strong></li> </ul> <h3>熔断的实现思路</h3> <ul> <li>熔断配置:失败事件的阈值、执行器配置等;失败事件包括超时、异常事件;</li> <li>断路器:在失败事件达到指定阈值时,将依赖服务的调用熔断,采取降级策略;采取某种规则从熔断状态恢复到正常状态;</li> <li>事件统计:在特定时间窗口内,统计依赖服务调用成功事件、失败事件(失败次数、失败比率等)、异常事件等;</li> <li>事件机制:连接事件统计、断路器状态机、服务调用与熔断降级,串联成完整的流程。</li> </ul> <h3>Hystrix</h3> <ul> <li>服务降级: <ul> <li>使用:在消费方主逻辑接口方法上@HystrixCommand(fallbackMethod=&quot;...&quot;)</li> <li>效果:消费方主逻辑触发超时异常时,就通过HystrixCommand注解中指定的降级逻辑进行执行</li> <li>作用:对自身服务起到基础的保护,同时还为异常情况提供了自动的服务降级切换机制</li> </ul></li> <li>线程隔离:为每一个Hystrix命令创建一个独立的线程池 <ul> <li>使用:@HystrixCommand将某个函数包装成了Hystrix命令,这里除了定义服务降级之外,还会自动的为这个函数实现调用的隔离</li> <li>效果:就算某个在Hystrix命令包装下的依赖服务出现延迟过高的情况,也只是对该依赖服务的调用产生影响,而不会拖慢其他的服务</li> <li>作用: <ul> <li>应用自身得到完全的保护,不会受不可控的依赖服务影响。即便给依赖服务分配的线程池被填满,也不会影响应用自身的其余部分。</li> <li>可以有效的降低接入新服务的风险。如果新服务接入后运行不稳定或存在问题,完全不会影响到应用其他的请求。</li> <li>当依赖的服务从失效恢复正常后,它的线程池会被清理并且能够马上恢复健康的服务,相比之下容器级别的清理恢复速度要慢得多。</li> <li>当依赖的服务出现配置错误的时候,线程池会快速的反应出此问题(通过失败次数、延迟、超时、拒绝等指标的增加情况)。同时,我们可以在不影响应用功能的情况下通过实时的动态属性刷新(后续会通过Spring Cloud Config与Spring Cloud Bus的联合使用来介绍)来处理它。</li> <li>当依赖的服务因实现机制调整等原因造成其性能出现很大变化的时候,此时线程池的监控指标信息会反映出这样的变化。同时,我们也可以通过实时动态刷新自身应用对依赖服务的阈值进行调整以适应依赖方的改变。</li> <li>除了上面通过线程池隔离服务发挥的优点之外,每个专有线程池都提供了内置的并发实现,可以利用它为同步的依赖服务构建异步的访问。</li> </ul></li> </ul></li> <li>服务熔断(断路器):在服务消费端的服务降级逻辑因为hystrix命令调用依赖服务超时,触发了降级逻辑,但是即使这样,受限于Hystrix超时时间的问题,我们的调用依然很有可能产生堆积,<strong>这个时候就需要断路器发挥作用,不再调用主逻辑,而是直接调用降级逻辑,减少响应延迟。</strong> <ul> <li>三个重要参数(在某个时间段内,请求总数达到某阈值并且错误请求占比达到某阈值): <ul> <li>快照时间窗:断路器确定是否打开需要统计一些请求和错误数据,而统计的时间范围就是快照时间窗,默认为最近的10秒。</li> <li>请求总数下限:在快照时间窗内,必须满足请求总数下限才有资格根据错误比例进行熔断。默认为20,意味着在10秒内,如果该hystrix命令的调用此时不足20次,即时所有的请求都超时或其他原因失败,断路器都不会打开。</li> <li>错误百分比下限:当请求总数在快照时间窗内超过了下限,比如发生了30次调用,如果在这30次调用中,有16次发生了超时异常,也就是超过50%的错误百分比,在默认设定50%下限情况下,这时候就会将断路器打开。</li> </ul></li> <li>休眠时间窗:当断路器打开,对主逻辑进行熔断之后,hystrix会启动一个休眠时间窗,在这个时间窗内,降级逻辑是临时的成为主逻辑(OPEN),当休眠时间窗到期,断路器将进入半开状态(HALF-OPEN),释放一次请求到原来的主逻辑上,如果此次请求正常返回,那么断路器将继续闭合(CLOSE),主逻辑恢复,如果这次请求依然有问题,断路器继续进入打开状态(OPEN),休眠时间窗重新计时。</li> </ul></li> </ul> <h1>7.安全</h1> <h2>授权登录</h2> <h3>RBAC</h3> <p>基于角色的权限访问控制(Role-Based Access Control)。一个用户可以拥有若干角色,每一个角色又可以被分配若干权限,这样就构造成“用户-角色-权限” 的授权模型。在这种模型中,用户与角色、角色与权限之间构成了多对多的关系,如下图: <img src="https://www.showdoc.com.cn/server/api/attachment/visitFile?sign=09bbeebf5df93c3bd93b88aa3ac24d43&amp;file=file.png" alt="" /></p> <h3>Cookie和Session的区别</h3> <ul> <li>Cookie:存储在客户端,保存用户信息,如用户名、密码、设置、sessionId或token等信息,易被篡改,一般加密</li> <li>Session:存储在服务器端,保存用户的登录信息、操作等信息,更安全</li> </ul> <h3>JWT概念</h3> <ul> <li>Token:一种不需要服务器端存放Session信息就能实现身份验证的认证方式</li> <li>JWT(JSON Web Token):本质上是一段签名的JSON格式的数据。由于是带有签名的,因此接受者可以验证它的真实性。 <ul> <li>三部分:Header、Payload和Signature=HMACSHA256(base64UrlEncode(header)+&quot;.&quot;+base64UrlEncode(payload),存在服务端的secret)</li> <li>服务端拿到JWT之后,会解析出其中包含的Header、Payload和Signature会根据Header、Payload、密钥再次生成一个 Signature。拿新生成的 Signature 和 JWT 中的 Signature 作对比,如果一样就说明 Header 和 Payload 没有被修改。</li> <li><strong>JWT 安全的核心在于签名,签名安全的核心在密钥。</strong></li> </ul></li> </ul> <h3>为什么Cookie无法防止CSRF攻击,而Token可以?</h3> <ul> <li>CSRF(Cross-site request forgery)跨站请求伪造:攻击者诱导受害者进入第三方网站,在第三方网站中,向被攻击网站发送跨站请求。利用受害者在被攻击网站已经获取的注册凭证,绕过后台的用户验证,达到冒充用户对被攻击的网站执行某项操作的目的。</li> <li>Cookie:用户挑战到第三方网站时,会携带本网站的Cookie</li> <li>Token:Token一般存放在localStorage(浏览器本地存储)中,在前端通过某些方式会给每个发到后端的请求加上这个 Token,这样就不会出现 CSRF 漏洞的问题。</li> </ul> <h3>Jwt的优势和缺陷解决</h3> <ul> <li>优势: <ul> <li>无状态:JWT自身包含了身份验证所需要的所有信息,因此,我们的服务器不需要存储 Session 信息</li> <li>有效避免CSRF攻击:使用 JWT 进行身份验证不需要依赖 Cookie ,因此可以避免 CSRF 攻击</li> <li>适合移动端应用:不依赖Cookie</li> <li>单点登录友好:JWT保存在客户端</li> </ul></li> <li>缺陷: <ul> <li>注销登录,修改密码等场景下Jwt还有效: <ul> <li>将Jwt存入内存数据库</li> <li>黑名单机制</li> </ul></li> <li>Jwt续签问题:如何动态刷新Jwt <ul> <li>服务端每次进行校验时,刷新有效期,重新生成Jwt返回给客户端</li> <li>将Jwt有效期设置到半夜</li> </ul></li> </ul></li> </ul> <h1>8.系统设计</h1> <h2>系统设计</h2> <h3>怎么做</h3> <ol> <li>问清楚系统具体要求 <ul> <li>功能性需求:核心功能</li> <li>非功能性需求:</li> <li>约束条件:比如系统需要达到多少QPS</li> </ul></li> <li>对系统进行抽象设计:设计包含系统一些组件以及这些组件之间连接的抽象架构图 <img src="https://www.showdoc.com.cn/server/api/attachment/visitFile?sign=6e75a9501aad7c7bb3aec696f7408c98&amp;file=file.png" alt="" /></li> <li>考虑系统目前需要优化的点 <ul> <li>当前系统部署在一台机器够吗?是否需要部署在多台机器+负载均衡</li> <li>数据库处理能否支撑业务需求?是否需要加索引?读写分离?缓存?</li> <li>数据量是否大到需要分库分表</li> <li>是否存在安全隐患</li> <li>系统是否分布式文件系统</li> </ul></li> <li>优化抽象设计</li> </ol> <h3>知识储备</h3> <ol> <li>高性能架构设计:引入读写分离、缓存、负载均衡、异步等</li> <li>高可用架构设计:CAP理论和BASE理论、通过集群提高系统整体稳定性、超时和重传机制、应对接口故障:降级、熔断、限流、排队。</li> <li>高扩展架构设计:如何拆分系统</li> </ol> <h3>性能指标</h3> <ul> <li>响应时间:用户发出请求到用户收到系统处理结果所需要的时间</li> <li>并发数:系统能够同时共多少人访问使用即系统同时能处理的请求数量</li> <li>QPS和TPS: <ul> <li>QPS:服务器每秒可以执行的查询次数=并发数/平均响应时间</li> <li>TPS:服务器每秒处理的事务(一个客户端向服务器发送请求然后服务器做出响应的过程,一个事务可包含多个请求)数</li> </ul></li> <li>吞吐量:系统单位时间内系统处理的请求数量</li> </ul> <h3>系统活跃度</h3> <ul> <li>PV(Page View):页面的浏览量或点击量</li> <li>UV(Unique Visitor):独立访客,统计1天内访问某站点的用户数</li> <li>DAU(Daily Active User):日活跃用户数量</li> <li>MAU(Monthly Active Users):月活跃用户人数</li> </ul> <h3>性能优化策略</h3> <ol> <li>当前系统的Sql语句是否存在问题</li> <li>当前系统是否需要升级硬件</li> <li>系统是否需要缓存</li> <li>系统架构本身是不是就是问题</li> <li>系统是否存在死锁的地方</li> <li>数据库索引使用是否合理</li> <li>系统是否存在内存泄漏</li> <li>系统的耗时操作进行了一步处理</li> </ol> <h3>如何设计一个秒杀系统</h3> <ul> <li>高并发:能够同时处理很多用户的请求</li> <li>高性能:处理用户请求的速度要快</li> <li>高可用:系统要在趋近100%的时间内都能正确提供服务</li> <li>一致性:</li> </ul> <p>重点关注的问题:</p> <ol> <li>参与秒杀的商品属于热点数据,如何处理热点数据 热点数据一定要放到缓存中,并且最好可以写入到JVM内存一份</li> <li>商品的库存有限,在面对大量订单的情况下,如何解决超卖的问题 <ul> <li><strong>下单即减库存</strong>:只要用户下单了,即使不付款,我们就扣库存;提前将秒杀商品库存放到缓存中,再通过Redis对库存进行原子操作。</li> <li>付款再减库存:当用户付款之后,我们再减库存。不过,这种情况可能会造成用户下单成功,但是付款失败</li> </ul></li> <li>如果系统使用了消息队列,如何保证消息队列不丢失消息 <ul> <li>使用消息队列进行流量削峰,将大量请求放到消息队列中</li> <li>在用户发起秒杀请求前让其答题或者拖动图形验证码</li> </ul></li> <li>如何保证秒杀系统的高可用 <ul> <li>Redis集群化</li> <li>限流:为了对服务端的接口请求的频率进行限制,防止服务挂掉,如Sentinel</li> <li>降级:关闭系统的一些非核心功能或者让它们的功能降低</li> <li>熔断:防止因为秒杀交易影响到其他正常服务的提供</li> </ul></li> <li>如何对项目进行压测?有哪些工具?</li> </ol> <h3>如何自己实现一个RPC框架</h3> <ul> <li>注册中心:负责服务地址的注册和查找,相当于目录服务</li> <li>网络传输:调用远程方法,需要发送网络请求来传递目标类和方法的信息以及方法的参数等数据到服务提供端</li> <li>序列化与反序列化:要在网络传输数据就要涉及到序列化</li> <li>动态代理:屏蔽远程方法调用的底层细节,当你调用远程方法的时候,实际会通过代理对象来传输网络请求。</li> <li>负载均衡:避免单个服务器响应同一请求,容易造成服务器宕机、崩溃等问题</li> <li>传输协议:客户端和服务端交流的基础</li> </ul> <h3>如何设计一个排行榜</h3> <ol> <li>Mysql的Order By关键字 适用:用户数据量不大的场景 好处:简单,不需要引入额外组件,成本低 坏处:每次生成排行榜比较耗时,对数据库性能消耗非常大 优化:给排序字段加索引并且限制排序数据量</li> <li>Redis中的Sorted Set 适用:基本可以满足大部分排行榜场景 好处:能够轻松应对百万级别的用户数据排序 坏处:Redis中只保存了排行榜展示所需的数据,用户具体的数据需要去对应的数据库(比如Mysql)中查</li> </ol> <h3>如何设计微博Feed流/信息流系统</h3> <p>Feed流:能够实时/智能推送信息的数据流。如朋友圈,关注的up主动态等 Feed流的形式:纯智能推荐,纯Timeline和智能推荐+Timeline 注意事项:</p> <ul> <li>实时性:你关注的人发了微博信息之后,需要在短时间内出现在你的信息流中</li> <li>高并发:信息流是微博的主题模块,是用户进入微博之后最先看到的模块,因此它的并发请求量是最高的,可以达到每秒几十万次请求</li> <li>性能:信息流拉取性能直接影响用户的使用体验。微博信息流系统中需要聚合的数据非常多。聚合这么多数据就需要查询多次缓存、数据库、计数器,而在每秒几十万次的请求下,如何保证在100ms之内完成这些查询操作,展示微博的信息流呢?这是微博信息流系统最复杂之处,也是技术上最大的挑战。</li> </ul> <p>Feed流的3种推送模式:</p> <ul> <li>推模式:当一个用户发送一个动态之后,主动将这个动态推送给其他相关用户(比如粉丝) <ul> <li>缺点:当粉丝很多,需要执行n条sql语句,写入数据库操作太多</li> <li>不适合关注者粉丝过多的场景</li> </ul></li> <li>拉模式:主动去拉取关注的人的动态,然后将这些动态根据相关指标(比如时间、热度)进行实时聚合 <ul> <li>缺点:查询和聚合的两个操作的成本比较高,实时性差</li> </ul></li> <li>推拉结合模式:区分活跃用户和非活跃用户,活跃用户使用推模式,非活跃用户使用拉模式 <ul> <li>适用:用户粉丝数比较大的场景 <img src="https://www.showdoc.com.cn/server/api/attachment/visitFile?sign=a8e3873f0a4e925486d3f49d017a6530&amp;file=file.png" alt="" /></li> </ul></li> </ul> <h3>如何设计一个短链系统</h3> <p>短链好处:</p> <ol> <li>短链更简洁,更方便传播:过长的链接不利于在互联网传播</li> <li>方便对链接的点击情况做后续追踪:比如查看短链最近一周访问量、访客数、访问来源</li> <li>对于短信等限制字数的场景来说更加友好</li> </ol> <p>短链原理:<strong>通过短链找到长链(原始链接),然后再重定向到长链地址即可</strong> <img src="https://www.showdoc.com.cn/server/api/attachment/visitFile?sign=95c8f5a5ecd1f01e92c2a6e48a8e9ce0&amp;file=file.png" alt="" /></p> <p>生成唯一短链:<strong>通过哈希算法对长链去哈希</strong></p> <pre><code class="language-java">//Guava 自带的 MurmurHash 算法实现 String url = "https://time.geekbang.org/column/intro/100020801"; long s = Hashing.murmur3_32().hashUnencodedChars(url).padToLong();// 3394174629</code></pre> <p>生成的哈希值是10进制的,为了缩短它的长度,<strong>我们可以将其转化为62进制(数字+大小写字母)</strong>。3394174629转换62进制后是3HHBS5,作为短链的唯一标识即可 解决哈希冲突:在长链后面拼上随机字符串再生成,若还是冲突再拼随机字符串</p> <p>存储短链:可以使用 Redis 这类 K-V 内存数据库来做,这样性能也会更好!当我们存放一个长链的时候,我们首先判断一下这个长链是否已经被转换过短链。</p> <h3>如何基于Redis统计网站UV(需要根据IP地址或者当前登录的用户来作为去重标准)</h3> <p>简单方案:为每一个网页维护一个哈希表,网页 ID +日期 为 Key, Value 为看过这篇文章的所有用户 ID 或者 IP(Set 类型的数据结构) <img src="https://www.showdoc.com.cn/server/api/attachment/visitFile?sign=739b1fa83a9cf48abea1eb5999f23e64&amp;file=file.png" alt="" /> 当我们需要计算对应页面的 UV 的话,直接计算出页面对应的 Set 集合的大小即可!但是,如果网站的访问量比较大,这种方式就不能够满足我们的需求了!</p> <p>改进方案:HyperLogLog 是一种基数计数概率算法,HyperLogLog 的计算结果并不是一个精确值,存在一定的误差,这是由于它本质上是用概率算法导致的。</p> <p>代码示例:</p> <pre><code class="language-java">public class HyperLogLogTest { private Jedis jedis; private final String SET_KEY = "SET:PAGE1:2021-12-19"; private final String PF_KEY = "PF:PAGE2:2021-12-19"; private final long NUM = 10000 * 10L; @BeforeEach void connectToRedis() { jedis = new Jedis(new HostAndPort("localhost", 6379)); } @Test void initData() { for (int i = 0; i &lt; NUM; ++i) { System.out.println(i); jedis.sadd(SET_KEY, "USER" + i); jedis.pfadd(PF_KEY, "USER" + i); } } @Test void getData() { DecimalFormat decimalFormat = new DecimalFormat("##.00%"); Long setCount = jedis.scard(SET_KEY); System.out.println(decimalFormat.format((double) setCount / (double)NUM)); long pfCount = jedis.pfcount(PF_KEY); System.out.println(decimalFormat.format((double) pfCount / (double)NUM)); } }</code></pre> <p>输出结果:</p> <pre><code class="language-java">100.00% 99.27%</code></pre> <p>需要获取指定天数的 UV:在 key 上添加日期作为标识即可</p> <pre><code class="language-bash">PFADD PAGE_1:UV:2021-12-19 USER1 USER2 ...... USERn</code></pre> <p>需要获取指定时间(精确到小时)的 UV:在 key 上添加指定时间作为标识即可</p> <pre><code class="language-bash">PFADD PAGE_1:UV:2021-12-19-12 USER1 USER2 ...... USERn</code></pre> <h1>9.服务器</h1> <h2>Nginx</h2> <h3>正向代理和反向代理</h3> <ul> <li>正向代理:代理的是客户端,目标服务器不知道客户端是谁(例,VPN)</li> <li>反向代理:代理的是目标服务器,隐藏了真实的服务器,为服务器首发请求,使真实服务器对客户端不可见</li> </ul> <h3>Nginx负载均衡策略</h3> <ul> <li>轮训(默认):每个请求按时间顺序逐一分配到不同的后端服务器,如果后端服务器down掉,能自动剔除</li> <li>加权随机:weight,代表权重,权重越高的后端服务器被访问的概率越大</li> <li>IP哈希:根据发出请求的后端ip的hash值来分配服务器,可以保证同IP发出的请求映射到同一服务器,或者具有相同hash值的不同ip映射到同一服务器</li> <li>最小连接数:当有新的请求出现时,遍历服务器节点列表并选取其中连接数最小的一台服务器来响应当前请求。连接数可以理解为当前处理的请求数。</li> </ul> <h2>Tomcat</h2> <h3>Tomcat=HTTP服务器+Servlet容器</h3> <ul> <li><strong>HTTP 服务器</strong> :处理 HTTP 请求并响应结果。</li> <li><strong>Servlet 容器</strong> :HTTP 服务器将请求交给 Servlet 容器处理,<strong>Servlet 容器会将请求转发到具体的 Servlet执行</strong>(Servlet 容器用来加载和管理业务类)。</li> </ul> <h3>Tomcat总体架构</h3> <p><img src="https://www.showdoc.com.cn/server/api/attachment/visitFile?sign=521e6c7a2ddd0dea93afec10491cdb88&amp;file=file.png" alt="" /></p> <ul> <li><strong>连接器(Connector):处理Socket连接,负责网络字节流与Request和R esponse对象的转化</strong> <ul> <li>EndPoint:负责底层Socket通信,提供字节流给Processor</li> <li>Processor:负责应用层协议解析,Tomcat Request/Response与ServletRequest/ServletResponse的转化</li> <li>Adapter:负责通过适配器Adapter调用容器,提供ServletRequest对象给容器</li> </ul></li> <li><strong>容器(Container):加载和管理Servlet,以及具体处理Request请求</strong>,包含4种容器,父子关系 <ul> <li>Engine:一个Service对应一个Engine,一个Engine管理多个虚拟站点(Host)</li> <li>Host:代表一个虚拟站点,可以给Tomcat配置多个虚拟站点,一个虚拟站点下可以部署多个Web应用(Context)</li> <li>Context:表示一个Web应用,一个Web应用中可能会有多个Servlet</li> <li>Wrapper:表示一个Servlet</li> </ul></li> </ul> <h3>请求是如何定位到Servlet:一个请求URL最后只会定位到一个Wrapper容器,也就是一个Servlet</h3> <p><img src="https://www.showdoc.com.cn/server/api/attachment/visitFile?sign=b28f9fd00c19d455afd9b30cbb3d1958&amp;file=file.png" alt="" /> 假如有用户访问一个 URL,比如图中的http://user.shopping.com:8080/order/buy,Tomcat 如何将这个 URL 定位到一个 Servlet 呢? </p> <ol> <li><strong>根据协议和端口号选定 Service 和 Engine</strong> : URL 访问的是 8080 端口,因此这个请求会被 HTTP 连接器接收,而一个连接器是属于一个 Service 组件的,这样 Service 组件就确定了 </li> <li><strong>根据域名选定 Host</strong> : 域名是 user.shopping.com,因此 Mapper 会找到 Host2 这个容器。 </li> <li><strong>根据 URL 路径找到 Context 组件</strong> 。 </li> <li><strong>根据 URL 路径找到 Wrapper(Servlet)</strong> : Context 确定后,Mapper 再根据 web.xml 中配置的 Servlet 映射路径来找到具体的 Wrapper 和 Servlet。</li> </ol> <h3>Tomcat为什么要打破双亲委托机制</h3> <p>Tomcat 自定义类加载器打破双亲委托机制的目的是为了优先加载 Web 应用目录下的类,然后再加载其他目录下的类,这也是 Servlet 规范的推荐做法。 要打破双亲委托机制,需要继承 ClassLoader 抽象类,并且需要重写它的 loadClass 方法,因为 ClassLoader 的默认实现就是双亲委托。</p> <h3>Tomcat的类加载器层次结构+如何隔离Web应用</h3> <p><img src="https://www.showdoc.com.cn/server/api/attachment/visitFile?sign=d99b5c5eda32ce7136d099990dbb0600&amp;file=file.png" alt="" /></p> <ol> <li>Web应用之间的类如何隔离 Tomcat自定义一个类加载器WebAppClassLoader,并且给每个Web应用创建一个该类加载器的实例。每一个Context容器负责创建和维护一个WebAppClassLoader加载器实例,不同的加载器加载的类被认为是不同的类,即使它们的类名相同。</li> <li>两个Web应用之间怎么共享类库,并且不会重复加载相同的类 Tomcat设计了类加载器 SharedClassLoader,作为 WebAppClassLoader 的父加载器,专门来加载 Web 应用之间共享的类。如果 WebAppClassLoader 自己没有加载到某个类,就会委托父加载器 SharedClassLoader 去加载这个类,SharedClassLoader 会在指定目录下加载共享类,之后返回给 WebAppClassLoader,这样共享的问题就解决了。</li> <li>Tomcat自身的类和Web应用的类如何隔离 Tomcat设计类加载器 CatalinaClassLoader,专门来加载 Tomcat 自身的类。这样设计有个问题,那 Tomcat 和各 Web 应用之间需要共享一些类时该怎么办呢? 老办法,还是再增加一个 CommonClassLoader,作为 CatalinaClassLoader 和 SharedClassLoader 的父加载器。</li> </ol> <h1>10.Spring框架</h1> <h2>Spring</h2> <h3>IOC(控制反转):将对象之间的相互依赖关系交给 IoC 容器来管理,并由 IoC 容器完成对象的注入</h3> <ul> <li>控制:指的是对象的创建(实例化、管理)的权利</li> <li>反转:控制权交给外部环境(Spring框架、IOC容器)</li> </ul> <h3>AOP(面向切面编程):将那些与业务无关,却为业务模块所共同调用的逻辑或责任(例如事务处理、日志管理、权限控制等)封装起来,便于减少系统的重复代码,降低模块间的耦合度,并有利于未来的可拓展性和可维护性。(通俗解释:在程序执行的某一个时机,把程序切一刀,添加一些功能,实现在不改变原来代码的前提下,对一些方法进行增强)</h3> <p>原理:动态代理 <img src="https://www.showdoc.com.cn/server/api/attachment/visitFile?sign=401d499815914c4efe677eddf429678e&amp;file=file.png" alt="" /></p> <ul> <li>JDK Proxy:被代理对象实现了某个接口,Spring AOP会去创建实现了相同接口的代理对象</li> <li>CGLib Proxy:被代理对象没有实现接口,Spring AOP会去生成一个被代理对象的子类作为代理</li> </ul> <h3>Spring启动流程</h3> <p><img src="https://www.showdoc.com.cn/server/api/attachment/visitFile?sign=fb3a07916a8c76645ac4eefecdc04fe9&amp;file=file.png" alt="" /></p> <ol> <li><strong>初始化Spring容器</strong> <ol> <li><strong>实例化BeanFactory【DefaultListableBeanFactory】工厂,用于生成Bean对象</strong></li> <li><strong>实例化BeanDefinitionReader注解配置读取器,用于对特定注解(如@Service、@Repository)的类进行读取转化成 BeanDefinition 对象</strong>,(BeanDefinition 是 Spring 中极其重要的一个概念,它存储了 bean 对象的所有特征信息,如是否单例,是否懒加载,factoryBeanName 等)</li> <li><strong>实例化ClassPathBeanDefinitionScanner路径扫描器,用于对指定的包目录进行扫描查找 bean 对象</strong></li> </ol></li> <li><strong>注册SpringConfig配置类到容器中</strong>:解析用户传入的 Spring 配置类,解析成一个 BeanDefinition 然后注册到容器中</li> <li>refresh()容器刷新 <ol> <li>prepareRefresh()刷新前的预处理: (1)initPropertySources():初始化一些属性设置,子类自定义个性化的属性设置方法; (2)getEnvironment().validateRequiredProperties():检验属性的合法性 (3)earlyApplicationEvents = new LinkedHashSet<ApplicationEvent>():保存容器中的一些早期的事件;</li> <li>obtainFreshBeanFactory():获取在容器初始化时创建的BeanFactory: (1)refreshBeanFactory():刷新BeanFactory,设置序列化ID; (2)getBeanFactory():返回初始化中的GenericApplicationContext创建的BeanFactory对象,即【DefaultListableBeanFactory】类型</li> <li>prepareBeanFactory(beanFactory):BeanFactory的预处理工作,向容器中添加一些组件: (1)设置BeanFactory的类加载器、设置表达式解析器等等 (2)添加BeanPostProcessor【ApplicationContextAwareProcessor】 (3)设置忽略自动装配的接口:EnvironmentAware、EmbeddedValueResolverAware、ResourceLoaderAware、ApplicationEventPublisherAware、MessageSourceAware、ApplicationContextAware; (4)注册可以解析的自动装配类,即可以在任意组件中通过注解自动注入:BeanFactory、ResourceLoader、ApplicationEventPublisher、ApplicationContext (5)添加BeanPostProcessor【ApplicationListenerDetector】 (6)添加编译时的AspectJ; (7)给BeanFactory中注册的3个组件:environment【ConfigurableEnvironment】、systemProperties【Map&lt;String, Object&gt;】、systemEnvironment【Map&lt;String, Object&gt;】</li> <li>postProcessBeanFactory(beanFactory):子类重写该方法,可以实现在BeanFactory创建并预处理完成以后做进一步的设置</li> <li>invokeBeanFactoryPostProcessors(beanFactory):<strong>在BeanFactory标准初始化之后执行BeanFactoryPostProcessor的方法,即BeanFactory的后置处理器</strong>: (1)先执行BeanDefinitionRegistryPostProcessor: postProcessor.postProcessBeanDefinitionRegistry(registry) ① 获取所有的实现了BeanDefinitionRegistryPostProcessor接口类型的集合 ② 先执行实现了PriorityOrdered优先级接口的BeanDefinitionRegistryPostProcessor ③ 再执行实现了Ordered顺序接口的BeanDefinitionRegistryPostProcessor ④ 最后执行没有实现任何优先级或者是顺序接口的BeanDefinitionRegistryPostProcessors (2)再执行BeanFactoryPostProcessor的方法:postProcessor.postProcessBeanFactory(beanFactory) ① 获取所有的实现了BeanFactoryPostProcessor接口类型的集合 ② 先执行实现了PriorityOrdered优先级接口的BeanFactoryPostProcessor ③ 再执行实现了Ordered顺序接口的BeanFactoryPostProcessor ④ 最后执行没有实现任何优先级或者是顺序接口的BeanFactoryPostProcessor</li> <li><strong>registerBeanPostProcessors(beanFactory):向容器中注册Bean的后置处理器BeanPostProcessor,它的主要作用是干预Spring初始化bean的流程,从而完成代理、自动注入、循环依赖等功能</strong> (1)获取所有实现了BeanPostProcessor接口类型的集合: (2)先注册实现了PriorityOrdered优先级接口的BeanPostProcessor; (3)再注册实现了Ordered优先级接口的BeanPostProcessor; (4)最后注册没有实现任何优先级接口的BeanPostProcessor; (5)最r终注册MergedBeanDefinitionPostProcessor类型的BeanPostProcessor:beanFactory.addBeanPostProcessor(postProcessor); (6)给容器注册一个ApplicationListenerDetector:用于在Bean创建完成后检查是否是ApplicationListener,如果是,就把Bean放到容器中保存起来:applicationContext.addApplicationListener((ApplicationListener&lt;?&gt;) bean); 此时容器中默认有6个默认的BeanProcessor(无任何代理模式下):【ApplicationContextAwareProcessor】、【ConfigurationClassPostProcessorsAwareBeanPostProcessor】、【PostProcessorRegistrationDelegate】、【CommonAnnotationBeanPostProcessor】、【AutowiredAnnotationBeanPostProcessor】、【ApplicationListenerDetector】</li> <li>initMessageSource():初始化MessageSource组件,主要用于做国际化功能,消息绑定与消息解析: (1)看BeanFactory容器中是否有id为messageSource 并且类型是MessageSource的组件:如果有,直接赋值给messageSource;如果没有,则创建一个DelegatingMessageSource; (2)把创建好的MessageSource注册在容器中,以后获取国际化配置文件的值的时候,可以自动注入MessageSource;</li> <li><strong>initApplicationEventMulticaster():初始化事件派发器</strong>,在注册监听器时会用到: (1)看BeanFactory容器中是否存在自定义的ApplicationEventMulticaster:如果有,直接从容器中获取;如果没有,则创建一个SimpleApplicationEventMulticaster (2)将创建的ApplicationEventMulticaster添加到BeanFactory中,以后其他组件就可以直接自动注入</li> <li>onRefresh():留给子容器、子类重写这个方法,在容器刷新的时候可以自定义逻辑</li> <li><strong>registerListeners():注册监听器:将容器中所有的ApplicationListener注册到事件派发器中,并派发之前步骤产生的事件</strong>: (1)从容器中拿到所有的ApplicationListener (2)将每个监听器添加到事件派发器中:getApplicationEventMulticaster().addApplicationListenerBean(listenerBeanName); (3)派发之前步骤产生的事件applicationEvents:getApplicationEventMulticaster().multicastEvent(earlyEvent);</li> <li>finishBeanFactoryInitialization(beanFactory):<strong>初始化所有剩下的单实例bean</strong>,核心方法是preInstantiateSingletons(),会调用getBean()方法创建对象; (1)获取容器中的所有beanDefinitionName,依次进行初始化和创建对象 (2)获取Bean的定义信息RootBeanDefinition,它表示自己的BeanDefinition和可能存在父类的BeanDefinition合并后的对象 (3)如果Bean满足这三个条件:非抽象的,单实例,非懒加载,则执行单例Bean创建流程: (4)所有Bean都利用getBean()创建完成以后,检查所有的Bean是否为SmartInitializingSingleton接口的,如果是;就执行afterSingletonsInstantiated();</li> <li>finishRefresh():发布BeanFactory容器刷新完成事件: (1)initLifecycleProcessor():初始化和生命周期有关的后置处理器:默认从容器中找是否有lifecycleProcessor的组件【LifecycleProcessor】,如果没有,则创建一个DefaultLifecycleProcessor()加入到容器; (2)getLifecycleProcessor().onRefresh():拿到前面定义的生命周期处理器(LifecycleProcessor)回调onRefresh()方法 (3)publishEvent(new ContextRefreshedEvent(this)):发布容器刷新完成事件; (4)liveBeansView.registerApplicationContext(this);</li> </ol></li> </ol> <h3>SpringMVC</h3> <p>@Transactional</p> <ul> <li>如果类或者方法加了这个注解,那么这个类里面的方法抛出异常,就会回滚,数据库里面的数据也会回滚。</li> <li>在 @Transactional 注解中如果不配置rollbackFor属性,那么事务只会在遇到RuntimeException的时候才会回滚,加上 rollbackFor=Exception.class,可以让事务在遇到非运行时异常时也回滚。</li> </ul> <h3>循环依赖及三级缓存</h3> <p>循环依赖示例: A 要依赖 B,发现 B 还没创建。于是开始创建 B ,创建的过程发现 B 要依赖 A, 而 A 还没创建好呀,因为它要等 B 创建好。</p> <p>解决前提:</p> <ol> <li>依赖的 Bean 必须都是单例。</li> <li>依赖注入的方式,必须不全是构造器注入,且 beanName 字母序在前的不能是构造器注入。</li> </ol> <p>解决思路:先把未完成属性注入的Bean保存起来,可以注入未完成属性注入和初始化的依赖</p> <p>三级缓存</p> <ol> <li><strong>singletonObjects</strong>:缓存某个 beanName 对应的<strong>经过了完整生命周期的bean</strong>;</li> <li><strong>earlySingletonObjects</strong>:缓存<strong>提前拿原始对象进行了 AOP 之后得到的代理对象</strong>,原始对象还没有进行属性注入和后续的 BeanPostProcesso r等生命周期;</li> <li><strong>singletonFactories</strong>:缓存的是一个 <strong>ObjectFactory ,主要用来去生成原始对象进行了 AOP之后得到的「代理对象」</strong>,在每个 Bean 的生成过程中,都会提前暴露一个工厂,这个工厂可能用到,也可能用不到,如果没有出现循环依赖依赖本 bean,那么这个工厂无用,本 bean 按照自己的生命周期执行,执行完后直接把本 bean 放入 singletonObjects 中即可,如果出现了循环依赖依赖了本 bean,则另外那个 bean 执行 ObjectFactory 提交得到一个 AOP 之后的代理对象(如果有 AOP 的话,如果无需 AOP ,则直接得到一个原始对象)。 <img src="https://www.showdoc.com.cn/server/api/attachment/visitFile?sign=e419adb543af6a7b51acd4a86dafcfd6&amp;file=file.png" alt="" /></li> </ol> <p>配合:</p> <ol> <li>首先,获取单例 Bean 的时候会通过 BeanName 先去 singletonObjects(一级缓存) 查找完整的 Bean,如果找到则直接返回,否则进行步骤 2。</li> <li>看对应的 Bean 是否在创建中,如果不在直接返回找不到,如果是,则会去 earlySingletonObjects (二级缓存)查找 Bean,如果找到则返回,否则进行步骤 3</li> <li>去 singletonFactories (三级缓存)通过 BeanName 查找到对应的工厂,如果存着工厂则通过工厂创建 Bean ,并且放置到 earlySingletonObjects 中。</li> <li>如果三个缓存都没找到,则返回 null。 <strong>重点</strong>:就是在对象实例化之后,都会在三级缓存里加入一个工厂,提前对外暴露还未完整的 Bean,这样如果被循环依赖了,对方就可以利用这个工厂得到一个不完整的 Bean,破坏了循环的条件。</li> </ol> <p>为什么需要三级:正常代理对象的生成是基于后置处理器,是在被代理的对象初始化后期调用生成的,所以如果你提早代理了其实是违背了 Bean 定义的生命周期。所以 Spring 先在一个三级缓存放置一个工厂,如果产生循环依赖,那么就调用这个工厂提早得到代理对象。如果没产生依赖,这个工厂根本不会被调用,所以 Bean 的生命周期就是对的。</p> <h3>Spring中的设计模式</h3> <ul> <li>工厂模式:在 Spring 的 BeanFactory 或 ApplicationContext 中,使用了工厂模式创建 bean 对象。 <ul> <li><code>BeanFactory</code> :懒汉模式(使用到某个 bean 的时候才会注入), 相比于ApplicationContext 来说会占用更少的内存,程序启动速度更快。</li> <li><code>ApplicationContext</code>:饿汉模式(容器启动的时候,不管用没用到,一次性创建所有 bean)。BeanFactory 仅提供了最基本的依赖注入支持,ApplicationContext 扩展了 BeanFactory ,除了有BeanFactory的功能还有额外更多功能,所以一般开发人员使用ApplicationContext会更多。</li> </ul></li> <li> <p>单例模式:<strong>Spring 通过 ConcurrentHashMap 实现单例注册表的特殊方式实现单例模式</strong>。 Spring 实现单例的核心代码如下:</p> <pre><code class="language-java">// 通过 ConcurrentHashMap(线程安全) 实现单例注册表 private final Map&lt;String, Object&gt; singletonObjects = new ConcurrentHashMap&lt;String, Object&gt;(64); public Object getSingleton(String beanName, ObjectFactory&lt;?&gt; singletonFactory) { Assert.notNull(beanName, "'beanName' must not be null"); synchronized (this.singletonObjects) { // 检查缓存中是否存在实例 Object singletonObject = this.singletonObjects.get(beanName); if (singletonObject == null) { //... try { singletonObject = singletonFactory.getObject(); } //... // 如果实例对象在不存在,我们注册到单例注册表中。 addSingleton(beanName, singletonObject); } return (singletonObject != NULL_OBJECT ? singletonObject : null); } } //将对象添加到单例注册表 protected void addSingleton(String beanName, Object singletonObject) { synchronized (this.singletonObjects) { this.singletonObjects.put(beanName, (singletonObject != null ? singletonObject : NULL_OBJECT)); } } }</code></pre> </li> <li> <p>代理模式:Spring AOP 就是基于<strong>动态代理</strong>。如果要代理的对象,实现了某个接口,Spring AOP 则使用 JDK Proxy,去创建代理对象;如果没有实现接口的对象,Spring AOP 则使用 Cglib 生成一个被代理对象的子类来作为代理,如下图所示: <img src="https://www.showdoc.com.cn/server/api/attachment/visitFile?sign=257c26e2ab267d43b792aae38b6e5a40&amp;file=file.png" alt="" /></p> </li> <li> <p>模板方法模式:Spring 中 jdbcTemplate、hibernateTemplate 等以 Template 结尾的数据库操作类,就使用到了模板模式。</p> <ul> <li>模板方法模式,又叫模板模式,在一个抽象类公开定义了执行他的方法的模板。它的子类可以按需要重写方法实现,但调用将以抽象类中定义的方式进行</li> <li>模板方法模式定义了一个操作中的算法的骨架,而将一些步骤延迟到子类中,使得子类可以不改变一个算法对的结构,就可以重定义该算法的某些特定步骤</li> </ul> </li> <li>观察者模式:Spring 事件驱动模型就是观察者模式很经典的一个应用。 <ul> <li>观察者模式是一种对象行为型模式。它表示的是一种对象与对象之间具有依赖关系,当一个对象发生改变的时候,这个对象所依赖的对象也会做出反应。</li> <li>Spring 事件驱动模型中的三种角色 <ul> <li>事件:<code>org.springframework.context.ApplicationEvent</code> 充当事角色基类。Spring 中默认存在以下事件,他们都是对 ApplicationContextEvent 的子类: <ul> <li>ContextStartedEvent:ApplicationContext 启动后触发的事件;</li> <li>ContextStoppedEvent:ApplicationContext 停止后触发的事件;</li> <li>ContextRefreshedEvent:ApplicationContext 初始化或刷新完成后触发的事件;</li> <li>ContextClosedEvent:ApplicationContext 关闭后触发的事件。</li> </ul></li> <li>事件监听者:<code>org.springframework.context.ApplicationEvent.ApplicationListener</code> 充当了事件监听者角色基类,通过 ApplicationListener 的 onApplicationEvent() 方法进行监听事件。 <pre><code class="language-java">@FunctionalInterface public interface ApplicationListener&lt;E extends ApplicationEvent&gt; extends EventListener { void onApplicationEvent(E var1); }</code></pre></li> <li>事件发布者:<code>org.springframework.context.ApplicationEventPublisher</code> 充当了事件的发布者基类,通过 ApplicationEventPublisher 的 publishEvent() 发布消息。 <pre><code class="language-java">@FunctionalInterface public interface ApplicationEventPublisher { default void publishEvent(ApplicationEvent event) { this.publishEvent((Object)event); } void publishEvent(Object var1); }</code></pre></li> </ul></li> </ul></li> </ul> <h3>@Autowired和@Resource异同</h3> <ul> <li>相同:用于属性注入,自动装配对象</li> <li>不同: <ul> <li>@Resource是JDK原生的注解,@Autowired是Spring2.5 引入的注解</li> <li>@Resource有两个属性name(bean的名字)和type(bean的类型)。如果既不指定name也不指定type属性,这时将通过反射机制使用byName自动注入策略。@Autowired只根据type进行注入,不会去匹配name。如果涉及到type无法辨别注入对象时,那需要依赖@Qualifier或@Primary注解一起来修饰。</li> </ul></li> </ul> <h1>11.运维</h1> <h2>Linux命令</h2> <h3>CPU占用过高如何排查</h3> <ul> <li>top命令:找出CPU占用高的进程的PID</li> <li>ps -mp PID -o THREAD,tid,time:显示进程的线程列表,定位到CPU占用高的线程TID</li> <li>printf &quot;%x&quot; tid:将线程ID转换为16进制格式</li> <li>jstack PID | grep TID -A 30:打印线程的堆栈信息。根据这个命令的输出可以定位某个进程的所有线程的当前运行状态、运行代码,以及是否死锁等等。</li> </ul> <h2>瓶颈定位和性能优化</h2> <h3>自顶向下与自底向上</h3> <ul> <li>自顶向下:<strong>自顶向下的第一步总是对运行在特定负载之下的应用进行监控</strong>。监控的范围包括操作系统、Java虚拟机、JavaEE容器以及应用的性能测量统计指标(比如响应时间、吞吐量)。基于监控信息所给出的提示再开展下一步工作,例如JVM垃圾收集器调优、JVM命令选项调优,或者应用程序性能分析。性能分析可能导致应用程序的更改,或者发现第三方库或JavaSE类库在实现上的不足。</li> <li>自底向上:<strong>自底向上需要收集和监控最底层CPU的统计数据</strong>。监控的CPU统计数据包括执行特定任务所需要的CPU指令数,以及应用在一定负载下运行时的CPU高速缓存未命中率。在一定负载下,应用执行和扩展所需的CPU指令越少,运行的就越快。降低CPU高速缓存未命中率也能改善应用的系能,因为CPU告诉缓存未命中会导致CPU为了等待从内存获取数据而浪费若干周期,从而降低CPU高速缓存未命中率,意味着CPU可以减少等待内存数据的时间,应用也就能运行的更快。 <h3>性能优化流程</h3> <ol> <li>准备阶段:主要工作是是通过性能测试,了解应用的概况、瓶颈的大概方向,明确优化目标; <ul> <li>深入了解调优对象: <ol> <li>对性能问题进行粗略评估:过滤一些因为低级的业务逻辑导致的性能问题。譬如,线上应用日志级别不合理,可能会在大流量时导致 CPU 和磁盘的负载飙高,这种情况调整日志级别即可;</li> <li>了解应用的的总体架构:比如应用的外部依赖和核心接口有哪些,使用了哪些组件和框架,哪些接口、模块的使用率较高,上下游的数据链路是怎么样的等;</li> <li>了解应用对应的服务器信息:如服务器所在的集群信息、服务器的 CPU/内存信息、安装的 Linux 版本信息、服务器是容器还是虚拟机、所在宿主机混部后是否对当前应用有干扰等;</li> </ol></li> <li>基准测试: <ol> <li>使用<strong>基准测试</strong>工具获取系统细粒度指标:可以使用若干 Linux 基准测试工具(eg. jmeter、ab、loadrunnerwrk、wrk)等),<strong>得到文件系统、磁盘 I/O、网络等的性能报告</strong>。除此之外,类似 GC、Web 服务器、网卡流量等信息,如有必要也是需要了解记录的;</li> <li>通过<strong>压测工具或者压测平台</strong>(如果有的话)对进行压测获取宏观业务指标。譬如:<strong>响应时间、吞吐量、TPS、QPS、消费速率(对于有 MQ 的应用)等</strong>。压力测试也可以省略,可以结合当前的实际业务和过往的监控数据,去统计当前的一些核心业务指标,如午高峰的服务 TPS。</li> </ol></li> </ul></li> <li>分析阶段:通过各种工具或手段,初步定位性能瓶颈点;</li> <li>调优阶段:根据定位到的瓶颈点,进行应用性能调优;</li> <li>测试阶段:让调优过的应用进行性能测试,与准备阶段的各项指标进行对比,观测其是否符合预期,如果瓶颈点没有消除或者性能指标不符合预期,则重复步骤2和3。</li> </ol></li> </ul> <h3>性能优化工具</h3> <p>下面给出了一张更为实用的「性能优化工具图谱」,该图分别从系统层、应用层(含组件层)的角度出发,列举了我们在分析性能问题时首先需要关注的各项指标<strong>(其中小旗标注的是最需要关注的)</strong>,这些点是最有可能出现性能瓶颈的地方。 <img src="https://www.showdoc.com.cn/server/api/attachment/visitFile?sign=85c3ad86f983c59fbc4672408cac03f1&amp;file=file.png" alt="" /> 系统层的工具分为CPU、内存、磁盘(含文件系统)、网络四个部分,工具集同性能工具(Linux Performance Tools-full)图中的工具基本一致。组件层和应用层中的工具构成为:JDK 提供的一些工具 + Trace 工具 + dump 分析工具 + Profiling 工具等。</p> <p>上面这张图该如何使用?</p> <ol> <li>首先,结合从不同角度分析出的结果,抽出共性,得到最终的结论<br /> 虽然从系统、组件、应用两个三个角度去描述瓶颈点的分布,但在实际运行时,这三者往往是相辅相成、相互影响的。系统是为应用提供了运行时环境,性能问题的本质就是系统资源达到了使用的上限,反映在应用层,就是应用/组件的各项指标开始下降;而应用/组件的不合理使用和设计,也会加速系统资源的耗尽。因此,分析瓶颈点时,需要我们结合从不同角度分析出的结果,抽出共性,得到最终的结论。</li> <li>其次,建议先从应用层入手<br /> <strong>分析图中标注的高频指标,抓出最重要的、最可疑的、最有可能导致性能的点,得到初步的结论后,再去系统层进行验证。</strong>这样做的好处是:很多性能瓶颈点体现在系统层,会是多变量呈现的,譬如,应用层的垃圾回收(GC)指标出现了异常,通过 JDK 自带的工具很容易观测到,但是体现在系统层上,会发现系统当前的 CPU 利用率、内存指标都不太正常,这就给我们的分析思路带来了困扰。</li> <li>最后,合理利用JProfiler等<br /> 如果瓶颈点在应用层和系统层均呈现出多变量分布,建议此时使用 ZProfiler、JProfiler 等工具对应用进行 Profiling,获取应用的综合性能信息(注:Profiling 指的是在应用运行时,通过事件(Event-based)、统计抽样(Sampling Statistical)或植入附加指令(Byte-Code instrumentation)等方法,收集应用运行时的信息,来研究应用行为的动态分析方法)。譬如,可以对 CPU 进行抽样统计,结合各种符号表信息,得到一段时间内应用内的代码热点。</li> </ol> <h3>核心性能指标</h3> <h4>CPU+线程</h4> <ol> <li> <p>CPU主要指标:</p> <ul> <li>CPU利用率(CPU Utilization):如果我们观察某段时间系统或应用进程的 CPU利用率一直很高(单个 core 超过80%),那么就值得我们警惕了。我们可以多次使用 jstack 命令 dump 应用线程栈查看热点代码,非 Java 应用可以直接使用 perf 进行 CPU 采采样,离线分析采样数据后得到 CPU 执行热点(Java 应用需要符号表进行堆栈信息映射,不能直接使用 perf得到结果)。</li> <li> <p>CPU 平均负载(Load Average):是指单位时间内,系统处于可运行状态(正在使用 CPU 或者正在等待 CPU 的进程,R 状态)和不可中断睡眠状态(必须等到某特定事件发生后才能被唤醒,D 状态;等待IO响应(如:磁盘IO,网络IO,其他外设IO等)、内核空间的互斥锁mutex_lock函数等都可以导致进程进入不可中断休眠状态)的平均进程数,也就是平均活跃进程数。<strong>平均负载高于 CPU 数量 70%,意味着系统存在瓶颈点</strong>,造成负载升高的原因有很多,在这里就不展开了。需要注意的是,通过监控系统监测平均负载的变化趋势,更容易定位问题,有时候大文件的加载等,也会导致平均负载瞬时升高。如果 1 分钟/5 分钟/15 分钟的三个值相差不大,那说明系统负载很平稳,则不用关注,如果这三个值逐渐降低,说明负载在渐渐升高,需要关注整体性能。</p> </li> <li>上下文切换次数(Context Switch):上下文切换这个指标,并没有经验值可推荐(几十到几万都有可能),这个指标值取决于系统本身的 CPU 性能,以及当前应用工作的情况。但是,如果系统或者应用的上下文切换次数出现数量级的增长,就有很大概率说明存在性能问题,如非自愿上下切换大幅度上升,说明有太多的线程在竞争 CPU。</li> </ul> </li> <li>线程主要指标:CPU 上的的一些异动,通常也可以从线程上观测到,但需要注意的是,线程问题并不完全和 CPU 相关。 <ul> <li>应用中的总的线程数是否过多。过多的线程,体现在 CPU 上就是导致频繁的上下文切换,同时线程过多也会消耗内存,线程总数大小和应用本身和机器配置相关;</li> <li>应用中各个线程状态的分布。观察 WAITING/BLOCKED 线程是否过多(线程数设置过多或锁竞争剧烈),结合应用内部锁使用的情况综合分析;</li> <li>结合 CPU 利用率,观察是否存在大量消耗 CPU 的线程。</li> </ul></li> </ol> <p>常用工具:</p> <ul> <li>top <img src="https://www.showdoc.com.cn/server/api/attachment/visitFile?sign=d59ad20e23c3af3e0163031862f5f47e&amp;file=file.png" alt="" /> <ul> <li>第一行<code>top - 10:51:55 up 63 days, 18:30, 1 user, load average: 0.10, 0.12, 0.05</code>: <ul> <li>当前时间 10:51:55</li> <li>系统运行时间 63天以前的18:30</li> <li>正在登录用户数为1</li> <li><code>load average</code>后的三个数字<code>0.10, 0.12, 0.05</code>,依次表示过去 1 分钟、5 分钟、15 分钟的平均负载。</li> <li>load average是指单位时间内,系统处于可运行状态(正在使用 CPU 或者正在等待 CPU 的进程,R 状态)和不可中断睡眠状态(D 状态)的平均进程数,也就是平均活跃进程数。 如果load average刚好等于CPU核数,那证明每个核都能得到很好的利用,如果平均负载数大于核数证明系统处于过载的状态,通常认为是超过核数的70%认为是严重过载,需要关注。还需结合1分钟平均负载,5分钟平均负载,15分钟平均负载看负载的趋势,如果1分钟负载比较高,5分钟和15分钟的平均负载都比较低,则说明是瞬间升高,需要观察。如果三个值都很高则需要关注下是否某个进程在疯狂消耗CPU或者有频繁的IO操作,也有可能是系统运行的进程太多,频繁的进程切换导致。 <strong>注意,CPU 平均负载和 CPU 使用率并没有直接关系!</strong></li> </ul></li> <li>第二行<code>Tasks: 133 total, 1 running, 132 sleeping, 0 stopped, 0 zombie</code> <ul> <li>总共133个进程</li> <li>1个正在运行</li> <li>132个休眠</li> <li>0个已停止</li> <li>0个位僵尸进程 僵尸进程是指子进程结束时父进程没有调用wait()/waitpid()等待子进程结束,那么就会产生僵尸进程。原因是子进程结束时并没有真正退出,而是留下一个僵尸进程的数据结构在系统进程表中,等待父进程清理。 如果父进程已经退出则会由init进程接替父进程进行处理(收尸)。由此可见,如果父进程不作为并且又不退出,就会有大量的僵尸进程,每个僵尸进程会占用进程表的一个位置(slot),如果僵尸进程太多会导致系统无法创建新的进程,因为进程表的容量是有限的。所以当zombie这个指标太大时需要引起我们的注意。 消灭僵尸进程的方法: <ol> <li>找到僵尸进程的父进程pid(pstree可以显示进程父子关系),kill -9 pid,父进程退出后init自动会清理僵尸进程。(需要注意的是kill -9并不能杀死僵尸进程)</li> <li>重启系统。</li> </ol></li> </ul></li> <li>第三行<code>Cpu(s): 0.1%us, 0.1%sy, 0.0%ni, 99.6%id, 0.2%wa, 0.0%hi, 0.0%si, 0.0%st</code> <ul> <li>主要展示cpu利用率,每一列的含义可以使用 man 查看。CPU 使用率体现了单位时间内 CPU 使用情况的统计,以百分比的方式展示。计算方式为:<code>CPU 利用率 = 1 - (CPU 空闲时间/ CPU 总的时间)</code>。 需要注意的是,通过性能分析工具得到的 CPU 的利用率其实是某个采样时间内的 CPU 平均值。<strong>注:top 工具显示的的 CPU 利用率是把所有 CPU 核的数值加起来的,即 8 核 CPU 的利用率最大可以到达800%(可以用<code>htop</code>等更新一些的工具代替 top)。</strong></li> <li>0.1%us:用户态占用CPU时间比例</li> <li>0.1%sy:内核态占用CPU时间比例</li> <li>0.0%ni:运行低优先级进程的CPU时间比例</li> <li>99.6%id:空闲CPU时间比例(系统空闲进程所占的总的进程的百分比)</li> <li>0.2%wa:处于IO等待的CPU时间比例</li> <li>0.0%hi:处理硬中断的CPU时间比例</li> <li>0.0%si:处理软中断的CPU时间比例</li> <li>0.0%st:表示当前系统运行在虚拟机中的时候,虚拟机占用的CPU时间比例。</li> </ul></li> </ul></li> <li>ps</li> <li>uptime</li> <li> <p>vmstat:常见的Linux监控工具,可以展现指定时间间隔的服务器的状态值,包括服务器的CPU的使用率,内存使用率,虚拟内存使用率,虚拟内存交换情况,IO读写情况。 <img src="https://www.showdoc.com.cn/server/api/attachment/visitFile?sign=995e8ac272b82ca665d3f6e1b8f58ebf&amp;file=file.png" alt="" /> 上表的 cs(context switch) 就是每秒上下文切换的次数,按照不同场景,CPU 上下文切换还可以分为<strong>中断上下文切换、线程上下文切换和进程上下文切换三种</strong>,但是无论是哪一种,过多的上下文切换,都会把 CPU 时间消耗在寄存器、内核栈以及虚拟内存等数据的保存和恢复上,从而缩短进程真正运行的时间,导致系统的整体性能大幅下降。vmstat 的输出中 us、sy 分别用户态和内核态的 CPU 利用率,这两个值也非常具有参考意义。 <strong>常见问题处理(参考)</strong></p> <ol> <li>如果在processes中运行的序列(process r)是连续的大于在系统中的CPU的个数表示系统现在运行比较慢,有多数的进程等待CPU。</li> <li>如果r的输出数大于系统中可用CPU个数的4倍的话,则系统面临着CPU短缺的问题,或者是CPU的速率过低,系统中有多数的进程在等待CPU,造成系统中进程运行过慢。</li> <li>如果空闲时间(cpu id)持续为0并且系统时间(cpu sy)是用户时间的两倍(cpu us)系统则面临着CPU资源的短缺。</li> </ol> <p>vmstat 的输出只给出了系统总体的上下文切换情况,要想查看每个进程的上下文切换详情(如自愿和非自愿切换),需要使用 pidstat,该命令还可以查看某个进程用户态和内核态的 CPU 利用率。</p> </li> <li> <p>free:查看系统内存的使用情况和 Swap 分区的使用情况</p> <pre><code class="language-bash">$free -h total used free shared buff/cache available Mem: 125G 6.8G 54G 2.5M 64G 118G Swap: 2.0G 305M 1.7G </code></pre> <p>上述输出各列的具体含义在这里不在赘述,也比较容易理解。重点介绍下 swap 和 buff/cache 这两个指标。 <strong>Swap 的作用是把一个本地文件或者一块磁盘空间作为内存来使用,包括换出和换入两个过程。</strong>Swap 需要读写磁盘,所以性能不是很高,事实上,包括 ElasticSearch 、Hadoop 在内绝大部分 Java 应用都建议关掉 Swap,这是因为内存的成本一直在降低,同时这也和 JVM 的垃圾回收过程有关:JVM在 GC 的时候会遍历所有用到的堆的内存,如果这部分内存被 Swap 出去了,遍历的时候就会有磁盘 I/O 产生。Swap 分区的升高一般和磁盘的使用强相关,具体分析时,需要结合缓存使用情况、swappiness 阈值以及匿名页和文件页的活跃情况综合分析。 <strong>buff/cache 是缓存和缓冲区的大小</strong>。<strong>缓存(cache):是从磁盘读取的文件的或者向磁盘写文件时的临时存储数据,面向文件。</strong>使用 cachestat 可以查看整个系统缓存的读写命中情况,使用 cachetop 可以观察每个进程缓存的读写命中情况。<strong>缓冲区(buffer)是写入磁盘数据或从磁盘直接读取的数据的临时存储,面向块设备。</strong>free 命令的输出中,这两个指标是加在一起的,使用 vmstat 命令可以区分缓存和缓冲区,还可以看到 Swap 分区换入和换出的内存大小。</p> </li> <li> <p>iostat:观察整个系统磁盘/文件系统相关的指标</p> <pre><code class="language-bash">$iostat -dx Linux 3.10.0-327.ali2010.alios7.x86_64 (loginhost2.alipay.em14) 10/20/2019 _x86_64_ (32 CPU) Device: rrqm/s wrqm/s r/s w/s rkB/s wkB/s avgrq-sz avgqu-sz await r_await w_await svctm %util sda 0.01 15.49 0.05 8.21 3.10 240.49 58.92 0.04 4.38 2.39 4.39 0.09 0.07 </code></pre> <p>上图中 %util ,即为磁盘 I/O 利用率,同 CPU 利用率一样,这个值也可能超过 100%(存在并行 I/O);rkB/s 和 wkB/s分别表示每秒从磁盘读取和写入的数据量,即吞吐量,单位为 KB;磁盘 I/O处理时间的指标为 r_await 和 w_await 分别表示读/写请求处理完成的响应时间,svctm 表示处理 I/O 所需要的平均时间,该指标已被废弃,无实际意义。r/s + w/s 为 IOPS 指标,分别表示每秒发送给磁盘的读请求数和写请求数;<strong>aqu-sz 表示等待队列的长度。</strong></p> </li> <li>pidstat:pidstat 的输出大部分和 iostat 类似,区别在于它可以实时查看每个进程的 I/O 情况。</li> <li>jstack</li> </ul> <h4>内存+堆</h4> <p>内存主要指标:</p> <ul> <li>系统内存的使用情况,包括剩余内存、已用内存、可用内存、缓存/缓冲区;</li> <li>进程(含 Java 进程)的虚拟内存、常驻内存、共享内存;</li> <li>进程的缺页异常数,包含主缺页异常和次缺页异常;</li> <li>Swap 换入和换出的内存大小、Swap 参数配置;</li> <li>JVM 堆的分配,JVM 启动参数;</li> <li>JVM 堆的回收,GC 情况。</li> </ul> <p>常见的内存问题:</p> <ul> <li>系统剩余内存/可用不足(某个进程占用太多、系统本身内存不足),内存溢出;</li> <li>内存回收异常:内存泄漏(进程在一段时间内内存使用持续走高)、GC 频率异常;</li> <li>缓存使用过大(大文件读取或写入)、缓存命中率不高;</li> <li>缺页异常过多(频繁的 I/O 读);</li> <li>Swap 分区使用异常(使用过大);</li> </ul> <p>分析思路:</p> <ol> <li>使用 free/top 查看内存的全局使用情况,如系统内存的使用、Swap 分区内存使用、缓存/缓冲区占用情况等,初步判断内存问题存在的方向:进程内存、缓存/缓冲区、Swap 分区;</li> <li>观察一段时间内存的使用趋势。如通过 vmstat 观察内存使用是否一直在增长;通过 jmap 定时统计对象内存分布情况,判断是否存在内存泄漏,通过 cachetop 命令,定位缓冲区升高的根源等;</li> <li>根据内存问题的类型,结合应用本身,进行详细分析。</li> </ol> <p>举例:使用 free 发现缓存/缓冲区占用不大,排除缓存/缓冲区对内存的影响后 -&gt; 使用 vmstat 或者 sar 观察一下各个进程内存使用变化趋势 -&gt; 发现某个进程的内存时候用持续走高 -&gt; 如果是 Java 应用,可以使用 jmap / VisualVM / heap dump 分析等工具观察对象内存的分配,或者通过 jstat 观察 GC 后的应用内存变化 -&gt; 结合业务场景,定位为内存泄漏/GC参数配置不合理/业务代码异常等。</p> <h4>磁盘+文件</h4> <p>磁盘主要指标:和磁盘/文件系统相关的指标主要有以下几个,常用的观测工具为 iostat和 pidstat,前者适用于整个系统,后者可观察具体进程的 I/O。</p> <ul> <li>磁盘 I/O 利用率:是指磁盘处理 I/O 的时间百分比; 磁盘吞吐量:是指每秒的 I/O 请求大小,单位为 KB;</li> <li>I/O 响应时间,是指 I/O 请求从发出到收到响应的间隔,包含在队列中的等待时间和实际处理时间;</li> <li>IOPS(Input/Output Per Second):每秒的 I/O 请求数;</li> <li>I/O 等待队列大小,指的是平均 I/O 队列长度,队列长度越短越好;</li> </ul> <p>常见的磁盘问题+分析思路:</p> <ul> <li>当磁盘 I/O 利用率长时间超过 80%,或者响应时间过大(对于 SSD,从 0.0x 毫秒到 1.x 毫秒不等,机械磁盘一般为5ms~10ms),通常意味着磁盘 I/O 存在性能瓶颈;</li> <li>如果 %util 很大,而 rkB/s 和 wkB/s 很小,一般是因为存在较多的磁盘随机读写,最好把随机读写优化成顺序读写,(可以通过 strace 或者 blktrace 观察 I/O 是否连续判断是否是顺序的读写行为,随机读写应可关注 IOPS 指标,顺序读写可关注吞吐量指标);</li> <li>如果 avgqu-sz 比较大,说明有很多 I/O 请求在队列中等待。一般来说,如果单块磁盘的队列长度持续超过2,一般认为该磁盘存在 I/O 性能问题。</li> </ul> <h4>网络</h4> <p>网络主要指标:</p> <ul> <li>网络带宽:表示链路的最大传输速率;</li> <li>网络吞吐:表示单位时间内成功传输的数据量大小;</li> <li>网络延时:表示从网络请求发出后直到收到远端响应,所需要的时间;</li> <li>网络连接数和错误数;</li> </ul> <p>常见的网络瓶颈:</p> <ul> <li>集群或机器所在的机房的网络带宽饱和,影响应用 QPS/TPS 的提升;</li> <li>网络吞吐出现异常,如接口存在大量的数据传输,造成带宽占用过高;</li> <li>网络连接出现异常或错误;</li> <li>网络出现分区。</li> </ul> <p>分析思路: 带宽和网络吞吐这两个指标,一般我们会关注整个应用的,通过监控系统可直接得到,如果一段时间内出现了明显的指标上升,说明存在网络性能瓶颈。对于单机,可以使用 sar 得到网络接口、进程的网络吞吐。 使用 ping 或者 hping3 可以得到是否出现网络分区、网络具体时延。对于应用,我们更关注整个链路的时延,可以通过中间件埋点后输出的 trace 日志得到链路上各个环节的时延信息。 使用 netstat、ss 和 sar 可以获取网络连接数或网络错误数。过多网络链接造成的开销是很大的,一是会占用文件描述符,二是会占用缓存,因此系统可以支撑的网络链接数是有限的。</p>

页面列表

ITEM_HTML