暂时未有相关云产品技术能力~
开始想直接从谷歌商店下载Chorme下载插件,但是一直报Image decode failed,网上找了好多方法未解决。后边直接下载了谷歌插件,是.crx文件,记录一下怎么加载到Chrome扩展程序,很简单,分三步强制将.crx的后缀名改为.zip或.rar文件,并解压打开Chrome扩展程序开发者模式加载已下载的扩展程序,选择你解压的文件夹至此完成,最开始的问题也解决了。
下载安装只需根据电脑系统和版本直接下载喽,大小大约10M左右!可以去官网下载想要的版本:mathtype下载官网最新版本: mathtype6.9b_trial.exeps:傻瓜式安装,关于如何嵌入到word中教程自行搜索喽!试用结束怎么办?当试用期结束,我们看到大部分功能变成灰色我们要做的就是删除注册表的一个文件就可以啦!使用命令行窗口(win+R)进入注册表然后我们根据以下提示找到该文件,直接右击暴力删除!嘿嘿,然后我们再回到word中看看,灰色没有了,又可以试用了,真香!ps:本人学习记录所用,分享给大家,这个方法太简单了。
TCP/IP中使用的是IP地址和端口号来确定网络上某一台主机上的某一个程序,不免有人有疑问,为什么不用域名来直接进行通信呢?因为IP地址是固定长度的,IPv4是32位,IPv6是128位,而域名是变长的,不便于计算机处理。IP地址对于用户来说不方便记忆,但域名便于用户使用,例如www.baidu.com这是百度的域名。总结:IP地址是面向主机的,而域名则是面向用户的。DNS(域名系统)是因特网使用的命名系统,用于解决IP地址和域名的映射关系ps:域名和IP的对应关系保存在一个叫hosts文件中。最初,通过互联网信息中心来管理这个文件,如果有一个新的计算机想接入网络,或者某个计算IP变更都需要到信息中心申请变更hosts文件。其他计算机也需要定期更新,才能上网。但是这样太麻烦了,就出现了DNS系统。一个组织的系统管理机构, 维护系统内的每个主机的IP和主机名的对应关系如果新计算机接入网络,将这个信息注册到数据库中用户输入域名的时候,会自动查询DNS服务器,由DNS服务器检索数据库,得到对应的IP。在域名解析的过程中仍然会优先查找hosts文件的内容。DNS域名结构域名系统必须要保持唯一性。命名:每一个域名(本文只讨论英文域名)都是一个标号序列(labels),用字母(A-Z,a-z,大小写等价)、数字(0-9)和连接符(-)组成标号序列总长度不能超过255个字符,它由点号分割成一个个的标号(label)每个标号应该在63个字符之内,每个标号都可以看成一个层次的域名。级别最低的域名写在左边,级别最高的域名写在右边。域名服务主要是基于UDP实现的,服务器的端口号为53。其中顶级域名分为:国家顶级域名、通用顶级域名、反向域名。DNS域名服务器域名是分层结构,域名服务器也是对应的层级结构。有了域名结构,还需要有一个东西去解析域名,域名需要由遍及全世界的域名服务器去解析,域名服务器实际上就是装有域名系统的主机。由高向低进行层次划分,可分为以下几大类:注:一个域名服务器所负责的范围,或者说有管理权限的范围,就称为区。我们需要注意的是:每个层的域名上都有自己的域名服务器,最顶层的是根域名服务器;每一级域名服务器都知道下级域名服务器的IP地址为了容灾, 每一级至少设置两个或以上的域名服务器DNS域名解析过程域名解析总体可分为一下过程:输入域名后, 先查找自己主机对应的域名服务器,域名服务器先查找自己的数据库中的数据.如果没有, 就向上级域名服务器进行查找, 依次类推最多回溯到根域名服务器, 肯定能找到这个域名的IP地址域名服务器自身也会进行一些缓存, 把曾经访问过的域名和对应的IP地址缓存起来, 可以加速查找过程具体过程:主机先向本地域名服务器进行递归查询本地域名服务器采用迭代查询,向一个根域名服务器进行查询根域名服务器告诉本地域名服务器,下一次应该查询的顶级域名服务器的IP地址本地域名服务器向顶级域名服务器进行查询顶级域名服务器告诉本地域名服务器,下一步查询权限服务器的IP地址本地域名服务器向权限服务器进行查询权限服务器告诉本地域名服务器所查询的主机的IP地址本地域名服务器最后把查询结果告诉主机拓展:递归查询和迭代查询递归查询:本机向本地域名服务器发出一次查询请求,就静待最终的结果。如果本地域名服务器无法解析,自己会以DNS客户机的身份向其它域名服务器查询,直到得到最终的IP地址告诉本机迭代查询:本地域名服务器向根域名服务器查询,根域名服务器告诉它下一步到哪里去查询,然后它再去查,每次它都是以客户机的身份去各个服务器查询。通俗地说递归就是把一件事情交给别人,如果事情没有办完,哪怕已经办了很多,都不要把结果告诉我,我要的是你的最终结果,而不是中间结果;如果你没办完,请你找别人办完。迭代则是我交给你一件事,你能办多少就告诉我你办了多少,然后剩下的事情就由我来办。在浏览器中输入url地址 ->> 显示主页的过程简单来讲,分为域名输入、DNS解析、TCP连接、发送HTTP请求、响应HTTP请求,解析渲染、断开连接。主要经历以下流程:浏览器解析输入:地址栏会根据用户输入,做出如下判断:输入的是非 URL 结构的字符串,则会用浏览器默认的搜索引擎搜索该字符串;输入的是 URL 结构字符串,则会构建完整的 URL 结构,浏览器进程会将完整的 URL 通过进程间通信,即 IPC,发送给网络进程DNS解析:在网络进程接收到 URL 后,并不是马上对指定 URL 进行请求。首先进行DNS 解析域名得到对应的 IP,然后通过 ARP 解析 IP 得到对应的 MAC地址。DNS解析域名(寻址)的过程:浏览器缓存:询问浏览器 DNS 缓存本地系统缓存:询问本地操作系统 DNS 缓存(即查找本地 hosts 文件)路由器缓存:查询路由器的DNS缓存ISP DNS 缓存:询问 ISP(Internet Service Provider)互联网服务提供商(例如电信、移动)的 DNS 服务器询问根服务器,这个过程可以进行递归和迭代两种查找的方式,两者都是先询问顶级域名服务器查找(DNS服务器先问根域名服务器.com域名服务器的IP地址,然后再问.com域名服务器,依次类推)浏览器向服务器发起tcp连接:解析出IP地址后,根据IP地址和默认端口,和服务器三次握手建立TCP连接。发送HTTP请求:浏览器发出读取文件(URL 中域名后面部分对应的文件)的HTTP 请求。cookie会随着请求发给服务器。服务器处理请求并返回HTTP报文:服务器对浏览器请求作出响应,并把对应的 html 文本发送给浏览器浏览器解析渲染页面:显示html文本内容释放TCP连接:连接结束(1)现代浏览器在与服务器建立了一个 TCP 连接后是否会在一个 HTTP 请求完成后断开?什么情况下会断开?在 HTTP/1.0 中,一个服务器在发送完一个HTTP 响应后,会断开 TCP 链接。但是这样每次请求都会重新建立和断开 TCP 连接,代价过大。HTTP/1.1 就把 Connection:keep-alive 头写进标准,并且默认开启持久连接(长连接),默认情况下建立 TCP 连接不会断开,只有在请求报头中声明 Connection: close 才会在请求完成后关闭连接。(2)一个 TCP 连接可以对应几个 HTTP 请求?如果维持连接,一个 TCP 连接是可以发送多个 HTTP 请求的。可以理解为复用。(3)一个 TCP 连接中 HTTP 请求发送可以一起发送么(比如一起发三个请求,再三个响应一起接收)?HTTP/1.1 存在一个问题,单个 TCP 连接在同一时刻只能处理一个请求,意思是说:两个请求的生命周期不能重叠,任意两个 HTTP 请求从开始到结束的时间在同一个 TCP 连接里不能重叠。虽然 HTTP/1.1 规范中规定了 Pipelining 来试图解决这个问题,但是这个功能在浏览器中默认是关闭的。但是,一些代理服务器不能正确的处理 HTTP Pipelining。正确的流水线实现是复杂的。HTTP2 提供了 Multiplexing 多路传输特性,可以在一个 TCP 连接中同时完成多个 HTTP 请求。多个 HTTP 请求可以在同一个 TCP 连接中并行进行。(4)为什么有的时候刷新页面不需要重新建立 SSL 连接?TCP 连接有的时候会被浏览器和服务端维持一段时间。TCP 不需要重新建立,SSL 自然也会用之前的。
SOCKET原理与连接?(1)基本概念:在TCP/IP协议栈中,在网络层IP地址可以代表唯一的一台主机,但是实际上网络通信是主机应用程序之间的通信,一个主机可能有很多进程并发执行,因此还需要端口号来区分进程。故,网络中的进程可以用 IP+端口号+协议 进行唯一标识。socket是应用层和传输层的一种抽象层,是应用层与传输层的接口,是支持TCP网络通信的基本通信单元,完成了不同进程间的通信。在互联网中,通信模式是客户端(client)与服务器(server)的端点对端点的通信模式。标识每个端点IP地址和端口号称为套接字。socket={IP:PORT}。标识源IP地址,源port,目的IP 地址,目的port,协议统称为套接字对。(2)网络进程通信:拓展:本地的进程间通信(IPC)有很多种方式,但可以总结为下面4类:消息传递(管道、FIFO、消息队列)同步(互斥量、条件变量、读写锁、文件和写记录锁、信号量)共享内存(匿名的和具名的)远程过程调用(Solaris门和Sun RPC)网络中进程之间如何通信?首要解决的问题是如何唯一标识一个进程,否则通信无从谈起!在本地可以通过进程PID来唯一标识一个进程,但是在网络中这是行不通的。其实TCP/IP协议族已经帮我们解决了这个问题,网络层的“ip地址”可以唯一标识网络中的主机,而传输层的“协议+端口”可以唯一标识主机中的应用程序(进程)。这样利用三元组(ip地址,协议,端口)就可以标识网络的进程了,网络中的进程通信就可以利用这个标志与其它进程进行交互。使用TCP/IP协议的应用程序通常采用应用编程接口:UNIX BSD的套接字(socket)和UNIX System V的TLI(已经被淘汰),来实现网络进程之间的通信。就目前而言,几乎所有的应用程序都是采用socket,而现在又是网络时代,网络中进程通信是无处不在,这就是我为什么说“一切皆socket”。(3)建立socket连接:建立socket连接至少需要一对套接字,其中一个运行于客户端,称为ClientSocket ,另一个运行于服务器端,称为ServerSocket 。套接字之间的连接过程分为三个步骤:服务器监听,客户端请求,连接确认。服务器监听:服务器端套接字并不定位具体的客户端套接字,而是处于等待连接的状态,实时监控网络状态,等待客户端的连接请求。客户端请求:指客户端的套接字提出连接请求,要连接的目标是服务器端的套接字。为此,客户端的套接字必须首先描述它要连接的服务器的套接字,指出服务器端套接字的地址和端口号,然后就向服务器端套接字提出连接请求。连接确认:当服务器端套接字监听到或者说接收到客户端套接字的连接请求时,就响应客户端套接字的请求,建立一个新的线程,把服务器端套接字的描述发给客户端,一旦客户端确认了此描述,双方就正式建立连接。而服务器端套接字继续处于监听状态,继续接收其他客户端套接字的连接请求。(3)SOCKET连接与TCP连接:创建Socket连接时,可以指定使用的传输层协议,Socket可以支持不同的传输层协议(TCP或UDP),当使用TCP协议进行连接时,该Socket连接就是一个TCP连接。(4)Socket连接与HTTP连接:由于通常情况下Socket连接就是TCP连接,因此Socket连接一旦建立,通信双方即可开始相互发送数据内容,直到双方连接断开。但在实际网络应用 中,客户端到服务器之间的通信往往需要穿越多个中间节点,例如路由器、网关、防火墙等,大部分防火墙默认会关闭长时间处于非活跃状态的连接而导 致 Socket 连接断连,因此需要通过轮询告诉网络,该连接处于活跃状态。而HTTP连接使用的是“请求—响应”的方式,不仅在请求时需要先建立连接,而且需要客户端向服务器发出请求后,服务器端才能回复数据。很多情况下,需要服务器端主动向客户端推送数据,保持客户端与服务器数据的实时与同步。此时若双方建立的是Socket连接,服务器就可以直接将数据传送给 客户端;若双方建立的是HTTP连接,则服务器需要等到客户端发送一次请求后才能将数据传回给客户端,因此,客户端定时向服务器端发送连接请求,不仅可以 保持在线,同时也是在“询问”服务器是否有新的数据,如果有就将数据传给客户端。socket的基本操作既然socket是“open—write/read—close”模式的一种实现,那么socket就提供了这些操作对应的函数接口。下面以TCP为例,介绍几个基本的socket接口函数。(1)socket()函数int socket(int domain, int type, int protocol);socket函数对应于普通文件的打开操作。普通文件的打开操作返回一个文件描述字,而socket()用于创建一个socket描述符(socket descriptor),它唯一标识一个socket。这个socket描述字跟文件描述字一样,后续的操作都有用到它,把它作为参数,通过它来进行一些读写操作。正如可以给fopen的传入不同参数值,以打开不同的文件。创建socket的时候,也可以指定不同的参数创建不同的socket描述符,socket函数的三个参数分别为:domain:即协议域,又称为协议族(family)。常用的协议族有,AF_INET、AF_INET6、AF_LOCAL(或称AF_UNIX,Unix域socket)、AF_ROUTE等等。协议族决定了socket的地址类型,在通信中必须采用对应的地址,如AF_INET决定了要用ipv4地址(32位的)与端口号(16位的)的组合、AF_UNIX决定了要用一个绝对路径名作为地址。type:指定socket类型。常用的socket类型有,SOCK_STREAM、SOCK_DGRAM、SOCK_RAW、SOCK_PACKET、SOCK_SEQPACKET等等(socket的类型有哪些?)。protocol:故名思意,就是指定协议。常用的协议有,IPPROTO_TCP、IPPTOTO_UDP、IPPROTO_SCTP、IPPROTO_TIPC等,它们分别对应TCP传输协议、UDP传输协议、STCP传输协议、TIPC传输协议(这个协议我将会单独开篇讨论!)。注意:并不是上面的type和protocol可以随意组合的,如SOCK_STREAM不可以跟IPPROTO_UDP组合。当protocol为0时,会自动选择type类型对应的默认协议。当我们调用socket创建一个socket时,返回的socket描述字它存在于协议族(address family,AF_XXX)空间中,但没有一个具体的地址。如果想要给它赋值一个地址,就必须调用bind()函数,否则就当调用connect()、listen()时系统会自动随机分配一个端口。bind()函数正如上面所说bind()函数把一个地址族中的特定地址赋给socket。例如对应AF_INET、AF_INET6就是把一个ipv4或ipv6地址和端口号组合赋给socket。int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);函数的三个参数分别为:sockfd:即socket描述字,它是通过socket()函数创建了,唯一标识一个socket。bind()函数就是将给这个描述字绑定一个名字。addr:一个const struct sockaddr *指针,指向要绑定给sockfd的协议地址。这个地址结构根据地址创建socket时的地址协议族的不同而不同addrlen:对应的是地址的长度。通常服务器在启动的时候都会绑定一个众所周知的地址(如ip地址+端口号),用于提供服务,客户就可以通过它来接连服务器;而客户端就不用指定,有系统自动分配一个端口号和自身的ip地址组合。这就是为什么通常服务器端在listen之前会调用bind(),而客户端就不会调用,而是在connect()时由系统随机生成一个。网络字节序与主机字节序主机字节序就是我们平常说的大端和小端模式:不同的CPU有不同的字节序类型,这些字节序是指整数在内存中保存的顺序,这个叫做主机序。引用标准的Big-Endian和Little-Endian的定义如下:a) Little-Endian就是低位字节排放在内存的低地址端,高位字节排放在内存的高地址端。b) Big-Endian就是高位字节排放在内存的低地址端,低位字节排放在内存的高地址端。网络字节序:4个字节的32 bit值以下面的次序传输:首先是0~7bit,其次8~15bit,然后16~23bit,最后是24~31bit。这种传输次序称作大端字节序。由于TCP/IP首部中所有的二进制整数在网络中传输时都要求以这种次序,因此它又称作网络字节序。字节序,顾名思义字节的顺序,就是大于一个字节类型的数据在内存中的存放顺序,一个字节的数据没有顺序的问题了。所以:在将一个地址绑定到socket的时候,请先将主机字节序转换成为网络字节序,而不要假定主机字节序跟网络字节序一样使用的是Big-Endian。由于这个问题曾引发过血案!公司项目代码中由于存在这个问题,导致了很多莫名其妙的问题,所以请谨记对主机字节序不要做任何假定,务必将其转化为网络字节序再赋给socket。listen()、connect()函数如果作为一个服务器,在调用socket()、bind()之后就会调用listen()来监听这个socket,如果客户端这时调用connect()发出连接请求,服务器端就会接收到这个请求。int listen(int sockfd, int backlog);int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);listen函数的第一个参数即为要监听的socket描述字,第二个参数为相应socket可以排队的最大连接个数。socket()函数创建的socket默认是一个主动类型的,listen函数将socket变为被动类型的,等待客户的连接请求。connect函数的第一个参数即为客户端的socket描述字,第二参数为服务器的socket地址,第三个参数为socket地址的长度。客户端通过调用connect函数来建立与TCP服务器的连接。accept()函数TCP服务器端依次调用socket()、bind()、listen()之后,就会监听指定的socket地址了。TCP客户端依次调用socket()、connect()之后就想TCP服务器发送了一个连接请求。TCP服务器监听到这个请求之后,就会调用accept()函数取接收请求,这样连接就建立好了。之后就可以开始网络I/O操作了,即类同于普通文件的读写I/O操作。int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);</pre>accept函数的第一个参数为服务器的socket描述字,第二个参数为指向struct sockaddr *的指针,用于返回客户端的协议地址,第三个参数为协议地址的长度。如果accpet成功,那么其返回值是由内核自动生成的一个全新的描述字,代表与返回客户的TCP连接。注意:accept的第一个参数为服务器的socket描述字,是服务器开始调用socket()函数生成的,称为监听socket描述字;而accept函数返回的是已连接的socket描述字。一个服务器通常通常仅仅只创建一个监听socket描述字,它在该服务器的生命周期内一直存在。内核为每个由服务器进程接受的客户连接创建了一个已连接socket描述字,当服务器完成了对某个客户的服务,相应的已连接socket描述字就被关闭。read()、write()等函数万事具备只欠东风,至此服务器与客户已经建立好连接了。可以调用网络I/O进行读写操作了,即实现了网咯中不同进程之间的通信!网络I/O操作有下面几组:read()/write()recv()/send()readv()/writev()recvmsg()/sendmsg()recvfrom()/sendto()我推荐使用recvmsg()/sendmsg()函数,这两个函数是最通用的I/O函数,实际上可以把上面的其它函数都替换成这两个函数。它们的声明如下:#include <unistd.h> ssize_t read(int fd, void *buf, size_t count); ssize_t write(int fd, const void *buf, size_t count); #include <sys/types.h> #include <sys/socket.h> ssize_t send(int sockfd, const void *buf, size_t len, int flags); ssize_t recv(int sockfd, void *buf, size_t len, int flags); ssize_t sendto(int sockfd, const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, socklen_t addrlen); ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags, struct sockaddr *src_addr, socklen_t *addrlen); ssize_t sendmsg(int sockfd, const struct msghdr *msg, int flags); ssize_t recvmsg(int sockfd, struct msghdr *msg, int flags); </pre>read函数是负责从fd中读取内容.当读成功时,read返回实际所读的字节数,如果返回的值是0表示已经读到文件的结束了,小于0表示出现了错误。如果错误为EINTR说明读是由中断引起的,如果是ECONNREST表示网络连接出了问题。write函数将buf中的nbytes字节内容写入文件描述符fd.成功时返回写的字节数。失败时返回-1,并设置errno变量。 在网络程序中,当我们向套接字文件描述符写时有俩种可能。1)write的返回值大于0,表示写了部分或者是全部的数据。2)返回的值小于0,此时出现了错误。我们要根据错误类型来处理。如果错误为EINTR表示在写的时候出现了中断错误。如果为EPIPE表示网络连接出现了问题(对方已经关闭了连接)。其它的我就不一一介绍这几对I/O函数了,具体参见man文档或者baidu、Google,下面的例子中将使用到send/recv。close()函数在服务器与客户端建立连接之后,会进行一些读写操作,完成了读写操作就要关闭相应的socket描述字,好比操作完打开的文件要调用fclose关闭打开的文件。int close(int fd);close一个TCP socket的缺省行为时把该socket标记为以关闭,然后立即返回到调用进程。该描述字不能再由调用进程使用,也就是说不能再作为read或write的第一个参数。注意:close操作只是使相应socket描述字的引用计数-1,只有当引用计数为0的时候,才会触发TCP客户端向服务器发送终止连接请求。Cookie和Session作用是什么,有什么区别?Cookie 是服务器发送到用户浏览器并保存在本地的一小块数据,它会在浏览器之后向同一服务器再次发起请求时被携带上,用于告知服务端两个请求是否来自同一浏览器。由于之后每次请求都会需要携带 Cookie 数据,因此会带来额外的性能开销(尤其是在移动环境下)。Cookie 是客户端保存用户信息的一种机制,用来记录用户的一些信息,也是实现Session的一种方式。Session是存在服务器的一种用来存放用户数据的类HashTable结构。当浏览器第一次发送请求时,服务器自动生成了一个HashTable和一个Session ID用来唯一标识这个HashTable,并将其通过响应发送到浏览器。当浏览器第二次发送请求,会将前一次服务器响应中的Session ID放在请求中一并发送到服务器上,服务器从请求中提取出Session ID,并和保存的所有Session ID进行对比,找到这个用户对应的HashTable。Session是服务端保存的一个数据结构,用来跟踪用户的状态,这个数据可以保存在集群、数据库或文件中。两者主要区别:应用场景不同:Cookie 和 Session都是用来跟踪浏览器用户身份的会话方式,但是两者的应用场景不太一样。Cookie 一般用来保存用户信息。比如①我们在 Cookie 中保存已经登录过得用户信息,下次访问网站的时候页面可以自动帮你登录的一些基本信息给填了;②一般的网站都会有保持登录也就是说下次你再访问网站的时候就不需要重新登录了,这是因为用户登录的时候我们可以存放了一个 Token 在 Cookie 中,下次登录的时候只需要根据 Token 值来查找用户即可(为了安全考虑,重新登录一般要将 Token 重写);③登录一次网站后访问网站其他页面不需要重新登录。Session 的主要作用就是通过服务端记录用户的状态,即用户的验证。 典型的场景是购物车,当你要添加商品到购物车的时候,系统不知道是哪个用户操作的,因为 HTTP 协议是无状态的。服务端给特定的用户创建特定的 Session 之后就可以标识这个用户并且跟踪这个用户了。数据存储位置不同:Cookie 数据保存在客户端(浏览器端),Session 数据保存在服务器端。数据安全性不同:Cookie 存储在客户端中,而Session存储在服务器上,相对来说 Session 安全性更高。如果要在 Cookie 中存储一些敏感信息,不要直接写入 Cookie 中,最好能将 Cookie 信息加密然后使用到的时候再去服务器端解密。存储数据类型不同:Cookie 只能存储 ASCII 码,而 Session 可以存储任何类型的数据。补充:session 的运行依赖 session id,而 session id 存在 cookie 中,叫做 JSESSIONID。如果浏览器禁用了 cookie ,同时 session 也会失效(可以通过其它方式实现,比如在 url 中传递 session_id)
redis事务基本实现Redis 可以通过 MULTI,EXEC,DISCARD 和 WATCH 等命令来实现事务(transaction)功能。> MULTI OK > SET USER "Guide哥" QUEUED > GET USER QUEUED > EXEC 1) OK 2) "Guide哥"使用 MULTI命令后可以输入多个命令。Redis 不会立即执行这些命令,而是将它们放到队列,当调用了EXEC命令将执行所有命令。过程如下:开始事务(MULTI)。命令入队(批量操作 Redis 的命令,先进先出(FIFO)的顺序执行)。执行事务(EXEC)。你也可以通过 DISCARD 命令取消一个事务,它会清空事务队列中保存的所有命令。> MULTI OK > SET USER "Guide哥" QUEUED > GET USER QUEUED > DISCARD OKWATCH 命令用于监听指定的键,当调用 EXEC 命令执行事务时,如果一个被 WATCH 命令监视的键被修改的话,整个事务都不会执行,直接返回失败。> WATCH USER OK > MULTI > SET USER "Guide哥" OK > GET USER Guide哥 > EXEC ERR EXEC without MULTIRedis 官网相关介绍 https://redis.io/topics/transactions 如下:但是,Redis 的事务和我们平时理解的关系型数据库的事务不同。我们知道事务具有四大特性: 1. 原子性,2. 隔离性,3. 持久性,4. 一致性。原子性(Atomicity): 事务是最小的执行单位,不允许分割。事务的原子性确保动作要么全部完成,要么完全不起作用;隔离性(Isolation): 并发访问数据库时,一个用户的事务不被其他事务所干扰,各并发事务之间数据库是独立的;持久性(Durability): 一个事务被提交之后。它对数据库中数据的改变是持久的,即使数据库发生故障也不应该对其有任何影响。一致性(Consistency): 执行事务前后,数据保持一致,多个事务对同一个数据读取的结果是相同的;关于 redis 没有事务回滚?首先原子性的定义:事务中的命令要么全部被执行,要么全部都不执行。然后再看官方文档关键段:Redis commands can fail during a transaction, but still Redis will execute the rest of the transaction instead of rolling back Redis 在事务失败时不进行回滚,而是继续执行余下的命令我根据Redis文档理解,认为事务过程中失败有两种可能:Redis 命令只会因为错误的语法而失败(并且这些问题不能在入队时发现),或是命令中用在了错误类型的键上面,所以如果在生产环境中你使用的正常命令,那么在 Redis 事务中,是不会出现错误而导致回滚的。来自文档:Redis commands can fail only if called with a wrong syntax...事务执行一半,Redis宕机。如果 Redis 服务器因为某些原因被管理员杀死,或者遇上某种硬件故障,那么可能只有部分事务命令会被成功写入到磁盘中。如果 Redis 在重新启动时发现 AOF 文件出了这样的问题,那么它会退出,并汇报一个错误。使用redis-check-aof程序可以修复这一问题:它会移除 AOF 文件中不完整事务的信息,确保服务器可以顺利启动注意:若在事务队列中存在命令性错误(类似于java编译性错误),则执行EXEC命令时,所有命令都不会执行若在事务队列中存在语法性错误(类似于java的1/0的运行时异常),则执行EXEC命令时,其他正确命令会被执行,错误命令抛出异常。Redis 官网也解释了自己为啥不支持回滚。简单来说就是 Redis 开发者们觉得没必要支持回滚,这样更简单便捷并且性能更好(回滚还需要解决回滚事务覆盖的问题)。Redis 开发者觉得即使命令执行错误也应该在开发过程中就被发现而不是生产过程中。你可以将 Redis 中的事务就理解为 :Redis 事务提供了一种将多个命令请求打包的功能。然后,再按顺序执行打包的所有命令,并且不会被中途打断。分布式事务锁基础知识:分布式:简单来说就是将业务进行拆分,部署到不同的机器来协调处理,保证高性能。比如用户在网上买东西,大致分为:订单系统、库存系统、支付系统、、、、这些系统共同来完成用户买东西这个业务操作。集群:同一个业务,通过部署多个实例来完成,保证应用的高可用。如果其中某个实例挂了,业务仍然可以正常进行,通常集群和分布式配合使用。来保证系统的高可用、高性能。分布式事务:按照传统的系统架构,下单、扣库存等等,都是在单机系统中, 这一系列的操作都是一个数据库中完成的,也就是说保证了事务的ACID特性。如果在分布式应用中就会涉及到跨应用、跨库。这样就涉及到了分布式事务,就要考虑怎么保证这一系列的操作要么都成功要么都失败。保证数据的一致性。分布式锁:因为资源有限,要通过互斥来保持一致性,比如下订单的数据库操作和支付的数据库操作就是一个保证互斥性, 不能同时去执行, 不然那就会出现一旦支付出现失败, 那么下订单也得重新下订单, 这就不合理了, 所以引入分布式事务锁。分布式事务锁:分布式锁是控制分布式系统之间同步访问共享资源的一种方式。如果不同的系统或是同一个系统的不同主机之间共享了一个或一组资源,那么访问这些资源的时候,往往需要互斥来防止彼此干扰来保证一致性,在这种情况下,便需要使用到分布式锁。简单来说就是好几个节点访问一个资源, 那我就是使用额外的锁机制互斥的只让其中一个能进行访问核心思想:在被保护的redis节点加一把锁, 让这把锁和被保护的redis节建立直接映射在访问这个redis之前都去看看这把锁在不在如果不存在锁,说明没有客户端使用,可以执行任务,执行完毕,解锁,删除锁 (并且要保证判断有无锁和加锁是原子操作)如果存在锁,则认为有其他客户端在使用,等待锁消失基本操作:Redis中可以使用SETNX命令实现分布式锁,SETNX——SET if Not exists(如果不存在,则设置)setnx key value如果需要解锁,使用 del key 命令就能释放锁分布式锁存在的问题,解决方案Q1:当一个客户端上锁之后服务宕机,由于锁是他上的只有他可以进行redis访问的,别人无法访问,所以导致锁无法被删除.A1:给锁设置一个过期时间,可以通过两种方法实现:通过命令 “setnx 键名 过期时间 “;或者通过设置锁的expire(失效)时间,让Redis去删除锁。Q2:当一个客户端设置了锁的失效时间, 但是这个客户端并没有宕机, 只是真的需要那么多时间来进行操作,也就是任务执行过长,超过过期时间。A2:实际是通过客户端的一个守护线程,大概时间快到的时间给线程续命。Q3:任务执行造成死循环,会造成无限续命A3:设置最大续命时间, 或者设置最大续命次数
算法描述使用归并排序进行升序排列。示例:输入:nums = [5,1,1,2,0,0] 输出:[0,0,1,1,2,5]算法设计基本思路:借助额外空间,合并两个有序数组,得到更长的有序数组。例如:「力扣」第 88 题:合并两个有序数组。核心函数:mergeSort函数(分治思想)和merge函数(合并有序数组),分治过程一定情况下可以实现并行化。算法优化优化1:小区间采用插入排序Java 源码里面也有类似这种操作,「小区间」的长度是个超参数,需要测试决定,我这里参考了 JDK 源码;优化2:两个数组本身有序,无需合并左边界大于右边界或者要归并的两个数组已经有序,无需进行merge过程。优化3:全程使用一份临时数组进行两个数组的合并操作避免创建临时数组和销毁的消耗,避免计算下标偏移量。即使用System.arraycopy(nums, left, tmp, left, right - left + 1)拷贝到tmp临时数组。优化4:位运算求解中间值对Java语言,left + right >>> 1 在可能存在溢出的情况下,结论也是正确的。代码实现class Solution { // 列表大小大于或等于该值,将优先使用归并排序,否则使用插入排序 private static final int INSERTION_SORT_THRESHOLD = 7; public int[] sortArray(int[] nums) { int n = nums.length; int[] tmp = new int[n]; mergeSort(nums, 0, n - 1, tmp); return nums; } private void mergeSort(int[] nums, int left, int right, int[] tmp) { if (right - left <= INSERTION_SORT_THRESHOLD) { insertionSort(nums, left, right); return; } // int mid = left + (right - left) / 2; int mid = left + right >>> 1; mergeSort(nums, left, mid, tmp); mergeSort(nums, mid + 1, right, tmp); // 如果子区间本身有序,则无需合并 if (nums[mid] <= nums[mid + 1]) { return; } merge(nums, left, mid, right, tmp); } private void insertionSort(int[] nums, int left, int right) { for (int i = left + 1; i <= right; ++i) { int tmp = nums[i]; int j = i; while (j > left && nums[j - 1] > tmp) { nums[j] = nums[j - 1]; j--; } nums[j] = tmp; } } private void merge(int[] nums, int left, int mid, int right, int[] tmp) { System.arraycopy(nums, left, tmp, left, right - left + 1); int i = left; int j = mid + 1; for (int k = left; k <= right; k++) { if (i == mid + 1) { nums[k] = tmp[j]; j++; } else if (j == right + 1) { nums[k] = tmp[i]; i++; } else if (tmp[i] <= tmp[j]) { // 注意写成 < 就丢失了稳定性(相同元素原来靠前的排序以后依然靠前) nums[k] = tmp[i]; i++; } else { // tmp[i] > tmp[j]:先放较小的元素 nums[k] = tmp[j]; j++; } } } }注意点(补充):实现归并排序的时候,要特别注意,不要把这个算法实现成非稳定排序,区别就在 <= 和 < ,已在代码中注明。「归并排序」比「快速排序」好的一点是,它借助了额外空间,可以实现「稳定排序」,Java 里对于「对象数组」的排序任务,就是使用归并排序(的升级版 TimSort,在这里就不多做介绍)。时间复杂度:O(N log N),这里 N 是数组的长度;空间复杂度:O(N),辅助数组与输入数组规模相当。「归并排序」也有「原地归并排序」和「不使用递归」的归并排序,但是我个人觉得不常用,编码、调试都有一定难度。经典问题:《剑指 Offer》第 51 题:数组中的逆序对,照着归并排序的思路就能写出来。「力扣」第 315 题:计算右侧小于当前元素的个数,它们是一个问题。应用拓展示例1:「力扣」第 88 题合并两个有序数组注意:归并排序的merge过程,但是考虑不使用额外空间,技巧:倒序遍历两个数组(先添加较大的)代码实现:class Solution { public void merge(int[] nums1, int m, int[] nums2, int n) { int i = m - 1; int j = n - 1; int merge = m + n - 1; while (i > -1 || j > -1) { if (j < 0) { nums1[merge--] = nums1[i--]; } else if (i < 0) { nums1[merge--] = nums2[j--]; } else if (nums1[i] > nums2[j]) { nums1[merge--] = nums1[i--]; } else { nums1[merge--] = nums2[j--]; } } } }示例2:《剑指 Offer》第 51 题:数组中的逆序对注意:暴力解超时!必然进行时间优化,这里利用归并排序分治思想(降低时间复杂度)+合并两个数组的有序性(统计逆序对的个数)排序的过程是必要的,正是因为排序才加速下一轮的统计逆序对的速度。分治先统计左右区间的逆序对,再计算跨区间的逆序对(这里我们采用在第2个区间归并时,即j指向元素归并回去的时候,统计逆序数 == 前面还没有归并回去的元素个数:mid - i + 1)即以左边的元素比当前大的元素个数,统计逆序对。ps:这里不要求排序所以需要拷贝一份nums数组。ps2:以右侧小于当前元素个数的方式统计逆序数的个数,类似下面的315题,只需要改成归并i,统计逆序数 == j - mid + 1,如果只是统计逆序数,左右无区别,如本题。代码实现:class Solution { public int reversePairs(int[] nums) { int n = nums.length; if (n < 2) { return 0; } int[] copy = new int[n]; System.arraycopy(nums, 0, copy, 0, n); int[] tmp = new int[n]; return reversePairs(copy, 0, n - 1, tmp); } private int reversePairs(int[] copy, int left, int right, int[] tmp) { if (left >= right) { return 0; } int mid = left + ((right - left) >> 1); int leftPairs = reversePairs(copy, left, mid, tmp); int rightPairs = reversePairs(copy, mid + 1, right, tmp); if (copy[mid] <= copy[mid + 1]) { return leftPairs + rightPairs; } int crossPairs = mergeAndCount(copy, left, mid, right, tmp); return leftPairs + rightPairs + crossPairs; } private int mergeAndCount(int[] copy, int left, int mid, int right, int[] tmp) { System.arraycopy(copy, left, tmp, left, right - left + 1); int i = left; int j = mid + 1; int count = 0; for (int k = left; k <= right; k++) { if (i == mid + 1) { copy[k] = tmp[j]; j++; } else if (j == right + 1) { copy[k] = tmp[i]; i++; } else if (tmp[i] <= tmp[j]) { copy[k] = tmp[i]; i++; } else { copy[k] = tmp[j]; j++; // 当j指向元素归并进去,计算逆序数 count += (mid - i + 1); } } return count; } }示例3:「力扣」第 315 题:计算右侧小于当前元素的个数注意:数据量很大,暴力必然超时!与上题相同,即每个位置逆序数的个数。需要在「前有序数组」的元素归并的时候,数一数「后有序数组」已经归并回去的元素的个数,因为这些已经出列的元素都比当前出列的元素要(严格)小;一个元素在算法的执行过程中位置发生变化,我们还想定位它,可以使用「索引数组」(记录元素下标),技巧在于:「原始数组」不变,用于比较两个元素的大小,真正位置变化的是「索引数组」的位置;归并回去时,方便知道哪个下标的元素。ps: res[indexes[k]] += (j - mid - 1); 理解:indexes[k] 表示当前 k 下标对应的原始数组的下标是多少,外面再套一层 res[indexes[k]] 记录了原始数组的对应下标右侧小于当前元素的个数,在排序的过程中,每一次计算一部分,所以是 += 。整个算法完成以后,indexes 映射成输入数组的元素的值以后,是有序的。代码实现:核心:比较数值,操作下标class Solution { public List<Integer> countSmaller(int[] nums) { List<Integer> result = new ArrayList<>(); int n = nums.length; if (n < 1) { return result; } int[] ans = new int[n]; int[] tmp = new int[n]; int[] indexs = new int[n]; for (int i = 0; i < n; i++) { indexs[i] = i; } countSmaller(nums, 0, n - 1, tmp, indexs, ans); for (int num : ans) { result.add(num); } return result; } private void countSmaller(int[] nums, int left, int right, int[] tmp, int[] indexs, int[] ans) { if (left == right) { return; } int mid = left + (right - left) / 2; countSmaller(nums, left, mid, tmp, indexs, ans); countSmaller(nums, mid + 1, right, tmp, indexs, ans); // 已经有序,不存在逆序对,不需要进行合并 if (nums[indexs[mid]] <= nums[indexs[mid + 1]]) { return; } mergeAndCount(nums, left, mid, right, tmp, indexs, ans); } private void mergeAndCount(int[] nums, int left, int mid, int right, int[] tmp, int[] indexs, int[] ans) { System.arraycopy(indexs, left, tmp, left, right - left + 1); int i = left; int j = mid + 1; for (int k = left; k <= right; k++) { if (i == mid + 1) { indexs[k] = tmp[j]; j++; } else if (j == right + 1) { indexs[k] = tmp[i]; i++; // 后有序数组已经归并完成,一定比当前要归并的元素小 ans[indexs[k]] += (right - mid); } else if (nums[tmp[i]] <= nums[tmp[j]]) { indexs[k] = tmp[i]; i++; // 当前元素要归并,后有序数组已经归并的元素一定比当前元素小 ans[indexs[k]] += (j - mid - 1); } else { indexs[k] = tmp[j]; j++; } } } }示例4:计算数组的小和(计算左侧大于当前元素总和) 要求时间复杂度O(NlogN),空间复杂度O(N)1左边比1小的数,没有; 3左边比3小的数,1; 4左边比4小的数,1、3; 2左边比2小的数,1; 5左边比5小的数,1、3、4、2; 所以小和为1+1+3+1+1+3+4+2=16代码实现:import java.util.Scanner; public class Main { // 运行超时 public static long solution1(int[] nums) { long sum = 0; for (int i = 0; i < nums.length; i++) { for (int j = 0; j < i; j++) { if (nums[j] <= nums[i]) { sum += nums[j]; } } } return sum; } public static long solution(int[] nums) { int n = nums.length; if (n < 1) { return 0; } int[] tmp = new int[n]; return mergeSort(nums, 0, n - 1, tmp); } private static long mergeSort(int[] nums, int left, int right, int[] tmp) { if (left == right) { return 0; } int mid = left + (right - left) / 2; return mergeSort(nums, left, mid, tmp) + mergeSort(nums, mid + 1, right, tmp) + mergeAndSum(nums, left, mid, right, tmp); } private static long mergeAndSum(int[] nums, int left, int mid, int right, int[] tmp) { System.arraycopy(nums, left, tmp, left, right - left + 1); int i = left; int j = mid + 1; long smallSum = 0; for (int k = left; k <= right; k++) { if (i == mid + 1) { nums[k] = tmp[j]; j++; } else if (j == right + 1) { nums[k] = tmp[i]; i++; } else if (tmp[i] <= tmp[j]) { // i进行归并时,左边小于右边产生小和(当前加入的一定比右边还未加入的元素小) smallSum += tmp[i] * (right - j + 1); nums[k] = tmp[i]; i++; } else { nums[k] = tmp[j]; j++; } } return smallSum; } public static void main(String[] args) { Scanner sc = new Scanner(System.in); int n = sc.nextInt(); int[] nums = new int[n]; int i = 0; while (n-- > 0) { nums[i++] = sc.nextInt(); } System.out.println(solution(nums)); // System.out.println(solution1(nums)); } }
内连接与左(右)外连接的区别内连接关键字:inner join on 语句:select * from a_table a inner join b_table b on a.a_id = b.b_id; # 组合两个表中的记录,返回关联字段相符的记录,也就是返回两个表的交集(阴影)部分。 左(外)连接关键字:left join on / left outer join on 语句:SELECT * FROM a_table a left join b_table b ON a.a_id = b.b_id; # left join 是left outer join的简写,它的全称是左外连接,是外连接中的一种。 左(外)连接,左表(a_table)的记录将会全部表示出来,而右表(b_table)只会显示符合搜索条件的记录。右表记录不足的地方均为NULL。 右(外)连接关键字:right join on / right outer join on 语句:SELECT * FROM a_table a right outer join b_table b on a.a_id = b.b_id; #right join是right outer join的简写,它的全称是右外连接,是外连接中的一种。与左(外)连接相反,右(外)连接,左表(a_table)只会显示符合搜索条件的记录,而右表(b_table)的记录将会全部表示出来。左表记录不足的地方均为NULL。 # 注意:书写sql语句时连接join关键字的左右两边是将要连接的表,on后边跟着关联条件。总结:内连接:查询左右表都有的数据,不要左/右中NULL的那一部分左连接:即以左表为基准,到右表找匹配的数据,找不到匹配的用NULL补齐。左右连接可以相互转换:A left join B ---> B right join A 是同样的。 #A 站在 B的左边 ---> B 站在 A的右边具体见:https://blog.csdn.net/zjt980452483/article/details/82945663where、having、group by、order by、limit的区别和使用顺序where:通过在SELECT语句的WHERE子句中指定条件进行查询,WHERE子句必须紧跟在FROM子句之后。如:从员工表里查询员工id为h0001的员工的工资select 工资 from 工资表 where id='h0001';having:一般与group by组合来使用,表示在得到分类汇总记录的基础之上,进一步筛选记录。如:从部门表里查部门内员工薪水总和大于100000的部门的编号。select 部门编号,sum(薪水) from 部门表 group by 部门编号 having sum(薪水)>100000;ps:相同点:where和having都可以加条件区别:1.where在分组之前加条件,having在分组之后加条件. 2.where的效率要远远高于having. 分组本身消耗资源非常大.GROUP BY:当需要分组查询时需要使用GROUP BY子句,例如查询每个部门的工资和,这说明要使用部门来分组。select 部门编号,sum(薪水) from 部门表 group by 部门编号;ORDER BY:order by 用来指定数据的排序方式。有升序和降序两种。desc表示降序,asc为升序,默认为升序,asc可省略。ps:order by 要写在where之后,limit之前。select * from stu_info order by id asc;// 按照id升序排序,其中asc可省略。 select * from stu_info order by id desc; //按照id降序LIMITE:LIMIT用来限定查询结果的起始行,以及总行数。如:查询10行记录,起始行从3开始select * from emp limit 3,10;综合运用时的使用顺序:从employee表中查询salary列的值>0且prize字段值>0的记录,结果以id字段分组且降序排列,起始行从0开始,显示5行:select * from emploee where salary>0 group by id having prize>0 order by id desc limit 0, 5;结果:查询到的记录条数小于5行,所以只能显示查询到的总行数。总结顺序: where(条件) - group by(分组) - having(条件) - order by(排序) - limit(限定查询结果)增删改查命令#练习: 创建db4数据库,判断是否存在,并制定字符集为gbk create database if not exists db4 character set gbk #查询所有数据库的名称: show databases; #修改数据库的字符集 alter database 数据库名称 character set 字符集名称; #判断数据库存在,存在再删除 drop database if exists 数据库名称; #查询当前正在使用的数据库名称 select database(); #使用数据库 use 数据库名称; #复制表,注意位置 create table 表名 like 被复制的表名; #查询表结构 desc 表名; #删除表中指定列 alter table 表名 drop 列名; #操作表,列名 + 数据类型,最后一行没有逗号 create table student( id int, name varchar(32), age int , score double(4,1), birthday date, insert_time timestamp );mysql常见函数与数据类型常用函数:字符函数 (自动进行数字和字符串的转化)数值计算函数比较运算符函数日期时间函数具体见:https://www.cnblogs.com/duhuo/p/5650876.html数据类型:MySQL中定义数据字段的类型对你数据库的优化是非常重要的。MySQL支持多种类型,大致可以分为三类:数值、日期/时间和字符串(字符)类型。count(1),count(*),count(列)区别是啥?主要是当数据量达到一定级别,考察基本调优思路。在Mysql中的不同的存储引擎对count函数有不同的实现方式。MyISAM引擎把一个表的总行数存在了磁盘上,因此执行count()的时候会直接返回这个数,效率很高(没有where查询条件,如果有 where 条件,那么即使是 MyISAM 也必须累积计数的。InnoDB引擎并没有直接将总数存在磁盘上,在执行count()函数的时候需要一行一行的将数据读出,然来后累计总数。注:下面的讨论和结论是基于 InnoDB 引擎的。count 是一个聚合函数,对于返回的结果集,一行行地判断,如果 count 函数的参数不是 NULL,累计值就加 1,否则不加。最后返回累计值。所以,count( * )、count(1)和count(主键 id) 都表示返回满足条件的结果集的总行数;而 count(字段),则表示返回满足条件的数据行里面,参数“字段”不为 NULL 的总个数。count(可空字段):扫描全表,读到server层,判断字段可空,拿出该字段所有值,判断每一个值是否为空,不为空则累加(ps:字段非主键的情况最好不要出现,因为不走索引)count(非空字段)与count(主键 id):扫描全表,读到server层,判断字段不可空,按行累加。count(1):扫描全表,但不取值,server层收到的每一行都是1,判断不可能是null,按值累加。注意:count(1)执行速度比count(主键 id)快的原因:从引擎返回 id 会涉及到解析数据行,以及拷贝字段值的操作,ps:两者都只扫描主键。count( * ):MySQL 执行count( * )在优化器做了专门优化。因为count( * )返回的行一定不是空。扫描全表,但是不取值,按行累加。ps:InnoDB 是索引组织表,主键索引树的叶子节点是数据,而普通索引树的叶子节点是主键值。因此,普通索引树比主键索引树小很多。对于 count ( * ) 来说,遍历哪个索引树得到的结果逻辑上都是一样的。MySQL 优化器会找到最小的那棵树来遍历。在保证逻辑正确的前提下,尽量减少扫描的数据量,是数据库系统设计的通用法则之一。性能对比:count(可空字段) < count(非空字段) = count(主键 id) < (count(1) ≈ count(*),都扫描全表,不取值,按行累加)至于分析性能差别的时候,记住这么几个原则:server 层要什么就给什么;InnoDB 只给必要的值;现在的优化器只优化了 count(*) 的语义为“取行数”,其他“显而易见”的优化并没有做。ps:Redis 存储计数会出现的问题,把计数值也放在 MySQL 中,利用事务的原子性和隔离性,就可以解决一致性的问题。数据量不大,我们尽量用 count ( * ) 实现计数;数据量很大的情况考虑新建 MySQL 表存储计数,用事务的原子性和隔离性解决。mysql常用命令Structured Query Language:结构化查询语言。其实就是定义了操作所有关系型数据库的规则。每一种数据库操作的方式存在不一样的地方,称为“方言”。通用写法:SQL 语句可以单行或多行书写,以分号结尾。可使用空格和缩进来增强语句的可读性。MySQL 数据库的 SQL 语句不区分大小写,关键字建议使用大写。111什么是sql注入,如何防止sql注入?所谓SQL注入,黑客对数据库进行攻击的常用手段之一。一部分程序员在编写代码的时候,没有对用户输入数据的合法性进行判断,注入者可以在表单中输入一段数据库查询代码并提交,程序将提交的信息拼凑生成一个完整sql语句,服务器被欺骗而执行该条恶意的SQL命令。注入者根据程序返回的结果,成功获取一些敏感数据,甚至控制整个服务器,这就是SQL注入。SQL注入攻击的总体思路:(1)寻找到SQL注入的位置(2)判断服务器类型和后台数据库类型(3)针对不同的服务器和数据库特点进行SQL注入攻击如何防御SQL注入?加强用户输入内容的验证严格区分权限(普通用户和管理用户)使用参数化语句(?代表此处为一个参数,需要后期通过set设置)使用专业软件对SQL漏洞进行扫描数据库设计的三个范式?第一范式(1NF):强调的是列的原子性(属性不可分割,即每个字段都不可能拆分),即列不能够再分成其他几列,比如字段name,中国名符合,外国名不符合。第二范式(2NF):一是表必须有一个主键;二是其他字段必须完全依赖于主键,理解为主键约束就好了,而不能只依赖于主键的一部分,比如学生表中的学号,其他字段都可以通过学号获取信息。第三范式(3NF):每一列数据都和主键直接相关,而不能间接相关。表中不能有其他表中存在的、存储相同信息的字段,通常实现是在通过外键去建立关联,因此第三范式只要记住外键约束就好了。比如在设计一个订单数据表的时候,可以将客户编号作为一个外键和订单表建立相应的关系。而不可以在订单表中添加关于客户其它信息(比如姓名、所属公司等)的字段。
项目概述My-RPC-Framework 是一款基于 Nacos 实现的 RPC 框架。网络传输实现了基于 Java 原生 Socket 与 Netty 版本,并且实现了多种序列化与负载均衡算法。从一个最简单的BIO + Java序列化开始,逐步完善成Netty + 多序列化方式的比较完整的框架,并且配置了Nacos服务发现。rpc框架的原理理解:客户端和服务端都可以访问到通用的接口,但是只有服务端有这个接口的实现类,客户端调用这个接口的方式:通知服务端我要调用这个接口,服务端收到之后找到这个接口的实现类并执行,将执行的结果返回给客户端,作为客户端调用该接口方法的返回值。原理很简单,但是实现值得商榷,例如客户端怎么知道服务端的地址?客户端怎么告诉服务端我要调用的接口?客户端怎么传递参数?只有接口客户端怎么生成实现类……等等等等。1.系统架构消费者调用提供者的方式取决于消费者的客户端选择,如选用原生 Socket 则该步调用使用 BIO,如选用 Netty 方式则该步调用使用 NIO。如该调用有返回值,则提供者向消费者发送返回值的方式同理。2.项目特性网络传输:实现了基于 Java 原生 Socket 传输与 Netty 传输两种网络传输方式消费端如采用 Netty 方式,会复用 Channel 避免多次连接如消费端和提供者都采用 Netty 方式,会采用 Netty 的心跳机制,保证连接序列化算法:实现了四种序列化算法,Json 方式、Kryo 算法、Hessian 算法与 Google Protobuf 方式(默认采用 Kryo方式序列化)负载均衡算法:实现了两种负载均衡算法:随机算法与轮转算法Nacos注册中心:使用 Nacos 作为注册中心,管理服务提供者信息服务提供侧自动注册服务通信协议:实现自定义的通信协议(MRF协议)接口抽象良好,模块耦合度低,网络传输、序列化器、负载均衡算法可配置3.项目模块rpc-api —— 服务端与客户端公共调用接口(通用接口)rpc-common —— 实体对象、工具类、枚举类等公共类rpc-core —— 框架的核心实现test-client —— 客户端测试代码test-server —— 服务端测试代码4.自定义传输协议调用参数与返回值的传输采用了如下 MRF 协议( My-RPC-Framework 首字母)以防止粘包:+---------------+---------------+-----------------+-------------+ | Magic Number | Package Type | Serializer Type | Data Length | | 4 bytes | 4 bytes | 4 bytes | 4 bytes | +---------------+---------------+-----------------+-------------+ | Data Bytes | | Length: ${Data Length} | +---------------------------------------------------------------+字段解释Magic Number魔数,表识一个 MRF 协议包,0xCAFEBABEPackage Type包类型,标明这是一个调用请求还是调用响应Serializer Type序列化器类型,标明这个包的数据的序列化方式Data Length数据字节的长度Data Bytes传输的对象,通常是一个RpcRequest或RpcClient对象,取决于Package Type字段,对象的序列化方式取决于Serializer Type字段。
写在前bitmap和布隆过滤器主要解决大数据去重的问题。用于对大量整型数据做去重和查询。其实如果并非如此大量的数据,有很多排重方案可以使用,典型的就是哈希表。实际上,哈希表为每一个可能出现的数字提供了一个一一映射的关系,每个元素都相当于有了自己的独享的一份空间,这个映射由散列函数来提供(这里我们先不考虑碰撞)。实际上哈希表甚至还能记录每个元素出现的次数,这样的数据结构完成这个任务有点“大材小用”了。如果用HashSet或HashMap存储,每一个用户ID都要存成int,占4字节即32bit。而一个用户在Bitmap中只占一个bit,内存节省了32倍!所以散列表最大的问题是所占用的空间非常大!!!Bitmap位图算法为什么用位图?我们先来看一个问题,假设我们有1千万个(不重复、未排序)的整数需要存储,每个整数的大小范围是1到1亿。然后,给定任意一个整数X,我们需要快速判断X是否在刚才的1千万个整数内。这个问题该如何处理呢?常规的做法肯定就是先考虑如何存储这1千万个整数,在Java中,int类型是4个字节,可以表示的范围区间是-2147483648~2147483647,所以每个整数都用int来表示是可行的。那么1千万个整数需要占用多少内存空间呢?两者相乘就是了,应该是4000万字节,也就是40MB。而且如果我们存放的整数是是一亿个(甚至更大),每个整数大小范围是1到100亿怎么办?占用的内存空间将会是非常巨大的,是普通的计算机或者手机不能接受的(放不下)。ps:多台机器存储的话,资源占用量太大;另外可以不用内存,但是I/O效率低;所以我们需要使用位图这种数据结构来大大节省内存的使用量。什么是位图?我们知道Byte表示字节,一个字节等于8bit,这里的bit就和位图有关系。在上面的例子中,我们不是要存放1千万个整数嘛,那就申请一个具有1千万个bit的数组,用每个bit(二进制位)来表示一个整数,当前bit为0表示不存在这个整数,为1表示该整数就存在。虽然数量是1千万个,但是每个数的范围是1到1亿,所以我们需要1亿个bit,换算下来,就是12.5MB,和刚才的40MB相比,节省的不是一点点。位图是指内存中连续的二进制位,用于对大量的整型数据做去重和查询。Bit-map就是用一个bit位来标记某个元素对应的Value,而Key即是该元素。对于位图的底层存储结构实际为数组结构,目的是为了存储大量的数据,为了表示存储的数据是否存在,可以使用0/1代表true/false。存储的数据流程为根据自定义的hash计算公式得到对应的数组下标并将数据进行相应的位运算得到的结果存储到数组中;当需要查询数据时将当前要查询的数据转换为对应的数组索引根据索引定位数组中的数据并执行存储时位运算的相反操作判断数据是否存在可以看出位图基于数组下标查询效率非常高效,且相对而言,位运算使用cpu计算也比较高效;由于采用了Bit为单位来存储数据,因此在存储空间方面,可以大大节省。位图也存在一些缺点,对于随着数据量的增加,要申请数组的空间也越来越大。bitmap应用1)可进行数据的快速查找,判重,删除,一般来说数据范围是int的10倍以下。2)去重数据而达到压缩数据比如 Java 中的 BitSet 类就是一个位图,Redis 也提供了 BitMap 位图类布隆过滤器为什么用布隆过滤器?位图存在两个问题处理数字很方便,但是处理字符就稍微有点困难了(关键)位图在数据量过大情况下导致了内存浪费布隆过滤器为了解决内存占用的问题,由于位图实际是利用一个index来定位一个数据,如果在数据密集度相对稀疏的情况下,会导致数组空间浪费(存在大量空洞插槽); 布隆过滤器利用多个插槽位置来定义一个元素,尽最大的可能性来提高空间利用率; 对于一个数据对应多个插槽,实际就是利用多个hash函数生成多个索引位置。什么是布隆过滤器?布隆过滤器实际是基于位图来实现的一种数据存储结构,用来判断某个元素(key)是否在某个集合中。和一般的位图不同的是,这个算法无需存储key的值,对于每个key,只需要k个比特位,每个存储一个标志,用来判断key是否在集合中。布隆过滤器最大的特点,就是对一个对象使用多个哈希函数。如果我们使用了 k 个哈希函数,就会得到 k 个哈希值,也就是 k 个下标,我们会把数组中对应下标位置的值都置为 1。布隆过滤器和位图最大的区别就在于,我们不再使用一位来表示一个对象,而是使用 k 位来表示一个对象。这样两个对象的 k 位都相同的概率就会大大降低,不仅能够解决单哈希容易造成冲突的问题,还能有效减少内存空间的使用。但要注意的是,布隆过滤器 主要是为了解决空间浪费问题,并不能完全解决hash冲突;由于hash冲突的存在会导致一些误判的发生(无中生有),虽然使用多个hash函数可以降低冲突的概率,但还是会存在一些极端的情况出现hash冲突,导致不存在的数据被布隆过滤器判定为存在;但是对于布隆过滤器判定为不存在的数据,是肯定不存在的。虽然布隆过滤器对于空间利用率较高,但随着数组剩余空间越来越小,会导致hash冲突的概率越来越大,因此需要设置一个阈值,当剩余空间达到一定阈值后,就需要执行扩容,降低hash冲突概率。优缺点:优点:不需要存储key,节省空间缺点:算法判断key在集合中时,有一定的概率key其实不在集合中,已经映射的数据无法删除应用场景redis 缓存穿透 就可以利用 布隆过滤器来降低缓存穿透发生概率;redis 缓存穿透的理解 , 对于 redis中和数据库中都不存在的数据,当从redis中获取不到数据时,请求就会请求到服务端执行db查询操作(绕过缓存),当存在大量缓存穿透的情况就会导致redis和服务以及数据库的压力剧增;当redis 使用 布隆过滤器后,首先能通过布隆过滤器的判定,对于不存在的数据,redis和db中肯定都不存在;但对于布隆过滤器判断为存在的数据,即使经过redis查询发生数据不存在,从而查找db也发现数据不存在 的情况, 但对于这种情况毕竟几率较低,即使出现了对整个redis以及服务的压力也不会非常大其他:比如网络爬虫抓取时url去重,邮件提供商反垃圾黑名单Email地址去重,之所以需要k个比特位是因为我们大多数情况下处理的是字符串,那么不同的字符串就有可能映射到同一个位置,产生冲突(尽可能的降低hash冲突)。总结BitMap:把数值转化为数组的下标,下标对应的值只存放一个bit 0或1 ,0不存在 1存在。判断时 根据数值当做下标找到对应的 bit 值,1存在,0不存在BloomFilter:把内容通过多个hash算法,转换成多个数组下标,下标对应的值只存放一个bit 0或1 ,0不存在 1存在。判断过滤时 多个下标对应的值都为1,则可能存在,只要有一个值为0,则一定不存在
5.6实现自动注销和负载均衡策略5.6.1服务自动注销上一节我们实现了服务的自动注册和发现,但是有些细心的同学就可能会发现,如果你启动完成服务端后把服务端给关闭了,并不会自动地注销 Nacos 中对应的服务信息,这样就导致了当客户端再次向 Nacos 请求服务时,会获取到已经关闭的服务端信息,最终就有可能因为连接不到服务器而调用失败。那么我们就需要一种办法,在服务端关闭之前自动向 Nacos 注销服务。但是有一个问题,我们不知道什么时候服务器会关闭,也就不知道这个方法调用的时机,就没有办法手工去调用。这时,我们就需要钩子。钩子是什么呢?是在某些事件发生后自动去调用的方法。那么我们只需要把注销服务的方法写到关闭系统的钩子方法里就行了。首先先写向 Nacos 注销所有服务的方法,这部分被放在了 NacosUtils 中作为一个静态方法,NacosUtils 是一个 Nacos 相关的工具类:public static void clearRegistry() { if(!serviceNames.isEmpty() && address != null) { String host = address.getHostName(); int port = address.getPort(); Iterator<String> iterator = serviceNames.iterator(); while(iterator.hasNext()) { String serviceName = iterator.next(); try { namingService.deregisterInstance(serviceName, host, port); } catch (NacosException e) { logger.error("注销服务 {} 失败", serviceName, e); } } } }所有的服务名称都被存储在 NacosUtils 类中的 serviceNames 中,在注销时只需要用迭代器迭代所有服务名,调用 deregisterInstance 注销服务。接着就是钩子了,新建一个类,ShutdownHook:public class ShutdownHook { private static final Logger logger = LoggerFactory.getLogger(ShutdownHook.class); private final ExecutorService threadPool = ThreadPoolFactory.createDefaultThreadPool("shutdown-hook"); private static final ShutdownHook shutdownHook = new ShutdownHook(); public static ShutdownHook getShutdownHook() { return shutdownHook; } public void addClearAllHook() { logger.info("关闭后将自动注销所有服务"); Runtime.getRuntime().addShutdownHook(new Thread(() -> { NacosUtil.clearRegistry(); threadPool.shutdown(); })); } }使用了单例模式创建其对象,在 addClearAllHook 中,Runtime 对象是 JVM 虚拟机的运行时环境,调用其 addShutdownHook 方法增加一个钩子函数,创建一个新线程调用 clearRegistry 方法完成注销工作。这个钩子函数会在 JVM 关闭之前被调用。这样在 RpcServer 启动之前,只需要调用 addClearAllHook,就可以注册这个钩子了。例如在 NettyServer 中:ChannelFuture future = serverBootstrap.bind(host, port).sync(); + ShutdownHook.getShutdownHook().addClearAllHook(); future.channel().closeFuture().sync();启动服务端后再关闭,就会发现 Nacos 中的注册信息都被注销了。5.6.2负载均衡策略负载均衡大家应该都熟悉,在上一节中客户端在 lookupService 方法中,从 Nacos 获取到的是所有提供这个服务的服务端信息列表,我们就需要从中选择一个,这便涉及到客户端侧的负载均衡策略。我们新建一个接口:LoadBalancerpublic interface LoadBalancer { Instance select(List<Instance> instances); }接口中的 select 方法用于从一系列 Instance 中选择一个。这里我就实现两个比较经典的算法:随机和转轮。随机算法顾名思义,就是随机选一个,毫无技术含量:public class RandomLoadBalancer implements LoadBalancer { @Override public Instance select(List<Instance> instances) { // nextInt():生成一个介于[0, instances.size())的int型随机值 return instances.get(new Random().nextInt(instances.size())); } }而转轮算法大家也应该了解,按照顺序依次选择第一个、第二个、第三个……这里就需要一个变量来表示当前选到了第几个:public class RoundRobinLoadBalancer implements LoadBalancer { private int index = 0; @Override public Instance select(List<Instance> instances) { if(index >= instances.size()) { index %= instances.size(); } return instances.get(index++); } }index 就表示当前选到了第几个服务器,并且每次选择后都会自增一。最后在 NacosServiceRegistry 中集成就可以了,这里选择外部传入的方式传入 LoadBalancer:public class NacosServiceDiscovery implements ServiceDiscovery { private final LoadBalancer loadBalancer; public NacosServiceDiscovery(LoadBalancer loadBalancer) { if(loadBalancer == null) this.loadBalancer = new RandomLoadBalancer(); else this.loadBalancer = loadBalancer; } public InetSocketAddress lookupService(String serviceName) { try { List<Instance> instances = NacosUtil.getAllInstance(serviceName); Instance instance = loadBalancer.select(instances); return new InetSocketAddress(instance.getIp(), instance.getPort()); } catch (NacosException e) { logger.error("获取服务时有错误发生:", e); } return null; } }而这个负载均衡策略,也可以在创建客户端时指定,例如无参构造 NettyClient 时就用默认的策略,也可以有参构造传入策略,具体的实现留给大家。
5.5实现基于 Nacos 的服务器注册与发现我们目前实现的框架看起来工作的还不错,但是有一个问题:我们的服务端地址是固化在代码中的,也就是说,对于一个客户端,它只会去寻找那么一个服务提供者,如果这个提供者挂了或者换了地址,那就没有办法了。在分布式架构中,有一个重要的组件,就是服务注册中心,它用于保存多个服务提供者的信息,每个服务提供者在启动时都需要向注册中心注册自己所拥有的服务。这样客户端在发起 RPC 时,就可以直接去向注册中心请求服务提供者的信息,如果拿来的这个挂了,还可以重新请求,并且在这种情况下可以很方便地实现负载均衡。常见的注册中心有 Eureka、Zookeeper 和 Nacos。5.5.1获取NacosNacos 是阿里开发的一款服务注册中心,在 SpringCloud Alibaba 逐步替代原始的 SpringCloud 的过程中,Nacos 逐步走红,所以我们就是用 Nacos 作为我们的注册中心。下载解压的过程略过。注意 Nacos 是依赖数据库的,所以我们需要在配置文件中配置 Mysql 的信息。为了简单,我们先以单机模式运行:sh startup.sh -m standalone或者直接运行startup.cmd启动后可以访问 Nacos 的web UI,地址 http://127.0.0.1:8848/nacos/index.html。默认的用户名和密码都是 nacos5.5.2项目中使用Nacos引入 nacos-client 依赖:<dependency> <groupId>com.alibaba.nacos</groupId> <artifactId>nacos-client</artifactId> <version>1.3.0</version> </dependency>这里我们修正之前的概念,第二节把本地保存服务的类称为 ServiceRegistry,现在更改为 ServiceProvider,而 ServiceRegistry 作为远程注册表(Nacos)使用,对应的类名也有修改。这里我们实现一个接口 ServiceRegistry:public interface ServiceRegistry { void register(String serviceName, InetSocketAddress inetSocketAddress); InetSocketAddress lookupService(String serviceName); }register 方法将服务的名称和地址注册进服务注册中心lookupService 方法则是根据服务名称从注册中心获取到一个服务提供者的地址。InetSocketAddress:服务的地址(ip+端口号)ps:本机的域名是"localhost",IPv4地址是127.0.0.1.该类中包含了一个InetAddress对象,代表了IP地址和端口号,专门用于socket网络通信,用于需要IP地址和端口号的场景构造方法//根据域名(主机名)获得ip地址加端口对象 InetSocketAddress localhost = new InetSocketAddress("localhost", 8080); //通过IP地址获取ip地址加端口对象 InetSocketAddress inetSocketAddress = new InetSocketAddress("127.0.0.1", 8080);主要方法InetAddress getAddress():返回此端口的IP地址。 String getHostName():返回此端口的主机名。 int getPort():返回此端口的端口号。接口有了,我们就可以写实现类了,我们实现一个 Nacos 作为注册中心的实现类:NacosServiceRegistry,我们也可以使用 ZooKeeper 作为注册中心,实现接口就可以public class NacosServiceRegistry implements ServiceRegistry { private static final Logger logger = LoggerFactory.getLogger(NacosServiceRegistry.class); private static final String SERVER_ADDR = "127.0.0.1:8848"; private static final NamingService namingService; static { try { namingService = NamingFactory.createNamingService(SERVER_ADDR); } catch (NacosException e) { logger.error("连接到Nacos时有错误发生: ", e); throw new RpcException(RpcError.FAILED_TO_CONNECT_TO_SERVICE_REGISTRY); } } @Override public void register(String serviceName, InetSocketAddress inetSocketAddress) { try { namingService.registerInstance(serviceName, inetSocketAddress.getHostName(), inetSocketAddress.getPort()); } catch (NacosException e) { logger.error("注册服务时有错误发生:", e); throw new RpcException(RpcError.REGISTER_SERVICE_FAILED); } } @Override public InetSocketAddress lookupService(String serviceName) { try { List<Instance> instances = namingService.getAllInstances(serviceName); Instance instance = instances.get(0); return new InetSocketAddress(instance.getIp(), instance.getPort()); } catch (NacosException e) { logger.error("获取服务时有错误发生:", e); } return null; } }Nacos 的使用很简单,通过 NamingFactory 创建 NamingService 连接 Nacos(连接的时候没有找到修改用户名密码的方式……是不需要吗),连接的过程写在了静态代码块中,在类加载时自动连接。namingService 提供了两个很方便的接口,registerInstance 和 getAllInstances 方法:registerInstance :直接向 Nacos 注册服务getAllInstances:可以获得提供某个服务的所有提供者的列表。在 lookupService 方法中,通过 getAllInstance 获取到某个服务的所有提供者列表后,需要选择一个,这里就涉及了负载均衡策略,这里我们先选择第 0 个,后面某节会详细讲解负载均衡。5.5.3注册服务我们修改 RpcServer 接口,新增一个方法 publishService,用于向 Nacos 注册服务:<T> void publishService(Object service, Class<T> serviceClass);接着只需要实现这个方法即可,以 NettyServer 的实现为例,NettyServer 在创建时需要创建一个 ServiceRegistry 了:public NettyServer(String host, int port) { this.host = host; this.port = port; serviceRegistry = new NacosServiceRegistry(); serviceProvider = new ServiceProviderImpl(); }接着实现 publishService 方法即可:public <T> void publishService(Object service, Class<T> serviceClass) { if(serializer == null) { logger.error("未设置序列化器"); throw new RpcException(RpcError.SERIALIZER_NOT_FOUND); } serviceProvider.addServiceProvider(service); serviceRegistry.register(serviceClass.getCanonicalName(), new InetSocketAddress(host, port)); start(); }publishService 需要将服务保存在本地的注册表,同时注册到 Nacos 上。我这里的实现是注册完一个服务后直接调用 start() 方法,这是个不太好的实现……导致一个服务端只能注册一个服务,之后可以多注册几个然后再手动调用 start() 方法。5.5.4服务发现客户端的修改就更简单了,以 NettyClient 为例,在过去创建 NettyClient 时,需要传入 host 和 port,现在这个 host 和 port 是通过 Nacos 获取的,sendRequest 修改如下:public Object sendRequest(RpcRequest rpcRequest) { if(serializer == null) { logger.error("未设置序列化器"); throw new RpcException(RpcError.SERIALIZER_NOT_FOUND); } AtomicReference<Object> result = new AtomicReference<>(null); try { InetSocketAddress inetSocketAddress = serviceRegistry.lookupService(rpcRequest.getInterfaceName()); Channel channel = ChannelProvider.get(inetSocketAddress, serializer); ...重点是最后两句,过去是直接使用传入的 host 和 port 直接构造 channel,现在是首先从 ServiceRegistry 中获取到服务的地址和端口,再构造。5.5.5测试NettyTestClient 如下:public class NettyTestClient { public static void main(String[] args) { RpcClient client = new NettyClient(); client.setSerializer(new ProtobufSerializer()); RpcClientProxy rpcClientProxy = new RpcClientProxy(client); HelloService helloService = rpcClientProxy.getProxy(HelloService.class); HelloObject object = new HelloObject(12, "This is a message"); String res = helloService.hello(object); System.out.println(res); } }构造 RpcClient 时不再需要传入地址和端口(服务地址),直接去向注册中心请求服务提供者的信息。NettyTestServer 如下:public class NettyTestServer { public static void main(String[] args) { HelloService helloService = new HelloServiceImpl(); NettyServer server = new NettyServer("127.0.0.1", 9999); server.setSerializer(new ProtobufSerializer()); server.publishService(helloService, HelloService.class); } }我这里是把 start 写在了 publishService 中,实际应当分离,否则只能注册一个服务。分别启动,可以看到和之前相同的结果。这里如果通过修改不同的端口,启动两个服务的话,会看到即使客户端多次调用,也只是由同一个服务端提供服务,这是因为在 NacosServiceRegistry 中,我们直接选择了服务列表的第 0 个,这个会在之后讲解负载均衡时作出修改。
5.3实现Netty传输和通用序列化接口核心:将传统的 BIO 方式传输换成效率更高的 NIO 方式,使用Netty(并非Java原生NIO);实现通用的序列化接口,为多种序列化支持做准备,自定义传输的协议。5.3.1Netty 服务端与客户端首先就需要在 pom.xml 中加入 Netty 依赖:<dependency> <groupId>io.netty</groupId> <artifactId>netty-all</artifactId> <version>${netty-version}</version> </dependency>netty 的最新版本可以在 maven repository查到,注意使用 netty 4 而不是 netty 5。为了保证通用性,我们可以把 Server 和 Client 抽象成两个接口,分别是 RpcServer 和 RpcClient:public interface RpcServer { void start(int port); } public interface RpcClient { Object sendRequest(RpcRequest rpcRequest); }而原来的 RpcServer 和 RpcClient 类实际上是上述两个接口的 Socket 方式实现类,改成 SocketServer 和 SocketClient 并实现上面两个接口即可,几乎不需要做什么修改。我们的任务,就是要实现 NettyServer 和 NettyClient。这里提一个改动,就是在 DefaultServiceRegistry.java 中,将包含注册信息的 serviceMap 和 registeredService 都改成了 static ,这样就能保证全局唯一的注册信息,并且在创建 RpcServer 时也就不需要传入了。NettyServer的实现很传统:public class NettyServer implements RpcServer { private static final Logger logger = LoggerFactory.getLogger(NettyServer.class); @Override public void start(int port) { EventLoopGroup bossGroup = new NioEventLoopGroup(); EventLoopGroup workerGroup = new NioEventLoopGroup(); try { ServerBootstrap serverBootstrap = new ServerBootstrap(); serverBootstrap.group(bossGroup, workerGroup) .channel(NioServerSocketChannel.class) .handler(new LoggingHandler(LogLevel.INFO)) .option(ChannelOption.SO_BACKLOG, 256) .option(ChannelOption.SO_KEEPALIVE, true) .childOption(ChannelOption.TCP_NODELAY, true) .childHandler(new ChannelInitializer<SocketChannel>() { @Override protected void initChannel(SocketChannel ch) throws Exception { ChannelPipeline pipeline = ch.pipeline(); pipeline.addLast(new CommonEncoder(new JsonSerializer())); pipeline.addLast(new CommonDecoder()); pipeline.addLast(new NettyServerHandler()); } }); ChannelFuture future = serverBootstrap.bind(port).sync(); future.channel().closeFuture().sync(); } catch (InterruptedException e) { logger.error("启动服务器时有错误发生: ", e); } finally { bossGroup.shutdownGracefully(); workerGroup.shutdownGracefully(); } } }了解过 Netty 的同学可能知道,Netty 中有一个很重要的设计模式——责任链模式,责任链上有多个处理器,每个处理器都会对数据进行加工,并将处理后的数据传给下一个处理器。代码中的 CommonEncoder、CommonDecoder和NettyServerHandler 分别就是编码器,解码器和数据处理器。因为数据从外部传入时需要解码,而传出时需要编码,类似计算机网络的分层模型,每一层向下层传递数据时都要加上该层的信息,而向上层传递时则需要对本层信息进行解码。而 NettyClient 的实现也很类似:public class NettyClient implements RpcClient { private static final Logger logger = LoggerFactory.getLogger(NettyClient.class); private String host; private int port; private static final Bootstrap bootstrap; public NettyClient(String host, int port) { this.host = host; this.port = port; } static { EventLoopGroup group = new NioEventLoopGroup(); bootstrap = new Bootstrap(); bootstrap.group(group) .channel(NioSocketChannel.class) .option(ChannelOption.SO_KEEPALIVE, true) .handler(new ChannelInitializer<SocketChannel>() { @Override protected void initChannel(SocketChannel ch) throws Exception { ChannelPipeline pipeline = ch.pipeline(); pipeline.addLast(new CommonDecoder()) .addLast(new CommonEncoder(new JsonSerializer())) .addLast(new NettyClientHandler()); } }); } @Override public Object sendRequest(RpcRequest rpcRequest) { try { ChannelFuture future = bootstrap.connect(host, port).sync(); logger.info("客户端连接到服务器 {}:{}", host, port); Channel channel = future.channel(); if(channel != null) { channel.writeAndFlush(rpcRequest).addListener(future1 -> { if(future1.isSuccess()) { logger.info(String.format("客户端发送消息: %s", rpcRequest.toString())); } else { logger.error("发送消息时有错误发生: ", future1.cause()); } }); channel.closeFuture().sync(); AttributeKey<RpcResponse> key = AttributeKey.valueOf("rpcResponse"); RpcResponse rpcResponse = channel.attr(key).get(); return rpcResponse.getData(); } } catch (InterruptedException e) { logger.error("发送消息时有错误发生: ", e); } return null; } }在静态代码块中就直接配置好了 Netty 客户端,等待发送数据时启动,channel 将 RpcRequest 对象写出,并且等待服务端返回的结果。注意这里的发送是非阻塞的,所以发送后会立刻返回,而无法得到结果。这里通过 AttributeKey 的方式阻塞获得返回结果:AttributeKey<RpcResponse> key = AttributeKey.valueOf("rpcResponse"); RpcResponse rpcResponse = channel.attr(key).get();通过这种方式获得全局可见的返回结果,在获得返回结果 RpcResponse 后,将这个对象以 key 为 rpcResponse 放入 ChannelHandlerContext 中,这里就可以立刻获得结果并返回,我们会在 NettyClientHandler 中看到放入的过程。5.3.2自定义协议与编解码器在传输过程中,我们可以在发送的数据上加上各种必要的数据,形成自定义的协议,而自动加上这个数据就是编码器的工作,解析数据获得原始数据就是解码器的工作。我们定义的协议是这样的:+---------------+---------------+-----------------+-------------+ | Magic Number | Package Type | Serializer Type | Data Length | | 4 bytes | 4 bytes | 4 bytes | 4 bytes | +---------------+---------------+-----------------+-------------+ | Data Bytes | | Length: ${Data Length} | +---------------------------------------------------------------+4 字节魔数,表示一个协议包,通过固定数值和字符串标识这个协议包。Package Type,标明这是一个调用请求还是调用响应Serializer Type 标明了实际数据使用的序列化器,这个服务端和客户端应当使用统一标准Data Length 就是实际数据的长度,设置这个字段主要防止粘包,最后就是经过序列化后的实际数据,可能是 RpcRequest 也可能是 RpcResponse 经过序列化后的字节,取决于 Package Type。规定好协议后,我们就可以来看看 CommonEncoder(编码器) 了:public class CommonEncoder extends MessageToByteEncoder { private static final int MAGIC_NUMBER = 0xCAFEBABE; private final CommonSerializer serializer; public CommonEncoder(CommonSerializer serializer) { this.serializer = serializer; } @Override protected void encode(ChannelHandlerContext ctx, Object msg, ByteBuf out) throws Exception { out.writeInt(MAGIC_NUMBER); if(msg instanceof RpcRequest) { out.writeInt(PackageType.REQUEST_PACK.getCode()); } else { out.writeInt(PackageType.RESPONSE_PACK.getCode()); } out.writeInt(serializer.getCode()); byte[] bytes = serializer.serialize(msg); out.writeInt(bytes.length); out.writeBytes(bytes); } }CommonEncoder 继承了MessageToByteEncoder 类,见名知义,就是把 Message(实际要发送的对象)转化成 Byte 数组。CommonEncoder 的工作很简单,就是把 RpcRequest 或者 RpcResponse 包装成协议包。 根据上面提到的协议格式,将各个字段写到管道里就可以了,这里serializer.getCode() 获取序列化器的编号,之后使用传入的序列化器将请求或响应包序列化为字节数组写入管道即可。而 CommonDecoder 的工作就更简单了:public class CommonDecoder extends ReplayingDecoder { private static final Logger logger = LoggerFactory.getLogger(CommonDecoder.class); private static final int MAGIC_NUMBER = 0xCAFEBABE; @Override protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception { int magic = in.readInt(); if(magic != MAGIC_NUMBER) { logger.error("不识别的协议包: {}", magic); throw new RpcException(RpcError.UNKNOWN_PROTOCOL); } int packageCode = in.readInt(); Class<?> packageClass; if(packageCode == PackageType.REQUEST_PACK.getCode()) { packageClass = RpcRequest.class; } else if(packageCode == PackageType.RESPONSE_PACK.getCode()) { packageClass = RpcResponse.class; } else { logger.error("不识别的数据包: {}", packageCode); throw new RpcException(RpcError.UNKNOWN_PACKAGE_TYPE); } int serializerCode = in.readInt(); CommonSerializer serializer = CommonSerializer.getByCode(serializerCode); if(serializer == null) { logger.error("不识别的反序列化器: {}", serializerCode); throw new RpcException(RpcError.UNKNOWN_SERIALIZER); } int length = in.readInt(); byte[] bytes = new byte[length]; in.readBytes(bytes); Object obj = serializer.deserialize(bytes, packageClass); out.add(obj); } }CommonDecoder 继承自 ReplayingDecoder ,与 MessageToByteEncoder 相反,它用于将收到的字节序列还原为实际对象。主要就是一些字段的校验,比较重要的就是取出序列化器的编号,以获得正确的反序列化方式,并且读入 length 字段来确定数据包的长度(防止粘包),最后读入正确大小的字节数组,反序列化成对应的对象。5.3.3序列化接口序列化器接口(CommonSerializer)如下:public interface CommonSerializer { byte[] serialize(Object obj); Object deserialize(byte[] bytes, Class<?> clazz); int getCode(); static CommonSerializer getByCode(int code) { switch (code) { case 1: return new JsonSerializer(); default: return null; } } }主要就是四个方法,序列化,反序列化,获得该序列化器的编号,已经根据编号获取序列化器,这里我已经写了一个示例的 JSON 序列化器,Kryo 序列化器会在后面讲解。作为一个比较简单的例子,我写了一个 JSON 的序列化器:public class JsonSerializer implements CommonSerializer { private static final Logger logger = LoggerFactory.getLogger(JsonSerializer.class); private ObjectMapper objectMapper = new ObjectMapper(); @Override public byte[] serialize(Object obj) { try { return objectMapper.writeValueAsBytes(obj); } catch (JsonProcessingException e) { logger.error("序列化时有错误发生: {}", e.getMessage()); e.printStackTrace(); return null; } } @Override public Object deserialize(byte[] bytes, Class<?> clazz) { try { Object obj = objectMapper.readValue(bytes, clazz); if(obj instanceof RpcRequest) { obj = handleRequest(obj); } return obj; } catch (IOException e) { logger.error("反序列化时有错误发生: {}", e.getMessage()); e.printStackTrace(); return null; } } /* 这里由于使用JSON序列化和反序列化Object数组,无法保证反序列化后仍然为原实例类型 需要重新判断处理 */ private Object handleRequest(Object obj) throws IOException { RpcRequest rpcRequest = (RpcRequest) obj; for(int i = 0; i < rpcRequest.getParamTypes().length; i ++) { Class<?> clazz = rpcRequest.getParamTypes()[i]; if(!clazz.isAssignableFrom(rpcRequest.getParameters()[i].getClass())) { byte[] bytes = objectMapper.writeValueAsBytes(rpcRequest.getParameters()[i]); rpcRequest.getParameters()[i] = objectMapper.readValue(bytes, clazz); } } return rpcRequest; } @Override public int getCode() { return SerializerCode.valueOf("JSON").getCode(); } }JSON 序列化工具我使用的是 Jackson,在 pom.xml 中添加依赖即可。序列化和反序列化都比较循规蹈矩,把对象翻译成字节数组,和根据字节数组和 Class 反序列化成对象。这里有一个需要注意的点,就是在 RpcRequest 反序列化时,由于其中有一个字段是 Object 数组,在反序列化时序列化器会根据字段类型进行反序列化,而 Object 就是一个十分模糊的类型,会出现反序列化失败的现象,这时就需要 RpcRequest 中的另一个字段 ParamTypes 来获取到 Object 数组中的每个实例的实际类,辅助反序列化,这就是 handleRequest() 方法的作用。上面提到的这种情况不会在其他序列化方式中出现,因为其他序列化方式是转换成字节数组,会记录对象的信息,而 JSON 方式本质上只是转换成 JSON 字符串,会丢失对象的类型信息。5.3.4NettyServerHandler 和 NettyClientHandlerNettyServerHandler 和 NettyClientHandler 都分别位于服务器端和客户端责任链的尾部,直接和 RpcServer 对象或 RpcClient 对象打交道,而无需关心字节序列的情况。NettyServerhandler 用于接收 RpcRequest,并且执行调用,将调用结果返回封装成 RpcResponse 发送出去。public class NettyServerHandler extends SimpleChannelInboundHandler<RpcRequest> { private static final Logger logger = LoggerFactory.getLogger(NettyServerHandler.class); private static RequestHandler requestHandler; private static ServiceRegistry serviceRegistry; static { requestHandler = new RequestHandler(); serviceRegistry = new DefaultServiceRegistry(); } @Override protected void channelRead0(ChannelHandlerContext ctx, RpcRequest msg) throws Exception { try { logger.info("服务器接收到请求: {}", msg); String interfaceName = msg.getInterfaceName(); Object service = serviceRegistry.getService(interfaceName); Object result = requestHandler.handle(msg, service); ChannelFuture future = ctx.writeAndFlush(RpcResponse.success(result)); future.addListener(ChannelFutureListener.CLOSE); } finally { ReferenceCountUtil.release(msg); } } @Override public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception { logger.error("处理过程调用时有错误发生:"); cause.printStackTrace(); ctx.close(); } }处理方式和 Socket 中的逻辑基本一致,不做讲解。NettyClientHandlerpublic class NettyClientHandler extends SimpleChannelInboundHandler<RpcResponse> { private static final Logger logger = LoggerFactory.getLogger(NettyClientHandler.class); @Override protected void channelRead0(ChannelHandlerContext ctx, RpcResponse msg) throws Exception { try { logger.info(String.format("客户端接收到消息: %s", msg)); AttributeKey<RpcResponse> key = AttributeKey.valueOf("rpcResponse"); ctx.channel().attr(key).set(msg); ctx.channel().close(); } finally { ReferenceCountUtil.release(msg); } } @Override public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception { logger.error("过程调用时有错误发生:"); cause.printStackTrace(); ctx.close(); } }这里只需要处理收到的消息,即 RpcResponse 对象,由于前面已经有解码器解码了,这里就直接将返回的结果放入 ctx 中即可。5.3.5测试netty方式public class NettyTestServer { public static void main(String[] args) { HelloService helloService = new HelloServiceImpl(); ServiceRegistry registry = new DefaultServiceRegistry(); registry.register(helloService); NettyServer server = new NettyServer(); server.start(9999); } } public class NettyTestClient { public static void main(String[] args) { RpcClient client = new NettyClient("127.0.0.1", 9999); RpcClientProxy rpcClientProxy = new RpcClientProxy(client); HelloService helloService = rpcClientProxy.getProxy(HelloService.class); HelloObject object = new HelloObject(12, "This is a message"); String res = helloService.hello(object); System.out.println(res); } }注意这里 RpcClientProxy 通过传入不同的 Client(SocketClient、NettyClient)来切换客户端不同的发送方式。执行后可以获得与之前类似的结果。
5.2实现服务端注册多个服务5.1中我们注册完HelloService的实现类,服务器就自行启动了。针对上述问题,将服务的注册和服务器启动分离,使得服务端可以提供多个服务。5.2.1服务注册表我们需要一个容器,这个容器很简单,就是保存一些本地服务的信息,并且在获得一个服务名字的时候能够返回这个服务的信息。创建一个 ServiceRegistry 接口:public interface ServiceRegistry { <T> void register(T service); Object getService(String serviceName); }一目了然,一个register注册服务信息,一个getService获取服务信息。我们新建一个默认的注册表类 DefaultServiceRegistry 来实现这个接口,提供服务注册服务,如下:public class DefaultServiceRegistry implements ServiceRegistry { private static final Logger logger = LoggerFactory.getLogger(DefaultServiceRegistry.class); private final Map<String, Object> serviceMap = new ConcurrentHashMap<>(); private final Set<String> registeredService = ConcurrentHashMap.newKeySet(); @Override public synchronized <T> void register(T service) { String serviceName = service.getClass().getCanonicalName(); if(registeredService.contains(serviceName)) return; registeredService.add(serviceName); Class<?>[] interfaces = service.getClass().getInterfaces(); if(interfaces.length == 0) { throw new RpcException(RpcError.SERVICE_NOT_IMPLEMENT_ANY_INTERFACE); } for(Class<?> i : interfaces) { serviceMap.put(i.getCanonicalName(), service); } logger.info("向接口: {} 注册服务: {}", interfaces, serviceName); } @Override public synchronized Object getService(String serviceName) { Object service = serviceMap.get(serviceName); if(service == null) { throw new RpcException(RpcError.SERVICE_NOT_FOUND); } return service; } }我们将服务名与提供服务的对象的对应关系保存在一个 ConcurrentHashMap 中,并且使用一个 Set 来保存当前有哪些对象已经被注册。在注册服务时,默认采用这个对象实现的接口的完整类名作为服务名,例如某个对象 A 实现了接口 X 和 Y,那么将 A 注册进去后,会有两个服务名 X 和 Y 对应于 A 对象。这种处理方式也就说明了某个接口只能有一个对象提供服务。获得服务的对象就更简单了,直接去 Map 里查找就行了。5.2.2其他处理为了降低耦合度,我们不会把 ServiceRegistry 和某一个 RpcServer 绑定在一起,而是在创建 RpcServer 对象时,传入一个 ServiceRegistry 作为这个服务的注册表。那么 RpcServer 这个类现在就变成了这样:public class RpcServer { private static final Logger logger = LoggerFactory.getLogger(RpcServer.class); private static final int CORE_POOL_SIZE = 5; private static final int MAXIMUM_POOL_SIZE = 50; private static final int KEEP_ALIVE_TIME = 60; private static final int BLOCKING_QUEUE_CAPACITY = 100; private final ExecutorService threadPool; private RequestHandler requestHandler = new RequestHandler(); private final ServiceRegistry serviceRegistry; public RpcServer(ServiceRegistry serviceRegistry) { this.serviceRegistry = serviceRegistry; BlockingQueue<Runnable> workingQueue = new ArrayBlockingQueue<>(BLOCKING_QUEUE_CAPACITY); ThreadFactory threadFactory = Executors.defaultThreadFactory(); threadPool = new ThreadPoolExecutor(CORE_POOL_SIZE, MAXIMUM_POOL_SIZE, KEEP_ALIVE_TIME, TimeUnit.SECONDS, workingQueue, threadFactory); } public void start(int port) { try (ServerSocket serverSocket = new ServerSocket(port)) { logger.info("服务器启动……"); Socket socket; while((socket = serverSocket.accept()) != null) { logger.info("消费者连接: {}:{}", socket.getInetAddress(), socket.getPort()); threadPool.execute(new RequestHandlerThread(socket, requestHandler, serviceRegistry)); } threadPool.shutdown(); } catch (IOException e) { logger.error("服务器启动时有错误发生:", e); } } }在创建 RpcServer 时需要传入一个已经注册好服务的 ServiceRegistry,而原来的 register 方法也被改成了 start 方法,因为服务的注册已经不由 RpcServer 处理了,它只需要启动就行了。而在每一个请求处理线程(RequestHandlerThread)中也就需要传入 ServiceRegistry 了,这里把处理线程和处理逻辑分成了两个类:RequestHandlerThread 只是一个线程,从ServiceRegistry 获取到提供服务的对象后,就会把 RpcRequest 和服务对象直接交给 RequestHandler 去处理,反射等过程被放到了 RequestHandler 里。(1)处理线程类(工作线程 ):RequesthandlerThread.java:处理线程,接收对象等public class RequestHandlerThread implements Runnable { private static final Logger logger = LoggerFactory.getLogger(RequestHandlerThread.class); private Socket socket; private RequestHandler requestHandler; private ServiceRegistry serviceRegistry; public RequestHandlerThread(Socket socket, RequestHandler requestHandler, ServiceRegistry serviceRegistry) { this.socket = socket; this.requestHandler = requestHandler; this.serviceRegistry = serviceRegistry; } @Override public void run() { try (ObjectInputStream objectInputStream = new ObjectInputStream(socket.getInputStream()); ObjectOutputStream objectOutputStream = new ObjectOutputStream(socket.getOutputStream())) { RpcRequest rpcRequest = (RpcRequest) objectInputStream.readObject(); String interfaceName = rpcRequest.getInterfaceName(); Object service = serviceRegistry.getService(interfaceName); Object result = requestHandler.handle(rpcRequest, service); objectOutputStream.writeObject(RpcResponse.success(result)); objectOutputStream.flush(); } catch (IOException | ClassNotFoundException e) { logger.error("调用或发送时有错误发生:", e); } } }(2)处理逻辑类:RequestHandler.java:通过反射进行方法调用public class RequestHandler { private static final Logger logger = LoggerFactory.getLogger(RequestHandler.class); public Object handle(RpcRequest rpcRequest, Object service) { Object result = null; try { result = invokeTargetMethod(rpcRequest, service); logger.info("服务:{} 成功调用方法:{}", rpcRequest.getInterfaceName(), rpcRequest.getMethodName()); } catch (IllegalAccessException | InvocationTargetException e) { logger.error("调用或发送时有错误发生:", e); } return result; } private Object invokeTargetMethod(RpcRequest rpcRequest, Object service) throws IllegalAccessException, InvocationTargetException { Method method; try { method = service.getClass().getMethod(rpcRequest.getMethodName(), rpcRequest.getParamTypes()); } catch (NoSuchMethodException e) { return RpcResponse.fail(ResponseCode.METHOD_NOT_FOUND); } return method.invoke(service, rpcRequest.getParameters()); } }在这种情况下,客户端完全不需要做任何改动。ps:JDK创建线程池如果没有指定线程工厂则会使用了默认的线程工厂(DefaultThreadFactory)static class DefaultThreadFactory implements ThreadFactory { private static final AtomicInteger poolNumber = new AtomicInteger(1); private final ThreadGroup group; private final AtomicInteger threadNumber = new AtomicInteger(1); private final String namePrefix; DefaultThreadFactory() { // 声明安全管理器 SecurityManager s = System.getSecurityManager(); // 得到线程组 group = (s != null) ? s.getThreadGroup() : Thread.currentThread().getThreadGroup(); // 线程名前缀,例如 "pool-1-thread-" namePrefix = "pool-" + poolNumber.getAndIncrement() + "-thread-"; } /** * 用于创建一个线程 */ public Thread newThread(Runnable r) { Thread t = new Thread(group, r, namePrefix + threadNumber.getAndIncrement(), 0); // 设置线程t为前台线程 if (t.isDaemon()) t.setDaemon(false); // 设置线程t的优先级为5 if (t.getPriority() != Thread.NORM_PRIORITY) t.setPriority(Thread.NORM_PRIORITY); return t; } }5.2.3测试我比较懒,还是搞一个服务的,就是测试下兼容性而已(理论上没问题)。服务端的测试:public class TestServer { public static void main(String[] args) { HelloService helloService = new HelloServiceImpl(); // 创建服务注册的实现类注册服务,RpcServer注入服务,然后启动服务 ServiceRegistry serviceRegistry = new DefaultServiceRegistry(); serviceRegistry.register(helloService); RpcServer rpcServer = new RpcServer(serviceRegistry); rpcServer.start(9000); } }客户端不需要变动。执行后应当获得和上次相同的结果。
基本术语结点 (node) :网络中的结点可以是计算机,集线器,交换机或路由器等。链路(link ) : 从一个结点到另一个结点的一段物理线路。中间没有任何其他交点。主机(host) :连接在因特网上的计算机。ISP(Internet Service Provider) :因特网服务提供者(提供商)。IXP(Internet eXchange Point) : 互联网交换点 IXP 的主要作用就是允许两个网络直接相连并交换分组,而不需要再通过第三个网络来转发分组。RFC(Request For Comments) :意思是“请求评议”,包含了关于 Internet 几乎所有的重要的文字资料。广域网 WAN(Wide Area Network) :任务是通过长距离运送主机发送的数据。城域网 MAN(Metropolitan Area Network):用来将多个局域网进行互连。局域网 LAN(Local Area Network) : 学校或企业大多拥有多个互连的局域网。个人区域网 PAN(Personal Area Network) :在个人工作的地方把属于个人使用的电子设备用无线技术连接起来的网络 。分组/包(packet ) :因特网中传送的数据单元。由首部 header 和数据段组成。分组又称为包,首部可称为包头。存储转发(store and forward ) :路由器收到一个分组,先检查分组是否正确,并过滤掉冲突包错误。确定包正确后,取出目的地址,通过查找表找到想要发送的输出端口地址,然后将该包发送出去。带宽(bandwidth) :在计算机网络中,表示在单位时间内从网络中的某一点到另一点所能通过的“最高数据率”。常用来表示网络的通信线路所能传送数据的能力。单位是“比特每秒”,记为 b/s。吞吐量(throughput ) :表示在单位时间内通过某个网络(或信道、接口)的数据量。吞吐量更经常地用于对现实世界中的网络的一种测量,以便知道实际上到底有多少数据量能够通过网络。吞吐量受网络的带宽或网络的额定速率的限制。重要知识点计算机网络(简称网络)把许多计算机连接在一起,而互联网把许多网络连接在一起,是网络的网络。路由器是实现分组交换的关键构件,其任务是转发收到的分组,这是网络核心部分最重要的功能。分组交换采用存储转发技术,表示把一个报文(要发送的整块数据)分为几个分组后再进行传送。在发送报文之前,先把较长的报文划分成为一个个更小的等长数据段。在每个数据端的前面加上一些由必要的控制信息组成的首部后,就构成了一个分组。分组又称为包。分组是在互联网中传送的数据单元,正是由于分组的头部包含了诸如目的地址和源地址等重要控制信息,每一个分组才能在互联网中独立的选择传输路径,并正确地交付到分组传输的终点。互联网按工作方式可划分为边缘部分和核心部分。主机在网络的边缘部分,其作用是进行信息处理。由大量网络和连接这些网络的路由器组成核心部分,其作用是提供连通性和交换。计算机通信是计算机中进程(即运行着的程序)之间的通信。计算机网络采用的通信方式是客户-服务器方式(C/S 方式)和对等连接方式(P2P 方式)。客户和服务器都是指通信中所涉及的应用进程。客户是服务请求方,服务器是服务提供方。按照作用范围的不同,计算机网络分为广域网 WAN,城域网 MAN,局域网 LAN,个人区域网 PAN。计算机网络最常用的性能指标是:速率,带宽,吞吐量,时延(发送时延,处理时延,排队时延),时延带宽积,往返时间和信道利用率。网络协议即协议,是为进行网络中的数据交换而建立的规则。计算机网络的各层以及其协议集合,称为网络的体系结构。五层体系结构由应用层,运输层,网络层(网际层),数据链路层,物理层组成。运输层最主要的协议是 TCP 和 UDP 协议,网络层最重要的协议是 IP 协议。ping和ICMP是如何探测网络情况的?ping和traceroute都是基于ICMP协议进行的。ping查询报文的操作,通过直接ping ip或者ping 域名的方式,计算机会一直发送ICMP请求,目标收到了信息会回复ICMP信息,头部类型8和0分别表示了发送和接收信息。查询报文是属于按照正常思路使用ICMP协议的操作。于此相对的,就是差错报文,差错报文是利用tracerouter发送UDP包到目标计算机的不常用端口(30000以上)的方式来可以让目标计算机返回错误信息。如果成功访问到了目标的UDP层,也会因为端口问题返回ICMP请求,这反而说明了当前计算机和目标计算机是相通的!另外,也可以通过设置TTL(time to live)的方式来进行中间路由的校验。网关和路由的工作原理网关和路由器一般是黏在一起的,网关往往分为两种,转发网关和NAT(network address translate)网关。两者区别:转发网关在转发请求的时候只会改变mac头部,但是NAT网关在转发请求的时候会同时改变ip地址。转发网关仅仅是起到一个过路财神的作用,就是为了进行数据信息的传递工作。但是NAT网关的作用就相对有趣了,是为了进行内外网的衔接而存在的。外网ip地址很稀有,因此就相对很贵,基本不可能对于内网中的每台设备都专门配备一个外网的ip,所以通过路由器时都会被伪装为同一个外网的ip,不同的是端口,NAPT(network address-port protocol)就是为此而存在的,该协议维护了一张表,用以表达两个网络间ip-端口对的互相映射关系。比如192.168.0.1就映射为外网的10086端口,192.168.0.2就映射为外网的10087端口,这样从内网发送出去的数据就可以互相区分彼此了,发送出去的数据也可以找回返回的路了。路由协议:路由之间需要进行沟通从而知道你到底帮助我些什么?路由器会维护一张路由表来记录如下问题:(1)目的网络:你的目的地是哪里?(2)出口设备:将包从哪个口扔出去?(3)下一跳网关:下一个路由器的地址?路由之间通过两种算法来构建路由器之间的网状关系,以及相互的最短路径。第一种:距离矢量路由这种方式通过TCP协议来每隔几秒向邻近的路由器发送自己的路由信息,从而整个网络每个路由器都会通过这种方式逐渐丰富自己的路由信息,最后所有路由器都会知道自己与其他路由器的连接情况。这种方式的优先是新装入路由快,但是路由一旦不行了,刷新路由就很慢,必须要不断试探直到距离超过阈值,才会判定路由之间不通。另外一个缺点就是需要发送整个全部路由表。网络太大就吃不消了。基于该算法的协议为BGP(border gateway protocol),为了避免该算法的在网络过大下产生的问题,就分为iBGP和eBGP用以分别对付内网和外网。第二种:链路状态路由算法这种方式通过DUP协议来每隔一段时间来广播传递信息,常常用于数据中心。他发送的是路由状态的改变信息,因此发送的数据量会明显小于第一种。每当一台新的路由器启动时,会向邻居发送信息以确定自己和邻居之间的距离,然后将该信息广播出去,以让整个网络中的所有路由器都能知道他已经横空出世了!同理,当邻居发送他怎么停机了时也会发送改动信息,以告诉整个网络,他好像暂时不行了。基于该算法的协议为IGP(Interior Gateway Protocol),主要用于数据中心,因为他可能快速地对路由变化进行响应所以更加便于进行负载均衡的操作。路由器和交换机的区别工作层次不同:交换机比路由器更简单,路由器比交换机能获取更多信息,交换机工作在数据链路层,而路由器工作在网络层数据转发所依据的对象不同。交换机的数据转发依据是利用物理地址或者说MAC地址来确定转发数据的目的地址,而路由器是依据ip地址进行工作的传统的交换机只能分割冲突域,不能分割广播域;而路由器可以分割广播域拓展:域名、IP、MAC 与 ARP域名是我们取代记忆复杂的 IP 的一种解决方案IP 地址才是目标在网络中所被分配的节点。MAC 地址是对应目标网卡所在的固定地址(物理地址)。ARP 英文全名为:Address resolution protocol ,地址解析协议,ARP为IP与MAC提供动态映射,过程自动完成。当PC发出通信请求时,根据协议规定,它的目的地址必然是48bit的MAC地址的。MAC并不能和IP直接去通信。那么就需我们的ARP协议来做相应的转换工作。
1. 操作系统基本特征(1) 并发并发是指宏观上在一段时间内能同时运行多个程序,而并行则指同一时刻能运行多个指令。并行需要硬件支持,如多流水线、多核处理器或者分布式计算系统操作系统通过引入进程和线程,使得程序能够并发运行。(2) 共享共享是指系统中的资源可以被多个并发进程共同使用。有两种共享方式:互斥共享和同时共享。互斥共享的资源称为临界资源,例如打印机等,在同一时刻只允许一个进程访问,需要用同步机制来实现互斥访问。(3)虚拟虚拟技术把一个物理实体转换为多个逻辑实体。主要有两种虚拟技术:时(时间)分复用技术和空(空间)分复用技术。多个进程能在同一个处理器上并发执行使用了时分复用技术,让每个进程轮流占用处理器,每次只执行一个时间片并快速切换。虚拟内存使用了空分复用技术,它将物理内存抽象为地址空间,每个进程都有各自的地址空间。地址空间的页被映射到物理内存,地址空间的页并不需要全部在物理内存中,当使用到一个没有在物理内存的页时,执行页面置换算法,将该页置换到内存中。(4)异步异步指进程不是一次性执行完毕,而是走走停停,以不可知的速度向前推进。2. 操作系统基本功能操作系统的基本功能:进程管理、内存管理、文件管理与设备管理。(1)进程管理进程控制、进程同步、进程通信、死锁处理、处理机调度等。(2)内存管理内存分配、地址映射、内存保护与共享、虚拟内存等。(3)文件管理文件存储空间的管理、目录管理、文件读写管理和保护等。(4)设备管理完成用户的 I/O 请求,方便用户使用各种设备,并提高设备的利用率。主要包括缓冲管理、设备分配、设备处理、虛拟设备等。3. 系统调用介绍系统调用之前,我们先来了解一下用户态和系统态。根据进程访问资源的特点,操作系统需要两种CPU状态:内核态:运行操作系统程序,cpu可以访问内存的所有数据,包括外围设备,例如硬盘,网卡,cpu也可以将自己从一个程序切换到另一个程序。用户态:只能受限的访问内存,运行用户程序,且不允许访问外围设备,占用cpu的能力被剥夺,cpu资源可以被其他程序获取。拓展:指令划分特权指令:只能由操作系统使用、用户程序不能使用的指令。 举例:启动I/O 内存清零 修改程序状态字 设置时钟 允许/禁止终端 停机非特权指令:用户程序可以使用的指令。 举例:控制转移 算数运算 取数指令 访管指令(使用户程序从用户态陷入内核态)特权级别:特权环:R0、R1、R2和R3R0相当于内核态,R3相当于用户态;不同级别能够运行不同的指令集合;系统调用:如果一个进程在用户态需要使用内核态的功能,就进行系统调用从而陷入内核,由操作系统代为完成。也就是说在我们运行的用户程序中,凡是与系统态级别的资源有关的操作(如文件管理、进程控制、内存管理等),都必须通过系统调用方式向操作系统提出服务请求,并由操作系统代为完成。系统调用和普通库函数调用非常相似,只是系统调用由操作系统核心提供,运行于系统态,而普通的函数调用由函数库或用户自己提供,运行于用户态。4. 内核态与用户态,之间如何转换为什么要有用户态和内核态?由于需要限制不同的程序之间的访问能力, 防止他们获取别的程序的内存数据, 或者获取外围设备的数据, 并发送到网络, CPU划分出两个权限等级 -- 用户态和内核态。主要是安全考虑。1)用户态切换到内核态的3种方式:a. 系统调用这是用户态进程主动要求切换到内核态的一种方式,用户态进程通过系统调用申请使用操作系统提供的服务程序完成工作。而系统调用的机制其核心还是使用了操作系统为用户特别开放的一个中断来实现,例如Linux的int 80h中断。b. 异常当CPU在执行运行在用户态下的程序时,发生了某些事先不可知的异常,这时会触发由当前运行进程切换到处理此异常的内核相关程序中,也就转到了内核态,比如缺页异常。c. 外围设备的中断当外围设备完成用户请求的操作后,会向CPU发出相应的中断信号,这时CPU会暂停执行下一条即将要执行的指令转而去执行与中断信号对应的处理程序,如果先前执行的指令是用户态下的程序,那么这个转换的过程自然也就发生了由用户态到内核态的切换。比如硬盘读写操作完成,系统会切换到硬盘读写的中断处理程序中执行后续操作等。这3种方式是系统在运行时由用户态转到内核态的最主要方式,其中系统调用可以认为是用户进程主动发起的,异常和外围设备中断则是被动的。内核态切换到用户态的途径——>设置程序状态字PSW,在PSW中有一个二进制位控制这两种模式。内核态与用户态的区别:内核态与用户态是操作系统的两种运行级别,当程序运行在3级特权级上时,就可以称之为运行在用户态;当程序运行在0级特权级上时,就可以称之为运行在内核态。运行在用户态下的程序不能直接访问操作系统内核数据结构和程序。当我们在系统中执行一个程序时,大部分时间是运行在用户态下的,在其需要操作系统帮助完成某些它没有权力和能力完成的工作时就会切换到内核态。处于用户态执行时,进程所能访问的内存空间和对象受到限制,其所处于占有的处理机是可被抢占的 ;而处于核心态执行中的进程,则能访问所有的内存空间和对象,且所占有的处理机是不允许被抢占的。5. 大内核和微内核1.大内核大内核是将操作系统功能作为一个紧密结合的整体放到内核。由于各模块共享信息,因此有很高的性能。2.微内核由于操作系统不断复杂,因此将一部分操作系统功能移出内核,从而降低内核的复杂性。移出的部分根据分层的原则划分成若干服务,相互独立。在微内核结构下,操作系统被划分成小的、定义良好的模块,只有微内核这一个模块运行在内核态,其余模块运行在用户态。因为需要频繁地在用户态和核心态之间进行切换,所以会有一定的性能损失。进程与线程1. 中断“中断” 是让【操作系统内核】夺回CPU使用权的唯一途径。基本方式:CPU中断正在运行的程序,转到处理中断事件程序。为什么在操作系统中设计了中断的概念?为了提高并发执行的效率。中断有三种方式:外部中断、异常与陷入。(1)外中断由 CPU 执行指令以外的事件引起,如 I/O 完成中断,表示设备输入/输出处理已经完成,处理器能够发送下一个输入/输出请求。此外还有时钟中断、控制台中断等。(2)异常由 CPU 执行指令的内部事件引起,如非法操作码、地址越界、算术溢出等。(3)陷入在用户程序中使用系统调用。2. 进程与线程进程是什么?进程是资源分配的基本单位。进程组成:PCB(进程描述信息、控制管理信息、资源分配信息等)、程序段(程序中代码)、数据段(运行过程中产生的各种数据)进程控制块 (Process Control Block, PCB) 描述进程的基本信息和运行状态,所谓的创建进程和撤销进程,都是指对 PCB 的操作。PCB(Process Control Block)包含的信息:(1)进程标识符信息。进程标识符用于惟一地标识一个进程。一个进程,通常有以下两个标识符:外部标识符,内部标识符。(2)处理机状态信息。处理机状态信息主要是由处理机各种寄存器中的内容所组成。(3)进程调度信息。在PCB中还存放了一些与进程调度和进程对换有关的信息,包括:进程状态、进程优先级、进程调度所需要的其他信息、事件。(4)进程控制信息。进程控制信息包括:程序和数据的地址、进程同步和通信机制、资源清单、链接指针。进程的组织形式:链接方式:按照进程状态将PCB分为多个队列,操作系统持有指向各个队列的指针索引方式:按照进程状态的不同,建立几张索引表,操作系统持有各个索引表的指针进程的最大线程数(1)32位windows下,一个进程空间4G,内核占2G,留给用户只有2G,一个线程默认栈是1M,所以一个进程最大开2048个线程。当然内存不会完全拿来做线程的栈,所以最大线程数实际值要小于2048,大概2000个。(2)32位Linux下,一个进程空间4G,内核占1G,用户留3G,一个线程默认8M,所以最多380个左右线程。进程在操作系统中的主要状态(1)就绪状态(ready):已经具备运行条件,因为其他线程正在运行而停止或者时间片已经用完,等待被调度(等待获取时间片)(2)运行状态(running):占用cpu(3)阻塞状态(waiting):等待资源/事件(如等待输入/输出而暂停运行)ps:还有创建状态和结束状态。相互转换关系如下:进程状态切换注意:不能由阻塞态直接转换为运行态,也不能由就绪态直接抓换为阻塞态(因为进入阻塞态是进程主动请求的,必然需要进程在运行时才能发出这种请求);运行态到阻塞态是一种进程自身做出的主动行为;线程是什么?线程是cpu调度(程序执行)的基本单位。一个进程中可以有多个线程,它们共享进程资源。例如:QQ 和浏览器是两个进程,浏览器进程里面有很多线程,例如 HTTP 请求线程、事件响应线程、渲染线程等等,线程的并发执行使得在浏览器中点击一个新链接从而发起 HTTP 请求时,浏览器还可以响应用户的其它事件。总结:两者的区别?进程是资源分配的最小单位,线程是程序执行(cpu调度)的最小单位。进程有自己的独立地址空间,每启动一个进程,系统就会为它分配地址空间,拥有系统资源;线程不拥有系统资源,但共享进程中的数据、地址空间。线程之间的通信更方便,同一进程下的线程共享全局变量、静态变量等数据;进程之间的通信需要以通信的方式(IPC(Inter-Process Communication))进行。多进程程序更健壮,当进程中的一个线程奔溃时,会导致其所属进程的所有线程奔溃。但是,进程有自己独立的地址空间(进程之间互不干扰)。线程相对进程能减少并发执行的时间和空间开销;ps:协程,是一种比线程更加轻量级的存在。正如一个进程可以拥有多个线程一样,一个线程也可以拥有多个协程。最重要的是,协程不是被操作系统内核所管理,而完全是由程序所控制(也就是在用户态执行)。这样带来的好处就是性能得到了很大的提升,不会像线程切换(上下文切换)那样消耗资源。3. 什么是上下文切换?当前任务在执行完 CPU 时间片切换到另一个任务之前会先保存自己的状态,以便下次再切换回这个任务时,可以再加载这个任务的状态。任务从保存到再加载的过程就是一次上下文切换。系统中的每个程序都是运行在某个进程的上下文中的。上下文是由程序正确运行所需的状态组成的,这个状态包括存放在存储器中的程序的代码和数据,他的栈,通用目的寄存器的内容,程序计数器,环境变量以及打开文件描述符的集合。上下文切换的什么?进程是由内核管理和调度的,所以进程的切换只能发生在内核态。所以,进程的上下文切换不仅包含了虚拟内存、栈、全局变量等用户空间的资源,还包括了内核堆栈、寄存器等内核空间的资源。通常,会把交换的信息保存在进程的 PCB,当要运行另外一个进程的时候,我们需要从这个进程的 PCB 取出上下文,然后恢复到 CPU 中,这使得这个进程可以继续执行。上下文切换场景?为了保证所有进程可以得到公平调度,CPU 时间被划分为一段段的时间片,这些时间片再被轮流分配给各个进程。这样,当某个进程的时间片耗尽了,就会被系统挂起,切换到其它正在等待 CPU 的进程运行;进程在系统资源不足(比如内存不足)时,要等到资源满足后才可以运行,这个时候进程也会被挂起,并由系统调度其他进程运行;当进程通过睡眠函数 sleep 这样的方法将自己主动挂起时,自然也会重新调度;当有优先级更高的进程运行时,为了保证高优先级进程的运行,当前进程会被挂起,由高优先级进程来运行;发生硬件中断时,CPU 上的进程会被中断挂起,转而执行内核中的中断服务程序;进程切换与线程切换的区别?进程切换就是上下文切换。虚拟内存是操作系统为每个进程提供的一种抽象,每个进程都有自己的虚拟地址空间。进程内的所有线程共享进程的虚拟地址空间。进程切换和线程切换最主要的一个区别在于进程切换涉及虚拟地址空间的切换而线程不会,所以进程切换开销较大,耗时。因为每个进程都有自己的虚拟地址空间,而线程是共享所在进程的虚拟地址空间的,因此同一个进程中的线程进行线程切换时不涉及虚拟地址空间的转换。4. 进程控制(状态切换)(1)进程的创建为新进程分配一个唯一的进程标识号,并申请一个空白的 PCB,若申请失败则创建失败。为新进程的程序和数据分配内存空间,若资源不足会进入阻塞态。初始化 PCB,主要包括标志信息、处理机状态信息、以及设置进程优先级等。若进程就绪队列未满,就将新进程插入就绪队列,等待被调度运行。(2)进程的终止进程终止包括:正常结束,表示进程已经完成并准备退出;异常结束,表示进程在运行时发生异常,程序无法继续运行,例如非法指令,IO 故障等;外界干预,指进程因为外界请求而终止,例如操作系统干预等。根据被终止进程的标识符,检索 PCB,读出该进程的状态。若被终止的进程处于执行状态,终止执行,将处理机资源分配给其他进程。若进程还有子进程,将所有子进程终止。将该进程的全部资源归还给父进程或操作系统,并将 PCB 从队列删除。(3)进程的阻塞与唤醒正在执行的进程由于等待的事件未发生,由系统执行阻塞原语,由运行态变为阻塞态。阻塞过程:找到将要被阻塞进程的 PCB。如果进程为运行态,保护现场并转为阻塞态,停止运行。把 PCB 插入相应事件的等待队列,当被阻塞进程期待的事件发生时,由相关进程调用唤醒原语,将进程唤醒。唤醒过程:在该事件的等待队列中找到进程对应的 PCB。将其从等待队列中移除,设置状态为就绪态。将 PCB 插入就绪队列,等待调度程序调度。(4)进程切换进程切换是指CPU从一个进程的运行转到另一个进程上运行。进程切换过程:保存处理机上下文,包括程序计数器和其他寄存器。更新 PCB 信息,并把 PCB 移入相应的阻塞队列。选择另一个进程执行并更新其 PCB。更新内存管理的数据结构,恢复处理机上下文。5.进程调度算法进程调度算法评价指标:image.png说明:不同环境的调度算法目标不同,因此需要针对不同环境来讨论调度算法。批处理系统批处理系统没有太多的用户操作,在该系统中,调度算法目标是保证吞吐量和周转时间(从提交到终止的时间)。(1)先来先服务 first-come first-serverd(FCFS)非抢占式的调度算法,按照请求的顺序进行调度。有利于长作业,但不利于短作业,因为短作业必须一直等待前面的长作业执行完毕才能执行,而长作业又需要执行很长时间,造成了短作业等待时间过长。(2)短作业优先 shortest job first(SJF)非抢占式的调度算法,按估计运行时间最短的顺序进行调度。长作业有可能会饿死,处于一直等待短作业执行完毕的状态。因为如果一直有短作业到来,那么长作业永远得不到调度。(3)最短剩余时间优先 shortest remaining time next(SRTN)最短作业优先的抢占式版本,按剩余运行时间的顺序进行调度。当一个新的作业到达时,其整个运行时间与当前进程的剩余时间作比较。如果新的进程需要的时间更少,则挂起当前进程,运行新的进程。否则新的进程等待。交互式系统交互式系统有大量的用户交互操作,在该系统中调度算法的目标是快速地进行响应。(1)时间片轮转将所有就绪进程按 FCFS 的原则排成一个队列,每次调度时,把 CPU 时间分配给队首进程,该进程可以执行一个时间片。当时间片用完时,由计时器发出时钟中断,调度程序便停止该进程的执行,并将它送往就绪队列的末尾,同时继续把 CPU 时间分配给队首的进程。时间片轮转算法的效率和时间片的大小有很大关系:因为进程切换都要保存进程的信息并且载入新进程的信息,如果时间片太小,会导致进程切换得太频繁,在进程切换上就会花过多时间。而如果时间片过长,那么实时性就不能得到保证。(2)优先级调度为每个进程分配一个优先级,按优先级进行调度。为了防止低优先级的进程永远等不到调度,可以随着时间的推移增加等待进程的优先级。(3)多级反馈队列一个进程需要执行 100 个时间片,如果采用时间片轮转调度算法,那么需要交换 100 次。多级队列是为这种需要连续执行多个时间片的进程考虑,它设置了多个队列,每个队列时间片大小都不同,例如 1,2,4,8,..。进程在第一个队列没执行完,就会被移到下一个队列。这种方式下,之前的进程只需要交换 7 次。每个队列优先权也不同,最上面的优先权最高。因此只有上一个队列没有进程在排队,才能调度当前队列上的进程。可以将这种调度算法看成是时间片轮转调度算法和优先级调度算法的结合。实时系统实时系统要求一个请求在一个确定时间内得到响应。分为硬实时和软实时,前者必须满足绝对的截止时间,后者可以容忍一定的超时。6.进程通信与同步进程同步与进程通信很容易混淆,它们的区别在于:(1)进程同步:控制多个进程按一定顺序执行,一种目的;(2)进程通信:进程间传输信息(包括进程同步所需要的信息,是一种手段)。进程通信是一种手段,而进程同步是一种目的。也可以说,为了能够达到进程同步的目的,需要让进程进行通信,传输一些进程同步所需要的信息。进程间的通信方式?进程之间的通信方式有管道、FIFO(命名管道),消息队列、信号量、共享内存与套接字。(1)管道管道是通过调用 pipe 函数创建的,fd[0] 用于读,fd[1] 用于写。它具有以下限制:只支持半双工通信(单向交替传输);只能在父子进程或者兄弟进程中使用。(2)FIFO(命名管道)也称为命名管道,去除了管道只能在父子进程中使用的限制。FIFO 常用于客户-服务器应用程序中,FIFO 用作汇聚点,在客户进程和服务器进程之间传递数据。(3)消息队列消息队列可以独立于读写进程存在,从而避免了 FIFO 中同步管道的打开和关闭时可能产生的困难;避免了 FIFO 的同步阻塞问题,不需要进程自己提供同步方法;读进程可以根据消息类型有选择地接收消息,而不像 FIFO 那样只能默认地接收。(4)信号量它是一个计数器,用于为多个进程提供对共享数据对象的访问。(5)共享内存共享内存在系统内存中开辟一块内存区,分别映射到各个进程的虚拟地址空间中,任何一个进程操作了内存区都会反映到其他进程中。因为数据不需要在进程之间复制,所以这是最快的一种 IPC。需要使用信号量用来同步对共享存储的访问。多个进程可以将同一个文件映射到它们的地址空间从而实现共享内存。当对共享内存的使用结束之后,这个映射关系将被删除。(6)套接字(socket)远程调用与其它通信机制不同的是,它可用于不同机器间的进程通信。socket编程是一个宽泛的说法, tcp,udp,http是我们经常用的一些网络协议。它的限制主要在与带宽,网络延时和连接数量的限制等。总结:IPC进程通信同一主机:pipe 管道,一个写入管道文件,一个读(单向)socket 套接字文件,进程间交换数据(双工工作方式)signal 信号shm shared memory,共享内存semaphore 信号量,一种计数器,分配资源不同主机:socket ip 和端口号RPC 远程过程调用MQ 消息队列,如:Kafka , RabbitMQ,ActiveMQ为什么要进程同步?多进程虽然提高了系统资源利用率和吞吐量,但是进程的异步性(可能是多次执行完成)可能造成系统的混乱。进程同步的任务就是对多个相关进程在执行顺序上进行协调,使并发执行的多个进程之间可以有效的共享资源和相互合作,保证程序执行的可再现性。(1)临界区临界区指的是一个访问共用资源(例如:共用设备或是共用存储器)的程序片段,而这些共用资源又无法同时被多个线程访问的特性。许多物理设备都属于临界资源,如输入机、打印机、磁带机等。(2)同步与互斥同步:多个进程因为合作产生的直接制约关系,使得进程有一定的先后执行关系。互斥:多个进程在同一时刻只有一个进程能进入临界区。(3)信号量信号量(Semaphore)是一个整型变量,可以对其执行 down 和 up 操作,也就是常见的 P 和 V 操作。P : 如果信号量大于 0 ,执行 -1 操作;如果信号量等于 0,进程睡眠,等待信号量大于 0;申请资源V :对信号量执行 +1 操作,唤醒睡眠的进程让其完成 P操作。释放资源P和 V操作需要被设计成原语,不可分割,通常的做法是在执行这些操作的时候屏蔽中断。如果信号量的取值只能为 0 或者 1,那么就成为了互斥量(Mutex),0 表示临界区已经加锁,1 表示临界区解锁。(4)管程使用信号量机制实现的生产者消费者问题需要客户端代码做很多控制,而管程把控制的代码独立出来,不仅不容易出错,也使得客户端代码调用更容易。管程有一个重要特性:在一个时刻只能有一个进程使用管程。进程在无法继续执行的时候不能一直占用管程,否则其它进程永远不能使用管程。管程引入了条件变量以及相关的操作:wait()和 signal()来实现同步操作。对条件变量执行 wait() 操作会导致调用进程阻塞,把管程让出来给另一个进程持有。signal() 操作用于唤醒被阻塞的进程。7.线程的通信方式线程通信主要通过共享内存和消息传递两种模型实现的,具体来说线程通信常用的方式有:(1)Volatile 内存共享volatile有两大特性,一是可见性,二是有序性,禁止指令重排序,其中可见性就是可以让线程之间进行通信。volatile语义保证线程可见性有两个原则保证:所有volatile修饰的变量一旦被某个线程更改,必须立即刷新到主内存所有volatile修饰的变量在使用之前必须重新读取主内存的值两个线程操作一个对象时,如果不加volitile关键字,就会使一个线程一直循环等待,volatile解决了一个线程空等待的问题。(2)wait/notify(等待/通知) 机制Object类提供了线程间通信的方法:wait()、notify()、notifyaAl(),等待通知机制是基于wait和notify方法来实现的,在一个线程内调用该线程锁对象的wait方法,线程将进入等待队列进行等待直到被通知或者被唤醒。为什么必须获取锁?因为调用wait方法时(使线程处于等待状态),必须要先释放锁,如果没有持有锁将会抛出异常。notify唤醒一个线程,但是还会持有线程的锁。ps:sleep使线程休眠一段时间,会持有线程的锁。(3)CountDownLatch 并发工具但是如果对于高并发情况下大量数据,当执行到某个业务逻辑节点时,需要唤醒另外一个线程对当前节点数据处理,使用notify通知,并不解决线程间的实时通信问题(notify不释放线程的锁)8. 守护、僵尸、孤儿进程的概念(1)守护进程:运行在后台的一种特殊进程,独立于控制终端并周期性地执行某些任务。(2)僵尸进程:一个进程 fork 子进程,子进程退出,而父进程没有wait/waitpid子进程,那么子进程的进程描述符仍保存在系统中,这样的进程称为僵尸进程。(3)孤儿进程:一个父进程退出,而它的一个或多个子进程还在运行,这些子进程称为孤儿进程。(孤儿进程将由 init 进程(进程号1)收养并对它们完成状态收集工作)谈一谈fork()函数在fork函数执行完毕后,如果创建新进程成功,则出现两个进程,一个是子进程,一个是父进程。在子进程中,fork函数返回0,在父进程中,fork返回新创建子进程的进程ID。我们可以通过fork返回的值来判断当前进程是子进程还是父进程。fork调用的一个奇妙之处就是它仅仅被调用一次,却能够返回两次,它可能有三种不同的返回值:1)在父进程中,fork返回新创建子进程的进程ID;2)在子进程中,fork返回0;3)如果出现错误,fork返回一个负值;子进程会继承父进程的哪些东西?子进程从父进程继承了:(1)用户号UIDs和用户组号GIDs(2)进程组号(3)当前工作目录(4)根目录(5)环境(6)打开文件的描述符(7)共享内存(8)堆栈等子进程与父进程不同的:(1)进程号PID(2)各自的父进程号(3)自己的文件描述符和目录流的拷贝(4)子进程不继承父进程的进程正文, 数据和其它锁定内存(5)不继承异步输入和输出(6)父进程设置的锁死锁什么是死锁?典型:哲学家进餐问题。并发环境下,各进程竞争资源造成相互等待对方手中的资源,导致各个进程都阻塞,都无法向前推进。1.死锁、饥饿与死循环的区别死锁:一定是互相等待对方手里的资源导致的,至少两个或者以上进程循环等待才能死锁,死锁一定处于阻塞态。饥饿:长期获取不到想要的资源,可能只有一个进程。既可以是阻塞态(长期得不到io设备)或者就绪态(长期得不到处理机)死循环:与上两种不同,死循环主要是逻辑错误或死循环导致。上边是由操作系统分配资源不合理导致的。1.死锁产生条件(同时满足四个)(1)互斥:每个资源要么已经分配给了一个进程,要么就是可用的(打印机)。(2)请求与保持:已经得到了某个资源的进程,可以再请求新的资源,此时请求被阻塞,原资源保持不放。(3)不可抢占:已经分配给一个进程的资源不能强制性地被抢占,它只能被占有它的进程显式(主动)地释放。(4)环路等待:有两个或者两个以上的进程组成一条环路,该环路中的每个进程都在等待下一个进程所占有的资源。2.处理方法主要有以下四种方法:鸵鸟策略死锁检测与死锁恢复死锁预防死锁避免(1)鸵鸟策略因为解决死锁问题的代价很高,因此鸵鸟策略这种不采取任务措施的方案会获得更高的性能。当发生死锁时不会对用户造成多大影响,或发生死锁的概率很低,可以采用鸵鸟策略。大多数操作系统,包括 Unix,Linux 和 Windows,处理死锁问题的办法仅仅是忽略它。(2)死锁检测与死锁恢复死锁检测算法: 用于检测系统状态,以确定系统中是否发生了死锁。每种类型一个资源的死锁检测每种类型多个资源的死锁检测步骤:首先为每个进程和每个资源指定一个唯一的号码;然后建立资源分配表和进程等待表。死锁恢复:解除死锁的主要方法有:资源剥夺。 挂起( 暂时放到外存上) 某些死锁进程, 并抢占它的资源, 将这些资源分配给其他的死锁进程。撤销进程。 强制撤销部分、甚至全部死锁进程,并剥夺这些进程的资源。 这种方式的优点是实现简单,但所付出的代价可能会很大。进程回退。让一个或多个死锁进程回退到足以避免死锁的地步。这就要求系统要记录进程的历史信息,设置还原点。(3)死锁预防在程序运行之前预防发生死锁。破坏互斥条件。例如假脱机打印机技术允许若干个进程同时输出,唯一真正请求物理打印机的进程是打印机守护进程。破坏占有和等待条件。一种实现方式是规定所有进程在开始执行前请求所需要的全部资源。破坏不可抢占条件。破坏环路等待。给资源统一编号,进程只能按编号顺序来请求资源。(4)死锁避免避免死锁并不是事先采取某种限制措施破坏死锁的必要条件,而是在资源动态分配过程中,防止系统进入不安全状态,以避免发生死锁,比如银行家算法、系统安全状态、安全性算法。系统安全状态不安全状态可能会导致死锁,如果一次分配不会导致系统进入不安全状态,则将资源分配给进程,否则就让进程等待。安全状态是指系统能按照某种进程推进顺序为每个进程分配资源,直到满足每个进程对资源的需求。银行家算法把操作系统视为银行家,资源视为资金,进程向操作系统申请资源相当于用户向银行家贷款。操作系统按照规则为进程分配资源,当进程首次申请资源时,要测试系统现存资源能否满足其最大需求量,可以则按申请量分配,否则推迟分配。当进程在执行中继续申请资源时,先测试该进程已占有的资源数与申请的资源数之和是否超过该进程对资源的最大需求量,如果超过则拒绝分配,否则再测试系统现存的资源能否满足该进程尚需的最大资源量,如果满足则按申请量分配,否则推迟分配。内存管理1. 内存管理的方式操作系统中的内存管理有三种,段式,页式,段页式。为什么需要三种管理方式?由于【连续内存分配方式】会导致【内存利用率偏低】以及【内存碎片】的问题,因此需要对这些离散的内存进行管理。引出了三种内存管理方式。分页存储管理:(1)基本分页存储管理中不具备页面置换功能,因此需要整个程序的所有页面都装入内存之后才可以运行。(2)需要一个页表来记录逻辑地址(页号)和实际存储地址(物理块号)之间的映射关系,以实现从页号到物理块号的映射。(3)由于页表也是存储在内存中的,因此内存数据需要两次的内存访问(一次是从内存中访问页表,从中找到指定的物理块号,加上页内偏移得到实际物理地址;第二次就是根据第一次得到的物理地址访问内存取出数据)。(4)为了减少两次访问内存导致的效率影响,分页管理中引入了快表。当要访问内存数据的时候,首先将页号在快表中查询,如果在快表中,直接读取相应的物理块号;如果没有找到,那么访问内存中的页表,从页表中得到物理地址,同时将页表中的该映射表项添加到快表中。(5)在某些计算机中如果内存的逻辑地址很大,将会导致程序的页表项会很多,而页表在内存中是连续存放的,所以相应的就需要较大的连续内存空间。为了解决这个问题,可以采用两级页表或者多级页表的方法,其中外层页表一次性调入内存且连续存放,内层页表离散存放。相应的访问内存页表的时候需要一次地址变换,访问逻辑地址对应的物理地址的时候也需要一次地址变换,而且一共需要访问内存3次才可以读取一次数据。引入多级页表的主要目的是为了避免把全部页表一直放在内存中占用过多空间(时间换空间),特别是那些根本就不需要的页表就不需要保留在内存中。多级页表属于时间换空间的典型场景。分段存储管理:分页是为了提高内存利用率,而分段是为了满足程序员在编写代码的时候的一些逻辑需求(比如数据共享,数据保护,动态链接等)。(1)分段内存管理当中,地址是二维的,一维是段号,一维是段内地址;(2)其中每个段的长度是不一样的,而且每个段内部都是从0开始编址的。由于分段管理中,每个段内部是连续内存分配,但是段和段之间是离散分配的,因此也存在一个逻辑地址到物理地址的映射关系,相应的就是段表机制。段表中的每一个表项记录了该段在内存中的起始地址和该段的长度。段表可以放在内存中也可以放在寄存器中。(3)访问内存的时候根据段号和段表项的长度计算当前访问段在段表中的位置,然后访问段表,得到该段的物理地址,根据该物理地址以及段内偏移量就可以得到需要访问的内存。由于也是两次内存访问,所以分段管理中同样引入了联想寄存器。分段和分页的对比(1)页是信息的物理单位,是出于系统内存利用率的角度提出的离散分配机制;段是信息的逻辑单位,每个段含有一组意义完整的信息,是出于用户角度提出的内存管理机制。(2)页的大小是固定的,由系统决定;段的大小是不确定的,由用户决定。(3)页地址空间是一维的,段地址空间是二维的。(4)分段比分页更容易实现信息的共享和保护,分页系统的虚拟内存功能。段页存储方式段页存储方式,既拥有分段系统的共享和保护,又拥有分页系统的虚拟内存功能。(1)先将用户程序分为若干个段,然后再把每个段分成若干个页,并且为每一个段赋予一个段名称。这样在段页式管理中,一个内存地址就由段号,段内页号以及页内地址三个部分组成。(2)段页式内存访问:系统中设置了一个段表寄存器,存放段表的起始地址和段表的长度。地址变换时,根据给定的段号(还需要将段号和寄存器中的段表长度进行比较防止越界)以及寄存器中的段表起始地址,就可以得到该段对应的段表项,从段表项中得到该段对应的页表的起始地址,然后利用逻辑地址中的段内页号从页表中找到页表项,从该页表项中的物理块地址以及逻辑地址中的页内地址拼接出物理地址,最后用这个物理地址访问得到所需数据。由于访问一个数据需要三次内存访问,所以段页式管理中也引入了高速缓冲寄存器。如何实现逻辑地址到物理地址的映射管理?分页机制的思想是:通过映射,可以使连续的线性地址与物理地址相关联,逻辑上连续的线性地址对应的物理地址可以不连续!分页的作用 :(1)将线性地址转换为物理地址 (2)用大小相同的页替换大小不同的段。逻辑地址 = 页号 + 页内偏移量,取到页号之后,查询页表,得到块号,然后在内存中通过块号&页内偏移量得到最终的物理地址2. 页表项中各个位的作用,什么是缺页中断?页表项中各个位的作用:中断位: 用于判断该页是不是在内存中,如果是0,表示该页面不在内存中,会引起一个缺页中断保护位(存取控制位):用于指出该页允许什么类型的访问(只读或者读写),如果用一位来标识的话:1表示只读,0表示读写修改位(脏位):用于页面的换出,如果某个页面被修改过(即为脏),在淘汰该页时,必须将其写回磁盘,反之,可以直接丢弃该页访问位:不论是读还是写(get or set),系统都会设置该页的访问位,它的值用来帮助操作系统在发生缺页中断时选择要被淘汰的页,即用于页面置换高速缓存禁止位(辅存地址位):对于那些映射到设备寄存器而不是常规内存的页面有用,假设操作系统正在循环等待某个I/O设备对其指令进行响应,保证硬件不断的从设备中读取数据而不是访问一个旧的高速缓存中的副本是非常重要的。即用于页面调入。在请求分页系统中,可以通过【查询页表中的中断位】来确定所要访问的页面是否存在于内存中。每当所要访问的页面不在内存时,会产生一次缺页中断,此时操作系统会根据页表中的外存地址在外存中找到所缺的一页,将其调入内存。缺页本身是一种中断,与一般的中断一样,需要经过4个处理步骤:保护CPU现场(保存断点)分析中断原因转入缺页中断处理程序进行处理(进入中断子程序)恢复CPU现场,继续执行3. 页面置换算法就是说 当系统内存不足的时候,而我们想往内存中添加现在立刻要用的缓存时,需要从现在的内存中踢出一部分东西。那么选取哪一部分被踢出来呢?这个选择的部分 就叫页面置换算法。我们为什么需要页面置换算法?因为在地址映射过程中,如果发现要访问的页面不在内存中,会产生缺页中断;当发生此现象的时候,如果操作系统内存中没有空余,则操作系统必须在内存里面选择一个页面将其移出内存。(1)最佳置换算法(OPT):理想的置换算法。置换策略是将当前页面中在未来最长时间内不会被访问的页置换出去。操作系统无法提前预判页面访问序列(无法实现)。 因此, 最佳置换算法是无法实现的。(2)先进先出置换算法(FIFO):每次淘汰最早调入的页面 。(3)最近最久未使用算法(LRU):每次淘汰最久没有使用的页面。使用了一个时间标志。(4)时钟算法clock(最近未使用算法NRU):页面设置一个访问位,并将页面链接为一个环形队列,页面被访问的时候访问位设置为1。页面置换的时候,如果当前指针所指页面访问为为0,那么置换,否则将其置为0,循环直到遇到一个访问为位0的页面(5)改进型Clock算法:在Clock算法的基础上添加一个修改位,替换时根究访问位和修改位综合判断。优先替换访问为何修改位都是0的页面,其次是访问位为0修改位为1的页面。(6)最少使用算法(LFU):设置寄存器记录页面被访问次数,每次置换的时候置换当前访问次数最少的。LFU和LRU是很类似的,支持硬件也是一样的,但是区分两者的关键在于一个以时间为标准,一个以次数为标准。4. 虚拟内存(分页存储管理)为了更好的管理内存,操作系统将【内存抽象成地址空间】(虚拟的存在,便于管理,实际是一种映射)。(1)每个程序拥有自己的地址空间,这个地址空间被分割成多个块,每一块称为一页。这些页被映射到物理内存,但不需要映射到连续的物理内存,也不需要所有页都必须在物理内存中。当程序引用到不在物理内存中的页时,由硬件执行必要的映射,将缺失的部分装入物理内存并重新执行失败的指令。(2)通过虚拟内存可以让程序可以拥有超过系统物理内存大小的可用内存空间。(3)虚拟内存为每个进程提供了一个一致的、私有的地址空间,它让每个进程产生了一种自己在独享主存的错觉(每个进程拥有一片连续完整的内存空间)。这样会更加有效地管理内存并减少出错。5. 快表地址转化原理(交换空间)为了解决虚拟地址到物理地址的转换速度,操作系统在页表方案基础之上引入了快表来加速虚拟地址到物理地址的转换。可以把快表理解为一种特殊的高速缓冲存储器(Cache),其中的内容是页表的一部分或者全部内容。作为页表的 Cache,它的作用与页表相似,但是提高了访问速率。由于采用页表做地址转换,读写内存数据时 CPU 要访问两次主存。有了快表,有时只要访问一次高速缓冲存储器,一次主存,这样可加速查找并提高指令执行速度。使用快表之后的地址转换流程是这样的:(1)根据虚拟地址中的页号查快表(快表中存放虚拟地址(逻辑地址)到物理地址的映射,这和页表相似);(2)如果该页在快表中,直接从快表中读取相应的物理地址;(3)如果该页不在快表中,就访问内存中的页表,再从页表中得到物理地址,同时将页表中的该映射表项添加到快表中;(4)当快表填满后,又要登记新页时,就按照一定的淘汰策略淘汰掉快表中的一个页。6. 局部性原理局部性原理表现在以下两个方面:(1)时间局部性 :如果程序中的某条指令一旦执行,不久以后该指令可能再次执行;如果某数据被访问过,不久以后该数据可能再次被访问。产生时间局部性的典型原因,是由于在程序中存在着大量的循环操作。(2)空间局部性 :一旦程序访问了某个存储单元,在不久之后,其附近的存储单元也将被访问,即程序在一段时间内所访问的地址,可能集中在一定的范围之内,这是因为指令通常是顺序存放、顺序执行的,数据也一般是以向量、数组、表等形式簇聚存储的。局部性原理主要是实现高速缓存:时间局部性是通过将近来使用的指令和数据保存到高速缓存存储器中,并使用高速缓存的层次结构实现。空间局部性通常是使用较大的高速缓存,并将预取机制集成到高速缓存控制逻辑中实现。虚拟内存技术实际上就是建立了 “内存一外存”的两级存储器的结构,利用局部性原理实现髙速缓存。7.内存泄露与溢出内存溢出(OOM):指程序在申请内存时,没有足够的空间使用,即你要求分配的内存超出了系统所能给你的,系统不能满足需求,所以产生溢出。内存泄露(ML):使用完系统分配的内存未归还,导致系统不能将它分配给其他程序了,一次内存泄露的危害还可以忽略。但是内存泄露堆积最终会导致内存溢出!内存溢出的解决方案:修改JVM启动参数,直接增加内存。检查错误日志,查看“OutOfMemory”错误前是否有其它异常或错误。对代码进行走查和分析,找出可能发生内存溢出的位置。重点排查以下几点:检查代码中是否有死循环或递归调用。检查是否有大循环重复产生新对象实体。检查对数据库查询中,是否有一次获得全部数据的查询。一般来说,如果一次取十万条记录到内存,就可能引起内存溢出。这个问题比较隐蔽,在上线前,数据库中数据较少,不容易出问题,上线后,数据库中数据多了,一次查询就有可能引起内存溢出。因此对于数据库查询尽量采用分页的方式查询。检查List、MAP等集合对象是否有使用完后,未清除的问题。List、MAP等集合对象会始终存有对对象的引用,使得这些对象不能被GC回收。使用内存查看工具动态查看内存使用情况。文件系统1. 磁盘结构(1)盘面(Platter):一个磁盘有多个盘面;(2)磁道(Track):盘面上的圆形带状区域,一个盘面可以有多个磁道;(3)扇区(Track Sector):磁道上的一个弧段,一个磁道可以有多个扇区,它是最小的物理储存单位,目前主要有 512 bytes 与 4 K 两种大小;(4)磁头(Head):与盘面非常接近,能够将盘面上的磁场转换为电信号(读),或者将电信号转换为盘面的磁场(写);(5)制动手臂(Actuator arm):用于在磁道之间移动磁头;(6)主轴(Spindle):使整个盘面转动。2. 磁盘调度算法读写一个磁盘块的时间的影响因素有:(1)旋转时间(主轴转动盘面,使得磁头移动到适当的扇区上)(2)寻道时间(制动手臂移动,使得磁头移动到适当的磁道上)(3)实际的数据传输时间其中,寻道时间最长,因此磁盘调度的主要目标是使磁盘的平均寻道时间最短。先来先服务(FCFS, First Come First Served)按照磁盘请求的顺序进行调度。优点是公平和简单。缺点也很明显,因为未对寻道做任何优化,使平均寻道时间可能较长。最短寻道时间优先(SSTF, Shortest Seek Time First)优先调度与当前磁头所在磁道距离最近的磁道。虽然平均寻道时间比较低,但是不够公平。如果新到达的磁道请求总是比一个在等待的磁道请求近,那么在等待的磁道请求会一直等待下去,也就是出现饥饿现象。具体来说,两端的磁道请求更容易出现饥饿现象。电梯算法(SCAN)电梯总是保持一个方向运行,直到该方向没有请求为止,然后改变运行方向。电梯算法(扫描算法)和电梯的运行过程类似,总是按一个方向来进行磁盘调度,直到该方向上没有未完成的磁盘请求,然后改变方向。因为考虑了移动方向,因此所有的磁盘请求都会被满足,解决了 SSTF 的饥饿问题。Linux内核补充1.linux终端按下Ctrl+C发生了什么ctrl-c: ( kill foreground process ) 发送 SIGINT 信号给前台进程组中的所有进程,强制终止程序的执行;ctrl-z: ( suspend foreground process ) 发送 SIGTSTP 信号给前台进程组中的所有进程,常用于挂起一个进程,而并非结束进程,用户可以使用fg/bg操作恢复2.IO模型(1)阻塞式 I/O应用进程被阻塞,直到数据从内核缓冲区复制到应用进程缓冲区中才返回。应该注意到,在阻塞的过程中,其它应用进程还可以执行,因此阻塞不意味着整个操作系统都被阻塞。因为其它应用进程还可以执行,所以不消耗 CPU 时间,这种模型的 CPU 利用率会比较高。(2)非阻塞式 I/O应用进程执行系统调用之后,内核返回一个错误码。应用进程可以继续执行,但是需要不断的执行系统调用来获知 I/O 是否完成,这种方式称为轮询(polling)。由于 CPU 要处理更多的系统调用,因此这种模型的 CPU 利用率比较低。ps:系统调用,一个进程在用户态需要使用内核态的功能。(3)IO多路复用linux用select/poll函数实现IO复用模型,这两个函数也会使进程阻塞,但是和阻塞IO所不同的是这两个函数可以同时阻塞多个IO操作。而且可以同时对多个读操作、写操作的IO函数进行检测。知道有数据可读或可写时,才真正调用IO操作函数。(4)信号驱动IOlinux用套接口进行信号驱动IO,安装一个信号处理函数,进程继续运行并不阻塞,当IO时间就绪,进程收到SIGIO信号,然后处理IO事件。相比于非阻塞式 I/O 的轮询方式,信号驱动 I/O 的 CPU 利用率更高(通知进程可以开始io操作)。(5)异步 I/O应用进程执行 aio_read 系统调用会立即返回,应用进程可以继续执行,不会被阻塞,内核会在所有操作完成之后向应用进程发送信号(通知进程io操作完成)。异步 I/O 与信号驱动 I/O 的区别在于,异步 I/O 的信号是通知应用进程 I/O 完成,而信号驱动 I/O 的信号是通知应用进程可以开始 I/O。注意:阻塞I/O,非阻塞I/O,信号驱动I/O和I/O复用都是同步I/O。同步I/O指内核向应用程序通知的是就绪事件,比如只通知有客户端连接,要求用户代码自行执行I/O操作;异步I/O是指内核向应用程序通知的是完成事件,比如读取客户端的数据后才通知应用程序,由内核完成I/O操作。3.select/poll/epollselect/poll/epoll 都是 I/O 多路复用的具体实现,select 出现的最早,之后是 poll,再是 epoll。(1)selectint select(int n, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);select 允许应用程序监视一组文件描述符,等待一个或者多个描述符成为就绪状态,从而完成 I/O 操作。fd_set 使用数组实现,数组大小使用 FD_SETSIZE 定义,所以只能监听少于 FD_SETSIZE 数量的描述符。有三种类型的描述符类型:readset、writeset、exceptset,分别对应读、写、异常条件的描述符集合。select本质上是通过设置或者检查存放fd标志位的数据结构来进行下一步处理,通过轮询所有的文件描述符来检查是否有事件发生。(2)pollint poll(struct pollfd *fds, unsigned int nfds, int timeout);poll 的功能与 select 类似,依然采用轮询遍历的方式检查是否有事件发生。其和select不同的地方:采用链表的方式替换原有fd_set数据结构,而使其没有连接数的限制。(3)epollepoll是一种更加高效的IO多路复用的方式,它可以监视的文件描述符数量突破了1024的限制(十万),同时不需要通过轮询遍历的方式去检查文件描述符上是否有事件发生,因为epoll_wait返回的就是有事件发生的文件描述符。本质上是事件驱动。具体是通过红黑树和就绪链表实现的,红黑树存储所有的文件描述符,就绪链表存储有事件发生的文件描述符;(1)epoll_ctl可以对文件描述符结点进行增、删、改、查,并且告知内核注册回调函数(事件)。(2)一旦文件描述符上有事件发生时,那么内核将该文件描述符节点插入到就绪链表里面(3)这时候epoll_wait将会接收到消息,并且将数据拷贝到用户空间。表面上看epoll的性能最好,但是在连接数少并且连接都十分活跃的情况下,select和poll的性能可能比epoll好,毕竟epoll的通知机制需要很多函数回调。epoll工作模式epoll 的描述符事件有两种触发模式:LT(level trigger)和 ET(edge trigger)。LT 模式当 epoll_wait() 检测到描述符事件到达时,将此事件通知进程,进程可以不立即处理该事件,下次调用 epoll_wait() 会再次通知进程。是默认的一种模式,并且同时支持 Blocking 和 No-Blocking。ET 模式和 LT 模式不同的是,通知之后进程必须立即处理事件,下次再调用 epoll_wait() 时不会再得到事件到达的通知。很大程度上减少了 epoll 事件被重复触发的次数,因此效率要比 LT 模式高。只支持 No-Blocking,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死。EPOLLONESHOT一个线程读取某个socket上的数据后开始处理数据,在处理过程中该socket上又有新数据可读,此时另一个线程被唤醒读取,此时出现两个线程处理同一个socket我们期望的是一个socket连接在任一时刻都只被一个线程处理,通过epoll_ctl对该文件描述符注册epolloneshot事件,一个线程处理socket时,其他线程将无法处理,当该线程处理完后,需要通过epoll_ctl重置epolloneshot事件4.select poll epoll区别(1)消息传递方式(拷贝或者共享内存):select:内核需要将消息传递到用户空间,需要内核的拷贝动作;poll:同上;epoll:通过内核和用户空间共享一块内存来实现,性能较高;(2)文件句柄剧增后带来的IO效率问题:select:因为每次调用都会对连接进行线性遍历,所以随着FD剧增后会造成遍历速度的“线性下降”的性能问题;poll:同上;epoll:由于epoll是根据每个FD上的callable函数来实现的,只有活跃的socket才会主动调用callback,所以在活跃socket较少的情况下,使用epoll不会对性能产生线性下降的问题,如果所有socket都很活跃的情况下,可能会有性能问题;(3)支持一个进程所能打开的最大连接数:select:单个进程所能打开的最大连接数,是由FD_SETSIZE宏定义的,其大小是32个整数大小(在32位的机器上,大小是3232,64位机器上FD_SETSIZE=3264),我们可以对其进行修改,然后重新编译内核,但是性能无法保证,需要做进一步测试;poll:本质上与select没什么区别,但是他没有最大连接数限制,他是基于链表来存储的;epoll:虽然连接数有上线,但是很大,1G内存的机器上可以打开10W左右的连接;补充1.使用信号量实现生产者-消费者问题问题描述:使用一个缓冲区来保存物品,只有缓冲区没有满,生产者才可以放入物品;只有缓冲区不为空,消费者才可以拿走物品。因为缓冲区属于临界资源,因此需要使用一个互斥量 mutex 来控制对缓冲区的互斥访问。为了同步生产者和消费者的行为,需要记录缓冲区中物品的数量。数量可以使用信号量来进行统计,这里需要使用两个信号量:empty 记录空缓冲区的数量,full 记录满缓冲区的数量。其中,empty 信号量是在生产者进程中使用,当 empty 不为 0 时,生产者才可以放入物品;full 信号量是在消费者进程中使用,当 full 信号量不为 0 时,消费者才可以取走物品。注意,不能先对缓冲区进行加锁,再测试信号量。也就是说,不能先执行 down(mutex) 再执行 down(empty)。如果这么做了,那么可能会出现这种情况:生产者对缓冲区加锁后,执行 down(empty) 操作, 发现 empty = 0,此时生产者睡眠。消费者不能进入临界区,因为生产者对缓冲区加锁了,消费者就无法执行 up(empty) 操作,empty 永远都为 0,导致生产者永远等待下,不会释放锁,消费者因此也会永远等待下去。2. 哲学家进餐问题五个哲学家围着一张圆桌,每个哲学家面前放着食物。哲学家的生活有两种交替活动:吃饭以及思考。当一个哲学家吃饭时,需要先拿起自己左右两边的两根筷子,并且一次只能拿起一根筷子。下面是一种错误的解法,如果所有哲学家同时拿起左手边的筷子,那么所有哲学家都在等待其它哲学家吃完并释放自己手中的筷子,导致死锁。为了防止死锁的发生,可以设置两个条件:(1)必须同时拿起左右两根筷子;(2)只有在两个邻居都没有进餐的情况下才允许进餐。3.C语言编译的整个过程(1)预处理阶段:处理以 # 开头的预处理命令;(2)编译阶段:翻译成汇编文件;(3)汇编阶段:将汇编文件翻译成可重定位目标文件;(4)链接阶段:将可重定位目标文件和 printf.o 等单独预编译好的目标文件进行合并,得到最终的可执行目标文件。ps: 目标文件(1)可执行目标文件:可以直接在内存中执行;(2)可重定位目标文件:可与其它可重定位目标文件在链接阶段合并,创建一个可执行目标文件;(3)共享目标文件:这是一种特殊的可重定位目标文件,可以在运行时被动态加载进内存并链接;ps: 静态链接和动态链接静态链接静态链接器以一组可重定位目标文件为输入,生成一个完全链接的可执行目标文件作为输出。链接器主要完成以下两个任务:(1)符号解析:每个符号对应于一个函数、一个全局变量或一个静态变量,符号解析的目的是将每个符号引用与一个符号定义关联起来。(2)重定位:链接器通过把每个符号定义与一个内存位置关联起来,然后修改所有对这些符号的引用,使得它们指向这个内存位置。。动态链接静态库有以下两个问题:(1)当静态库更新时那么整个程序都要重新进行链接;(2)对于 printf 这种标准函数库,如果每个程序都要有代码,这会极大浪费资源。共享库是为了解决静态库的这两个问题而设计的,在 Linux 系统中通常用 .so 后缀来表示,Windows 系统上它们被称为 DLL。它具有以下特点:(1)在给定的文件系统中一个库只有一个文件,所有引用该库的可执行目标文件都共享这个文件,它不会被复制到引用它的可执行文件中;(2)在内存中,一个共享库的 .text 节(已编译程序的机器代码)的一个副本可以被不同的正在运行的进程共享。4.读者-写者问题读者写者问题描述非常简单,有一个写者很多读者,多个读者可以同时读文件,但写者在写文件时不允许有读者在读文件,同样有读者在读文件时写者也不去能写文件。类似于生产者消费者问题的分析过程,首先来找找哪些是属于“等待”情况。写者要等到没有读者时才能去写文件。所有读者要等待写者完成写文件后才能去读文件。“读者--写者问题”是保证一个Writer进程必须与其他进程互斥地访问共享对象的同步问题。要解决的问题:读、读共享; 写、写互斥; 写、读互斥。实现:利用记录型信号量解决读者--写者问题利用信号量集解决读者--写者问题,增加一个限制:最多只允许RN个读者同时读
认证、授权与凭证什么是认证(Authentication)?通俗地讲就是验证当前用户的身份是否合法的过程,即你是谁?证明“你是你自己”(比如:你每天上下班打卡,都需要通过指纹打卡,当你的指纹和系统里录入的指纹相匹配时,就打卡成功)互联网中的认证:用户名密码登录;邮箱发送登录链接;手机号接收验证码。只要你能收到邮箱/验证码,就默认你是账号的主人!认证主要是为了保护系统的隐私数据与资源。拓展:什么是会话?认证通过后,为了避免用户每次操作都进行认证(除银行转账等),可以将用户信息保存在会话中,会话就是系统为了保存当前用户的登录状态所提供的机制,常见的有基于Session和token的方式,具体见下文。什么是授权(Authorization)?简单来讲就是谁(who)对什么(what)进行了什么操作(how)。认证是保证用户的合法性,授权则是为了更细粒度的对隐私数据的划分。*授权是在认证通过后,控制不同的用户访问不同的资源。用户授予第三方应用访问该用户某些资源的权限。比如,你在安装手机应用的时候,APP 会询问是否允许授予权限(访问相册、地理位置等权限);你在访问微信小程序时,当登录时,小程序会询问是否允许授予权限(获取昵称、头像、地区、性别等个人信息)实现授权的方式有:业界通常基于R(role/resource)BAC实现授权:(1)基于角色的访问控制(2)基于资源(权限)的访问控制,系统设计时定义好某项操作的权限标识,系统扩展性好。什么是凭证(Credentials)实现认证和授权的前提是需要一种媒介(证书) 来标记访问者的身份。例如:在战国时期,商鞅变法,发明了照身帖。照身帖由官府发放,是一块打磨光滑细密的竹板,上面刻有持有人的头像和籍贯信息。国人必须持有,如若没有就被认为是黑户,或者间谍之类的。在现实生活中,每个人都会有一张专属的居民身份证,是用于证明持有人身份的一种法定证件。通过身份证,我们可以办理手机卡/银行卡/个人贷款/交通出行等等,这就是认证的凭证。在互联网应用中,一般网站会有两种模式,游客模式和登录模式。游客模式下,可以正常浏览网站上面的文章,一旦想要点赞/收藏/分享文章,就需要登录或者注册账号。当用户登录成功后,服务器会给该用户使用的浏览器颁发一个令牌(token),这个令牌用来表明你的身份,每次浏览器发送请求时会带上这个令牌,就可以使用游客模式下无法使用的功能。Cookie与Session什么是Cookie?HTTP 是无状态的协议(对于事务处理没有记忆能力,每次客户端和服务端会话完成时,服务端不会保存任何会话信息):每个请求都是完全独立的,服务端无法确认当前访问者的身份信息,无法分辨上一次的请求发送者和这一次的发送者是不是同一个人。所以服务器与浏览器为了进行会话跟踪(知道是谁在访问我),就必须主动的去维护一个状态,这个状态用于告知服务端前后两个请求是否来自同一浏览器。而这个状态需要通过 cookie 或者 session 去实现。cookie 存储在客户端: cookie 是服务器发送到用户浏览器并保存在本地的一小块数据,它会在浏览器下次向同一服务器再发起请求时被携带并发送到服务器上。cookie 是不可跨域的: 每个 cookie 都会绑定单一的域名,无法在别的域名下获取使用,一级域名和二级域名之间是允许共享使用的(靠的是 domain)cookie 重要的属性什么是 Sessionsession 是另一种记录服务器和客户端会话状态的机制,即告诉服务端前后两个请求是否来自同一个客户端(浏览器),知道谁在访问我。因为http本身是无状态协议,这样,无法确定你的本次请求和上次请求是不是你发送的。如果要进行类似论坛登陆相关的操作,就实现不了了。session 是基于 cookie 实现的,session 存储在服务器端,sessionId 会被存储到客户端的cookie 中。ps:还有一种是浏览器禁用了cookie或不支持cookie,这种可以通过URL重写的方式发到服务器;session 认证流程(如上图):用户第一次请求服务器的时候,服务器根据用户提交的相关信息,创建对应的 Session请求返回时将此 Session 的唯一标识信息 SessionID 返回给浏览器浏览器接收到服务器返回的 SessionID 信息后,会将此信息存入到 Cookie 中,同时 Cookie 记录此 SessionID 属于哪个域名当用户第二次访问服务器的时候,请求会自动判断此域名下是否存在 Cookie 信息,如果存在自动将 Cookie 信息也发送给服务端,服务端会从 Cookie 中获取 SessionID,再根据 SessionID 查找对应的 Session 信息,如果没有找到说明用户没有登录或者登录失效,如果找到 Session 证明用户已经登录可执行后面操作。根据以上流程可知,SessionID 是连接 Cookie 和 Session 的一道桥梁,大部分系统也是根据此原理来验证用户登录状态。基于Session的认证机制由Servlet规范定制,Servlet容器已经实现,用户通过HttpSession的操作方法可以实现:Cookie 和 Session 的区别安全性:Session 是存储在服务器端的,Cookie 是存储在客户端的。所以 Session 相比 Cookie 安全,存取值的类型不同:Cookie 只支持存字符串数据,想要设置其他类型的数据,需要将其转换成字符串,Session 可以存任意数据类型。有效期不同: Cookie 可设置为长时间保持,比如我们经常使用的默认登录功能,Session 一般失效时间较短,客户端关闭(默认情况下)或者 Session 超时都会失效。存储大小不同: 单个 Cookie 保存的数据不能超过 4K,Session 可存储数据远高于 Cookie,但是当访问量过多,会占用过多的服务器资源。拓展:Session痛点看起来通过 cookie + session 的方式是解决了问题, 但是我们忽略了一个问题,上述情况能正常工作是因为我们假设 server 是单机工作的,但实际在生产上,为了保障高可用,一般服务器至少需要两台机器,通过负载均衡的方式来决定到底请求该打到哪台机器上。假设登录请求打到了 A 机器,A 机器生成了 session 并在 cookie 里添加 sessionId 返回给了浏览器,那么问题来了:下次添加购物车时如果请求打到了 B 或者 C,由于 session 是在 A 机器生成的,此时的 B,C 是找不到 session 的,那么就会发生无法添加购物车的错误,就得重新登录了,此时请问该怎么办。主要有以下三种方式:(1 )session 复制A 生成 session 后复制到 B, C,这样每台机器都有一份 session,无论添加购物车的请求打到哪台机器,由于 session 都能找到,故不会有问题这种方式虽然可行,但缺点也很明显:同一样的一份 session 保存了多份,数据冗余如果节点少还好,但如果节点多的话,特别是像阿里,微信这种由于 DAU 上亿,可能需要部署成千上万台机器,这样节点增多复制造成的性能消耗也会很大。(2)session 粘连这种方式是让每个客户端请求只打到固定的一台机器上,比如浏览器登录请求打到 A 机器后,后续所有的添加购物车请求也都打到 A 机器上,Nginx 的 sticky 模块可以支持这种方式,支持按 ip 或 cookie 粘连等等,如按 ip 粘连方式如下这样的话每个 client 请求到达 Nginx 后,只要它的 ip 不变,根据 ip hash 算出来的值会打到固定的机器上,也就不存在 session 找不到的问题了,当然不难看出这种方式缺点也是很明显,对应的机器挂了怎么办?(3)session 共享这种方式也是目前各大公司普遍采用的方案,将 session 保存在 redis,memcached 等中间件中,请求到来时,各个机器去这些中间件取一下 session 即可。缺点其实也不难发现,就是每个请求都要去 redis 取一下 session,多了一次内部连接,消耗了一点性能,另外为了保证 redis 的高可用,必须做集群,当然了对于大公司来说, redis 集群基本都会部署,所以这方案可以说是大公司的首选了。Token(令牌)与 JWT(跨域认证)Token概述(no session!)通过上文分析我们知道通过在服务端共享 session 的方式可以完成用户的身份定位,但是不难发现也有一个小小的瑕疵:搞个校验机制我还得搭个 redis 集群?大厂确实 redis 用得比较普遍,但对于小厂来说可能它的业务量还未达到用 redis 的程度,所以有没有其他不用 server 存储 session 的用户身份校验机制呢,使用token!简单来说:首先请求方输入自己的用户名,密码,然后 server 据此生成 token,客户端拿到 token 后会保存到本地(token存储在浏览器端),之后向 server 请求时在请求头带上此 token 即可(server有校验机制,检验token合法性,同时server通过token中携带的uid确定是谁在访问它)。可以看到 token 主要由三部分组成:header:指定了签名算法payload:可以指定用户 id,过期时间等非敏感数据Signature: 签名,server 根据 header 知道它该用哪种签名算法,再用密钥根据此签名算法对 head + payload 生成签名,这样一个 token 就生成了。当 server 收到浏览器传过来的 token 时,它会首先取出 token 中的 header + payload,根据密钥生成签名,然后再与 token 中的签名比对,如果成功则说明签名是合法的,即 token 是合法的。而且你会发现 payload 中存有我们的 userId,所以拿到 token 后直接在 payload 中就可获取 userid,避免了像 session 那样要从 redis 去取的开销。你会发现这种方式确实很妙,只要 server 保证密钥不泄露,那么生成的 token 就是安全的,因为如果伪造 token 的话在签名验证环节是无法通过的,就此即可判定 token 非法。可以看到通过这种方式有效地避免了 token 必须保存在 server 的弊端,实现了分布式存储,不过需要注意的是,token 一旦由 server 生成,它就是有效的,直到过期,无法让 token 失效,除非在 server 为 token 设立一个黑名单,在校验 token 前先过一遍此黑名单,如果在黑名单里则此 token 失效,但一旦这样做的话,那就意味着黑名单就必须保存在 server,这又回到了 session 的模式,那直接用 session 不香吗。所以一般的做法是当客户端登出要让 token 失效时,直接在本地移除 token 即可,下次登录重新生成 token 就好。另外需要注意的是 token 一般是放在 header 的 Authorization 自定义头里,不是放在 Cookie 里的,这主要是为了解决跨域不能共享 Cookie 的问题总结:token解决什么问题(为什么要用token)?完全由应用管理,可以避开同源策略支持跨域访问,cookie不支持, Cookie 跨站是不能共享的,这样的话如果你要实现多应用(多系统)的单点登录(SSO),使用 Cookie 来做需要的话就很困难了。但如果用 token 来实现 SSO 会非常简单,只要在 header 中的 authorize 字段(或其他自定义)加上 token 即可完成所有跨域站点的认证。token是无状态的,可以在多个服务器间共享token可以避免CSRF攻击(跨站请求攻击)易于扩展,在移动端原生请求是没有 cookie 之说的,而 sessionid 依赖于 cookie,sessionid 就不能用 cookie 来传了,如果用 token 的话,由于它是随着 header 的 authoriize 传过来的,也就不存在此问题,换句话说token 天生支持移动平台,可扩展性好拓展1:那啥是CSRF呢?攻击者通过一些技术手段欺骗用户的浏览器去访问一个自己曾经认证过的网站并运行一些操作(如发邮件,发消息,甚至财产操作如转账和购买商品)。由于浏览器曾经认证过(cookie 里带来 sessionId 等身份认证的信息),所以被访问的网站会认为是真正的用户操作而去运行。那么如果正常的用户误点了上面这张图片,由于相同域名的请求会自动带上 cookie,而 cookie 里带有正常登录用户的 sessionid,类似上面这样的转账操作在 server 就会成功,会造成极大的安全风险CSRF 攻击的根本原因在于对于同样域名的每个请求来说,它的 cookie 都会被自动带上,这个是浏览器的机制决定的!至于完成一次CSRF攻击必要的两个步骤:1、首先登了一个正常的网站A,并且在本地生成了cookie2、在cookie有效时间内,访问了危险网站B(就获取了身份信息)Q:那我不访问危险网站就完了呗?A:危险网站也许只是个存在漏洞的可信任网站!Q:那我访问完正常网站,关了浏览器就好了呀?A:即使关闭浏览器,cookie也不保证一定立即失效,而且关闭浏览器并不能结束会话,session的生命周期跟这些都没关系。拓展2:同源策略?就是不同源的客户端脚本在没有明确授权情况下,不准读写对方的资源!同源就是:协议、域名与端口号都相同。同源策略是由 Netscape 提出的著名安全策略,是浏览器最核心、基本的安全功能,它限制了一个源中加载脚本与来自其他源中资源的交互方式。拓展3:什么是跨域,如何解决?当浏览器执行脚本时会检查是否同源,只有同源的脚本才会执行,如果不同源即为跨域。产生原因:它是由浏览器的同源策略造成的,是浏览器对JavaScript实施的安全限制。解决方案:nginx(静态服务器)反向代理解决跨域(前端常用),a明确访问c代理服务器,但是不知道c的内容从哪里来,c反向从别的地方拿来数据。(忽略的是目标地址),浏览器可以访问a,而服务器之间不存在跨域问题,浏览器先访问a的服务器c,让c服务器作为代理去访问b服务器,拿到之后再返回数据给a。jsonp:通常为了减轻web服务器的负载,我们把js、css、图片等静态资源分离到另一台独立域名的服务器上,在html页面中再通过相应的标签从不同域名下加载静态资源,而被浏览器允许。添加响应头拓展4:易于扩展?比如有多台服务器,使用负载均衡,第一次登录转发到了A,A中seesion缓存了用户的登录信息,第二次登录转发到了B,这时候就丢失了登录状态,当然这样也是有解决方案可以共享session,但token只需要所有的服务器使用相同的解密手段即可。拓展5:无状态?服务端不保存客户端请求者的任何信息,客户端每次请求必须自备描述信息,通过这些信息来识别客户端身份。服务端只需要确认该token是否是自己亲自签发即可,签发和验证都在服务端进行。拓展6:什么是单点登录?所谓单点登录,是指在多个应用系统中,用户只需要登录一次就可以访问所有相互信任的应用系统。Acesss Token访问资源接口(API)时所需要的资源凭证简单 token 的组成: uid(用户唯一的身份标识)、time(当前时间的时间戳)、sign(签名,token 的前几位以哈希算法压缩成的一定长度的十六进制字符串)特点:服务端无状态化、可扩展性好支持移动端设备安全性高支持跨程序调用token 的身份验证流程:客户端使用用户名跟密码请求登录服务端收到请求,去验证用户名与密码验证成功后,服务端会签发一个 token 并把这个 token 发送给客户端客户端收到 token 以后,会把它存储起来,比如放在 cookie 里或者 localStorage 里客户端每次向服务端请求资源的时候需要带着服务端签发的 token服务端收到请求,然后去验证客户端请求里面带着的 token ,如果验证成功,就向客户端返回请求的数据注意点:每一次请求都需要携带 token,需要把 token 放到 HTTP 的 Header 里基于 token 的用户认证是一种服务端无状态的认证方式,服务端不用存放 token 数据。用解析 token 的计算时间换取 session 的存储空间,从而减轻服务器的压力,减少频繁的查询数据库token 完全由应用管理,所以它可以避开同源策略Refresh Token另外一种 token——refresh tokenrefresh token 是专用于刷新 access token 的 token。如果没有 refresh token,也可以刷新 access token,但每次刷新都要用户输入登录用户名与密码,会很麻烦。有了 refresh token,可以减少这个麻烦,客户端直接用 refresh token 去更新 access token,无需用户进行额外的操作。两者区别:Access Token 的有效期比较短,当 Acesss Token 由于过期而失效时,使用 Refresh Token 就可以获取到新的 Token,如果 Refresh Token 也失效了,用户就只能重新登录了。Refresh Token 及过期时间是存储在服务器的数据库中,只有在申请新的 Acesss Token 时才会验证,不会对业务接口响应时间造成影响,也不需要向 Session 一样一直保持在内存中以应对大量的请求。Token 的缺点那有人就问了,既然 token 这么好,那为什么各个大公司几乎都采用共享 session 的方式呢,可能很多人是第一次听到 token,token 不香吗? token 有以下两点劣势:token 太长了:token 是 header, payload 编码后的样式,所以一般要比 sessionId 长很多,很有可能超出 cookie 的大小限制(cookie 一般有大小限制的,如 4kb),如果你在 token 中存储的信息越长,那么 token 本身也会越长,这样的话由于你每次请求都会带上 token,对请求来是个不小的负担不太安全:网上很多文章说 token 更安全,其实不然,细心的你可能发现了,我们说 token 是存在浏览器的,再细问,存在浏览器的哪里?既然它太长放在 cookie 里可能导致 cookie 超限,那就只好放在 local storage 里,这样会造成安全隐患,因为 local storage 这类的本地存储是可以被 JS 直接读取的,另外由上文也提到,token 一旦生成无法让其失效,必须等到其过期才行,这样的话如果服务端检测到了一个安全威胁,也无法使相关的 token 失效。所以 token 更适合一次性的命令认证,设置一个比较短的有效期!!!拓展:不管是 cookie 还是 token,从存储角度来看其实都不安全(实际上防护 CSRF 攻击的正确方式是用 CSRF token),都有暴露的风险,我们所说的安全更多的是强调传输中的安全,可以用 HTTPS 协议来传输, 这样的话请求头都能被加密,也就保证了传输中的安全。其实我们把 cookie 和 token 比较本身就不合理,一个是存储方式,一个是验证方式,正确的比较应该是 session vs token。Token 和 Session 的区别token和session其实都是为了身份验证,session一般翻译为会话,而token更多的时候是翻译为令牌;session和token都是有过期时间一说,都需要去管理过期时间;Session 是一种记录服务器和客户端会话状态的机制,使服务端有状态化,可以记录会话信息(可能保存在缓存、文件或数据库)。而 Token 是令牌,访问资源接口(API)时所需要的资源凭证。Token 使服务端无状态化,不会存储会话信息。其实token与session的问题是一种时间与空间的博弈问题,session是空间换时间,而token是时间换空间。两者的选择要看具体情况而定。虽然确实都是“客户端记录,每次访问携带”,但 token 很容易设计为自包含的,也就是说,后端不需要记录什么东西,每次一个无状态请求,每次解密验证,每次当场得出合法 /非法的结论。这一切判断依据,除了固化在 CS 两端的一些逻辑之外,整个信息是自包含的。这才是真正的无状态。 而 sessionid ,一般都是一段随机字符串,需要到后端去检索 id 的有效性。万一服务器重启导致内存里的 session 没了呢?万一 redis 服务器挂了呢?所谓 Session 认证只是简单的把 User 信息存储到 Session 里,因为 SessionID 的不可预测性,暂且认为是安全的。而 Token ,如果指的是 OAuth Token 或类似的机制的话,提供的是 认证 和 授权 ,认证是针对用户,授权是针对 App 。其目的是让某 App 有权利访问某用户的信息。这里的 Token 是唯一的。不可以转移到其它 App上,也不可以转到其它用户上。Session 只提供一种简单的认证,即只要有此 SessionID ,即认为有此 User 的全部权利。是需要严格保密的,这个数据应该只保存在站方,不应该共享给其它网站或者第三方 App。所以简单来说:如果你的用户数据可能需要和第三方共享,或者允许第三方调用 API 接口,用 Token 。如果永远只是自己的网站,自己的 App,用什么就无所谓了。JWT概述JSON Web Token(简称 JWT)是目前最流行的跨域认证解决方案。是一种认证授权机制。JWT 是为了在网络应用环境间传递声明而执行的一种基于 JSON 的开放标准(RFC 7519)。JWT 的声明一般被用来在身份提供者和服务提供者间传递被认证的用户身份信息,以便于从资源服务器获取资源。比如用在用户登录上。可以使用 HMAC 算法或者是 RSA 的公/私秘钥对 JWT 进行签名。因为数字签名的存在,这些传递的信息是可信的。阮一峰老师的 JSON Web Token 入门教程 讲的非常通俗易懂,这里就不再班门弄斧了生成 JWThttps://jwt.io/https://www.jsonwebtoken.io/JWT 的原理JWT 认证流程:用户输入用户名/密码登录,服务端认证成功后,会返回给客户端一个 JWT客户端将 token 保存到本地(通常使用 localstorage,也可以使用 cookie)当用户希望访问一个受保护的路由或者资源的时候,需要请求头的 Authorization 字段中使用Bearer 模式添加 JWT,其内容看起来是下面这样Authorization: Bearer <token>服务端的保护路由将会检查请求头 Authorization 中的 JWT 信息,如果合法,则允许用户的行为因为 JWT 是自包含的(内部包含了一些会话信息),因此减少了需要查询数据库的需要因为 JWT 并不使用 Cookie 的,所以你可以使用任何域名提供你的 API 服务而不需要担心跨域资源共享问题(CORS)因为用户的状态不再存储在服务端的内存中,所以这是一种无状态的认证机制JWT 的使用方式方式一当用户希望访问一个受保护的路由或者资源的时候,可以把它放在 Cookie 里面自动发送,但是这样不能跨域,所以更好的做法是放在 HTTP 请求头信息的 Authorization 字段里,使用 Bearer 模式添加 JWT。GET /calendar/v1/events用户的状态不会存储在服务端的内存中,这是一种 无状态的认证机制服务端的保护路由将会检查请求头 Authorization 中的 JWT 信息,如果合法,则允许用户的行为。由于 JWT 是自包含的,因此减少了需要查询数据库的需要JWT 的这些特性使得我们可以完全依赖其无状态的特性提供数据 API 服务,甚至是创建一个下载流服务。因为 JWT 并不使用 Cookie ,所以你可以使用任何域名提供你的 API 服务而不需要担心跨域资源共享问题(CORS)方式二跨域的时候,可以把 JWT 放在 POST 请求的数据体里。方式三通过 URL 传输http://www.example.com/user?token=xxx项目中使用 JWT项目地址:https://github.com/yjdjiayou/jwt-demoToken 和 JWT 的区别相同:都是访问资源的令牌都可以记录用户的信息都是使服务端无状态化都是只有验证成功后,客户端才能访问服务端上受保护的资源区别:Token:服务端验证客户端发送过来的 Token 时,还需要查询数据库获取用户信息,然后验证 Token 是否有效。JWT:将 Token 和 Payload 加密后存储于客户端,服务端只需要使用密钥解密进行校验(校验也是 JWT 自己实现的)即可,不需要查询或者减少查询数据库,因为 JWT 自包含了用户信息和加密的数据。常见的前后端鉴权方式Session-CookieToken 验证(包括 JWT,SSO)OAuth2.0(开放授权)常见的加密算法不可逆加密:【Hash加密算法/散列算法/摘要算法】一旦加密就不能反向解密得到密码原文,一般用来加密用户密码,app的服务器端数据库里一般存储的也都是加密后的用户密码。在数据传输的过程中,首先把密码类数据经过MD5加密算法加密,然后再在外面使用可逆的加密方式加密一次,这样在数据传输的过程中,即便数据被截获了,但是想要完全破解,还是很难的。Hash算法特别的地方在于它是一种单向算法,用户可以通过Hash算法对目标信息生成一段特定长度的唯一的Hash值,却不能通过这个Hash值重新获得目标信息。因此Hash算法常用在不可还原的密码存储、信息完整性校验等。用途:一般用于效验下载文件正确性,一般在网站上下载文件都能见到;存储用户敏感信息,如密码、 卡号等不可解密的信息。常见的不可逆加密算法有:MD5、SHA、HMACMD5:Message Digest Algorithm MD5(中文名为消息摘要算法第五版)为计算机安全领域广泛使用的一种散列函数,用以提供消息的完整性保护。该算法的文件号为RFC 1321(R.Rivest,MIT Laboratory for Computer Science and RSA Data Security Inc. April 1992)。 MD5即Message-Digest Algorithm 5(信息-摘要算法5),用于确保信息传输完整一致。是计算机广泛使用的杂凑算法之一(又译摘要算法、哈希算法),主流编程语言普遍已有MD5实现。将数据(如汉字)运算为另一固定长度值,是杂凑算法的基础原理,MD5的前身有MD2、MD3和MD4。MD5算法具有以下特点: 1、压缩性:任意长度的数据,算出的MD5值长度都是固定的。 2、容易计算:从原数据计算出MD5值很容易。 3、抗修改性:对原数据进行任何改动,哪怕只修改1个字节,所得到的MD5值都有很大区别。 4、强抗碰撞:已知原数据和其MD5值,想找到一个具有相同MD5值的数据(即伪造数据)是非常困难的。 MD5的作用是让大容量信息在用数字签名软件签署私人密钥前被"压缩"成一种保密的格式(就是把一个任意长度的字节串变换成一定长的十六进制数字串)。除了MD5以外,其中比较有名的还有sha-1、RIPEMD以及Haval等。SHA1 :安全哈希算法(Secure Hash Algorithm)主要适用于数字签名标准 (Digital Signature Standard DSS)里面定义的数字签名算法(Digital Signature Algorithm DSA)。对于长度小于2^64位的消息,SHA1会产生一个160位的消息摘要。当接收到消息的时候,这个消息摘要可以用来验证数据的完整性。在传输的过程中,数据很可能会发生变化,那么这时候就会产生不同的消息摘要。 SHA1有如下特点: 1.不可以从消息摘要中复原信息; 2.两个不同的消息不会产生同样的消息摘要,(但会有1x10 ^ 48分之一的机率出现相同的消息摘要,一般使用时忽略)。可逆加密:可逆加密有对称加密和非对称加密。对称加密:【文件加密和解密使用相同的密钥,即加密密钥也可以用作解密密钥】在对称加密算法中,数据发信方将明文和加密密钥一起经过特殊的加密算法处理后,使其变成复杂的加密密文发送出去,收信方收到密文后,若想解读出原文,则需要使用加密时用的密钥以及相同加密算法的逆算法对密文进行解密,才能使其回复成可读明文。在对称加密算法中,使用的密钥只有一个,收发双方都使用这个密钥,这就需要解密方事先知道加密密钥。对称加密算法的优点是算法公开、计算量小、加密速度快、加密效率高。用途:一般用于保存用户手机号、身份证等敏感但能解密的信息。常见的对称加密算法有AES(高级加密标准)、DES(数据加密算法)、3DES、Blowfish、IDEA、RC4、RC5、RC6非对称加密:【两个密钥:公开密钥(publickey)和私有密钥,公有密钥加密,私有密钥解密】非对称加密算法是一种密钥的保密方法。非对称加密算法需要两个密钥:公开密钥(publickey)和私有密钥(privatekey)。公开密钥与私有密钥是一对,如果用公开密钥对数据进行加密,只有用对应的私有密钥才能解密;如果用私有密钥对数据进行加密,那么只有用对应的公开密钥才能解密。因为加密和解密使用的是两个不同的密钥,所以这种算法叫作非对称加密算法。非对称加密算法实现机密信息交换的基本过程是:甲方生成一对密钥并将其中的一把作为公用密钥向其它方公开;得到该公用密钥的乙方使用该密钥对机密信息进行加密后再发送给甲方;甲方再用自己保存的另一把专用密钥对加密后的信息进行解密。服务器存私钥,客户端拿公钥,客户端加解密算法可以做成so库。非对称加密与对称加密相比,其安全性更好;非对称加密的缺点是加密和解密花费时间长、速度慢,只适合对少量数据进行加密。用途:一般用于签名和认证。常见的非对称加密算法有:RSA(公钥加密算法)、DSA(数字签名用)、ECC(移动设备用)、Diffie-Hellman、El Gamal常见问题使用 cookie 时需要考虑的问题因为存储在客户端,容易被客户端篡改,使用前需要验证合法性,所以,不要存储敏感数据,比如用户密码,账户余额;使用 httpOnly 在一定程度上提高安全性尽量减少 cookie 的体积,能存储的数据量不能超过 4kb;一个浏览器针对一个网站最多存 20 个Cookie,浏览器一般只允许存放 300 个Cookie设置正确的 domain 和 path,减少数据传输cookie 无法跨域(每个 cookie 都会绑定单一的域名,无法在别的域名下获取使用)移动端对 cookie 的支持不是很好,而 session 需要基于 cookie 实现,所以移动端常用的是 token使用 session 时需要考虑的问题将 session 存储在服务器里面,当用户同时在线量比较多时,这些 session 会占据较多的内存,需要在服务端定期的去清理过期的 session当网站采用集群部署的时候,会遇到多台 web 服务器之间如何做 session 共享的问题。因为 session 是由单个服务器创建的,但是处理用户请求的服务器不一定是那个创建 session 的服务器,那么该服务器就无法拿到之前已经放入到 session 中的登录凭证之类的信息了。解决方案:写客户端cookie的方式、服务器之间session数据同步、用mysql数据库共享session数据。当多个应用要共享 session 时,除了以上问题,还会遇到跨域问题,因为不同的应用可能部署的主机不一样,需要在各个应用做好 cookie 跨域的处理。sessionId 是存储在 cookie 中的,假如浏览器禁止 cookie 或不支持 cookie 怎么办? 一般会把 sessionId 跟在 url 参数后面即重写 url,所以 session 不一定非得需要靠 cookie 实现移动端对 cookie 的支持不是很好,而 session 需要基于 cookie 实现,所以移动端常用的是 token使用 token 时需要考虑的问题如果你认为用数据库来存储 token 会导致查询时间太长,可以选择放在内存当中。比如 redis 很适合你对 token 查询的需求。token 完全由应用管理,所以它可以避开同源策略token 可以避免 CSRF 攻击(因为不需要 cookie 了)移动端对 cookie 的支持不是很好,而 session 需要基于 cookie 实现,所以移动端常用的是 token使用 JWT 时需要考虑的问题因为 JWT 并不依赖 Cookie 的,所以你可以使用任何域名提供你的 API 服务而不需要担心跨域资源共享问题(CORS)JWT 默认是不加密,但也是可以加密的。生成原始 Token 以后,可以用密钥再加密一次。JWT 不加密的情况下,不能将秘密数据写入 JWT。JWT 不仅可以用于认证,也可以用于交换信息。有效使用 JWT,可以降低服务器查询数据库的次数。JWT 最大的优势是服务器不再需要存储 Session,使得服务器认证鉴权业务可以方便扩展。但这也是 JWT 最大的缺点:由于服务器不需要存储 Session 状态,因此使用过程中无法废弃某个 Token 或者更改 Token 的权限。也就是说一旦 JWT 签发了,到期之前就会始终有效,除非服务器部署额外的逻辑。JWT 本身包含了认证信息,一旦泄露,任何人都可以获得该令牌的所有权限。为了减少盗用,JWT的有效期应该设置得比较短。对于一些比较重要的权限,使用时应该再次对用户进行认证。JWT 适合一次性的命令认证,颁发一个有效期极短的 JWT,即使暴露了危险也很小,由于每次操作都会生成新的 JWT,因此也没必要保存 JWT,真正实现无状态。为了减少盗用,JWT 不应该使用 HTTP 协议明码传输,要使用 HTTPS 协议传输。使用加密算法时需要考虑的问题绝不要以明文存储密码永远使用 哈希算法 来处理密码,绝不要使用 Base64 或其他编码方式来存储密码,这和以明文存储密码是一样的,使用哈希,而不要使用编码。编码以及加密,都是双向的过程,而密码是保密的,应该只被它的所有者知道, 这个过程必须是单向的。哈希正是用于做这个的,从来没有解哈希这种说法, 但是编码就存在解码,加密就存在解密。绝不要使用弱哈希或已被破解的哈希算法,像 MD5 或 SHA1 ,只使用强密码哈希算法。绝不要以明文形式显示或发送密码,即使是对密码的所有者也应该这样。如果你需要 “忘记密码” 的功能,可以随机生成一个新的 一次性的(这点很重要)密码,然后把这个密码发送给用户。分布式架构下 session 共享方案1. session 复制任何一个服务器上的 session 发生改变(增删改),该节点会把这个 session 的所有内容序列化,然后广播给所有其它节点,不管其他服务器需不需要 session ,以此来保证 session 同步优点: 可容错,各个服务器间 session 能够实时响应。缺点: 会对网络负荷造成一定压力,如果 session 量大的话可能会造成网络堵塞,拖慢服务器性能。2. 粘性 session /IP 绑定策略采用 Ngnix 中的 ip_hash 机制,将某个 ip的所有请求都定向到同一台服务器上,即将用户与服务器绑定。 用户第一次请求时,负载均衡器将用户的请求转发到了 A 服务器上,如果负载均衡器设置了粘性 session 的话,那么用户以后的每次请求都会转发到 A 服务器上,相当于把用户和 A 服务器粘到了一块,这就是粘性 session 机制。优点: 简单,不需要对 session 做任何处理。缺点: 缺乏容错性,如果当前访问的服务器发生故障,用户被转移到第二个服务器上时,他的 session 信息都将失效。适用场景: 发生故障对客户产生的影响较小;服务器发生故障是低概率事件 。实现方式: 以 Nginx 为例,在 upstream 模块配置 ip_hash 属性即可实现粘性 session。3. session 共享(常用)使用分布式缓存方案比如 Memcached 、Redis 来缓存 session,但是要求 Memcached 或 Redis 必须是集群把 session 放到 Redis 中存储,虽然架构上变得复杂,并且需要多访问一次 Redis ,但是这种方案带来的好处也是很大的:实现了 session 共享;可以水平扩展(增加 Redis 服务器);服务器重启 session 不丢失(不过也要注意 session 在 Redis 中的刷新/失效机制);不仅可以跨服务器 session 共享,甚至可以跨平台(例如网页端和 APP 端)4. session 持久化将 session 存储到数据库中,保证 session 的持久化优点: 服务器出现问题,session 不会丢失缺点: 如果网站的访问量很大,把 session 存储到数据库中,会对数据库造成很大压力,还需要增加额外的开销维护数据库。只要关闭浏览器 ,session 真的就消失了?不对。对 session 来说,除非程序通知服务器删除一个 session,否则服务器会一直保留,程序一般都是在用户做 log off 的时候发个指令去删除 session。然而浏览器从来不会主动在关闭之前通知服务器它将要关闭,因此服务器根本不会有机会知道浏览器已经关闭,之所以会有这种错觉,是大部分 session 机制都使用会话 cookie 来保存 session id,而关闭浏览器后这个 session id 就消失了,再次连接服务器时也就无法找到原来的 session。如果服务器设置的 cookie 被保存在硬盘上,或者使用某种手段改写浏览器发出的 HTTP 请求头,把原来的 session id 发送给服务器,则再次打开浏览器仍然能够打开原来的 session。恰恰是由于关闭浏览器不会导致 session 被删除,迫使服务器为 session 设置了一个失效时间,当距离客户端上一次使用 session 的时间超过这个失效时间时,服务器就认为客户端已经停止了活动,才会把 session 删除以节省存储空间。
写在前本部分题目,非自顶向下:就是从任意节点到任意节点的路径,不需要自顶向下注意:这类题通常用深度优先搜索(DFS)和广度优先搜索(BFS)解决,BFS较DFS繁琐,这里为了简洁只展现DFS代码1.二叉树最大路径和(124-难)题目描述:路径 被定义为一条从树中任意节点出发,沿父节点-子节点连接,达到任意节点的序列。同一个节点在一条路径序列中 至多出现一次 。该路径 至少包含一个 节点,且不一定经过根节点。路径和 是路径中各节点值的总和。给你一个二叉树的根节点 root ,返回其 最大路径和 。示例:输入:root = [-10,9,20,null,null,15,7] 输出:42 解释:最优路径是 15 -> 20 -> 7 ,路径和为 15 + 20 + 7 = 42思路:递归实现,递归函数的关键写好递归处理的逻辑,怎么处理当前子树,需要返回什么,设置递归出口。关键是正确定义递归函数。递归函数:计算当前节点为父亲节点提供的贡献(即当前节点到根节点的最大路径)。递归的计算左右子节点的最大贡献值,逻辑:贡献值大于0,才会选取对应的节点最大贡献值更新最大路径和(当前节点值和左右子节点最大贡献值)特别注意:dfs函数中定义的是一个节点能贡献的最大值(计算该节点到根节点的最大路径),而我们统计结果时更新的二叉树中任意两点的最大路径(可能是多个节点到这个根节点的组合)!代码实现:private int ans = 0; public int longestUnivaluePath(TreeNode root) { dfs(root); return ans; } private int dfs(TreeNode root) { if (root == null) { return 0; } int l = dfs(root.left); int r = dfs(root.right); int left = root.left != null && root.val == root.left.val ? l : 0; int right = root.right != null && root.val == root.right.val ? r : 0; ans = Math.max(ans, left + right); return Math.max(left, right) + 1; }2.最长同值路径(687-中)题目描述:给定一个二叉树,找到最长的路径,这个路径中的每个节点具有相同值。 这条路径可以经过也可以不经过根节点。注意:两个节点之间的路径长度由它们之间的边数表示。示例:5 / \ 4 5 / \ \ 1 1 5 返回 2思路:递归实现,思想与上题基本相似。递归函数:当前节点到根节点的最长同值路径递归左右子树的最长同值路径;逻辑:出现同值,更新路径左右路径,否则为0更新最长同值路径代码实现:public int pathSum(TreeNode root, int sum) { if (root == null) { return 0; } return dfs(root, sum) + pathSum(root.left, sum) + pathSum(root.right, sum); } private int dfs(TreeNode root, int sum) { if (root == null) { return 0; } int ans = sum == root.val ? 1 : 0; sum -= root.val; return dfs(root.left, sum) + dfs(root.right, sum) + ans; }3.二叉树的直径(543-易)题目描述:给定一棵二叉树,你需要计算它的直径长度。一棵二叉树的直径长度是任意两个结点路径长度中的最大值。这条路径可能穿过也可能不穿过根结点。注意:两个节点之间的路径长度由它们之间的边数表示。示例:给定二叉树 1 / \ 2 3 / \ 4 5 返回 3, 它的长度是路径 [4,2,1,3] 或者 [5,2,1,3]。思路:递归实现,递归实现,思想与上题基本相似,还是区分递归函数和最终结果的区别。代码实现:private int max = 0; public int diameterOfBinaryTree(TreeNode root) { dfs(root); return max; } private int dfs(TreeNode root) { if (root == null) { return 0; } int left = dfs(root.left); int right = dfs(root.right); max = Math.max(max, left + right); return Math.max(left, right) + 1; }注意点left,right代表的含义要根据题目所求设置,比如最长路径、最大路径和等等全局变量res的初值设置是0还是INT_MIN要看题目节点是否存在负值,如果存在就用INT_MIN,否则就是0注意两点之间路径为1,因此一个点是不能构成路径的
写在前本部分题目,讨论自顶向下的情况,就是从某一个节点(不一定是根节点),从上向下寻找路径,到某一个节点(不一定是叶节点)结束,而继续细分的话还可以分成一般路径与给定和的路径。注意:这类题通常用深度优先搜索(DFS)和广度优先搜索(BFS)解决,BFS较DFS繁琐,这里为了简洁只展现DFS代码1.二叉树的所有路径(257-易)题目描述:给定一个二叉树,返回所有从根节点到叶子节点的路径。示例:输入: 1 / \ 2 3 \ 5 输出: ["1->2->5", "1->3"] 解释: 所有根节点到叶子节点的路径为: 1->2->5, 1->3思路:递归实现如果root不为空,我们才进行拼接!判断是否到达叶子节点。如果到达,加入集合返回结果;否则,拼接箭头,递归左右子树。代码实现:public List<String> binaryTreePaths(TreeNode root) { List<String> ans = new ArrayList<>(); if (root == null) { return ans; } dfs(root, "", ans); return ans; } private void dfs(TreeNode root, String path, List<String> ans) { if (root == null) { return; } StringBuilder sb = new StringBuilder(path); sb.append(String.valueOf(root.val)); if (root.left == null && root.right == null) { ans.add(sb.toString()); } else { sb.append("->"); dfs(root.left, sb.toString(), ans); dfs(root.right, sb.toString(), ans); } }2.求和路径(面试题04.12)题目描述:给定一棵二叉树,其中每个节点都含有一个整数数值(该值或正或负)。设计一个算法,打印节点数值总和等于某个给定值的所有路径的数量。注意,路径不一定非得从二叉树的根节点或叶节点开始或结束,但是其方向必须向下(只能从父节点指向子节点方向)。示例:给定如下二叉树,以及目标和 sum = 22, 5 / \ 4 8 / / \ 11 13 4 / \ / \ 7 2 5 1 返回:3 解释:和为 22 的路径有:[5,4,11,2], [5,8,4,5], [4,11,7]思路:递归实现本题关键是路径的起止点不确定,所以我们要在主函数递归左右子节点(累加结果)我们维护一个变量记录结果,即当root.val == sum时我们获得一个结果,累积左右子节点。代码实现:public int pathSum(TreeNode root, int sum) { if (root == null) { return 0; } return dfs(root, sum) + pathSum(root.left, sum) + pathSum(root.right, sum); } private int dfs(TreeNode root, int sum) { if (root == null) { return 0; } int ans = sum == root.val ? 1 : 0; sum -= root.val; return dfs(root.left, sum) + dfs(root.right, sum) + ans; }3.路径总和(112-易)题目描述:给你二叉树的根节点 root 和一个表示目标和的整数 targetSum ,判断该树中是否存在 根节点到叶子节点 的路径,这条路径上所有节点值相加等于目标和 targetSum 。示例:输入:root = [5,4,8,11,null,13,4,7,2,null,null,null,1], targetSum = 22 输出:true思路:递归实现终止条件,如果当前节点为空,返回false到达叶子节点,且sum = root.val递归的左右子节点(注意更新sum值,即减去root.val)代码实现:public boolean hasPathSum(TreeNode root, int sum) { if (root == null) { return false; } sum -= root.val; if (root.left == null && root.right == null && sum == 0) { return true; } return hasPathSum(root.left, sum) || hasPathSum(root.right, sum); }4.路径总和II(113-中)题目描述:本题在上题基础上(从根节点到叶子节点的路径和等于给定值),要求找出这些路径!示例:输入:root = [5,4,8,11,null,13,4,7,2,null,null,5,1], targetSum = 22 输出:[[5,4,11,2],[5,8,4,5]]思路:递归实现,注意本题与257不同的是需要回溯。我们先加入路径,不满足需要从队列中删除,所以需要实现一个双端队列。注意找到一个结果也需要继续进行回溯。代码实现:List<List<Integer>> ans = new LinkedList<>(); Deque<Integer> path = new LinkedList<>(); public List<List<Integer>> pathSum(TreeNode root, int targetSum) { if (root == null) { return ans; } dfs(root, targetSum); return ans; } public void dfs(TreeNode root, int targetSum) { if (root == null) { return; } path.offerLast(root.val); targetSum -= root.val; if (root.left == null && root.right == null && targetSum == 0) { ans.add(new LinkedList<>(path)); } dfs(root.left, targetSum); dfs(root.right, targetSum); path.pollLast(); }拓展(T437):这里路径不限制从根节点到叶子节点,但是方向一定是从上到下的。注意:区分开始节点(主递归函数)和结束节点(判断结束标志)!代码实现:public int pathSum(TreeNode root, int sum) { if (root == null) { return 0; } return pathSumStartWithRoot(root, sum) + pathSum(root.left, sum) + pathSum(root.right, sum); } private int pathSumStartWithRoot(TreeNode root, int sum) { if (root == null) { return 0; } int ans = 0; sum -= root.val; if (sum == 0) { ans++; } ans += pathSumStartWithRoot(root.left, sum) + pathSumStartWithRoot(root.right, sum); return ans; }5.从叶子节点开始的最小字符串(988-中)题目描述:给定一颗根结点为 root 的二叉树,树中的每一个结点都有一个从 0 到 25 的值,分别代表字母 'a' 到 'z':值 0 代表 'a',值 1 代表 'b',依此类推。找出按字典序最小的字符串,该字符串从这棵树的一个叶结点开始,到根结点结束。(小贴士:字符串中任何较短的前缀在字典序上都是较小的:例如,在字典序上 "ab" 比 "aba" 要小。叶结点是指没有子结点的结点。)示例:输入:[0,1,2,3,4,3,4] 输出:"dba"思路:递归实现,需要进行回溯,一些必要的函数:直接使用StringBuilder的reverse()方法进行反转,反转两次是为了继续进行拼接。使用compareTo()比较两个字符串的字典序(本质是比较ascii值)使用deleteCharAt(待删除的索引)进行回溯注意:我们是更新获取最小的字典序,所以我们初始值应该大于'z'的ascii值代码实现:String ans = "~"; public String smallestFromLeaf(TreeNode root) { if (root == null) { return ans; } dfs(root, new StringBuilder()); return ans; } private void dfs(TreeNode root, StringBuilder sb) { if (root == null) { return; } sb.append((char)(root.val + 'a')); if (root.left == null && root.right == null) { sb.reverse(); String tmp = sb.toString(); sb.reverse(); if (tmp.compareTo(ans) < 0) { ans = tmp; } } dfs(root.left, sb); dfs(root.right, sb); sb.deleteCharAt(sb.length() - 1); }6.根节点到叶子节点数字之和(129-中)题目描述:给你一个二叉树的根节点 root ,树中每个节点都存放有一个 0 到 9 之间的数字。每条从根节点到叶节点的路径都代表一个数字:例如,从根节点到叶节点的路径 1 -> 2 -> 3 表示数字 123 。计算从根节点到叶节点生成的 所有数字之和 。示例:输入:root = [1,2,3] 输出:25 解释: 从根到叶子节点路径 1->2 代表数字 12 从根到叶子节点路径 1->3 代表数字 13 因此,数字总和 = 12 + 13 = 25思路:递归实现,递归所有的路径(根节点到叶子节点),进行累加。定义一个变量记录自上向下的路径和。当到达叶子节点,找到一条路径和。递归的寻找左右子节点。代码实现:public int sumNumbers(TreeNode root) { return dfs(root, 0); } private int dfs(TreeNode root, int pathSum) { if (root == null) { return 0; } pathSum = pathSum * 10 + root.val; if (root.left == null && root.right == null) { return pathSum; } return dfs(root.left, pathSum) + dfs(root.right, pathSum); }注意点如果是找路径和等于给定target的路径的,那么可以不用新增一个临时变量cursum来判断当前路径和, 只需要用给定和target减去节点值,最终结束条件判断target==0即可。是否要回溯:二叉树的问题大部分是不需要回溯的,原因如下:二叉树的递归部分:dfs(root->left),dfs(root->right)已经把可能的路径穷尽了,因此到任意叶节点的路径只可能有一条,绝对不可能出现另外的路径也到这个满足条件的叶节点的;但是对路径有限制,则需要使用回溯!如T4和T5;二维数组(例如迷宫问题)的DFS,for循环向四个方向查找每次只能朝向一个方向,并没有穷尽路径, 因此某一个满足条件的点可能是有多条路径到该点的;并且visited数组标记已经走过的路径是会受到另外路径是否访问的影响,这时候必须回溯找到路径后是否要return:取决于题目是否要求找到叶节点满足条件的路径,如果必须到叶节点,那么就要return(也可以不return); 但如果是到任意节点都可以,那么必不能return,因为这条路径下面还可能有更深的路径满足条件,还要在此基础上继续递归是否要双重递归(即调用根节点的dfs函数后,继续调用根左右节点的pathsum函数):看题目要不要求从根节点开始的,还是从任意节点开始本部分题目,非自顶向下:就是从任意节点到任意节点的路径,不需要自顶向下7.二叉树最大路径和(124-难)题目描述:路径 被定义为一条从树中任意节点出发,沿父节点-子节点连接,达到任意节点的序列。同一个节点在一条路径序列中 至多出现一次 。该路径 至少包含一个 节点,且不一定经过根节点。路径和 是路径中各节点值的总和。给你一个二叉树的根节点 root ,返回其 最大路径和 。示例:输入:root = [-10,9,20,null,null,15,7] 输出:42 解释:最优路径是 15 -> 20 -> 7 ,路径和为 15 + 20 + 7 = 42思路:递归实现,递归函数的关键写好递归处理的逻辑,怎么处理当前子树,需要返回什么,设置递归出口。关键是正确定义递归函数。递归函数:计算当前节点为父亲节点提供的贡献(即当前节点到根节点的最大路径)。递归的计算左右子节点的最大贡献值,逻辑:贡献值大于0,才会选取对应的节点最大贡献值更新最大路径和(当前节点值和左右子节点最大贡献值)特别注意:dfs函数中定义的是一个节点能贡献的最大值(计算该节点到根节点的最大路径),而我们统计结果时更新的二叉树中任意两点的最大路径(可能是多个节点到这个根节点的组合)!代码实现:private int ans = 0; public int longestUnivaluePath(TreeNode root) { dfs(root); return ans; } private int dfs(TreeNode root) { if (root == null) { return 0; } int l = dfs(root.left); int r = dfs(root.right); int left = root.left != null && root.val == root.left.val ? l : 0; int right = root.right != null && root.val == root.right.val ? r : 0; ans = Math.max(ans, left + right); return Math.max(left, right) + 1; }8.最长同值路径(687-中)题目描述:给定一个二叉树,找到最长的路径,这个路径中的每个节点具有相同值。 这条路径可以经过也可以不经过根节点。注意:两个节点之间的路径长度由它们之间的边数表示。示例:5 / \ 4 5 / \ \ 1 1 5 返回 2思路:递归实现,思想与上题基本相似。递归函数:当前节点到根节点的最长同值路径递归左右子树的最长同值路径;逻辑:出现同值,更新路径左右路径,否则为0更新最长同值路径代码实现:public int pathSum(TreeNode root, int sum) { if (root == null) { return 0; } return dfs(root, sum) + pathSum(root.left, sum) + pathSum(root.right, sum); } private int dfs(TreeNode root, int sum) { if (root == null) { return 0; } int ans = sum == root.val ? 1 : 0; sum -= root.val; return dfs(root.left, sum) + dfs(root.right, sum) + ans; }9.二叉树的直径(543-易)题目描述:给定一棵二叉树,你需要计算它的直径长度。一棵二叉树的直径长度是任意两个结点路径长度中的最大值。这条路径可能穿过也可能不穿过根结点。注意:两个节点之间的路径长度由它们之间的边数表示。示例:给定二叉树 1 / \ 2 3 / \ 4 5 返回 3, 它的长度是路径 [4,2,1,3] 或者 [5,2,1,3]。思路:递归实现,递归实现,思想与上题基本相似,还是区分递归函数和最终结果的区别。代码实现:private int max = 0; public int diameterOfBinaryTree(TreeNode root) { dfs(root); return max; } private int dfs(TreeNode root) { if (root == null) { return 0; } int left = dfs(root.left); int right = dfs(root.right); max = Math.max(max, left + right); return Math.max(left, right) + 1; }附(特殊):所有距离为k的节点(863-中)题目描述:给定一个二叉树(具有根结点 root), 一个目标结点 target ,和一个整数值 K 。返回到目标结点 target 距离为 K 的所有结点的值的列表。 答案可以以任何顺序返回。示例:输入:root = [3,5,1,6,2,0,8,null,null,7,4], target = 5, K = 2 输出:[7,4,1] 解释: 所求结点为与目标结点(值为 5)距离为 2 的结点, 值分别为 7,4,以及 1思路:本题搜索的大体思路可以按照图的bfs,我们需要给所有节点添加一个指向父节点的引用(这样就知道了距离该节点1距离的所有节点),进而找到距离target节点为k的所有节点。用map集合构建每个节点的父节点,即建立当前节点到父节点的引用(构建图)用hashset记录节点是否被访问过我们可以看成以当前节点为中心的辐射(是一种广度搜索的思想),完成一层之后k--。代码实现:private Map<TreeNode, TreeNode> parents; private Set<TreeNode> isVisited; private List<Integer> ans; public List<Integer> distanceK(TreeNode root, TreeNode target, int k) { parents = new HashMap<>(); getParents(root, null); isVisited = new HashSet<>(); ans = new ArrayList<>(); Deque<TreeNode> queue = new LinkedList<>(); queue.add(target); while (!queue.isEmpty()) { if (k-- == 0) { while (!queue.isEmpty()) { ans.add(queue.poll().val); } break; } int size = queue.size(); for (int i = 0; i < size; ++i) { TreeNode node = queue.poll(); isVisited.add(node); if (node.left != null && !isVisited.contains(node.left)) { queue.add(node.left); } if (node.right != null && !isVisited.contains(node.right)) { queue.add(node.right); } TreeNode p = parents.get(node); if (p != null && !isVisited.contains(p)) { queue.add(p); } } } return ans; } private void getParents(TreeNode root, TreeNode parent) { if (root == null) { return; } parents.put(root, parent); getParents(root.left, root); getParents(root.right, root); }注意点left,right代表的含义要根据题目所求设置,比如最长路径、最大路径和等等全局变量res的初值设置是0还是INT_MIN要看题目节点是否存在负值,如果存在就用INT_MIN,否则就是0注意两点之间路径为1,因此一个点是不能构成路径的
写在前就是一种缓存淘汰策略。计算机的缓存容量有限,如果缓存满了就要删除一些内容,给新内容腾位置。但问题是,删除哪些内容呢?我们肯定希望删掉哪些没什么用的缓存,而把有用的数据继续留在缓存里,方便之后继续使用。那么,什么样的数据,我们判定为「有用的」的数据呢?LRU 缓存淘汰算法就是一种常用策略。LRU 的全称是 Least Recently Used,也就是说我们认为最近使用过的数据应该是是「有用的」,很久都没用过的数据应该是无用的,内存满了就优先删那些很久没用过的数据。算法描述运用你所掌握的数据结构,设计和实现一个 LRU (最近最少使用) 缓存机制 。实现 LRUCache 类:LRUCache(int capacity) 以正整数作为容量 capacity 初始化 LRU 缓存int get(int key) 如果关键字 key 存在于缓存中,则返回关键字的值,否则返回 -1 。void put(int key, int value) 如果关键字已经存在,则变更其数据值;如果关键字不存在,则插入该组「关键字-值」。当缓存容量达到上限时,它应该在写入新数据之前删除最久未使用的数据值,从而为新的数据值留出空间。注意哦,get 和 put 方法必须都是 O(1) 的时间复杂度!示例:/* 缓存容量为 2 */ LRUCache cache = new LRUCache(2); // 你可以把 cache 理解成一个队列 // 假设左边是队头,右边是队尾 // 最近使用的排在队头,久未使用的排在队尾 // 圆括号表示键值对 (key, val) cache.put(1, 1); // cache = [(1, 1)] cache.put(2, 2); // cache = [(2, 2), (1, 1)] cache.get(1); // 返回 1 // cache = [(1, 1), (2, 2)] // 解释:因为最近访问了键 1,所以提前至队头 // 返回键 1 对应的值 1 cache.put(3, 3); // cache = [(3, 3), (1, 1)] // 解释:缓存容量已满,需要删除内容空出位置 // 优先删除久未使用的数据,也就是队尾的数据 // 然后把新的数据插入队头 cache.get(2); // 返回 -1 (未找到) // cache = [(3, 3), (1, 1)] // 解释:cache 中不存在键为 2 的数据 cache.put(1, 4); // cache = [(1, 4), (3, 3)] // 解释:键 1 已存在,把原始值 1 覆盖为 4 // 不要忘了也要将键值对提前到队头算法设计分析上面的操作过程,要让 put 和 get 方法的时间复杂度为 O(1),我们可以总结出 cache 这个数据结构必要的条件:查找快,插入快,删除快,有顺序之分。因为显然 cache 必须有顺序之分,以区分最近使用的和久未使用的数据;而且我们要在 cache 中查找键是否已存在;如果容量满了要删除最后一个数据;每次访问还要把数据插入到队头。那么,什么数据结构同时符合上述条件呢?哈希表查找快,但是数据无固定顺序;链表有顺序之分,插入删除快,但是查找慢。所以结合一下,形成一种新的数据结构:哈希链表。双向链表也叫双链表,是链表的一种,它的每个数据结点中都有两个指针,分别指向直接后继和直接前驱。所以,从双向链表中的任意一个结点开始,都可以很方便地访问它的前驱结点和后继结点。一般我们都构造双向循环链表。LRU 缓存算法的核心数据结构就是哈希链表:双向链表和哈希表的结合体。这个数据结构长这样:思想很简单,就是借助哈希表赋予了链表快速查找的特性嘛:可以快速查找某个 key 是否存在缓存(链表)中,同时可以快速删除、添加节点。回想刚才的例子,这种数据结构是不是完美解决了 LRU 缓存的需求?代码实现首先定义双端链表类(包括数据和记录前驱/后继节点的指针)class DLinkedNode { int key; int value; DLinkedNode pre; DLinkedNode next; public DLinkedNode() {}; public DLinkedNode(int key, int value) { this.key = key; this.value = value; } }双向链表需要提供一些接口api,便于我们操作,主要就是链表的一些操作,画图理解!private void addFirst(DLinkedNode node) { node.pre = head; node.next = head.next; head.next.pre = node; head.next = node; } private void moveToFirst(DLinkedNode node) { remove(node); addFirst(node); } private void remove(DLinkedNode node) { node.pre.next = node.next; node.next.pre = node.pre; } // 删除尾结点,并返回头节点 private DLinkedNode removeLast() { DLinkedNode ans = tail.pre; remove(ans); return ans; } private int getSize() { return size; }确定LRU缓存类的成员变量(链表长度、缓存容量和map映射等)和构造函数。注意:定义虚拟头尾结点便于在头部插入元素或者寻找尾部元素!并在构造函数初始化。private Map<Integer, DLinkedNode> cache = new HashMap<>(); private int size; private int capacity; private DLinkedNode head, tail; public LRUCache(int capacity) { this.size = 0; this.capacity = capacity; head = new DLinkedNode(); tail = new DLinkedNode(); head.next = tail; tail.pre = head; }核心代码:get和put方法,都是先根据key获取这个映射,根据映射节点的情况(有无)进行操作。注意:get和put都在使用,所以数据要提前!put操作如果改变了双端链表长度(不是仅改变值),需要先判断是否达到最大容量!public int get(int key) { DLinkedNode node = cache.get(key); if (node == null) { return -1; } // 将该数据移到双端队列头部 moveToFirst(node); return node.value; } public void put(int key, int value) { DLinkedNode node = cache.get(key); if (node != null) { // 如果存在key,先修改值,然后移动到头部 node.value = value; moveToFirst(node); } else { // 如果key存在,先考虑是否超过容量限制 if (capacity == cache.size()) { // 删除尾结点和hash表中对应的映射! DLinkedNode tail = removeLast(); cache.remove(tail.key); --size; } DLinkedNode newNode = new DLinkedNode(key, value); // 建立映射,并更新双向链表头部 cache.put(key, newNode); addFirst(newNode); ++size; } }完整代码如下:class LRUCache { class DLinkedNode { int key; int value; DLinkedNode pre; DLinkedNode next; public DLinkedNode() {}; public DLinkedNode(int key, int value) { this.key = key; this.value = value; } } private Map<Integer, DLinkedNode> cache = new HashMap<>(); private int size; private int capacity; // 虚拟头尾结点便于在头部插入元素或者寻找尾部元素! private DLinkedNode head, tail; public LRUCache(int capacity) { this.size = 0; this.capacity = capacity; // 使用伪头部和伪尾部节点 head = new DLinkedNode(); tail = new DLinkedNode(); head.next = tail; tail.pre = head; } public int get(int key) { DLinkedNode node = cache.get(key); if (node == null) { return -1; } // 将该数据移到双端队列头部 moveToFirst(node); return node.value; } public void put(int key, int value) { DLinkedNode node = cache.get(key); if (node != null) { // 如果存在key,先修改值,然后移动到头部 node.value = value; moveToFirst(node); } else { // 如果key存在,先考虑是否超过容量限制 if (capacity == cache.size()) { // 删除尾结点和hash表中对应的映射! DLinkedNode tail = removeLast(); cache.remove(tail.key); --size; } DLinkedNode newNode = new DLinkedNode(key, value); // 建立映射,并更新双向链表头部 cache.put(key, newNode); addFirst(newNode); ++size; } } private void addFirst(DLinkedNode node) { node.pre = head; node.next = head.next; head.next.pre = node; head.next = node; } private void moveToFirst(DLinkedNode node) { remove(node); addFirst(node); } private void remove(DLinkedNode node) { node.pre.next = node.next; node.next.pre = node.pre; } // 删除尾结点,并返回头节点 private DLinkedNode removeLast() { DLinkedNode ans = tail.pre; remove(ans); return ans; } private int getSize() { return size; } }总结与补充LRU缓存机制的核心:双向链表(保证元素有序,且能快速的插入和删除)+hash表(可以快速查询)为什么使用双向链表?因为:对于删除操作,使用双向链表,我们可以在O(1)的时间复杂度下,找到被删除节点的前节点。为什么要在链表中同时存键值,而不是只存值?因为:当缓存容量满了之后,我们不仅要在双向链表中删除最后一个节点(即最久没有使用的节点),还要把cache中映射到该节点的key删除,这个key只能有Node得到(即hash表不能通过值得到键)。
写在前在并发编程中,最需要处理的就是线程之间的通信和线程间的同步问题,JMM中可见性、原子性、有序性也是这两个问题带来的。volatile 是java虚拟机提供的轻量级的同步机制在并发编程中,需要解决的两个问题:通信:在命令式编程中,线程之间的通信包括共享内存和消息传递 而 java并发采用的是共享内存模型,线程之间共享程序的公共状态,通过读写内存总的公共状态来隐式通信JMM关于同步的规定:1.线程解锁前,必须把共享变量的值刷回主内存2.线程加锁前,必须读取共享内存的最新值到自己的本地内存3.加锁解锁是同一把锁volatile关键字主要作用保证内存可见性我们已经知道Java 内存模型分为了主内存和工作内存两部分,其规定程序所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程的工作内存中保存了该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作(赋值、读取等)都必须在工作内存中进行,而不能直接读取主内存中的变量。不同线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递都必须经过主内存的传递来完成。这样就会存在一个情况,工作内存值改变后到主内存更新一定是需要一定时间的,所以可能会出现多个线程操作同一个变量的时候出现取到的值还是未更新前的值。这样的情况我们通常称之为「可见性」,而我们加上 volatile 关键字修饰的变量就可以保证对所有线程的可见性。当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效,线程接下来将会从主内存中读取共享变量为什么 volatile 关键字可以有可见性?volatile是如何保证有序性呢?答案是内存屏障Memory BarrierMemory Barrier 可以保证内存可见性和特定操作的执行顺序volatile写操作之后都会插入一个store屏障,将工作内存中的值刷回到主内存,在读操作之前都会插入一个load屏障,从主内存读取最新的数据(可见性),而无论是stroe还是load都会告诉编译器和cpu,屏障前后的指令都不要进行重排序优化(禁止指令重排)这得益于 Java 语言的先行发生原则(happens-before)。简单地说,就是先执行的事件就应该先得到结果。但是! volatile 并不能保证并发下的安全。当一个线程修改了变量的值,新的值会立刻同步到主内存当中。而其他线程读取这个变量的时候,也会从主内存中拉取最新的变量值。Java 里面的运算并非原子操作,比如 i++ 这样的代码,实际上,它包含了 3 个独立的操作:读取 i 的值,将值加 1,然后将计算结果返回给 i。这是一个「读取-修改-写入」的操作序列,并且其结果状态依赖于之前的状态,所以在多线程环境下存在问题。要解决自增操作在多线程下线程不安全的问题,可以选择使用 Java 提供的原子类,如 AtomicInteger 或者使用 synchronized 同步方法。原子性:在 Java 中,对基本数据类型的变量的读取和赋值操作是原子性操作,即这些操作是不可被中断的,要么执行,要么不执行。也就是说,只有简单的读取、赋值(而且必须是将数字赋值给某个变量)才是原子操作。(变量之间的相互赋值不是原子操作,比如 y = x,实际上是先读取 x 的值,再把读取到的值赋值给 y 写入工作内存)禁止指令重排什么是数据依赖性?对同一数据的两个操作中只要有一个是写操作,那么就存在数据依赖性,比如写后写,读后写,写后读。指令重排:处理器为了提高程序效率,可能对输入代码进行优化,它不保证各个语句的执行顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。指令重排是一把双刃剑,虽然优化了程序的执行效率,但是在某些情况下,却会影响到多线程的执行结果。比如下面的代码:boolean contextReady = false; //在线程A中执行: context = loadContext(); // 步骤 1 contextReady = true; // 步骤 2 //在线程B中执行: while(!contextReady ){ sleep(200); } doAfterContextReady (context);以上程序看似没有问题。线程 B 循环等待上下文 context 的加载,一旦 context 加载完成,contextReady == true 的时候,才执行 doAfterContextReady 方法。但是,如果线程 A 执行的代码发生了指令重排,也就是上面的步骤 1 和步骤 2 调换了顺序,那线程 B 就会直接跳出循环,直接执行 doAfterContextReady() 方法导致出错。而 volatile 采用「内存屏障」这样的 CPU 指令就解决这个问题,不让它指令重排。使用 volatile 变量进行写操作,汇编指令带有 lock 前缀,相当于一个内存屏障,后面的指令不能重排到内存屏障之前。使用 lock 前缀引发两件事:① 将当前处理器缓存行的数据写回系统内存。②使其他处理器的缓存无效。相当于对缓存变量做了一次 store 和 write 操作,让 volatile 变量的修改对其他处理器立即可见。使用场景从上面的总结来看,我们非常容易得出 volatile 的使用场景:运行结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值。变量不需要与其他的状态变量共同参与不变约束。比如下面的场景,就很适合使用 volatile 来控制并发,当 shutdown() 方法调用的时候,就能保证所有线程中执行的 work() 立即停下来。volatile boolean shutdownRequest; private void shutdown(){ shutdownRequest = true; } private void work(){ while (!shutdownRequest){ // do something } }总结与补充对于 volatile 主要特性:保证可见性、禁止指令重排、解决 long 和 double 的 8 字节赋值问题。还有一个比较重要的是:它并不能保证并发安全(不能保证原子性),不要和 synchronized 混淆。可以创建Volatile数组吗?Java 中可以创建 volatile类型数组,不过只是一个指向数组的引用,而不是整个数组。如果改变引用指向的数组,将会受到volatile 的保护,但是如果多个线程同时改变数组的元素,volatile标示符就不能起到之前的保护作用了。volatile能使得一个非原子操作变成原子操作吗?虽然volatile只能保证可见性不能保证原子性,但用volatile修饰long和double可以保证其操作原子性。一种实践是用 volatile 修饰 long 和 double 变量,使其能按原子类型来读写。double 和 long 都是64位宽,因此对这两种类型的读是分为两部分的,第一次读取第一个 32 位,然后再读剩下的 32 位,这个过程不是原子的,但 Java 中 volatile 型的 long 或 double 变量的读写是原子的。volatile 修复符的另一个作用是提供内存屏障(memory barrier),例如在分布式框架中的应用。简单的说,就是当你写一个 volatile 变量之前,Java 内存模型会插入一个写屏障(write barrier),读一个 volatile 变量之前,会插入一个读屏障(read barrier)。意思就是说,在你写一个 volatile 域时,能保证任何线程都能看到你写的值,同时,在写之前,也能保证任何数值的更新对所有线程是可见的,因为内存屏障会将其他所有写的值更新到缓存。volatile和synchronized的区别与联系本质不同:volatile本质是在告诉jvm当前变量在寄存器(工作内存)中的值是不确定的,需要从主存中读取,主要用于解决变量在多个线程之间的可见性问题;synchronized则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住,只要解决多个线程访问资源的同步性;作用域不同:volatile仅能使用在变量级别;synchronized则可以使用在变量、方法、和类级别的;是否原子性:volatile仅能实现变量的修改可见性,不能保证原子性;而synchronized则可以保证变量的修改可见性和原子性;volatile不保证原子性的原因:线程A修改了变量还没结束时,另外的线程B可以看到已修改的值,而且可以修改这个变量,而不用等待A释放锁,因为volatile 变量没上锁是否加锁(阻塞):volatile不会造成线程的阻塞(没有上锁);synchronized可能会造成线程的阻塞;是否指令重排:volatile标记的变量不会被编译器优化(即禁止指令重排);synchronized标记的变量可以被编译器优化。volatile可以保证线程安全?单纯使用 volatile 关键字是不能保证线程安全的!线程安全必须保证原子性,可见性,有序性。而volatile只能保证可见性和有序性!volatile 只提供了一种弱的同步机制,用来确保将变量的更新操作通知到其他线程;volatile 语义是禁用 CPU 缓存,直接从主内存读、写变量。语义表现为:更新(写) volatile 变量时,JMM 会把线程对应的本地内存中的共享变量值刷新到主内存中;读 volatile 变量时,JMM 会把线程对应的本地内存设置为无效,直接从主内存中读取共享变量;当把变量声明为 volatile 类型后,JVM 增加内存屏障,禁止 CPU 进行指令重排。volatile底层的实现机制?“观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令”,lock前缀指令实际上相当于一个内存屏障(也成内存栅栏),内存屏障会提供3个功能:它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;它会强制将对缓存的修改操作立即写入主存;如果是写操作,它会导致其他CPU中对应的缓存行无效。拓展:缓存一致性?cpu和内存之间是有高速缓存的,一般分为多级。cpu首先是要从内存中读取一个数据进缓存,然后从缓存中读取进行操作,将结果返回给缓存,再把缓存写回内存。比如:如果同一个变量i=0,有两个线程执行i++方法,线程1把i从内存中读取进缓存,而现在线程2也把i读取进缓存,两个线程执行完i++后,线程1写回内存,i = 1,线程2也写回内存i = 1,两次++结果最终值为1,这就是著名的缓存一致性问题。为了解决这个问题,前人给了两种方案:
常见问题:对某个知识点的理解或看法,一般从是什么,原理,好处与应用场景来回答你对AQS的理解(想法)?CountDownLatch 和 CyclicBarrier 了解吗,两者的区别是什么?用过 Semaphore 吗?1 AQS 简单介绍AQS 的全称为(AbstractQueuedSynchronizer),这个类在 java.util.concurrent.locks 包下面。AQS 是一个用来构建锁和同步器的基础框架,使用 AQS 能简单且高效地构造出应用广泛的大量的同步器。比如我们提到的 ReentrantLock,Semaphore,其他的诸如 ReentrantReadWriteLock,SynchronousQueue,FutureTask(jdk1.7) 等等皆是基于 AQS 的。当然,我们自己也能利用 AQS 非常轻松容易地构造出符合我们自己需求的同步器。用大白话来说,AQS就是基于CLH队列,用volatile修饰共享变量state,线程通过CAS去改变状态符,成功则获取锁成功,失败则进入等待队列,同时等待被唤醒。2 AQS 原理2.1 AQS 原理概览AQS 核心思想:如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制 AQS 是用 CLH 队列锁实现的,即将暂时获取不到锁的线程加入到队列中。CLH(Craig,Landin,and Hagersten)队列是一个虚拟的双向队列(虚拟的双向队列即不存在队列实例,仅存在结点之间的关联关系)。AQS 是将每条请求共享资源的线程封装成一个 CLH 锁队列的一个结点(Node)来实现锁的分配,即通过内置的先进先出队列(FIFO),完成获取资源线程的排队工作。看个 AQS(AbstractQueuedSynchronizer)原理图:AQS 使用一个volatile int 成员变量来表示同步状态,通过内置的 FIFO 队列来完成获取资源线程的排队工作。AQS 使用 CAS 对该同步状态进行原子操作实现对其值的修改。private volatile int state; //共享变量,使用volatile修饰保证线程可见性状态信息通过 protected 类型的 getState,setState,compareAndSetState 进行操作。ps:protected:被保护的方法,存在继承关系,父类方法被保护,父类可以自己调用,子类也可以调用父类的protected方法,非继承关系不可见//返回同步状态的当前值 protected final int getState() { return state; } //设置同步状态的值 protected final void setState(int newState) { state = newState; } //原子地(CAS操作)将同步状态值设置为给定值update如果当前同步状态的值等于expect(期望值) protected final boolean compareAndSetState(int expect, int update) { return unsafe.compareAndSwapInt(this, stateOffset, expect, update); }补充:为什么只有前驱节点是头节点时才能尝试获取同步状态?头节点是成功获取到同步状态的节点,后继节点的线程被唤醒后需要检查自己的前驱节点是否是头节点。目的是维护同步队列的 FIFO 原则,节点和节点在循环检查的过程中基本不通信,而是简单判断自己的前驱是否为头节点,这样就使节点的释放规则符合 FIFO。并且也便于对过早通知的处理,过早通知指前驱节点不是头节点的线程由于中断被唤醒。2.2 AQS 对资源的共享方式AQS 定义两种资源共享方式(1)Exclusive(独占)只有一个线程能执行,如 ReentrantLock。又可分为公平锁和非公平锁,ReentrantLock 同时支持两种锁,下面以 ReentrantLock 对这两种锁的定义做介绍:公平锁:按照线程在队列中的排队顺序,先到者先拿到锁非公平锁:当线程要获取锁时,先通过两次 CAS 操作去抢锁,如果没抢到,当前线程再加入到队列中等待唤醒。说明:下面这部分关于 ReentrantLock 源代码内容节选自:https://www.javadoop.com/post/AbstractQueuedSynchronizer-2 ,这是一篇很不错文章,推荐阅读。下面来看 ReentrantLock 中相关的源代码:ReentrantLock 默认采用非公平锁,因为考虑获得更好的性能,通过 boolean 来决定是否用公平锁(传入 true 用公平锁)。/** Synchronizer providing all implementation mechanics */ private final Sync sync; public ReentrantLock() { // 默认非公平锁 sync = new NonfairSync(); } public ReentrantLock(boolean fair) { sync = fair ? new FairSync() : new NonfairSync(); }ReentrantLock 中公平锁的 lock 方法static final class FairSync extends Sync { final void lock() { acquire(1); } // AbstractQueuedSynchronizer.acquire(int arg) public final void acquire(int arg) { if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) selfInterrupt(); } protected final boolean tryAcquire(int acquires) { final Thread current = Thread.currentThread(); int c = getState(); if (c == 0) { // 1. 和非公平锁相比,这里多了一个判断:是否有线程在等待 if (!hasQueuedPredecessors() && compareAndSetState(0, acquires)) { setExclusiveOwnerThread(current); return true; } } else if (current == getExclusiveOwnerThread()) { int nextc = c + acquires; if (nextc < 0) throw new Error("Maximum lock count exceeded"); setState(nextc); return true; } return false; } }非公平锁的 lock 方法:static final class NonfairSync extends Sync { final void lock() { // 2. 和公平锁相比,这里会直接先进行一次CAS,成功就返回了 if (compareAndSetState(0, 1)) setExclusiveOwnerThread(Thread.currentThread()); else acquire(1); } // AbstractQueuedSynchronizer.acquire(int arg) public final void acquire(int arg) { if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) selfInterrupt(); } protected final boolean tryAcquire(int acquires) { return nonfairTryAcquire(acquires); } } /** * Performs non-fair tryLock. tryAcquire is implemented in * subclasses, but both need nonfair try for trylock method. */ final boolean nonfairTryAcquire(int acquires) { final Thread current = Thread.currentThread(); int c = getState(); if (c == 0) { // 这里没有对阻塞队列进行判断 if (compareAndSetState(0, acquires)) { setExclusiveOwnerThread(current); return true; } } else if (current == getExclusiveOwnerThread()) { int nextc = c + acquires; if (nextc < 0) // overflow throw new Error("Maximum lock count exceeded"); setState(nextc); return true; } return false; }ps:公平锁和非公平锁只有两处不同非公平锁在调用 lock 后,首先就会调用 CAS 进行一次抢锁,如果这个时候恰巧锁没有被占用,那么直接就获取到锁返回了。非公平锁在 CAS 失败后,和公平锁一样都会进入到 tryAcquire 方法,在 tryAcquire 方法中,如果发现锁这个时候被释放了(state == 0),非公平锁会直接 CAS 抢锁,但是公平锁会判断等待队列是否有线程处于等待状态,如果有则不去抢锁,乖乖排到后面。ps:如果这两次 CAS 都不成功,那么后面非公平锁和公平锁是一样的,都要进入到阻塞队列等待唤醒。相对来说,非公平锁会有更好的性能,因为它的吞吐量比较大。非公平锁让获取锁的时间变得更加不确定,可能会导致在阻塞队列中的线程长期处于饥饿状态。(2)Share(共享)多个线程可同时执行,如 Semaphore/CountDownLatch。Semaphore、CountDownLatCh、 CyclicBarrier、ReadWriteLock 我们都会在后面讲到。ReentrantReadWriteLock 可以看成是组合式,因为 ReentrantReadWriteLock 也就是读写锁允许多个线程同时对某一资源进行读。不同的自定义同步器争用共享资源的方式也不同。自定义同步器在实现时只需要实现共享资源 state 的获取与释放方式即可,至于具体线程等待队列的维护(如获取资源失败入队/唤醒出队等),AQS 已经在上层已经帮我们实现好了。总结:独占模式表示锁只会被一个线程占用,其他线程必须等到持有锁的线程释放锁后才能获取锁,同一时间只能有一个线程获取到锁。共享模式表示多个线程获取同一个锁有可能成功,ReadLock 就采用共享模式。独占模式通过 acquire 和 release 方法获取和释放锁,共享模式通过 acquireShared 和 releaseShared 方法获取和释放锁。2.3 AQS 底层使用了模板方法模式同步器的设计是基于模板方法模式的,如果需要自定义同步器一般的方式是这样(模板方法模式很经典的一个应用):使用者继承 AbstractQueuedSynchronizer 并重写指定的方法。(这些重写方法很简单,无非是对于共享资源 state 的获取和释放)将 AQS 组合在自定义同步组件的实现中,并调用其模板方法,而这些模板方法会调用使用者重写的方法。这和我们以往通过实现接口的方式有很大区别,这是模板方法模式很经典的一个运用,下面简单的给大家介绍一下模板方法模式,模板方法模式是一个很容易理解的设计模式之一。模板方法模式是基于”继承“的,主要是为了在不改变模板结构的前提下在子类中重新定义模板中的内容以实现复用代码。举个很简单的例子假如我们要去一个地方的步骤是:购票buyTicket()->安检securityCheck()->乘坐某某工具回家ride()->到达目的地arrive()。我们可能乘坐不同的交通工具回家比如飞机或者火车,所以除了ride()方法,其他方法的实现几乎相同。我们可以定义一个包含了这些方法的抽象类,然后用户根据自己的需要继承该抽象类然后修改 ride()方法。AQS 使用了模板方法模式,自定义同步器时需要重写下面几个 AQS 提供的模板方法:isHeldExclusively()//该线程是否正在独占资源。只有用到condition才需要去实现它。 tryAcquire(int)//独占方式。尝试获取资源,成功则返回true,失败则返回false。 tryRelease(int)//独占方式。尝试释放资源,成功则返回true,失败则返回false。 tryAcquireShared(int)//共享方式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。 tryReleaseShared(int)//共享方式。尝试释放资源,成功则返回true,失败则返回false。默认情况下,每个方法都抛出 UnsupportedOperationException。 这些方法的实现必须是内部线程安全的,并且通常应该简短而不是阻塞。AQS 类中的其他方法都是 final ,所以无法被其他类使用,只有这几个方法可以被其他类使用。以 ReentrantLock 为例,state 初始化为 0,表示未锁定状态。A 线程 lock()时,会调用 tryAcquire()独占该锁并将 state+1。此后,其他线程再 tryAcquire()时就会失败,直到 A 线程 unlock()到 state=0(即释放锁)为止,其它线程才有机会获取该锁。当然,释放锁之前,A 线程自己是可以重复获取此锁的(state 会累加),这就是可重入的概念。但要注意,获取多少次就要释放多么次,这样才能保证 state 是能回到零态的。再以 CountDownLatch 以例,任务分为 N 个子线程去执行,state 也初始化为 N(注意 N 要与线程个数一致)。这 N 个子线程是并行执行的,每个子线程执行完后 countDown()一次,state 会 CAS(Compare and Swap)减 1。等到所有子线程都执行完后(即 state=0),会 unpark()主调用线程,然后主调用线程就会从 await()函数返回,继续后余动作。一般来说,自定义同步器要么是独占方法,要么是共享方式,他们也只需实现tryAcquire-tryRelease、tryAcquireShared-tryReleaseShared中的一种即可。但 AQS 也支持自定义同步器同时实现独占和共享两种方式,如ReentrantReadWriteLock。推荐两篇 AQS 原理和相关源码分析的文章:http://www.cnblogs.com/waterystone/p/4920797.htmlhttps://www.cnblogs.com/chengxiao/archive/2017/07/24/7141160.html3 Semaphore(信号量)-允许多个线程同时访问synchronized 和 ReentrantLock 都是一次只允许一个线程访问某个资源(独占式),Semaphore(信号量)可以指定多个线程同时访问某个资源(共享式)。示例代码如下:public class SemaphoreExample1 { // 请求的数量 private static final int threadCount = 550; public static void main(String[] args) throws InterruptedException { // 创建一个具有固定线程数量的线程池对象(如果这里线程池的线程数量给太少的话你会发现执行的很慢) ExecutorService threadPool = Executors.newFixedThreadPool(300); // 一次只能允许执行的线程数量。 final Semaphore semaphore = new Semaphore(20); for (int i = 0; i < threadCount; i++) { final int threadnum = i; threadPool.execute(() -> {// Lambda 表达式的运用 try { semaphore.acquire();// 获取一个许可,所以可运行线程数量为20/1=20 test(threadnum); semaphore.release();// 释放一个许可 } catch (InterruptedException e) { // TODO Auto-generated catch block e.printStackTrace(); } }); } threadPool.shutdown(); System.out.println("finish"); } public static void test(int threadnum) throws InterruptedException { Thread.sleep(1000);// 模拟请求的耗时操作 System.out.println("threadnum:" + threadnum); Thread.sleep(1000);// 模拟请求的耗时操作 } }执行 acquire 方法阻塞,直到有一个许可证可以获得然后拿走一个许可证;每个 release 方法增加一个许可证,这可能会释放一个阻塞的 acquire 方法。然而,其实并没有实际的许可证这个对象,Semaphore 只是维持了一个可获得许可证的数量。 Semaphore 经常用于限制获取某种资源的线程数量。当然一次也可以一次拿取和释放多个许可,不过一般没有必要这样做:semaphore.acquire(5);// 获取5个许可,所以可运行线程数量为20/5=4 test(threadnum); semaphore.release(5);// 获取5个许可,所以可运行线程数量为20/5=4除了 acquire方法之外,另一个比较常用的与之对应的方法是tryAcquire方法,该方法如果获取不到许可就立即返回 false。Semaphore 有两种模式,公平模式和非公平模式。公平模式: 调用 acquire (阻塞)的顺序就是获取许可证的顺序,遵循 FIFO;非公平模式: 抢占式的,效率较高。Semaphore 对应的两个构造方法如下:public Semaphore(int permits) { sync = new NonfairSync(permits); } public Semaphore(int permits, boolean fair) { sync = fair ? new FairSync(permits) : new NonfairSync(permits); }这两个构造方法都必须提供许可的数量;第二个构造方法可以指定模式,默认非公平模式。总结:Semaphore与CountDownLatch一样,都是共享锁的一种实现,即多个线程可以获取一个锁(同步资源)。它默认构造AQS的state为permits(某种资源许可的线程数量)。当执行任务的线程数量超出permits,那么多余的线程将会被放入阻塞队列Park,并自旋判断state是否大于0。只有当state大于0的时候,阻塞的线程才能继续执行,此时先前执行任务的线程继续执行release方法,release方法(增加一个许可证)使得state的变量会加1,那么自旋的线程便会判断成功。如此,每次只有最多不超过permits数量的线程能自旋成功,便限制了执行任务线程的数量。Semaphore 源码分析:https://juejin.im/post/5ae755366fb9a07ab508adc64 CountDownLatch (倒计时器)CountDownLatch允许 count 个线程阻塞在一个地方,直至所有线程的任务都执行完毕。在 Java 并发中,countdownlatch 的概念是一个常见的面试题。CountDownLatch是共享锁的一种实现,它默认构造 AQS 的 state 值为 count。当线程使用countDown方法时,其实使用了tryReleaseShared方法以CAS的操作来减少state,直至state为0就代表所有的线程都调用了countDown方法。当调用await方法的时候,如果state不为0,就代表仍然有线程没有调用countDown方法,那么就把已经调用过countDown的线程都放入阻塞队列Park,并自旋CAS判断state == 0,直至最后一个线程调用了countDown,使得state == 0,于是阻塞的线程便判断成功,全部往下执行。4.1 CountDownLatch 的两种典型用法某一线程在开始运行前等待 n 个线程执行完毕。将 CountDownLatch 的计数器初始化为 n :new CountDownLatch(n),每当一个任务线程执行完毕,就将计数器减 1 countdownlatch.countDown(),当计数器的值变为 0 时,在CountDownLatch上 await() 的线程就会被唤醒。一个典型应用场景就是启动一个服务时,主线程需要等待多个组件加载完毕,之后再继续执行。实现多个线程开始执行任务的最大并行性。注意是并行性,不是并发,强调的是多个线程在某一时刻同时开始执行。类似于赛跑,将多个线程放到起点,等待发令枪响,然后同时开跑。做法是初始化一个共享的 CountDownLatch 对象,将其计数器初始化为 1 :new CountDownLatch(1),多个线程在开始执行任务前首先 coundownlatch.await(),当主线程调用 countDown() 时,计数器变为 0,多个线程同时被唤醒。4.2 CountDownLatch 的使用示例public class CountDownLatchExample1 { // 请求的数量 private static final int threadCount = 550; public static void main(String[] args) throws InterruptedException { // 创建一个具有固定线程数量的线程池对象(如果这里线程池的线程数量给太少的话你会发现执行的很慢) ExecutorService threadPool = Executors.newFixedThreadPool(300); final CountDownLatch countDownLatch = new CountDownLatch(threadCount); for (int i = 0; i < threadCount; i++) { final int threadnum = i; threadPool.execute(() -> {// Lambda 表达式的运用 try { test(threadnum); } catch (InterruptedException e) { // TODO Auto-generated catch block e.printStackTrace(); } finally { countDownLatch.countDown();// 表示一个请求已经被完成 } }); } countDownLatch.await(); threadPool.shutdown(); System.out.println("finish"); } public static void test(int threadnum) throws InterruptedException { Thread.sleep(1000);// 模拟请求的耗时操作 System.out.println("threadnum:" + threadnum); Thread.sleep(1000);// 模拟请求的耗时操作 } }上面的代码中,我们定义了请求的数量为 550,当这 550 个请求被处理完成之后,才会执行System.out.println("finish");。与 CountDownLatch 的第一次交互是主线程等待其他线程。主线程必须在启动其他线程后立即调用 CountDownLatch.await() 方法。这样主线程的操作就会在这个方法上阻塞,直到其他线程完成各自的任务。其他 N 个线程必须引用闭锁对象,因为他们需要通知 CountDownLatch 对象,他们已经完成了各自的任务。这种通知机制是通过 CountDownLatch.countDown()方法来完成的;每调用一次这个方法,在构造函数中初始化的 count 值就减 1。所以当 N 个线程都调 用了这个方法,count 的值等于 0,然后主线程就能通过 await()方法,恢复执行自己的任务。再插一嘴:CountDownLatch 的 await() 方法使用不当很容易产生死锁,比如我们上面代码中的 for 循环改为:for (int i = 0; i < threadCount-1; i++) { ....... }这样就导致 count 的值没办法等于 0,然后就会导致一直等待。如果对CountDownLatch源码感兴趣的朋友,可以查看: 【JUC】JDK1.8源码分析之CountDownLatch(五)注意:CountDownLatch 是一次性的,计数器的值只能在构造方法中初始化一次,之后没有任何机制再次对其设置值,当 CountDownLatch 使用完毕后,它不能再次被使用。5 CyclicBarrier(循环栅栏)CyclicBarrier 和 CountDownLatch 非常类似,它也可以实现线程间的技术等待,但是它的功能比 CountDownLatch 更加复杂和强大。主要应用场景和 CountDownLatch 类似。CountDownLatch的实现是基于AQS的,而CyclicBarrier是基于 ReentrantLock(ReentrantLock也属于AQS同步器)和 Condition 的。CyclicBarrier 的字面意思是可循环使用(Cyclic)的屏障(Barrier)。它要做的事情是,让一组线程到达一个屏障(也可以叫同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被屏障拦截的线程才会继续干活。CyclicBarrier 默认的构造方法是 CyclicBarrier(int parties),其参数表示屏障拦截的线程数量,每个线程调用await方法告诉 CyclicBarrier 我已经到达了屏障,然后当前线程被阻塞。再来看一下它的构造函数:public CyclicBarrier(int parties) { this(parties, null); } public CyclicBarrier(int parties, Runnable barrierAction) { if (parties <= 0) throw new IllegalArgumentException(); this.parties = parties; this.count = parties; this.barrierCommand = barrierAction; }其中,parties 就代表了有拦截的线程的数量,当拦截的线程数量达到这个值的时候就打开栅栏,让所有线程通过。5.1 CyclicBarrier 的应用场景CyclicBarrier 可以用于多线程计算数据,最后合并计算结果的应用场景。比如我们用一个 Excel 保存了用户所有银行流水,每个 Sheet 保存一个帐户近一年的每笔银行流水,现在需要统计用户的日均银行流水,先用多线程处理每个 sheet 里的银行流水,都执行完之后,得到每个 sheet 的日均银行流水,最后,再用 barrierAction 用这些线程的计算结果,计算出整个 Excel 的日均银行流水。5.2 CyclicBarrier 的使用示例示例 1:public class CyclicBarrierExample2 { // 请求的数量 private static final int threadCount = 550; // 需要同步的线程数量 private static final CyclicBarrier cyclicBarrier = new CyclicBarrier(5); public static void main(String[] args) throws InterruptedException { // 创建线程池 ExecutorService threadPool = Executors.newFixedThreadPool(10); for (int i = 0; i < threadCount; i++) { final int threadNum = i; Thread.sleep(1000); threadPool.execute(() -> { try { test(threadNum); } catch (InterruptedException e) { // TODO Auto-generated catch block e.printStackTrace(); } catch (BrokenBarrierException e) { // TODO Auto-generated catch block e.printStackTrace(); } }); } threadPool.shutdown(); } public static void test(int threadnum) throws InterruptedException, BrokenBarrierException { System.out.println("threadnum:" + threadnum + "is ready"); try { /**等待60秒,保证子线程完全执行结束*/ cyclicBarrier.await(60, TimeUnit.SECONDS); } catch (Exception e) { System.out.println("-----CyclicBarrierException------"); } System.out.println("threadnum:" + threadnum + "is finish"); } }运行结果,如下:threadnum:0is ready threadnum:1is ready threadnum:2is ready threadnum:3is ready threadnum:4is ready threadnum:4is finish threadnum:0is finish threadnum:1is finish threadnum:2is finish threadnum:3is finish threadnum:5is ready threadnum:6is ready threadnum:7is ready threadnum:8is ready threadnum:9is ready threadnum:9is finish threadnum:5is finish threadnum:8is finish threadnum:7is finish threadnum:6is finish ......可以看到当线程数量也就是请求数量达到我们定义的 5 个的时候, await方法之后的方法才被执行。另外,CyclicBarrier 还提供一个更高级的构造函数CyclicBarrier(int parties, Runnable barrierAction),用于在线程到达屏障时,优先执行barrierAction,方便处理更复杂的业务场景。示例代码如下:public class CyclicBarrierExample3 { // 请求的数量 private static final int threadCount = 550; // 需要同步的线程数量 private static final CyclicBarrier cyclicBarrier = new CyclicBarrier(5, () -> { System.out.println("------当线程数达到之后,优先执行------"); }); public static void main(String[] args) throws InterruptedException { // 创建线程池 ExecutorService threadPool = Executors.newFixedThreadPool(10); for (int i = 0; i < threadCount; i++) { final int threadNum = i; Thread.sleep(1000); threadPool.execute(() -> { try { test(threadNum); } catch (InterruptedException e) { // TODO Auto-generated catch block e.printStackTrace(); } catch (BrokenBarrierException e) { // TODO Auto-generated catch block e.printStackTrace(); } }); } threadPool.shutdown(); } public static void test(int threadnum) throws InterruptedException, BrokenBarrierException { System.out.println("threadnum:" + threadnum + "is ready"); cyclicBarrier.await(); System.out.println("threadnum:" + threadnum + "is finish"); } }运行结果,如下:threadnum:0is ready threadnum:1is ready threadnum:2is ready threadnum:3is ready threadnum:4is ready ------当线程数达到之后,优先执行------ threadnum:4is finish threadnum:0is finish threadnum:2is finish threadnum:1is finish threadnum:3is finish threadnum:5is ready threadnum:6is ready threadnum:7is ready threadnum:8is ready threadnum:9is ready ------当线程数达到之后,优先执行------ threadnum:9is finish threadnum:5is finish threadnum:6is finish threadnum:8is finish threadnum:7is finish ......5.3 CyclicBarrier源码分析当调用 CyclicBarrier 对象调用 await() 方法时,实际上调用的是dowait(false, 0L)方法。await() 方法就像树立起一个栅栏的行为一样,将线程挡住了,当拦住的线程数量达到 parties 的值时,栅栏才会打开,线程才得以通过执行。public int await() throws InterruptedException, BrokenBarrierException { try { return dowait(false, 0L); } catch (TimeoutException toe) { throw new Error(toe); // cannot happen } }dowait(false, 0L):// 当线程数量或者请求数量达到 count 时 await 之后的方法才会被执行。上面的示例中 count 的值就为 5。 private int count; /** * Main barrier code, covering the various policies. */ private int dowait(boolean timed, long nanos) throws InterruptedException, BrokenBarrierException, TimeoutException { final ReentrantLock lock = this.lock; // 锁住 lock.lock(); try { final Generation g = generation; if (g.broken) throw new BrokenBarrierException(); // 如果线程中断了,抛出异常 if (Thread.interrupted()) { breakBarrier(); throw new InterruptedException(); } // cout减1 int index = --count; // 当 count 数量减为 0 之后说明最后一个线程已经到达栅栏了,也就是达到了可以执行await 方法之后的条件 if (index == 0) { // tripped boolean ranAction = false; try { final Runnable command = barrierCommand; if (command != null) command.run(); ranAction = true; // 将 count 重置为 parties 属性的初始化值 // 唤醒之前等待的线程 // 下一波执行开始 nextGeneration(); return 0; } finally { if (!ranAction) breakBarrier(); } } // loop until tripped, broken, interrupted, or timed out for (;;) { try { if (!timed) trip.await(); else if (nanos > 0L) nanos = trip.awaitNanos(nanos); } catch (InterruptedException ie) { if (g == generation && ! g.broken) { breakBarrier(); throw ie; } else { // We're about to finish waiting even if we had not // been interrupted, so this interrupt is deemed to // "belong" to subsequent execution. Thread.currentThread().interrupt(); } } if (g.broken) throw new BrokenBarrierException(); if (g != generation) return index; if (timed && nanos <= 0L) { breakBarrier(); throw new TimeoutException(); } } } finally { lock.unlock(); } }总结:CyclicBarrier 内部通过一个 count 变量作为计数器,cout 的初始值为 parties 属性的初始化值,每当一个线程到了栅栏这里了,那么就将计数器减一。如果 count 值为 0 了,表示这是这一代最后一个线程到达栅栏,就尝试执行我们构造方法中输入的任务。CyclicBarrier 的计数器提供 reset 功能,可以多次使用。5.4 CyclicBarrier 和 CountDownLatch 的区别下面这个是国外一个大佬的回答:CountDownLatch 是计数器,只能使用一次,而 CyclicBarrier 的计数器提供 reset 功能,可以多次使用。但是我不那么认为它们之间的区别仅仅就是这么简单的一点。我们来从 jdk 作者设计的目的来看,javadoc 是这么描述它们的:CountDownLatch: A synchronization aid that allows one or more threads to wait until a set of operations being performed in other threads completes.(CountDownLatch: 一个或者多个线程,等待其他多个线程完成某件事情之后才能执行;)CyclicBarrier : A synchronization aid that allows a set of threads to all wait for each other to reach a common barrier point.(CyclicBarrier : 多个线程互相等待,直到到达同一个同步点,再继续一起执行。)对于 CountDownLatch 来说,重点是“一个线程(多个线程)等待”,而其他的 N 个线程在完成“某件事情”之后,可以终止,也可以等待。CountDownLatch 是计数器,线程完成一个记录一个,只不过计数不是递增而是递减,只能使用一次。而对于 CyclicBarrier,重点是多个线程,在任意一个线程没有完成,所有的线程都必须等待。而 CyclicBarrier 更像是一个阀门,需要所有线程都到达,阀门才能打开,然后继续执行。补充ReentrantReadWriteLock需要注意的是:读写锁 ReentrantReadWriteLock 可以保证多个线程可以同时读,所以在读操作远大于写操作的时候,读写锁就非常有用了。synchronized 和 ReentrantLock 区别是什么?重入锁 ReentrantLock 是 Lock 最常见的实现,与 synchronized 一样可重入,不过它增加了一些高级功能:等待可中断: 持有锁的线程长期不释放锁时,正在等待的线程可以选择放弃等待而处理其他事情。公平锁: 公平锁指多个线程在等待同一个锁时,必须按照申请锁的顺序来依次获得锁,而非公平锁不保证这一点,在锁被释放时,任何线程都有机会获得锁。synchronized 是非公平的,ReentrantLock 在默认情况下是非公平的,可以通过构造方法指定公平锁。一旦使用了公平锁,性能会急剧下降,影响吞吐量。锁绑定多个条件: 一个 ReentrantLock 可以同时绑定多个 Condition。synchronized 中锁对象的 wait 跟 notify 可以实现一个隐含条件,如果要和多个条件关联就不得不额外添加锁,而 ReentrantLock 可以多次调用 newCondition 创建多个条件。一般优先考虑使用 synchronized:synchronized 是语法层面的同步,足够简单。Lock 必须确保在 finally 中释放锁,否则一旦抛出异常有可能永远不会释放锁。使用 synchronized 可以由 JVM 来确保即使出现异常锁也能正常释放。尽管 JDK5 时 ReentrantLock 的性能优于 synchronized,但在 JDK6 进行锁优化后二者的性能基本持平。从长远来看 JVM 更容易针对synchronized 优化,因为 JVM 可以在线程和对象的元数据中记录 synchronized 中锁的相关信息,而使用 Lock 的话 JVM 很难得知具体哪些锁对象是由特定线程持有的。CountDownLatch、CyclicBarrier、Semaphore共同之处与区别以及各自使用场景三者的区别:CountDownLatch :一个线程A或是一组线程A等待其它线程执行完毕后才继续执行。CyclicBarrier:一组线程使用await()指定barrier,所有线程都到达各自的barrier后,再同时执行各自barrier下面的代码。Semaphore:是用来控制同时访问特定资源的线程数量,它通过协调各个线程,以保证合理的使用公共资源。CountDownLatch是减计数方式,start==0时释放所有等待的线程;CyclicBarrier是加计数方式,计数达到构造方法中参数指定的值时释放所有等待的线程。Semaphore,每次semaphore.acquire(),获取一个资源,每次semaphore.acquire(n),获取n个资源,当达到semaphore 指定资源数量时就不能再访问线程处于阻塞,必须等其它线程释放资源,semaphore.relase()每次资源一个资源,semaphore.relase(n)每次资源n个资源。CountDownLatch当计数到0时,计数无法被重置;CountDownLatch每次调用countDown()方法计数减一,调用await()方法只进行阻塞,对计数没任何影响;CyclicBarrier计数达到指定值时,计数置为0重新开始。CyclicBarrier只有一个await()方法,调用await()方法计数加1,若加1后的值不等于构造方法的值,则线程阻塞。CountDownLatch、CyclicBarrier、Semaphore 都有一个int类型参数的构造方法。CountDownLatch、CyclicBarrier这个值作为计数用,达到该次数即释放等待的线程,而Semaphore 中所有acquire获取到的资源达到这个数,会使得其它线程阻塞。共同之处:CountDownLatch与CyclikBarrier两者的共同点是都具有await()方法,并且执行此方法会引起线程的阻塞,达到某种条件才能继续执行(这种条件也是两者的不同)。Semaphore,acquire方获取的资源达到最大数量时,线程再次acquire获取资源时,也会使线程处于阻塞状态。CountDownLatch、CyclicBarrier、Semaphore 都有一个int类型参数的构造方法。应用场景:CountDownLatch 两种典型用法:主线程等待多个组件加载完毕后再执行;实现多个线程开始执行任务的最大并行性。CyclicBarrier 可以用于多线程计算数据,最后合并计算结果的应用场景。Semaphore 经常用于限制获取某种资源的线程数量
1.通配符匹配(44 - 难)题目描述:给定一个字符串 (s) 和一个字符模式 (p) ,实现一个支持 '?' 和 '*' 的通配符匹配。'?' 可以匹配任何单个字符。'*' 可以匹配任意字符串(包括空字符串)。说明:s 可能为空,且只包含从 a-z 的小写字母。p 可能为空,且只包含从 a-z 的小写字母,以及字符 ? 和 *示例 :输入: s = "adceb" p = "*a*b" 输出: true 解释: 第一个 '*' 可以匹配空字符串, 第二个 '*' 可以匹配字符串 "dce".思路:本题是典型的字符串匹配的题目。动态规划:dp[i][j] :p的前i个字符与s的前j个字符是否匹配状态转移方程:根据匹配规则,分为两种情况两个字符串对应位置相同或者p串对应位置为“?”p串对应位置为“*”,此时星号可以匹配字母或者是空字符注意:当p为空且s不为空,一定不能匹配;反之不一定,如果p串全部为星号,则能匹配(全部匹配空)。代码实现:public boolean isMatch(String s, String p) { int len1 = p.length(); int len2 = s.length(); boolean[][] dp = new boolean[len1 + 1][len2 + 1]; dp[0][0] = true; for (int i = 1; i < len1 + 1; i++) { if (p.charAt(i - 1) != '*') { break; } dp[i][0] = true; } for (int i = 1; i < len1 + 1; i++) { for (int j = 1; j < len2 + 1; j++) { if (p.charAt(i - 1) == s.charAt(j - 1) || p.charAt(i - 1) == '?') { dp[i][j] = dp[i - 1][j - 1]; } else if (p.charAt(i - 1) == '*') { dp[i][j] = dp[i - 1][j] || dp[i][j - 1]; } } } return dp[len1][len2]; }2.正则表达式匹配(10 - 难)题目描述:给你一个字符串 s 和一个字符规律 p(都可为空),请你来实现一个支持 '.' 和 '*' 的正则表达式匹配。'.' 匹配任意单个字符'*' 匹配零个或多个前面的那一个元素所谓匹配,是要涵盖 整个 字符串 s的,而不是部分字符串。注意:题目保证每次出现星号,前面都能匹配到有效字符!示例 :输入:s = "ab" p = ".*" 输出:true 解释:".*" 表示可匹配零个或多个('*')任意字符('.')。思路:本题与上一题类似,但是本题难点在于讨论星号的情况:匹配不上,可以将字符+*全部忽略,即匹配0个如果能够匹配上,匹配0个,或者匹配掉s串的当前字符。注意:当s为空且p不为空,需要检查p的结构;反之一定不能匹配。代码实现:public boolean isMatch(String s, String p) { int len1 = p.length(); int len2 = s.length(); boolean[][] dp = new boolean[len1 + 1][len2 + 1]; dp[0][0] = true; // 对于匹配串s为空且p不为空,判断模式串是否为【字符+*】结构 for (int i = 1; i < len1 + 1; i++) { if (p.charAt(i - 1) == '*') { dp[i][0] = dp[i - 2][0]; } } for (int i = 1; i < len1 + 1; i++) { for (int j = 1; j < len2 + 1; j++) { if (p.charAt(i - 1) == '*') { if (p.charAt(i - 2) == s.charAt(j - 1) || p.charAt(i - 2) == '.') { dp[i][j] = dp[i - 2][j] || dp[i][j - 1]; } else { dp[i][j] = dp[i - 2][j]; } } else { if (p.charAt(i - 1) == s.charAt(j - 1) || p.charAt(i - 1) == '.') { dp[i][j] = dp[i - 1][j - 1]; } } } } return dp[len1][len2]; }3.编辑距离(72 - 难)题目描述:给你两个单词 word1 和 word2,请你计算出将 word1 转换成 word2 所使用的最少操作数 。你可以对一个单词进行如下三种操作:插入一个字符删除一个字符替换一个字符示例 :输入:word1 = "horse", word2 = "ros" 输出:3 解释: horse -> rorse (将 'h' 替换为 'r') rorse -> rose (删除 'r') rose -> ros (删除 'e')思路:本题word1可以看成模式串,即字符串匹配问题。dp[i][j] :word1前i个字符转换成word2前j个字符的最少操作数状态转移方程:关键是讨论三种操作状态,取最小值+1,做为当前的最小操作数插入操作:dp[i][j - 1],即等于word1前i个字符转换成word2前j - 1个字符的最少操作数删除操作(即不用word1第i个元素):dp[i - 1][j],即等于word1前i - 1个字符转换成word2前j个字符的最少操作数替换操作:dp[i - 1][j - 1]初始化:word1为空且word2不为空,最小转化次数为word1.length(),即全部添加;否则,最小转化次数为word2.length(),即全部删除。代码实现:public int minDistance(String word1, String word2) { int len1 = word1.length(); int len2 = word2.length(); int[][] dp = new int[len1 + 1][len2 + 1]; for (int i = 1; i < len1 + 1; i++) { dp[i][0] = i; } for (int j = 1; j < len2 + 1; j++) { dp[0][j] = j; } for (int i = 1; i < len1 + 1; i++) { for (int j = 1; j < len2 + 1; j++) { if (word1.charAt(i - 1) == word2.charAt(j - 1)) { dp[i][j] = dp[i - 1][j - 1]; } else { dp[i][j] = Math.min(Math.min(dp[i - 1][j], dp[i][j - 1]), dp[i - 1][j - 1]) + 1; } } } return dp[len1][len2]; }4.最长公共子序列(1143 - 中)题目描述:给定两个字符串 text1 和 text2,返回这两个字符串的最长 公共子序列 的长度。如果不存在 公共子序列 ,返回 0 。一个字符串的 子序列 是指这样一个新的字符串:它是由原字符串在不改变字符的相对顺序的情况下删除某些字符(也可以不删除任何字符)后组成的新字符串。例如,"ace" 是 "abcde" 的子序列,但 "aec" 不是 "abcde" 的子序列。两个字符串的 公共子序列 是这两个字符串所共同拥有的子序列。示例 :输入:text1 = "abcde", text2 = "ace" 输出:3 解释:最长公共子序列是 "ace" ,它的长度为 3 。思路:本题与上题类似,使用动态规划解决比较好,因为本题没有规定谁是谁的子串。关键是:当两个字符不同是,我们需要取 i 字符或 j 字符不取的最大值。具体见代码。代码实现:public int longestCommonSubsequence(String text1, String text2) { int m = text1.length(), n = text2.length(); int[][] dp = new int[m + 1][n + 1]; for (int i = 1; i <= m; i++) { for (int j = 1; j <= n; j++) { if (text1.charAt(i - 1) == text2.charAt(j - 1)) { dp[i][j] = dp[i - 1][j - 1] + 1; } else { dp[i][j] = Math.max(dp[i][j - 1], dp[i - 1][j]); } } } return dp[m][n]; }5.两个字符串的删除操作(583 - 中)题目描述:给定两个单词 word1 和 word2,找到使得 word1 和 word2 相同所需的最小步数,每步可以删除任意一个字符串中的一个字符。示例 :输入: "sea", "eat" 输出: 2 解释: 第一步将"sea"变为"ea",第二步将"eat"变为"ea"思路:将问题转化一下,本题本质:求两个字符串的最长子序列,与上题相同,最后直接返回多余的长度,即使两个字符串相同的最小操作次数。ps:先将字符串转化为字符数组的方式比直接使用charAt()效率高。代码实现:public int minDistance(String word1, String word2) { int m = word1.length(), n = word2.length(); char[] str1 = word1.toCharArray(); char[] str2 = word2.toCharArray(); int[][] dp = new int[m + 1][n + 1]; for (int i = 1; i < m + 1; i++) { for (int j = 1; j < n + 1; j++) { if (str1[i - 1] == str2[j - 1]) { dp[i][j] = dp[i - 1][j - 1] + 1; } else { dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]); } } } return m + n - 2 * dp[m][n]; }6.不相交的线(1035 - 中)题目描述:在两条独立的水平线上按给定的顺序写下 nums1 和 nums2 中的整数。现在,可以绘制一些连接两个数字 nums1[i] 和 nums2[j] 的直线,这些直线需要同时满足满足:nums1[i] == nums2[j]且绘制的直线不与任何其他连线(非水平线)相交。请注意,连线即使在端点也不能相交:每个数字只能属于一条连线。以这种方法绘制线条,并返回可以绘制的最大连线数。示例 :输入:nums1 = [1,4,2], nums2 = [1,2,4] 输出:2 解释:可以画出两条不交叉的线,如上图所示。 但无法画出第三条不相交的直线,因为从 nums1[1]=4 到 nums2[2]=4 的直线将与从 nums1[2]=2 到 nums2[1]=2 的直线相交。思路:本题本质:找两个数组的最长重复子序列,最长子序列的长度就是可以绘制的最大连接数。ps:对于自定义的数据类型++i的效率高于i++。但是有的编译器进行了优化。原因:i++需要先取出i值,再进行+1,所以用到temp存放当前值。i++必须要有一个临时对象才可以完成代码实现:public int maxUncrossedLines(int[] nums1, int[] nums2) { int n1 = nums1.length, n2 = nums2.length; int[][] dp = new int[n1 + 1][n2 + 1]; for (int i = 1; i < n1 + 1; ++i) { for (int j = 1; j < n2 + 1; ++j) { if (nums1[i - 1] == nums2[j - 1]) { dp[i][j] = dp[i - 1][j - 1] + 1; } else { dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]); } } } return dp[n1][n2]; }7.最长重复子数组(718 - 中)题目描述:给两个整数数组 A 和 B ,返回两个数组中公共的、长度最长的子数组的长度。示例 :输入: A: [1,2,3,2,1] B: [3,2,1,4,7] 输出:3 解释: 长度最长的公共子数组是 [3, 2, 1] 。思路:dp数组定义与上述公共子序列基本相同。dp[i][j]表示第一个数组 A 前 i 个元素和数组 B 前 j 个元素组成的最长公共子数组(相当于子串)的长度。与公共子序列不同的是,当两个数组元素对比不同时,因为子数组是连续的,所以 dp[i][j] == 0。注意:由于状态方程不同,当不相同时清零,所以我们要更新最大值。代码实现:public int findLength(int[] nums1, int[] nums2) { int m = nums1.length; int n = nums2.length; int max = 0; int[][] dp = new int[m + 1][n + 1]; for (int i = 1; i < m + 1; ++i) { for (int j = 1; j < n + 1; ++j) { if (nums1[i - 1] == nums2[j - 1]) { dp[i][j] = dp[i - 1][j - 1] + 1; } else { dp[i][j] = 0; } max = Math.max(max, dp[i][j]); } } return max; }
写在前在看redis缓存雪崩、击穿和穿透之前,先回答一下几个缓存的问题。为什么要用 redis 而不用 map/guava 做缓存?缓存分为本地缓存和分布式缓存。以Java为例,使⽤⾃带的 map 或者 guava 实现的是本地缓存,最主要的特点是轻量以及快速,⽣命周期随着 jvm 的销毁⽽结束,并且在多实例的情况下,每个实例都需要各⾃保存⼀份缓存,缓存不具有⼀致性。使⽤ redis 或 memcached 之类的称为分布式缓存,在多实例的情况下,各实例共⽤⼀份缓存数据,缓存具有⼀致性。缺点是需要保持 redis 或 memcached服务的⾼可⽤(需要维护),整个程序架构上为较为复杂。Redis 与 Memcached的区别两者都是非关系型(NoSql)内存键值数据库,主要有以下不同:(1)数据类型。Memcached 仅支持字符串类型,而 Redis 支持五种不同的数据类型,可以更灵活地解决问题。(2)数据持久化。Redis 支持两种持久化策略:RDB 快照和 AOF 日志,而 Memcached 不支持持久化。(3)分布式。Memcached 不支持分布式,只能通过在客户端使用一致性哈希来实现分布式存储,这种方式在存储和查询时都需要先在客户端计算一次数据所在的节点。Redis Cluster 实现了分布式的支持。(4)内存管理机制。在 Redis 中,并不是所有数据都一直存储在内存中,可以将一些很久没用的 value 交换到磁盘(设置过期时间),而 Memcached 的数据则会一直在内存中。Memcached 将内存分割成特定长度的块来存储数据,以完全解决内存碎片的问题。但是这种方式会使得内存的利用率不高,例如块的大小为 128 bytes,只存储 100 bytes 的数据,那么剩下的 28 bytes 就浪费掉了。(5)Memcached是多线程,⾮阻塞IO复⽤的⽹络模型;Redis使⽤单线程的多路 IO 复⽤模型。使用Redis有什么缺点?存在的问题?(1)缓存和数据库双写一致性问题(2)缓存雪崩问题(3)缓存击穿问题(4)缓存穿透(并发竞争)问题缓存雪崩为了使查询速度更快,我们选择使用缓存来保存数据,使原本每次请求都需要查询数据库的操作变成先查询缓存,缓存有直接返回,缓存没有则查询数据库然后再写入缓存中,通常缓存都是有有效时长的,否则就会一直占用内存空间。问题描述:当大量请求在访问都会先从缓存查询,如果此时大部分缓存同时过期失效,那么这些请求都查询不到缓存,此时他们会全部将请求到数据库,当请求数量足够大时此时将会把数据库压垮。简言之,如果缓存挂掉了,就意味着大量的请求都跑到数据库去了,压垮数据库,这就是缓存雪崩。解决方案:缓存数据的过期时间后边加一个随机值,防止同一时间大量数据过期现象发生,让数据均匀失效。一般并发量不是特别多的时候,使用最多的解决方案是加锁排队。给每一个缓存数据增加相应的缓存标记,记录缓存是否失效,如果缓存标记失效,则更新数据缓存。热点数据可以考虑不失效。Redis是如何判断数据是否过期的呢?Redis 通过一个叫做过期字典(可以看作是hash表)来保存数据过期的时间。过期字典的键是一个指针,这个指针指向键空间中的某个键对象( 也即是某个数据库键)。过期字典的值是一个long long 类型的整数,这个整数保存了键所指向的数据库键的过期时间:一个毫秒精度的UNIX 时间戳。过期字典是存储在 redisDb 这个结构里的:键空间+键的过期时间typedef struct redisDb { ... dict *dict; //数据库键空间,保存着数据库中所有键值对 dict *expires // 过期字典,保存着键的过期时间 ... } redisDb;通过过期字典,程序可以用以下步骤检查一个给定键是否过期:(1)检查给定键是否存在于过期字典: 如果存在,那么取得键的过期时间。(2)检查当前UNIX 时间戳是否大于键的过期时间: 如果是的话,那么键已经过期;否则的话,键未过期。Redis 给缓存数据设置过期时间有啥用?(1)有助于缓解内存的消耗,避免长时间占用内存。如果缓存中的所有数据都是一直保存的话,分分钟直接 Out of memory。(2)实际业务场景需要。很多时候,我们的业务场景就是需要某个数据只在某一时间段内存在,比如我们的短信验证码可能只在1分钟内有效,用户登录的 token 可能只在 1 天内有效。如果使用传统的数据库来处理的话,一般都是自己判断过期,这样更麻烦并且性能要差很多。127.0.0.1:6379> exp key 60 # 数据在 60s 后过期 (integer) 1 127.0.0.1:6379> setex key 60 value # 数据在 60s 后过期 (setex:[set] + [ex]pire) OK 127.0.0.1:6379> ttl key # 查看数据还有多久过期 (integer) 56注意:Redis 中除了字符串类型有自己独有设置过期时间的命令 setex 外,其他方法都需要依靠 expire 命令来设置过期时间 。另外, persist 命令可以移除一个键的过期时间, ttl查看键还有多久过期redis 设置过期时间,怎么处理过期数据呢?(过期键删除策略)Redis中有个设置时间过期的功能,即对存储在 redis 数据库中的值可以设置⼀个过期时间。作为⼀个 缓存数据库,这是⾮常实⽤的。如我们⼀般项⽬中的 token 或者⼀些登录信息,尤其是短信验证码都是有时间限制的,按照传统的数据库处理⽅式,⼀般都是⾃⼰判断过期,这样⽆疑会严重影响项⽬性 。通过key设置过期时间:我们 set key 的时候,都可以给⼀个 expire time,就是过期时间。通过过期时间我们可以指定这个key可以存活的时间。如果假设你设置了一批 key 只能存活 1 分钟,那么 1 分钟后,Redis 是怎么对这批 key 进行删除的呢(策略)?惰性删除 :只会在取出 key 的时候才对数据进行过期检查。这样对 CPU 最友好,但是可能会造成太多过期 key 没有被删除。定期删除 : 每隔一段时间抽取一批 key 执行删除过期 key 操作。对内存友好。并且,Redis 底层会通过限制删除操作执行的时长和频率来减少删除操作对 CPU 时间的影响。所以 Redis 采用的是 定期删除+惰性/懒汉式删除 。但是,仅仅通过给 key 设置过期时间还是有问题的。因为还是可能存在定期删除和惰性删除漏掉了很多过期 key 的情况。这样就导致大量过期 key 堆积在内存里,然后就 Out of memory 了。怎么解决这个问题呢?答案就是: Redis 内存淘汰机制。Redis 内存淘汰机制作为内存数据库,出于对性能和内存消耗的考虑,Redis 的淘汰算法实际实现上并非针对所有 key,而是抽样一小部分并且从中选出被淘汰的 key。no-eviction:禁止驱逐数据,也就是说当内存不足以容纳新写入数据时,新写入操作会报错。这个应该没人使用吧!allkeys-lru(least recently used):当内存不足以容纳新写入数据时,在键空间中,移除最近最少使用的 key(这个是最常用的)allkeys-random:从数据集(server.db[i].dict)中任意选择数据淘汰volatile-lru:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,移除最近最少使用(时间上最久的)的 key。volatile-random:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,随机移除某个 key。volatile-ttl:从已设置过期时间的数据集(server.db[i].expires)中挑选将要过期的数据淘汰redis 4.0 版本后增加以下两种(针对最少使用的淘汰机制):volatile-lfu(least frequently used):从已设置过期时间的数据集(server.db[i].expires)中挑选最不经常(用的频率最低的淘汰)使用的数据淘汰allkeys-lfu(least frequently used):当内存不足以容纳新写入数据时,在键空间中,移除最不经常使用的 keyps:MySQL⾥有2000w数据,Redis中只存20w的数据,如何保证Redis中的数据都是热点数据?如何提高缓存命中率?使用 Redis 缓存数据时,为了提高缓存命中率,需要保证缓存数据都是热点数据。可以将内存最大使用量设置为热点数据占用的内存量,然后启用 allkeys-lru 淘汰策略,将最近最少使用的数据淘汰。缓存击穿(缓存雪崩的另一个场景,热点数据在某一时刻过期失效)问题描述:对于一些设置了过期时间的key,当redis缓存中有一个key是大量请求同时访问的热点数据,如果突然这个key时间到了,那么大量的请求在缓存中获取不到该key,穿过缓存直接来到数据库导致数据库崩溃,这样因为单个key失效而穿过缓存到数据库称为缓存击穿。相比于缓存雪崩是大量key在同一时间过期引发的问题,缓存击穿强调的是某一热点key过期的瞬间引发的问题。两者都是由key过期导致大量并发请求直接到数据库。热点缓存失效解决方案可以使用互斥锁避免大量请求同时落到db。对缓存查询加锁,如果KEY不存在,就加锁,然后查DB入缓存,然后解锁;其他进程如果发现有锁就等待,然后等解锁后返回数据或者进入DB查询。布隆过滤器,判断某个容器是否在集合中,例如请求的参数不合法(请求参数不存在等)。可以将热点数据设置为永不过期。做好熔断、降级,防止系统崩溃。ps:上述两种问题,针对redis服务器不可用情况:采用 Redis 集群,避免单机出现问题整个缓存服务都没办法使用。限流,避免同时处理大量的请求。缓存穿透问题描述:缓存穿透是指查询一个一定不存在的数据。由于缓存不命中,并且出于容错考虑,如果从数据库查不到数据,则不写入缓存,这将导致这个不存在的数据每次请求都要到数据库去查询,失去了缓存的意义。这样,如果请求的数据在缓存大量不命中,导致大量的请求走向数据库,就很可能将数据库搞垮,导致整个服务瘫痪。这种通常是恶意查询和被攻击几率较大。击穿和穿透不同,穿透是key不存在,可以理解为直接绕过redis缓存去使得数据库崩掉。而击穿可以理解为击穿缓存,这种通常为大量并发对热点key(常用的)进行大规模的读写操作导致数据库崩溃。解决方案:接口层增加校验:如用户鉴权校验,key值基础校验,id<=0的直接拦截;从缓存取不到的数据,在数据库中也没有取到,这时也可以将key-value对写为key-null(即将查到的null设置为该key的缓存对象),缓存有效时间可以设置短点,如30秒(设置太长会导致正常情况也没法使用)。这样可以防止攻击用户反复用同一个id暴力攻击;采用布隆过滤器,将所有可能存在的数据哈希到一个足够大的 bitmap 中,一个一定不存在的数据会被这个 bitmap 拦截掉,从而避免了对底层存储系统的查询压力。ps:布隆过滤器是一个非常神奇的数据结构,通过它我们可以非常方便地判断一个给定数据是否存在于海量数据中。我们需要的就是判断 key 是否合法,有没有感觉布隆过滤器就是我们想要找的那个“人”。具体是这样做的:把所有可能存在的请求的值都存放在布隆过滤器中,当用户请求过来,先判断用户发来的请求的值是否存在于布隆过滤器中。不存在的话,直接返回请求参数错误信息给客户端(无效的请求),存在的话才会走下面的流程。但是,需要注意的是布隆过滤器可能会存在误判的情况。总结来说就是: 布隆过滤器说某个元素存在,小概率会误判。布隆过滤器说某个元素不在,那么这个元素一定不在。为什么会出现误判的情况呢? 我们还要从布隆过滤器的原理来说!我们先来看一下,当一个元素加入布隆过滤器中的时候,会进行哪些操作:使用布隆过滤器中的哈希函数对元素值进行计算,得到哈希值(有几个哈希函数得到几个哈希值)。根据得到的哈希值,在位数组中把对应下标的值置为 1。我们再来看一下,当我们需要判断一个元素是否存在于布隆过滤器的时候,会进行哪些操作:对给定元素再次进行相同的哈希计算;得到值之后判断位数组中的每个元素是否都为 1,如果值都为 1,那么说明这个值在布隆过滤器中,如果存在一个值不为 1,说明该元素不在布隆过滤器中。然后,一定会出现这样一种情况:不同的字符串可能哈希出来的位置相同。 (优化方案:可以适当增加位数组大小或者调整我们的哈希函数来降低概率)缓存与数据库双写一致性问题描述:从理论上来说,只要我们设置了键的过期时间,我们就能够保证缓存和数据库的数据最终一致性。因为只要缓存数据过期了,就会被删除,下次读的时候因为缓存里面没有,就会从数据库中查询并更新到缓存中。但是,在缓存数据没过期的时间内,缓存数据和数据库数据是不同步的。怎样保证在写入数据库的同时,同步更新缓存中的数据。就是缓存与数据库双写一致性问题。对于读操作,流程是这样的:如果我们的数据在缓存里面有,那就直接读取缓存的数据;如果缓存里面没有,则先去查询数据库,然后将数据库查出来的数据写入到缓存中,最后再将数据返回给请求。如果仅仅只是查询的话,缓存的数据和数据库的数据都是没问题的。但是,当我们要更新的时候,有一些情况就很可能造成数据库和缓存的数据不一致了。举个例子,数据库的库存值是999,但是缓存的库存值是1000,那么很可能在一段时间内,页面拿到的是缓存1000的值,尽管实际上的库存是999(数据库的值)。怎样解决缓存与数据库双写一致性问题?方案:解决思路基本上都是删除缓存。因为这样的话,下一次读就会到数据库中读到缓存中,保证缓存的一致性。就算数据库更新操作失败了,也不会有缓存数据与数据库数据不一致的问题,即使缓存数据和数据库数据都是旧数据。只是删除缓存的时机不同会引发不同的问题。先更新数据库,再删除缓存。可能出现删除缓存失败导致不一致。先删除缓存,再更新数据库。可能出现读取脏数据,即在更新数据库之前读到数据。写请求先将缓存修改为指定值,再更新数据库,再更新缓存。读请求过来之后,先读缓存,判断是指定值,则进入等待状态,等待写请求更新缓存之后再读缓存。如果等待超时,则直接到数据库中读取数据,更新缓存。这种方案可以保证读写的一致性,但是因为读请求需要等待写请求的完成(串行化了),降低了吞吐量。如果为了短时间的不一致性问题,选择让系统设计变得更加复杂的话,完全没必要。这里聊聊,Cache Aside Pattern(旁路缓存模式)更新数据库,删除缓存,如果数据库删除成功,缓存删除失败,解决方案:缓存失效时间变短(不推荐,治标不治本) :我们让缓存数据的过期时间变短,这样的话缓存就会从数据库中加载数据。另外,这种解决办法对于先操作缓存后操作数据库的场景不适用。增加 cache 更新重试机制(常用): 如果 cache 服务当前不可用导致缓存删除失败的话,我们就隔一段时间进行重试,重试次数可以自己定。如果多次重试还是失败的话,我们可以把当前更新失败的 key 存入队列中,等缓存服务可用之后,再将 缓存中对应的 key 删除即可。ps:为什么是删除缓存,而不是更新缓存?(1)缓存可能复杂:很多时候,在复杂点的缓存场景,缓存不单单是数据库中直接取出来的值。(2)更新代价高:另外更新缓存的代价有时候是很高的。总结:一般来说,如果允许缓存可以稍微的跟数据库偶尔有不一致的情况,最好不要做这个方案,即:读请求和写请求串行化,串到一个内存队列里去。串行化可以保证一定不会出现不一致的情况,但是它也会导致系统的吞吐量大幅度降低,用比正常情况下多几倍的机器去支撑线上的一个请求。补充什么是缓存预热?缓存预热是一个比较常见的概念,就是指在系统上线后,先将相关的缓存数据直接加载到缓存系统。这样,用户请求的时候就不需要先去查询数据库,再将数据放入缓存了,用户可以直接拿到实现被预热的缓存数据。什么是缓存(熔断)降级?熔断机制:“我们提供过载保护。当某个服务故障或者异常发生时,若这个异常条件需要我们处理,我们会采取一些保护措施---直接熔断整个服务,而不是一直等到此服务超时,从而防止整个系统的故障。”什么是缓存降级?当访问量剧增,服务出现问题(比如响应慢或不响应)或非核心服务影响到核心流程的性能时。仍然需要保证服务还是可用的,及时是有损服务。系统可以根据一些关键数据进行自动降级,也可以配置开关实现人工降级。关键点:降级的最终目的是保证核心服务可用,即使是有损的。而且有些服务是无法降级的,比如加入购物车、结算等服务。在进行降级之前,要先对系统进行梳理,看看系统是不是可以弃帅保车,进而梳理出哪些是核心服务(不可降级),哪些是非核心服务(可降级)。拿日志级别设置预案作为参考:一般级别。比如某些服务偶尔因为网络抖动或者服务正在上线而超时,就可以自动降级。警告级别。有些服务在一段时间内成功率有波动(比如在95~100%之间),就可以自动降级或人工降级,并发送警告。错误级别。比如可用率低于90%,或者数据库连接池被打爆了,或者访问量突然猛增到系统能承受的最大阀值,此时可以根据情况自动降级或者人工降级。严重错误级别。比如因为特殊原因数据错误了,此时就需要紧急人工降级。
1.完全平方数(279 - 中)题目描述:给定正整数 n,找到若干个完全平方数(比如 1, 4, 9, 16, ...)使得它们的和等于 n。你需要让组成和的完全平方数的个数最少。给你一个整数 n ,返回和为 n 的完全平方数的 最少数量 。完全平方数 是一个整数,其值等于另一个整数的平方;换句话说,其值等于一个整数自乘的积。例如,1、4、9 和 16 都是完全平方数,而 3 和 11 不是。示例 :输入:n = 12 输出:3 解释:12 = 4 + 4 + 4思路:本题要求使用最少的数量,可以建立一个数组,元素的最大平方数不超过n,我们只需倒序进行搜索即可,使用深度优先遍历。终止条件:n = 0,即n全部用完,更新最小值,注意当i<0,直接返回。递归函数传入参数:(数组, 当前剩余值, 当前进行到的索引值,当前使用的完全平方数的个数)注意:这里有两个优化使用乘法加速获取当前元素的下一步的起始最大次数,如示例,12/4 = 3,在4这个完全平方数,下一次最大的次数为3。倒序遍历过程中,对于完全平方数大于当前最小值ans的情况,进行剪枝。代码实现:private int ans = Integer.MAX_VALUE; public int numSquares(int n) { int num = (int)Math.sqrt(n); int[] nums = new int[num]; for (int i = 0; i < num; i++) { nums[i] = (i + 1) * (i + 1); } dfs(nums, n, nums.length - 1, 0); return ans == Integer.MAX_VALUE ? -1 : ans; } private void dfs(int[] nums, int n, int i, int count) { if (n == 0) { ans = Math.min(ans, count); return; } if (i < 0) return; int start = n / nums[i]; for (int k = start; k >= 0 && k + count < ans; --k) { dfs(nums, n - k * nums[i], i - 1, k + count); } }2.平方数之和(633 - 中)题目描述:给定一个非负整数 c ,你要判断是否存在两个整数 a 和 b,使得 a^2 + b^2 = c 。示例 :输入:c = 5 输出:true 解释:1 * 1 + 2 * 2 = 5思路:本题主要由两种解法,一种是类似两数之和的哈希解法,构建平方数数组;另一个则是像最大矩形面积的双指针解法,利用双指针寻找可能范围内的元素(最优解)。注意:注意其中一个数可能是0,所以平方数组和循环条件要考虑边界。代码实现:// hash public boolean judgeSquareSum(int c) { int n = (int)Math.sqrt(c); int[] nums = new int[n + 1]; Set<Integer> set = new HashSet<>(); for (int i = 0; i <= n; ++i) { nums[i] = i * i; set.add(nums[i]); } for (int num : nums) { if (set.contains(c - num)) { return true; } } return false; } // 双指针 public boolean judgeSquareSum(int c) { int l = 0, r = (int)Math.sqrt(c); while (l <= r) { if (l * l + r * r == c) { return true; } else if (l * l + r * r > c) { r--; } else { l++; } } return false; }3.鸡蛋掉落(887 - 难)题目描述:给你 k 枚相同的鸡蛋,并可以使用一栋从第 1 层到第 n 层共有 n 层楼的建筑。已知存在楼层 f ,满足 0 <= f <= n ,任何从 高于 f 的楼层落下的鸡蛋都会碎,从 f 楼层或比它低的楼层落下的鸡蛋都不会破。每次操作,你可以取一枚没有碎的鸡蛋并把它从任一楼层 x 扔下(满足 1 <= x <= n)。如果鸡蛋碎了,你就不能再次使用它。如果某枚鸡蛋扔下后没有摔碎,则可以在之后的操作中 重复使用 这枚鸡蛋。请你计算并返回要确定 f 确切的值 的 最小操作次数 是多少?示例 :输入:k = 1, n = 2 输出:2 解释: 鸡蛋从 1 楼掉落。如果它碎了,肯定能得出 f = 0 。 否则,鸡蛋从 2 楼掉落。如果它碎了,肯定能得出 f = 1 。 如果它没碎,那么肯定能得出 f = 2 。 因此,在最坏的情况下我们需要移动 2 次以确定 f 是多少。思路:这是一个经典动态规划问题,可以看一下李永乐老师的双蛋问题,比较通俗易懂。dp[i][j]:使用 i 个鸡蛋,有 j 层楼梯(注意:这里 j 不表示高度,代表区间)的情况下,的最少实验的次数(约束条件)。第一个鸡蛋扔到第x层楼,两种状态:碎dp[i - 1][x- 1]或者没碎dp[i][j - x],最终枚举所有扔鸡蛋的情况,即枚举x。对于上边找x的过程,我们可以通过二分查找进行优化。dp[i - 1][x- 1]是一个随x(层数)的增加而单调递增的函数,相反,dp[i][j - x]随着层数x单调递减,我们的目标找这两函数的交点,类似于数组中找山峰/峡谷(最大/最小值),那么显然就是二分提升效率了。第一个鸡蛋在哪里扔(也算一次操作),这也是状态转移方程中为什么+1.代码实现:public int superEggDrop(int k, int n) { // k 鸡蛋数 n 为楼层数 int[][] dp = new int[k + 1][n + 1]; for (int i = 1; i <= k; ++i) { dp[i][1] = 1; } for (int i = 1; i <= n; ++i) { dp[1][i] = i; } for(int i = 2; i <= k; ++i){ for(int j = 2; j <= n; ++j){ // 未使用二分的解法 x为所选楼层 // int min = Integer.MAX_VALUE; // for(int x = 1; x <= j; ++x){ // min = Math.min(min, Math.max(dp[i - 1][x - 1], dp[i][j - x])); // } // dp[i][j] = 1 + min; // 改用二分查找(第一次扔的层数,替换枚举) int l = 1, r = j; while(l + 1 < r){ int mid = l + r >> 1; if(dp[i-1][mid-1] > dp[i][j-mid]){ r = mid; } else { l = mid; } } int leftVal = Math.max(dp[i - 1][l - 1], dp[i][j - l]); int rightVal = Math.max(dp[i - 1][r - 1], dp[i][j - r]); dp[i][j] = 1 + Math.min(leftVal, rightVal); } } // for(int[] d:dp){ // System.out.println(Arrays.toString(d)); // } return dp[k][n]; }4.打印从1到最大的n位数(补充)题目描述:输入数字 n,按顺序打印出从 1 到最大的 n 位十进制数。比如输入 3,则打印出 1、2、3 一直到最大的 3 位数 999。示例 :输入: n = 1 输出: [1,2,3,4,5,6,7,8,9]代码实现:public int[] printNumbers(int n) { int x = (int)Math.pow(10, n); int[] nums = new int[x - 1]; for (int i = 0; i < x - 1; ++i) { nums[i] = i + 1; } return nums; }5.自除数(728 - 易)题目描述:自除数 是指可以被它包含的每一位数除尽的数。还有,自除数不允许包含 0 。128 是一个自除数,因为 128 % 1 == 0,128 % 2 == 0,128 % 8 == 0。给定上边界和下边界数字,输出一个列表,列表的元素是边界(含边界)内所有的自除数。示例 :输入: 上边界left = 1, 下边界right = 22 输出: [1, 2, 3, 4, 5, 6, 7, 8, 9, 11, 12, 15, 22]代码实现:public List<Integer> selfDividingNumbers(int left, int right) { List<Integer> ans = new ArrayList<>(); for (int i = left; i <= right; ++i) { if (selfDividingNumbers(i)) { ans.add(i); } } return ans; } // 是否为自除数 public boolean selfDividingNumbers(int num) { int i = num; while (i > 0) { int x = i % 10; if (x == 0 || num % x != 0) { return false; } i /= 10; } return true; }6.各位相加(258 - 易)题目描述:给定一个非负整数 num,反复将各个位上的数字相加,直到结果为一位数。进阶: 你可以不使用循环或者递归,且在 O(1) 时间复杂度内解决这个问题吗?示例 :输入: 38 输出: 2 解释: 各位相加的过程为:3 + 8 = 11, 1 + 1 = 2。 由于 2 是一位数,所以返回 2。思路:本题迭代比较简单,引入一个辅助变量tmp即可。对于进阶问题,参考@wangliang大佬:涉及数学上一个名词,数根。数根可以计算模运算的同余,对于非常大的数字的情况下可以节省很多时间。数字根可作为一种检验计算正确性的方法。例如,两数字的和的数根等于两数字分别的数根的和。另外,数根也可以用来判断数字的整除性,如果数根能被3或9整除,则原来的数也能被3或9整除。上边的应用转化一下:能够被9整除的整数,各位上的数字加起来也必然能被9整除,所以,连续累加起来,最终必然就是9。不能被9整除的整数,各位上的数字加起来,结果对9取模,和初始数对9取摸,是一样的,所以,连续累加起来,最终必然就是初始数对9取摸。看下边一个规律:原数: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 数根: 1 2 3 4 5 6 7 8 9 1 2 3 4 5 6 7 8 9 1 2 3 4 5 6 7 8 9 1 2 3结合上边的规律,对于给定的 n 有三种情况。n 是 0 ,数根就是 0。n 不是 9 的倍数,数根就是 n 对 9 取余,即 n % 9。n 是 9 的倍数,数根就是 9。原数是 n,树根就可以表示成 (n-1) % 9 + 1,可以结合下边的过程理解。原数: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 偏移: 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 取余: 0 1 2 3 4 5 6 7 8 0 1 2 3 4 5 6 7 8 0 1 2 3 4 5 6 7 8 0 1 2 数根: 1 2 3 4 5 6 7 8 9 1 2 3 4 5 6 7 8 9 1 2 3 4 5 6 7 8 9 1 2 3为什么要先偏移,再加1呢?假设如果可以被 9 整除,直接取模得到是0,按照数根的应用应该是9才对,我们可以进行偏移之后,再进行取余+1.代码实现:public int addDigits(int num) { if (num < 9) return num; int tmp = num; while (tmp > 9) { num = tmp; tmp = 0; while (num > 0) { tmp += num % 10; num /= 10; } } return tmp; } // 是否为自除数 public int addDigits(int num) { return (num - 1) % 9 + 1; }7.范围求和II(598 - 易)题目描述:给定一个初始元素全部为 0,大小为 m*n 的矩阵 M 以及在 M 上的一系列更新操作。操作用二维数组表示,其中的每个操作用一个含有两个正整数 a 和 b 的数组表示,含义是将所有符合 0 <= i < a 以及 0 <= j < b 的元素 M[i][j] 的值都增加 1。在执行给定的一系列操作后,你需要返回矩阵中含有最大整数的元素个数。示例 :输入: m = 3, n = 3 operations = [[2,2],[3,3]] 输出: 4 解释: 初始状态, M = [[0, 0, 0], [0, 0, 0], [0, 0, 0]] 执行完操作 [2,2] 后, M = [[1, 1, 0], [1, 1, 0], [0, 0, 0]] 执行完操作 [3,3] 后, M = [[2, 2, 1], [2, 2, 1], [1, 1, 1]] M 中最大的整数是 2, 而且 M 中有4个值为2的元素。因此返回 4。思路:分析一下题目。可以确定的是最大值一定是 M[0][0] ,范围一定是数组中最小的行和最小的列。有了上边两个结论,因为本题只是统计个数。注意:复用了变量m 和 n。代码实现:public int maxCount(int m, int n, int[][] ops) { for (int[] r : ops) { m = Math.min(m, r[0]); n = Math.min(n, r[1]); } return m * n; }
写在前持久化解决了单机redis的数据保存问题,但是redis还是存在以下两个问题:假如某天这台redis服务器挂了,redis服务将彻底丧失redis的读和写都集中到一台机上,如果请求量比较大时,将可能被击溃redis的多机数据库实现,主要分为以下三种:主从(复制)模式(redis2.8版本之前的模式)哨兵sentinel模式(redis2.8及之后的模式)redis cluster集群模式(redis3.0版本之后)Redis主从复制为了解决上述两个问题,redis提供了主从架构,在主从架构中,主服务器(master)可以进行读写操作,从服务器(slave)一般只进行读操作。缓解了单个redis服务器的压力;主服务器将所有数据源源不断的同步到从服务器上,一旦主服务器挂了,还有从服务器可以提供服务,redis服务将不会间断。和Mysql主从复制的原因一样,Redis的主从结构可以采用一主多从或者级联结构。同步类型与同步过程根据同步是否全量分为:全量同步和部分同步(增量同步)。全量同步 指主服务器每次与从服务器同步都是同步全部数据。主服务器持久化数据为一个rdb文件,在此期间用缓存区把所有对主服务器的写操作命令存储起来,然后再rdb传给从服务器,再把储存起来的命令也传过去;从服务器从接收到的rdb文件加载数据,然后再加载传过来的命令。部分同步 指主服务器每次与从服务器同步都是只同步增量数据。Redis的主从复制分为同步(sync)和命令传播两个操作。我们向从服务器发送SLAVEOF命令(异步命令),从服务器对主服务器的同步操作需要通过向主服务器发送SYNC命令来完成。步骤如下(全量同步),一般发生在SLAVE初始化阶段,需要将MASTER的数据全部复制一份:(1)从服务器连接主服务器,发送SYNC命令;(2)主服务器接收到SYNC命名后,开始执行BGSAVE命令生成RDB文件,并使用缓冲区记录此后执行的所有写命令;(3)主服务器BGSAVE执行完后,向【所有】从服务器发送快照文件,并在发送期间继续记录被执行的写命令;(4)从服务器收到快照文件后丢弃所有旧数据,载入收到的快照;(5)主服务器快照发送完毕后开始向从服务器发送缓冲区中的写命令;(6)从服务器完成对快照的载入,开始接收命令请求,并执行来自主服务器缓冲区的写命令,将自己的数据库状态更新至主服务器数据库当前所处的状态。Redis命令传播是指同步操作完毕后,开始正常工作时主服务器发生的写操作同步到从服务器的过程。命令传播的过程主要是主服务器每执行一个写命令就会向从服务器发送相同的写命令,从服务器接收并执行收到的写命令。主服务器通过向从服务器传播命令(主服务器执行的写命令)来更新从服务器的状态,保存主从服务器一致。从服务器则通过向主服务器发送命令(每秒发送1次REPLCONF ACK命令)来进行心跳检测。以此来检测主从服务器的网络连接状态和检测命令丢失。什么是部分重同步?从Redis 2.8开始,如果遭遇连接断开,重新连接之后可以从中断处继续进行复制,而不必重新同步。它的工作原理是这样:主服务器端为复制流维护一个内存缓冲区(replication backlog)。主从服务器都维护一个复制偏移量(replication offset)和服务器的运行id(run id)。当连接断开时,从服务器会重新连接上主服务器,然后请求继续复制,假如主从服务器的两个run id相同,并且指定的偏移量在内存缓冲区中还有效,复制就会从上次中断的点开始继续。如果其中一个条件不满足,就会进行完全重新同步(在2.8版本之前就是直接进行完全重新同步)。因为主运行id不保存在磁盘中,如果从服务器重启了的话就只能进行完全同步了。部分重新同步这个新特性内部使用PSYNC命令,旧的实现中使用SYNC命令。Redis2.8版本可以检测出它所连接的服务器是否支持PSYNC命令,不支持的话使用SYNC命令。主从复制的特点一台主服务器(也称为master node)可以连接多台从服务器(也称为slave node)从服务器也可以连接其他redis服务器,作为其他redis服务器的主服务器,从而形成一条链从服务器在复制的时候,不会阻塞主服务器的正常工作,同时也不会阻塞自己的查询操作,它会用旧的数据集来提供服务;但是复制完成的时候,需要删除旧数据集,加载新数据集,这个时候就会暂停对外服务了;从服务器主要用来进行横向扩容,做读写分离,提高读的吞吐量主从复制的优缺点优点(开始的两个问题):解决数据备份问题做到读写分离,提高服务器性能缺点:每个客户端连接redis实例的时候都是指定了ip和端口号的,如果所连接的redis实例因为故障下线了,而主从模式也没有提供一定的手段通知客户端另外可连接的客户端地址,因而需要手动更改客户端配置重新连接主从模式下,如果主节点由于故障下线了,那么从节点因为没有主节点而同步中断,因而需要人工进行故障转移工作无法实现动态扩容哨兵Sentinel模式(基于主从模式)使用哨兵模式进行主从替换与故障恢复!Redis Sentinel 是一个分布式系统, 可以在一个架构中运行多个 Sentinel 进程(progress), 这些进程使用流言协议(gossip protocols)来接收关于主服务器是否下线的信息, 并使用投票协议(agreement protocols)来决定是否执行自动故障迁移, 以及选择哪个从服务器作为新的主服务器。哨兵模式是一种特殊的模式,首先Redis提供了哨兵的命令,哨兵是一个独立的进程(独立的redis节点,不存储数据,只支持部分命令),实际上是一个运行在特殊模式下的一个redis服务器,独立运行。哨兵与服务器之间会创建两个连接,命令连接和订阅连接,哨兵与哨兵之间只会创建命令连接。其原理是哨兵通过发送命令,等待Redis服务器响应,从而监控运行的多个Redis实例。启动一个普通的redis服务器之后,通过--sentinel来启动Redis SentinelRedis 2.8版开始正式提供名为Sentinel的主从切换方案,通俗的来讲,Sentinel可以用来管理多个Redis服务器实例,可以实现一个功能上实现HA的集群,Sentinel主要负责三个方面的任务:监控:通过发送命令,不间断的监控Redis服务器运行状态,包括主服务器和从服务器。提醒:当被监控的某个 Redis 服务器出现问题时, Sentinel 可以通过 API 向管理员或者其他应用程序发送通知。自动故障迁移(核心任务):当哨兵监测到主服务器宕机,会自动在已下线主服务器属下的所有从服务器里面,挑选出一个从服务器将其转换为主服务器(自动切换)。然后通过发布订阅模式通知其他的从服务器,修改配置文件,让它们切换主机。多哨兵模式:一个哨兵进程对Redis服务器进行监控,可能会出现问题,为此,我们可以使用多个哨兵进行监控。各个哨兵之间还会进行监控,多哨兵可以防止误判并且使整个哨兵集合更加健壮。用文字描述一下故障切换(failover)的过程。假设主服务器宕机,哨兵1先检测到这个结果,系统并不会马上进行failover过程,仅仅是哨兵1主观的认为主服务器不可用,这个现象成为主观下线。当后面的哨兵也检测到主服务器不可用,并且数量达到一定值时,那么哨兵之间就会进行一次投票,投票的结果由一个哨兵发起,进行failover操作。切换成功后,就会通过发布订阅模式,让各个哨兵把自己监控的从服务器实现切换主机,这个过程称为客观下线。这样对于客户端而言,一切都是透明的。对于不可用描述术语总结:主观下线(SDOWN):单个哨兵实例对主节点做出下线判断。客观下线(ODOWN):多个哨兵实例对主节点做出下线判断。主节点有主观和客观下线,从节点只有主观下线领导者哨兵选举流程:当确定redis服务器确实挂了以后,哨兵要进行故障转移,并且只能有一个哨兵去完成该操作,所以这时候就要选举出一名哨兵来当此重任。每个在线的哨兵节点都可以成为领导者,当它确认(比如哨兵3)主节点下线时,会向其它哨兵发is-master-down-by-addr命令,征求判断并要求将自己设置为领导者,由领导者处理故障转移;当其它哨兵收到此命令时,可以同意或者拒绝它成为领导者;如果哨兵3发现自己在选举的票数大于等于num(sentinels)/2+1时,将成为领导者,如果没有超过,继续选举…………讲讲slave->master选举算法(怎么完成故障迁移)如果master被判odown了,大部分哨兵允许主备切换,那么需要选举一个slave,依次考虑如下:看slave-priority:选择slave优先级最高的;看offset:选择复制offset(偏移量)最大的,指复制最完整的从节点看runid:程序id,就选runid最小的,越早开启的自动发现机制(三个定时任务)监控redis服务器的运行状态:以10秒一次的频率,向被监控的master发送Info命令,根据回复获取当前master信息。这个任务达到两个目的:发现slave节点和确定主从关系以1秒一次的频率,向所有的redis服务器包括 Sentinel 发送ping命令,通过回复判断服务器是否在线,这个其实是一个心跳检测,是失败判定的依据。以2秒一次的频率,向master节点的channel交换信息(pub/sub)。master节点上有一个发布订阅的频道(__sentinel__:hello)。sentinel节点通过__sentinel__:hello频道进行信息交换(对节点的"看法"和自身的信息),达成共识。哨兵模式的优缺点优点:哨兵模式是基于主从模式的,所有主从的优点,哨兵模式都有。主从可以自动切换,系统更健壮,可用性更高缺点:redis较难支持在线扩容,在集群容量达上限时在线扩容变的很复杂。ps:“高可用性”(High Availability)通常来描述一个系统经过专门的设计,从而减少停工时间,而保持其服务的高度可用性。1. redis 集群模式的工作原理能说一下么?在集群模式下,****redis 的 key 是如何寻址的?分布式寻址都有哪些算法?了解一致性 hash 算法吗?Redis Cluster集群模式工作原理:集群是Redis提供的分布式数据库方案,集群通过分片来进行数据共享,并提供复制和故障转移功能。一个Redis集群通常由多个节点组成;最初,每个节点都是独立的,需要将独立的节点连接起来才能形成可工作的集群。Redis Cluster并没有使用一致性hash,而是采用hash slot(哈希槽) 算法,一共分成16384个槽,每个key通过CRC16校验后对16384取模来决定放置哪个槽(每个节点对应若干个槽)。关键点:自动将数据进行分片(通过hash方式),每个 master 上放一部分数据(均分存储一定哈希槽区间的数据),先写入主节点,再写入从节点。注:同一分片多个节点间的数据不保持一致性提供内置的高可用支持,部分 master 不可用时(注意:key找的是哈希槽),还是可以继续工作的,哈希槽会迁移。将请求发送到任意节点(这个key可能不在这个节点上),接收到请求的节点会将查询请求发送到正确的节点上执行(返回转向指令)。redis cluster架构下的每个redis都要开放两个端口号,比如一个是6379,另一个就是加1w的端口号16379。6379端口号就是redis服务器入口。16379端口号是用来进行节点间通信的,也就是 cluster bus 的东西,cluster bus 的通信,用来进行故障检测、配置更新、故障转移授权。cluster bus 用的是一种叫gossip 协议的二进制协议,用于节点间高效的数据交换,占用更少的网络带宽和处理时间。节点间的内部通信机制写在前:任何文件系统中的数据分为数据和元数据。数据是指普通文件中的实际数据,而元数据指用来描述一个文件的特征的系统数据,诸如访问权限、文件拥有者以及文件数据块的分布信息(inode...)等等。在集群文件系统中,分布信息包括文件在磁盘上的位置以及磁盘在集群中的位置。用户需要操作一个文件必须首先得到它的元数据,才能定位到文件的位置并且得到文件的内容或相关属性。基本通信原理,集群元数据的维护有两种方式:集中式、Gossip 协议(分布式)。redis cluster 节点间采用 gossip 协议进行通信。集中式管理:集中式是将集群元数据(节点信息、故障等等)集中存储在某个节点上。集中式元数据集中存储的一个典型代表,就是大数据领域的 storm。它是分布式的大数据实时计算引擎,是集中式的元数据存储的结构,底层基于 zookeeper(分布式协调的中间件)对所有元数据进行存储维护。优缺点:元数据的读取和更新,时效性非常好,一旦元数据出现了变更,就立即更新到集中式的存储中,其它节点读取的时候就可以感知到;但可能会存在存储压力和单点失效问题。分布式管理:redis 维护集群元数据采用另一个方式, gossip 协议:所有节点都持有一份元数据,不同的节点如果出现了元数据的变更,就不断将元数据发送给其它的节点,让其它节点也进行元数据的变更。优缺点:元数据存储比较分散,降低了元数据存储压力;但元数据更新会延迟,可能导致集群中的一些操作会有一些滞后,实现较复杂,一致性维护复杂。补充:10000 端口:每个节点都有一个专门用于节点间通信的端口,就是自己提供服务的端口号+10000,比如 7001,那么用于节点间通信的就是 17001 端口。每个节点每隔一段时间都会往另外几个节点发送 ping 消息,同时其它几个节点接收到 ping 之后返回 pong。交换的信息:信息包括故障信息,节点的增加和删除,hash slot 信息等等。gossip协议gossip 协议包含多种消息,包含 ping,pong,meet,fail 等等。meet:某个节点发送 meet 给新加入的节点,让新节点加入集群中,然后新节点就会开始与其它节点进行通信。其实内部就是发送了一个 gossip meet 消息给新加入的节点,通知那个节点去加入我们的集群。ping:每个节点都会频繁给其它节点发送 ping,其中包含自己的状态还有自己维护的集群元数据,互相通过 ping 交换元数据。pong:返回 ping 和 meet,包含自己的状态和其它信息,也用于信息广播和更新。fail:某个节点判断另一个节点 fail 之后,就发送 fail 给其它节点,通知其它节点说,某个节点宕机啦分布式寻址算法在分布式系统中,对数据的准确定位以及整个系统的结构具有很高的要求。现代分布式寻址算法中,主要以下面三种算法为代表:hash 算法(大量缓存重建)一致性 hash 算法(自动缓存迁移)+ 虚拟节点(自动负载均衡)redis cluster 的 hash slot 算法(也叫hash槽)使用场景:hash算法比较适合固定分区或者分布式节点的集群架构。一致性hash算法比较适合需要动态扩容的分布式架构以及一些动态负载均衡的分布式中间件和RPC中间件。hash slot是Redis对hash算法的一种实现。hash 算法对于不同的请求,比如来了一个 key,首先计算 hash 值,然后对节点数(服务器数)取模。然后打在不同的 master 节点上。一旦某一个 master 节点宕机或者进行扩容,需要重新基于最新的master节点数计算hash值。当系统具有海量数据时将会是场灾难。一致性hash算法(动态扩容和负载均衡)一致性 hash 算法将整个 hash 值空间组织成一个虚拟的圆环,整个空间按顺时针方向组织,下一步将各个 master 节点(使用服务器的 ip 或主机名)进行 hash。这样就能确定每个节点在其哈希环上的位置。来了一个 key,首先计算 hash 值,并确定此数据在环上的位置,从此位置沿环顺时针“行走”,遇到的第一个 master 节点就是 key 所在位置。在一致性哈希算法中,如果一个节点挂了,受影响的数据仅仅是此节点到环空间前一个节点(沿着逆时针方向行走遇到的第一个节点)之间的数据,其它不受影响。增加一个节点也同理。然而,一致性哈希算法在节点太少时,容易因为节点分布不均匀而造成缓存热点的问题。为了解决这种热点问题,一致性 hash 算法引入了虚拟节点机制,即对每一个节点计算多个 hash,每个计算结果位置都放置一个虚拟节点。这样就实现了数据的均匀分布,负载均衡。ps:缓存热点问题解决方案问题描述:同一时间访问同一个缓存key的请求数量过高,导致某台特定的redis服务器压力过大,而其他的redis服务器没有分担到压力。举例说明:店铺活动查询的时候缓存key为店铺编码,value为店铺能够参加的活动编码信息,某个时候店铺搞活动瞬时redis访问命令数飙升,热点key 所在的redis服务器压力瞬间飙升。解决方案:热点数据推送到jvm内存,内存有则直接访问内存,内存不存在再去访问缓存加随机数,将一份redis缓存数据通过key后面加随机数的方式生成多份分别分散到不同的redis服务器上,访问的时候随机访问其中的一份。redis cluster 的 hash slot 算法基本模型:记录和物理机之间引入了虚拟桶层,记录通过hash函数映射到虚拟桶,记录和虚拟桶是多对一的关系;第二层是虚拟桶和物理机之间的映射,同样也是多对一的关系,即一个物理机对应多个虚拟桶,这个层关系是通过内存表实现的。redis cluster 有固定的 16384 个 hash slot,对每个 key 进行CRC16检验 ,然后对 16384 取模,可以获取 key 对应的 hash slot。redis cluster 中每个 master 都会持有部分 slot,比如有 3 个 master,那么可能每个 master 持有 5000 多个 hash slot。hash slot 让 node 的增加和移除很简单,增加一个 master,就将其他 master 的 hash slot 移动部分过去,减少一个 master,就将它的 hash slot 移动到其他 master 上去(不会造成节点阻塞)。客户端的 api,可以对指定的数据,让他们走同一个 hash slot,通过 hash tag 来实现。任何一台机器宕机,另外两个节点,不影响的。因为 key 找的是 hash slot,不是机器。redis cluster 的高可用与主备切换原理redis cluster 的高可用的原理,几乎跟哨兵是类似的。如果一个节点认为另外一个节点宕机,那么就是 pfail,主观宕机。如果多个节点都认为另外一个节点宕机了,那么就是 fail,客观宕机,跟哨兵的原理几乎一样,sdown,odown。在 cluster-node-timeout 内,某个节点一直没有返回 pong,那么就被认为 pfail。如果一个节点认为某个节点 pfail 了,那么会在 gossip ping 消息中,ping 给其他节点,如果超过半数的节点都认为 pfail 了,那么就会变成 fail。对宕机的 master node,从其所有的 slave node 中,选择一个切换成 master node。每个从节点,都根据自己对 master 复制数据的 offset(偏移量),来设置一个选举时间,offset 越大(复制数据越多)的从节点,选举时间越靠前,优先进行选举。所有的 master node 开始 slave 选举投票,给要进行选举的 slave 进行投票,如果大部分 master node(N/2 + 1)都投票给了某个从节点,那么选举通过,那个从节点可以切换成 master。从节点执行主备切换,从节点切换为主节点。
写在前Redis是一种高级key-value数据库。它跟memcached类似,不过数据可以持久化,而且支持的数据类型很丰富。有字符串,链表,集合和有序集合。支持在服务器端计算集合的并,交和补集(difference)等,还支持多种排序功能。所以Redis也可以被看成是一个数据结构服务器。Redis的所有数据都是保存在内存中,然后不定期的通过异步方式保存到磁盘上(这称为“半持久化模式”);也可以把每一次数据变化都写入到一个append only file(aof)里面(这称为“全持久化模式”)。很多时候我们需要持久化数据也就是将内存中的数据写入到硬盘里面,大部分原因是为了之后重用数据(比如重启机器、机器故障之后恢复数据),或者是为了防止系统故障而将数据备份到一个远程位置。Redis 不同于 Memcached 的很重要一点就是,Redis 支持持久化,而且支持两种不同的持久化操作。redis提供两种方式进行持久化,一种是RDB持久化(原理是将Reids在内存中的数据库记录定时dump到磁盘上的RDB持久化),另外一种是AOF(append only file)持久化(原理是将Reids的操作日志以追加的方式写入文件)。Redis如何持久化?RDB方式持久化RDB持久化是指在指定的时间间隔内将内存中的数据集快照写入磁盘。实际操作过程是fork一个子进程,先将数据集写入临时文件,写入成功后,再替换之前的文件,RDB文件是一个经过压缩的二进制文件。RDB是Redis默认的持久化方式,会在对应的目录下生产一个dump.rdb文件,重启会通过加载dump.rdb文件恢复数据。优点:(1)整个redis数据库中只有一个文件dump.rdb,方便持久化;(2)性能最大化,fork子进程来完成写操作,让主进程继续处理命令,所以是IO最大化(使用单独子进程来进行持久化,主进程不会进行任何IO操作,保证了redis的高性能) ;(3)如果数据集偏大,RDB的启动效率会比AOF更高。缺点:(1)数据安全性低。(RDB是间隔一段时间进行持久化,如果持久化之间redis发生故障,会发生数据丢失。所以这种方式更适合数据要求不是特别严格的时候)(2) 由于RDB是通过fork子进程来协助完成数据持久化工作的,因此,如果当数据集较大时,可能会导致整个服务器停止服务几百毫秒,甚至是1秒钟。AOF方式持久化AOF持久化是以日志的形式记录服务器所处理的每一个写、删除操作,查询操作不会记录,以文本的方式记录,文件中可以看到详细的操作记录。她的出现是为了弥补RDB的不足(数据的不一致性),所以它采用日志的形式来记录每个写操作,并追加到文件中。Redis 重启的会根据日志文件的内容将写指令从前到后执行一次以完成数据的恢复工作。优点:(1)数据安全性更高(即数据持久性,解决了数据不一致的问题),redis.conf中的appendfysnc是对redis性能有重要影响的参数之一。可取三种值(同步策略):always、everysec和no。设置为always时,会极大消弱Redis的性能,因为这种模式下每次write后都会调用fsync(Linux为调用fdatasync)。如果设置为no,则write后不会有fsync调用,由操作系统自动调度刷磁盘,性能是最好的。everysec为最多每秒调用一次fsync(每秒同步也是异步完成的,出现宕机,这一秒数据也将丢失),这种模式性能并不是很糟糕(性能还不错),一般也不会产生毛刺,这归功于Redis引入了BIO线程,所有fsync操作都异步交给了BIO线程。(2)通过append模式写文件(保存了之前已经存在的内容),即使中途服务器宕机,也不会破坏已经存在的内容,可以在下一次Redis启动之前,通过redis-check-aof工具解决数据一致性问题。(3)如果日志过大,Redis可以自动启用rewrite机制。即Redis以append模式不断的将修改数据写入到老的磁盘文件中,同时Redis还会创建一个新的文件用于记录此期间有哪些修改命令被执行(保证数据一致性)。因此在进行rewrite切换时可以更好的保证数据安全性。缺点:(1)AOF文件比RDB文件大,且恢复速度慢;数据集大的时候,比RDB启动效率低。(2)根据同步策略的不同,AOF在运行效率上往往会慢于RDB。总之,每秒同步策略的效率是比较高的,同步禁用策略(no)的效率和RDB一样高效。二者选择的标准:,就是看系统是愿意牺牲一些性能,换取更高的缓存一致性(aof),还是愿意写操作频繁的时候,不启用备份来换取更高的性能,待手动运行save的时候,再做备份(rdb)。rdb这个就更有些 eventually consistent的意思了。总之一句话:性能优先,选rdb;安全性优先(缓存一致性),选aof。常用的配置RDB持久化配置:Redis会将数据集的快照dump到dump.rdb文件中。此外,我们也可以通过配置文件来修改Redis服务器dump快照的频率,在打开6379.conf文件之后,我们搜索save,可以看到下面的配置信息:save 900 1 #在900秒(15分钟)之后,如果至少有1个key发生变化,则dump内存快照。 save 300 10 #在300秒(5分钟)之后,如果至少有10个key发生变化,则dump内存快照。 save 60 10000 #在60秒(1分钟)之后,如果至少有10000个key发生变化,则dump内存快照。AOF持久化配置,在Redis的配置文件中存在三种同步方式,它们分别是:appendfsync always #每次有数据修改发生时都会写入AOF文件。 appendfsync everysec #每秒钟同步一次,该策略为AOF的缺省策略。 appendfsync no #从不同步。高效但是数据不会被持久化(数据不一致)。RDB 持久化的原理RDB 持久化是把当前进程数据生成快照保存到硬盘的过程,有两个Redis命令可以用于生成RDB文件, 分别对应 save 和 bgsave 命令:save:阻塞当前 Redis 服务器,直到 RDB持久化过程完成为止,对于内存比较大的实例会造成长时间阻塞,线上环境不建议使用。bgasve:Redis 进程执行 fork 操作创建子进程,RDB 持久化过程由子进程负责,完成后自动结束。阻塞只发生在 fork 阶段,一般时间很短。bgsave 是针对 save 阻塞问题做的优化,因此 Redis 内部所有涉及 RDB 的操作都采用 bgsave 的方式,而 save 方式已经废弃。bgsave 的原理?原理(执行流程):① 执行 bgsave 命令,Redis 父进程判断当前是否存在正在执行的子进程(如 RDB/AOF 子进程),如果存在 bgsave 命令直接返回。② 父进程执行 fork 操作创建子进程,fork 操作过程中父进程会阻塞。③ 父进程 fork 完成后,bgsave 命令返回并不再阻塞父进程,可以继续响应其他命令。④ 子进程创建 RDB 文件,根据父进程内存生成临时快照文件,完成后对原有文件进行原子替换。⑤ 进程发送信号给父进程表示完成,父进程更新统计信息。会不会复制多一倍内存?不会复制两倍内存。主进程fork()子进程之后,内核把主进程中所有的内存页的权限都设为read-only,然后子进程的地址空间指向主进程。这也就是共享了主进程的内存,当其中某个进程写内存时(这里肯定是主进程写,因为子进程只负责rdb文件持久化工作,不参与客户端的请求),CPU硬件检测到内存页是read-only的,于是触发页异常中断(page-fault),陷入内核的一个中断例程。中断例程中,内核就会把触发的异常的页复制一份(这里仅仅复制异常页,也就是所修改的那个数据页,而不是内存中的全部数据),于是主子进程各自持有独立的一份。一句话总结,子进程共享主进程的物理空间,当主子进程有内存写入操作时,read-only内存页发生中断,将触发异常的内存页复制一份(其余的页还是共享主进程的)。AOF 持久化的原理AOF 持久化以独立日志的方式记录每次写命令,重启时再重新执行 AOF 文件中的命令达到恢复数据的目的。AOF 的主要作用是解决了数据持久化的实时性,目前是 Redis 持久化的主流方式。开启 AOF 功能需要设置:appendonly yes,默认不开启。保存路径同 RDB 方式一致,通过 dir 配置指定。AOF 的工作流程操作(命令写入 append -> 文件同步 sync -> 文件重写 rewrite -> 重启加载 load):① 所有的写入命令会追加到 aof_buf 缓冲区中。② AOF 缓冲区根据对应的同步策略向硬盘做同步操作。③ 随着 AOF 文件越来越大,需要定期对 AOF 文件进行重写rewrite,达到压缩的目的。④ 当服务器重启时,可以加载 AOF 文件进行数据恢复AOF 命令写入的原理?AOF 命令写入的内容直接是文本协议格式,采用文本协议格式的原因:文本协议具有很好的兼容性。开启 AOF 后所有写入命令都包含追加操作,直接采用协议格式避免了二次处理开销。文本协议具有可读性,方便直接修改和处理。AOF 把命令追加到缓冲区的原因:Redis 使用单线程响应命令,如果每次写 AOF 文件命令都直接追加到硬盘,那么性能完全取决于当前硬盘负载。先写入缓冲区中还有另一个好处,Redis 可以提供多种缓冲区同步硬盘策略,在性能和安全性方面做出平衡。为什么Redis先执行指令,再记录AOF日志,而不是像其它存储引擎一样反过来呢?在常见的数据库中,持久化重做日志一般是先写日志再修改数据库,保证数据/操作不会丢失。所以看到redis的AOF日志的机制后,很困惑原因:由于AOF文件会比较大,为了避免写入无效指令(错误指令),必须先做指令检查?如何检查,只能先执行了。因为语法级别检查并不能保证指令的有效性,比如删除一个不存在的key。而MySQL这种是因为它本身就维护了所有的表的信息,所以可以语法检查后过滤掉大部分无效指令直接记录日志,然后再执行。AOF 文件重写的原理?文件重写是把 Redis 进程内的数据转化为写命令同步到新 AOF 文件的过程,这个新的AOF⽂件和原有的AOF⽂件所保存的数据库状态⼀样,但体积更⼩。可以降低文件占用空间,更小的文件可以更快地被加载。重写后 AOF 文件变小的原因:丢弃超时的数据:进程内已经超时的数据不再写入文件。无效的AOF命令:旧的 AOF 文件含有无效命令,重写使用进程内数据直接生成,这样新的 AOF 文件只保留最终数据写入命令。写命令合并:多条写命令可以合并为一个,为了防止单条命令过大造成客户端缓冲区溢出,对于 list、set、hash、zset 等类型操作,以 64 个元素为界拆分为多条。重写流程:① 执行 AOF 重写请求,如果当前进程正在执行 AOF 重写,请求不执行并返回,如果当前进程正在执行 bgsave 操作,重写命令延迟到 bgsave 完成之后再执行。② 父进程执行 fork 创建子进程,开销等同于 bgsave 过程。③ 父进程 fork 操作完成后继续响应其他命令,所有修改命令依然写入 AOF 缓冲区并同步到硬盘,保证原有 AOF 机制正确性。④ 子进程根据内存快照,按命令合并规则写入到新的 AOF 文件。每次批量写入数据量默认为 32 MB,防止单次刷盘数据过多造成阻塞。⑤ 新 AOF 文件写入完成后,子进程发送信号给父进程,父进程更新统计信息。⑥ 父进程把 AOF 重写缓冲区的数据写入到新的 AOF 文件并替换旧文件,完成重写。redis 重启加载的顺序?AOF 和 RDB 文件都可以用于服务器重启时的数据恢复。Redis 持久化文件的加载流程:① AOF 持久化开启且存在 AOF 文件时,即优先加载 AOF文件。因为AOF中数据全,性能影响小。② AOF 关闭时且存在 RDB 文件时,加载RDB 文件。③ 加载 AOF/RDB 文件成功后,Redis启动成功。④ AOF/RDB 文件存在错误导致加载失败时,Redis启动失败并打印错误信息。注意:经过测试,如果 aof不存在(手动删除aof文件),貌似会创建一个新的 空的 aof文件,并没有去用 rdb文件这是咋回事?加载的顺序,取决于你的配置文件是否开启了aof,而不是说去判断aof文件是否存在!!所以配置很重要!!流程图如下:redis加载顺序拓展:Redis 4.0 对于持久化机制的优化Redis 4.0 开始支持 RDB 和 AOF 的混合持久化(默认关闭,可以通过配置项 aof-use-rdb-preamble 开启)。如果把混合持久化打开,AOF 重写的时候就直接把 RDB 的内容写到 AOF 文件开头。这样做的好处是可以结合 RDB 和 AOF 的优点: 快速加载同时避免丢失过多的数据。当然缺点也是有的, AOF 里面的 RDB 部分是压缩格式不再是 AOF 格式,可读性较差。应用场景对比rdb适合大规模的数据恢复,由于rdb是以快照的形式持久化数据,恢复的数据快,在一定的时间备份一次,而aof的保证数据更加完整,损失的数据只在秒内。具体哪种更适合生产,在官方的建议中两种持久化机制同时开启,如果两种机制同时开启,优先使用aof持久化机制。
1.x的平方根(69 - 易)题目描述:实现 int sqrt(int x) 函数。计算并返回 x 的平方根,其中 x 是非负整数。由于返回类型是整数,结果只保留整数的部分,小数部分将被舍去。示例 :输入: 8 输出: 2 说明: 8 的平方根是 2.82842..., 由于返回类型是整数,小数部分将被舍去。思路:本题中x取值为非负整数,显然平方根介于0~x/2之间,那么我们可以使用二分去查找这个整数(题目要求返回值是整数)。代码实现比较简单,但是这里注意几个细节:循环退出条件l == r,这样我们最终不用纠结返回l还是r。二分中mid = l + (r - l + 1) / 2,这里为什么加1,分析原因:在区间只有 2 个数的时候,根据 if、else 的逻辑区间的划分方式是:[left..mid - 1] 与 [mid..right]。如果 mid 下取整,在区间只有 22个数的时候有 mid = left,一旦进入分支 [mid..right] 区间不会再缩小,发生死循环。解决办法:把取中间数的方式改成上取整。代码实现:public int mySqrt(int x) { if (x <= 1) return x; int l = 0, r = x / 2; while (l < r) { int mid = l + (r - l + 1) / 2; if (mid > x / mid) { r = mid - 1; } else { l = mid; } } return l; }2.Excel表列名称(168 - 易)题目描述:给定一个正整数,返回它在 Excel 表中相对应的列名称。示例 :1 -> A 2 -> B 3 -> C ... 26 -> Z 27 -> AA 28 -> AB ...思路:本题考察点进制转化:十进制转二十六进制每确定一个字母(二十六位),十进制除以二十六。怎么确定这个字母呢,十进制模二十六(控制在0~25)。注意:这里的拼接方式为前插(insert(索引,前插元素))模结果等于0需要特判,如果是0,证明字母是Z,根据循环条件(n > 0), 所以n - 1的目的是保证得到Z后退出循环。比如n = 26,如果不减1,最后结果为AZ,而不是Z代码实现:public String convertToTitle(int n) { StringBuilder sb = new StringBuilder(); while (n > 0) { int c = n % 26; if (c == 0) { c = 26; n--; } sb.insert(0, (char)('A' + c - 1)); n /= 26; } return sb.toString(); }3.分数转小数(166 - 中)题目描述:给定两个整数(可正可负),分别表示分数的分子 numerator 和分母 denominator,以 字符串形式返回小数 。如果小数部分为循环小数,则将循环的部分括在括号内。如果存在多个答案,只需返回 任意一个 。对于所有给定的输入,保证 答案字符串的长度小于 10^4 。示例 :输入:numerator = 1, denominator = 2 输出:"0.5"思路:本题也是细节题,主要需要解决三个问题:负数情况:先判断结果的正负(异或,相同为0,不同为1)整除情况:直接通过余数判断循环如何判断?开始循环的时候,说明之前已经出现这个余数,我们只需要记录位置,插入括号即可。这里需要有长除法的基本知识,循环核心思想:当余数出现循环的时候,对应的商也会循环。这里使用hashmap记录余数和第一次出现的索引。注意:需要将所有的数都转成long型,避免结果越界!代码实现:public String fractionToDecimal(int numerator, int denominator) { if (numerator == 0) return "0"; StringBuilder sb = new StringBuilder(); if (numerator < 0 ^ denominator < 0) { sb.append("-"); } // Convert to Long or else abs(-2147483648) overflows long dividend = Math.abs(Long.valueOf(numerator)); long divisor = Math.abs(Long.valueOf(denominator)); sb.append(dividend / divisor); long reminder = dividend % divisor; if (reminder == 0) { return sb.toString(); } sb.append("."); Map<Long, Integer> map = new HashMap<>(); while (reminder != 0) { if (map.containsKey(reminder)) { sb.insert(map.get(reminder), "("); sb.append(")"); break; } map.put(reminder, sb.length()); reminder *= 10; sb.append(String.valueOf(reminder / divisor)); reminder %= divisor; } return sb.toString(); }4.2的幂(166 - 中)题目描述:给你一个整数 n,请你判断该整数是否是 2 的幂次方。如果是,返回 true ;否则,返回 false 。示例 :输入:n = 1 输出:true 解释:2^0 = 1思路:朴素解很简单,看一下位运算,n为2的幂,必定满足下边两个条件:恒有 n & (n - 1) == 0,这是因为:n 二进制最高位为 1,其余所有位为 0;n - 1二进制最高位为 0,其余所有位为 1;一定满足 n > 0。代码实现:// 朴素解 public boolean isPowerOfTwo(int n) { if (n <= 0) return false; while (n % 2 == 0) { n /= 2; } return n == 1; } // 位运算 public boolean isPowerOfTwo(int n) { return n > 0 && (n & (n - 1)) == 0; }5.快乐数(202 - 易)题目描述:编写一个算法来判断一个数 n 是不是快乐数。「快乐数」定义为:对于一个正整数,每一次将该数替换为它每个位置上的数字的平方和。然后重复这个过程直到这个数变为 1,也可能是 无限循环 但始终变不到 1。如果 可以变为 1,那么这个数就是快乐数。如果 n 是快乐数就返回 true ;不是,则返回 false 。示例 :输入:19 输出:true 解释: 12 + 92 = 82 82 + 22 = 68 62 + 82 = 100 12 + 02 + 02 = 1思路:本题是一种比较经典的快慢指针问题,类似的还有T287寻找重复元素。这种类型题目标就是找快慢指针相遇的点。对于本题:如果两个指针相遇,此时检查相遇点,如果是1,找到欢乐数;如果不是1证明进入死循环。代码实现:public int squareSum(int n) { int sum = 0; while (n > 0) { int i = n % 10; sum += i * i; n /= 10; } return sum; } public boolean isHappy(int n) { int slow = n, fast = squareSum(n); while (fast != 1 && slow != fast) { slow = squareSum(slow); fast = squareSum(squareSum(fast)); } return fast == 1; }6.第 n 位数字(400 - 中)题目描述:在无限的整数序列 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, ...中找到第 n 位数字。注意:n 是正数且在 32 位整数范围内(n < 231)。示例 :输入:11 输出:0 解释:第 11 位数字在序列 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, ... 里是 0 ,它是 10 的一部分。思路:本题我们找第n位数的过程是什么?核心:通过索引定位元素定位到n所属的区间(一位数、两位数...),用digit表示,1, 2...定位到这个区间的具体一个数,用start表示该区间所有的起始点数,个位是1,十位是10...定位到这个数的具体索引,用indexCount表示该区间一共有多少个索引,公式:indexCount = digit * 9 * start;注意:while循环之后,n的值为从当前区间的第一个数start开始的第n个数(即第n - 1的索引),start + (n - 1) / digit定位到目标数,(n - 1)% digit就是原始n在这个数的第几位。例如n循环一次值为4(原始n为13),那么10, 1【1】,12;代码实现:public int findNthDigit(int n) { int digit = 1; long start = 1, indexCount = digit * 9 * start; while (n > indexCount) { n -= indexCount; digit++; start *= 10; indexCount = digit * 9 * start; } long num = start + (n - 1) / digit; int remainder = (n - 1) % digit; String strNum = String.valueOf(num); return (int)(strNum.charAt(remainder) - '0'); }7.Pow(x, n)(50 - 中)题目描述:实现Pow(x, n),x的n次幂。示例 :输入:x = 2.00000, n = 10 输出:1024.00000思路:本题考查的核心是快速幂算法。类似x^4 = (x2)2,这时幂次N = N/2。对于奇数情况需要特殊判断依次,即幂次为奇数情况。注意:判断幂次N的正负。代码实现:public double myPow(double x, int n) { long N = n; return N >= 0 ? iterate(x, N) : 1.0 / iterate(x, -N); } private double iterate(double x, long N) { double ans = 1.0; // 初始贡献值 double c = x; while (N > 0) { if (N % 2 == 1) { ans *= c; } c *= c; N /= 2; } return ans; }8.阶乘后0的数量(172 - 易)题目描述:给定一个整数 n,返回 n! 结果尾数中零的数量。要求: 时间复杂度应为 O(log n) 。示例 :输入: 5 输出: 1 解释: 5! = 120, 尾数中有 1 个零.思路:本题考查数学问题,转化一下,就是找2和5的对数(相乘结果为0),因为2的因数是每两个出现一次,5的因数是每5个出现一次,所以本题我们只需要找5的因数的个数即可。注意:每隔5*5个数还会出现一个5的因数,每隔5*5*5个数也会出现一个5的因数...,这样5的因数的数量就变成了n / 5 + n / 25 + ...,为了避免分母溢出,我们可以在每次循环让n/5(同样的效果)。代码实现:时间复杂度(logN)public int trailingZeroes(int n) { int count = 0; while (n > 0) { count += n / 5; n /= 5; } return count; }
1.螺旋矩阵(54-中)题目描述:给你一个 m 行 n 列的矩阵 matrix ,请按照 顺时针螺旋顺序 ,返回矩阵中的所有元素。示例:输入:matrix = [[1,2,3],[4,5,6],[7,8,9]] 输出:[1,2,3,6,9,8,7,4,5]思路:直接模拟按层填充,定义左右上下边界,循环遍历矩阵。注意:如果是方阵,就可以直接退出循环,但是,如果存在l > r || t > b,则证明最后一次不是圈,是一行或者一列,这时我们可以直接终止循环。代码:public List<Integer> spiralOrder(int[][] matrix) { List<Integer> ans = new ArrayList<>(); int m = matrix.length, n = matrix[0].length; int l = 0, r = n - 1, t = 0, b = m - 1; while (l <= r && t <= b) { for (int j = l; j <= r; ++j) { ans.add(matrix[t][j]); } t++; for (int i = t; i <= b; ++i) { ans.add(matrix[i][r]); } r--; if (l > r || t > b) { break; } for (int j = r; j >= l; --j) { ans.add(matrix[b][j]); } b--; for (int i = b; i >= t; --i) { ans.add(matrix[i][l]); } l++; } return ans; }2.螺旋矩阵II(59-中)题目描述:生成1-n^2的所有元素,且元素按顺时针螺旋排列的正方形矩阵。示例:输入: 3 输出: [ [ 1, 2, 3 ], [ 8, 9, 4 ], [ 7, 6, 5 ] ]思路:与上题思路相同,区别是退出循环机制,采用num < tar,这样避免了判断奇偶的情况。代码:public int[][] generateMatrix(int n) { int l = 0, r = n - 1, t = 0, b = n - 1; int[][] matrix = new int[n][n]; int num = 1; while (l <= r && t <= b) { for (int j = l; j <= r; ++j) { matrix[t][j] = num++; } t++; for (int i = t; i <= b; ++i) { matrix[i][r] = num++; } r--; for (int j = r; j >= l; --j) { matrix[b][j] = num++; } b--; for (int i = b; i >= t; --i) { matrix[i][l] = num++; } l++; } return matrix; }3.对角线打印矩阵(498-中)题目描述:给定一个含有 M x N 个元素的矩阵(M 行,N 列),请以对角线遍历的顺序返回这个矩阵中的所有元素,对角线遍历如下图所示。示例:输入: [ [ 1, 2, 3 ], [ 4, 5, 6 ], [ 7, 8, 9 ] ] 输出: [1,2,4,7,5,3,6,8,9]思路:两种方向的变更,右上到左下,依次交换方向,将结果加入数组。流程如下:确定循环次数,即方向变更的次数 m + n - 1从0开始,偶数次数为右上,奇数次数为右下(通过取余实现)最后确定两个方向具体实现,比如右上方向,设横坐标x, 纵坐标y,一般变化(x--, y++),但是注意边界处理,即上图中1 - 2, 3 - 6,对应不同的边界处理。左下方向类似。具体见代码代码:class Solution { public int[] findDiagonalOrder(int[][] mat) { int rowLength = mat.length; int colLength = mat[0].length; int[] ans = new int[rowLength * colLength]; int loop = rowLength + colLength - 1; int x = 0; int y = 0; int index = 0; for (int i = 0; i < loop; i++) { if (i % 2 == 0) { while (x >= 0 && y < colLength) { ans[index++] = mat[x][y]; x--; y++; } if (y < colLength) { // 对应1-2边界 x++; } else { // 对应3-6边界,ps:需要将上一步while中的x和y位置进行修正(x加1, y不变) x += 2; y--; } } else { while (x < rowLength && y >= 0) { ans[index++] = mat[x][y]; x++; y--; } if (x < rowLength) { // 对应4-7边界 y++; } else { // 对应8-9边界 x--; y += 2; } } } return ans; } }4.旋转图像(48-中)题目描述:给定一个 n × n 的二维矩阵 matrix 表示一个图像。请你将图像顺时针旋转 90 度。你必须在 原地 旋转图像,这意味着你需要直接修改输入的二维矩阵。请不要 使用另一个矩阵来旋转图像。示例:输入:matrix = [[1,2,3],[4,5,6],[7,8,9]] 输出:[[7,4,1],[8,5,2],[9,6,3]]思路:法1:直接模拟旋转,可以看成一圈一圈的移动。注意:内层循环,为了避免奇数情况,每一圈可能有一个不移动,我们对n进行加1,可以自行举例n = 2 和 n = 3的情况。法2:先水平翻转,然后主对角线翻转。代码:// 代码1 public void rotate(int[][] matrix) { int n = matrix.length; for (int i = 0; i < n / 2; i++) { for (int j = 0; j < (n + 1) / 2; j++) { int tmp = matrix[i][j]; matrix[i][j] = matrix[n - j - 1][i]; matrix[n - j - 1][i] = matrix[n - i - 1][n - j - 1]; matrix[n - i - 1][n - j - 1] = matrix[j][n - i - 1]; matrix[j][n - i - 1] = tmp; } } } // 代码2 public void rotate(int[][] matrix) { int n = matrix.length; // 水平翻转 for (int i = 0; i < n / 2; i++) { for (int j = 0; j < n; j++) { int temp = matrix[i][j]; matrix[i][j] = matrix[n - i - 1][j]; matrix[n - i - 1][j] = temp; } } // 主对角线翻转 for (int i = 0; i < n; i++) { for (int j = 0; j < i; j++) { int temp = matrix[i][j]; matrix[i][j] = matrix[j][i]; matrix[j][i] = temp; } } }5.搜索二维矩阵(74-中)题目描述:编写一个高效的算法来判断 m x n 矩阵中,是否存在一个目标值。该矩阵具有如下特性:每行中的整数从左到右按升序排列。每行的第一个整数大于前一行的最后一个整数。示例:输入:matrix = [[1,3,5,7],[10,11,16,20],[23,30,34,60]], target = 3 输出:true思路:本题关键解题关键是矩阵有序,我们可以从右上角或者左下角出发,缩小数据搜索范围,遍历寻找目标值。以右上角为例,当前遍历左边的元素比当前值小,当前遍历下边的元素比当前值大。最优解为二分查找:本题中行尾和行首连接,也具有单调性,故可将二维矩阵转成一维矩阵去做。代码:// 直接搜索,时间复杂度:O(m + n) public boolean searchMatrix(int[][] matrix, int target) { int i = 0, j = matrix[0].length - 1; while (i < matrix.length && j > -1) { if (matrix[i][j] == target) return true; else if (matrix[i][j] > target) { j--; } else { i++; } } return false; } // 二分查找,时间复杂度:O(log(mn)) public boolean searchMatrix(int[][] matrix, int target) { int m = matrix.length, n = matrix[0].length; int i = 0, j = m * n - 1; while (i < j) { int mid = i + ((j - i) >> 1); if (matrix[mid / n][mid % n] < target) { i = mid + 1; } else { j = mid; } } return matrix[i / n][i % n] == target; }6.搜索二维矩阵II(240-中)题目描述:在有序矩阵中寻找指定数值。每行的元素从左到右升序排列。每列的元素从上到下升序排列。示例:输入:matrix = [[1,4,7,11,15],[2,5,8,12,19],[3,6,9,16,22],[10,13,14,17,24],[18,21,23,26,30]], target = 5 输出:true思路:直接搜索基本思路同上。但是二分查找则不同,上题我们首先在第一列小于目标值的第一个数,然后再查找目标行,但是由于本题数据排布整体不是严格单调的!利用有序的性质,我们可以一行一行的进行二分查找(也可以一列一列的)当某一行的第一个元素都大于target了,那么当前行和之后的行都不用考虑了如果当前行的最后一个元素小于target,当前行排除,直接进入下一行。代码:// 直接搜索,时间复杂度:O(m + n) public boolean searchMatrix(int[][] matrix, int target) { int i = 0, j = matrix[0].length - 1; while (i < matrix.length && j > -1) { if (matrix[i][j] == target) return true; else if (matrix[i][j] > target) { j--; } else { i++; } } return false; } // 二分查找,时间复杂度:O(mlogn) public boolean searchMatrix(int[][] matrix, int target) { int m = matrix.length, n = matrix[0].length; for (int i = 0; i < m; ++i) { if (matrix[i][0] > target) { break; } if (matrix[i][n - 1] < target) { continue; } int col = binarySearch(matrix[i], target); if (col != -1) { return true; } } return false; } // 二分查找目标值的索引(类似源码) public int binarySearch(int[] arr, int target) { int i = 0, j = arr.length - 1; while (i <= j) { int mid = (i + j) >>> 1; if (arr[mid] == target) { return mid; } else if (arr[mid] < target) { i = mid + 1; } else { j = mid - 1; } } return -1; }7.矩阵置零(73-中)题目描述:给定一个 m x n 的矩阵,如果一个元素为 0 ,则将其所在行和列的所有元素都设为 0 。请使用 原地 算法(在函数输入矩阵上直接修改)。进阶:一个直观的解决方案是使用 O(mn) 的额外空间,但这并不是一个好的解决方案。一个简单的改进方案是使用 O(m + n) 的额外空间,但这仍然不是最好的解决方案。你能想出一个仅使用常量空间的解决方案吗?示例:输入:matrix = [[1,1,1],[1,0,1],[1,1,1]] 输出:[[1,0,1],[0,0,0],[1,0,1]]思路:本题的关键点:如果在输入矩阵上原地修改把行列修改成了 0,那么再遍历到 0 的时候就不知道是原本数组中的 0 还是我们修改得到的 0。所有的解法都是为了解决该问题。方案1:遍历两遍矩阵,第一遍记录哪些行哪些列有0,第二遍置0。使用两个set进行记录,空间复杂度O(m + n)方案2:核心:将第一行和第一列作为标志位,为了避免是由于第一行和第一列本来就有0造成的置0,我们需要对第一行第一列单独进行判断,只需要加两个boolean类型变量进行判断即可。对于方案2优化,直接使用一个标志位,简化代码,但是不如两个标志位好理解。你可能会说一个不是造成了覆盖吗(原先第一个行某个位置不是0,现在是0)。假设该列没有0,那么第一行对应位置一定不是0,如果是0,那么可能是本身,也可能是下边0导致修改,如何区别呢?正序置0我们没有办法区别,逆序置0即使是覆盖,那么该位置最终也一定是0。代码:// 空间复杂度:O(m + n) public void setZeroes(int[][] matrix) { int m = matrix.length, n = matrix[0].length; Set<Integer> rowZero = new HashSet<>(); Set<Integer> colZero = new HashSet<>(); for (int i = 0; i < m; ++i) { for (int j = 0; j < n; ++j) { if (matrix[i][j] == 0) { rowZero.add(i); colZero.add(j); } } } for (int i = 0; i < m; ++i) { for (int j = 0; j < n; ++j) { if (rowZero.contains(i) || colZero.contains(j)) { matrix[i][j] = 0; } } } } // 标记法,空间复杂度:O(1) public void setZeroes(int[][] matrix) { int m = matrix.length, n = matrix[0].length; boolean row0Flag = false; boolean col0Flag = false; for (int j = 0; j < n; ++j) { if (matrix[0][j] == 0) { row0Flag = true; break; } } for (int i = 0; i < m; ++i) { if (matrix[i][0] == 0) { col0Flag = true; break; } } // 第一行第一列作为标志位 for (int i = 1; i < m; ++i) { for (int j = 1; j < n; ++j) { if (matrix[i][j] == 0) { matrix[i][0] = matrix[0][j] = 0; } } } for (int i = 1; i < m; ++i) { for (int j = 1; j < n; ++j) { if (matrix[i][0] == 0 || matrix[0][j] == 0) { matrix[i][j] = 0; } } } if (row0Flag) { for (int j = 0; j < n; ++j) { matrix[0][j] = 0; } } if (col0Flag) { for (int i = 0; i < m; ++i) { matrix[i][0] = 0; } } } // 代码优化:一个标志 public void setZeroes(int[][] matrix) { int m = matrix.length, n = matrix[0].length; boolean col0Flag = false; for (int i = 0; i < m; ++i) { if (matrix[i][0] == 0) col0Flag = true; for (int j = 1; j < n; ++j) { if (matrix[i][j] == 0) { matrix[i][0] = matrix[0][j] = 0; } } } for (int i = m - 1; i >= 0; --i) { for (int j = n - 1; j >= 1; --j) { if (matrix[i][0] == 0 || matrix[0][j] == 0) { matrix[i][j] = 0; } } if (col0Flag) matrix[i][0] = 0; } }
集合遍历的四种方式?创建一个List集合:public static void main(String[] args) { List<String> listNames = new ArrayList<>(); listNames.add("qiuqiu"); listNames.add("kaka"); listNames.add("beibei"); listNames.add("hutu"); listNames.add("wangzai"); }这个list包含我们小区的所有小狗的名字。注意在语句的右边<>的使用ArrayList<>();这个语法从Java7开始使用,允许我们以一种更严谨的方式声明泛型的集合,因为编译器可以从左边推测出右边的参数类型(因此叫做“类型引用”)经典for循环这种迭代方法在编程中非常熟悉,其中计数器变量从集合中的第一个元素运行到最后一个元素for (int i = 0; i < listNames.size(); i++) { String name = listNames.get(i); System.out.println(name); }pros:这是编程中最熟悉的构造如果我们需要访问并使用计数器变量,比如打印小狗狗们的的数字顺序:1,2,3……cons:使用计数器变量要求集合必须以基于索引的形式(如ArrayList)存储元素,并且我们必须提前知道集合的大小迭代的方式由于经典循环方式的限制,创建了使用迭代器的方式,这种方式允许我们迭代各种集合。因此你可以看到Collection接口定义了每个集合必须实现iterator()方法,需要先创建迭代器对象。在List上用迭代器遍历:Iterator<String> itr = listNames.iterator(); while (itr.hasNext()) { String name = itr.next(); System.out.println(name); }在Set上用迭代器遍历:Set<String> set = new HashSet<>(); set.add("a"); set.add("b"); set.add("c"); set.add("d"); Iterator<String> itr = set.iterator(); while (itr.hasNext()) { String letter = itr.next(); System.out.println(letter); }在Map上用迭代器遍历(迭代keySet()):Map<String, Integer> grade = new HashMap<>(); grade.put("Operating System", 90); grade.put("Computer Network", 92); grade.put("Software Engineering", 90); grade.put("Oracle", 90); Iterator<String> itr = grade.keySet().iterator(); while (itr.hasNext()) { String key = itr.next(); Integer value = grade.get(key); System.out.println(key + "=>" + value); }加强for循环从Java 5开始,程序员可以使用一种更简洁的语法来遍历集合-加强for循环。for (String s : listNames) { System.out.println(s); }注意:加强for循环实际上在背后使用的是迭代器。这意味着编译时Java编译器会将增强型for循环语法转换为迭代器构造。 新的语法为程序员提供了一种更方便的迭代集合的方式。使用Lambda表达式的forEachJava 8引入了Lambda表达式,介绍了一种遍历集合的全新方式-forEach方法listNames.forEach(name -> System.out.println(name));forEach方法与之前的方法最大的区别是什么?在之前的方法中(经典for循环,迭代器和加强for循环),程序员可以控制集合是如何迭代的。迭代代码不是集合本身的一部分,它是由程序员编写的,因此称为外部迭代。相比之下,新方法将迭代代码封装在集合本身中,因此程序员不必为迭代集合编写代码。 相反,程序员会在每次迭代中指定要做什么 - 这是最大的区别!因此术语forEach内部迭代:集合处理迭代本身,而程序员传递动作 - 即每次迭代需要做的事情。
并发读写数据一致性保证(Java并发容器)写在前业务开发过程,其实就是用户业务数据的处理过程,因而开发的核心任务就是维护数据一致不出错。现实场景中,多个用户会并发读写同一份数据(如秒杀),不加控制会翻车、加了控制则降低并发度,影响性能和用户体验。如何优雅的进行并发数据控制呢?本质上需要解决两个问题:读-写冲突写-写冲突让我们看下Java经典的并发容器CopyOnWriteList以及ConcurrentHashMap是如何协调这两个问题的CopyOnWriteList读写策略CopyOnWrite顾名思义即写时复制策略针对写处理,首先加ReentrantLock锁,然后复制出一份数据副本,对副本进行更改之后,再将数据引用替换为副本数据,完成后释放锁针对读处理,依赖volatile提供的语义保证,每次读都能读到最新的数组引用读-写冲突显然,CopyOnWriteList采用读写分离的思想解决并发读写的冲突当读操作与写操作同时发生时:如果写操作未完成引用替换,这时读操作处理的是原数组而写操作处理的数组副本,互不干扰如果写操作已完成引用替换,这时读操作与写操作处理的都是同一个数组引用可见在读写分离的设计下,并发读写过程中,读不一定能实时看到最新的数据,也就是所谓的弱一致性。也正是由于牺牲了强一致性,可以让读操作无锁化,支撑高并发读写-写冲突当多个写操作的同时发生时,先拿到锁的先执行,其他线程只能阻塞等到锁的释放简单粗暴又行之有效,但并发性能相对较差ConcurrentHashMap(JDK7)读写策略主要采用分段锁的思想,降低同时操作一份数据的概率针对读操作:先在数组中定位Segment并利用UNSAFE.getObjectVolatile原子读语义获取Segment接着在数组中定位HashEntry并利用UNSAFE.getObjectVolatile原子读语义获取HashEntry然后依赖final不变的next指针遍历链表找到对应的volatile值针对写操作:先在数组中定位Segment并利用UNSAFE.getObjectVolatile原子读语义获取Segment然后尝试加锁ReentrantLock接着在数组中定位HashEntry并利用UNSAFE.getObjectVolatile原子读语义获取HashEntry链表头节点遍历链表,若找到已存在的key,则利用UNSAFE.putOrderedObject原子写新值,若找不到,则创建一个新的节点,插入到链表头,同时利用UNSAFE.putOrderedObject原子更新链表头完成操作后释放锁读-写冲突若并发读写的数据不位于同一个Segment,操作是相互独立的若位于同一个Segment,ConcurrentHashMap利用了很多Java特性来解决读写冲突,使得很多读操作都无锁化当读操作与写操作同时发生时:若PUT的KEY已存在,直接更新原有的value,此时读操作在volatile的保证下可以读到最新值,无需加锁若PUT的key不存在增加一个节点,或删除一个节点时,会改变原有的链表结构,注意到HashEntry的每个next指针都是final的,因此得复制链表,在更新HashEntry数组元素(即链表头节点)的时候又是通过UNSAFE提供的语义保证来完成更新的,若新链表更新前发生读操作,此时还是获取原有的链表,无需加锁,但是数据不是最新的可见,支持无锁并发读操作还是弱一致的写-写冲突若并发写操作的数据不位于同一个Segment,操作是相互独立的若位于同一个Segment,多个线程还是由于加ReentrantLock锁导致阻塞等待ConcurrentHashMap(JDK8)读写策略与JDK7相比,少了Segment分段锁这一层,直接操作Node数组(链表头数组),后面称为桶针对读操作,通过UNSAFE.getObjectVolatile原子读语义获取最新的value针对写操作,由于采用懒惰加载的方式,刚初始化时只确定桶的数量,并没有初始默认值。当需要put值的时候先定位下标,然后该下标下桶的值是否为null,如果是,则通过UNSAFE.comepareAndSwapObject(CAS)赋值,如果不为null,则加Synchronized锁,找到对应的链表/红黑树的节点value进行更改,后释放锁读-写冲突若并发读写的数据不位于同一个桶(Node数组),则相互独立互不干扰若位于同一个桶,与JDK7的版本相比,简单了许多,但还是基于Java的特性使得许多读操作无锁化当读操作与写操作同时发生时:若PUT的key已经存在,则直接更新值,此时读操作在volatile的保证下可以获取最新值若PUT的key不存在,会新建一个节点 或 删除一个节点的时候,会改变对原有的结构,这时next指针是volatile的,直接插入到链表尾(超过一定长度变成红黑树)等对结构的修改,此时读操作也是可以获取到最新的next因此只要写操作happens-before读操作,volatile语义就可以保证读的数据是最新的,可以说JDK8版本的ConcurrentHashMap是强一致的(此处只关注基本读写(GET/PUT),可能会有弱一致的场景遗漏,例如扩容操作,不过应该是全局加锁的,如有错误烦请指出,共同学习)写-写冲突若并发读写的数据不位于同一个桶,则相互独立互不干扰若位于同一个桶,注意到写操作在不同的场景下采取不同的策略,CAS或Synchronized当多个写操作同时发生时,若桶为null,则CAS应对并发写,当第一个写操作赋值成功后,后面的写线程CAS失败,转为竞争Synchronized锁,阻塞等待小结为什么这么设计(个人观点)对数据进行存储必然涉及数据结构的设计,任何对数据的操作都得基于数据结构,常规思路是对整个数据结构加锁,但是锁的存在会大大影响性能,所以接下来的任务,就是找到哪些可以无锁化的操作。操作主要分为两大类,读和写。先看写,因为涉及到原有数据的改动,不加控制肯定会翻车,怎么控制呢?写操作也分两种,一种会改变结构,一种不会:对于会改变结构的写,不管底层是数组还是链表,由于改动得基于原有的结构,必然得加锁串行化保证原子操作,优化的点就是锁层面的优化,例如最开始HashTable等synchronized锁到ConcurrentHashMap1.7版本的ReentrantLock锁,再到1.8版本的Synchronized改良锁 。或者数据分散化,concurrnethashmap等基于hash的数据结构比CopyOnWriteList的数据结构就多了桶分散的优势。对于不会改变结构的写,或者改动的频率不大(桶扩容频率低),由于锁的开销实在是太大了,CAS是个不错的思路。为什么CopyOnWriteList不用CAS来控制并发写,我个人觉得主要原因还是因为结构变化频繁,可以看下ActomicReferenceArray等基于CAS的数组容器,都是创建后就不允许结构发生变化的。确保数据不会改错之后,读相对就好办了,主要考虑是不是要实时读最新的数据(等待写操作完成),也就是强一致还是弱一致的问题:强一致的话,读就得等写完成,读写竞争同一把锁,这就相互影响了读写的效率。大多数场景下,读的数据一致性要求没有写的要求高,可以读错,但是坚决不可以写错。要是在读的这一刻,数据还没改完,读到旧数据也没关系,只要最后写完对读可见即可。还好JMM(Java内存模型)有个volatile可见性的语义,可以保证不加锁的情况下,读也能看到写更改的数据。此外还有UNSAFE包的各种内存直接操作,也可相对高性能的完成可见性语义,对读操作而言,最好的数据,就是不变的数据,不用担心被修改引发的各种问题。唯一的不变是变化,一些数据还是有变化的可能,如果要支持这种不变性,或者说尽量减少变化的频率,变化的部分就得在别的地方处理,也就是所谓的读写分离常见面试问题分析1.ConcurrentHashMap 和 Hashtable 的区别?ConcurrentHashMap与HashTable都可以用于多线程的环境(都是线程安全的容器),但是当Hashtable的大小增加到一定的时候,性能会急剧下降,因为迭代时需要被锁定很长的时间。因为ConcurrentHashMap引入了分割(segmentation),不论它变得多么大,仅仅需要锁定map的某个部分,而其它的线程不需要等到迭代完成才能访问map。简而言之,在迭代的过程中,ConcurrentHashMap仅仅锁定map的某个部分,而Hashtable则会锁定整个map。(1)底层数据结构:JDK1.7的ConcurrentHashMap 底层采用分段的数组+链表实现,JDK1.8 采用的数据结构跟HashMap1.8的结构一样,Node数组+链表/红黑树;Hashtable和JDK1.8之前的HashMap的底层数据结构类似都是采用数组+链表的形式,数组是HashMap的主体,链表则是主要为了解决哈希冲突而存在的;(2)实现线程安全的方式(重要):在JDK1.7的时候,ConcurrentHashMap(分段锁,可重入锁)对整个桶数组进行了分割分段(Segment),每一把锁只锁容器其中一部分数据,多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。(默认分配16个Segment,比Hashtable效率提高16倍。) 到了 JDK1.8 的时候已经摒弃了Segment的概念,而是直接用 Node 数组+链表+红黑树的数据结构来实现,并发控制使用synchronized和CAS来操作。(JDK1.6以后 对 synchronized锁做了很多优化)整个看起来就像是优化过且线程安全的 HashMap,虽然在JDK1.8中还能看到Segment的数据结构,但是已经简化了属性,只是为了兼容旧版本;Hashtable(同一把锁) :使用 synchronized 来保证线程安全,效率非常低下。当一个线程访问同步方法时,其他线程也访问同步方法,可能会进入阻塞或轮询状态。如使用 put 添加元素,另一个线程不能使用 put 添加元素,也不能使用 get,竞争会越来越激烈效率越低。2.ConcurrentHashMap有哪些构造函数?//无参构造函数 public ConcurrentHashMap() { } //可传初始容器大小(initialCapacity)的构造函数 public ConcurrentHashMap(int initialCapacity) { if (initialCapacity < 0) throw new IllegalArgumentException(); int cap = ((initialCapacity >= (MAXIMUM_CAPACITY >>> 1)) ? MAXIMUM_CAPACITY : tableSizeFor(initialCapacity + (initialCapacity >>> 1) + 1)); this.sizeCtl = cap; } //可传入map的构造函数 public ConcurrentHashMap(Map<? extends K, ? extends V> m) { this.sizeCtl = DEFAULT_CAPACITY; putAll(m); } //可设置阈值(加载因子*容量)和初始容量 public ConcurrentHashMap(int initialCapacity, float loadFactor) { this(initialCapacity, loadFactor, 1); } //可设置初始容量和阈值和并发级别(concurrencyLevel) public ConcurrentHashMap(int initialCapacity, float loadFactor, int concurrencyLevel) { if (!(loadFactor > 0.0f) || initialCapacity < 0 || concurrencyLevel <= 0) throw new IllegalArgumentException(); if (initialCapacity < concurrencyLevel) // Use at least as many bins initialCapacity = concurrencyLevel; // as estimated threads long size = (long)(1.0 + (long)initialCapacity / loadFactor); int cap = (size >= (long)MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : tableSizeFor((int)size); this.sizeCtl = cap; }3.ConcurrentHashMap 底层具体实现知道吗?实现原理是什么?(怎么保证线程安全的?)JDK1.7在JDK1.7中ConcurrentHashMap采用了数组+Segment+分段锁的方式实现。Segment(分段锁):ConcurrentHashMap中的分段锁称为Segment,它即类似于HashMap的结构,即内部拥有一个Entry数组,数组中的每个元素又是一个链表,同时又是一个ReentrantLock(Segment继承了ReentrantLock)。内部结构:ConcurrentHashMap使用分段锁技术,将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问,能够实现真正的并发访问。如下图是ConcurrentHashMap的内部结构图:从上面的结构我们可以了解到,ConcurrentHashMap定位一个元素的过程需要进行两次Hash操作。第一次Hash定位到Segment,第二次Hash定位到元素所在的链表的头部。JDK1.8ConcurrentHashMap取消了Segment分段锁,采⽤CAS和synchronized来保证并发安全。数据结构跟HashMap1.8的结构类似,数组+链表/红黑二叉树。Java 8在链表⻓度超过一定阈值(8)时将链表(寻址时间复杂度为O(N))转换为红⿊树(寻址时间复杂度为O(log(N)))。对于put操作,如果Key对应的数组元素为null,则通过CAS操作将其设置为当前值。如果Key对应的数组元素(也即链表表头或者树的根元素)不为null,则对该元素使用synchronized关键字申请锁,然后进行操作。synchronized只锁定当前链表或红黑二叉树的首节点,这样只要hash不冲突,就不会产⽣并发,效率又提升N倍。ConcurrentHashMap的get方法不需要加锁,get方法采用了unsafe方法,来保证线程安全。ConcurrentHashMap用什么技术保证线程安全?jdk1.7:Segment分段锁+HashEntry(存储键值对数据)来进行实现的;jdk1.8:放弃了Segment臃肿的设计,采用Node+CAS+Synchronized来保证线程安全;4.ConcurrentHashMap迭代器是强一致性还是弱一致性?HashMap呢?ConcurrentHashMap能完全替代HashTable吗?HashTable虽然性能上不如ConcurrentHashMap,但并不能完全被取代,两者的迭代器的一致性不同的。ConcurrentHashMap弱一致性:ConcurrentHashMap可以支持在迭代过程中,当iterator被创建后集合再发生改变就不再是抛出ConcurrentModificationException,取而代之的是在改变时new新的数据从而不影响原有的数据,iterator完成后再将头指针替换为新的数据,这样iterator线程可以使用原来老的数据,而写线程(添加元素等)也可以并发的完成改变,更重要的,这保证了多个线程并发执行的连续性和扩展性,是性能提升的关键。ConcurrentHashMap的get,clear,iterator 都是弱一致性的。hashmap强一致性:HashMap则抛出了ConcurrentModificationException,因为HashMap包含一个修改计数器modCount,当你调用他的next()方法来获取下一个元素时,迭代器将会用到这个计数器。两者应用具体分析:Hashtable的任何操作都会把整个map锁住,是阻塞的。好处是总能获取最实时的更新,比如说线程A调用putAll写入大量数据,期间线程B调用get,线程B就会被阻塞,直到线程A完成putAll,因此线程B肯定能获取到线程A写入的完整数据。坏处是所有调用都要排队,效率较低。ConcurrentHashMap 是设计为非阻塞的。在更新时会局部锁住某部分数据,但不会把整个map都锁住。同步读取操作则是完全非阻塞的。好处是在保证合理的同步前提下,效率很高。坏处 是严格来说读取操作不能保证反映最近的更新。例如线程A调用putAll写入大量数据,期间线程B调用get,则只能get到目前为止已经顺利插入的部分 数据。选择哪一个,是在性能与数据一致性之间权衡。ConcurrentHashMap适用于追求性能的场景,大多数线程都只做insert/delete操作,对读取数据的一致性要求较低。5.ConcurrentHashMap如何发生ReHash?ConcurrentLevel并发级别( 实际上是Segment的实际数量 默认16) 一旦设定的话,就不会改变。ConcurrentHashMap当元素个数大于临界值的时候,就会发生扩容。但是ConcurrentHashMap与其他的HashMap不同的是,它不会对Segment 数量增大,只会增加Segment 后面的链表容量的大小。即对每个Segment 的元素进行的ReHash操作。
五种基本数据类型String、Hash、List、Set和Zset。1、String等同于java中的,Map<String,String>string 是redis里面的最基本的数据类型,一个key对应一个value。string 是二进制安全的,可以把图片和视频文件保存在String中。string的最大内存值 512M,即一个key或者value最大值是512M,官方说可以存2.5亿个key。应用场景:String是最常用的一种数据类型,普通的key/value存储都可以归为此类,如用户信息,登录信息和配置信息等;实现方式:String在redis内部存储默认就是一个字符串,被redisObject所引用,当遇到incr、decr等操作(自增自减等原子操作)时会转成数值型进行计算,此时redisObject的encoding字段为int。Redis虽然是用C语言写的,但却没有直接用C语言的字符串,而是自己实现了一套字符串。目的就是为了提升速度,提升性能。Redis构建了一个叫做简单动态字符串(Simple Dynamic String),简称SDS。struct sdshdr{ // 记录已使用长度 int len; // 记录空闲未使用的长度 int free; // 字符数组 char[] buf; };Redis的字符串也会遵守C语言的字符串的实现规则,即最后一个字符为空字符。然而这个空字符不会被计算在len里头。因为有了对字符串长度定义len, 所以在处理字符串时候不会以零值字节(\0)为字符串结尾标志.二进制安全就是输入任何字节都能正确处理, 即使包含零值字节.Redis动态扩展步骤:计算出大小是否足够开辟空间至满足所需大小开辟与已使用大小len相长度同的空闲free空间(如果len < 1M),开辟1M长度的空闲free空间(如果len >= 1M)Redis字符串的性能优势快速获取字符串长度:直接返回len避免缓冲区溢出:每次追加字符串时都会检查空间是否够用降低空间分配次数提升内存使用效率:(1)空间预分配;(2)惰性空间回收常用命令:set/get/decr/incr/mget等,具体如下;常用命令命令添加一对kvset key value添加多对kv(可覆盖)mset key value key value….添加多对kv(不可覆盖,只要有一个已存在,全部取消)msetnx key value key value….获取get value获取多对kvmget key key…删除del key在末尾追加append key value查询v的长度strlen key给数值类型的v加/减1incr/decr key给数值类型增加/减少指定大小的值incrby/decrby key value获取v的长度getrange key在指定位置添加指定值(中间默认用空格补全)setrange key offset value添加指定生命周期的kvsetex key seconds value如果不存在则添加setnx key value获取旧值,设置新值setget key valueps:计数器(字符串的内容为整数的时候可以使用),如 set number 1。补充:127.0.0.1:6379> expire key 60 # 数据在 60s 后过期 (integer) 1 127.0.0.1:6379> setex key 60 value # 数据在 60s 后过期 (setex:[set] + [ex]pire) OK 127.0.0.1:6379> ttl key # 查看数据还有多久过期 (integer) 562、Hash等同于java中的:Map<String,Map<String,String>>,redis的hash是一个string类型的field和value的映射表,特别适合存储对象。在redis中,hash因为是一个集合,所以有两层。第一层是key:hash集合value,第二层是hashkey:string value。所以判断是否采用hash的时候可以参照有两层key的设计来做参考。并且注意的是,设置过期时间只能在第一层的key上面设置。使用hash,一般是有那种需要两层key的应用场景,也可以是‘删除一个key可以删除所有内容’的场景。例如一个商品有很多规格,规格里面有不同的值。或者查找所有商品的规格,查找商品id即可,具体的规格可以通过两个key查找。应用场景:我们要存储一个用户信息对象数据,其中包括用户ID、用户姓名、年龄和生日,通过用户ID我们希望获取该用户的姓名或者年龄或者生日;实现方式:Redis的Hash实际是内部存储的Value为一个HashMap,并提供了直接存取这个Map成员的接口。如,Key是用户ID, value是一个Map。这个Map的key是成员的属性名,value是属性值。这样对数据的修改和存取都可以直接通过其内部Map的Key(Redis里称内部Map的key为field), 也就是通过 key(用户ID) + field(属性标签) 就可以操作对应属性数据。当前HashMap的实现有两种方式:当HashMap的成员比较少时Redis为了节省内存会采用类似一维数组的方式来紧凑存储,而不会采用真正的HashMap结构,这时对应的value的redisObject的encoding为zipmap,当成员数量增大时会自动转成真正的HashMap,此时redisObject的encoding字段为int。常用命令:hget/hset/hgetall等,具体如下:作用命令添加单个hset key field value获取单个hget key field一次性添加多个键值hmset key field1 value1 field2 value2 …一次性获取多个hmget获取所有键值hgetall key删除hdel获取键值对的个数hlen检查是否包含某个字段hget key field查看所有keyhkeys给某个数值类型(否则报错)的值增加指定整数值hincrby key field increment给某个数字类型值,增加指定浮点类型值hincrbyfloat key field increment如果不存在则添加hsetnx3、list等同于java中的Map<String,List<String>>,list 底层是一个链表,在redis中,插入list中的值,只需要找到list的key即可,而不需要像hash一样插入两层的key。list是一种有序的、可重复的集合。redis的list是一个双向链表(易于插入和删除元素)。它会按照我们插入的顺序排序,然后我们可以从他的头部添加/获取元素,也可以从它的尾部添加/获取元素,但带来的额外的空间开销应用场景:Redis list的应用场景非常多,也是Redis最重要的数据结构之一,比如twitter的关注列表,粉丝列表等都可以用Redis的list结构来实现;实现方式:Redis list的实现为一个双向链表,即可以支持反向查找和遍历,更方便操作,不过带来了部分额外的内存开销,Redis内部的很多实现,包括发送缓冲队列等也都是用的这个数据结构。常用命令:lpush/rpush/lpop/rpop/lrange等,具体如下:常用命令命令左压栈lpush key v1 v2 v3 v4…右压栈rpush key v1 v2 …查看里面的元素lrange key start offset左弹栈lpop key右弹栈rpop key按照索引查找lindex key index查看长度llen key删除几个几lrem key 数量 value指定开始和结束的位置截取,再赋值给keyltrim key start offset右出栈左压栈,把resoure的左后一个,压倒dest的第一个rpoplpush resource destination重置指定索引的值lset key index value在指定元素前/后插入指定元素linsert key before/after 值1 值2性能总结:它是一个字符串链表,left、right都可以插入添加。如果键不存在,创建新的链表。如果键已经存在,新增内容。值全部移除,key消失。由于是链表,所以它对头和尾操作的效率都极高。但是假如是对中间元素的操作,效率就可怜了。4、Set等同于java中的Map<String,Set<String>>,Set 是一种无序的,不能重复的集合。并且在redis中,只有一个key它的底层由hashTable实现的,天生去重。应用场景:Redis set对外提供的功能与list类似是一个列表的功能,特殊之处在于set是可以自动去重的,当你需要存储一个列表数据,又不希望出现重复数据时,set是一个很好的选择,并且set提供了判断某个成员是否在一个set集合内的重要接口,这个也是list所不能提供的;如保存一些标签的名字。标签的名字不可以重复,顺序是可以无序的。实现方式:set 的内部实现是一个 value永远为null的HashMap,实际就是通过计算hash的方式来快速排重的,这也是set能提供判断一个成员是否在集合内的原因。常用命令:sadd/spop/smembers/sunion等,具体如下:常用命令命令添加值sadd key values查看值smembers key检查集合是否有值sismember key value查看set集合里面的元素个数scard key删除集合中的指定元素srem key value随机弹出某个元素srandmember key随机出栈spop key把key1中的某个值赋值给key2smove SourceSet destSet member数学集合类命令差集sdiff交集sinte并集sunion5、ZsetZSet(Sorted Set:有序集合) 每个元素都会关联一个double类型的分数score,分数允许重复,集合元素按照score排序(当score相同的时候,会按照被插入的键的字典顺序进行排序),还可以通过 score 的范围来获取元素的列表。set的值是 k1 v1 k2 v2zset的值 K1 score v1 k2 score v2应用场景:Redis sorted set的使用场景与set类似,区别是set不是自动有序的,而sorted set可以通过用户额外提供一个优先级(score)的参数来为成员排序,并且是插入有序的,即自动排序。当你需要一个有序的并且不重复的集合列表,那么可以选择sorted set数据结构,比如twitter 的public timeline可以以发表时间作为score来存储,这样获取时就是自动按时间排好序的。底层实现:zset是Redis提供的一个非常特别的数据结构,常用作排行榜等功能,以用户id为value,关注时间或者分数作为score进行排序。实现机制分别是zipList和skipList。规则如下:zipList:满足以下两个条件[score,value]键值对数量少于128个;每个元素的长度小于64字节;skipList:不满足以上两个条件时使用跳表、组合了hash和skipListhash用来存储value到score的映射,这样就可以在O(1)时间内找到value对应的分数;skipList按照从小到大的顺序存储分数skipList每个元素的值都是[socre,value]对为什么用skiplist不用平衡树?主要从内存占用、对范围查找的支持和实现难易程度这三方面总结的原因。内存占用:skiplist比平衡树更灵活一些。一般来说,平衡树每个节点至少包含2个指针,而skiplist每个节点包含的指针数目平均为1/(1-p),具体取决于参数p的大小。如果像Redis里的实现一样,取p=1/4,那么平均每个节点包含1.33个指针,比平衡树更有优势。范围查找的支持:在做范围查找的时候,平衡树比skiplist操作要复杂。在平衡树上,我们找到指定范围的小值之后,还需要以中序遍历的顺序继续寻找其它不超过大值的节点。如果不对平衡树进行一定的改造,这里的中序遍历并不容易实现。而在skiplist上进行范围查找就非常简单,只需要在找到小值之后,对第1层链表进行若干步的遍历就可以实现。实现难易程度:skiplist更加简单,平衡树的插入和删除操作可能引发子树的调整,逻辑复杂,而skiplist的插入和删除只需要修改相邻节点的指针,操作简单又快速。拓展:mysql为什么不用跳表?常用命令:zadd/zrange/zrem/zcard等;127.0.0.1:6379> zadd myZset 3.0 value1 # 添加元素到 sorted set 中 3.0 为权重 (integer) 1 127.0.0.1:6379> zadd myZset 2.0 value2 1.0 value3 # 一次添加多个元素 (integer) 2 127.0.0.1:6379> zcard myZset # 查看 sorted set 中的元素数量 (integer) 3 127.0.0.1:6379> zscore myZset value1 # 查看某个 value 的权重 "3" 127.0.0.1:6379> zrange myZset 0 -1 # 顺序输出某个范围区间的元素,0 -1 表示输出所有元素 1) "value3" 2) "value2" 3) "value1" 127.0.0.1:6379> zrange myZset 0 1 # 顺序输出某个范围区间的元素,0 为 start 1 为 stop 1) "value3" 2) "value2" 127.0.0.1:6379> zrevrange myZset 0 1 # 逆序输出某个范围区间的元素,0 为 start 1 为 stop 1) "value1" 2) "value2"三大特殊数据类型1、geospatial官网地址:https://redis.io/commands/geoadd可以用来推算两地之间的距离,方圆半径内的人。关于经度纬度的限制:https://www.redis.net.cn/order/3685.html1# 添加三个城市 2127.0.0.1:16379[2]> geoadd china:city 116.40 39.99 beijing 3(integer) 1 4127.0.0.1:16379[2]> geoadd china:city 117.190 39.1255 tianjin 5(integer) 1 6127.0.0.1:16379[2]> geoadd china:city 120.36955 36.094 qingdao 7(integer) 1 8127.0.0.1:16379[2]> 9 10# 获取指定key的经度和纬度 11127.0.0.1:16379[2]> geopos china:city beijing 121) 1) "116.39999896287918091" 13 2) "39.99000043587556519" 14127.0.0.1:16379[2]> 15 16# 获取两个给定位置的距离 17127.0.0.1:16379[2]> geodist china:city beijing qingdao # 默认单位为米 18"555465.2188" 19127.0.0.1:16379[2]> geodist china:city beijing qingdao km 20"555.4652" 21127.0.0.1:16379[2]> geodist china:city beijing qingdao m 22"555465.2188" 23127.0.0.1:16379[2]> geodist china:city beijing qingdao mi # 英里 24"345.1509" 25127.0.0.1:16379[2]> geodist china:city beijing qingdao ft # 英尺 26"1822392.4503" 27 28# 查找附近的人 29# 以给定的经纬度为中心,找出某一半径内的元素 30127.0.0.1:16379[2]> georadius china:city 117.190 39.1255 200 km # 半径为200km 311) "tianjin" 322) "beijing" 33127.0.0.1:16379[2]> georadius china:city 117.190 39.1255 200 km withdist # 指定显示距离 341) 1) "tianjin" 35 2) "0.0001" 362) 1) "beijing" 37 2) "117.6221" 38127.0.0.1:16379[2]> georadius china:city 117.190 39.1255 200 km count 2 # 指定显示2个结果 391) 1) "tianjin" 40 2) "0.0001" 412) 1) "beijing" 42 2) "117.6221" 43 44# 以指定的members为依据,找到它指定范围内的元素 45127.0.0.1:16379[2]> GEORADIUSBYMEMBER china:city beijing 120 km 461) "beijing" 472) "tianjin" 48 49# 返回一个或者多个位置的11位长度的hash串表示 50127.0.0.1:16379[2]> geohash china:city beijing 511) "wx4g2xzyss0" 52127.0.0.1:16379[2]> geohash china:city beijing tianjin 531) "wx4g2xzyss0" 542) "wwgqddx4sc0" 55 56# geo底层使用 zset 实现 57127.0.0.1:16379[2]> ZRANGE china:city 0 -1 581) "qingdao" 592) "tianjin" 603) "beijing" 61 62# 可以通过zrem删除 geo添加的key中的member 63127.0.0.1:16379[2]> ZREM china:city beijing 64(integer) 1 65127.0.0.1:16379[2]> ZRANGE china:city 0 -1 661) "qingdao" 672) "tianjin"2、Hyperloglog一般我们使用Hyperloglog做基数统计。什么是基数?就是一个集合中不重复的数的个数。集合A:{1,3,5,7,9,7}集合B:{1,3,5,7,9}AB集合的基数都是5应用:统计网站的访问量(一个人访问网站很多次仍然算作一次)。优点:占用的内存是固定的,找2^64次方个数的基数,只需要12KB内存。缺点:有0.81%的错误率,可以忽略不计1# PFCOUNT 计算出来的数量就是Set的基数 2127.0.0.1:16379[2]> PFADD key4 q w e r q 3(integer) 1 4127.0.0.1:16379[2]> PFCOUNT key4 5(integer) 4 6 7# 添加key1和key对应的多个值 8127.0.0.1:16379[2]> PFADD key1 q w e r 9(integer) 1 10 11# 统计key下有多少个值 12127.0.0.1:16379[2]> PFCOUNT key1 13(integer) 4 14 15# 添加key2和key对应的多个值 16127.0.0.1:16379[2]> PFADD key2 a s d f 17(integer) 1 18 19# 合并多个key成为一个key 20127.0.0.1:16379[2]> PFMERGE key3 key1 key2 21OK 22127.0.0.1:16379[2]> PFCOUNT key3 23(integer) 83、Bitmap(*)概述:bitmap 存储的是连续的二进制数字(0 和 1),通过 bitmap, 只需要一个 bit 位来表示某个元素对应的值或者状态,key 就是对应元素本身 。我们知道 8 个 bit 可以组成一个 byte,所以 bitmap 本身会极大的节省储存空间。应用场景: 适合需要保存状态信息(比如是否签到、是否登录...)并需要进一步对这些信息进行分析的场景。比如用户签到情况、活跃用户情况、用户行为统计(比如是否点赞过某个视频)。针对上面提到的一些场景,这里进行进一步说明。使用场景一:用户行为分析 很多网站为了分析你的喜好,需要研究你点赞过的内容。# 记录你喜欢过 001 号小姐姐 127.0.0.1:6379> setbit beauty_girl_001 uid 1 Copy to clipboardErrorCopied使用场景二:统计活跃用户使用时间作为 key,然后用户 ID 为 offset,如果当日活跃过就设置为 1那么我该如果计算某几天/月/年的活跃用户呢(暂且约定,统计时间内只有有一天在线就称为活跃),有请下一个 redis 的命令# 对一个或多个保存二进制位的字符串 key 进行位元操作,并将结果保存到 destkey 上。 # BITOP 命令支持 AND 、 OR 、 NOT 、 XOR 这四种操作中的任意一种参数 BITOP operation destkey key [key ...] Copy to clipboardErrorCopied 初始化数据: 127.0.0.1:6379> setbit 20210308 1 1 (integer) 0 127.0.0.1:6379> setbit 20210308 2 1 (integer) 0 127.0.0.1:6379> setbit 20210309 1 1 (integer) 0 Copy to clipboardErrorCopied 统计 20210308~20210309 总活跃用户数: 1 127.0.0.1:6379> bitop and desk1 20210308 20210309 (integer) 1 127.0.0.1:6379> bitcount desk1 (integer) 1 Copy to clipboardErrorCopied 统计 20210308~20210309 在线活跃用户数: 2 127.0.0.1:6379> bitop or desk2 20210308 20210309 (integer) 1 127.0.0.1:6379> bitcount desk2 (integer) 2 Copy to clipboardErrorCopied使用场景三:用户在线状态对于获取或者统计用户在线状态,使用 bitmap 是一个节约空间效率又高的一种方法。只需要一个 key,然后用户 ID 为 offset,如果在线就设置为 1,不在线就设置为 0。总结速度快,因为数据存在内存中,类似于HashMap,HashMap的优势就是查找和操作的时间复杂度都是O(1)支持丰富数据类型,支持string,list,set,sorted set(zset),hash补充:一个字符串类型的值能存储最大容量是512M其余类型元素最大存储量为2^32 - 1,注意hash是键值对的个数
什么是 RPC ?rpc解决了什么问题RPC (Remote Procedure Call)即远程过程调用,是分布式系统常见的一种通信方法。它允许程序调用另一个地址空间(通常是共享网络的另一台机器上)的过程或函数,而不用程序员显式编码这个远程调用的细节。简单的说:RPC就是从一台机器(客户端)上通过参数传递的方式调用另一台机器(服务器)上的一个函数或方法(可以统称为服务)并得到返回的结果。RPC会隐藏底层的通讯细节(不需要直接处理Socket通讯或Http通讯)。客户端发起请求,服务器返回响应(类似于Http的工作方式)RPC在使用形式上像调用本地函数(或方法)一样去调用远程的函数(或方法)。最终解决的问题:让分布式或者微服务系统中不同服务之间的调用(远程调用)像本地调用一样简单!调用者感知不到远程调用的逻辑。为此rpc需要解决三个问题(实现的关键):Call ID映射。我们怎么告诉远程机器(注册中心)我们要调用哪个函数呢?在本地调用中,函数体是直接通过函数指针来指定的,我们调用具体函数,编译器就自动帮我们调用它相应的函数指针。但是在远程调用中,是无法调用函数指针的,因为两个进程的地址空间是完全不一样。所以,在RPC中,所有的函数都必须有自己的一个ID。这个ID在所有进程中都是唯一确定的。客户端在做远程过程调用时,必须附上这个ID。然后我们还需要在客户端和服务端分别维护一个 {函数 <--> Call ID} 的对应表。两者的表不一定需要完全相同,但相同的函数对应的Call ID必须相同。当客户端需要进行远程调用时,它就查一下这个表,找出相应的Call ID,然后把它传给服务端,服务端也通过查表,来确定客户端需要调用的函数,然后执行相应函数的代码。序列化和反序列化。客户端怎么把参数值传给远程的函数呢?在本地调用中,我们只需要把参数压到栈里,然后让函数自己去栈里读就行。但是在远程过程调用时,客户端跟服务端是不同的进程,不能通过内存来传递参数。甚至有时候客户端和服务端使用的都不是同一种语言(比如服务端用C++,客户端用Java或者Python)。这时候就需要客户端把参数先转成一个字节流,传给服务端后,再把字节流转成自己能读取的格式。这个过程叫序列化和反序列化。同理,从服务端返回的值也需要序列化反序列化的过程。数据网络传输。远程调用往往是基于网络的,客户端和服务端是通过网络连接的。所有的数据都需要通过网络传输,因此就需要有一个网络传输层。网络传输层需要把Call ID和序列化后的参数字节流传给服务端,然后再把序列化后的调用结果传回客户端。只要能完成这两者的,都可以作为传输层使用。因此,它所使用的协议其实是不限的,能完成传输就行。尽管大部分RPC框架都使用TCP协议,但其实UDP也可以,而gRPC干脆就用了HTTP2。Java的Netty(基于NIO通信方式作为高性能网络服务的前提)也属于这层的东西。RPC调用流程:服务消费方(client)以本地调用方式调用服务;client stub接收到调用后负责将方法、参数等组装成能够进行网络传输的消息体;找到服务地址,并将消息发送到服务端;(client stub封装、发送)server stub收到消息后进行解码,根据解码结果调用本地的服务;本地服务执行并将结果返回给server stub;server stub将返回结果打包成消息并发送至消费方。(server stub解码、调用、返回与发送)4.client stub接收到消息,并进行解码。服务消费方接收返回结果;一个完整的RPC架构里面包含了四个核心的组件,分别是Client ,Server,Client Stub以及Server Stub,这个Stub大家可以理解为存根(调用与返回)。分别说说这几个组件:客户端(Client): 服务的调用方。服务端(Server):真正的服务提供者。客户端存根:存放服务端的地址消息,再将客户端的请求参数打包成网络消息,然后通过网络远程发送给服务方。服务端存根:接收客户端发送过来的消息,将消息解包,并调用本地的方法。小结:RPC 的目标就是封装调用过程,用户无需关心这些细节,可以像调用本地方法一样即可完成远程服务调用。要实现一个RPC不算难,难的是实现一个高性能、高可靠、高可用的RPC框架(需要考虑的问题)如何解决获取实例的问题?既然系统采用分布式架构,那一个服务势必会有多个实例,所以需要一个服务注册中心,比如在Dubbo中,就可以使用Zookeeper作为注册中心,在调用时,从Zookeeper获取服务的实例列表,再从中选择一个进行调用。也可以同Nacos做服务注册中心;如何选择实例?就要考虑负载均衡,例如dubbo提供了4种负载均衡策略;如果每次都去注册中心查询列表,效率很低,那么就要加缓存;客户端总不能每次调用完都等着服务端返回数据,所以就要支持异步调用;服务端的接口修改了,老的接口还有人在用,这就需要版本控制;服务端总不能每次接到请求都马上启动一个线程去处理,于是就需要线程池;常用的RPC框架Dubbo: Dubbo 是阿里巴巴公司开源的一个高性能优秀的服务框架,使得应用可通过高性能的 RPC 实现服务的输出和输入功能,可以和 Spring框架无缝集成。目前 Dubbo 已经成为 Spring Cloud Alibaba 中的官方组件。gRPC :gRPC 是可以在任何环境中运行的开源高性能RPC框架。它可以通过可插拔的支持来有效地连接数据中心内和跨数据中心的服务,以实现负载平衡,跟踪,运行状况检查和身份验证。它也适用于分布式计算的最后一英里,以将设备,移动应用程序和浏览器连接到后端服务。Hessian是一个轻量级的 remoting-on-http 工具,使用简单的方法提供了 RMI 的功能。 相比 WebService,Hessian 更简单、快捷。采用的是二进制 RPC协议,因为采用的是二进制协议,所以它很适合于发送二进制数据。数据交互为什么用 RPC,不用 HTTP?除 RPC 之外,常见的多系统数据交互方案还有分布式消息队列、HTTP 请求调用、数据库和分布式缓存等。RPC 和 HTTP 调用是没有经过中间件的,它们是端到端系统的直接数据交互。首先需要指正,这两个并不是并行概念。RPC 是一种设计,就是为了解决不同服务之间的调用问题,完整的 RPC 实现一般会包含有 传输协议 和 序列化协议 这两个。而 HTTP 是一种传输协议,RPC 框架完全可以使用 HTTP 作为传输协议,也可以直接使用 TCP,使用不同的协议一般也是为了适应不同的场景使用 TCP 和使用 HTTP 各有优势:(1)传输效率:TCP:通常自定义上层协议,可以让请求报文体积更小HTTP:如果是基于HTTP 1.1 的协议,请求中会包含很多无用的内容(2)性能消耗,主要在于序列化和反序列化的耗时TCP,可以基于各种序列化框架进行,效率比较高HTTP,大部分是通过 json 来实现的,字节大小和序列化耗时都要更消耗性能(3)跨平台:TCP:通常要求客户端和服务器为统一平台HTTP:可以在各种异构系统上运行总结:RPC 的 TCP 方式主要用于公司内部的服务调用,性能消耗低,传输效率高。HTTP主要用于对外的异构环境,浏览器接口调用,APP接口调用,第三方接口调用等。Java知识点调用如何实现客户端无感(动态代理技术)? 与静态代理的区别。静态代理:每个代理类只能为一个接口服务,这样会产生很多代理类。普通代理模式,代理类Proxy的Java代码在JVM运行时就已经确定了,也就是静态代理在编码编译阶段就确定了Proxy类的代码。而动态代理是指在JVM运行过程中,动态的创建一个类的代理类,并实例化代理对象。JDK 动态代理是利用反射机制生成一个实现代理接口的匿名类,在调用业务方法前调用InvocationHandler 处理。代理类必须实现 InvocationHandler 接口,并且,JDK 动态代理只能代理实现了接口的类。JDK 动态代理类基本步骤,如果想代理没有实现接口的对象?JDK 动态代理类基本步骤:编写需要被代理的类和接口编写代理类,需要实现 InvocationHandler 接口,重写 invoke() 方法;使用Proxy.newProxyInstance(ClassLoader loader, Class<?>[] interfaces, InvocationHandler h)动态创建代理类对象,通过代理类对象调用业务方法。CGLIB 框架实现了对无接口的对象进行代理的方式。JDK 动态代理是基于接口实现的,而 CGLIB 是基于继承实现的。它会对目标类产生一个代理子类,通过方法拦截技术过滤父类的方法调用。代理子类需要实现 MethodInterceptor 接口。CGLIB 底层是通过 asm 字节码框架实时生成类的字节码,达到动态创建类的目的,效率较 JDK 动态代理低。Spring 中的 AOP 就是基于动态代理的,如果被代理类实现了某个接口,Spring 会采用 JDK 动态代理,否则会采用 CGLIB。写一个动态代理的例子利用Java的反射技术(Java Reflection),在运行时创建一个实现某些给定接口的新类(也称“动态代理类”)及其实例(对象),代理的是接口(Interfaces),不是类(Class),也不是抽象类。在运行时才知道具体的实现,spring aop就是此原理。// 1、创建代理对象的接口 interface DemoInterface { String hello(String msg); } // 2、创建具体被代理对象的实现类 class DemoImpl implements DemoInterface { @Override public String hello(String msg) { System.out.println("msg = " + msg); return "hello"; } } // 3、创建一个InvocationHandler实现类,持有被代理对象的引用,invoke方法中:利用反射调用被代理对象的方法 class DemoProxy implements InvocationHandler { private DemoInterface service; public DemoProxy(DemoInterface service) { this.service = service; } @Override public Object invoke(Object obj, Method method, Object[] args) throws Throwable { System.out.println("调用方法前..."); Object returnValue = method.invoke(service, args); System.out.println("调用方法后..."); return returnValue; } } public class Solution { public static void main(String[] args) { DemoProxy proxy = new DemoProxy(new DemoImpl()); //4.使用Proxy.newProxyInstance(ClassLoader loader, Class<?>[] interfaces, InvocationHandler h)动态创建代理类对象,通过代理类对象调用业务方法。 DemoInterface service = (DemoInterface)Proxy.newProxyInstance( DemoInterface.class.getClassLoader(), new Class<?>[]{DemoInterface.class}, proxy ); System.out.println(service.hello("呀哈喽!")); } }输出:调用方法前... msg = 呀哈喽! 调用方法后... hello拓展:newProxyInstance:loader: 用哪个类加载器去加载代理对象interfaces:动态代理类需要实现的接口h:动态代理方法在执行时,会调用h里面的invoke方法去执行invoke三个参数:obj:就是代理对象,newProxyInstance方法的返回对象method:调用的方法args: 方法中的参数序列化与反序列化对象是怎么在网络中传输的?通过将对象序列化成字节数组,即可将对象发送到网络中。在 Java 中,想要序列化一个对象:要序列化对象所属的类必须实现了 Serializable 接口;并且其内部属性必须都是可序列化的。序列化对象主要由两种用途:把对象的字节序列永久地保存到硬盘上,通常存放在一个文件中或数据库中;比如最常见的是Web服务器中的Session对象,当有 10万用户并发访问,就有可能出现10万个Session对象,内存可能吃不消,于是Web容器就会把一些session先序列化到硬盘中,等要用了,再把保存在硬盘中的对象还原到内存中。用于网络传输对象的字节序列。当两个进程在进行远程通信时,彼此可以发送各种类型的数据。无论是何种类型的数据,都会以二进制序列的形式在网络上传送。发送方需要把这个Java对象转换为字节序列,才能在网络上传送;接收方则需要把字节序列再恢复为Java对象。在 Java 序列化期间,哪些变量未序列化?序列化期间,静态变量(static修饰)和瞬态变量(transient修饰)未被序列化:由于静态变量属于类,而不是对象,序列化保存的是对象的状态,静态变量保存的是类的状态。因此在 Java 序列化过程中不会保存它们。注意:static修饰的变量是不可序列化,同时也不可串行化。瞬态变量也不包含在 Java 序列化过程中, 并且不是对象的序列化状态的一部分。如果有一个属性(字段)不想被序列化的,则该属性必须被声明为 transient!一旦变量被transient修饰,变量将不再是对象持久化的一部分,该变量内容在序列化后无法被访问(如银行卡号、密码等不想用序列化机制保存)。transient关键字只能修饰变量,而不能修饰方法和类。注意,本地变量是不能被transient关键字修饰的。变量如果是用户自定义类变量,则该类需要实现Serializable接口。如何实现对象的序列化和反序列化?将需要序列化的类实现Serializable接口就可以了,Serializable接口中没有任何方法,可以理解为一个标记,即表明这个类可以序列化。JDK 中提供了 ObjectOutStream 类来对对象进行序列化。对象序列化(将对象转化为字节序列)包括如下步骤:创建一个对象输出流,它可以包装一个其他类型的目标输出流,如文件输出流;ObjectOutputStream out = new ObjectOutputStream(new fileOutputStream(“D:\\objectfile.obj”));通过对象输出流的writeObject()方法写对象。out.writeObject(“Hello”);对象的反序列化(将字节序列重建成一个对象的过程)步骤如下:创建一个对象输入流,它可以包装一个其他类型的源输入流,如文件输入流;ObjectInputStream in = new ObjectINputStream(new fileInputStream(“D:\\objectfile.obj”));通过对象输入流的readObject()方法读取对象。String obj1 = (String)in.readObject();区别可外部接口:Externalizable 给我们提供 writeExternal() 和 readExternal() 方法, 这让我们灵活地控制 Java 序列化机制, 而不是依赖于 Java 的默认序列化。正确实现 Externalizable 接口可以显著提高应用程序的性能。框架中实现了哪几种序列化方式,介绍一下?实现了 JSON、Kryo、Hessian 和 Protobuf 的序列化。后三种都是基于字节的序列化。JSON 是一种轻量级的数据交换语言,该语言以易于让人阅读的文字为基础,用来传输由属性值或者序列性的值组成的数据对象,类似 xml,Json 比 xml更小、更快更容易解析。JSON 由于采用字符方式存储,占用相对于字节方式较大,并且序列化后类的信息会丢失,可能导致反序列化失败。Kryo 是一个快速高效的 Java 序列化框架,旨在提供快速、高效和易用的 API。无论文件、数据库或网络数据 Kryo 都可以随时完成序列化。 Kryo 还可以执行自动深拷贝、浅拷贝。这是对象到对象的直接拷贝,而不是对象->字节->对象的拷贝。kryo 速度较快,序列化后体积较小,但是跨语言支持较复杂。Hessian 是一个基于二进制的协议,Hessian 支持很多种语言,例如 Java、python、c++,、net/c#、D、Erlang、PHP等,它的序列化和反序列化也是非常高效。速度较慢,序列化后的体积较大。protobuf(Protocol Buffers)是由 Google 发布的数据交换格式,提供跨语言、跨平台的序列化和反序列化实现,底层由 C++ 实现,其他平台使用时必须使用 protocol compiler 进行预编译生成 protoc 二进制文件。性能主要消耗在文件的预编译上。序列化反序列化性能较高,平台无关。serialVersionUID的作用Java的序列化机制是通过 在运行时 判断类的serialVersionUID来验证版本一致性的(用于实现对象的版本控制)。serialVersionUID 是一个 private static final long 型 ID, 当它被印在对象上时, 它通常是对象的哈希码,你可以使用 serialver 这个 JDK 工具来查看序列化对象的 serialVersionUID。在进行反序列化时,JVM会把传来的字节流中的serialVersionUID与本地相应实体(类)的serialVersionUID进行比较,如果相同就认为是一致的,可以进行反序列化,否则就会出现序列化版本不一致的异常。(InvalidCastException)类的serialVersionUID的默认值完全依赖于Java编译器的实现,对于同一个类,用不同的Java编译器编译,有可能会导致不同的 serialVersionUID,也有可能相同。为了提高serialVersionUID的独立性和确定性,强烈建议在一个可序列化类中显示的定义serialVersionUID,为它赋予明确的值。显式地定义serialVersionUID有两种用途:在某些场合,希望类的不同版本对序列化兼容,因此需要确保类的不同版本具有相同的serialVersionUID;在某些场合,不希望类的不同版本对序列化兼容,因此需要确保类的不同版本具有不同的serialVersionUID。网络传输(基于Netty)简单介绍NettyNetty概述:Netty是一个异步事件驱动的网络应用程序框架,用于快速开发可维护的高性能协议服务器和客户端;Netty基于 NIO 的,封装了 JDK 的 NIO,让我们使用起来更加方法灵活。特点和优势:使用简单:封装了 NIO 的很多细节,使用更简单。功能强大:预置了多种编解码功能,支持多种主流协议。定制能力强:可以通过 ChannelHandler 对通信框架进行灵活地扩展。性能高:通过与其他业界主流的 NIO 框架对比,Netty 的综合性能最优。为什么Netty性质高?IO 线程模型:同步非阻塞,用最少的资源做更多的事。内存零拷贝:尽量减少不必要的内存拷贝,实现了更高效率的传输。内存池设计:申请的内存可以重用,主要指直接内存。内部实现是用一颗二叉查找树管理内存分配情况。串行化处理读写:避免使用锁带来的性能开销。高性能序列化协议:支持 protobuf 等高性能序列化协议。简单说一下IO模型,BIO、NIO与AIOJava的IO阻塞和非阻塞以及同步和异步的问题:是否阻塞:关注的接口调用(发出请求)后等待数据返回时的状态。被挂起无法执行其他操作的则是阻塞型的;可以被立即「抽离」去完成其他「任务」的则是非阻塞型的。同步或者异步:关注的是应用程序是否需要自己去向系统内核问询以及任务完成时,消息通知的方式(即应用程序与系统内核的交互)。对于同步模式来说,应用层需要不断向操作系统内核询问数据是否读取完毕,当数据读取完毕,那此时系统内核将数据返回给应用层,应用层即可以用取得的数据做其他相关的事情;对于异步模式来说,应用层无需主动向系统内核问询,在系统内核读取完文件数据之后,会主动通知应用层数据已经读取完毕,此时应用层即可以接收系统内核返回过来的数据,再做其他事情。总结:同步/异步是宏观上(进程间通讯,通常表现为网络IO的处理上),阻塞/非阻塞是微观上(进程内数据传输,通常表现为对本地IO的处理上);阻塞和非阻塞是同步/异步的表现形式。由上描述基本可以总结一句简短的话,同步和异步是目的,阻塞和非阻塞是实现方式。(1)BIO(同步阻塞):传统BIO,一个连接一个线程,客户端有连接请求时服务器端就需要启动一个线程进行处理。线程开销大。数据的读写必须阻塞在一个线程内,等待其完成。伪异步IO:将请求连接放入线程池,一对多,但线程还是很宝贵的资源,但底层还是同步阻塞IO。具体是将客户端的Socket请求封装成一个task任务(实现Runnable类)然后投递到线程池中去,配置相应的队列实现。存在的问题:在读取数据较慢时(比如数据量大、网络传输慢等),大量并发的情况下,其他接入的消息,只能一直等待,这就是最大的弊端。这就是阻塞IO存在的弊端,对于低负载、低并发的应用程序,可以使用同步阻塞I/O来提升开发速率和更好的维护性。(2)NIO(同步非阻塞):一个请求一个线程,但客户端发送的连接请求都会注册到多路复用器上,多路复用器轮询到连接有I/O请求时才启动一个线程进行处理。主要解决BIO高负载、高并发的(网络)应用的性能问题。Buffer是一个对象,包含一些要写入或者读出的数据(读写都要经过Buffer缓冲区),实际上是一个数组,提供对数据结构化访问以及维护读写位置等信息。对数据的读取和写入要通过Channel通道,通道与流不同之处在于通道时双向的(可以读、写或者同时进行),而流只在一个方向移动。一般使用的SocketChannle和ServerSocketChannle一般都是SelectableChannel,用于网络读写的Channel,用于文件操作的Channel是FileChannel。多路复用器 Selector:当Channel管道注册到Selector选择器以后,Selector会分配给每个管道一个key值唯一标识,Selector选择器是以轮询的方式进行查找注册的所有Channel,当我们的Channel准备就绪或者监听到相应的事件状态的时候,selector就会识别这个事件状态,通过SelectionKey可以获取就绪Channel的集合,进行后续的I/O操作。(3)AIO(异步非阻塞):一个有效请求一个线程,客户端的I/O请求都是由OS先完成了,再通知服务器应用去启动线程进行处理。在NIO编程之上引入了异步通道的概念,并提供了异步文件和异步套接字的实现,从而真正实现了异步非阻塞。AIO它不需要通过多路复用器对注册的通道进行轮询操作即可以实现异步读写,从而简化了NIO编程模型。适用于连接数目多且连接比较长(重操作)的架构,充分调用OS参与并发操作,编程比较复杂;而 NIO方式适用于连接数目多且连接比较短(轻操作)的架构,并发局限于应用中。Netty线性模型?Netty 通过 Reactor 模型基于多路复用器接收并处理用户请求,内部实现了两个线程池, boss 线程池和 worker 线程池,其中 boss 线程池的线程负责处理请求的 accept 事件,当接收到 accept 事件的请求时,把对应的 socket 封装到一个 NioSocketChannel 中,并交给 worker 线程池,其中 worker 线程池负责请求的 read 和 write 事件,由对应的Handler 处理。单线程模型:所有I/O操作都由一个线程完成,即多路复用、事件分发和处理都是在一个Reactor 线程上完成的。既要接收客户端的连接请求,向服务端发起连接,又要发送/读取请求或应答/响应消息。一个NIO 线程同时处理成百上千的链路,性能上无法支撑,速度慢,若线程进入死循环,整个程序不可用,对于高负载、大并发的应用场景不合适。多线程模型:有一个NIO 线程(Acceptor) 只负责监听服务端,接收客户端的TCP 连接请求;NIO 线程池负责网络IO 的操作,即消息的读取、解码、编码和发送;1 个NIO 线程可以同时处理N 条链路,但是1 个链路只对应1 个NIO 线程,这是为了防止发生并发操作问题。但在并发百万客户端连接或需要安全认证时,一个Acceptor 线程可能会存在性能不足问题。主从多线程模型:Acceptor 线程用于绑定监听端口,接收客户端连接,将SocketChannel 从主线程池的 Reactor 线程的多路复用器上移除,重新注册到Sub 线程池的线程上,用于处理I/O 的读写等操作,从而保证 mainReactor 只负责接入认证、握手等操作;如何解决 TCP 的粘包拆包问题TCP 是以流的方式来处理数据,一个完整的包可能会被 TCP 拆分成多个包进行发送;也可能把小的封装成一个大的数据包发送(多个小的数据包合并发送),对于接收端的应用程序拿到缓冲区的数据不知如何拆分。TCP 粘包/拆包的原因:应用程序写入的字节大小大于套接字发送缓冲区的大小,会发生拆包现象,而应用程序写入数据小于套接字缓冲区大小,网卡将应用多次写入的数据发送到网络上(将发送端的缓冲区填满一次性发送),这将会发生粘包现象;总之:出现TCP 粘包/拆包的关键:套接字缓冲区大小限制与应用程序写入数据大小的关系。Netty 自带解决方式,Netty对解决粘包和拆包的方案做了抽象,提供了一些解码器(Decoder)来解决粘包和拆包的问题。如:消息定长:FixedLengthFrameDecoder 类包尾增加特殊字符分割:行分隔符类:LineBasedFrameDecoder自定义分隔符类 :DelimiterBasedFrameDecoder将消息分为消息头和消息体:LengthFieldBasedFrameDecoder 类。适用于消息头包含消息长度的协议(最常用)。分为有头部的拆包与粘包、长度字段在前且有头部的拆包与粘包、多扩展头部的拆包与粘包。基于Netty进行网络读写的程序,可以直接使用这些Decoder来完成数据包的解码。对于高并发、大流量的系统来说,每个数据包都不应该传输多余的数据(所以补齐的方式不可取),LenghtFieldBasedFrameDecode更适合这样的场景。常见的解决方案:发送端将每个包都封装成固定的长度,比如100字节大小。如果不足100字节可通过补0或空等进行填充到指定长度;发送端在每个包的末尾使用固定的分隔符,例如\r\n。如果发生拆包需等待多个包发送过来之后再找到其中的\r\n进行合并;例如,FTP协议;将消息分为头部和消息体,头部中保存整个消息的长度,只有读取到足够长度的消息之后才算是读到了一个完整的消息;通过自定义协议进行粘包和拆包的处理(本框架demo使用的,其中有字段标明包的长度)。说下 Netty 零拷贝Netty 的零拷贝主要包含三个方面:Netty 的接收和发送 ByteBuffer 采用 DIRECT BUFFERS,使用堆外直接内存进行 Socket 读写,不需要进行字节缓冲区的二次拷贝。如果使用传统的堆内存(HEAP BUFFERS)进行 Socket 读写,JVM 会将堆内存 Buffer 拷贝一份到直接内存中,然后才写入 Socket 中。相比于堆外直接内存,消息在发送过程中多了一次缓冲区的内存拷贝。Netty 提供了组合 Buffer 对象,可以聚合多个 ByteBuffer 对象,用户可以像操作一个 Buffer 那样方便的对组合 Buffer 进行操作,避免了传统通过内存拷贝的方式将几个小 Buffer 合并成一个大的 Buffer。Netty 的文件传输采用了 transferTo 方法,它可以直接将文件缓冲区的数据发送到目标 Channel,避免了传统通过循环 write 方式导致的内存拷贝问题。说下 Netty 重要组件核心组件:Bytebuf(字节容器):网络通信最终都是通过字节流进行传输的。 ByteBuf 就是 Netty 提供的一个字节容器,其内部是一个字节数组。 当我们通过 Netty 传输数据的时候,就是通过 ByteBuf 进行的。可以将ByteBuf看作是 Netty 对 Java NIO 提供了 ByteBuffer字节容器(复杂和繁琐)的封装和抽象。Bootstrap 和 ServerBootstrap(启动引导类):Bootstrap 通常使用 connet() 方法连接到远程的主机和端口,作为一个 Netty TCP 协议通信中的客户端。另外,Bootstrap 也可以通过 bind() 方法绑定本地的一个端口,作为 UDP 协议通信中的一端。ServerBootstrap通常使用 bind() 方法绑定本地的端口上,然后等待客户端的连接。Bootstrap 只需要配置一个线程组— EventLoopGroup ,而 ServerBootstrap需要配置两个线程组— EventLoopGroup ,一个用于接收连接,一个用于具体的 IO 处理。Channel接口(Netty 网络操作抽象类):可以进行基本的 I/O 操作,如 bind、connect、read、write 等。 一旦客户端成功连接服务端,就会新建一个Channel同该用户端进行绑定。常见的实现类:NioServerSocketChannel(服务端)NioSocketChannel(客户端)这两个 Channel 可以和 BIO 编程模型中的ServerSocket以及Socket两个概念对应上。EventLoop接口(事件循环):主要是配合 Channel 处理 I/O 操作,用来处理连接的生命周期中所发生的事情。 实际就是负责监听网络事件并调用事件处理器进行相关 I/O 操作(读写)的处理。与Channel 的关系:Channel为Netty网络操作(读写等操作)的抽象类,EventLoop负责处理注册在其上的Channel的I/O操作,两者配合进行I/O操作。与EventloopGroup 关系:EventLoopGroup 包含多个 EventLoop(每一个 EventLoop 通常内部包含一个线程),它管理着所有的 EventLoop 的生命周期。并且,EventLoop 处理的 I/O 事件都将在它专有的 Thread 上被处理,即 Thread 和 EventLoop 属于 1 : 1 的关系,从而保证线程安全。三者关系ChannelFuture接口(操作执行结果):Netty是异步非阻塞的,Netty 框架中所有的 I/O 操作都为异步的,因此我们需要 ChannelFuture 的 addListener()方法注册一个 ChannelFutureListener 监听事件,当操作执行成功或者失败时,监听就会自动触发返回结果。ChannelFuture 的 channel() 方法获取连接相关联的Channel 。ChannelFuture 接口的 sync()方法让异步的操作编程同步的。bind()是异步的,但是,你可以通过 sync()方法将其变为同步。与用户逻辑与数据流密切相关:ChannelHandler(消息处理器):充当了所有处理入站和出站数据的逻辑容器。ChannelHandler 主要用来处理各种事件(消息的具体处理器),这里的事件很广泛,比如可以是连接、数据接收、异常、数据转换等。ChannelPipeline(ChannelHandler对象的链表):为 ChannelHandler 链提供了容器,当 channel 创建时,就会被自动分配到它专属的 ChannelPipeline,这个关联是永久性的。当 ChannelHandler 被添加到的 ChannelPipeline 它得到一个 ChannelHandlerContext。Netty 是如何保持长连接的(心跳机制)首先 TCP 协议的实现中也提供了 keepalive 报文用来探测对端是否可用。TCP 层将在定时时间到后发送相应的 KeepAlive 探针以确定连接可用性。打开该设置:ChannelOption.SO_KEEPALIVE, trueTCP 心跳的问题:考虑一种情况,某台服务器因为某些原因导致负载超高,CPU 100%,无法响应任何业务请求,但是使用 TCP 探针则仍旧能够确定连接状态,这就是典型的连接活着但业务提供方已死的状态,对客户端而言,这时的最好选择就是断线后重新连接其他服务器,而不是一直认为当前服务器是可用状态一直向当前服务器发送些必然会失败的请求。Netty 中提供了 IdleStateHandler 类专门用于处理心跳。IdleStateHandler 的构造函数如下:public IdleStateHandler(long readerIdleTime, long writerIdleTime, long allIdleTime,TimeUnit unit){ }第一个参数是隔多久检查一下读事件是否发生,如果 channelRead() 方法超过 readerIdleTime 时间未被调用则会触发超时事件调用 userEventTrigger() 方法;第二个参数是隔多久检查一下写事件是否发生,writerIdleTime 写空闲超时时间设定,如果 write() 方法超过 writerIdleTime 时间未被调用则会触发超时事件调用 userEventTrigger() 方法;第三个参数是全能型参数,隔多久检查读写事件;第四个参数表示当前的时间单位。所以这里可以分别控制读,写,读写超时的时间,单位为秒,如果是0表示不检测,所以如果全是0,则相当于没添加这个 IdleStateHandler,连接是个普通的短连接。Nacos注册中心待补充。。。设计模式责任链模式与在netty中的应用适用场景:对于一个请求来说,如果有个对象都有机会处理它,而且不明确到底是哪个对象会处理请求时,我们可以考虑使用责任链模式实现它,让请求从链的头部往后移动,直到链上的一个节点成功处理了它为止优点:发送者不需要知道自己发送的这个请求到底会被哪个对象处理掉,实现了发送者和接受者的解耦简化了发送者对象的设计可以动态的添加节点和删除节点缺点:所有的请求都从链的头部开始遍历,对性能有损耗极差的情况,不保证请求一定会被处理
1.有效的括号(20-易)题目描述:给定一个只包括 '(',')','{','}','[',']' 的字符串 s ,判断字符串是否有效。有效字符串需满足:左括号必须用相同类型的右括号闭合。左括号必须以正确的顺序闭合。示例:输入:s = "()[]{}" 输出:true思路:本题核心还是栈的应用:直接栈:遇到左括号,压栈右括号,其他都要出栈比较映射+栈:代码是压左括号,遇到右括号出栈(映射的key),比较映射值与栈顶值是否相同。两种思路的共同点是遇到右括号一定出栈比较!代码:class Solution { // 直接使用栈 public boolean isValid(String s) { if (s.length() % 2 == 1) { return false; } Deque<Character> stack = new LinkedList<>(); for (char c : s.toCharArray()) { if (c == '(') { stack.push(')'); } else if (c == '[') { stack.push(']'); } else if (c == '{'){ stack.push('}'); } else if (stack.isEmpty() || c != stack.pop()) { return false; } } return stack.isEmpty(); } // 栈 + 映射(本质一样) private static final Map<Character,Character> map = new HashMap<>(){{ put(')', '('); put(']', '['); put('}', '{'); }}; public boolean isValid(String s) { if (s.length() % 2 == 1) { return false; } Deque<Character> stack = new LinkedList<>(); for(Character c : s.toCharArray()){ if (map.containsKey(c)) { if (stack.isEmpty() || map.get(c) != stack.pop()) { return false; } } else { stack.push(c); } } return stack.isEmpty(); } }2.括号生成(22-中)题目描述:数字 n 代表生成括号的对数,请你设计一个函数,用于能够生成所有可能的并且 有效的 括号组合。示例:输入:n = 3 输出:["((()))","(()())","(())()","()(())","()()()"]思路:本题可以使用深度优先遍历(推荐),可以记录当前递归得到的结果,所以不用进行回溯:产生右分支的时候,还受到左分支的限制,右边剩余可以使用的括号数量一定得在严格大于左边剩余的数量的时候,才可以产生分支,即右边使用的括号大于左边使用的括号就剪枝;终止条件:左右括号都用完ps:可以使用括号累加或者减少,代码如下。代码:// 做加法统计 public List<String> generateParenthesis(int n) { List<String> ans = new ArrayList<>(); if (n == 0) return ans; dfs("", 0, 0, n, ans); return ans; } public void dfs(String curStr, int left, int right, int n, List<String> ans) { if (left == n && right == n) { ans.add(curStr); return; } if (left < right) { // 剪枝 return; } if (left < n) { dfs(curStr + "(", left + 1, right, n, ans); } if (right < n) { dfs(curStr + ")", left, right + 1, n, ans); } } // 做减法统计 public List<String> generateParenthesis(int n) { List<String> ans = new ArrayList<>(); if (n == 0) return ans; dfs("", n, n, ans); return ans; } public void dfs(String curStr, int left, int right, List<String> ans) { if (left == 0 && right == 0) { ans.add(curStr); return; } if (left > right) { // 剪枝 return; } if (left > 0) { dfs(curStr + "(", left - 1, right, ans); } if (right > 0) { dfs(curStr + ")", left, right - 1, ans); } }3.括号匹配深度(补充)题目描述:"","()","()()","((()))"都是合法的括号序列 对于一个合法的括号序列我们又有以下定义它的深度:空串""的深度是0如果字符串"X"的深度是x,字符串"Y"的深度是y,那么字符串"XY"的深度为max(x,y)如果"X"的深度是x,那么字符串"(X)"的深度是x+1参考:https://gitee.com/SnailClimb/JavaGuide/blob/master/docs/dataStructures-algorithms示例:输入描述: 输入包括一个合法的括号序列s,s长度length(2 ≤ length ≤ 50),序列中只包含'('和')'。 输出描述: 输出一个正整数,即这个序列的深度。 -------------------------------------------------------------------- 输入: (()) 输出: 2思路:遍历字符串,维护一个变量count记录深度。遇到(,则count+1,否则count - 1。代码:import java.util.Scanner; public class Main { public static void main(String[] args) { Scanner sc = new Scanner(System.in); String s = sc.nextLine(); int count = 0, max = 0; for (char ch : s.toCharArray()) { if (ch == '(') count++; else count--; max = Math.max(count, max); } sc.close(); System.out.println(max); } }4.最长有效括号(32-难)题目描述:给你一个只包含 '(' 和 ')' 的字符串,找出最长有效(格式正确且连续)括号子串的长度。示例:输入:s = "(()" 输出:2 解释:最长有效括号子串是 "()"思路:对于括号匹配问题一般使用栈进行判断,栈中存放左括号的下标,便于计算最大长度:入栈操作:遇到 ( ,将其下标入栈,等待匹配;注意:为了避免栈为空报错:开始压入-1,过程中用一个右括号下标作为标志。出栈操作:遇到 ) ,出栈更新最大长度。另外,官方给出了一种不需要额外空间的解法,维持两个计数器left和right:遍历字符串,遇到左括号,left增加,否则right增加,当left == right时,计算此时长度。当right > left时,我们开始,即left和right置0。注意:但是对于 (() ,左括号一直大于右括号,我们没法得到最大长度,即left == right,其实,我们只需要倒序再遍历一遍即可。代码:// 辅助栈 public int longestValidParentheses(String s) { Deque<Integer> stack = new LinkedList<>(); if (s == null || s.length() == 0) { return 0; } int ans = 0; stack.push(-1); // 避免开始即为右括号 for (int i = 0; i < s.length(); i++) { if (s.charAt(i) == '(') { stack.push(i); } else { stack.pop(); if (stack.isEmpty()) { stack.push(i); } else { ans = Math.max(ans, i - stack.peek()); } } } return ans; } // 遍历计数 public int longestValidParentheses(String s) { if (s == null || s.length() == 0) { return 0; } int ans = 0; int left = 0, right = 0; for (int i = 0; i < s.length(); i++) { if (s.charAt(i) == '(') { left++; } else { right++; } if (left == right) { ans = Math.max(ans, left + right); } else if (right > left) { left = right = 0; } } left = right = 0; for (int i = s.length() - 1; i >= 0; i--) { if (s.charAt(i) == '(') { left++; } else { right++; } if (left == right) { ans = Math.max(ans, left + right); } else if (left > right) { left = right = 0; } } return ans; }5.有效的括号字符串(678-中)题目描述:给定一个只包含三种字符的字符串:(,) 和 *,写一个函数来检验这个字符串是否为有效字符串。有效字符串具有如下规则:左右括号必须匹配,左括号在前,右括号在后*可以为单个左右括号或者一个空格空字符串有效示例:输入: "(*)" 输出: True 输入: "(*))" 输出: True思路:基本匹配问题,想到使用栈,关键是如何处理*问题:使用双栈,但是栈中存的是下标,为什么呢?当我们遍历完字符串时,如果两栈中都还有元素,那么我们就可以比较下标判断是否可以构成有效括号,比如 *( 就不可以。遇到左括号和星号,入栈对应下标遇到有括号,优先使用left栈中字符,如果没有,使用star栈中星号进行匹配解决栈中剩余元素下标,最终左括号栈为空返回true(即全部匹配成功)还有一种类似最长有效括号,使用计数法,正反两遍,判断多余左括号的情况。代码如下。代码:// 辅助栈 public boolean checkValidString(String s) { Deque<Integer> leftStack = new LinkedList<>(); Deque<Integer> starStack = new LinkedList<>(); if (s == null || s.length() == 0) { return true; } for (int i = 0; i < s.length(); ++i) { if (s.charAt(i) == '(') { leftStack.push(i); } else if (s.charAt(i) == '*') { starStack.push(i); } else { if (leftStack.isEmpty() && starStack.isEmpty()) { return false; } else if (!leftStack.isEmpty()) { leftStack.pop(); } else { starStack.pop(); } } } while (!leftStack.isEmpty() && !starStack.isEmpty()) { if (leftStack.peek() > starStack.peek()) { return false; } leftStack.pop(); starStack.pop(); } return leftStack.isEmpty(); } // 计数法 public boolean checkValidString(String s) { int countL = 0, countLS = 0; int n = s.length(); for (int i = 0; i < n; ++i) { if (s.charAt(i) == '(') { countL++; } else if (s.charAt(i) == '*') { countLS++; } else { if (countL > 0) { countL--; } else if (countLS > 0) { countLS--; } else { return false; } } } int countR = 0, countRS = 0; for (int i = n - 1; i >= 0; --i) { if (s.charAt(i) == ')') { countR++; } else if (s.charAt(i) == '*') { countRS++; } else { if (countR > 0) { countR--; } else if (countRS > 0) { countRS--; } else { return false; } } } return true; }6.删除无效括号(301-中)题目描述:给你一个由若干括号和字母组成的字符串 s ,删除最小数量的无效括号,使得输入的字符串有效。返回所有可能的结果。答案可以按 任意顺序 返回。示例:输入:s = "(a)())()" 输出:["(a())()","(a)()()"]思路:一般对于括号的有效性,一般使用栈和计数法()。本题使用dfs+计数法:提前确定dfs计数得分的可能最大值(便于确定边界)对于枚举括号类型,在不越界的情况下,括号可以选择加或者不加根据题意,一般类型选择直接加入结果结果set去重:遍历数组结束,查看得分(score == 0保存这个结果,并更新dfs最大子串的长度),不满足则直接return最终根据全局变量len,统计最终的结果代码:class Solution { // 记录dfs过程中最大的子串 private int len = 0; public List<String> removeInvalidParentheses(String s) { // 对结果进行去重 Set<String> all = new HashSet<>(); char[] chs = s.toCharArray(); int l = 0, r = 0; for (char c : chs) { if (c == '(') { l++; } else if (c == ')'){ r++; } } // 提前dfs可能的最大结果 int max = Math.min(l, r); dfs(chs, 0, 0, max, "", all); List<String> ans = new ArrayList<>(); for (String str : all) { // 挑选符合要求的结果 if (str.length() == len) { ans.add(str); } } return ans; } private void dfs(char[] chs, int i, int score, int max, String cur, Set<String> ans) { if (i == chs.length) { if (score == 0 && cur.length() >= len) { // 找到合法的括号组合,加入结果集 len = Math.max(len, cur.length()); ans.add(cur); } return; } if (chs[i] == '(') { // 通过得分,检查左括号不越界(加或者不加) if (score + 1 <= max) { dfs(chs, i + 1, score + 1, max, cur + "(", ans); } dfs(chs, i + 1, score, max, cur, ans); } else if (chs[i] == ')') { // 通过得分,检查右括号不越界(加或者不加) if (score > 0) { dfs(chs, i + 1, score - 1, max, cur + ")", ans); } dfs(chs, i + 1, score, max, cur, ans); } else { // 一般字符直接加入结果 dfs(chs, i + 1, score, max, cur + String.valueOf(chs[i]), ans); } } }
1.ls命令(list缩写)通过ls 命令不仅可以查看linux文件夹包含的文件,而且可以查看文件权限(包括目录、文件夹、文件权限)、查看目录信息等等ls -a 列出目录所有文件,包含以.开始的隐藏文件 ls -A 列出除.及..的其它文件 ls -r 反序排列 ls -t 以文件修改时间排序 ls -S 以文件大小排序 ls -h 以易读大小显示 ls -l 除了文件名之外,还将文件的权限、所有者、文件大小等信息详细列出来 ls -lhrt 按易读方式按时间反序排序,并显示文件详细信息 ls -lrS 按大小反序显示文件详细信息 ls -l t* 列出当前目录中所有以“t”开头的目录的详细内容 ls | sed "s:^:`pwd`/:" 列出文件绝对路径(不包含隐藏文件) find $pwd -maxdepth 1 | xargs ls -ld 列出文件绝对路径(包含隐藏文件)2.cd命令(change directory缩写)命令语法:cd [目录名]。说明:切换当前目录至dirNamecd / 进入要目录 cd ~ 进入"家"目录 cd - 进入上一次工作路径 cd !$ 把上个命令的参数作为cd参数使用3.pwd命令(print working directory缩写)查看当前工作目录路径pwd 查看当前路径 pwd -P 查看软链接的实际路径4.mkdir命令(make directory缩写)创建文件夹mkdir t 当前工作目录下创建名为t的文件夹 mkdir -p /tmp/test/t1/t 在tmp目录下创建路径为test/t1/t的目录,若不存在,则创建5.rm命令(remove缩写)删除一个目录中的一个或多个文件或目录,如果没有使用- r选项,则rm不会删除目录。如果使用 rm 来删除文件,通常仍可以将该文件恢复原状: rm [选项] 文件…rm -i *.log 删除任何.log文件;删除前逐一询问确认 rm -rf test 删除test子目录及子目录中所有档案删除,并且不用一一确认 rm -- -f* 删除以-f开头的文件6.rmdir命令(remove directory缩写)从一个目录中删除一个或多个子目录项,删除某目录时也必须具有对其父目录的写权限。注意:不能删除非空目录rmdir -p parent/child/child11 当parent子目录被删除后使它也成为空目录的话,则顺便一并删除7.mv命令(move缩写)移动文件或修改文件名,根据第二参数类型(如目录,则移动文件;如为文件则重命令该文件)。 当第二个参数为目录时,可将多个文件以空格分隔作为第一参数,移动多个文件到参数2指定的目录中mv test.log test1.txt 将文件test.log重命名为test1.txt mv log1.txt log2.txt log3.txt /test3 将文件log1.txt,log2.txt,log3.txt移动到根的test3目录中 mv -i log1.txt log2.txt 将文件file1改名为file2,如果file2已经存在,则询问是否覆盖 mv * ../ 移动当前文件夹下的所有文件到上一级目录8.cp命令(copy缩写)将源文件复制至目标文件,或将多个源文件复制至目标目录。注意:命令行复制,如果目标文件已经存在会提示是否覆盖,而在shell脚本中,如果不加-i参数,则不会提示,而是直接覆盖!cp -ai a.txt test 复制a.txt到test目录下,保持原文件时间,如果原文件存在提示是否覆盖 cp -s a.txt link_a.txt 为a.txt建议一个链接(快捷方式)9.cat命令(concatenate)cat主要有三大功能:一次显示整个文件:cat filename从键盘创建一个文件:cat > filename 只能创建新文件,不能编辑已有文件.将几个文件合并为一个文件:cat file1 file2 > filecat -n log2012.log log2013.log 把 log2012.log 的文件内容加上行号后输入 log2013.log 这个文件里 cat -b log2012.log log2013.log log.log 把 log2012.log 和 log2013.log 的文件内容加上行号(空白行不加)之后将内容附加到 log.log 里10.more命令功能类似于cat, more会以一页一页的显示方便使用者逐页阅读,而最基本的指令就是按空白键(space)就往下一页显示,按 b 键就会往回(back)一页显示->>命令参数: +n 从笫n行开始显示 -n 定义屏幕大小为n行 +/pattern 在每个档案显示前搜寻该字串(pattern),然后从该字串前两行之后开始显示 -c 从顶部清屏,然后显示 -d 提示“Press space to continue,’q’ to quit(按空格键继续,按q键退出)”,禁用响铃功能 -l 忽略Ctrl+l(换页)字符 -p 通过清除窗口而不是滚屏来对文件进行换页,与-c选项相似 -s 把连续的多个空行显示为一行 -u 把文件内容中的下画线去掉 ->>常用操作命令: Enter 向下n行,需要定义。默认为1行 Ctrl+F 向下滚动一屏 空格键 向下滚动一屏 Ctrl+B 返回上一屏 = 输出当前行的行号 :f 输出文件名和当前行的行号 V 调用vi编辑器 !命令 调用Shell,并执行命令 q 退出more 例子: more +3 text.txt more +3 text.txt ls -l | more -5 在所列出文件目录详细信息,借助管道使每次显示5行11.less命令less与more类似,但使用less可以随意浏览文件,而more仅能向前移动,却不能向后移动,而且less在查看之前不会加载整个文件-i 忽略搜索时的大小写 -N 显示每行的行号 -o <文件名> 将less 输出的内容在指定文件中保存起来 -s 显示连续空行为一行 /字符串: 向下搜索“字符串”的功能 ? 字符串:向上搜索“字符串”的功能 n: 重复前一个搜索(与 / 或 ? 有关) N: 向重复前一个搜索(与 / 或 ? 有关) -x <数字> 将“tab”键显示为规定的数字空格 b 向后翻一页 d 向后翻半页 h 显示帮助界面 Q 退出less 命令 u 向前滚动半页 y 向前滚动一行 空格键 滚动一行 回车键 滚动一页 [pagedown]: 向下翻动一页 [pageup]: 向上翻动一页待补充,详情见参考... ...面试常见问题知道哪些linux指令?(1)文件与目录操作ls命令:查看linux文件夹中文件信息ls -l:以长列表的形式列出文件和目录的详细信息ls -Sr:以文件大小进行排序(文件小的在前)ls -a :列出全部的文件,连同隐藏文件(开头为.的文件)一起列出来(常用)cd命令:切换目录 [目录名]。补充:绝对路径: 如/etc/init.d,当前目录和上层目录: ./ ../,主目录: ~/pwd命令:查看当前的工作目录(显示当前路径)du命令:查看文件或者目录所占的内存du -h :查看一个文件夹中所有文件的大小(包含子目录中的文件)mkdir命令:mkdir [目录名],也可同时创建两个目录rm命令:删除一个目录中文件或者目录(注:如果没有使用- r选项,则rm不会删除目录)mv命令:移动文件或修改文件名,根据第二参数类型(如目录,则移动文件;如为文件则重命令该文件)cp命令:复制文件或者目录,还可以把多个文件一次性地复制到一个目录下cp -a file1 file2 :连同文件的所有特性把文件file1复制成文件file2cp file1 file2 file3 dir :把文件file1、file2、file3复制到目录dir中In命令:建立链接软链接(快捷方式):ln -s slink source硬链接(物理地址): ln link sourcefind命令:搜索文件/目录(2)文件查看与处理cat命令:查看文件内容more命令:查看一个长文件的内容head/tail命令head/tail - n: 查看文件的前n行/后n行tail -f /log/msg : 查看实时添加到文件中的内容(日志)grep 命令:从输入文件中查找匹配到给定模式列表的行,文本搜索命令。grep code hello.txt:查找文件中的关键字grep ^code hello.txt:查找文件中以code开头的内容sed命令:文件中删除替换元素sort命令:合并两个文件awk命令:将一行按照字段处理,默认分隔符为空格或tab(3)网络与进程管理ps命令:将某个进程显示出来,补充:其中UID:进程拥有者,PID:进程ID,PPID:上级父程序的IDps -a:显示所有用户的所有进程ps --ppid 进程名:子进程查父进程;ptree -p 进程名:父进程查子进程ps -ef|grep xxx(如java) :显示进程pid(程序id),例显示所有java进程ps -aux | grep xxx(-aux显示所有状态)top命令:实时显示进程的状态kill命令:使用kill命令来终结进程。先使用ps命令找到进程id,使用kill -9 进程号,彻底杀死某个进程kill -s 进程的名字netstat 命令:检查网络是否连通netstat -anp|grep 端口号: 查看端口号是否被占用(状态为LISTEN表示被占用)。netstat -nap|grep 7779:知道进程id7779,查看其占用的端口ifconfig命令:命令查看 ip 地址及接口信息(网络接口属性)route命令:route -n:查看路由表host命令:解析主机名,hostname命令:查看主机名nslookup命令:查询dns记录,查看域名解析是否正常sar命令: 查看网卡流量:sar -n DEV 1 2 命令后面1 2 意思是:每一秒钟取1次值,取2次。DEV显示网络接口信息iostat命令: 查看IO情况 iostat -d -k 2 参数-d表示,显示设备(磁盘)使用状态; 2表示,数据显示每隔2秒刷新一次。(4)打包与解压tar命令:tar -cvf xxx.tar file:创建非压缩的tar包tar -tf xxx.tar :查看tar包中的内容tar –xvf xxx.tar: 解压 tar包zip/unzip xxx.zip: 压缩/解压zipgzip -9 文件名:最大程度压缩(5)其他系统服务命令:server,systemctl等关机重启注销命令:shutdown,reboot,logout等rpm包管理命令:rpm -qa:已经安装的rpm包;rpm -ivh xxx.rpm:安装rpm包;rpm -e xxx:写在rpm包yum包管理命令:yum list/update:升级/显示所有安装包等ps:rpm与yum之间的关系rpm是由红帽公司开发的软件包管理方式,使用rpm我们可以方便的进行软件的安装、查询、卸载、升级等工作。但是rpm软件包之间的依赖性问题往往会很繁琐,尤其是软件由多个rpm包组成时。Yum(全称为 Yellow dog Updater, Modified)是一个在Fedora和RedHat以及SUSE中的Shell前端软件包管理器。基于RPM包管理,能够从指定的服务器自动下载RPM包并且安装,可以自动处理依赖性关系,并且一次安装所有依赖的软体包,无须繁琐地一次次下载、安装。ps:linux通配符“?”可替代单个字符。“*”可替代任意多个字符。
解答这类题目, 省略不掉遍历, 因此我们先从遍历方式说起,通常我们遍历子串或者子序列有三种遍历方式以某个节点为开头的所有子序列: 如 [a],[a, b],[ a, b, c] ... 再从以 b 为开头的子序列开始遍历 [b] [b, c]。即暴力解法。根据子序列的长度为标杆,如先遍历出子序列长度为 1 的子序列,在遍历出长度为 2 的 等等。 leetcode (5. 最长回文子串 ) 中的解法就用到了。以子序列的结束节点为基准,先遍历出以某个节点为结束的所有子序列,因为每个节点都可能会是子序列的结束节点,因此要遍历下整个序列,如: 以 b 为结束点的所有子序列: [a , b] [b] 以 c 为结束点的所有子序列: [a, b, c] [b, c] [ c ]。这里因为可以产生递推关系, 采用动态规划时, 经常通过此种遍历方式, 如 背包问题, 最大公共子串====================== 子序问题(连续)======================1.最大子序和(53 - 易)题目描述:给定一个整数数组 nums ,找到一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。与剑指42相同。示例 :输入:nums = [-2,1,-3,4,-1,2,1,-5,4] 输出:6 解释:连续子数组 [4,-1,2,1] 的和最大,为 6 。思路:典型的动态规划,关键点:dp[i]:以nums[i]为结尾的最大和的连续子数组(重要)状态转移方程:dp[i] = Math.max(dp[i - 1] + nums[i], nums[i]),因为元素可能存在负数。由于dp[i]只依赖dp[i - 1],所以可以用一个变量pre记录状态更替。代码实现:class Solution { // 动态规划(注意:最后返回的是所有位置中最大的) public int maxSubArray(int[] nums) { int n = nums.length; if (nums == null || n == 0) { return 0; } // dp[i]:以i结尾的最大子序和 int[] dp = new int[n]; dp[0] = nums[0]; int ans = nums[0]; for (int i = 1; i < n; i++) { dp[i] = Math.max(dp[i - 1] + nums[i], nums[i]); ans = Math.max(ans, dp[i]); } return ans; } // 状态压缩 public int maxSubArray(int[] nums) { int n = nums.length; if (nums == null || n == 0) { return 0; } int pre = nums[0]; int ans = nums[0]; for (int i = 1; i < n; i++) { pre = Math.max(pre + nums[i], nums[i]); ans = Math.max(ans, pre); } return ans; } }2.最长连续递增序列(674 - 易)题目描述:给定一个未经排序的整数数组,找到最长且 连续递增的子序列,并返回该序列的长度。连续递增的子序列 可以由两个下标 l 和 r(l < r)确定,如果对于每个 l <= i < r,都有 nums[i] < nums[i + 1] ,那么子序列 [nums[l], nums[l + 1], ..., nums[r - 1], nums[r]] 就是连续递增子序列。示例 :输入:nums = [1,3,5,4,7] 输出:3 解释:最长连续递增序列是 [1,3,5], 长度为3。 尽管 [1,3,5,7] 也是升序的子序列, 但它不是连续的,因为 5 和 7 在原数组里被 4 隔开。思路:典型的动态规划:dp[i]:以nums[i]为结尾的最长连续递增序列的长度状态转移方程:如果递增:dp[i] = dp[i - 1] + 1,否则dp[i] = 1。由于dp[i]只依赖dp[i - 1],所以可以用一个变量pre记录状态更替。代码实现:class Solution { // 动态规划(最后返回整个数组中最长的连续递增子序列) public int findLengthOfLCIS(int[] nums) { int n = nums.length; if (nums == null || n == 0) { return 0; } int[] dp = new int[n]; dp[0] = 1; int ans = 1; for (int i = 1; i < n; i++) { if (nums[i] > nums[i - 1]) { dp[i] = dp[i - 1] + 1; } else { dp[i] = 1; } ans = Math.max(ans, dp[i]); } return ans; } // 状态压缩 public int findLengthOfLCIS(int[] nums) { int n = nums.length; if (nums == null || n == 0) { return 0; } int pre = 1; int ans = 1; for (int i = 1; i < n; i++) { if (nums[i] > nums[i - 1]) { pre = pre + 1; } else { pre = 1; } ans = Math.max(ans, pre); } return ans; } }3.最长重复子数组(718 - 中)题目描述:给两个整数数组 A 和 B ,返回两个数组中公共的、长度最长的子数组的长度。示例 :输入: A: [1,2,3,2,1] B: [3,2,1,4,7] 输出:3 解释: 长度最长的公共子数组是 [3, 2, 1] 。思路:典型的动态规划:dp[i][j]:以nums1[i]和nums2[j]为结尾的最长公共子数组。状态转移方程:相等:dp[i][j] = dp[i - 1][j - 1] + 1,否则dp[i][j] = 0。(即以nums1[i] 和 nums2[j] 结尾的公共子数组为0!因为要求连续!)ps:为了简化代码,我们判断条件是nums[i - 1]和nums[j - 1]!空间压缩:逆序遍历,保证不覆盖。代码实现:class Solution { // 动态规划 public int findLength(int[] nums1, int[] nums2) { int m = nums1.length; int n = nums2.length; if (m == 0 || n == 0) { return 0; } int[][] dp = new int[m + 1][n + 1]; int ans = 0; for (int i = 1; i <= m; i++) { for (int j = 1; j <= n; j++) { if (nums1[i - 1] == nums2[j - 1]) { dp[i][j] = dp[i - 1][j - 1] + 1; } else { dp[i][j] = 0; } ans = Math.max(ans, dp[i][j]); } } return ans; } // 空间压缩:dp[i][j]只依赖dp[i - 1][j - 1],这里压缩到一列! public int findLength(int[] nums1, int[] nums2) { int m = nums1.length; int n = nums2.length; if (m == 0 || n == 0) { return 0; } int[] dp = new int[n + 1]; int ans = 0; for (int i = 1; i <= m; i++) { for (int j = n; j >= 1; j--) { if (nums1[i - 1] == nums2[j - 1]) { dp[j] = dp[j - 1] + 1; } else { dp[j] = 0; } ans = Math.max(ans, dp[j]); } } return ans; } }====================== 子序问题(不连续)=====================1.最长公共子序列(1143 - 中)题目描述:给定两个字符串 text1 和 text2,返回这两个字符串的最长 公共子序列 的长度。如果不存在 公共子序列 ,返回 0 。一个字符串的 子序列 是指这样一个新的字符串:它是由原字符串在不改变字符的相对顺序的情况下删除某些字符(也可以不删除任何字符)后组成的新字符串。例如,"ace" 是 "abcde" 的子序列,但 "aec" 不是 "abcde" 的子序列。两个字符串的 公共子序列 是这两个字符串所共同拥有的子序列。示例 :输入:text1 = "abcde", text2 = "ace" 输出:3 解释:最长公共子序列是 "ace" ,它的长度为 3 。思路:典型动态规划,类比T718,但这里是是不连续的:当前两个字符,我们可以利用之前的结果故,最终结果不需要遍历整个数组,最后一个就是全局最大。代码实现:class Solution { // 动态规划 public int longestCommonSubsequence(String text1, String text2) { int m = text1.length(), n = text2.length(); int[][] dp = new int[m + 1][n + 1]; for (int i = 1; i <= m; i++) { for (int j = 1; j <= n; j++) { if (text1.charAt(i - 1) == text2.charAt(j - 1)) { dp[i][j] = dp[i - 1][j - 1] + 1; } else { dp[i][j] = Math.max(dp[i][j - 1], dp[i - 1][j]); } } } return dp[m][n]; } }2.最长递增子序列(300 - 中)题目描述:给你一个整数数组 nums ,找到其中最长严格递增子序列的长度。子序列是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。例如,[3,6,2,7] 是数组 [0,3,1,6,2,2,7] 的子序列。示例 :输入:nums = [10,9,2,5,3,7,101,18] 输出:4 解释:最长递增子序列是 [2,3,7,101],因此长度为 4 。思路:基本解法是动态规划,对比T674(连续),不同点:由于不连续,当前位置依赖依赖1~i-1位置的值(不能压缩空间)当出现不递增时,我们不做操作,只有递增才遍历更新最大值。dp数组所有元素初始化为1(初始最长递增子序列是其本身)。另外本题最优解:贪心+二分。思路比较巧妙,新建数组 cell,用于保存最长上升子序列。对原序列进行遍历,将每位元素二分插入 cell 中,(注意cell数组严格上升,故可以使用二分查找)。如果插入元素大于cell数组中的最大值,直接加在后边;否则,用它覆盖掉cell数组中比它大的元素中最小的那个(因为已经有序所以可以使用二分查找)。比如:cell中已经有[1, 3, 5],插入2,则覆盖3,cell数组变为[1, 2, 5]总之,思想就是让 cell 中存储比较小的元素。重要,cell 未必是真实的最长上升子序列,但长度是对的!代码实现:class Solution { // 动态规划 public int lengthOfLIS(int[] nums) { int n = nums.length; if (n <= 1) return n; // dp[i]:以i结尾的最长严格递增子序列的长度 int[] dp = new int[n]; Arrays.fill(dp, 1); int ans = 1; for (int i = 1; i < n; i++) { for (int j = 0; j < i; j++) { // 注意:不能进行空间压缩,因为当前位置i依赖1~i-1位置的值 if (nums[i] > nums[j]) { dp[i] = Math.max(dp[i], dp[j] + 1); } } ans = Math.max(ans, dp[i]); } return ans; } public int lengthOfLIS(int[] nums) { int n = nums.length; if (n <= 1) return n; // cell存储比较小的元素 int[] cell = new int[n]; cell[0] = nums[0]; // index记录最长连续递增子序列的结束索引 int index = 0; for (int i = 1; i < n; i++) { int l = 0, r = index; if (nums[i] > cell[index]) { index++; cell[index] = nums[i]; } else { while (l < r) { int mid = l + ((r - l) >> 1); if (nums[i] > cell[mid]) { l = mid + 1; } else { r = mid; } } cell[l] = nums[i]; } } return index + 1; } }3.不相交的线(1035 - 中)题目描述:在两条独立的水平线上按给定的顺序写下 nums1 和 nums2 中的整数。现在,可以绘制一些连接两个数字 nums1[i] 和 nums2[j] 的直线,这些直线需要同时满足满足:nums1[i] == nums2[j]且绘制的直线不与任何其他连线(非水平线)相交。请注意,连线即使在端点也不能相交:每个数字只能属于一条连线。以这种方法绘制线条,并返回可以绘制的最大连线数。示例 :输入:nums1 = [1,4,2], nums2 = [1,2,4] 输出:2 解释:可以画出两条不交叉的线,如上图所示。 但无法画出第三条不相交的直线,因为从 nums1[1]=4 到 nums2[2]=4 的直线将与从 nums1[2]=2 到 nums2[1]=2 的直线相交。思路:本题本质就是求最长公共子序列,与1143相同。代码相同,空间压缩省略。代码实现:class Solution { // 动态规划 public int maxUncrossedLines(int[] nums1, int[] nums2) { int m = nums1.length; int n = nums2.length; if (m == 0 || n == 0) { return 0; } int[][] dp = new int[m + 1][n + 1]; for (int i = 1; i <= m; i++) { for (int j = 1; j <= n; j++) { if (nums1[i - 1] == nums2[j - 1]) { dp[i][j] = dp[i - 1][j - 1] + 1; } else { dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]); } } } return dp[m][n]; } }===================== 子序问题(编辑距离)=====================1.不同的子序列(115 - 难)题目描述:给定一个字符串 s 和一个字符串 t ,计算在 s 的子序列中 t 出现的个数。字符串的一个 子序列 是指,通过删除一些(也可以不删除)字符且不干扰剩余字符相对位置所组成的新字符串。(例如,"ACE" 是 "ABCDE" 的一个子序列,而 "AEC" 不是)题目数据保证答案符合 32 位带符号整数范围。示例 :输入:s = "rabbbit", t = "rabbit" 输出:3 解释: 如下图所示, 有 3 种可以从 s 中得到 "rabbit" 的方案。 (上箭头符号 ^ 表示选取的字母) rabbbit ^^^^ ^^ rabbbit ^^ ^^^^ rabbbit ^^^ ^^^思路:本题是一个字符串匹配问题,即在主串s中找模式串t,要求:寻找s子序列中为t的个数(t是s的子序列)。典型动态规划,关键点:dp[i][j] 代表 T 前 i 字符串可以由 S 前j 字符串组成最多个数。状态转移方程:注:当s[j] == t[i]是,这里的j位置可选也可以不选。即主串中的j的位置。当S[j] == T[i] , dp[i][j] = dp[i-1][j-1] + dp[i][j-1];即只有相等,模式串才移动。当 S[j] != T[i] , dp[i][j] = dp[i][j-1]注意:dp数组开辟长度,因为s和t的子序列还有空字符串存在。代码实现:public int numDistinct(String s, String t) { int sl = s.length(), tl = t.length(); int[][] dp = new int[tl + 1][sl + 1]; // t字符串为空,空串是所有字符串的子集 for (int j = 0; j <= sl; j++) dp[0][j] = 1; for (int i = 1; i <= tl; i++) { for (int j = 1; j <= sl; j++) { if (t.charAt(i - 1) == s.charAt(j - 1)) // j位置选或者不选(输出所有的可能所以是+) dp[i][j] = dp[i - 1][j - 1] + dp[i][j - 1]; else dp[i][j] = dp[i][j - 1]; } } return dp[tl][sl]; }2.判断子序列(392 - 易)题目描述:给定字符串 s 和 t ,判断 s 是否为 t 的子序列。字符串的一个子序列是原始字符串删除一些(也可以不删除)字符而不改变剩余字符相对位置形成的新字符串。(例如,"ace"是"abcde"的一个子序列,而"aec"不是)。进阶:如果有大量输入的 S,称作 S1, S2, ... , Sk 其中 k >= 10亿,你需要依次检查它们是否为 T 的子序列。在这种情况下,你会怎样改变代码?示例 :输入:s = "abc", t = "ahbgdc" 输出:true思路:本题动态规划比上题简单。本题可以直接根据字符串的String.indexOf(char ch, int index):查找字符在字符串中从指定位置开始第一次出现的索引,没有则返回-1。也可以使用双指针进行比较,实现比较简单(推荐!)。动态规划(效率较低):字符串匹配问题,关键点:dp[i][j]:s 的前 i 个字符与 t的前 j 个字符中公共子序列的长度(匹配的长度)状态转移方程:如果字符相同dp[i][j] = dp[i - 1][j - 1] + 1,否则dp[i][j] = dp[i][j - 1]。ps:这里是判断 s 是否为 t 的子序列,当最后公共子序列长度 = m(s的长度),说明是子序列。代码实现:class Solution { // 库函数:判断索引是否存在 public boolean isSubsequence(String s, String t) { int index = -1; for (char ch : s.toCharArray()) { index = t.indexOf(ch, index + 1); if (index == -1) { return false; } } return true; } // 双指针 public boolean isSubsequence(String s, String t) { int m = s.length(), n = t.length(); int i = 0, j = 0; while (i < m && j < n) { if (s.charAt(i) == t.charAt(j)) { i++; } j++; } return i == m; } // 动态规划 public boolean isSubsequence(String s, String t) { int m = s.length(), n = t.length(); // dp[i][j]:s的前i个字符是否与t的前j个字符公共子序列的长度(匹配的长度) int[][] dp = new int[m + 1][n + 1]; for (int i = 1; i <= m; i++) { for (int j = 1; j <= n; j++) { if (s.charAt(i - 1) == t.charAt(j - 1)) { dp[i][j] = dp[i - 1][j - 1] + 1; } else { dp[i][j] = dp[i][j - 1]; } } } return dp[m][n] == m ? true : false; } }待补充。。583, 72===================== 子序问题(回文)=====================1.最长回文子序列(516 - 中)题目描述:给定一个字符串 s ,找到其中最长的回文子序列,并返回该序列的长度。可以假设 s 的最大长度为 1000 。示例 :输入:"bbbab" 输出:4 一个可能的最长回文子序列为 "bbbb"。思路:本题目标是返回最长的回文子序列的长度,使用动态规划:dp数组:在子串 s[i..j] 中,最长回文子序列的长度为 dp[i][j],最终目标是 dp[0][n - 1]。状态转移方程比较简单,相同添加(长度+2),不同选两个边界加与不加的最大值。if (s[i] == s[j]) // 它俩一定在最长回文子序列中 dp[i][j] = dp[i + 1][j - 1] + 2; else // s[i+1..j] 和 s[i..j-1] 谁的回文子序列更长? dp[i][j] = max(dp[i + 1][j], dp[i][j - 1]);需要注意的是:根据一般位置的依赖关系(每个元素可能依赖左/下/左下元素),我们需要从左下角向右上角填元素。只需要从对角线开始(右上部分,即i <= j)。代码实现:class Solution { public int longestPalindromeSubseq(String s) { int n = s.length(); char[] cs = s.toCharArray(); int[][] dp = new int[n][n]; for (int i = 0; i < n; ++i) { dp[i][i] = 1; } for (int i = n - 1; i >= 0; i--) { for (int j = i + 1; j < n; j++) { if (cs[i] == cs[j]) { // 相等直接加入 dp[i][j] = dp[i + 1][j - 1] + 2; } else { dp[i][j] = Math.max(dp[i][j - 1], dp[i + 1][j]); } } } return dp[0][n - 1]; } }2.最长回文子串(5 - 中)题目描述:给你一个字符串 s,找到 s 中最长的回文子串。示例 :输入:s = "babad" 输出:"bab" 解释:"aba" 同样是符合题意的答案。思路:本题比较容易想到的是中心扩散法:从每个位置向两边扩散,遇到不是回文的停止扩散。记录最长的回文子串.要分奇数中心扩散和偶数中心扩散两种情况(取最大值)。当上述最大值大于当前最长回文子串,更新最长会子串开始和结束位置。遍历每个位置,然后扩散,时间复杂度O(N^2)。显然上述会有大量的重复计算,可以使用动态规划进行优化:dp[i][j] 表示:子串 s[i..j] 是否为回文子串,这里子串 s[i..j] 定义为左闭右闭区间,即可以取到 s[i] 和 s[j]。根据头尾字符是否相等,需要分类讨论:dp[i][j] = (s[i] == s[j]) and (dp[i + 1][j - 1] or j - i < 3),包含奇偶情况。代码实现:class Solution { // 中心扩展法 public String longestPalindrome(String s) { char[] cs = s.toCharArray(); int len = cs.length; if (len < 2) { return s; } int start = 0; int end = 0; for (int i = 0; i < len; i++) { int len1 = extendSubString(cs, i, i); int len2 = extendSubString(cs, i, i + 1); int max = Math.max(len1, len2); if (max > end - start) { // 对于(如a b b a),i代表左b位置 start = i - (max - 1) / 2; end = i + max / 2; } } return s.substring(start, end + 1); } private int extendSubString(char[] cs, int left, int right) { while (left >= 0 && right < cs.length && cs[left] == cs[right]) { left--; right++; } // right - left + 1 - 2 return right - left - 1; } // 动态规划 public String longestPalindrome(String s) { char[] cs = s.toCharArray(); int len = s.length(); boolean[][] dp = new boolean[len][len]; int start = 0; int end = 0; int maxLen = 0; for (int j = 0; j < len; j++) { for (int i = 0; i < j; i++) { if (cs[i] == cs[j] && (dp[i + 1][j - 1] || j - i < 3)) { dp[i][j] = true; if (j - i + 1 > maxLen) { maxLen = j - i + 1; start = i; end = j; } } } } return s.substring(start, end + 1); } }
Java语言的特点,与c++的区别(1)Java源码会先经过编译器编译成字节码(class文件),然后由JVM中内置的解释器解释成机器码。而C++经过一次编译就形成机器码。C++比Java执行效率快,但是Java可以利用JVM跨平台(一次编译,到处运行!)(2)Java是纯面向对象的语言,所有代码都必须在类定义。而C++中还有面向过程的东西,比如全局变量和全局函数。(3)C++中有指针,Java中不提供指针直接访问内存,内存更加安全,但是有引用。(4)C++支持多继承,Java类都是单继承。但是继承都有传递性,同时Java中的接口是多继承,接口可以多实现。(5)Java 中内存的分配和回收由Java虚拟机实现(自动内存管理机制),会自动清理引用数为0的对象。而在 C++ 编程时,则需要花精力考虑如何避免内存泄漏。(6)C++运算符可以重载,但是Java中不可以。同时C++中支持强制自动转型,Java中不行,会出现ClassCastException(类型不匹配)。ps:在 C 语⾔中,字符串或字符数组最后都会有⼀个额外的字符‘\0’来表示结束。但是,Java 语⾔中没有结束符这⼀概念。 这是⼀个值得深度思考的问题,具体原因推荐看这篇⽂章:https://blog.csdn.net/sszgg2006/article/details/49148189总结:java编译形成字节码,平台无关性(扩展性好),c++一次编译形成机器码(效率高);c++是指针,java是对象的引用;c++多继承,java单继承,多实现;c++需要自己花费时间分配内存和垃圾回收,避免内存泄露,java由jvm实现自动内存管理(分配和回收)。**ps:Java平台无关性体现在两个方面:JVM: Java 编译器可生成与计算机体系结构无关的字节码指令,字节码文件不仅可以轻易地在任何机器上解释执行,还可以动态地转换成本地机器代码,转换是由 JVM 实现的,JVM 是平台相关的,屏蔽了不同操作系统的差异。语言规范: 基本数据类型大小有明确规定,例如 int 永远为 32 位,而 C/C++ 中可能是 16 位、32 位,也可能是编译器开发商指定的其他大小。Java 中数值类型有固定字节数,二进制数据以固定格式存储和传输,字符串采用标准的 Unicode 格式存储。JDK JRE JVMjdk:开发者工具(针对开发人员),它是功能齐全的 Java SDK。它拥有 JRE 所拥有的一切,还有编译器(javac)和工具(如 javadoc 和 jdb)。它能够创建和编译程序。jre:运行时环境(需要运行java程序的人员),它是运行已编译 Java 程序所需的所有内容的集合,包括 Java 虚拟机(JVM),Java 类库,java 命令和其他的一些基础构件。但是,它不能用于创建新程序。jvm:运行java字节码的虚拟机,JVM 有针对不同系统的特定实现(Windows,Linux,macOS),目的是使用相同的字节码,它们都会给出相同的结果。字节码和不同系统的 JVM 实现是 Java 语言“一次编译,随处可以运行”的关键所在!!!三者关系:什么是字节码?采用字节码的好处是什么?编译型语言:程序在执行之前需要一个专门的编译过程,把程序编译成 为机器语言的文件,运行时不需要重新翻译,直接使用编译的结果就行了。程序执行效率高,依赖编译器,跨平台性差些。如C、C++、Delphi等。解释型语言:程序不需要编译,程序在运行时才翻译成机器语言,每执行一次都要翻译一次。程序执行效率比较低,依赖解释器,跨平台性好。如Python/JavaScript / Perl /Shell等。java中的编译器和解释器:Java虚拟机是在机器和编译程序之间加入了一层抽象的虚拟的机器。这台虚拟的机器在任何平台上都提供给编译程序一个的共同的接口。编译程序只需要面向虚拟机,生成虚拟机能够理解的代码,然后由解释器来将虚拟机代码转换为特定系统的机器码执行。在Java中,这种供虚拟机理解的代码叫做字节码(即扩展名为 .class的文件),它不面向任何特定的处理器,只面向虚拟机。每一种平台的解释器是不同的,但是实现的虚拟机是相同的。Java源程序经过编译器编译后变成字节码,字节码由虚拟机解释执行,虚拟机将每一条要执行的字节码送给解释器,解释器将其翻译成特定机器上的机器码,然后在特定的机器上运行。Java对不同的操作系统有不同的JVM,Java编译器可以生成与平台无关的字节码指令,字节码文件不仅可以轻易地在任何机器上解释执行,还可以动态地转换成本地机器代码,转换是由 JVM 实现的,所以 Java实现了真正意义上的跨平台!注意:字节码到机器码这一步,JVM 类加载器首先加载字节码文件,然后通过解释器逐行解释执行,这种方式的执行速度会相对比较慢。而且,有些方法和代码块是经常需要被调用的(也就是所谓的热点代码),所以后面引进了 JIT 编译器,而 JIT 属于运行时编译。当 JIT 编译器完成第一次编译后,其会将运行频率高的字节码对应的机器码保存下来,下次可以直接使用。而我们知道,机器码的运行效率肯定是高于 Java 解释器的。这也解释了我们为什么经常会说 Java 是编译与解释共存的语言。采用字节码好处:Java语言通过字节码的方式,在一定程度上解决了传统解释型语言执行效率低的问题,同时又保留了解释型语言可移植的特点。由于字节码并不专对一种特定的机器,因此,Java程序无须重新编译便可在多种不同的计算机上运行。ps:HotSpot 采⽤了惰性评估(Lazy Evaluation)的做法,根据⼆⼋定律,消耗⼤部分系统资源的只有那⼀⼩部分的代码(热点代码),⽽这也就是 JIT 所需要编译的部分。JVM 会根据代码每次被执⾏的情况收集信息并相应地做出⼀些优化,因此执⾏的次数越多,它的速度就越快。JDK 9 引⼊了⼀种新的编译模式AOT(Ahead of Time Compilation),它是直接将字节码编译成机器码,这样就避免了 JIT 预热等各⽅⾯的开销。JDK ⽀持分层编译和 AOT 协作使⽤。但是 ,AOT 编译器的编译质量是肯定⽐不上 JIT 编译器的。创建对象的方式有哪些?通过 new 关键字:这是最常用的一种方式,通过 new 关键字调用类的有参或无参构造方法来创建对象。比如 Object obj = new Object();通过 Class 类的 newInstance() 方法:这种默认是调用类的无参构造方法创建对象。比如 Person p2 = (Person) Class.forName("com.ys.test.Person").newInstance();通过 Constructor 类的 newInstance 方法:这和第二种方法类时,都是通过反射来创建对象。通过 java.lang.relect.Constructor 类的 newInstance() 方法指定某个构造器来创建对象。Person p3 = (Person) Person.class.getConstructors()[0].newInstance();利用 Clone 方法:Clone 是 Object 类中的一个方法,无论何时我们调用一个对象的clone方法,JVM就会创建一个新的对象,将前面的对象的内容全部拷贝进去。通过 对象A.clone() 方法会创建一个内容和对象 A 一模一样的对象 B,clone 克隆,顾名思义就是创建一个一模一样的对象出来。Person p4 = (Person) p3.clone();反序列化: 当我们序列化和反序列化一个对象,JVM会给我们创建一个单独的对象。序列化是把堆内存中的 Java 对象数据,通过某种方式把对象存储到磁盘文件中或者传递给其他网络节点(在网络上传输)。而反序列化则是把磁盘文件中的对象数据或者把网络节点上的对象数据,恢复成Java对象模型的过程。总结:前三种是通过构造函数创建对象,后两个不需要。深拷贝or浅拷贝?拷贝的引入:引用拷贝:创建一个指向对象的引用变量的拷贝,即创建一个指向该对象的新的引用变量,teacher与otherteacher指向内存地址相同(只是引用不同),所以肯定指向一个对象Teacher("Taylor",26)。Teacher teacher = new Teacher("Taylor",26); Teacher otherteacher = teacher; System.out.println(teacher); System.out.println(otherteacher); // -----------结果----------------- blog.Teacher@355da254 blog.Teacher@355da254对象拷贝:创建对象本身的副本,输出内存地址不同,即创建了新的对象(不是把原对象的地址赋给一个新的引用变量)。Teacher teacher = new Teacher("Swift",26); Teacher otherteacher = (Teacher)teacher.clone(); System.out.println(teacher); System.out.println(otherteacher); // -----------结果----------------- blog.Teacher@355da254 blog.Teacher@4dc63996注意:深拷贝和浅拷贝都是对象拷贝,都是针对一个已有的对象!对于基本数据类型(元类型),两种拷贝方式都是对值字段的复制(值传递),两者没有区别。浅拷贝:被复制对象的所有变量都含有与原来的对象相同的值,而所有的对其他对象的引用仍然指向原来的对象。即对象的浅拷贝会对“主”对象进行拷贝,但不会复制主对象里面的对象。"里面的对象“会在原来的对象和它的副本之间共享。简言之,浅拷贝仅仅复制所考虑的对象,但不复制它所引用的对象(单层的拷贝),故原始对象及其副本引用的同一个对象。深拷贝:深拷贝是一个整个独立的对象拷贝,深拷贝会拷贝所有的属性,并拷贝属性指向的动态分配的内存。当对象和它所引用的对象一起拷贝时即发生深拷贝。深拷贝相比于浅拷贝速度较慢并且花销较大。简言之,深拷贝把要复制的对象所引用的对象都复制了一遍(多层的拷贝),修改其中一个对象的任何内容,不会影响另一个对象的内容。浅拷贝实现方式:首先让定义的实体类实现Cloneable接口。然后重写clone方法,将clone方法的修饰符由protected改为public。这样就可以通过调用clone方法进行浅拷贝。深拷贝实现方式:首先是将引用的实体类也实现Cloneable接口(同时重写clone方法,也是修改修饰符为public)。然后同样是让定义的实体类实现Cloneable接口。然后重写clone方法,将clone方法的修饰符由protected改为public。但是方法体需要进行重写,将引用的对象属性调用它本身的clone方法进行赋值,然后将赋值后的对象返回即可。什么是反射(reflection)机制?应用场景与优缺点。反射是框架设计的灵魂。使用的前提条件:必须先得到代表字节码的Class类型对象官方解释:反射是在运行状态中,对于任意一个类,都能够知道这个类的所有属性和方法;对于任意一个对象,都能够调用它的任意一个属性和方法;这种动态获取的信息以及动态调用对象的属性方法的功能称为 Java 语言的反射机制。总结:在运行时,构造任意类的对象,动态的获取类的信息并调用类的属性和方法。如图是类的正常加载过程:反射的原理在于Class对象。熟悉一下加载的时候:Class对象的由来是将class文件读入内存,并为之创建一个Class对象,反射的本质:得到Class对象反向获取信息及动态调用对象的属性或者方法。既然Java反射可以访问和修改私有成员变量,那封装成private还有意义么?从OOP封装的角度,可以理解为private只是一种约定(我们要去遵守),是一种设计规范。反射调用私有方法和属性:getDeclaredMethods():调用私有方法getDeclaredFields():获取私有变量值getConstructor():得到有参的构造函数注意:setAccessible(true);获取访问权限!获取Class对象的方式(如何使用反射?):通过Object类中的getClass方法:因为所有类都继承Object类。getClass方法:返回一个对象的运行时类,进而可以通过Class获取这个类中的相关属性和方法;任何数据类型(包括基本数据类型)都有一个“静态”的class属性;通过Class类的静态方法:Class.forName(String className)(常用,即通过全限定类名(绝对路径/真实路径)创建Class对象)package reflection; public class Reflection { public static void main(String[] args) { //第一种方式获取Class对象 Student stu1 = new Student(); //通过new方式(构造器床架对象)产生一个Student对象,一个Class对象。 Class stuClass = stu1.getClass(); //Object类中的getClass()方法,获取Class对象 System.out.println(stuClass.getName()); //第二种方式获取Class对象 Class stuClass2 = Student.class; System.out.println(stuClass == stuClass2); //判断第一种方式获取的Class对象和第二种方式获取的是否是同一个 //第三种方式获取Class对象 try { Class stuClass3 = Class.forName("reflection.Student"); //注意此字符串必须是真实路径,包名.类名 System.out.println(stuClass3 == stuClass2); //判断三种方式是否获取的是同一个Class对象 } catch (ClassNotFoundException e) { e.printStackTrace(); } } }注意:在运行期间,一个类,只有一个Class对象产生。三种方式常用第三种,第一种对象都有了还要反射干什么。第二种需要导入类的包,依赖太强,不导包就抛编译错误。一般都第三种,可以通过传入一个字符串(全限定类名),也可写在配置文件中等多种方法。使用反射调用类中的方法,分为三种情况:调用静态方法调用公共方法调用私有方法package com.interview.chapter4; class MyReflect { // 静态方法 public static void staticMd() { System.out.println("Static Method"); } // 公共方法 public void publicMd() { System.out.println("Public Method"); } // 私有方法 private void privateMd() { System.out.println("Private Method"); } public static void main(String[] args) { // 反射调用静态方法 Class myClass = Class.forName("com.interview.chapter4.MyReflect"); Method method = myClass.getMethod("staticMd"); method.invoke(myClass); // 反射调用公共方法 Class myClass = Class.forName("com.interview.chapter4.MyReflect"); // 创建实例对象(相当于 new ) Object instance = myClass.newInstance(); Method method2 = myClass.getMethod("publicMd"); method2.invoke(instance); // 反射调用私有方法 Class myClass = Class.forName("com.interview.chapter4.MyReflect"); // 创建实例对象(相当于 new ) Object object = myClass.newInstance(); Method method3 = myClass.getDeclaredMethod("privateMd"); method3.setAccessible(true); method3.invoke(object); } }反射使用总结:通过 Class.forName("全限定类名"),获取调用类的Class对象;反射获取类实例要通过 newInstance(),相当于 new 一个新对象;反射获取方法要通过 getMethod(),获取到类方法之后使用 invoke() 对类方法进行调用(执行一个方法)。如果是类方法为私有方法的话,则需要通过 setAccessible(true) 来修改方法的访问限制,并且获取方法使用getDeclaredMethod()。反射应用场景:编程工具 IDEA 或 Eclipse 等,在写代码时会有代码(属性或方法名)提示,就是因为使用了反射;很多知名框架都用到反射机制,通过配置加载不同类,在不修改源码的情况下,注入属性或者调用方法。例如,Spring 可以通过配置来加载不同的类,调用不同的方法,代码如下所示:<bean id="person" class="com.spring.beans.Person" init-method="initPerson"> </bean>例如,MyBatis 在 Mapper 使用外部类的 Sql 构建查询时,代码如下所示:@SelectProvider(type = PersonSql.class, method = "getListSql") List<Person> getList(); class PersonSql { public String getListSql() { String sql = new SQL() {{ SELECT("*"); FROM("person"); }}.toString(); return sql; } }数据库连接池,也会使用反射调用不同类型的数据库驱动,代码如下所示:String url = "jdbc:mysql://127.0.0.1:3306/mydb"; String username = "root"; String password = "root"; Class.forName("com.mysql.jdbc.Driver"); Connection connection = DriverManager.getConnection(url, username, password);Web服务器中利用反射调用了Sevlet的服务方法。另外,像 Java 中的一大利器 注解 的实现也用到了反射。为什么你使用 Spring 的时候 ,一个@Component注解就声明了一个类为 Spring Bean 呢?为什么你通过一个 @Value注解就读取到配置文件中的值呢?究竟是怎么起作用的呢?这些都是因为你可以基于反射分析类,然后获取到类/属性/方法/方法的参数上的注解。你获取到注解之后,就可以做进一步的处理。优缺点:优点:可以动态执行,节省资源在运行期间根据业务功能动态执行方法、访问属性,最大限度发挥了java的灵活性。缺点:对性能有影响,这类操作总是慢于直接执行java代码,要先生成Class对象。动态代理(设计模式),常见的两种动态代理的实现?写在前:静态代理:每个代理类只能为一个接口服务,这样会产生很多代理类。普通代理模式,代理类Proxy的Java代码在JVM运行时就已经确定了,也就是静态代理在编码编译阶段就确定了Proxy类的代码。而动态代理是指在JVM运行过程中,动态的创建一个类的代理类,并实例化代理对象。动态代理:首先它是一个代理机制,代理可以看作是对调用目标的一个包装,这样我们对目标代码的调用不是直接发生的,而是通过代理完成,通过代理可以让调用者与实现者之间解耦。比如进行 RPC 调用,通过代理,可以提供更加友善的界面;还可以通过代理,做一个全局的拦截器。代理是一种常用的设计模式,其目的就是为其他对象提供一个代理以控制对某个对象的访问。解决的问题:直接访问一个对象带来的问题。应用场景:经典应用有 Spring AOP数据查询、例如,依赖注入 @Autowired 和事务注解 @Transactional 等,都是利用动态代理实现的;封装一些rpc调用;通过代理实现一个全局拦截器等。动态代理与反射的关系:反射可以用来实现动态代理,但动态代理还有其他的实现方式,比如 ASM(一个短小精悍的字节码操作框架)、cglib 等。jdk动态代理:在java的类库中,java.util.reflect.Proxy类就是其用来实现动态代理的顶层类。可以通过Proxy类的静态方法Proxy.newProxyInstance()方法动态的创建一个类的代理类,并实例化。由它创建的代理类都是Proxy类的子类。JDK动态代理实现步骤:编写需要被代理的类和接口编写代理类,需要实现 InvocationHandler 接口,重写 invoke() 方法;使用Proxy.newProxyInstance(ClassLoader loader, Class<?>[] interfaces, InvocationHandler h)动态创建代理类对象,通过代理类对象调用业务方法。注意: JDK Proxy 只能代理实现接口的类(即使是 extends 继承类也是不可以代理的)。cglib实现动态代理:CGLIB是一个高性能的代码生成类库,被Spring广泛应用。其底层是通过ASM字节码框架生成类的字节码,达到动态创建类的目的。ps:Spring AOP动态代理的实现方式有两种:cglib 和 JDK 原生动态代理。cglib动态代理的实现步骤:创建被代理的目标类。创建一个方法拦截器类,并实现CGLIB的MethodInterceptor接口的intercept()方法。通过Enhancer类增强工具,创建目标类的代理类。利用代理类进行方法调用,就像调用真实的目标类方法一样。要是用 cglib 实现要添加对 cglib 的依赖,如果是 maven 项目的话,直接添加以下代码:<dependency> <groupId>cglib</groupId> <artifactId>cglib</artifactId> <version>3.2.12</version> </dependency>cglib 的具体实现,请参考以下代码:class Panda { public void eat() { System.out.println("The panda is eating"); } } class CglibProxy implements MethodInterceptor { private Object target; // 代理对象 public Object getInstance(Object target) { this.target = target; Enhancer enhancer = new Enhancer(); // 设置父类为实例类 enhancer.setSuperclass(this.target.getClass()); // 回调方法 enhancer.setCallback(this); // 创建代理对象 return enhancer.create(); } public Object intercept(Object o, Method method, Object[] objects, MethodProxy methodProxy) throws Throwable { System.out.println("调用前"); Object result = methodProxy.invokeSuper(o, objects); // 执行方法调用 System.out.println("调用后"); return result; } } public static void main(String[] args) { // cglib 动态代理调用 CglibProxy proxy = new CglibProxy(); Panda panda = (Panda)proxy.getInstance(new Panda()); panda.eat(); }由以上代码可以知道,cglib 的调用通过实现 MethodInterceptor 接口的 intercept 方法,调用 invokeSuper 进行动态代理的。它可以直接对普通类(可以有子类的普通类,但不能代理最终类)进行动态代理,并不需要像 JDK 代理那样,需要通过接口来完成, Spring 的动态代理也是通过 cglib 实现的。注意:cglib 底层是通过子类继承被代理对象的方式实现动态代理的(即,动态的生成被代理类的子类),因此代理类不能是最终类(final),否则就会报错 java.lang.IllegalArgumentException: Cannot subclass final class xxx。JDK原生态动态代理和CGlib区别JDK 原生动态代理:只能代理实现接口的类(即使是 extends 继承类也是不可以代理的),不需要添加任何依赖,可以平滑的支持 JDK 版本的升级;cglib 不需要实现接口,底层通过子类继承被代理对象的方式实现动态代理。可以直接代理普通类(但不能代理final修饰的类),需要添加依赖包,性能更高。两者的区别:JDK 动态代理:基于 Java 反射机制实现,必须要实现了接口的业务类(extends 继承类不可代理)才能用这种办法生成代理对象。CGLib 动态代理:基于 ASM 机制实现,通过生成业务类的子类作为代理类(本质是子类继承被代理类的方法),所以代理的类不能是 final 修饰的。JDK Proxy 的优势:最小化依赖关系,减少依赖意味着简化开发和维护,JDK 本身的支持,可能比 CGLib 更加可靠。平滑进行 JDK 版本升级,而字节码类库通常需要进行更新以保证在新版 Java 上能够使用。代码实现简单。基于类似 CGLib 框架的优势:无需实现接口,达到代理类无侵入。故CGLib适合那些没有接口抽象的类代理。只操作我们关心的类,而不必为其他相关类增加工作量。ps:为什么 JDK 原生的动态代理必须要通过接口来完成?这是由于 JDK 原生设计的原因,动态代理的实现方法 newProxyInstance() 的源码如下:/** * ...... * @param loader the class loader to define the proxy class * @param interfaces the list of interfaces for the proxy class to implement * ...... */ @CallerSensitive public static Object newProxyInstance(ClassLoader loader, Class<?>[] interfaces, InvocationHandler h) throws IllegalArgumentException { // 省略其他代码前两个参数的声明:loader:为类加载器,也就是 target.getClass().getClassLoader()interfaces:接口代理类的接口实现列表因此,要使用 JDK 原生的动态只能通过实现接口来完成。什么是注解,什么是元注解?注解是一种标记,使类或接口附加额外信息,帮助编译器和 JVM 完成一些特定功能,例如 @Override 标识一个方法是重写方法。原理:注解的底层也是使用反射实现的。可以发现注解的本质就是接口,这个接口继承了jdk里面的Annotation接口。元注解是自定义注解的注解,例如:@Target:约束作用位置,值是 ElementType 枚举常量,包括 METHOD 方法、VARIABLE 变量、TYPE 类/接口、PARAMETER 方法参数、CONSTRUCTORS 构造方法和 LOACL_VARIABLE 局部变量等。@Rentention:约束生命周期,值是 RetentionPolicy 枚举常量,包括 SOURCE 源码、CLASS 字节码和 RUNTIME 运行时。@Documented:表明这个注解应该被 javadoc 记录。注解的作用:生成文档,常用的有@param@return等。替代配置文件的作用,尤其是在spring等一些框架中,使用注解可以大量的减少配置文件的数量。比如@Configuration标识这是一个配置类,@ComponentScan("spring.ioc.stu")配置包扫描路径 :@Configuration @ComponentScan("spring.ioc.stu") public class SpringConfiguration { xxx; }检查代码的格式,如@Override,标识某一个方法是否覆盖了它的父类的方法。三大内置注解:@Deprecated 已过期,表示方法是不被建议使用的@Override 重写,标识覆盖它的父类的方法@SuppressWarnings 压制警告,抑制警告
1.二进制求和(67-易)给你两个二进制字符串,返回它们的和(用二进制表示)。输入为 非空 字符串且只包含数字 1 和 0。示例:输入: a = "11", b = "1" 输出: "100"思路:使用StringBuilder进行字符串拼接,最后结果不要忘记进行反转对当前位a、b加和sum取余和取模分别计算当前位的拼接值和进位值carry都遍历结束注意判断是否存在进位值代码:public String addBinary(String a, String b) { StringBuilder ans = new StringBuilder(); int carry = 0; for (int i = a.length() - 1, j = b.length() - 1; i >= 0 || j >= 0; i--, j--) { int sum = carry; sum += i >= 0 ? a.charAt(i) - '0' : 0; sum += j >= 0 ? b.charAt(j) - '0' : 0; ans.append(sum % 2); carry = sum / 2; } ans.append(carry == 1 ? carry : ""); return ans.reverse().toString(); }2.加一(66 - 易)给定一个由 整数 组成的 非空 数组所表示的非负整数,在该数的基础上加一。最高位数字存放在数组的首位, 数组中每个元素只存储单个数字。你可以假设除了整数 0 之外,这个整数不会以零开头。示例:输入:digits = [1,2,3] 输出:[1,2,4] 解释:输入数组表示数字 123。思路:注意进位分为下边三种情况:末位无进位,则末位加一即可:因为末位无进位,前面也不可能产生进位,比如 45 => 46末位有进位,但在中间位置进位停止:则需要找到进位的典型标志,即为当前位 %10后为 0,则前一位加 1,直到不为 0 为止,比如 499 => 500末位有进位,并且一直进位到最前方导致结果多出一位:对于这种情况,需要在第 2 种情况遍历结束的基础上,进行单独处理,比如 999 => 1000ps:模拟加法应该进行倒序遍历。代码:public int[] plusOne(int[] digits) { int len = digits.length; for(int i = len - 1; i >= 0; i--) { digits[i]++; digits[i] %= 10; if(digits[i]!=0) return digits; } digits = new int[len + 1]; digits[0] = 1; return digits; }3.单链表加1(369-中)题目描述:用一个 非空 单链表来表示一个非负整数,然后将这个整数加一。 你可以假设这个整数除了 0 本身,没有任何前导的 0。 这个整数的各个数位按照 高位在链表头部、低位在链表尾部 的顺序排列。示例:输入: [1,2,3] 输出: [1,2,4]思路:****类似数组+1,注意反转链表,一般有三种情况:尾部不为9,直接+ 1;尾部为9,进位全部为9,需要新建一个链表头还有一种快慢指针(双指针)的解法,比较巧妙:fast.val != 9,慢指针移动到当前快指针处fast.val = 9,慢指针原地不动遍历结束,慢指针的值加一,慢指针后续所有节点值设置为0!代码:public class Solution003 { /** * 链表加一,正向输出 */ public static class ListNode { int val; ListNode next; public ListNode() { } public ListNode(int val) { this.val = val; } public ListNode(int val, ListNode next) { this.val = val; this.next = next; } } public static void main(String[] args) { // 测试 1->4->3->2->NULL ListNode node = new ListNode(9); node.next = new ListNode(9); // ListNode head = plusOne(node); ListNode head = plusOne_1(node); while (head != null) { System.out.print(head.val); head = head.next; } } public static ListNode plusOne(ListNode head) { ListNode reverseHead = reverse(head); ListNode cur = reverseHead; ListNode lastNode = null; int add = 0, c = 1; while (cur != null) { int sum = cur.val + add + c; add = c = 0; if (sum == 10) { cur.val = 0; add = 1; } else { cur.val = sum; } if (cur.next == null) { lastNode = cur; } cur = cur.next; } // 特判,需要新建头结点的情况 if (add > 0) { lastNode.next = new ListNode(add); } return reverse(reverseHead); } public static ListNode plusOne_1(ListNode head) { if (head == null) { return null; } ListNode slow = new ListNode(0); ListNode fast = head; slow.next = fast; while (fast != null) { if (fast.val != 9) { slow = fast; } fast = fast.next; } slow.val += 1; ListNode cur = slow.next; while (cur != null) { cur.val = 0; cur = cur.next; } return slow.next == head ? slow : head; } public static ListNode reverse(ListNode head) { if (head == null) { return null; } ListNode pre = null, next; while (head != null) { next = head.next; head.next = pre; pre = head; head = next; } return pre; } }4.链表求和(面试题 02.05)给定两个用链表表示的整数,每个节点包含一个数位。这些数位是反向存放的,也就是个位排在链表首部。编写函数对这两个整数求和,并用链表形式返回结果。链表不能修改进阶:思考一下,假设这些数位是正向存放的,又该如何解决呢?示例:输入:(7 -> 1 -> 6) + (5 -> 9 -> 2),即617 + 295 输出:2 -> 1 -> 9,即912 进阶: 输入:(7 -> 2 -> 4 -> 3) + (5 -> 6 -> 4) 即7243 + 564 输出:7 -> 8 -> 0 -> 7 即7807思路:对于原问题,常规解法,注意组织结果链表使用虚拟节点(结果要求逆序)。进阶问题:使用两个栈,倒序的取出链表的值使用头插法组织新链表!(结果要求正序)代码:原问题public ListNode addTwoNumbers(ListNode l1, ListNode l2) { int sum, carry = 0; ListNode dummyHead = new ListNode(0); ListNode pre = dummyHead; while (l1 != null || l2 != null || carry > 0) { sum = carry; sum += l1 != null ? l1.val : 0; sum += l2 != null ? l2.val : 0; carry = sum / 10; pre.next = new ListNode(sum % 10); pre = pre.next; l1 = l1 != null ? l1.next : l1; l2 = l2 != null ? l2.next : l2; } return dummyHead.next; }进阶问题:public ListNode addTwoNumbers(ListNode l1, ListNode l2) { Stack<Integer> stack1 = new Stack<>(); Stack<Integer> stack2 = new Stack<>(); while (l1 != null) { stack1.push(l1.val); l1 = l1.next; } while (l2 != null) { stack2.push(l2.val); l2 = l2.next; } int carry = 0; ListNode head = null; while (!stack1.isEmpty() || !stack2.isEmpty() || carry > 0) { int sum = carry; sum += (stack1.isEmpty() ) ? 0 : stack1.pop(); sum += (stack2.isEmpty() ) ? 0 : stack2.pop(); carry = sum / 10; ListNode node = new ListNode(sum % 10); // 头插节点构造链表 node.next = head; head = node; } return head; }5.面试题 17.01. 不用加号的加法设计一个函数把两个数字相加。不得使用 + 或者其他算术运算符。思路:本题不能使用运算符进行求解,考察点是位运算:运算:相同为0,不同为1。两个数异或,**ab 计算不考虑进位的二进制加法结果**&运算:两个都是1与的结果为1,否则为0。a&b 计算所有进位的位,左移(<<)再异或就是进一位的结果,以此类推代码:public int add(int a, int b) { int tmp; while (b != 0) { tmp = a ^ b; b = (a & b) << 1; a = tmp; } return a; }6.字符串相加(415 - 易)给定两个字符串形式的非负整数 num1 和num2 ,计算它们的和。不能使用库函数等。思路:与上面类似,模拟竖向加法,注意:StringBuilder可以显著提高效率。代码:public String addStrings(String num1, String num2) { StringBuilder ans = new StringBuilder(""); int i = num1.length() - 1, j = num2.length() - 1; int carry = 0, sum; while (i >= 0 || j >= 0 || carry != 0) { sum = carry; sum += i >= 0 ? num1.charAt(i) - '0' : 0; sum += j >= 0 ? num2.charAt(j) - '0' : 0; ans.append(sum % 10); carry = sum / 10; i--; j--; } return ans.reverse().toString(); }6.字符串相减(面试题补充)给定两个字符串形式的非负整数 num1 和num2 ,计算它们的差。注意:num1 和num2 都只会包含数字 0-9num1 和num2 都不包含任何前导零你不能使用任何內建 BigInteger 库思路:本题与上题唯一不同的是大数相减可能产生负数,分两步:判断两个字符串的大小(对应大数)模拟相减(根据大小),返回结果注意细节:相减过后,前导0要去除对于字符串"0" ,不需加负号ps:compareTo()方法依次比较两个字符串的大小,不同则返回对应字符的ascii码差值,停止;当一方比较完成则比较长度。代码:public class Solution001 { public static void main(String[] args) { // String num1 = "121", num2 = "120"; String num1 = "119", num2 = "123"; System.out.println(num1.compareTo(num2)); System.out.println(subString(num1, num2)); } public static String subString(String num1, String num2) { String ans = null; if (isLess(num1, num2)) { ans = sub(num2, num1); if (ans != "0") { StringBuilder sb = new StringBuilder(ans); sb.insert(0, '-'); ans = sb.toString(); } } else { ans = sub(num1, num2); } return ans; } // 比较两个数的大小 public static boolean isLess(String num1, String num2) { return num1.compareTo(num2) < 0; } public static String sub(String num1, String num2) { StringBuilder sb = new StringBuilder(); int i = num1.length() - 1, j = num2.length() - 1; int borrow = 0; while (i >= 0 || j >= 0) { int x = i >= 0 ? num1.charAt(i) - '0' : 0; int y = j >= 0 ? num2.charAt(j) - '0' : 0; sb.append((x - borrow - y + 10) % 10); borrow = x - borrow - y < 0 ? 1 : 0; i--; j--; } String tmp = sb.reverse().toString(); //删除前导0,注意边界是res.size()-1!!防止当res为"0000"时,删为""的清空 int start = 0; for (int pos = 0; pos < tmp.length(); pos++) { if (tmp.charAt(pos) != '0') { start = pos; break; } } return tmp.substring(start); } }7.36进制加法(高频面试题)36进制由0-9,a-z,共36个字符表示。要求按照加法规则计算出任意两个36进制正整数的和,如1b + 2x = 48 (解释:47+105=152 )要求:不允许使用先将36进制数字整体转为10进制,相加后再转回为36进制的做法思路:本题是上一题字符串相加的延伸,本题是36进制的大数字符串相加,输出结果为36进制数。getChar:拼接时,将数值转换为36进制字符getInt:将36进制字符转换为数值代码:public class Solution { public static void main(String[] args) { String a = "1b", b = "2x", c; // System.out.println(getInt('z')); // System.out.println(getChar(35)); c = add36Strings(a, b); System.out.println(c); } public static String add36Strings(String num1, String num2) { StringBuilder ans = new StringBuilder(); int i = num1.length() - 1, j = num2.length() - 1; int carry = 0, sum; while (i >= 0 || j >= 0 || carry != 0) { sum = carry; sum += i >= 0 ? getInt(num1.charAt(i)) : 0; sum += j >= 0 ? getInt(num2.charAt(j)) : 0; ans.append(getChar(sum % 36)); carry = sum / 36; i--; j--; } return ans.reverse().toString(); } public static int getInt(char c) { if (c >= '0' && c <= '9') { return c - '0'; } else { return c - 'a' + 10; } } public static char getChar(int a) { if (a <= 9) { return (char)(a + '0'); } else { return (char)(a - 10 + 'a'); } } }7.补充:字符串相乘(43 - 中)给定两个字符串形式的非负整数 num1 和num2 ,计算它们的乘积。不能使用库函数等。思路:模拟惩罚的计算过程,将被乘数与乘数的没一位相乘,然后调用两个字符串相加。参考leetcode题解,在两数相乘时,乘数某位与被乘数某位相乘,与产生结果的位置的规律来完成。具体规律如下:乘数 num1 位数为 M,被乘数 num2 位数为 N, num1 x num2 结果 ans 最大总位数为 M+Nnum1[i] x num2[j] 的结果为 tmp(位数为两位,"0x","xy"的形式),其第一位位于 res[i+j],第二位位于 res[i+j+1]。算法流程代码:public String multiply(String num1, String num2) { if (num1.equals("0") || num2.equals("0")) { return "0"; } int m = num1.length(), n = num2.length(); int[] arr = new int[m + n]; for (int i = m - 1; i >= 0; i--) { int n1 = num1.charAt(i) - '0'; for (int j = n - 1; j >= 0; j--) { int n2 = num2.charAt(j) - '0'; int sum = arr[i + j + 1] + n1 * n2; arr[i + j + 1] = sum % 10; arr[i + j] += sum / 10; // 记录进位值 } } StringBuilder ans = new StringBuilder(); for (int i = 0; i < m + n; i++) { if (i == 0 && arr[i] == 0) continue; ans.append(arr[i]); } return ans.toString(); }
1.无重复字符的最长子串(3 - 中/剑指48)题目描述:给定一个字符串,请你找出其中不含有重复字符的 最长子串 的长度。示例 :输入: s = "abcabcbb" 输出: 3 解释: 因为无重复字符的最长子串是 "abc",所以其长度为 3。思路:本题是面试的高频题目,也是hash表的一个具体应用。思路是维持一个队列(窗口),保持队列中的元素满足题目要求(元素不重复)。具体实现是:使用hashmap记录每个字符的索引位置,便于更新下一个窗口的左边界,遍历字符串。如果窗口中含有这个元素,移动移除左边界元素(左边索引+1);否则加入窗口,更新最长子串长度。代码实现:public int lengthOfLongestSubstring(String s) { int n = s.length(); if (s == null || n == 0) { return 0; } Map<Character, Integer> map = new HashMap<>(); int max = 0; int left = 0; for (int i = 0; i < n; ++i) { char c = s.charAt(i); if (map.containsKey(c)) { left = Math.max(left, map.getOrDefault(c, 0) + 1); } map.put(c, i); max = Math.max(max, i - left + 1); } return max; }2.串联所有单词的子串(30 - 难)题目描述:给定一个字符串 s 和一些长度相同的单词 words。找出 s 中恰好可以由 words 中所有单词串联形成的子串的起始位置。注意:子串要与 words 中的单词完全匹配,中间不能有其他字符,但不需要考虑 words 中单词串联的顺序。示例 :输入: s = "barfoothefoobarman", words = ["foo","bar"] 输出:[0,9] 解释: 从索引 0 和 9 开始的子串分别是 "barfoo" 和 "foobar" 。 输出的顺序不重要, [9,0] 也是有效答案。思路:由于单词长度固定,我们维护一个长度为所有元素长度总和的队列(窗口)。本题也是考察hash表,键为存储的单词,值存的单词出现的次数。具体实现见代码。注意:可以用indexOf(),判断一个字符或者字符串是否存在一个字符串中,不存在返回负数;否则将这个单词加入哈希表;当单词数组长度超过字符串长度,直接剔除。对遍历的每一个窗口,用一个subMap记录,left和right记录左右边界(更新的话,是先将单词从字符串中截取出来,再在subMap中删除);通过移动右边界right,依次得到一个单词。如果wordMap中没有这个单词,更新左边界,清除这次记录(包括subMap和单词数量记录数count);否则,加入subMap,注意count符合要求统计左边界,超过限制删除左边界。代码实现:public static List<Integer> solution2(String s, String[] words) { List<Integer> res = new ArrayList<>(); Map<String, Integer> wordsMap = new HashMap<>(); if (s.length() == 0 || words.length == 0) return res; for (String word: words) { // 主串s中没有这个单词,直接返回空 if (s.indexOf(word) < 0) return res; wordsMap.put(word, wordsMap.getOrDefault(word, 0) + 1); } int oneLen = words[0].length(), wordsLen = oneLen * words.length; if (wordsLen > s.length()) return res; for (int i = 0; i < oneLen; ++i) { int left = i, right = i, count = 0; // 统计每个符合要求的word Map<String, Integer> subMap = new HashMap<>(); // 右窗口不能超出主串长度 while (right + oneLen <= s.length()) { // 得到一个单词 String word = s.substring(right, right + oneLen); right += oneLen; // words[]中没有这个单词,那么当前窗口肯定匹配失败,直接右移到这个单词后面 if (!wordsMap.containsKey(word)) { left = right; subMap.clear(); count = 0; } else { subMap.put(word, subMap.getOrDefault(word, 0) + 1); ++count; // 如果这个单词出现的次数大于words[]中它对应的次数,又由于每次匹配和words长度相等的子串 // 如 ["foo","bar","foo","the"] "| foobarfoobar| foothe" // 第二个bar虽然是words[]中的单词,但是次数抄了,那么右移一个单词长度后 "|barfoobarfoo|the" // bar还是不符合,所以直接从这个不符合的bar之后开始匹配,也就是将这个不符合的bar和它之前的单词(串)全移出去 while (subMap.getOrDefault(word, 0) > wordsMap.getOrDefault(word, 0)) { // 从当前窗口字符统计map中删除从左窗口开始到数量超限的所有单词(次数减一) String w = s.substring(left, left + oneLen); subMap.put(w, subMap.getOrDefault(w, 0) - 1); // 符合的单词数减一 --count; // 左窗口位置右移 left += oneLen; } // 当前窗口字符串满足要求 if (count == words.length) res.add(left); } } } return res; }3.最小覆盖子串(76 - 难)题目描述:给你一个字符串 s 、一个字符串 t 。返回 s 中涵盖 t 所有字符的最小子串。如果 s 中不存在涵盖 t 所有字符的子串,则返回空字符串 "" 。要求:时间复杂度O(N)。注意:如果 s 中存在这样的子串,我们保证它是唯一的答案,两个字符串只含有英文字符。示例 :输入:s = "ADOBECODEBANC", t = "ABC" 输出:"BANC"思路:可以通过滑动窗口解决,注意:最小覆盖子串长度不唯一,但至少与t相同。我们可以定义两个变量:start(最小覆盖子串的起始位置),size(最小覆盖子串的长度)关键:如何判断窗口中含有t的所有元素?可以使用数组或者哈希表记录字符出现的次数。使用数组记录t中(我们需要寻找的)字符的个数,cnt记录要找字符总数。当我们遍历s字符串时,遇到需要的字符我们就将他出现的次数 - 1。总次数cnt == 0,此时找到一个覆盖子串。上述还不是一次寻找的最小覆盖子串,需要找左边界,如何找呢?还是通过出现的次数,方法:在遍历过程中,s中出现每个的字符在need数组中-1,这样我们在找左边界是为负数,一定要恢复!这时,可以统计长度size,更新起始索引start;最终,移动窗口,如何移动(删除左边界元素)呢?将其加入need数组,更新左边界和cnt个数 + 1,下次遍历出现这个字符就可以删除(也可以理解为替换)。代码实现:public String minWindow(String s, String t) { if (s == null || s.length() == 0 || t == null || t.length() == 0 || s.length() < t.length()) { return ""; } // 记录需要字符的个数 int[] need = new int[128]; for (int i = 0; i < t.length(); i++) { need[t.charAt(i)]++; } int left = 0; int size = Integer.MAX_VALUE, cnt = t.length(); int start = 0; for (int i = 0; i < s.length(); i++) { if (need[s.charAt(i)] > 0) cnt--; need[s.charAt(i)]--; // 窗口中已经包含所有需要的字符 if (cnt == 0) { while (left < i && need[s.charAt(left)] < 0) { need[s.charAt(left)]++; left++; } if (i - left + 1 < size) { size = i - left + 1; start = left; } // 窗口移动,注意更新need数组和cnt值 need[s.charAt(left)]++; left++; cnt++; } } return size == Integer.MAX_VALUE ? "" : s.substring(start, start + size); }4.找到字符串中所有字母的异位词(438 - 中)题目描述:给定一个字符串 s 和一个非空字符串 p,找到 s 中所有是 p 的字母异位词的子串,返回这些子串的起始索引。字符串只包含小写英文字母,并且字符串 s 和 p 的长度都不超过 20100。说明:字母异位词指字母相同,但排列不同的字符串。不考虑答案输出的顺序。示例 :输入: s: "cbaebabacd" p: "abc" 输出: [0, 6] 解释: 起始索引等于 0 的子串是 "cba", 它是 "abc" 的字母异位词。 起始索引等于 6 的子串是 "bac", 它是 "abc" 的字母异位词。思路:显然滑窗,窗口大小为p字符串的长度,关键:那么如何比较两部分的元素是否是异位词呢?可以直接调库函数Arrays.equals(),判断两个字符串是否是异位词(即两个数组是否相同)。本题仍使用数组存储字符出现次数,但是本题两个串申请了两个空间,方便进行上边的对比。这里判断与上题相比简单了许多,只要长度满足我们就进行判断,两部分异构,记录起始索引;否则,移除左边界,这里直接在sMap删除。代码实现:public List<Integer> findAnagrams(String s, String p) { List<Integer> ans = new ArrayList<>(); if (s.length() < p.length()) return ans; int[] pMap = new int[26]; for (int i = 0; i < p.length(); i++) { pMap[p.charAt(i) - 'a']++; } int[] sMap = new int[26]; int left = 0; for (int i = 0; i < s.length(); i++) { sMap[s.charAt(i) - 'a']++; if (i - left + 1 == p.length()) { if (Arrays.equals(sMap, pMap)) { ans.add(left); } sMap[s.charAt(left) - 'a']--; left++; } } return ans; }5.至多包含两个不同字符的最长子串(159 - 中)题目描述:给定一个字符串 s ,找出 至多 包含两个不同字符的最长子串 t ,并返回该子串的长度。示例 :示例 1: 输入: "eceba" 输出: 3 解释: t 是 "ece",长度为3。 示例 2: 输入: "ccaabbb" 输出: 5 解释: t 是 "aabbb",长度为5。思路:维护一个窗口,元素类型至多两种。本题关键:如何判断窗口中的元素类型大于2了呢?这里使用Hashmap记录当前字符在窗口中最靠右的索引值,为什么这么做呢?这样我们可以控制窗口的移动,right每右移一步:若已经存在,更新map中当前元素最靠右的位置若不存在,添加到map唯一存在,索引也是最靠右的这样就好办了,如果我们元素类型超了2,即map的大小等于3。记录最小索引值,用于下边删除和更新左边界。删除操作是删除索引最小的那些或那个元素,因为记录的是右边界,这样我们也不用担心左边界问题。代码实现:public int lengthOfLongestSubstringTwoDistinct(String s) { int strLength = s.length(); if (strLength < 3) return strLength; int left = 0, right = 0; HashMap<Character, Integer> map = new HashMap<>(); int maxLen = 2; while (right < strLength) { map.put(s.charAt(right), right); right++; if (map.size() == 3) { int minIdx = Collections.min(map.values()); map.remove(s.charAt(minIdx)); left = minIdx + 1; } maxLen = Math.max(maxLen, right - left); } return maxLen; }ps:map.values()显示map中所有的值,返回值类型Collection<Integer>,用min()获取最小值。map.remove(key):删除map中键为key的元素6.至多包含k个不同字符的最长子串(340 - 难)题目描述:给定一个字符串 s ,找出 至多 包含k个不同字符的最长子串 t ,并返回该子串的长度。示例 :示例 1: 输入: "eceba" 2 输出: 3 解释: t 是 "ece",长度为3。 示例 2: 输入: "aa", 1 输出: 2 解释: t 是 "aa",长度为2。思路:维护一个窗口,元素类型至多k种。具体实现:类比上一题,解决方案还是使用hash表记录字符出现的最右索引。代码实现:public int lengthOfLongestSubstringKDistinct(String s, int k) { int strLength = s.length(); if (strLength < k + 1) return strLength; if (strLength * k == 0) return 0; int left = 0, right = 0; HashMap<Character, Integer> map = new HashMap<>(); int maxLen = k; while (right < strLength) { map.put(s.charAt(right), right); right++; if (map.size() == k + 1) { int minIdx = Collections.min(map.values()); map.remove(s.charAt(minIdx)); left = minIdx + 1; } maxLen = Math.max(maxLen, right - left); } return maxLen; }7.替换后最长重复字符(424 - 中)题目描述:给你一个仅由大写英文字母组成的字符串,你可以将任意位置上的字符替换成另外的字符,总共可最多替换 k 次。在执行上述操作后,找到包含重复字母的最长子串的长度。示例 :输入:s = "ABAB", k = 2 输出:4 解释:用两个'A'替换为两个'B',反之亦然。思路:可以想一下k = 0的情况(不替换字符),即求字符串的最长重复元素。关键点:如何判断一个字符串改变了k个字符就变成了一条连续的子串?只要保证【当前子串的长度 <= 当前子串出现字符最多的个数 + k】,这样一定可以将当前子串替换为一条连续的子串(元素都相同)。当满足上述条件,右边界扩张,并更新之前出现重复字母的最多个数(这个个数用一个数组记录);一旦不满足上述条件,我们移除左边界(直接在数组中将左边界对应的次数 - 1);注意:我们窗口维护了可能的最长子串,所以返回值为窗口大小,即s.length() - left。代码实现:public int characterReplacement(String s, int k) { int n = s.length(); if (s == null || n < 2 || n < k) return n; int[] map = new int[26]; int left = 0; int ans = 0, preMax = 0; for (int i = 0; i < n; i++) { int index = s.charAt(i) - 'A'; map[index]++; preMax = Math.max(preMax, map[index]); if (i - left + 1 > preMax + k) { map[s.charAt(left) - 'A']--; left++; } } return n - left; }
1.存在重复元素(217 - 易)题目描述:给定一个整数数组,判断是否存在重复元素。如果存在一值在数组中出现至少两次,函数返回 true 。如果数组中每个元素都不相同,则返回 false 。示例 :输入: [1,2,3,1] 输出: true思路:本题可以先排序再遍历数组,简单。也可以利用HashSet去重判断,依次计入集合。代码实现:hashsetpublic boolean containsDuplicate(int[] nums) { Set<Integer> set = new HashSet<>(); for (int num : nums) { set.add(num); } return set.size() < nums.length; }2.剑指 Offer 03. 数组中重复的数字题目描述:找出数组中重复的数字(含有多个重复元素)。在一个长度为 n 的数组 nums 里的所有数字都在 0~n-1 的范围内。数组中某些数字是重复的,但不知道有几个数字重复了,也不知道每个数字重复了几次。请找出数组中任意一个重复的数字。示例 :输入: [2, 3, 1, 0, 2, 5, 3] 输出:2 或 3思路:使用hashset,当元素王集合中加入失败if (!set.add(num)),即存在重复元素,返回即可(返回任意重复元素)。原地置换(快慢指针):根据题目元素都小于长度n,可以让 位置i 的地方放元素i(有重复数字,索引和值是一对多映射)。如果位置i的元素不是i的话,那么我们就把i元素放到它应该在的位置,即 nums[i] 和nums[nums[i]]的元素交换(即元素nums[i]应该放在下标nums[i]),这样就把原来在nums[i]位置的元素正确归位了。如果发现 要把 nums[i]位置元素正确归位的时候,发现nums[i](这个nums[i]是下标)那个位置上的元素和要归位的元素已经一样了,此时一定nums[i] == nums[nums[i]],说明就重复了值域的二分查找:数组元素值介于 0~n-1,且至少存在一个元素是重复的。可以对值域进行二分查找,统计nums数组中元素介于0mid的数量,如果这个数量大于mid值,重复元素肯定在0m之间。ps:二分查找没有ac,请教大佬指点!代码实现:hashset实现:额外空间复杂度O(N),不修改原始数据。public int findRepeatNumber(int[] nums) { Set<Integer> set = new HashSet<Integer>(); int repeat = -1; for (int num : nums) { // 集合中已经存在该元素 if (!set.add(num)) { repeat = num; break; } } return repeat; }快慢指针(原地置换):不使用额外空间,原地查找,时间复杂度O(N),但修改了原始数据。public int findRepeatNumber(int[] nums) { int tmp; for(int i = 0; i < nums.length; i++){ while (nums[i] != i){ if(nums[i] == nums[nums[i]]){ return nums[i]; } // 将元素nums[i]归位(放到下标为nums[i]位置) tmp = nums[i]; nums[i] = nums[tmp]; nums[tmp] = tmp; } } return -1; }3.寻找重复数(287 - 中)题目描述:给定一个包含 n + 1 个整数的数组 nums ,其数字都在 1 到 n 之间(包括 1 和 n),可知至少存在一个重复的整数。假设 nums 只有 一个重复的整数 ,找出 这个重复的数 。示例 :输入:nums = [1,3,4,2,2] 输出:2思路:使用hashset,当元素王集合中加入失败if (!set.add(num)),即存在重复元素,返回即可(返回任意重复元素)。快慢指针(最优解),因为元素介于1~n,且只存在一个重复值,所以可以看成链表找环的入口,使用快慢指针查找,快指针一次跳两步(不会出现数组越界)。二分查找:虽然数组无序,但是值域1~n,可以在值域上进行二分查找。代码实现:hashset实现:额外空间复杂度O(N),不修改原始数据。public int findDuplicate(int[] nums) { Set<Integer> set = new HashSet<>(); for (int num : nums) { if (!set.add(num)) return num; set.add(num); } return -1; }快慢指针:不使用额外空间,原地查找,时间复杂度O(N),但修改了原始数据。public int findDuplicate(int[] nums) { int l1 = 0, l2 = 0; while (true) { l1 = nums[l1]; l2 = nums[nums[l2]]; if (l1 == l2) break; } l2 = 0; while (l1 != l2) { l1 = nums[l1]; l2 = nums[l2]; } return l1; }值域的二分查找:时间O(NlongN),额外空间复杂度O(1),不修改原数据。public int findDuplicate(int[] nums) { int l = 1, r = nums.length - 1; while (l < r) { int mid = l + ((r - l) >> 1); int cnt = 0; for (int i = 0; i < nums.length; i++) { if (nums[i] <= mid) { cnt++; //记录小于等于mid元素的个数 } } if (cnt > mid) { //重复值在左区域,mid取大了 r = mid; } else { l = mid + 1; //重复值在右区域,mid取小了 } } return l; }4.补充:快乐数(202 - 易)题目描述:编写一个算法来判断一个数 n 是不是快乐数。「快乐数」定义为:对于一个正整数,每一次将该数替换为它每个位置上的数字的平方和。然后重复这个过程直到这个数变为 1,也可能是 无限循环 但始终变不到 1。如果 可以变为 1,那么这个数就是快乐数。如果 n 是快乐数就返回 true ;不是,则返回 false 。示例 :输入:19 输出:true 解释: 12 + 92 = 82 82 + 22 = 68 62 + 82 = 100 12 + 02 + 02 = 1思路:快慢指针,每个数字都会根据 各位平方和 指向另一个数字,从任意数字开始进行 各位平方和 的迭代操作,就相当于在链表上游走。如果快指针找到则提前退出。如果快慢指针相遇,那么必定出现循环(存在环,死循环),需要判断这个循环值是不是1。代码实现:public int squareSum(int n) { int sum = 0; while(n > 0){ int digit = n % 10; sum += digit * digit; n /= 10; } return sum; } public boolean isHappy(int n) { int slow = n, fast = squareSum(n); while (fast != 1 && slow != fast){ slow = squareSum(slow); fast = squareSum(squareSum(fast)); } return fast == 1; }5.删除有序数组中的重复项(26 - 易)题目描述:给你一个有序数组 nums ,请你 原地 删除重复出现的元素,使每个元素 只出现一次 ,返回删除后数组的新长度。不要使用额外的数组空间,你必须在 原地 修改输入数组 并在使用 O(1) 额外空间的条件下完成。示例 :输入:nums = [1,1,2] 输出:2, nums = [1,2] 解释:函数应该返回新的长度 2 ,并且原数组 nums 的前两个元素被修改为 1, 2 。不需要考虑数组中超出新长度后面的元素。思路:快慢指针,快指针遍历数组,慢指针记录不重复的元素(遇到与慢指针元素不同,将快指针指向元素覆盖慢指针指向元素),最后返回慢指针下标 + 1,即为删除后数组长度。代码实现:快慢指针,不使用额外空间,原地查找,时间复杂度O(N)。public int removeDuplicates(int[] nums) { int slow = 0; for (int fast = 1; fast < nums.length; fast++) { if (nums[fast] != nums[slow]) { nums[++slow] = nums[fast]; } } return slow + 1; }6.删除有序数组中的重复项II(80 - 中)题目描述:给你一个有序数组 nums ,请你 原地 删除重复出现的元素,使每个元素最多出现两次 ,返回删除后数组的新长度。不要使用额外的数组空间,你必须在 原地 修改输入数组 并在使用 O(1) 额外空间的条件下完成。示例 :输入:nums = [1,1,1,2,2,3] 输出:5, nums = [1,1,2,2,3] 解释:函数应返回新长度 length = 5, 并且原数组的前五个元素被修改为 1, 1, 2, 2, 3 。 不需要考虑数组中超出新长度后面的元素。思路:快慢指针,快指针遍历数组,慢指针记录不重复的元素,注意:这里快指针指向元素依次比较元素nums[slow - max + 1],即上一轮已经覆盖完成的两个元素,这里max需要返回元素的最多重复数。代码实现:快慢指针,不使用额外空间,原地查找,时间复杂度O(N)。public int removeDuplicates(int[] nums) { int slow = 1; int max = 2; // 返回元素的最多重复次数 for (int fast = 2; fast < nums.length; fast++) { if (nums[slow - max + 1] != nums[fast]) { nums[++slow] = nums[fast]; } } return slow + 1; }
写在前:拓扑排序本质是BFS和贪心算法,是这两种算法在有向图应用的专有名词,即拓扑排序针对有向图问题。参考这里。拓扑排序实际上应用的是贪心算法。贪心算法简而言之:每一步最优,全局就最优。具体到拓扑排序,每一次都从图中删除没有前驱的顶点,这里并不需要真正的做删除操作,我们可以设置一个入度数组,每一轮都输出入度为 0 的结点,并移除它、修改它指向的结点的入度(−1即可),依次得到的结点序列就是拓扑排序的结点序列。如果图中还有结点没有被移除,则说明“不能完成所有课程的学习”。拓扑排序保证了每个活动(在这题中是“课程”)的所有前驱活动都排在该活动的前面,并且可以完成所有活动。拓扑排序的结果不唯一。拓扑排序还可以用于检测一个有向图是否有环。相关的概念还有 AOV 网,这里就不展开了。ps:入度:指向当前节点的节点个数;后继节点:当前节点指向的节点拓扑排序注意点:拓扑排序的结果不唯一可以检测有向图是否有环(对于无向图是否有环使用并查集这种数据结构)贪心点:让入度为1的点入队删除节点的操作,通过入度数组体现!算法的基本流程:在开始排序前,扫描对应的存储空间(使用邻接表),将入度为 0 的结点放入队列。只要队列非空,就从队首取出入度为 0 的结点,将这个结点输出到结果集中,并且将这个结点的所有邻接结点(它指向的结点)的入度减 1,在减 1 以后,如果这个被减 1的结点的入度为 0 ,就继续入队。当队列为空的时候,检查结果集中的顶点个数是否和课程数相等即可。在代码具体实现的时候,除了保存入度为 0 的队列,我们还需要两个辅助的数据结构(本质都是数组结构):邻接表:通过结点的索引,我们能够得到这个结点的后继结点,注:为了避免重复加入,邻接表元素为hashset类型入度数组:通过结点的索引,我们能够得到指向这个结点的结点个数。这个两个数据结构在遍历题目给出的邻边以后就可以很方便地得到。应用场景:任务调度计划、课程安排1.课程表(207 - 中)题目描述:你这个学期必须选修 numCourses 门课程,记为 0 到 numCourses - 1 。在选修某些课程之前需要一些先修课程。 先修课程按数组 prerequisites 给出,其中 prerequisites[i] = [ai, bi] ,表示如果要学习课程 ai 则 必须 先学习课程 bi 。例如,先修课程对 [0, 1] 表示:想要学习课程 0 ,你需要先完成课程 1 。请你判断是否可能完成所有课程的学习?如果可以,返回 true ;否则,返回 false 。示例:输入:numCourses = 2, prerequisites = [[1,0]] 输出:true 解释:总共有 2 门课程。学习课程 1 之前,你需要完成课程 0 。这是可能的。 输入:numCourses = 2, prerequisites = [[1,0],[0,1]] 输出:false 解释:总共有 2 门课程。学习课程 1 之前,你需要先完成课程 0 ;并且学习课程 0 之前,你还应先完成课程 1 。这是不可能的代码实现:public boolean canFinish(int numCourses, int[][] prerequisites) { if (numCourses <= 0) return false; // 不需要先修课程 if (prerequisites.length == 0) return true; int[] inDegree = new int[numCourses]; HashSet<Integer>[] adj = new HashSet[numCourses]; for (int i = 0; i < numCourses; i++) { adj[i] = new HashSet<>(); } // 遍历邻边,得到入度数组和邻接表 for (int[] p : prerequisites) { inDegree[p[0]]++; adj[p[1]].add(p[0]); } Queue<Integer> queue = new LinkedList<>(); // 加入入度为0的节点 for (int i = 0; i < numCourses; i++) { if (inDegree[i] == 0) { queue.add(i); } } // 记录已经出队的课程数量(选修的课程数) int cnt = 0; while (!queue.isEmpty()) { Integer top = queue.poll(); cnt++; // 遍历当前节点的所有后继节点 for (int successor : adj[top]) { // ps:通过重置后继几点的入度数组,“删除”top节点 inDegree[successor]--; if (inDegree[successor] == 0) { queue.add(successor); } } } return cnt == numCourses; }2.课程表II(210 - 中)题目描述:现在你总共有 n 门课需要选,记为 0 到 n-1。在选修某些课程之前需要一些先修课程。 例如,想要学习课程 0 ,你需要先完成课程 1 ,我们用一个匹配来表示他们: [0,1]给定课程总量以及它们的先决条件,返回你为了学完所有课程所安排的学习顺序。可能会有多个正确的顺序,你只要返回一种就可以了。如果不可能完成所有课程,返回一个空数组。示例:输入:numCourses = 2, prerequisites = [[1,0]] 输出:true 解释:总共有 2 门课程。学习课程 1 之前,你需要完成课程 0 。这是可能的。 输入:numCourses = 2, prerequisites = [[1,0],[0,1]] 输出:false 解释:总共有 2 门课程。学习课程 1 之前,你需要先完成课程 0 ;并且学习课程 0 之前,你还应先完成课程 1 。这是不可能的代码实现:public int[] findOrder(int numCourses, int[][] prerequisites) { if (numCourses <= 0) return new int[0]; int[] inDegree = new int[numCourses]; HashSet<Integer>[] adj = new HashSet[numCourses]; for (int i = 0; i < numCourses; i++) { adj[i] = new HashSet<>(); } for (int[] p : prerequisites) { inDegree[p[0]]++; adj[p[1]].add(p[0]); } int cnt = 0; int[] ans = new int[numCourses]; Queue<Integer> queue = new LinkedList<>(); // 入度为0的节点入队列 for (int i = 0; i < numCourses; i++) { if (inDegree[i] == 0) { queue.add(i); } } // 当前课程修完,并加入结果集 while (!queue.isEmpty()) { Integer top = queue.poll(); ans[cnt] = top; cnt++; for (int successor : adj[top]) { inDegree[successor]--; if (inDegree[successor] == 0) { queue.add(successor); } } } if (cnt == numCourses) return ans; return new int[0]; }
写在前:并查集是一种树型的数据结构,用于处理一些不交集(Disjoint Sets)的合并及查询问题。有一个联合-查找算法(Union-find Algorithm)定义了两个用于此数据结构的操作:Find(查找):确定元素属于哪一个子集。它可以被用来确定两个元素是否属于同一子集。Union(合并):将两个子集合并成同一个集合。ps:这里的集合也可叫连通块(连通分量),典型应用解决连通分量问题。并查集解决单个问题(添加,合并,查找)的时间复杂度都是O(1),可以应用在在线算法中。路径压缩算法:即查找过程,最理想的情况就是所有点的代表节点都是相同,一共就是两级结构。做法就是在查找时找到根之后把自身的值修改成根的下标,可以通过递归和迭代实现。路径压缩1.冗余连接(684 - 中)题目描述:在本问题中, 树指的是一个连通且无环的无向图。输入一个图,该图由一个有着N个节点 (节点值不重复1, 2, ..., N) 的树及一条附加的边构成。附加的边的两个顶点包含在1到N中间,这条附加的边不属于树中已存在的边。结果图是一个以边组成的二维数组。每一个边的元素是一对[u, v] ,满足 u < v,表示连接顶点u 和v的无向图的边。返回一条可以删去的边,使得结果图是一个有着N个节点的树。如果有多个答案,则返回二维数组中最后出现的边。答案边 [u, v] 应满足相同的格式 u < v。示例 :输入: [[1,2], [2,3], [3,4], [1,4], [1,5]] 输出: [1,4] 解释: 给定的无向图为: 5 - 1 - 2 | | 4 - 3思路:树是一个连通并无环的无向图,如果一棵树有 N个节点,则这棵树有 N-1条边。初始化N个节点,每个节点属于不同的集合。可以通过并查集寻找附加的边,本质是检测图的环。如果边的两个节点已经出现在同一个集合里(在合并之前已经连通),说明着边的两个节点已经连在一起了,如果再加入这条边一定就出现环了,记录并返回。代码实现:class Solution { public int[] findRedundantConnection(int[][] edges) { int n = edges.length + 1; UnionFind uf = new UnionFind(n); for (int[] edge : edges) { uf.union(edge[0], edge[1]); } return uf.ans; } } class UnionFind { int[] roots; int[] ans = new int[2]; // 存放结果集 public UnionFind(int n) { roots = new int[n]; for (int i = 0; i < n; i++) { roots[i] = i; } } public int find(int i) { if (i != roots[i]) { roots[i] = find(roots[i]); } return roots[i]; } public void union(int p, int q) { int pRoot = find(p); int qRoot = find(q); if (pRoot != qRoot) { roots[pRoot] = qRoot; } else { ans[0] = p; ans[1] = q; } } }2.省份数量(547 - 中)题目描述:有 n 个城市,其中一些彼此相连,另一些没有相连。如果城市 a 与城市 b 直接相连,且城市 b 与城市 c 直接相连,那么城市 a 与城市 c 间接相连。省份 是一组直接或间接相连的城市,组内不含其他没有相连的城市。给你一个 n x n 的矩阵 isConnected ,其中 isConnected[i][j] = 1 表示第 i 个城市和第 j 个城市直接相连,而 isConnected[i][j] = 0 表示二者不直接相连。返回矩阵中 省份 的数量(城市群)。示例 :输入:isConnected = [[1,1,0],[1,1,0],[0,0,1]] 输出:2思路:并查集解法(单独写一个并查集类)图的顶点数为 n,则初始化 n 个单顶点集合,每个集合指向自身。然后遍历图中的每个顶点,将当前顶点与其邻接点进行合并。最终结果返回合并后的集合的数量即可。代码实现:class Solution { public int findCircleNum(int[][] isConnected) { int n = isConnected.length; // 初始化n个单顶点集合, UnionFind uf = new UnionFind(n); // 遍历一半矩阵(两个城市単连接即可),遇到1连接两个城市 for (int i = 0; i < n; i++) { for (int j = i + 1; j < n; j++) { if (isConnected[i][j] == 1) { uf.union(i, j); } } } return uf.size; } } class UnionFind { int[] roots; // 记录每个省份的核心城市 int size; // 集合的数量 public UnionFind(int n) { roots = new int[n]; for (int i = 0; i < n; i++) { roots[i] = i; } size = n; } // 寻找当前城市所在省份(城市群)的核心城市 public int find(int i) { //若当前城市不是核心城市,则递归更新(类似树结构) if (i != roots[i]) { roots[i] = find(roots[i]); } return roots[i]; } // 将两个城市连接到一个省份 public void union(int p, int q) { int pRoot = find(p); int qRoot = find(q); // 当两个城市的核心城市不同时,连接并更新核心城市,集合数量-1;若两个城市在同一省份则不减 if (pRoot != qRoot) { roots[pRoot] = qRoot; size--; } } }3.被围绕的区域(130 - 中)题目描述:给你一个 m x n 的矩阵 board ,由若干字符 'X' 和 'O' ,找到所有被 'X' 围绕的区域,并将这些区域里所有的 'O' 用 'X' 填充,返回填充后的矩阵。示例 :输入:board = [["X","X","X","X"],["X","O","O","X"],["X","X","O","X"],["X","O","X","X"]] 输出:[["X","X","X","X"],["X","X","X","X"],["X","X","X","X"],["X","O","X","X"]] 解释:被围绕的区间不会存在于边界上,换句话说,任何边界上的 'O' 都不会被填充为 'X'。 任何不在边界上,或不与边界上的 'O' 相连的 'O' 最终都会被填充为 'X'。如果两个元素在水平或垂直方向相邻,则称它们是“相连”的。思路:本题中O本质就是两大类:一种可以连通到边界,一种不能连通到边界(需要X覆盖)。并查集就是解决一种分类(连通性)问题的。对于每个节点我们用行数和列数生成id进行标识(用于下一步)。遍历每个O节点,上下左右四个方向进行合并:定义dummy节点区分上述两大分类(将连通到边界的与dummy进行合并)。如果当前O在边界,先与dummy节点合并否则将当前点与上下左右O进行合并再遍历矩阵,只需要判断O节点是否与dummy节点连通,如不连通则替换为X。代码实现:class Solution { int row, col; public void solve(char[][] board) { if (board == null || board.length == 0) return; row = board.length; col = board[0].length; int dummy = row * col; // 虚拟分类点 UnionFind uf = new UnionFind(row * col + 1); for (int i = 0; i < row; i++) { for (int j = 0; j < col; j++) { if (board[i][j] == 'O') { //当前节点在边界就和 dummy 合并 if (i == 0 || i == row - 1 || j == 0 || j == col - 1) { uf.union(dummy, node(i, j)); } else { //将上下左右的 O 节点和当前节点合并 if (board[i - 1][j] == 'O') uf.union(node(i, j), node(i - 1, j)); if (board[i][j - 1] == 'O') uf.union(node(i, j), node(i, j - 1)); if (board[i + 1][j] == 'O') uf.union(node(i, j), node(i + 1, j)); if (board[i][j + 1] == 'O') uf.union(node(i, j), node(i, j + 1)); } } } } for (int i = 0; i < row; i++) { for (int j = 0; j < col; j++) { if (uf.isConnected(node(i, j), dummy)) { board[i][j] = 'O'; } else { board[i][j] = 'X'; } } } } int node(int i, int j) { return i * col + j; } } class UnionFind { int[] roots; public UnionFind(int n) { roots = new int[n]; for (int i = 0; i < n; i++) { roots[i] = i; } } public int find(int i) { while (i != roots[i]) { roots[i] = roots[roots[i]]; i = roots[i]; } return i; } public void union(int p, int q) { int pRoot = find(p); int qRoot = find(q); if (pRoot != qRoot) { roots[pRoot] = qRoot; } } public boolean isConnected(int p, int q) { return find(p) == find(q); } }ps:这里并查集的效率有点低。4.岛屿的数量(200 - 中)题目描述:给你一个由 '1'(陆地)和 '0'(水)组成的的二维网格,请你计算网格中岛屿的数量。岛屿总是被水包围,并且每座岛屿只能由水平方向和/或竖直方向上相邻的陆地连接形成。此外,你可以假设该网格的四条边均被水包围。示例 :输入:grid = [ ["1","1","1","1","0"], ["1","1","0","1","0"], ["1","1","0","0","0"], ["0","0","0","0","0"] ] 输出:1思路:使用并查集计算集合(岛屿)的数量。对于陆地情况可以上下左右进行查找。类似547省份数量,用size变量保存连通分量的数量注意:最终size = 连通块的个数 + 水的元素个数故在遍历每个元素的时候,统计水的个数(0的个数)岛屿数量 = size - 水的个数代码实现:class Solution { public int row, col; public int numIslands(char[][] grid) { row = grid.length; col = grid[0].length; int n = row * col; int ocean = 0; UnionFind uf = new UnionFind(n); for (int i = 0; i < row; i++) { for (int j = 0; j < col; j++) { if (grid[i][j] == '0') { ocean += 1; } else { if (i > 0 && grid[i - 1][j] == '1') uf.union(node(i, j), node(i - 1, j)); if (j > 0 && grid[i][j - 1] == '1') uf.union(node(i, j), node(i, j - 1)); if (i < row - 1 && grid[i + 1][j] == '1') uf.union(node(i, j), node(i + 1, j)); if (j < col - 1 && grid[i][j + 1] == '1') uf.union(node(i, j), node(i, j + 1)); } } } return uf.size - ocean; } int node (int i, int j) { return i * col + j; } } class UnionFind { int[] roots; int size; // 岛屿数量 public UnionFind(int n) { roots = new int[n]; for (int i = 0; i < n; i++) { roots[i] = i; } size = n; } public int find(int i) { while (i != roots[i]) { roots[i] =roots[roots[i]]; i = roots[i]; } return i; } public void union(int p, int q) { int pRoot = find(p); int qRoot = find(q); if (pRoot != qRoot) { roots[pRoot] = qRoot; size--; } } }5.移除最多的同行或同列石头(947 - 中)题目描述:如果想移除一个石头,那么它所在的行或者列必须有其他石头存在。我们能移除的最多石头数是多少?也就是说,只有一个石头在连通分量中,才能被移除。对于连通分量而言,最理想的状态是只剩一块石头。对于任何容量为n(n>1)一个连通分量,可以移除的石头数都为n-1。示例 :输入:stones = [[0,0],[0,2],[1,1],[2,0],[2,2]] 输出:3 解释:一种移除 3 块石头的方法如下所示: 1. 移除石头 [2,2] ,因为它和 [2,0] 同行。 2. 移除石头 [2,0] ,因为它和 [0,0] 同列。 3. 移除石头 [0,2] ,因为它和 [0,0] 同行。 石头 [0,0] 和 [1,1] 不能移除,因为它们没有与另一块石头同行/列。思路:本题注意理解我们要删除最多的石头(最后返回值),为此我们将所有的行和列进行连接呢?在图上构建若干个集合,每个集合可以看成一个钥匙串,最后每个钥匙串至少剩下一个钥匙。一个核心(尽可能多删除)并查集里的元素是 描述「横坐标」和「纵坐标」的数值。假设有M个集合,设总结点数为N,那么显而易见,我们能够删除的总结点数,也即move数为:move = N - M;在原始的输入中,行标号和列标号是共用的[1,n]区间内的数字,导致行值和列值重复,无法达到我们完成并查集的结点连接的目的。根据题目给出的数据范围,单个坐标数值小于10000。将列值映射到[10000,10000+n]的区间内,这样我们就获得了所有节点的标号。代码实现:class Solution { public int removeStones(int[][] stones) { int size = stones.length; // 题目给的数值在0 -10000,0-10000给横坐标,10001-20002留给纵坐标 UnionFind uf = new UnionFind(20002); for (int i = 0; i < size; i++) { uf.union(stones[i][0], stones[i][1] + 10001); } // 统计集合个数(即roots数量),由于行列为同一roots,故统计一个即可 HashSet<Integer> set = new HashSet<>(); for (int i = 0; i < size; i++) { set.add(uf.find(stones[i][0])); } // 总石头数量 - roots数量 == 最多可以删除的节点数 return size - set.size(); } } class UnionFind { int[] roots; public UnionFind(int n) { roots = new int[n]; for (int i = 0; i < n; i++) { roots[i] = i; } } public int find(int i) { while (i != roots[i]) { roots[i] = roots[roots[i]]; i = roots[i]; } return i; } public void union(int p, int q) { int pRoot = find(p); int qRoot = find(q); if (pRoot != qRoot) { roots[pRoot] = qRoot; } } }6.连通网络的操作次数(1319 - 中)题目描述:用以太网线缆将 n 台计算机连接成一个网络,计算机的编号从 0 到 n-1。线缆用 connections 表示,其中 connections[i] = [a, b] 连接了计算机 a 和 b。网络中的任何一台计算机都可以通过网络直接或者间接访问同一个网络中其他任意一台计算机。给你这个计算机网络的初始布线 connections,你可以拔开任意两台直连计算机之间的线缆,并用它连接一对未直连的计算机。请你计算并返回使所有计算机都连通所需的最少操作次数。如果不可能,则返回 -1 。示例 :输入:n = 4, connections = [[0,1],[0,2],[1,2]] 输出:1 解释:拔下计算机 1 和 2 之间的线缆,并将它插到计算机 1 和 3 上。思路:最终实现目标:将所有网络连接成同一个网络。类似547省份问题。注意两个问题:线要够,即n个节点至少需要n-1条线三个连通块至少需要两个边(即需要两条多余的线),问题转化为:最少操作数(多余线缆)= 连通块数量 - 1代码实现:class Solution { public int makeConnected(int n, int[][] connections) { int num = connections.length; if (num < n - 1) return -1; // 线不够 UnionFind uf = new UnionFind(n); for (int[] connection : connections) { uf.union(connection[0], connection[1]); } return uf.count - 1; } } class UnionFind { int[] roots; int count; // 记录连通块的数量 public UnionFind(int n) { roots = new int[n]; for (int i = 0; i < n; i++) { roots[i] = i; } count = n; } public int find(int i) { while (i != roots[i]) { roots[i] = roots[roots[i]]; i = roots[i]; } return i; } public void union(int p, int q) { int pRoot = find(p); int qRoot = find(q); if (pRoot != qRoot) { roots[pRoot] = qRoot; count--; } } }
谈谈对面向对象/面向过程的理解?面向过程让计算机有步骤地顺序做一件事,是过程化思维。因为类调⽤时需要实例化,开销⽐较⼤,⽐较消耗资源,所以当性能是最重要的考量因素的时候,⽐如单⽚机、嵌⼊式开发Linux/Unix 等⼀般采⽤⾯向过程开发。但是,使用面向过程语言开发大型项目,软件复用和维护存在很大问题,模块之间耦合严重。面向对象更适合解决规模较大的问题,可以拆解问题复杂度,对现实事物进行抽象并映射为开发对象,更接近人的思维。因为⾯向对象有封装、继承、多态性的特性,所以可以设计出低耦合的系统,使系统更加灵活、更加易于维护。应用举例:把大象放进冰箱里分为以下步骤:把冰箱门打开;把大象放进去;关上冰箱门(强调过程和过程中所涉及的行为/动作)。用面向对象思想考虑:无论是打开冰箱,放进大象,关闭冰箱,所有操作都是操作冰箱这个对象,所以只需要将所有功能都定义在冰箱这个对象上,冰箱上就有打开、存储、关闭得所有功能 。总结:面向过程是一种过程化思维,强调流程化解决问题,性能更高,但是维护,复用较难,代码耦合性也较大;面向对象代码强调高内聚、低耦合,先抽象模型定义共性行为,再解决实际问题,更接近人的思维,易维护、复用与扩展,但性能相对较低。ps:Java 性能差的主要原因并不是因为它是⾯向对象语⾔,⽽是 Java 是半编译语⾔,最终的执⾏代码并不是可以直接被 CPU 执⾏的⼆进制机器码。类与对象的关系面向对象的三大特性?封装:封装就是把一个对象的属性私有化,同时提供一些可以被外界访问的属性的方法,对数据的访问主要通过已经定义的接口,是一种信息隐藏技术;封装的好处:(1)封装后可提供多处调用,减少耦合(2)隐藏数据信息,避免恶意修改带来的安全问题(3)类内部的结构可以自由更改而不会影响其他代码(4)能够对成员属性进行精准的控制如何实现封装:(1)public (公有的):可以被该类的和非该类的任何成员访问。(2)private(私有的):仅仅可以被该类的成员访问,任何非该类的成员一概不能访问。(主要是隐藏数据来保证数据的安全性);如果需要访问可以调用它的内部类进行访问(3)protected(保护的):仅仅可以被子类和类本身还有同一个包里的类访问继承:继承可以使用已经存在类的所有功能(父类所有非private属性和功能);子类可以拥有自己的属性和方法,即子类可以对父类进行扩展;子类可以用自己的方式实现父类的方法。继承的优缺点:继承将所有子类公共的部分都放在父类,实现代码复用;但是,继承是一种父子类之间的强耦合关系,父类改变,子类不得不变,同时继承破坏了封装,将父类的实现细节暴露给子类。继承特性(注意):(1)Java继承为单继承,具有传递性(2)⼦类拥有⽗类对象所有的属性和⽅法(包括私有属性和私有⽅法),但是⽗类中的私有属性和⽅法⼦类是⽆法访问,只是拥有。(3)⼦类可以拥有⾃⼰属性和⽅法,即⼦类可以对⽗类进⾏扩展(4)⼦类可以⽤⾃⼰的⽅式实现⽗类的⽅法。多态:一个对象具有不同的形态,具体表现为:父类引用指向子类实例。多态的特点:(1)对象类型和引用类型之间具有继承(类)/实现(接口)的关系;(2)引用类型变量发出的方法调用的到底是哪个类中的方法,必须在程序运行期间才能确定;(3)多态不能调用“只在子类存在但在父类不存在”的方法;(4)如果子类重写了父类的方法,真正执行的是子类覆盖的方法,如果子类没有覆盖父类的方法,执行的是父类的方法。注意:下面三种类型没法体现多态特性(不能被重写):(1)static修饰的方法属于类,不属于实例(2)final修饰的方法(3)private修饰的对子类不可以访问(4)protected修饰的方法可以被子类访问,也可以被重写,但是无法被外部引用(无多态)多态的分类:编译时多态,方法的重载;运行时多态,方法的重写ps:A a = New B(); 向上转型是JAVA中的一种调用方式,是多态的一种表现。向上转型并非是将B自动向上转型为A的对象,相反它是从另一种角度去理解向上两字的:它是对A的对象的方法的扩充,即A的对象可访问B从A中继承来的和B重写A的方法,其它的方法都不能访问,包括A中的私有成员方法。向上转型一定是安全的,没有问题的,正确的(主要是为了提高扩展性和维护性)。但是也有一个弊端:对象一旦向上转型为父类,那么就无法调用子类原本特有的内容。访问修饰符private : 在同一类内可见。使用对象:变量、方法。 注意:不能修饰类(外部类)default (即缺省,什么也不写,不使用任何关键字): 在同一包内可见,不使用任何修饰符。使用对象:类、接口、变量、方法。protected : 对同一包内的类和所有子类可见。使用对象:变量、方法。 注意:不能修饰类(外部类)。public : 对所有类可见。使用对象:类、接口、变量、方法修饰符当前类同包子类其他包privatev×××defaultvv××protectedvvv×publicvvvv重载与重写区别重载:发生在同一个类中,方法名必须相同,参数列表(参数个数、类型不同,多个参数的顺序)必须不同,方法返回值和访问修饰符可以不同,发生在编译时(编译器会根据参数列表进行匹配,确定方法,如果失败,编译器报错,这叫做重载分辨)。每个重构的方法(构造函数)都必须有独一无二的参数类型列表。最常见的地方就是构造器的重载,即构造函数。注意:构造方法不能被重写!public class Test1 { public void out(){ System.out.println("参数"+null); } // 重载 ------ -----------方法名必须相同------------------------------- // 参数数目不同 public void out(Integer n){ System.out.println("参数"+n.getClass().getName()); } // 参数类型不同 public void out(String string){ System.out.println("参数"+string.getClass().getName()); } public void out(Integer n ,String string){ System.out.println("参数"+n.getClass().getName()+","+string.getClass().getName()); } //参数顺序不同 public void out(String string,Integer n){ System.out.println("参数"+string.getClass().getName()+","+n.getClass().getName()); } public static void main(String[] args) { Test1 test1 = new Test1(); test1.out(); test1.out(1); test1.out("string"); test1.out(1,"string"); test1.out("string",1); } }重写:子类对父类中允许访问的方法进行重新编写(覆盖父类的方法),或者实现类对接口的重写,方法名和参数列表必须相同(可以理解为外壳不变)!返回值范围和抛出异常范围小于等于父类,访问修饰符范围大于等于父类(子类方法的访问权限更大),发生在运行时;便于记忆:两同(方法名和参数列表)两小(返回值和抛出异常的范围)一大(访问权限)!注意:如果父类方法的访问修饰符为private,则子类不能重写该方法。class Test{ public void out(){ System.out.println("我是父类方法"); } // 子类不能重写此方法 private void out1(){ System.out.println("我是父类方法"); } } public class Test1 extends Test{ @Override // 方法名与参数列表必须完全一致 public void out() { System.out.println("我是重写后的子类方法"); } public static void main(String[] args) { Test test = new Test(); test.out(); test = new Test1(); test.out(); } }总结重写(Overriding)重载(Overloading)应用场景父子类、接口与实现类本类方法名称必须一致必须一致参数列表一定不能修改(必须一致)必须修改(每个重构方法参数列表独一无二)返回类型一定不能修改(必须一致)可以修改异常可以减少或删除,但不能扩展可以修改接口与抽象类的异同点与总结?对于面向对象编程来说,抽象是它的一大特征之一。在Java中,可以通过两种形式来体现OOP的抽象:接口和抽象类。这两者有太多相似的地方,又有太多不同的地方。两者的相同点:都是Java中体现OOP中抽象的基本形式。都不能被实例化,即不能进行new操作。因为:接口中没有构造方法,抽象类有构造方法,但是不是用来实例化的,是用来初始化的。接口的实现类和抽象类的子类只有全部实现了接口或者抽象类中的方法后才可以被实例化。主要区别:实现接口的关键字为implements,继承抽象类的关键字为extends。抽象类只能单继承(Java),注意:一个子类继承一个抽象类,则子类必须实现父类抽象方法,否则子类也必须定义为抽象类;接口/类可以继承多个接口(接口支持多继承,解决了Java类单继承的局限);包含抽象方法的一定是抽象类,但是抽象类不一定含有抽象方法。抽象类中的抽象方法的修饰符只能为public或者protected,默认为public;接口中的方法被隐式指定为public abstract(JDK1.8之前),但是JDK1.8中对接口增加了新的特性:(1)默认方法(default method):JDK 1.8允许给接口添加非抽象的方法实现,但必须使用default关键字修饰;定义了default的方法可以不被实现子类所实现,但只能被实现子类的对象调用;如果子类实现了多个接口,并且这些接口包含一样的默认方法,则子类必须重写默认方法;(2)静态方法(static method):JDK 1.8中允许使用static关键字修饰一个方法,并提供实现,称为接口静态方法。接口静态方法只能通过接口调用(接口名.静态方法名);抽象类可以使用各种类型的成员变量,接口成员变量只能(默认)是常量型(public static final修饰),必须赋初值,不能被修改;抽象类有构造方法,接口没有构造方法,接口只是对行为的抽象,没有具体的存在。在接口里写入构造方法时,编译器提示:Interfaces cannot have constructors;解决的问题:抽象类主要是为了代码复用,注意是先有子类后有父类,共性部分派生出一个抽象类。抽象类不允许实例化(抽象类中有些方法没实现,无法执行);接口的设计目的是对类的行为有约束,即强制要求不同的类具有不同的行为。只约束有无,不对实现限制;传递的信息(本质是否改变):抽象类是对类本质的抽象,表达一种is a的关系(如奔驰是车,本质相同),抽象类包含并实现子类的通用特性,将子类的差异化特性进行抽象,交由子类去实现;接口是对行为的抽象,表达一种like a的关系(飞机可以像鸟一样飞,本质不同),关心的是实现类可以做什么,至于主体和如何做并不关心。应用场景:抽象类:关注一个事务本质,设计抽象类代价高(编写出子类的所有共性,否则后期修改抽象类,需要修改所有子类);接口:关注一个操作(能做什么,即实现的功能),接口设计(功能上)难度较低,可以在同一类中实现多个接口。注意:抽象类不能用final修饰,定义抽象类就是让其他类继承的,如果定义为 final 该类就不能被继承,这样彼此就会产生矛盾。补充:抽象方法抽象方法还必须使用关键字abstract做修饰。在所有的普通方法上面都会有一个“{}”,这个表示方法体,有方法体的方法一定可以被对象直接使用,抽象方法没有方法体。抽象方法必须为public或者protected(因为如果为private,则不能被子类继承,子类便无法实现该方法),缺省情况下默认为public。//没有方法体,有abstract关键字做修饰 public abstract void xxx();抽象类是为了把相同的但不确定的东西的提取出来,为了以后的重用。定义成抽象类的目的,就是为了在子类中实现抽象方法。例如,构造一个动物类,包括“吃”,‘嚎叫’等方法。由于不同动物的 吃 和 嚎叫 是不同的,可以将 吃 和 嚎叫定义为抽象类。在继承这个动物类时,不同的动物再实现不同的 吃 和 嚎叫方法。抽象类的使用注意点:抽象类可以有构造方法。由于抽象类里会存在一些属性,那么抽象类中一定存在构造方法,其存在目的是为了属性的初始化。并且子类对象实例化的时候,依然满足先执行父类构造,再执行子类构造的顺序。抽象类不能使用final声明,因为抽象类必须有子类,而final定义的类不能有子类。抽象类能否使用static声明吗?外部抽象类不允许使用static声明,而内部的抽象类可以使用static声明。使用static声明的内部抽象类相当于一个外部抽象类,继承的时候使用“外部类.内部类”的形式表示类名称。抽象类中的static方法可以直接调用。hashCode与equals的区别和联系hashCode概述hashCode()方法和equals()方法的作用其实一样,在Java里都是用来对比两个对象是否相等。hashCode() 的作用是获取哈希码(也称为散列码),它实际上是返回一个int整数。这个哈希码的作用是确定该对象在哈希表中的索引位置。hashCode() 定义在JDK的Object.java中,Java中的任何类都包含有hashCode() 函数。哈希表(散列表)存储的是键值对(key-value),它的特点是:能根据“键”快速的检索出对应的“值”。这其中就利用到了散列码!(可以快速找到所需要的对象)。另外需要注意的是: Object 的 hashcode ⽅法是本地⽅法(native),也就是⽤ c 语⾔或 c++ 实现的,该⽅法通常⽤来将对象的 内存地址 转换为整数之后返回。为什么要有hashCode?以“HashSet如何检查重复”为例子来说明为什么要hashCode:对象加入HashSet时,HashSet会先计算对象的hashcode值来判断对象加入的位置,看该位置是否有值,如果没有,HashSet会假设对象没有重复出现,直接加入。但是如果发现有值,这时会再调用equals()方法来检查两个对象是否真的相同。如果两者相同,HashSet就不会让其加入操作成功。如果不同的话,就会重新散列到其他位置。这样就大大减少了equals的次数(避免新加入的对象与内部每个对象进行比较),相应就大大提高了执行速度。也就是说 hashcode 只是⽤来缩⼩查找成本。什么时候重写hashCode?一般的地方不需要,只有当类需要放在HashTable、HashMap、HashSet等hash结构的集合时才会重写hashCode。在散列表中hashCode() 的作用是获取对象的散列码,进而确定该对象在散列表中的位置。重写equals,为什么还要重写hashCode呢?如果只是重写了equals,比如说是基于对象的内容实现的,而保留hashCode的实现不变,那么很可能出现某两个对象明明是“相等”,而hashCode却不一样。这样,当你用其中的一个作为键保存到hashMap、hasoTable或hashSet中,再以“相等的”找另一个作为键值去查找他们的时候,则根本找不到。如果两个对象相等,则 hashcode ⼀定也是相同的。两个对象相等,对两个对象分别调⽤ equals⽅法都返回 true。但是,两个对象有相同的 hashcode 值,它们也不⼀定是相等的 。因此,equals ⽅法被覆盖过,则 hashCode ⽅法也必须被覆盖。ps:hashCode()的默认行为是对堆上的对象产生独特值(默认返回当前对象的地址)。如果没有重写hashCode(),则该class的两个对象无论如何都不会相等(即使这两个对象指向相同的数据)。hashCode与equals区别:两者的区别主要体现在性能和可靠性。性能:因为重写的equals()里一般比较复杂,这样效率就比较低;而利用hashCode()进行对比,则只要生成一个hash值进行比较就可以了,效率很高。可靠性:equals()相等的两个对象他们的hashCode()肯定相等,也就是用equals()对比是绝对可靠的。hashCode()相等(哈希碰撞)的两个对象他们的equals()不一定相等,也就是hashCode()不是绝对可靠的。补充:阿里开发规范只要重写 equals,就必须重写 hashCode;因为 Set 存储的是不重复的对象(上例),依据 hashCode 和 equals 进行判断,所以 Set 存储的对象必须重写这两个方法;如果自定义对象做为 Map 的键,那么必须重写 hashCode 和 equals;String 重写了 hashCode 和 equals 方法,所以我们可以非常愉快地使用 String 对象作为 key 来使用;super函数用法,与this能否同时使用?super和this关键字区别?访问父类的构造函数:可以使用 super() 函数访问父类的构造函数,从而委托父类完成一些初始化的工作。应该注意到,子类一定会调用父类的构造函数来完成初始化工作,一般是调用父类的默认构造函数。访问父类的成员:如果子类重写了父类的某个方法,可以通过使用 super 关键字来引用父类的方法实现。super()和this()能不能同时使用?不能同时使用,this和super不能同时出现在一个构造函数里面,因为this必然会调用其它的构造函数,其它的构造函数必然也会有super语句的存在,所以在同一个构造函数里面有相同的语句,就失去了语句的意义,编译器也不会通过。this关键字:理解为本类的,先查找本类,如果本类没有再查找父类!表示当前对象(区分成员变量与方法形参)【重要】class People{ String name; public People(String name){//正确写法 this.name = name; } public People(String name){//错误写法 name = name;//当一个方法的形参与成员变量的名字相同时,就会覆盖成员变量 } }this调用构造器①在我们类的构造器中,可以显示的使用“this(形参列表)”方式,调用本类中指定的其他构造器②构造器中不能通过“this(形参列表)”方式调用自己③如果一个类中有n个构造器,则最多有n-1构造器中使用了“this(形参列表)”④规定:“this(形参列表)”必须声明在当前构造器的首行⑤构造器内部,最多只能声明一个“this(形参列表)”,用来调用其他的构造器super关键字:理解为父类的,不查找本类,直接调用父类的结构常用情况:当子类重写了父类的方法后,我们想在子类方法中调用父类中被重写的方法时,必须显示的使用super.方法,表示调用的是父类中被重写的方法调用父类被重写的方法super调用构造器①我们可以在子类的构造器中显示的使用“super(形参列表)”的方式,调用父类中声明的制定的构造器②“super(形参列表)”的使用,必须声明在子类构造器的首行③我们在类的构造器中,针对于“this(形参列表)”或“super(形参列表)”只能二选一,不能同时出现④在构造器的首行,没有显示的声明“this(形参列表)”或“super(形参列表)”,则默认调用的是父类中空参的构造器⑤在类的多个构造器中,至少有一个类的构造器使用了“super(形参列表)”,调用父类中的构造器成员变量与局部变量区别变量:在程序执行的过程中,在某个范围内其值可以发生改变的量。从本质上讲,变量其实是内存中的一小块区域。成员变量:方法外部,类内部定义的变量;局部变量:类的方法中的变量。区别如下:作用域不同:成员变量:针对整个类有效,属于类的。局部变量:只在某个范围内有效。(一般指的就是方法,语句体内)存储位置不同:成员变量:存储在堆内存中。局部变量:存储在栈中,如果局部变量类型为基本数据类型,那么存储在栈内存,如果为引⽤数据类型,那存放的是指向堆内存对象的引⽤或者是指向常量池中的地址。生命周期不同:成员变量:随着对象的创建而存在,随着对象的消失而消失。局部变量:当方法调用完,或者语句结束后,就自动释放。初始值不同:成员变量:会⾃动以类型的默认值⽽赋值(⼀种情况例外:被 final 修饰的成员变量也必须显式地赋值)。局部变量:没有默认初始值,使用前必须赋值。修饰符不同:成员变量:可以被 public , private , static ,final等修饰符所修饰,static修饰属于类,否则属于实例。局部变量:不能被访问控制修饰符及 static 所修饰,但可以被final修饰使用原则:就近原则,首先在局部范围找,有就使用;接着在成员位置找。静态变量与实例变量的区别两者主要区别在隶属对象、存储位置和存在个数:隶属对象不同: 静态变量由于不属于任何实例对象,属于类的;每次创建对象,都会为每个对象分配成员变量内存空间,实例变量是属于实例对象的。存储位置不同: 静态变量存储在方法区;实例变量存储在堆内存当中,其引用存在当前线程栈。存在个数不同:静态变量内存中只会有一份,在类的加载过程中,JVM只为静态变量分配一次内存空间;而在内存中,创建几次对象,就有几份成员变量。静态方法与实例方法的区别,为什么静态方法不能调用非静态成员?两种方法主要区别在外部调用方式和访问本类成员的限制:外部调用方式不同:在外部调用静态⽅法时,可以使用"类名.方法名"的方式,也可以使用"对象名.方法名"的方式;而实例方法只有使用"对象名.方法名"这种方式。也就是说,调⽤静态方法可以无需创建对象。访问本类的成员限制:静态方法在访问本类的成员时,只允许访问静态成员(即静态成员变量和静态方法),而不允许访问实例成员变量和实例方法;实例方法则无此限制。注意:静态方法中不能有 this 和 super 关键字,因此这两个关键字与具体对象关联。由于静态方法可以不通过对象进行调用,静态方法属于类,不属于对象。静态资源在类初始化的过程加载的,非静态资源在类new的过程中加载的。因此静态方法里,不能调用和访问其他非静态成员。Java内部类的作用是什么,有哪些分类?应用场景?为什么需要内部类?内部类方法可以访问该类定义所在作用域中的数据,包括被 private 修饰的私有数据内部类可以对同一包中的其他类隐藏起来,非内部类是不可以使用 private和 protected修饰的,但是内部类却可以,从而达到隐藏的作用。同时也可以将一定逻辑关系的类组织在一起,增强可读性。内部类间接实现 Java 多继承,当我们想要定义一个回调函数却不想写大量代码的时候我们可以选择使用匿名内部类来实现内部类分类:静态内部类(嵌套类): 属于外部类,只加载一次。作用域仅在包内,可通过 外部类名.内部类名 直接访问,类内只能访问外部类所有静态属性和方法。HashMap 的 Node 节点,ReentrantLock 中的 Sync 类,ArrayList 的 SubList 都是静态内部类。内部类中还可以定义内部类,如 ThreadLoacl 静态内部类 ThreadLoaclMap 中定义了内部类 Entry。非静态内部类成员内部类: 属于外部类的每个对象,随对象一起加载。不可以定义静态成员和方法,可访问外部类的所有内容。局部内部类: 定义在方法内,不能声明访问修饰符,只能定义实例成员变量和实例方法,作用范围仅在声明类的代码块中。匿名内部类: 只用一次的没有名字的类,可以简化代码,创建的对象类型相当于 new 的类的子类类型。用于实现事件监听和其他回调。静态内部类和非静态内部类的区别:在调用子类构造方法之前会先调用父类没有参数的构造方法,其目的是? 子类的初始化顺序?目的:帮助子类做初始化工作。子类初始化顺序:父类的静态代码块和静态变量子类静态代码块和静态变量父类普通代码块和普通变量父类构造方法子类普通代码块和普通变量子类构造方法一个类的构造方法的作用是什么? 若一个类没有声明构造方法,该程序能正确执行吗? 构造方法有哪些特性?构造方法主要作用:完成对类对象的初始化没有声明类的构造方法,程序也可以执行,因为类有默认的不带参数的构造方法。构造方法的特性:名字与类名相同没有返回值,但不能用void声明构造函数生成类的对象时自动执行,无需调用创建一个对象用什么运算符?对象实体与对象引用有何不同?new 运算符:new创建的对象实例,存放在堆内存中对象的引用(堆内存地址)指向对象实例,存放在栈内存中对象引用:一个对象引用可以指向 0 个或 1 个对象(一根绳⼦可以不系⽓球,也可以系一个⽓球)对象实体:一个对象可以有 n 个引用指向它(可以用 n条绳子系住一个气球)。ps:对象引用看成绳子,对象看成气球。Java 按值调用还是引用调用(传递)?值传递:在调用函数时将实际参数复制一份(副本)传递到函数中,这样函数对参数进行修改,不会影响实际参数(实际是对副本进行操作)。引用传递:在调用函数时将实际参数的地址传递到函数中,这样函数对参数进行修改,会影响实际参数。值传递引用传递根本区别会创建副本(Copy)不创建副本(直接使用参数)导致结果形参和实参本质互不影响形参影响实参ps:实参与形参:实际参数是调用有参方法的时候真正传递的内容(传给方法的值),而形式参数是用于接收实参内容的参数。Java 总是按值传递,分析如下:基本类型:像 int ,double等基本数据类型在参数传递时并没有传进变量本身,而是创建了一个新的相同数值的变量, 函数修改这个新变量并没有影响原来变量的数值。基本类型 - 值传递引用类型(对象):为什么对象a的值改变了呢?因为虽然也是按值传递,复制了一份新的引用但是指向的对象是同一个,修改后会影响原对象!这种方式假如在函数内修改 a=null; 只是把复制的引用与对象的联系断开,不影响函数外与实际对象。引用类型 - 值传递另外比较常见的例子:public static void main(String[] args) { int[] arr = { 1, 2, 3, 4, 5 }; System.out.println(arr[0]); // 1 change(arr); System.out.println(arr[0]); // 0 } public static void change(int[] array) { // 将数组的第一个元素变为0 array[0] = 0; }上边的代码中array作为arr的引用的拷贝,但两者都是指向堆中的数组对象,所以,外部对引用对象的改变会反映到对象本身。对比看一下,如果是引用传递呢?因为引用传递是不复制的,直接使用参数,如下图:这时候函数把指针a=null就指针就置空了,函数外也无法再通过指针访问对象了引用传递综上所述:Java是值传递,即使传的是引用也不是引用传递,值的内容为对象的引用(赋值修改,但是对象本身没有改变)。JDK8新特性?lambda 表达式:允许把函数作为参数传递到方法,简化匿名内部类代码。函数式接口:使用 @FunctionalInterface 标识,有且仅有一个抽象方法,可被隐式转换为 lambda 表达式。方法引用:可以引用已有类或对象的方法和构造方法,进一步简化 lambda 表达式。接口:接口可以定义 default 修饰的默认方法,降低了接口升级的复杂性,还可以定义静态方法。注解:引入重复注解机制,相同注解在同地方可以声明多次。注解作用范围也进行了扩展,可作用于局部变量、泛型、方法异常等。类型推测:加强了类型推测机制,使代码更加简洁。Optional 类:处理空指针异常,提高代码可读性。Stream 类:引入函数式编程风格,提供了很多功能,使代码更加简洁。方法包括 forEach 遍历、count 统计个数、filter 按条件过滤、limit 取前 n 个元素、skip 跳过前 n 个元素、map 映射加工、concat 合并 stream 流等。日期:增强了日期和时间 API,新的 java.time 包主要包含了处理日期、时间、日期/时间、时区、时刻和时钟等操作。JavaScript:提供了一个新的 JavaScript 引擎,允许在 JVM上运行特定 JavaScript 应用。注意lamda表达式的优缺点和应用场景:优点:1. 简洁。2. 非常容易并行计算。3. 可能代表未来的编程趋势。缺点:1. 若不用并行计算,很多时候计算速度没有比传统的 for 循环快。(并行计算有时需要预热才显示出效率优势)2.** 不容易调试**。3. 若其他程序员没有学过 lambda 表达式,代码不容易让其他语言的程序员看懂。例子:List<int> evenNumbers = list.FindAll(i => (i% 2) == 0);应用场景:Lamda表达式主要用于替换以前广泛使用的匿名内部类,各种回调,比如事件响应器、传入Thread类的Runnable等。(1)使用() -> {} 替代匿名类//Before Java 8: new Thread(new Runnable() { @Override public void run() { System.out.println("Before Java8 "); } }).start(); //Java 8 way: new Thread(() -> System.out.println("In Java8!")); // Before Java 8: JButton show = new JButton("Show"); show.addActionListener(new ActionListener() { @Override public void actionPerformed(ActionEvent e) { System.out.println("without lambda expression is boring"); } }); // Java 8 way: show.addActionListener((e) -> { System.out.println("Action !! Lambda expressions Rocks"); });(2)使用内循环替代外循环外循环:描述怎么干,代码里嵌套2个以上的for循环的都比较难读懂;只能顺序处理List中的元素;内循环:描述要干什么,而不是怎么干;不一定需要顺序处理List中的元素//Before Java 8: new Thread(new Runnable() { @Override public void run() { System.out.println("Before Java8 "); } }).start(); //Java 8 way: new Thread(() -> System.out.println("In Java8!")); // Before Java 8: JButton show = new JButton("Show"); show.addActionListener(new ActionListener() { @Override public void actionPerformed(ActionEvent e) { System.out.println("without lambda expression is boring"); } }); // Java 8 way: show.addActionListener((e) -> { System.out.println("Action !! Lambda expressions Rocks"); });(3)支持函数编程为了支持函数编程,Java 8加入了一个新的包java.util.function,其中有一个接口java.util.function.Predicate是支持Lambda函数编程(4)用管道方式处理数据更加简洁Java 8里面新增的Stream API ,让集合中的数据处理起来更加方便,性能更高,可读性更好jar包和war包的主要区别概念:(1)jar包:JAR包是类的归档文件,JAR 文件格式以流行的 ZIP 文件格式为基础。与 ZIP 文件不同的是,JAR 文件不仅用于压缩和发布,而且还用于部署和封装库、组件和插件程序,并可被像编译器和 JVM 这样的工具直接使用。(2)war包:war包是JavaWeb程序打的包,war包里面包括写的代码编译成的class文件,依赖的包,配置文件,所有的网站页面,包括html,jsp等等。一个war包可以理解为是一个web项目,里面是项目的所有东西。目录结构:(1)jar包里的com里放的就是class文件,配置文件,但是没有静态资源的文件,大多数 JAR 文件包含一个 META-INF 目录,它用于存储包和扩展的配置数据,如安全性和版本信息。(2)war包里的WEB-INF里放的class文件和配置文件,META-INF和jar包作用一样,但是war包里还包含静态资源的文件。项目部署:(1)部署普通的spring项目用war包就可以(2)部署springboot项目用jar包就可以,因为springboot内置tomcat。总结不同:(1)war包和项目的文件结构保持一致,jar包则不一样。(2)jar包里没有静态资源的文件(index.jsp)
1.最小路径和(64 - 中)题目描述:给定一个包含非负整数的 m x n 网格 grid ,请找出一条从左上角到右下角的路径,使得路径上的数字总和为最小。说明:每次只能向下或者向右移动一步。示例 :输入:grid = [[1,3,1],[1,5,1],[4,2,1]] 输出:7 解释:因为路径 1→3→1→1→1 的总和最小。思路:典型动态规划,定义dp数组记录累加值,状态转移方程为:dp[i][j] = Math.min(dp[i - 1][j], dp[i][j - 1]) + grid[i][j]。代码实现:public int minPathSum(int[][] grid) { if (grid == null || grid.length == 0 || grid[0] == null || grid[0].length == 0) return 0; int m = grid.length, n = grid[0].length; int[][] dp = new int[m][n]; dp[0][0] = grid[0][0]; for(int i = 1; i < m; i++) { dp[i][0] = dp[i - 1][0] + grid[i][0]; } for(int i = 1; i < n; i++) { dp[0][i] = dp[0][i - 1] + grid[0][i]; } for (int i = 1; i < m; i++) { for (int j = 1; j < n; j++) { dp[i][j] = Math.min(dp[i - 1][j], dp[i][j - 1]) + grid[i][j]; } } return dp[m - 1][n - 1]; }空间优化:空间复杂度O(n),滚动数组:因为i行依赖i行和i - 1行,所以我们计算完i行覆盖i - 1行的值,不会对最终结果。public int minPathSum(int[][] grid) { int m = grid.length, n = grid[0].length; int[] dp = new int[n]; for (int i = 0; i < m; ++i) { for (int j = 0; j < n; ++j) { if (j == 0) { // 只能从上边界来 dp[j] = dp[j]; } else if (i == 0) { // 只能从左边界来 dp[j] = dp[j - 1]; } else { dp[j] = Math.min(dp[j - 1], dp[j]); } dp[j] += grid[i][j]; } } return dp[n - 1]; }2.不同路径(62 - 中)题目描述:一个机器人位于一个 m x n 网格的左上角 (起始点在下图中标记为 “Start” )。机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为 “Finish” )。问总共有多少条不同的路径?示例 :输入:m = 3, n = 7 输出:28思路:典型动态规划问题,与上题不同的是dp数组记录的是到达该点的路径总数,状态转移方程为:dp[i][j] = dp[i - 1][j] + dp[i][j - 1]。注意dp数组初始值为1,即到达该位置的路径为1.代码实现:public int uniquePaths(int m, int n) { int[][] dp = new int[m][n]; for (int i = 0; i < m; ++i) { dp[i][0] = 1; } for (int j = 0; j < n; ++j) { dp[0][j] = 1; } for (int i = 1; i < m; ++i) { for (int j = 1; j < n; ++j) { dp[i][j] = dp[i - 1][j] + dp[i][j - 1]; } } return dp[m - 1][n - 1]; }空间优化:空间复杂度O(min(m, n)),同理。public int uniquePaths(int m, int n) { int less = Math.min(m, n), more = Math.max(m, n); int[] dp = new int[less]; Arrays.fill(dp, 1); for (int i = 1; i < more; ++i) { for (int j = 1; j < less; ++j) { dp[j] = dp[j] + dp[j - 1]; } } return dp[less - 1]; }3.不同路径II(63 - 中)题目描述:一个机器人位于一个 m x n 网格的左上角 (起始点在下图中标记为 “Start” )。机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为 “Finish” )。现在考虑网格中有障碍物。问总共有多少条不同的路径?示例 :输入:obstacleGrid = [[0,0,0],[0,1,0],[0,0,0]] 输出:2 解释: 3x3 网格的正中间有一个障碍物。 从左上角到右下角一共有 2 条不同的路径: 1. 向右 -> 向右 -> 向下 -> 向下 2. 向下 -> 向下 -> 向右 -> 向右思路:与上题类似,注意更新dp数组进行判断,该点如果是障碍则跳过。注意:本题不能进行空间压缩。代码实现:public int uniquePathsWithObstacles(int[][] obstacleGrid) { int m = obstacleGrid.length, n = obstacleGrid[0].length; int[][] dp = new int[m][n]; // 处理边界条件 for (int i = 0; i < m && obstacleGrid[i][0] == 0; ++i) dp[i][0] = 1; for (int j = 0; j < n && obstacleGrid[0][j] == 0; ++j) dp[0][j] = 1; // 顺序填表 for (int i = 1; i < m; ++i) { for (int j = 1; j < n; ++j) { //当前位置为障碍物 if (obstacleGrid[i][j] == 1) continue; dp[i][j] = dp[i][j - 1] + dp[i - 1][j]; } } return dp[m - 1][n - 1]; }拓展:三角形最小路径和(120 - 中)题目描述:给定一个三角形 triangle ,找出自顶向下的最小路径和。每一步只能移动到下一行中相邻的结点上。相邻的结点 在这里指的是 下标 与 上一层结点下标 相同或者等于 上一层结点下标 + 1 的两个结点。也就是说,如果正位于当前行的下标 i ,那么下一步可以移动到下一行的下标 i 或 i + 1 。示例 :输入:triangle = [[2],[3,4],[6,5,7],[4,1,8,3]] 输出:11 解释:如下面简图所示: 2 3 4 6 5 7 4 1 8 3 自顶向下的最小路径和为 11(即,2 + 3 + 5 + 1 = 11)。思路:动态规划,dp数组:到达某层的最小路径和,由题意的依赖关系,转移方程应为:dp[j] = Math.min(dp[j], dp[j + 1]) + triangle.get(i).get(j),即从下向上填表。注意:由于i依赖于i和i + 1,所以外层循环要倒叙,空间压缩时防止覆盖。代码实现:public int minimumTotal(List<List<Integer>> triangle) { int n = triangle.size(); int[] dp = new int[n + 1]; for (int i = n - 1; i >= 0; --i) { for (int j = 0; j <= i; ++j) { dp[j] = Math.min(dp[j], dp[j + 1]) + triangle.get(i).get(j); } } return dp[0]; }拓展:圆环回原点问题(字节高频题)题目描述:圆环上有10个点,编号为0~9。从0点出发,每次可以逆时针和顺时针走一步,问走n步回到0点共有多少种走法。举例:如果n=1,则从0出发只能到1或者9,不可能回到0,共0种走法如果n=2,则从0出发有4条路径:0->1->2, 0->1->0, 0->9->8, 0->9->0,其中有两条回到了0点,故一共有2种走法示例:输入: 2 输出: 2 解释:有2种方案。分别是0->1->0和0->9->0思路:本题考察点是动态规划,与Leetcode70爬楼梯相似。dp[i][j] 为从0点出发走 i步到 j点 的方案数状态转移方程:走 n 步到 0 的方案数=走 n-1 步到 1 的方案数+走 n-1 步到 9 的方案数(j步达到i点的问题,转化为j-1步从相邻的两个节点到达目标节点的方法数之和)。即 dp[i][j] = dp[i - 1][(j - 1 + n) % n] + dp[i - 1][(j + 1) % n]ps:公式之所以取余是因为 j-1或 j+1 可能会超过圆环0~9的范围。代码:import java.util.Scanner; public class Solution005 { public static void main(String[] args) { System.out.println("请输入圆环节点个数和步数n:"); Scanner sc = new Scanner(System.in); int length = sc.nextInt(); int n = sc.nextInt(); System.out.println(solution(length, n)); } public static int solution(int n, int k) { if (n == 0) { return 1; } // 只有两个环,偶数步数有一种方案,奇数步数不能到达 if (n == 2) { if (k % 2 == 0) { return 1; } else { return 0; } } // dp[i][j]为从0点出发走i步到j点的方案数 int[][] dp = new int[k + 1][n]; dp[0][0] = 1; // 0步到达任何位置(除0)的方法数为0,java可省略 for (int j = 1; j < n; j++) { dp[0][j] = 0; } for (int i = 1; i <= k; i++) { for (int j = 0; j < n; j++) { dp[i][j] = dp[i - 1][(j - 1 + n) % n] + dp[i - 1][(j + 1) % n]; } } return dp[k][0]; } }拓展:如果本题不是返回原点,而是k步到达3这个点的方法总数,只需要返回 dp[k][3] 即可。拓展:交错字符串(97-中)题目描述:给定三个字符串 s1、s2、s3,请你帮忙验证 s3 是否是由 s1 和 s2 交错 组成的。每个字符串被分成长度若干的子串,验证这些子串是否可以交错拼接 s3。示例:输入:s1 = "aabcc", s2 = "dbbca", s3 = "aadbbcbcac" 输出:true s3 = aa ddbc bc a c 输入:s1 = "aabcc", s2 = "dbbca", s3 = "aadbbbaccc" 输出:false s3不能由s1和s2交错组成。思路:参考@sage大佬,上图问题转化为:每次只能向右或者向下选择字符,问是否存在target路径。注意当len(s1) + len(s2) != len(s3)时一定不能交错匹配,否则进行动态规划求解:dp[i][j]代表 s1 前 i 个字符与 s2 前 j 个字符拼接成 s3 的 i+j 字符,也就是存在目标路径能够到达 i ,j ;状态转移方程:到达(i, j)可能由上边位置过来或者左边位置过来ps:边界条件注意,字符串都为空,返回true;代码:public boolean isInterleave(String s1, String s2, String s3) { int n1 = s1.length(), n2 = s2.length(); if (s3.length() != n1 + n2) { return false; } // dp[i,j]表示s1前i字符能与s2前j字符组成s3前i+j个字符; boolean[][] dp = new boolean[n1 + 1][n2 + 1]; dp[0][0] = true; for (int i = 1; i <= n1 && s1.charAt(i - 1) == s3.charAt(i - 1); i++) dp[i][0] = true; for (int j = 1; j <= n2 && s2.charAt(j - 1) == s3.charAt(j - 1); j++) dp[0][j] = true; for (int i = 1; i <= n1; i++) { for (int j = 1; j <= n2; j++) { dp[i][j] = dp[i - 1][j] && s1.charAt(i - 1) == s3.charAt(i + j - 1) || dp[i][j - 1] && s2.charAt(j - 1) == s3.charAt(i + j - 1); } } return dp[n1][n2]; }
Java中的数据类型Java的数据类型分为两大类:基本类型和引用类型引用类型:引用类型指向一个对象,不是原始值,指向对象的变量称为引用变量。Java中除了基本类型,其他都是引用类型,如类(String类)、接口、数组、枚举、注解等。将引用存放栈中,实际存放值在堆内存。基本数据类型(也称值类型):整型(byte/short/int/long)、浮点型(float/double)、字符型(char占两个字节)与布尔型(boolean占一个字节)。直接在栈中存储数值。编程语言内置的最小粒度的数据类型,共有四大类八种基本数据类型。具体如下表:标识符和关键字的区别是什么?Java关键字?在我们编写程序的时候,需要大量地为程序、类、变量、方法等取名字,于是就有了标识符,简单来说,标识符就是一个名字。但是有一些标识符,Java 语言已经赋予了其特殊的含义,只能用于特定的地方,这种特殊的标识符就是关键字。因此,关键字是被赋予特殊含义的标识符。比如,在我们的日常生活中 ,“警察局”这个名字已经被赋予了特殊的含义,所以如果你开一家店,店的名字不能叫“警察局”,“警察局”就是我们日常生活中的关键字。Java中的关键字:自动拆装箱、发生与实现?每个基本数据类型都对应一个包装类,除了 int 和 char 对应 Integer 和 Character 外,其余基本数据类型的包装类都是首字母大写即可,比较两个包装类数值要用 equals ,而不能用 == 。自动装箱: 将基本数据类型包装为一个包装类对象,例如向一个泛型为 Integer 的集合添加 int 元素。使用基础型类给引用类型变量赋值;具体实现:调用引用类型对应的静态方法valueOf,本质是在该方法内部调用构造函数创建对象。自动装箱的缓存机制:为了节省内存和提升性能,Java给多个包装类提供了缓存机制,可以在自动装箱过程中,把一部分对象放到缓存中(引用常量池),实现了对象的复用。如Byte、Short、Integer、Long、Character等都支持缓存。自动拆箱: 将一个包装类对象转换为一个基本数据类型,例如将一个包装类对象赋值给一个基本数据类型的变量。当基础类型与引用类型进行 “==、+、-、×、÷” 运算时,会对引用类型进行自动拆箱;具体实现:引用类型对象内部包含对应基本类型的成员变量,自动拆箱时返回该成员变量即可;switch支持哪些类型?jdk1.6之前只支持int、char、byte、short这样的整型的基本类型或对应的包装类型Integer、Character、Byte、Short常量,包装类型最终也会经过拆箱为基本类型,本质上还是只支持基本类型;JDK1.5开始支持enum,原理是给枚举值进行了内部的编号,进行编号和枚举值的映射。JDK1.7开始支持String,但不允许为null,原理是借助 hashcode( ) 来实现。注意:null的hashCode()没有,hashmap是人为写到0槽的!== 与 equals 区别"=="对比的是栈中的内容:基本数据类型比较的是变量值;引用类型比较的堆中内存对象的地址。equals 本质上就是 ==,只不过 String 和 Integer 等重写了 equals 方法,把它变成了值(内容)比较。注:对于"=="中引用类型,除非是同一个new出来的对象,他们的比较后的结果为true,否则比较后结果为false。因为每new一次,都会重新开辟堆内存空间。String中重写equals方法举例:public boolean equals(Object anObject) { // 1. 如果比较的两个对象的首地址是相同的,那指的肯定是同一个对象 if (this == anObject) { return true; } // 2. 两个对象的首地址不相同,比较内容是否相同 // instanceof:判断传入的实参是否为String类型,因为形参类型是固定的(重写的要求),所以需要判断 if (anObject instanceof String) { String anotherString = (String)anObject; int n = value.length; // 判断传入对象长度是否相同 if (n == anotherString.value.length) { char v1[] = value; char v2[] = anotherString.value; int i = 0; while (n-- != 0) { //实质比较字符的ascii值,即两个字符串内容的比较! if (v1[i] != v2[i]) return false; i++; } return true; } } return false; }重写思路:判断两个对象的内存首地址判断传入形参的类型是否为String类型判断传入对象的长度判断字符的ascii码,即比较内容应用案例分析:String str1 = "Hello"; // 分配到常量池 String str2 = new String("Hello"); // 在堆中分配内存 String str3 = str2; // 引用传递 System.out.println(str1 == str2); // false,比较两者栈中的内存地址,显然不同 System.out.println(str1 == str3); // false System.out.println(str2 == str3); // true System.out.println(str1.equals(str2)); // true,string重写了equals方法,实际上对比的两个字符串的内容 System.out.println(str1.equals(str3)); // true System.out.println(str2.equals(str3)); // true注意:字符串/包装类对象值的比较必须使用equals(),即对比内容。比如下边的Integer缓存-128~127的整型值,如果超过这个范围,就会在堆上创建对象,不会复用已有的对象。int和Integer的区别(包装类与基本类型的区别)Integer是int的包装类,int则是java的一种基本数据类型Integer变量必须实例化后才能使用,而int变量不需要Integer实际是对象的引用,当new一个Integer时,实际上是生成一个指针指向此对象;而int则是直接存储数据值 。Integer的默认值是null,int的默认值是0Integer a= 127与 Integer b = 127相等吗?(常量池缓存技术)写在前:Java 基本类型的包装类的大部分都实现了常量池技术。Byte,Short,Integer,Long 这 4 种整型的包装类默认创建了数值 [-128,127] 的相应类型的缓存数据,Character 创建了数值在[0,127]范围的缓存数据,Boolean 直接返回 True Or False。注意:一个字节有8位。最大正数二进制是0111 1111 = 64+32+16+8+4+2+1=127;最小负数二进制是1000 0000→ 反码:1111 1111→ 补码: -{(1+2+4+8+16+32+64)+1} =-(127+1)=-128,整形的缓存范围都是[-128,127]。对于对象引用类型:==比较的是对象的内存地址,对于基本数据类型:==比较的是值。Integer内部有一个IntegerCache的内部类。对整型值在-128到127之间的对象进行缓存。缓存会在Integer类第一次使用的时候被初始化出来,那么自动装箱时不会new新的Integer对象,而是直接引用常量池中的Integer对象(直接从缓存中取出),超过范围 a1==b1的结果是false。注:若数值超过缓存范围,则需要在堆中创建新的对象!public static void main(String[] args) { Integer a = new Integer(1); Integer b = 1; // 将1自动装箱成Integer类型,实际是调用静态方法valueOf() int c = 1; System.out.println(a == b); // false,引用了不同对象 System.out.println(a == c); // true,a自动拆箱与c比较 System.out.println(b == c); // true Integer a1 = 128; Integer b1 = 128; System.out.println(a1 == b1); // false, 自动装箱,new新的Integer对象 Integer a2 = 127; Integer b2 = 127; System.out.println(a2 == b2); // true,字面值介于-128与127, 自动装箱不会new新的Integer对象,直接引用常量池中的对象 }java有了基本类型,为什么还要设计对应的引用类型?可以使用为该引用类型而编写的方法Java集合(map、set、list)等所有集合只能存放引用类型数据,不能存放基本类型数据(容器中实际存放的是对象的引用)。引用类型对象存储在堆上,可以控制其生命周期;而基本类型存储在栈上,会随着代码块运行结束被回收Java泛型使用类型擦除法实现,基本类型无法实现泛型。为什么会设置包装类型?Java是一门面向对象的编程语言,但是Java中的基本数据类型却不是面向对象的,并不具有对象的性质,这在实际生活中存在很多的不便。为了让基本类型也具有对象的特征,就出现了包装类型,使得Java具有了对象的性质,并且为其添加了属性和方法,丰富了基本类型的操作,方便涉及到对象的操作。比如,我们在使用集合类型时,就一定要使用包装类型,因为容器都是装object的,基本数据类型显然不适用。逻辑上来讲,java只有包装类就够了,为了运行速度,需要用到基本数据类型。实际上,任何一门语言设计之初,都会优先考虑运行效率的问题,所以二者同时存在是合乎情理的。java泛型相关问题在 jdk 1.5 之前没有泛型的情况的下只能通过对类型 Object 的引用来实现参数的任意化,其带来的缺点是要做显式强制类型转换,而这种强制转换编译期是不做检查的,容易把问题留到运行时,会出现“java.lang.ClassCastException”异常。因此,导致此类错误编码过程中不易发现。import java.util.ArrayList; import java.util.List; public class Test001 { public static void main(String[] args) { List list = new ArrayList(); list.add("java"); list.add("非泛型"); list.add(100); // 泛型使用(jdk1.7实例化类型可以自动推断) List<String> list1 = new ArrayList<>(); list1.add("java"); list1.add("泛型"); // 想加入一个Integer类型的对象时会出现编译错误 //list1.add(100); for (int i = 0; i < list1.size(); i++) { String name1 = list1.get(i); System.out.println("name1:" + name1); } System.out.println(); for (int i = 0; i < list.size(); i++) { // Exception in thread "main" java.lang.ClassCastException: java.lang.Integer cannot be cast to java.lang.String // String name = (String) list.get(i); // System.out.println("name:" + name); } // 泛型擦除(泛型只存在编译阶段,成功编译过后的class文件中是不包含任何泛型信息的) List<String> name = new ArrayList<String>(); name.add("java"); List<Integer> age = new ArrayList<Integer>(712); System.out.println("name class:" + name.getClass()); System.out.println("age class:" + age.getClass()); System.out.println(name.getClass() == age.getClass()); List<Object> objectList = new ArrayList<>(); List<String> stringList = new ArrayList<>(); // compilation error incompatible types // objectList = stringList; } }在如上的编码过程中,我们发现主要存在两个问题:当我们将一个对象放入集合中,集合不会记住此对象的类型,当再次从集合中取出此对象时,该对象的编译类型变成了Object类型,但其运行时类型任然为其本身类型。因此,取出集合元素时需要人为的强制类型转化到具体的目标类型,且很容易出现“java.lang.ClassCastException”异常。泛型是jdk1.5的新特性,泛型提供了编译时类型安全检测机制,该机制允许程序员在编译时检测到非法的类型。泛型的本质是参数化类型,也就是说所操的数据类型被指定为一个参数。这种参数类型可以用在类、接口和方法的创建中,分别称为泛型类、泛型接口、泛型方法。 Java语言引入泛型的好处是安全简单。好处:类型安全,编译期检查,不存在 ClassCastException。提升可读性,编码阶段就显式知道泛型集合、泛型方法等处理的对象类型。代码重用,合并了同类型的处理代码。泛型擦除是什么?介绍下常用的通配符原理:泛型只存在于编译阶段,不存在与运行阶段(编译后的class文件不存在泛型的概念)。例如定义 List<Object> 或 List<String>,在编译后都会变成 List 。Java中List和原始类型List之间的区别?原始类型和带参数类型< Object >之间的主要区别:在编译时编译器不会对原始类型进行类型安全检查,却会对带参数的类型进行检查,通过使用Object作为类型,可以告知编译器该方法可以接受任何类型的对象,比如String或Integer。你可以把任何带参数的类型传递原始类型List,但却不能把List< String >传递给接受List< Object >方法,因为会产生编译错误。定义一个泛型类型,会自动提供一个对应原始类型,类型变量会被擦除。如果没有限定类型就会替换为 Object,如果有限定类型就会替换为第一个限定类型,例如 <T extends A & B> 会使用 A 类型替换 T。常用的通配符为: T,E,K,V,?(1)? 表示不确定的 java 类型(2)T (type) 表示具体的一个 java 类型(3)K V (key value) 分别代表 java 键值中的 Key Value(4)E (element) 代表 Element什么是泛型中的限定通配符和非限定通配符 ? List<? extends T>和List <? super T>之间有什么区别 ?限定通配符对类型进行了限制。有两种限定通配符,一种是<? extends T>它通过确保类型必须是T的子类来设定类型的上界,另一种是<? super T>它通过确保类型必须是T的父类来设定类型的下界。泛型类型必须用限定内的类型来进行初始化,否则会导致编译错误。另一方面<?>表示了非限定通配符,因为<?>可以用任意类型来替代。总结:extend用于设定类型的上界(必须是T的子类),super用于设定类型的下界(必须是T的父类)编写泛型方法并不困难,你需要用泛型类型来替代原始类型,比如使用T, E or K,V等被广泛认可的类型占位符。泛型方法的例子请参阅Java集合类框架。public V put(K key, V value) { return cache.put(key, value); }Array事实上并不支持泛型,这也是为什么Joshua Bloch在Effective Java一书中建议使用List来代替Array,因为List可以提供编译期的类型安全保证,而Array却不能。浅谈String str = "" 和 new String()的区别两者看似都是创建了一个字符串对象,但在内存中确是各有各的想法。String str1= “abc”; 在编译期,JVM会去常量池来查找是否存在“abc”,如果不存在,就在常量池中开辟一个空间来存储“abc”;如果存在,就不用新开辟空间。然后在栈内存中开辟一个名字为str1的空间,来存储“abc”在常量池中的地址值。String str2 = new String("abc") ; 在编译阶段JVM先去常量池中查找是否存在“abc”,如果不存在,则在常量池中开辟一个空间存储“abc”。在运行时期,通过String类的构造器(intern()方法)在堆内存中new了一个空间,然后将String池中的“abc”复制一份存放到该堆空间中,在栈中开辟名字为str2的空间,存放堆中new出来的这个String对象的地址值。也就是说,前者在初始化的时候可能创建了一个对象,也可能一个对象也没有创建;后者因为new关键字,至少在内存中创建了一个对象,也有可能是两个对象。什么是字符串常量池(String Pool)?字符串常量池位于堆内存中(java8之前在方法区,java8之后被放到堆内存),专门用来存储字符串常量,可以提高内存的使用率,避免开辟多块空间存储相同的字符串;在创建字符串时(new String()),JVM 会首先检查字符串常量池,如果该字符串已经存在池中,则返回它的引用,如果不存在,则实例化一个字符串通过 String 的 intern() 方法放到池中,并返回其引用。ps:池化(如Java线程池、连接池与内存池等)的优点:降低资源消耗:通过池化技术重复利用已经创建的资源,提高内存的使用率提高响应速度:如果字符串已经存在直接返回它的引用,如果没有池化的字符串则是通过String的intern()方法放入池中,然后再返回它的引用。便于资源的管理。String a = "a" + new String("b")创建了几个对象?常量和常量拼接仍是常量,结果在常量池,只要有变量参与拼接结果就是变量,存在堆。使用字面量时只创建一个常量池中的常量。使用 new 时如果常量池中,如果有,直接让引用指向常量池的对象;没有该值就会在常量池中新创建,再在堆中创建一个对象,引用常量池中常量。因此 String a = "a" + new String("b") 会创建三个或者四个对象,常量池中的 a 和 b,堆中的 b(常量池中存在,则无需创建) 和堆中的 ab。字符串拼接有哪几种方式?直接用 + ,底层用 StringBuilder 实现。只适用小数量。如果频繁(或者在循环体中)的使用 + 拼接,相当于不断创建新的 StringBuilder 对象(sppend进行拼接)再转换成 String 对象(toString进行拼接),效率极差,内存消耗大。使用 String 的 concat 方法,该方法中使用 Arrays.copyOf 创建一个新的字符数组 buf 并将当前字符串 value 数组的值拷贝到 buf 中,buf 长度 = 当前字符串长度 + 拼接字符串长度。之后调用 getChars 方法使用 System.arraycopy 将拼接字符串的值也拷贝到 buf 数组,最后用 buf 作为构造参数 new 一个新的 String 对象返回。效率稍高于直接使用 +。public String concat(String str) { int otherLen = str.length(); if (otherLen == 0) { return this; } int len = value.length; char buf[] = Arrays.copyOf(value, len + otherLen); str.getChars(buf, len); return new String(buf, true); }使用 StringBuilder 或 StringBuffer,两者的 append 方法都继承自 AbstractStringBuilder,该方法首先使用 Arrays.copyOf 确定新的字符数组容量,再调用 getChars 方法使用 System.arraycopy 将新的值追加到数组中。StringBuilder 是 JDK5 引入的,效率高但线程不安全。StringBuffer 使用 synchronized 保证线程安全。public synchronized StringBuffer append(String str) { toStringCache = null; super.append(str); return this; }StringUtils中提供的join方法,最主要的功能是:将数组或集合以某拼接符拼接到一起形成新的字符串,并且,Java8中的String类中也提供了一个静态的join方法,用法和StringUtils.join类似。底层也是通过StringBuilder进行拼接。String 常用的方法?判断功能boolean equals(Object obj):比较字符串的内容是否相同,区分大小写a Aboolean equalsIgnoreCase(String str):比较字符串的内容是否相同,忽略大小写 a Aboolean contains(String str):判断大字符串中是否包含小字符串boolean startsWith(String str):判断字符串是否以某个指定的字符串开头boolean endsWith(String str):判断字符串是否以某个指定的字符串结尾boolean isEmpty():判断字符串是否为空。获取功能int length():获取字符串的长度。char charAt(int index):获取指定索引位置的字符int indexOf(String str):返回指定字符串在此字符串中第一次出现处的索引。int indexOf(int ch,int fromIndex):返回指定字符在此字符串中从指定位置后第一次出现处的索引。int indexOf(String str,int fromIndex):返回指定字符串在此字符串中从指定位置后第一次出现处的索引。String substring(int start):从指定位置开始截取字符串,默认到末尾。String substring(int start,int end):从指定位置开始到指定位置结束截取字符串。转换功能byte[] getBytes():把字符串转换为字节数组。char[] toCharArray():把字符串转换为字符数组。static String valueOf(char[] chs):把字符数组转成字符串。static String valueOf(int i):把int类型的数据转成字符串。注意:String类的valueOf方法可以把任意类型的数据转成字符串。String toLowerCase():把字符串转成小写。String toUpperCase():把字符串转成大写。String concat(String str):把字符串拼接。其他方法String rerplace(char old,char new);返回一个新的字符串,它是通过用 newChar 替换此字符串中出现的所有 oldChar 得到的。String replace(String old,String new);返回一个新的字符串,它是通过用 newChar 替换此字符串中出现的所有 oldChar 得到的。String trim(); 去掉字符串两端的空格字符串的" "与null的区别:" "是字符串常量.同时也是一个String类的对象,既然是对象当然可以调用String类中的方法;Null是空常量,不能调用任何的方法,否则会出现空指针异常,null常量可以给任意的引用数据类型赋值ps:String创建字符串的方式:直接赋值(在常量池中,只开辟了一块内存,并且会自动入池,不会产生垃圾)和实例化方式(通过构造函数创建的字符串对象在堆中,会开辟两块堆内存空间,其中一块堆内存会变成垃圾被系统回收,而且不能够自动入池,需要通过public String intern();方法进行手工入池,通常在开发的过程中不会采用构造方法进行字符串的实例化。)String是否可变?String类就是final修饰(字符数组final修饰)!Jdk8中String类有两个成员变量:/** The value is used for character storage. */ private final char value[]; /** Cache the hash code for the string */ private int hash; // Default to 0也就是说在String类内部,一旦初始化就不能被改变。所以可以认为String对象是不可变的。ps:在 Java 9 之后,String 、StringBuilder 与 StringBuffer 的实现改用 byte 数组存储字符串 private final byte[] value。String为什么要设计为不可变?(String的优点)效率(字符串常量池),字符串常量池的需要,只有字符串不可变时,字符串常量池才能实现。安全(多线程、hash值唯一、类加载,参数安全)。多线程安全,对象是只读的,不会出现线程安全问题。因为 String 的 hash 值经常被使用,例如 String 用做 HashMap 的 key。不可变的特性可以使得 hash 值也不可变,因此只需要进行一次计算。类加载器要用到字符串,不可变提供了安全性,以便类被正确地加载。String被许多的Java类(库)用来当做参数,例如网络连接地址URL,文件路径path,还有反射机制所需要的String参数等, 假若String不是固定不变的,将会引起各种安全隐患。String 是不可变类为什么值可以修改?String 类和其存储数据的成员变量 value 字符数组都是 final 修饰的。对一个 String 对象的任何修改实际上都是创建一个新 String 对象,再引用该对象(只是修改变量引用的对象)。只是修改 String 变量引用的对象,没有修改原 String 对象的内容。用反射,可以反射出String对象中的value属性, 进而通过获得的value引用改变数组的结构。可以通过类对象的getDeclaredField()方法字段(Field)对象,然后再通过字段对象的setAccessible(true)将其设置为可以访问,接下来就可以通过get/set方法来获取/设置字段的值了。Java里实现不可变类的四大要素:尽量使用final修饰所有的属性(field)尽量使用private修饰属性。禁止提供可改变实例状态的公开接口禁止不可变类被“外部”继承使用final关键字修饰类构造器私有化 & 提供静态构造方法String、StringBuffer与StringBuilder的区别和应用场景是否产生新对象:String是final修饰的,不可变,每次操作都会产生新的String对象(一定程度上导致了内存浪费)。StringBuffer和StringBuilder都是在原对象上进行操作(不会产生新对象)。频繁的对String对象进行修改,会造成很大的内存开销:// str指向了一个String对象(内容为“hello”) String str = “hello"; // 对str进行“+”操作,str原来指向的对象并没有变,而是str又指向了另外一个对象(“hello world”),原来的对象还在内存中。 str = str + "world“;注意:String类是final修饰,不能被继承和重写,实现了equals()和hashCode()方法。三者区别(线程安全性与性能):线程安全性:StringBuffer是线程安全的(内部方法都用synchronized关键字修饰),StringBuilder是线程不安全的;String不可变性保证线程安全(可理解为常量)。性能(效率):StringBuilder>StringBuffer>String;ps:相同情况下使用 StringBuilder 相比使用 StringBuffer 仅能获得 10%~15% 左右的性能提升。注:这里谈到一个对象是否线程安全,是不是需要额外进行加锁,保证满足三个条件:多线程环境下、变量为共享变量和结果不受影响。应用场景:操作少量数据使用String;StringBuffer和StringBuilder经常需要改变字符串内容时使用:单线程操作字符串缓冲区下⼤量数据使用StringBuilder;多线程(共享变量)操作字符串缓冲区下⼤量数据为了保证结果的正确性使用StringBuffer。构造方法:作用、特性主要作⽤是完成对类对象的初始化⼯作。注意:在调⽤⼦类构造⽅法之前会先调⽤⽗类没有参数的构造⽅法,目的是帮助子类初始化。构造方法特性(特别注意):名字与类名相同,没有返回值,但不能⽤ void 声明构造函数。⽣成类的对象时⾃动执⾏,⽆需调⽤。⼀个类即使没有声明构造⽅法也会有默认的不带参数的构造⽅法。构造方法不能被继承,构造方法只能被显式或者隐式地调用。子类的构造方法总是先调用父类的构造方法,如果子类的构造方法没有显式地指出使用父类的哪个构造方法,子类则默认调用父类的无参构造方法(此时若父类自定义了构造方法,而子类又没有用super则会报错)。ps:Java不支持像C++中那样的复制构造方法(没有这个概念),但是在Object类中有一个clone()方法。关于 static 关键字的⼀些总结static关键字常见的用法是修饰变量和方法,其次可以修饰代码块,比较少的应用是修饰类(匿名内部类)。static关键字最基本的用法是修饰变量与方法:被static修饰的变量属于类变量,可以通过类名.变量名直接引用,而不需要new出一个类来被static修饰的方法属于类方法,可以通过类名.方法名直接引用,而不需要new出一个类来被static修饰的变量、被static修饰的方法统一属于类的静态资源,是类实例之间共享的,换言之,一处变、处处变。JDK把不同的静态资源放在了不同的类中而不把所有静态资源放在一个类里面,很多人可能想当然认为当然要这么做,但是是否想过为什么要这么做呢?个人认为主要有三个好处:不同的类有自己的静态资源,这可以实现静态资源分类。比如和数学相关的静态资源放在java.lang.Math中,和日历相关的静态资源放在java.util.Calendar中,这样就很清晰了避免重名。不同的类之间有重名的静态变量名、静态方法名也是很正常的,如果所有的都放在一起不可避免的一个问题就是名字重复,这时候怎么办?分类放置就好了。避免静态资源类无限膨胀,这很好理解。静态资源属于类,但是是独立于类存在的。从JVM的类加载机制的角度讲,静态资源是类初始化的时候加载的,而非静态资源是类new的时候加载的。类的初始化早于类的new,比如Class.forName(“xxx”)方法,就是初始化了一个类,但是并没有new它,只是加载这个类的静态资源罢了。所以对于静态资源来说,它是不可能知道一个类中有哪些非静态资源的;但是对于非静态资源来说就不一样了,由于它是new出来之后产生的,因此属于类的这些东西它都能认识。故:静态方法能不能引用非静态资源?不能,new的时候才会产生的东西,对于初始化后就存在的静态资源来说,根本不认识它。静态方法里面能不能引用静态资源?可以,因为都是类初始化的时候加载的,大家相互都认识。非静态方法里面能不能引用静态资源?可以,非静态方法就是实例方法,那是new之后才产生的,那么属于类的内容它都认识。静态代码块也是static的重要应用之一。也是用于初始化一个类的时候做操作用的,和静态变量、静态方法一样,静态块里面的代码只执行一次,且只在初始化类的时候执行。静态块很简单,不过提三个小细节:静态资源的加载顺序是严格按照静态资源的定义顺序来加载的静态代码块对于定义在它之后的静态变量,可以赋值,但是不能访问(如print()操作)。静态代码块是严格按照父类静态代码块->子类静态代码块的顺序加载的,且只加载一次。static修饰类:这个用得相对比前面的用法少多了,static一般情况下来说是不可以修饰类的,如果static要修饰一个类,说明这个类是一个静态内部类(注意static只能修饰一个内部类),也就是匿名内部类。像线程池ThreadPoolExecutor中的四种拒绝机制CallerRunsPolicy、AbortPolicy、DiscardPolicy、DiscardOldestPolicy就是静态内部类。静态方法 与 实例方法早期的结构化编程,几乎所有的方法都是“静态方法”,引入实例化方法概念是面向对象概念出现以后的事情了,区分静态方法和实例化方法不能单单从性能上去理解(两者在性能上,如加载时机,和内存占用都一样,调用速度也没有太大区别),创建c++,java,c#这样面向对象语言的大师引入实例化方法一定不是要解决什么性能、内存的问题,而是为了让开发更加模式化、面向对象化。这样说的话,静态方法和实例化方式的区分是为了解决模式的问题。静态方法和实例方法的区别主要体现在两个方面:外部调用访问特点静态方法类名.方法名/对象名.方法名只允许访问静态成员(即静态成员变量和静态方法)实例方法对象名.方法名访问无限制静态方法只能访问静态成员,实例方法可以访问静态和实例成员!之所以不允许静态方法访问实例成员变量,是因为实例成员变量是属于某个对象的,而静态方法在执行时,并不一定存在对象。同样,因为实例方法可以访问实例成员变量,如果允许静态方法调用实例方法,将间接地允许它使用实例成员变量,所以它也不能调用实例方法。基于同样的道理,静态方法中也不能使用关键字this。关于 final 关键字的⼀些总结final 关键字主要应用位置:常量、变量、方法和类(除抽象类)被final修饰的常量:在编译阶段会存入调用类的常量池中,具体参见类加载机制最后部分和Java内存区域被final修饰的变量不能被改变: 对于⼀个 final 变量,如果是基本数据类型的变量,则其数值⼀旦在初始化之后便不能更改;如果是引⽤类型的变量,则在对其初始化之后便不能再让其指向另⼀个对象。切记不可变的是变量的引用!,但内容可以进行改变。被final 修饰的类不能被继承。final 类中的所有成员⽅法都会被隐式地指定为 final ⽅法。被final修饰的方法不能被重写:目的是把⽅法锁定,以防任何继承类修改它的含义。final关键字的好处:final方法比非final快一些final关键字提高了性能。JVM和Java应用都会缓存final变量。final变量可以安全的在多线程环境下进行共享,而不需要额外的同步开销。使用final关键字,JVM会对方法、变量及类进行优化。ps:final不能修饰抽象类和接口!!!final finally finalize区别final可以修饰类、变量、方法,修饰类表示该类不能被继承、修饰方法表示该方法不能被重写、修饰变量表示该变量是一个常量不能被重新赋值(引用类型变量初始化后不能在指向另一个对象)。finally一般作用在try-catch代码块中,在处理异常的时候,通常我们将一定要执行的代码方法finally代码块中,表示不管是否出现异常,该代码块都会执行,一般用来存放一些关闭资源的代码。finalize是一个方法,属于Object类的一个方法,而Object类是所有类的父类,该方法一般由垃圾回收器来调用,当我们调用System.gc() 方法的时候,由垃圾回收器调用finalize(),回收垃圾,一个对象是否可回收的最后判断。try-catch-finally相关try块: 用于捕获异常。其后可接零个或多个 catch 块,如果没有 catch 块,则必须跟一个 finally 块。catch块: 用于处理 try 捕获到的异常。finally 块: 无论是否捕获或处理异常,finally 块里的语句都会被执行。当在 try 块或 catch 块中遇到 return 语句时,finally 语句块将在方法返回之前被执行。异常处理中return的执行顺序:(1)执行try的语句块(2)转至catch块,再执行finally块,try块的return永远不会执行.(3)若finally块中有return,则返回值;(4)若finally块中没有return,则返回catch块的return值 (此时catch块中的return值是暂存的)finally语句块什么情况不会执行finally语句块第一行出现异常程序抛出异常(或者return)之前,调用system.exits(int),退出程序程序抛出异常(或者return)之前,线程死亡程序抛出异常(或者return)之前,关闭CPU注意: 当 try 语句和 finally 语句中都有 return 语句时,在方法返回之前,finally 语句的内容将被执行,并且 finally 语句的返回值将会覆盖原始的返回值!!!public class Test { public static int f(int value) { try { return value * value; } finally { if (value == 2) { return 0; } } } }如果调用 f(2),返回值将是 0,因为 finally 语句的返回值覆盖了 try 语句块的返回值。Java异常常见问题异常是什么:java.lang.Throwable派生出Error、Exception。Exception又分为运行时异常和非运行时异常。error:程序(JVM)无法处理的严重错误 ,不能通过 catch 来进行捕获(智能尽量避免),通常会导致程序终止。例如系统崩溃、内存不足、虚拟机运行错误、类定义错误等;exception:程序可以处理的异常,需要对其进行处理 ;Exception分类:运行时异常(非受检异常):继承自 RuntimeException,即使不做处理也可以通过编译。RuntimeException及其子类都统称为非受检查异常,例如:NullPointExecrption、NumberFormatException(字符串转换为数字)、ArrayIndexOutOfBoundsException(数组越界)、ClassCastException(类型转换错误)、ArithmeticException(算术错误)等。可以由程序员自己决定(因为运行时异常中有很多是代码本身写错了,需要的不是处理异常,而是修改代码,如空指针异常、数据访问越界异常)。非运行时异常(受检异常):必须对其进行处理,需要用 try...catch... 语句捕获并进行处理(如果不进catch/throw处理的话,就没办法通过编译),并且可以从异常中恢复。除了RuntimeException及其子类以外,其他的Exception类及其子类都属于受检异常。常见的受检查异常有:IO 相关的异常、ClassNotFoundException、SQLExceptionjava常见的异常:java.lang.InstantiationError:实例化错误。当一个应用试图通过new操作符构造一个抽象类或者接口时抛出该异常.java.lang.OutOfMemoryError:内存不足错误。当可用内存不足以让Java虚拟机分配给一个对象时抛出该错误。java.lang.StackOverflowError:堆栈溢出错误。当一个应用递归调用的层次太深而导致堆栈溢出或者陷入死循环时抛出该错误。java.lang.IndexOutOfBoundsException:索引越界异常。当访问某个序列的索引值小于0或大于等于序列大小时,抛出该异常。java.lang.NullPointerException:空指针异常。java.lang.ArithmeticException:算术条件异常。譬如:整数除零等。Throwable 类常用方法:public string getMessage():返回异常发生时的简要描述;public string toString():返回异常发生时的详细信息;public string getLocalizedMessage():返回异常对象的本地化信息。使用Throwable 的子类覆盖这个方法,可以生成本地化信息。如果子类没有覆盖该方法,则该方法返回的信息与getMessage()返回的结果相同;public void printStackTrace():在控制台上打印 Throwable 对象封装的异常信息。Object 类有哪些方法?Object 类是一个特殊的类,是所有类的父类。它主要提供了以下 11 个方法://native方法,用于返回当前运行时对象的Class对象,使用了final关键字修饰,故不允许子类重写。 public final native Class<?> getClass() //native方法,用于返回对象的哈希码,主要使用在哈希表中,比如JDK中的HashMap。 public native int hashCode() public boolean equals(Object obj)//用于比较2个对象的内存地址是否相等,String类对该方法进行了重写用户比较字符串的值是否相等。 protected native Object clone() throws CloneNotSupportedException//naitive方法,用于创建并返回当前对象的一份拷贝。一般情况下,对于任何对象 x,表达式 x.clone() != x 为true,x.clone().getClass() == x.getClass() 为true。Object本身没有实现Cloneable接口,所以不重写clone方法并且进行调用的话会发生CloneNotSupportedException异常。 public String toString()//返回类的名字@实例的哈希码的16进制的字符串。建议Object所有的子类都重写这个方法。 public final native void notify()//native方法,并且不能重写。唤醒一个在此对象监视器上等待的线程(监视器相当于就是锁的概念)。如果有多个线程在等待只会任意唤醒一个。 public final native void notifyAll()//native方法,并且不能重写。跟notify一样,唯一的区别就是会唤醒在此对象监视器上等待的所有线程,而不是一个线程。 public final native void wait(long timeout) throws InterruptedException//native方法,并且不能重写。暂停线程的执行。注意:sleep方法没有释放锁,而wait方法释放了锁 。timeout是等待时间。 public final void wait(long timeout, int nanos) throws InterruptedException//多了nanos参数,这个参数表示额外时间(以毫微秒为单位,范围是 0-999999)。 所以超时的时间还需要加上nanos毫秒。 public final void wait() throws InterruptedException//跟之前的2个wait方法一样,只不过该方法一直等待,没有超时时间这个概念 protected void finalize() throws Throwable { }//实例被垃圾回收器回收的时候触发的操作equals:检测对象是否相等,默认使用 == 比较对象引用,可以重写 equals 方法自定义比较规则。equals 方法规范:自反性、对称性、传递性、一致性、对于任何非空引用 x,x.equals(null) 返回 false。hashCode:散列码是由对象导出的一个整型值,没有规律,每个对象都有默认散列码,值由对象存储地址得出。字符串散列码由内容导出,值可能相同。为了在集合中正确使用,一般需要同时重写 equals 和 hashCode,要求 equals 相同 hashCode 必须相同,hashCode 相同 equals 未必相同,因此 hashCode 是对象相等的必要不充分条件。toString:打印对象时默认的方法,如果没有重写打印的是表示对象值的一个字符串。clone:clone 方法声明为 protected,类只能通过该方法克隆它自己的对象,如果希望其他类也能调用该方法必须定义该方法为 public。如果一个对象的类没有实现 Cloneable 接口,该对象调用 clone 方抛出一个** CloneNotSupport异常。默认的 clone 方法是浅拷贝,一般重写 clone 方法需要实现 Cloneable 接口并指定访问修饰符为 public。finalize:确定一个对象死亡至少要经过两次标记,如果对象在可达性分析后发现没有与 GC Roots 连接的引用链会被第一次标记,随后进行一次筛选,条件是对象是否有必要执行 finalize 方法。假如对象没有重写该方法或方法已被虚拟机调用,都视为没有必要执行。如果有必要执行,对象会被放置在 F-Queue 队列,由一条低调度优先级的 Finalizer 线程去执行。虚拟机会触发该方法但不保证会结束,这是为了防止某个对象的 finalize 方法执行缓慢或发生死循环。只要对象在 finalize 方法中重新与引用链上的对象建立关联就会在第二次标记时被移出回收集合。由于运行代价高昂且无法保证调用顺序,在 JDK 9 被标记为过时方法,并不适合释放资源。getClass:返回包含对象信息的类对象。wait / notify / notifyAll:阻塞或唤醒持有该对象锁的线程。拓展:为什么Java把wait与notify放在Object中?功能角度1)wait与notify的原始目的,是多线程场景下,某条件触发另一逻辑,该条件对应的直接关系为某种对象,进而对应为Object,其对应为内存资源。2)Thread对应为CPU,与具体条件不是直接关系,Thread是对象的执行依附者。内存角度1)线程的同步需要Monitor的管理,其与实际操作系统的重型资源(锁)相关。2)只有涉及多线程的场景,才需要线程同步,如果wait与notify放在Thread,则每个Thread都需要分配Monitor,浪费资源。3)如果放在Object,单线程场景不分配Monitor,只在多线程分配。分配Monitor的方法为检测threadId的不同。获取键盘输入常用的两种方法ScannerScanner input = new Scanner(System. in); String S = input .nextLine(); input. close();BufferedReader (new InputStreamReader(System. in))BufferedReader input = new BufferedReader(new InputStreamReader(System. in)); String S = input.readLine(); BufferedReader input = new BufferedReader(new FileReader(file)); // 按行读取文件!Java引用和C指针的区别现象:指针在运行时可以改变其所指向的值(地址),即指向其它变量;而引用一旦和某个对象绑定后就不能再改变,总是指向最初的对象。编译:程序在编译时分别将指针和引用添加到符号表上,符号表上记录的是变量名以及变量所对应的地址。指针变量在符号表上对应的地址值为指针变量的地址值,而引用在符号表上对应的地址值为引用对象的地址值。符号表生成后就不会再改,指针可以改变,因此指针可以改变指向的对象(指针变量中的值可以改),而引用对象不能改。类型:引用其值为地址的数据元素,Java封装了的地址,可以转成字符串查看,长度可以不必关心,C指针是一个装地址的变量,长度一般是计算机字长,可以认为是个int内存占用:引用声明时没有实体,不占空间,C指针如果声明后会用到才会赋值,如果用不到不会分配内存内存溢出:java引用不用主动回收。C指针是容易产生内存溢出的,所以程序员需小心使用、及时回收。初始化:java的引用初始值为 null。c的指针是int,如不初始化指针,那它的值就不是固定的了。本质:java中的引用和C++中的指针本质上都是想通过一个叫做引用或者指针的东西,找到要操作的目标(变量对象等),方便在程序里操作。所不同的是JAVA的办法更安全,方便些,但没有了C++的灵活,高效。以上内容仅供学习使用,如有错误,感谢指正!
简述http,主要特点,1.0, 1.1, 2.0 和 3.0区别HTTP(HyperText Transfer Protocol)超文本传输协议,是TCP/IP协议集中的一个应用层协议,用于定义浏览器和Web服务器之间交换数据的过程以及数据本身的格式。HTTP工作模式HTTP1.0,1.1, 2.0 区别(发展)HTTP 1.0与1.1缓存处理:在HTTP1.0中主要使用header里的If-Modified-Since,Expires来做为缓存判断的标准,HTTP1.1则引入了更多的缓存控制策略例如Entity tag,If-Unmodified-Since, If-Match, If-None-Match等更多可供选择的缓存头来控制缓存策略。带宽优化以及网络连接的使用:HTTP1.0中,存在一些浪费带宽的现象,例如客户端只是需要某个对象的一部分,而服务器却将整个对象送过来了,并且不支持断点续传功能,HTTP1.1则在请求头引入了range头域,它允许只请求资源的某个部分,即返回码是206(Partial Content),这样就方便了开发者自由的选择以便于充分利用带宽和连接。错误通知管理:在HTTP1.1中新增了24个错误状态响应码,如409(Conflict)表示请求的资源与资源的当前状态发生冲突;410(Gone)表示服务器上的某个资源被永久性的删除。Host头处理:在HTTP1.0中认为每台服务器都绑定一个唯一的IP地址,因此,请求消息中的URL并没有传递主机名(hostname)。但随着虚拟主机技术的发展,在一台物理服务器上可以存在多个虚拟主机(Multi-homed Web Servers),并且它们共享一个IP地址。HTTP1.1的请求消息和响应消息都应支持Host头域,且请求消息中如果没有Host头域会报告一个错误(400 Bad Request)。连接方式转变:HTTP 1.1支持长(持续)连接(PersistentConnection)和请求的流水线(Pipelining)处理,在一个TCP连接上可以传送多个HTTP请求和响应,减少了建立和关闭连接的消耗和延迟,在HTTP1.1中默认开启Connection: keep-alive,一定程度上弥补了HTTP1.0每次请求都要创建连接的缺点。HTTP 2.0头部压缩,双方各自维护一个header的索引表,使得不需要直接发送值,通过发送key缩减头部大小多路复用,使用多个stream,每个stream又分帧传输,使得一个tcp连接能够处理多个http请求可以使用服务端推送HTTP/1.x 缺陷:HTTP/1.x 实现简单、以牺牲性能为代价的客户端需要使用多个连接才能实现并发和缩短延迟;不会压缩请求和响应首部,从而导致不必要的网络流量;不支持有效的资源优先级,致使底层 TCP 连接的利用率低下。二者区别:http1.1 -> http2.0HTTP2使用的是二进制传送,HTTP1.X是文本(字符串)传送。二进制传送的单位是帧和流。帧组成了流,同时流还有流ID标识。HTTP2支持多路复用。因为有流ID,所以通过同一个http请求实现多个http请求传输变成了可能,可以通过流ID来标识究竟是哪个流从而定位到是哪个http请求。HTTP2头部压缩。HTTP2通过gzip和compress压缩头部然后再发送,同时客户端和服务器端同时维护一张头信息表,所有字段都记录在这张表中,这样后面每次传输只需要传输表里面的索引ID就行,通过索引ID查询表头的值。HTTP2支持服务器推送。HTTP2支持在未经客户端许可的情况下,主动向客户端推送内容。拓展: HTTP长连接/短连接?在HTTP/1.0中默认使用短连接。也就是说,客户端和服务器每进行一次HTTP操作,就建立一次连接,任务结束就中断连接。当客户端浏览器访问的某个HTML或其他类型的Web页中包含有其他的Web资源(如JavaScript文件、图像文件、CSS文件等),每遇到这样一个Web资源,浏览器就会重新建立一个HTTP会话。从HTTP/1.1起,默认使用长连接,用以保持连接特性。使用长连接的HTTP协议,会在响应头加入这行代码:Connection:keep-alive。在使用长连接的情况下,当一个网页打开完成后,客户端和服务器之间用于传输HTTP数据的TCP连接不会关闭,客户端再次访问这个服务器时,会继续使用这一条已经建立的连接。Keep-Alive不会永久保持连接,它有一个保持时间,可以在不同的服务器软件(如Apache)中设定这个时间。实现长连接需要客户端和服务端都支持长连接。HTTP协议的长连接和短连接,实质上是TCP协议的长连接和短连接。拓展:Host 是 HTTP 1.1 协议中新增的一个请求头,主要用来实现虚拟主机技术。虚拟主机(virtual hosting)即共享主机(shared web hosting),可以利用虚拟技术(网络空间技术,空分复用)把一台完整的服务器分成若干网络空间,每一台网络空间都拥有独立域名和IP地址,具备完整的Internet服务器的功能。因此可以在单一主机上运行多个网站或服务。举个栗子,有一台 ip 地址为 61.135.169.125 的服务器,在这台服务器上部署着谷歌、百度、淘宝的网站。为什么我们访问 https://www.google.com 时,看到的是 Google 的首页而不是百度或者淘宝的首页?原因就是Host 请求头决定着访问哪个虚拟主机。补充与总结:HTTP 1.0无状态,无连接;短连接:每次发送请求都要重新建立tcp请求,即三次握手,非常浪费性能;无host头域,也就是http请求头里的host;不允许断点续传,而且不能只传输对象的一部分,要求传输整个对象。HTTP 1.1长连接,流水线,使用connection:keep-alive使用长连接;请求管道化;增加缓存处理(新的字段如cache-control);增加Host字段,支持断点传输等;由于长连接会给服务器造成压力。HTTP 2.0二进制分帧;多路复用(或连接共享),使用多个stream,每个stream又分帧传输,使得一个tcp连接能够处理多个http请求;头部压缩,双方各自维护一个header的索引表,使得不需要直接发送值,通过发送key缩减头部大小;服务器推送(Sever push)。HTTP 3.0基于google的QUIC协议,而quic协议是使用udp实现的;减少了tcp三次握手时间,以及tls握手时间;解决了http 2.0中前一个stream丢包导致后一个stream被阻塞的问题;优化了重传策略,重传包和原包的编号不同,降低后续重传计算的消耗;连接迁移,不再用tcp四元组确定一个连接,而是用一个64位随机数来确定这个连接;更合适的流量控制。HTTP和TCP是有状态还是无状态?两者之间的联系与区别?HTTP无状态协议,是指协议对于事务处理没有记忆能力。缺少状态意味着如果后续处理需要前面的信息,则它必须重传。这样缺点是导致每次连接传送的数据量增大(每次请求会传输大量重复信息)。优点是,在服务器不需要先前信息时它的应答就较快(点到为止,解放了服务器)。客户端与服务器进行动态交互的 Web 应用程序出现之后,HTTP 无状态的特性严重阻碍了这些应用程序的实现,毕竟交互是需要承前启后的,简单的购物车程序也要知道用户到底在之前选择了什么商品。于是保持HTTP连接状态的技术应运而生:通过cookie:cookie可以保持登录信息到用户下次与服务器的会话,即下次访问同一网站时,用户会发现不必输入用户名和密码就已经登录了(当然,不排除用户手工删除Cookie)。而还有一些Cookie在用户退出会话的时候就被删除了,这样可以有效保护个人隐私。Cookies 最典型的应用是判定注册用户是否已经登录网站,用户可能会得到提示,是否在下一次进入此网站时保留用户信息以便简化登录手续,这些都是 Cookies 的功用。另一个重要应用场合是“购物车”之类处理。用户可能会在一段时间内在同一家网站的不同页面中选择不同的商品,这些信息都会写入 Cookies,以便在最后付款时提取信息。通过session会话保存:与cookie相对的解决方案,session是通过服务器来保持状态的。当客户端访问服务器时,服务器根据需求设置 Session,将会话信息保存在服务器上,同时将标示 Session 的 SessionId 传递给客户端浏览器,浏览器将这个 SessionId 保存在内存中,我们称之为无过期时间的 Cookie。浏览器关闭后,这个 Cookie 就会被清掉,它不会存在于用户的 Cookie 临时文件。以后浏览器每次请求都会额外加上这个参数值,服务器会根据这个 SessionId,就能取得客户端的数据信息。如果客户端浏览器意外关闭,服务器保存的 Session 数据不是立即释放,此时数据还会存在,只要我们知道那个 SessionId,就可以继续通过请求获得此 Session 的信息,因为此时后台的 Session 还存在,当然我们可以设置一个 Session 超时时间,一旦超过规定时间没有客户端请求时,服务器就会清除对应 SessionId 的 Session 信息。TCP协议是一种有状态协议。具体见tcp/ip详解。联系与区别:Http协议是建立在TCP协议基础之上的,当浏览器需要从服务器获取网页数据的时候,会发出一次Http请求。Http会通过TCP建立起一个到服务器的连接通道,当本次请求需要的数据完毕后,Http会立即将TCP连接断开,这个过程是很短的。所以Http连接是一种短连接,是一种无状态的连接。TCP是传输层协议:负责数据的传输(定义传输数据内容的规范),包括定义端口、流量控制与数据校验等。HTTP是应用层协议:负责请求和响应数据的封装(定义的是数据传输与连接方式的规范),请求报文包括请求行(请求方法、url与http版本信息)、请求头与请求体;响应报文包括状态行(http版本、状态码与对应的状态信息)、响应头与响应体。常用的HTTP方法有哪些?一共有八种,HTTP1.0三种:GET: 用于请求访问已经被URI(统一资源标识符)识别的资源,可以通过URL传参给服务器POST:用于传输信息给服务器,主要功能与GET方法类似,但一般推荐使用POST方式。HEAD: 获得报文首部,与GET方法类似,只是不返回报文主体,一般用于验证URI是否有效。HTTP1.1增加了5种:PUT: 传输文件,报文主体中包含文件内容,保存到对应URI位置。DELETE:删除文件,与PUT方法相反,删除对应URI位置的文件。OPTIONS:查询相应URI支持的HTTP方法。TRACE 和 CONNECT方法ps: GET方法与POST方法的区别?GET和POST在本质上没有区别,都是HTTP协议中的两种发送请求的方法。而HTTP呢,是基于TCP/IP的关于数据如何在万维网中如何通信的协议。GET/POST都是TCP链接。功能上(应用场景):get重点在从服务器上获取资源,post重点在向服务器发送数据(更新服务器资源);REST服务角度上:GET是幂等的,即读取同一个资源,总是得到相同的数据,而POST不是幂等的,因为每次请求对资源的改变并不是相同的;进一步地,GET不会改变服务器上的资源,而POST会对服务器资源进行改变;http传参类型:GET 请求由 url 触发,想携带参数就只能在 url 后附加(GET只接受ASCII字符,POST没有限制)。POST 请求来自表单提交,表单数据被浏览器编码到 HTTP 请求报文的请求体中。大小限制:GET 长度受限于 url,而 url 的长度由浏览器和服务器决定。POST 没有大小限制,起限制作用的是服务器的处理能力。安全的角度:POST的安全性要比GET的安全性高,因为GET请求提交的数据将明文出现在URL上,而且POST请求参数则被包装到请求体中,相对更安全。但无论 GET 还是 POST 都不安全,因为 HTTP 是明文协议。GET请求会被浏览器主动缓存,而POST不会,除非手动设置。GET在浏览器回退时是无害的,而POST会再次提交请求。HTTP请求报文与响应报文格式请求报文包含三部分:请求行、请求头部、空行和请求数据。请求行:包含请求方法、URL、HTTP版本信息ps:空格不能缺省。请求方法(见上):当然并不是所有的服务器都实现了所有的方法,部分方法即便支持,处于安全性的考虑也是不可用的请求路径:主机域名、资源路径版本号格式:HTTP/主版本号.次版本号,如HTTP/1.0和HTTP/1.1请求头部:为请求报文添加了一些附加信息,由“名/值”对组成,每行一对,名和值之间使用冒号分隔,常见请求头如下:请求头(名)说明Host接受请求的服务器地址,可以是IP:端口号,也可以是域名User-Agent发送请求的应用程序名称Connection指定与连接相关的属性,如Connection:Keep-Alive(使用长连接)Accept-Charset通知服务端可以发送的编码格式Accept-Encoding通知服务端可以发送的数据压缩格式,如gzip,deflateAccept-Language通知服务端可以发送的语言,如zh-CN(简体中文)请求正文(可选):比如GET请求就没有请求正文请求报文总结:示例:响应报文包含三部分:响应行(状态行),响应头,和响应体状态行:包含HTTP版本、状态码、对应的状态信息,空格不能缺省。响应头部:与请求头部类似,为响应报文添加了一些附加信息响应头(名)Server服务器应用程序软件的名称和版本Content-Type响应正文的类型(是图片image还是二进制字符串)Content-Length响应正文长度Content-Charset响应正文使用的编码Content-Encoding响应正文使用的数据压缩格式Content-Language响应正文使用的语言响应正文(存放返回客户端的信息),作用方式类似请求体。响应报文总结ps: URI、URN和URL的区别URI全名为Uniform Resource Indentifier(统一资源标识),用来唯一的标识一个资源,是一个通用的概念,URI由两个主要的子集URL和URN组成URL全名为Uniform Resource Locator(统一资源定位),通过描述资源的位置来标识资源URN全名为Uniform Resource Name(统一资源命名),通过资源的名字来标识资源,与其所处的位置无关,这样即使资源的位置发生变动,其URN也不会变化HTTP规范将更通用的概念URI作为其资源标识符,但是实际上,HTTP应用程序处理的只是URI的URL子集在Java的URI中,一个URI实例可以代表绝对的,也可以是相对的,只要它符合URI的语法规则。而URL类则不仅符合语义,还包含了定位该资源的信息,因此它不能是相对的。在Java类库中,URI类不包含任何访问资源的方法,URI唯一的作用就是解析。相反的是,URL类可以打开一个到达资源的流。HTTP常见的状态码?(1)1XX 提示信息,表示目前是协议处理的中间状态,还需要后续的操作。100 Continue :表明到目前为止都很正常,客户端可以继续发送请求或者忽略这个响应。(2)#2XX 成功,报文已被收到并正确处理200 OK,响应成功201 Created 该请求已成功,并因此创建了一个新的资源。这通常是在POST请求,或是某些PUT请求之后返回的响应。204 No Content :请求已经成功处理,但是返回的响应报文不包含实体的主体部分。一般在只需要从客户端往服务器发送信息,而不需要返回数据时使用。206 Partial Content :表示客户端进行了范围请求,响应报文包含由 Content-Range 指定范围的实体内容。(3)#3XX 重定向,资源位置发生变动,需要客户端重新发送请求。301 Moved Permanently :永久性重定向302 Found :临时性重定向,服务器返回的头部信息中会包含一个 Location 字段,内容是重定向到的url。303 See Other :和 302 有着相同的功能,但是 303 明确要求客户端应该采用 GET 方法获取资源。注:虽然 HTTP 协议规定 301、302 状态下重定向时不允许把 POST 方法改成 GET 方法,但是大多数浏览器都会在 301、302 和 303 状态下的重定向把 POST 方法改成 GET 方法。304 Not Modified :如果请求报文首部包含一些条件,例如:If-Match,If-Modified-Since,If-None-Match,If-Range,If-Unmodified-Since,如果不满足条件,则服务器会返回 304 状态码。307 Temporary Redirect :临时重定向,与 302 的含义类似,但是 307 要求浏览器不会把重定向请求的 POST 方法改成 GET 方法。(4)#4XX 客户端错误,请求报文有误,服务器无法处理。400 Bad Request :请求报文中存在语法错误。401 Unauthorized :表示发送的请求需要有认证信息(BASIC 认证、DIGEST 认证)。如果之前已进行过一次请求,则表示用户认证失败。403 Forbidden :请求被拒绝。与401响应不同的是,身份验证并不能提供任何帮助,而且这个请求也不应该被重复提交404 Not Found: 请求失败,请求所希望得到的资源未被在服务器上发现(5)#5XX 服务器错误,服务器在处理请求时内部发生错误。500 Internal Server Error :服务器正在执行请求时发生错误。501 服务器不支持当前请求所需要的某个功能。当服务器无法识别请求的方法,并且无法支持其对任何资源的请求。503 Service Unavailable :服务器暂时处于超负载或正在进行停机维护,现在无法处理请求。HTTP长连接数字签名与报文鉴别数字签名:证明数据或身份的真实性,为什么进行数字签名(的功能)?报文鉴别:接受者可以确认报文发送方的身份,即证明来源。报文完整性:接受者可以确定报文没有被篡改过,防止伪造或篡改。不可否认:发送者事后不能抵赖对报文的签名,防止发送者否认签名。ps:报文鉴别:鉴别收到的报文确实是期望的发送方发送的,而不是别人伪造的。数字签名可以实现,但缺点是对较长报文进行签名时需要长时间的运算。有一种相对简单的报文鉴别方式,即密码散列函数,要找到两个不同的报文,它们具有相同的密码散列函数输出,在计算上是不可行的。使用散列函数进行报文鉴别:通信双方共享一个密钥 k ,发送方生成报文 m,用 k 级联 m 生成 m+k,并使用 SHA-1 或 MD5 这样的散列函数计算 m+k 的散列值 h,这个散列值就被称为报文鉴别码 MAC。发送方会利用 MAC 生成扩展报文并发送给接收方。接收方收到后,由于知道共享密钥 k,因此可以计算出 MAC,如果和 h 相等就可以得出一切正常的结论。数字签名实现方式 : 数字签名算法很多 , 公钥算法 是最简单的算法 , 即 发送者 使用 私钥加密数据 , 接收者 使用 对应的公钥 解密数据 ;HTTP优化方案?TCP复用:TCP连接复用是将多个客户端的HTTP请求复用到一个服务器端TCP连接上,而HTTP复用则是一个客户端的多个HTTP请求通过一个TCP连接进行处理。前者是负载均衡设备的独特功能;而后者是HTTP 1.1协议所支持的新功能,目前被大多数浏览器所支持。内容缓存:将经常用到的内容进行缓存起来,那么客户端就可以直接在内存中获取相应的数据了。压缩:将文本数据进行压缩,减少带宽SSL加速(SSL Acceleration):使用SSL协议对HTTP协议进行加密,在通道内加密并加速TCP缓冲:通过采用TCP缓冲技术,可以提高服务器端响应时间和处理效率,减少由于通信链路问题给服务器造成的连接负担。HTTPS基本知识?HTTPS是一种应用层协议,本质上来说它是HTTP协议的一种变种。HTTPS比HTTP协议安全,因为HTTP是明文传输,而HTTPS是加密传输,加密过程中使用了三种加密手段,分别是证书,对称加密和非对称加密。HTTPS相比于HTTP多了一层SSL/TSL,其构造如下:证书加密:服务器在使用证书加密之前需要去证书颁发机构申请该服务器的证书,在HTTPS的请求过程服务器端将会把本服务器的证书发送给客户端,客户端进行证书验证,以此来验证服务器的身份(验证交互双方的正确性)。对称加密:HTTPS的请求中,客户端和服务器之间的通信的数据是通过对称加密算法进行加密的。对称加密,即在加密和解密的过程使用同一个私钥进行加密以及解密,而且对称加密算法是公开的,所以该私钥是不能够泄漏的,一旦泄漏,对称加密形同虚设。例如:A第一次加密传输给B是安全的,后边A使用相同的私钥进行加密,就可能存在私钥泄露。适用于大量数据传输,速度快。非对称加密:HTTPS的请求中也使用了非对称加密算法。非对称加密,加密和解密过程使用不同的密钥,一个公钥,对外公开,一个私钥,仅是解密端拥有。由于公钥和私钥是分开的,非对称加密算法安全级别高,加密密文长度有限制,适用于对少量数据进行加密,速度较慢。HTTPS请求执行的流程HTTPS作为一种安全的应用层协议,它使用了以上三种加密手段,我们现在尝试分析其加密的思想。首先,数据正文一般数据量较大,适用于对称加密,因为对称加密速度快,适应于大量数据加密,但是安全级别低,其中对称加密的私钥需要在网络中传输,容易被盗取;其次,正因为非对称机密私钥易被盗取,所以我们需要对这个私钥进行加密,而且安全级别要求高,所以这个可以用非对称加密进行加密,原因是对称加密的私钥数据量小,非对称加密可以提供高安全级别和高响应速度。最后,由于非对称加密的公钥可以在网络中传输,如何保证公钥传送到给正确的一方,这个时候使用了证书来验证。证书不是保证公钥的安全性,而是验证正确的交互方。可以使用下图进行说明:上述过程就是两次HTTP请求,其详细过程如下:客户端想服务器发起HTTPS的请求,连接到服务器的443端口(在此之前,服务器要去证书颁发机构申请证书);服务器将非对称加密的公钥传递给客户端,以证书的形式回传到客户端;客户端接受到该公钥进行验证,就是验证2中证书,如果有问题,则HTTPS请求无法继续;如果没有问题,则上述公钥是合格的。(第一次HTTP请求)客户端随机生成client key(客户端私钥),使用前面的公钥对client key进行非对称加密;进行二次HTTP请求,将加密之后的client key传递给服务器;服务器使用私钥进行解密(注意私钥是存储在服务器),得到client key,使用client key对数据进行对称加密;将对称加密的数据传递给客户端,客户端使用对称解密得到服务器发送的数据,完成第二次HTTP请求。拓展:SSL加密过程(大致分三步)客户端向服务器端索要并验证证书合法性;双方协商生成“会话密钥”;对称密钥双方采用“会话密钥”进行加密通信;浏览器(客户端)如何验证证书的合法性?验证域名、有效期等信息是否正确:证书上都有包含这些信息,比较容易完成验证;判断证书来源是否合法:每份签发证书都可以根据验证链查找到对应的根证书,操作系统、浏览器会在本地存储权威机构的根证书,利用本地根证书可以对对应机构签发证书完成来源验证判断证书是否被篡改:需要与 CA 服务器进行校验;判断证书是否已吊销:通过CRL(Certificate Revocation List 证书注销列表)和 OCSP(Online Certificate Status Protocol 在线证书状态协议)实现,其中 OCSP 可用于第3步中以减少与 CA 服务器的交互,提高验证效率。ps: HTTP缺点,与HTTPS的区别?HTTP的缺点:被窃听:通信使用明文不加密,内容可能被窃听被伪装:不验证通信方身份,可能遭到伪装被篡改:无法验证报文完整性,可能被篡改两者之间的区别:传输信息的安全性不同:HTTP协议运行在TCP之上,所有传输的内容都是明文;HTTPS运行在SSL/TLS之上的加密传输协议,所有传输的内容都经过加密的。使用端口不同:HTTP默认使用80端口,HTTPS默认使用443端口。CA证书:HTTPS请求的过程需要CA证书要验证身份以保证客户端请求到服务器端之后,传回的响应是来自于服务器端,而HTTP则不需要CA证书;HTTPS = HTTP + 加密 + 认证 + 完整性保护Tomcat底层原理(了解)Tomcat通过监听端口,获取数据,然后解析数据,根据请求url找到对应的Servlet实现类,然后通过反射执行Servlet实现类中的方法。参考鸣谢:https://blog.csdn.net/a19881029/article/details/14002273https://blog.51cto.com/linpeisong/1746151https://www.jianshu.com/p/a6d086a3997dhttps://blog.csdn.net/seujava_er/article/details/90018326
TCP协议概述属于 传输层通信协议基于TCP的应用层协议有HTTP、SMTP、FTP、Telnet 和 POP3主要特点:面向连接、面向字节流、全双工通信、通信可靠。优缺点:优点:数据传输可靠缺点:效率慢(因需建立连接、发送确认包等)应用场景:要求通信数据可靠时,即 数据要准确无误地传递给对方。如:传输文件:HTTP、HTTPS、FTP等协议;传输邮件:POP、SMTP等协议万维网:HTTP协议文件传输:FTP协议电子邮件:SMTP协议远程终端接入:TELNET协议简单了解TCP报文格式TCP虽面向字节流,但传送的数据单元 = 报文段报文段 = 首部 + 数据 2部分TCP的全部功能体现在它首部中各字段的作用,故下面主要讲解TCP报文段的首部ps:首部的前 20 个字节固定,后面有 4n 字节根据需要增加。故 TCP首部最小长度 = 20字节(最大60个字节)。端口号:用来标识同一计算机的不同的应用进程。源端口:源端口与ip地址的作用是标识报文的返回地址目的端口:目的端口指明接收方计算机上的应用程序TCP报头中的源端口号和目的端口号同IP数据报中的源IP与目的IP唯一确定一条TCP连接。序号与确认号:是TCP可靠传输的关键部分。序号是本报文段发送的数据组的第一个字节的序号。在TCP传送的流中,每一个字节一个序号。e.g.一个报文段的序号为300,此报文段数据部分共有100字节,则下一个报文段的序号为400。所以序号确保了TCP传输的有序性。确认号,即ACK,指明下一个期待收到的字节序号,表明该序号之前的所有数据已经正确无误的收到。确认号只有当ACK标志为1时才有效。比如建立连接时,SYN报文的ACK标志位为0。数据偏移/首部长度:4bits。由于首部可能含有可选项内容,因此TCP报头的长度是不确定的,报头不包含任何任选字段则长度为20字节,4位首部长度字段所能表示的最大值为1111,转化为10进制为15,15*32/8 = 60,故报头最大长度为60字节。首部长度也叫数据偏移,是因为首部长度实际上指示了数据区在报文段中的起始偏移值。保留:为将来定义新的用途保留,现在一般置0。控制位:URG ACK PSH RST SYN FIN,共6个,每一个标志位表示一个控制功能。URG:紧急指针标志,为1时表示紧急指针有效,为0则忽略紧急指针。ACK:确认序号标志,为1时表示确认号有效,为0表示报文中不含确认信息,忽略确认号字段。PSH:push标志,为1表示是带有push标志的数据,指示接收方在接收到该报文段以后,应尽快将这个报文段交给应用程序,而不是在缓冲区排队。RST:重置连接标志,用于重置由于主机崩溃或其他原因而出现错误的连接。或者用于拒绝非法的报文段和拒绝连接请求。例如,A向B发起连接,但B之上并未监听相应的端口,这时B操作系统上的TCP处理程序会发RST包。SYN:同步序号,用于建立连接过程,在连接请求中,SYN=1和ACK=0表示该数据段没有使用捎带的确认域,而连接应答捎带一个确认,即SYN=1和ACK=1。FIN:finish标志,用于释放连接,为1时表示发送方已经没有数据发送了,即关闭本方数据流。窗口字段:滑动窗口大小,用来告知发送端接受端的缓存大小,以此控制发送端发送数据的速率,从而达到流量控制。窗口大小时一个16bit字段,因而窗口大小最大为65535。奇偶校验,此校验和是对整个的 TCP 报文段,包括 TCP 头部和 TCP 数据,以 16 位字进行计算所得。由发送端计算和存储,并由接收端进行验证。紧急指针:只有当 URG 标志置 1 时紧急指针才有效。紧急指针是一个正的偏移量,和顺序号字段中的值相加表示紧急数据最后一个字节的序号。 TCP 的紧急方式是发送端向另一端发送紧急数据的一种方式。选项和填充:最常见的可选字段是最长报文大小,又称为MSS(Maximum Segment Size),每个连接方通常都在通信的第一个报文段(为建立连接而设置SYN标志为1的那个段)中指明这个选项,它表示本端所能接受的最大报文段的长度。选项长度不一定是32位的整数倍,所以要加填充位,即在这个字段中加入额外的零,以保证TCP头是32的整数倍。数据部分: TCP 报文段中的数据部分是可选的。在一个连接建立和一个连接终止时,双方交换的报文段仅有 TCP 首部。如果一方没有数据要发送,也使用没有任何数据的首部来确认收到的数据。在处理超时的许多情况中,也会发送不带任何数据的报文段。重要字段:ACK与ack的区别客户端与服务器来回共发送三个TCP报文段来建立运输连接,三个TCP报文段分别为:(1)客户端A向服务器B发送的TCP请求报段“SYN=1,seq=x”;(2)服务器B向客户端A发送的TCP确认报文段“SYN=1,ACK=1,seq=y,ack=x+1”;(3)客户端A向服务器B发送的TCP确认报文段“ACK=1,seq=x+1,ack=y+1”。ACK:这里出现的ACK即为上面所说的TCP报文段首部中的“ACK字段”,置1时该报文段为确认报文段。ack:而ack则为TCP报文段首部中“确认号字段”的具体数值(序列号)。ack=x+1说明B希望A下次发来的报文段的第一个数据字节为序号=x+1的字节;ack=y+1说明A希望B下次发来的报文段的第一个数据字节为序号=y+1的字节。建立连接过程ps:在建立TCP连接之前,客户端和服务器都处于关闭状态(CLOSED),直到客户端主动打开连接,服务器才被动打开连接(处于监听状态 = LISTEN),等待客户端的请求。为什么一定要进行三次握手?两次握手或者四次握手不可以呢?TCP 协议是一个面向连接的、安全可靠的传输层协议,三次握手的机制是为了保证能建立一个安全可靠的连接。第一次握手:客户端向服务器发送一个连接请求的报文段,在报文里面:SYN标志位置为1,同时随机选择初始化序列号seq = x,客户端进入SYN-SENT(同步已发送)状态,即等待服务器确认。此时客户端什么都不能确认,服务器确认了对方发送正常,自己接收正常;第二次握手:当服务器收到这个报文段之后,若同意建立连接(为该TCP连接分配TCP缓存、变量),就向客户端发送一个确认报文段,在确认报文段中SYN 位和 ACK 位都置 1,确认号是 ack = x + 1,同时也为自己选择一个初始序号 seq = y, 服务器进程进入 SYN-RCVD(同步已收到)状态。此时客户端确认了自己和对方的收发正常;服务器确认了对方发送正常,自己接收正常;注意:上述两次握手不能携带数据,但要消耗一个序列号。第三次握手:当客户端收到服务器发送的确认响应报文之后(为该TCP连接分配TCP缓存、变量),还要继续再次给服务器发送连接确认报文段,确认报文段的 ACK 置 1,确认号 ack = y + 1,而自己的序号 seq = x + 1,客户端和服务器都进入ESTABLISHED(已建立连接)状态。注意:这时 ACK 报文段可以携带数据。但如果不携带数据则不消耗序号,这种情况下,下一个数据报文段的序号仍是 seq = x + 1。通过上述三次握手,双方确认自己与对方的发送与接收是正常的,就建立起一条TCP连接,即可传送应用层数据。ps:因 TCP提供的是全双工通信,故通信双方的应用进程在任何时候都能发送数据;三次握手期间,任何1次未收到对面的回复,则都会重发。为什么两次握手不行呢?结论:防止服务器接收了早已经失效的连接请求报文,服务器同意连接,从而一直等待客户端请求,最终导致形成死锁、浪费资源。ps:SYN洪泛攻击:(具体见下文)从上可看出:服务端的TCP资源(TCP缓存、变量)分配时刻 = 完成第二次握手时;而客户端的TCP资源分配时刻 = 完成第三次握手时。这就使得服务器易于受到SYN洪泛攻击,即同时多个客户端发起连接请求,从而需进行多个请求的TCP连接资源分配。为什么不需要四次握手呢?有人可能会说 A 发出第三次握手的信息后在没有接收到 B 的请求就已经进入了连接状态,那如果 A 的这个确认包丢失或者滞留了怎么办?我们需要明白一点,完全可靠的通信协议是不存在的。在经过三次握手之后,客户端和服务器已经可以确认之前的通信状况,都收到了确认信息。所以即便再增加握手次数也不能保证后面的通信完全可靠,所以是没有必要的。第2次握手传回了SYN,为什么还要传回ACK?回传了SYN(同步序号标志)只是证明服务器收到的确实是客户端发送的信号,证明从客户端到服务器的通信是正常的。**但是服务器到客户端之间的通道还需要ACK(确认序号标志)来保证信息的准确无误。SYN 同步序列编号(Synchronize Sequence Numbers) 是 TCP/IP 建立连接时使用的握手信号。在客户机和服务器之间建立正常的 TCP 网络连接时,客户机首先发出一个 SYN 消息,服务器使用 SYN-ACK 应答表示接收到了这个消息,最后客户机再以 ACK确认序号标志消息响应。这样在客户机和服务器之间才能建立起可靠的 TCP 连接,数据才可以在客户机和服务器之间传递。什么是SYN攻击(半连接攻击)?半连接攻击是一种攻击协议栈的攻击方式,坦白说就是攻击主机的一种攻击方式。通过将主机的资源消耗殆尽,从而导致应用层的程序无资源可用,导致无法运行。正常情况下,三次握手过程中,服务器发送 SYN-ACK 之后,收到客户端的 ACK 之前的 TCP 连接称为半连接(half-open connect)。此时服务器处于 SYN_RCVD 状态。当收到 ACK 后,服务器才能转入 ESTABLISHED 状态。SYN 攻击指的是,攻击客户端在短时间内伪造大量不存在的IP地址,向服务器不断地发送SYN包,服务器回复确认包,将收到SYN包放入半连接队列,并等待客端户的确认。由于源地址是不存在的,服务器需要不断的重发直至超时,这些伪造的SYN包将长时间占用未连接队列,正常的SYN请求被丢弃,导致目标系统运行缓慢,严重者会引起网络堵塞甚至系统瘫痪。如何来解决半连接攻击?可以通过拓展半连接队列的大小,来进行补救,但缺点是,不能无限制的增加,这样会耗费过多的服务端资源,导致服务端性能地下。这种方式几乎不可取。现主要通syncookies或者syn中继机制来防范半连接攻击,不为半连接分配核心内存的方式来防范。syncookies 的原理就是当服务端收到客户端 SYN 包后,不会放到半连接队列里,而是通过 {src_ip, src_port, timestamp} 等计算一个 cookie(也就是一个哈希值),通过 SYN+ACK包返回给客户端,客户端返回一个 ACK 包,携带上这个 cookie,服务端通过校验可以直接把这个连接放入全连接队列。整个过程不需要半连接队列的参与。tcp全连接攻击?全连接攻击是通过消耗服务端进程数和连接数,只连接而不进行发送数据的一种攻击方式。当客户端连接到服务端,仅仅只是连接,此时服务端会为每一个连接创建一个进程来处理客户端发送的数据。但是客户端只是连接而不发送数据,此时服务端会一直阻塞在recv或者read的状态,如此一来,多个连接,服务端的每个连接都是出于阻塞状态从而导致服务端的崩溃。如何来解决全连接攻击?可以通过不为全连接分配进程处理的方式来防范全连接攻击,具体的情况是当收到数据之后,在为其分配一个处理线程。具体的处理方式在accept返回之前是不分配处理线程的。直到接收相关的数据之后才为之提供一个处理过程。例如在apache服务中,是通过预创建一定量的子进程作为处理连接继承。所有的自己进程都继承父进程的sockfd,每当有一个连接过来时,只有当accept返回是,才会为该链接分配一个进程来处理连接请求。负责,子进程一直处于等待状态。如果出现值是连接存在,而始终不放数据,该链接的状态是SYN_RECV,在协议栈中,提供一个保活期给该链接,如果超过保活期还没有数据到来,服务端协议栈将会断开该链接。如果没有该保活期,虽然避免了ESTABLESHED状态的数量,但是SYN_RECV的数据量的增长仍旧是不可估算的,所以需要利用保活期来监控该链接是需要清除断开。释放连接的过程为什么断开一个 TCP 连接则需要“四次挥手”?第一次挥手:客户端服务器发送一个连接释放报文段,并停止再发送数据,主动关闭 TCP 连接。在这段报文段中,终止控制位FIN 置 1 ,其序号 seq = u(等于前面已传送过的数据的最后一个字节的序号加 1),这时客户端进入 FIN-WAIT-1(终止等待1)状态,等待服务器的确认。第二次挥手:服务器收到连接释放报文段后立即发出确认,确认号是 ack = u + 1,而这个报文段自己的序号是 v(等于服务器前面已经传送过的数据的最后一个字节的序号加1),然后服务器就进入 CLOSE-WAIT(关闭等待)状态,客户端进入 FIN-WAIT-2(终止等待2)状态。第三次挥手:若服务器已经没有要向客户端发送的数据,其应用进程就通知 TCP 释放连接。这时服务器发出的连接释放报文段必须使 FIN = 1。假定服务器的序号为 w(在半关闭状态,服务器可能又发送了一些数据)。服务器 还必须重复上次已发送过的确认号 ack = u + 1。这时服务器就进入 LAST-ACK(最后确认)状态,等待客户端的确认。第四次挥手:客户端在收到服务器的连接释放报文后,必须对此发出确认。在确认报文段中把 ACK 置 1,确认号 ack = w + 1,而自己的序号 seq = u + 1(前面发送的 FIN 报文段要消耗一个序号)。然后进入 客户端TIME-WAIT(时间等待) 状态。请注意,现在 TCP 连接还没有释放掉。必须经过时间等待计时器设置的时间 2MSL(MSL:最长报文段寿命)后,客户端才能进入到 CLOSED 状态,然后撤销传输控制块,结束这次 TCP 连接。当然如果服务器一收到 客户端的确认就进入 CLOSED 状态,然后撤销传输控制块。所以在释放连接时,服务器结束 TCP 连接的时间要早于客户端。TCP是全双工的连接,必须两端同时关闭连接,连接才算真正关闭。简言之,客户端发送了 FIN 连接释放报文之后,服务器收到了这个报文,就进入了 CLOSE-WAIT 状态。这个状态是为了让服务器端发送还未传送完毕的数据,传送完毕之后,服务器才会发送 FIN 连接释放报文,对方确认后就完全关闭了TCP连接。举个例子:A 和 B 打电话,通话即将结束后,A 说“我没啥要说的了”,B回答“我知道了”,但是 B 可能还会有要说的话,A 不能要求 B 跟着自己的节奏结束通话,于是 B 可能又巴拉巴拉说了一通,最后 B 说“我说完了”,A 回答“知道了”,这样通话才算结束。为什么 TIME-WAIT 状态必须等待 2MSL 的时间呢?原因1:为了保证客户端发送的最后1个连接释放确认报文 能到达服务器,从而使得服务器能正常释放连接。等待 2MSL 用来重发可能丢失的ACK报文。在Client发送出最后的ACK回复,但该ACK可能丢失。Server如果没有收到ACK,将不断重复发送FIN片段。所以Client不能立即关闭,它必须确认Server接收到了该ACK。Client会在发送出ACK之后进入到TIME_WAIT状态。Client会设置一个计时器,等待2MSL的时间。如果在该时间内再次收到FIN,那么Client会重发ACK并再次等待2MSL。所谓的2MSL是两倍的MSL(Maximum Segment Lifetime)。MSL指一个片段在网络中最大的存活时间,2MSL就是一个发送和一个回复所需的最大时间。如果直到2MSL,Client都没有再次收到FIN,那么Client推断ACK已经被成功接收,则结束TCP连接。原因2:防止已失效的连接请求报文段出现在本连接中。客户端在发送完最后一个 ACK 报文段后,再经过时间 2MSL,就可以使本连接持续的时间内所产生的所有报文段都从网络中消失。这样就可以使下一个连接中不会出现这种早已经失效的连接请求报文段。ps:设想这样一个情景:客户端已主动与服务器建立了 TCP 连接。但后来客户端的主机突然发生故障。显然,服务器以后就不能再收到客户端发来的数据。因此,应当有措施使服务器不要再白白等待下去。这就需要使用TCP的保活计时器。基本原理:服务器每收到一次客户的数据,就重新设置保活计时器,时间的设置通常是两个小时。若两个小时都没有收到客户端的数据,服务器就发送一个探测报文段,以后则每隔 75 秒钟发送一次。若连续发送 10个 探测报文段后仍然无客户端的响应,服务器就认为客户端出了故障,接着就关闭这个连接。大量 TIME-WAIT 的原因、导致的问题、处理?原因:在高并发短连接的 TCP 服务器上,服务器处理完请求后立刻主动关闭连接,该场景下大量 socket 处于 TIME-WAIT 状态。导致的问题:TIME-WAIT 状态无法真正释放句柄资源,socket 使用的本地端口在默认情况下不能再被使用,会限制有效连接数量,成为性能瓶颈。解决方案:调小 tcp_fin_timeout 的值、将 tcp_tw_reuse 设为 1 开启重用,将 tcp_tw_recycle 设为 1 开启快速回收。tcp协议的状态及对应的意义?tcp11种状态及变迁其实基本包含在正常的三次握手和四次挥手中,除开CLOSING。正常的三次握手包括4中状态变迁:服务器打开监听(LISTEN)->客户端先发起SYN主动连接标识->服务器回复SYN及ACK确认->客户端再确认即三次握手TCP连接成功。这里边涉及四种状态及变迁:LISTEN状态(监听):表示服务器的某个端口正处于监听状态,正在等待客户端连接的到来。SYN_SENT状态(同步已发送):当客户端发送SYN请求建立连接之后,客户端处于SYN_SENT状态,等待服务器发送SYN+ACKSYN_RECV状态(同步已接收):当服务器收到客户端的连接请求SYN之后,服务器处于SYN_RCVD,在接收到SYN请求之后会向客户端回复一个SYN+ACK的确认报文ESTABLISED状态(连接已建立):当客户端回复服务器一个ACK和服务器收到该ACK(TCP最后一次握手)之后,服务器和客户端都处于该状态,表示TCP连接已经成功建立正常的四次握手包含6种tcp状态变迁,如主动发起关闭方为客户端:客户端发送FIN进入FIN_WAIT1 -> 服务器发送ACK确认并进入CLOSE_WAIT(被动关闭)状态->客户端收到ACK确认后进入FIN_WAIT2状态 -> 服务器再发送FIN进入LAST_ACK状态 -> 客户端收到服务器的FIN后发送ACK确认进入TIME_WAIT状态 -> 服务器收到ACK确认后进入CLOSED状态断开连接 -> 客户端在等待2MSL的时间如果期间没有收到服务器的相关包,则进入CLOSED状态断开连接。FIN_WAIT1状态(终止等待1):当数据传输期间当客户端想断开连接,向服务器发送了一个FIN之后,客户端处于该状态FIN_WAIT2状态(终止等待2):当客户端收到服务器发送的连接断开确认ACK之后,客户端处于该状态CLOSE_WAIT状态(关闭等待):当服务器发送连接断开确认ACK之后,但是还没有发送自己的FIN之前的这段时间,服务器处于该状态TIME_WAIT状态(时间等待):当客户端收到了服务器发送的FIN并且发送了自己的ACK之后,客户端处于该状态LAST_ACK状态(最后确认):表示被动关闭的一方(比如服务器)在发送FIN之后,等待对方的ACK报文时,就处于该状态CLOSED状态(关闭):结束状态(或初始状态),服务器和客户端都处于该状态,表示TCP连接是“关闭的”或者“未打开的”CLOSING状态:连接断开期间,一般是客户端发送一个FIN,然后服务器回复一个ACK,然后服务器发送完数据后再回复一个FIN,当客户端和服务器同时接受到FIN时,客户端和服务器处于CLOSING状态,也就是此时双方都正在关闭同一个连接。在进入CLOSING状态后,只要收到了对方对自己发送的FIN的ACK,收到FIN的ACK确认就进入TIME_WAIT状态,因此,如果RTT(Round Trip Time TCP包的往返延时)处在一个可接受的范围内,发出的FIN会很快被ACK从而进入到TIME_WAIT状态,CLOSING状态持续的时间就特别短,因此很难看到这种状态。TCP/UDP都是传输层常见的协议,为进程提供通用数据的传输服务。两者的区别?我们知道网络层,可以实现两个主机之间的通信。但是这并不具体,因为,真正进行通信的实体是在主机中的进程,是一个主机中的一个进程与另外一个主机中的一个进程在交换数据。IP协议虽然能把数据报文送到目的主机,但是并没有交付给主机的具体应用进程。而端到端的通信才应该是应用进程之间的通信。是否面向连接:TCP 提供面向连接的服务(通信前先通过三次握手建立连接)。在传送数据之前必须先建立连接,数据传送结束后要释放连接;UDP 在传送数据之前不需要先建立连接,远地主机在收到 UDP 报文后,不需要给出任何确认。ps:关于连接,并不是连在一起,实际上连接是客户端和服务器都维护了一个变量(维护现在数据传输的状态,传了哪些数据,下一次要传哪些数据等等)。UDP通讯有四个参数:源IP、源端口、目的IP和目的端口,tcp在此基础上特有的参数是序列号和应答号,在三次握手的过程中确定相互连接的特性,保证数据传输的安全性。传输是否可靠:TCP 保证数据的可靠传输(三次握手建立连接,有确认、窗口、重传、拥塞控制机制,传输完成断开连接),UDP 使用尽最大努力交付,即不保证可靠交付,因此主机不需要维持复杂的连接状态。传输形式:TCP 是面向字节流的,因此它能将信息分割成组,并在接收端将其重组;UDP 是面向数据报文段的传输,没有分组的开销。连接对象的个数:TCP 是点到点之间的一对一通信,UDP 支持一对一、一对多和多对多的交互通信。传输效率: TCP 有拥塞控制,UDP 没有拥塞控制,因此网络中出现的拥塞不会降低源主机的发送速率。首部字节:UDP 的首部开销很小,只有 8 字节,相比 TCP 的 20 字节(最小20,最大需要60)要短。应用场景:UDP协议比TCP协议的效率更高,TCP协议比UDP协议更加安全可靠。TCP:当对网络通讯质量有要求的时候,比如:整个数据要准确无误的传递给对方,这往往用于一些要求可靠的应用,比如HTTP、HTTPS、FTP等传输文件的协议,POP、SMTP等邮件传输的协议。建立一个 TCP 连接是需要客户端与服务器端达成三个信息的共识。1) Socket:由 IP 地址和端口号组成 2) 序列号:用来解决乱序问题等 3) 窗口大小:用来做流量控制UDP:效率要求相对较高,对通讯质量要求不严的场景:QQ视频、语音、直播等即时通信场景。TCP协议如何保证传输可靠?下面主要对数据传输出现错误/无应答/堵塞/超时/重复等问题。校验和: TCP 将保持它首部和数据的检验和。这是一个端到端的检验和,目的是检测数据在传输过程中的任何变化。如果收到段的检验和有差错,TCP 将丢弃这个报文段和不确认收到此报文段。确认应答与序列号:TCP传输的过程中,每次接收方收到数据后,都会对传输方进行确认应答,也就是发送ACK报文。这个ACK报文当中带有对应的确认序列号(TCP传输时将每个字节的数据都进行了编号,这就是序列号),告诉发送方,接收到了哪些数据,下一次的数据从哪里发。流量控制: TCP 连接的每一方都有固定大小的缓冲空间,TCP的接收端只允许发送端发送接收端缓冲区能接纳的数据。当接收方来不及处理发送方的数据,能提示发送方降低发送的速率,防止包丢失。TCP 使用的流量控制协议是可变大小的滑动窗口协议。 (TCP 利用滑动窗口实现流量控制)拥塞控制: 当网络拥塞(对网络中某一资源的需求已经超过该资源所能提供的有效部分),网络性能下降,此时应减少数据的发送。常用的拥塞的手段:慢开始、拥塞避免、快重传与快恢复。ARQ协议(停止等待协议): 也是为了实现可靠传输的,它的基本原理就是每发完一个分组就停止发送,等待对方确认。在收到确认后再发下一个分组。超时重传: TCP协议保证数据可靠性的另一个重要机制,其原理是在发送某一个数据以后就开启一个计时器,在一定时间内如果没有得到发送的数据报的ACK报文,那么就重新发送数据,直到发送成功为止。重复数据丢弃:TCP 的接收端会丢弃重复的数据。注意:TCP丢包:TCP是基于不可靠的网路实现可靠传输,肯定会存在丢包问题。如果在通信过程中,发现缺少数据或者丢包,那边么最大的可能性是程序发送过程或者接受过程中出现问题。总结:为了满足TCP协议不丢包,即保证可靠传输,规定如下:数据分片(TCP控制分片大小和重组)达到确认(根据分片数据序号进行确认)超时重传(发从分片时设置定时器,超时未确认,重传)滑动窗口(TCP在滑动窗口的基础上提供流量控制,防止由于传输速率不一致,较快主机致使较慢主机的缓冲区溢出)失序处理(TCP对可能失序的数据报进行重排序,然后交给应用层)重复处理(TCP丢弃重复数据)数据校验(TCP保持它首部和数据的校验和,这个端到端的校验和目的检测传输过程中的变化,如果检验和有差错,丢弃这个分片,也不确认,所以客户端超时重传)注意:TCP丢包有三方面的原因,一是网络的传输质量不好,二是安全策略,三是服务器性能瓶颈如果网络延迟抖动大,其实不一定会丢包,一般网络拥塞时才会丢包只要网络是通的,TCP有重传机制,肯定会将没传送成功的数据重新发送什么是滑动窗口机制,为什么会有滑动窗口呢?先理解2个基础概念:发送窗口、接收窗口滑动窗口是类似于一个窗口(窗口是指一次批量发送多少数据,是缓存的一部分,用来存放字节流),是用来告诉发送端可以发送数据的大小或者说是窗口标记了接收端缓冲区的大小,这样就可以实现流量控制。工作原理:对于发送端:每收到一个确认帧,发送窗口就向前滑动一个帧的距离;当发送窗口内无可发送的帧时(即窗口内的帧全部是已发送但未收到确认的帧),发送方就会停止发送,直到收到接收方发送的确认帧使窗口移动,窗口内有可以发送的帧,之后才开始继续发送对于接收端:当收到数据帧后,将窗口向前移动一个位置,并发回确认帧,若收到的数据帧落在接收窗口之外,则一律丢弃。注意点:只有接收窗口向前滑动、接收方发送了确认帧时,发送窗口才有可能(只有发送方收到确认帧才是一定)向前滑动停止-等待协议、后退N帧协议 & 选择重传协议只是在发送窗口大小和接收窗口大小上有所差别当接收窗口的大小为1时,可保证帧有序接收。数据链路层的滑动窗口协议中,窗口的大小在传输过程中是固定的(注意要与TCP的滑动窗口协议区别)关于滑动窗口的知识点:接收端将自己可以接收的缓冲区大小放入TCP首部中的“窗口大小”字段,通过ACK来通知发送端;窗口大小指的是无需等待确认应答而可以继续发送数据的最大值,即就是说不需要接收端的应答,可以一次连续的发送数据;窗口大小字段越大,说明网络的吞吐率越高;操作系统内核为了维护滑动窗口,需要开辟发送缓冲区,来记录当前还有哪些数据没有应答,只有确认应答过的数据,才能从缓冲区删掉,注意:发送缓冲区太大,就会有空间开销;接收端一旦发现自己的缓冲区快满了,就会将窗口大小设置成一个更小的值通知给发送端,发送端收到这个值后,就会减慢自己的发送速度;如果接收端发现自己的缓冲区满了,就会将窗口的大小设置为0,此时发送端将不再发送数据,但是需要定期个发送一窗口探测数据,段使接收端把窗口大小告诉发送端。注意,在TCP的首部中,有一个16位窗口字段,此字段就是用来存放窗口大小信息的。滑动窗口中的数据类型:已经发送但是还没确认可以发送,但是还没有发送谈谈对 ARQ(Auto Repeat reQuest)协议理解(针对出错重传)?ARQ解决的问题:出现差错时,让发送方重传差错数据:即 出错重传类型:停止等待协议:每发送完一个分组就停止发送,等待对方确认,在收到确认后再发送下一个分组。连续 ARQ 协议:可提高信道利用率。发送方维持一个发送窗口,凡位于发送窗口内的分组可以连续发送出去,而不需要等待对方确认。接收方一般采用累计确认,对按序到达的最后一个分组发送确认,表明到这个分组为止的所有分组都已经正确收到了。ps:若信道传输质量很差,导致误码率较大时,后退N帧协议不一定优于停止-等待协议自动重传请求 ARQ 协议:停止等待协议中超时重传是指只要超过一段时间仍然没有收到确认,就重传前面发送过的分组(认为刚才发送过的分组丢失了)。因此每发送完一个分组需要设置一个超时计时器,其重传时间应比数据在分组传输的平均往返时间更长一些。这种自动重传方式常称为自动重传请求 ARQ。TCP流量控制和拥塞控制?流量控制和拥塞控制解决的问题:当接收方来不及接收收到的数据时,可通知发送方降低发送数据的效率:即 速度匹配流量控制:问题:对于应用程序读取的速度较慢,而发送方发送得太快,就会使接收缓存溢出。所谓的流量控制, 就是告诫对方发送速率不要太快, 要让接收方来得及接收数据。流量控制机制是丢包。解决方案:TCP 通过接收窗口(接收端缓冲区的大小)实现流量控制,接收窗口告诉发送方自己可用的缓存空间,发送方的发送窗口不能超过接收方的接收窗口!注意:TCP窗口的单位是字节, 而不是报文段。注意:拥塞控制:问题:网络中的链路容量、交换结点中的缓存与处理机都有着工作的极限,当网络的需求超过他们的工作极限时,就出现了拥塞。拥塞控制就是为了防止过多的数据注入到网络中,这样可以使网络中的路由器或链路不致过载。解决方案:为了进行拥塞控制,TCP 发送方要维持一个 拥塞窗口(cwnd) 的状态变量。拥塞控制窗口的大小取决于网络的拥塞程度,并且动态变化。发送方让自己的发送窗口取为拥塞窗口和接收方的接受窗口中较小的一个。TCP的拥塞控制采用了四种算法,即慢开始、拥塞避免、快重传和快恢复。tcp拥塞控制慢开始与拥塞避免:初始阶段:设置拥塞窗口cwnd = 1,即发送方只能发送一个报文段。慢开始阶段:拥塞窗口cwnd以指数增长当执行慢开始算法时,cwnd初始值为1,发送第1个报文段M0;发送端每收到1个确认,就把cwnd+1,于是发送端可以发送2个报文段M1和M2;接收端发回两个确认,发送端每收到1个确认,就把cwnd+1,于是发送端可以发送4个报文段... ...拥塞避免阶段:拥塞窗口按照线性规律增长当拥塞窗口cwnd增长到慢开始门限slow start thresh(ssthresh),执行拥塞避免算法当拥塞窗口增长到给定值,网络出现超时,即出现网络拥塞,则ssthresh = cwnd / 2更新慢开始门限ssthresh 后,拥塞窗口重新设置为1,重新执行慢开始算法快重传和快恢复:在接收方,要求每次接收到报文段都应该对最后一个已收到的有序报文段进行确认。例如已经接收到 M1 和 M2,此时收到 M4,应当发送对 M2 的确认。快重传:在发送方,如果收到三个重复确认,那么可以知道下一个报文段丢失,此时执行快重传,立即重传下一个报文段。例如收到三个 M2,则 M3 丢失,立即重传 M3。快恢复:在这种情况下,只是丢失个别报文段,而不是网络拥塞。因此执行快恢复,令 ssthresh = cwnd / 2 ,cwnd = ssthresh,注意到此时直接进入拥塞避免。特别注意:慢开始和快恢复的快慢指的是 cwnd 的设定值,而不是 cwnd 的增长速率。慢开始 cwnd 设定为 1,而快恢复 cwnd 设定为 ssthresh。补充:流量控制和拥塞控制的区别TCP粘包和拆包TCP属于传输层的协议,传输层除了有TCP协议外还有UDP协议。那么UDP是否会发生粘包或拆包的现象呢?答案是不会。UDP是基于报文发送的,从UDP的帧结构可以看出,在UDP首部采用了16bit来指示UDP数据报文的长度,因此在应用层能很好的将不同的数据报文区分开,从而避免粘包和拆包的问题。而TCP是基于字节流的,虽然应用层和TCP传输层之间的数据交互是大小不等的数据块,但是(1)TCP把这些数据块仅仅看成一连串无结构的字节流,没有边界;(2)另外从TCP的帧结构也可以看出,在TCP的首部没有表示数据长度的字段,基于上面两点,在使用TCP传输数据时,才有粘包或者拆包现象发生的可能。什么情况造成TCP粘包和拆包?解决TCP粘包和拆包的方法:发送端给每个数据包添加包首部,首部中应该至少包含数据包的长度,这样接收端在接收到数据后,通过读取包首部的长度字段,便知道每一个数据包的实际长度了。发送端将每个数据包封装为固定长度(不够的可以通过补0填充),这样接收端每次从接收缓冲区中读取固定长度的数据就自然而然的把每个数据包拆分开来。可以在数据包之间设置边界,如添加特殊符号,这样,接收端通过这个边界就可以将不同的数据包拆分开。自定义传输协议UDP怎么设计可靠传输?传输层无法保证数据的可靠传输,只能通过应用层来实现了。实现的方式可以参照tcp可靠性传输的方式,只是实现不在传输层,实现转移到了应用层。最简单的方式是在应用层模仿传输层TCP的可靠性传输。下面不考虑拥塞处理,可靠UDP的简单设计。添加seq/ack机制,确保数据发送到对端添加发送和接收缓冲区,主要是用户超时重传。添加超时重传机制。参考https://www.jianshu.com/p/65605622234bhttp://www.open-open.com/lib/view/open1517213611158.htmlhttps://blog.csdn.net/dangzhangjing97/article/details/81008836https://blog.csdn.net/qq_30108237/article/details/107057946https://www.jianshu.com/p/6c73a4585eba
MySQL属于关系型数据库。顾名思义,关系型数据库就是一种建立在关系模型的基础上的数据库。关系模型表明了数据库中所存储的数据之间的联系(一对一、一对多、多对多)。关系型数据库中,我们的数据都被存放在了各种表中(比如用户表),表中的每一列就存放着一条数据(比如一个用户的信息)。大部分关系型数据库都使用 SQL 来操作数据库中的数据。并且,大部分关系型数据库都支持事务的四大特性(ACID)。常见的关系型数据库还有MySQL、PostgreSQL、Oracle、SQL Server、SQLite(微信本地的聊天记录的存储就是用的 SQLite) ......。和其它数据库相比,MySQL有点与众不同,它的架构可以在多种不同场景中应用并发挥良好作用。主要体现在存储引擎的架构上,插件式的存储引擎架构将查询处理和其它的系统任务以及数据的存储提取相分离。这种架构可以根据业务的需求和实际需要选择合适的存储引擎。mysql架构连接层:最上层是一些客户端和连接服务。主要完成一些类似于连接处理、授权认证、及相关的安全方案。在该层上引入了线程池的概念,为通过认证安全接入的客户端提供线程。同样在该层上可以实现基于SSL的安全链接。服务器也会为安全接入的每个客户端验证操作权限。服务层:第二层服务层,主要完成大部分的核心服务功能, 包括查询解析、分析、优化、缓存、以及所有的内置函数,所有跨存储引擎的功能也都在这一层实现,包括触发器、存储过程、视图等。引擎层:第三层存储引擎层,存储引擎真正的负责了MySQL中数据的存储和提取,服务器通过API与存储引擎进行通信。不同的存储引擎具有的功能不同,这样我们可以根据自己的实际需要进行选取。存储层:第四层为数据存储层,主要是将数据存储在运行于该设备的文件系统之上,并完成与存储引擎的交互。面试题:MySQL 的查询流程具体是?or 一条SQL语句在MySQL中如何执行的?大体来说,MySQL 可以分为 Server 层和存储引擎层两部分。Server 层包括连接器、查询缓存、分析器、优化器、执行器等,涵盖 MySQL 的大多数核心服务功能,以及所有的内置函数(如日期、时间、数学和加密函数等),所有跨存储引擎的功能都在这一层实现,比如存储过程、触发器、视图等。而存储引擎层负责数据的存储和提取。其架构模式是插件式的,支持 InnoDB、MyISAM、Memory 等多个存储引擎。现在最常用的存储引擎是 InnoDB,它从 MySQL 5.5.5 版本开始成为了默认存储引擎。可以通过指定存储引擎的类型来选择别的引擎,比如在 create table 语句中使用 engine=memory,来指定使用内存引擎创建表。具体流程:客户端发出请求连接器负责连接客户端(验证用户身份,给予权限)mysql -h$ip -P$port -u$user -p #输入用户名和密码进行验证 #连接成功后,可以用过show processlist查看连接,如果没有操作wait_timeout控制,默认8个小时关闭。连接建立完成后,你就可以执行 select 语句了。查询缓存(存在缓存则直接返回结果,不存在则执行后续操作)查询缓存的失效非常频繁,只要有对一个表的更新,这个表上所有的查询缓存(之前执行过的语句及其结果可能会以 key-value 对的形式,被直接缓存在内存中。key 是查询的语句,value 是查询的结果。)都会被清空。所以查询缓存的命中率会非常低(不建议)。分析器(对SQL语句进行语法分析和词法分析操作)如果没有命中查询缓存,就要开始真正执行语句了。首先,MySQL 需要知道你要做什么,因此需要对 SQL 语句做解析。词法分析:你输入的是由多个字符串和空格组成的一条 SQL 语句,MySQL 需要识别出里面的字符串分别是什么,代表什么。检查查询的表或者列是否存在。语法分析:语法分析器会根据语法规则,判断你输入的这个 SQL 语句是否满足 MySQL 语法。比如 select 少打了开头的字母“s”。优化器(主要对执行的SQL优化,执行最优的方案)经过了分析器,MySQL 就知道你要做什么了。在开始执行之前,还要先经过优化器的处理。优化器的工作:优化器是在表里面有多个索引的时候,决定使用哪个索引;或者在一个语句有多表关联(join)的时候,决定各个表的连接顺序。执行器(先检查用户是否有执行权限,有才会使用这个引擎提供的接口)MySQL 通过分析器知道了你要做什么,通过优化器知道了该怎么做,于是就进入了执行器阶段,开始执行语句。开始执行的时候,要先判断一下你对这个表 T 有没有执行查询的权限,如果没有,就会返回没有权限的错误,如果有权限,就打开表继续执行。打开表的时候,执行器就会根据表的引擎定义,去使用这个引擎提供的接口。去引擎层获取数据的返回(如果开启查询缓存,则会缓存查询结果)sql执行流程重要的日志模块:redo log 和 binlog前面我们说过,在一个表上有更新的时候,跟这个表有关的查询缓存会失效,所以这条语句就会把表 T 上所有缓存结果都清空。这也就是我们一般不建议使用查询缓存的原因。接下来,分析器会通过词法和语法解析知道这是一条更新语句。优化器决定要使用 ID 这个索引。然后,执行器负责具体执行,找到这一行,然后更新。与查询流程不一样的是,更新流程还涉及两个重要的日志模块,它们正是我们今天要讨论的主角:redo log(重做日志)和 binlog(归档日志)。如果每一次的更新操作都需要写进磁盘,然后磁盘也要找到对应的那条记录,然后再更新,整个过程 IO 成本、查找成本都很高。为了解决这个问题,MySQL 的设计者就用了类似酒店掌柜粉板的思路来提升更新效率(先记录,等不忙时处理)。redo log固定大小,从头开始写,写到末尾就又回到开头循环写。其中write pos (当前记录的位置)和 checkpoint(当前要擦除的位置) 之间的是“粉板”上还空着的部分,可以用来记录新的操作。如果 write pos 追上 checkpoint,表示“粉板”满了,这时候不能再执行新的更新,得停下来先擦掉一些记录,把 checkpoint 推进一下。ps:有了 redo log,InnoDB 就可以保证即使数据库发生异常重启,之前提交的记录都不会丢失,这个能力称为 crash-safe。要理解 crash-safe 这个概念,可以想想我们前面赊账记录的例子。只要赊账记录记在了粉板上或写在了账本上,之后即使掌柜忘记了,比如突然停业几天,恢复生意后依然可以通过账本和粉板上的数据明确赊账账目。MySQL 整体来看,其实就有两块:一块是 Server 层,它主要做的是 MySQL 功能层面的事情;还有一块是引擎层,负责存储相关的具体事宜。 redo log(重做日志) 是 InnoDB 引擎特有的日志,而 Server 层也有自己的日志,称为 binlog(归档日志)。ps:因为最开始 MySQL 里并没有 InnoDB 引擎。MySQL 自带的引擎是 MyISAM,但是 MyISAM 没有 crash-safe 的能力,binlog 日志只能用于归档。而 InnoDB 是另一个公司以插件形式引入 MySQL 的,既然只依靠 binlog 是没有 crash-safe 能力的,所以 InnoDB 使用另外一套日志系统——也就是 redo log 来实现 crash-safe 能力。两种日志模块的区别:redo log 是 InnoDB 引擎特有的;binlog 是 MySQL 的 Server 层实现的,所有引擎都可以使用。redo log 是物理日志,记录的是“在某个数据页上做了什么修改”;binlog 是逻辑日志,记录的是这个语句的原始逻辑,比如“给 ID=2 这一行的 c 字段加 1 ”。redo log 是循环写的,空间固定会用完;binlog 是可以追加写入的。“追加写”是指 binlog 文件写到一定大小后会切换到下一个,并不会覆盖以前的日志。将 redo log 的写入拆成了两个步骤:prepare 和 commit,这就是"两阶段提交"。如果不使用“两阶段提交”,那么数据库的状态就有可能和用它的日志恢复出来的库的状态不一致。redo log 和 binlog 都可以用于表示事务的提交状态,而两阶段提交就是让这两个状态保持逻辑上的一致(两阶段提交是跨系统维持数据逻辑一致性时常用的一个方案)。一个 SQL 执行的很慢,我们要分两种情况讨论:偶尔很慢,则有如下原因:(1)数据库在刷新脏页,例如 redo log 写满了需要同步到磁盘。(2)执行的时候,遇到锁,如表锁、行锁。这条 SQL 语句一直执行的很慢,则有如下原因:(1)没有用上索引:例如该字段没有索引;由于对字段进行运算、函数操作导致无法用索引。(2)数据库选错了索引。(3)考虑表中的数据量是否太大,如果是的话可以进行横向或者纵向的分表。大表数据查询,怎么优化?(1)优化shema、sql语句+索引;(2)第二加缓存,memcached, redis;(3)主从复制,读写分离;(4)垂直拆分,根据你模块的耦合度,将一个大的系统分为多个小的系统,也就是分布式系统;(5)水平切分,针对数据量大的表,这一步最麻烦,最能考验技术水平,要选择一个合理的sharding key, 为了有好的查询效率,表结构也要改动,做一定的冗余,应用也要改,sql中尽量带sharding key, 将数据定位到限定的表上去查,而不是扫描全部的表;mysql有什么特点(优点)(1)MySQL数据库是用C和C++语言编写的,并且使用了多种编辑器进行测试,以保证源码的可移植性(2)支持多个操作系统,例如:Windows、Linux、Mac OS等等(3)支持多线程,可以充分的利用CPU资源(4)为多种编程语言提供API,包括C语言,Java,PHP。Python语言等(5)MySQL优化了SQL算法,有效的提高了查询速度(6)MySQL内提供了用于管理,检查以及优化数据库操作的管理工具(7)它能够作为一个单独的应用程序应用在客户端服务器网络环境中,也可以作为一个库嵌入到其他的软件中并提供多种语言支持(8)支持多种存储引擎。Oracle数据库和MySQL数据库的不同之处1、体积不同。Oracle它体积比较庞大,一般是用来开发大型应用(例如分布式)的。而MySQL的体积相对来说比较小,较之Oracle更容易安装、维护以及管理,操作也简单2、容量不同。Oracle容量无限,根据配置决定。3、平台支持及速度的区别。Oracle支持大多数平台;而MySQL支持各种平台,适合Linux4、数据库崩溃造成的影响不同。Oracle数据库崩溃后恢复很麻烦,因为他把很多东西放在内存里5、性能的区别。Oracle全面,完整,稳定,但一般数据量大,对硬件要求较高 ;而MySQL使用CPU和内存极少,性能很高,但扩展性较差。分库分表如何选择?为什么要分库分表(设计高并发系统的时候,数据库层面应该如何设计)?首先要清楚,分库和分表是两回事,是两个独立的概念。分库和分表都是为了防止数据库服务因为同一时间的访问量(增删查改)过大导致宕机而设计的一种应对策略。为什么要分库按一般的经验来说,一个单库最多支持并发量到2000,且最好保持在1000。如果有20000并发量的需求,这时就需要扩容了,可以将一个库的数据拆分到多个库中,访问的时候根据一定条件访问单库,缓解单库的性能压力。为什么要分表分表也是一样的,如果单表的数据量太大,就会影响SQL语句的执行性能。分表就是按照一定的策略将单表的数据拆分到多个表中,查询的时候也按照一定的策略去查询对应的表,这样就将一次查询的数据范围缩小了。比如按照用户id来分表,将一个用户的数据就放在一个表中,crud先通过用户id找到那个表在进行操作就可以了。这样就把每个表的数据量控制在一定范围内,提升SQL语句的执行性能。用过哪些分库分表的中间件?不同的分库分表中间件都有什么优点和缺点?分库分表常见的中间件有:cobar、TDDL、atlas、sharding-jdbc和mycat等。cobar:cobar是阿里的b2b团队开发和开源的,属于proxy层方案,介于应用服务器和数据库服务器之间。应用程序通过JDBC驱动访问cobar集群,cobar根据SQL和分库规则对SQL做分解,然后分发到MySQL集群不同的数据库实例上执行。cobar并不支持读写分离、存储过程、跨库join和分页等操作。早些年还可以用,但是最近几年都没更新了,基本没啥人用,算是淘汰了。TDDL:TDDL是淘宝团队开发的,属于client层方案。支持基本的crud语法和读写分离,但是并不支持join、多表查询等语法。目前使用的也不多,因为使用还需要依赖淘宝的diamond配置管理系统。atlas:atlas是360开源的,属于proxy层方案。以前是有一些公司再用的,但是社区最新的维护都在5年前了,现在用的公司也基本没有了。sharding-jdbc:sharding-jdbc是当当开源的,属于client层方案。这个中间件对SQL语法的支持比较多,没有太多限制。2.0版本也开始支持分库分表、读写分离、分布式id生成、柔性事务(最大努力送达型事务、TCC事务)。目前社区也还一直在开发和维护,算是比较活跃,是一个现在也可以选择的方案。mycat:mycat是基于cobar改造的,属于proxy层方案。其支持的功能十分完善,是目前非常火的一个数据库中间件。社区很活跃,不断在更新。相比于sharding-jdbc来说,年轻一些,经历的锤炼也少一些。总结综上所述,现在建议考量使用的就是sharding-jdbc和mycat。sharding-jdbc这种client层的优点在于不用部署,因此运维成本也就比较低。同时因为不需要代理层的二次转发请求,性能很高。但是如果遇到升级的话,需要各个系统都重新升级版本再发布,因为各个系统都需要耦合sharding-jdbc的依赖。mycat这种proxy方案的缺点在于需要部署,因此运维成本也就比较高。但是优点在于其对于各个项目是透明(解耦)的,如果要升级的话只需要在中间件处理就行了。通常来说,这两个方案都是可以选用的。但是建议中小型公司选用sharding-jdbc比较好,因为client层方案轻便,维护成本低;建议中大型公司选用mycat比较好,因为proxy层方案可以应对多个系统和项目大量使用,虽然维护成本相对来说会较高,但是中大型公司还缺这点人力吗。水平拆分的概念水平拆分的意思,就是把一个表的数据拆分到多个库的多个表里面去。这里面的每个库的表结构都是一样的,只不过是表中存放的数据不一样,每个库表的数据汇总起来就是全部数据。水平拆分的意义在于将数据均匀地存放在各个库表里,依靠多个库来杠更高的并发,而且还能借助多个库的存储容量来进行扩容。垂直拆分的概念垂直拆分的意思,就是把一个有很多字段的表给拆分成多个表或者多个库上面去,每个库表的结构都不一样,每个库表都包含部分字段。一般来说,会将较少的访问频率很高的字段放到一个表里面去,然后将较多的访问频率很低的字段放到另外一个表里面去。因为数据库是有缓存的,你访问频率高的行字段越少,就可以在缓存里面缓存更多的行,性能也就越好。这个一般在表层面做的较多一些。水平拆分和垂直拆分的场景所谓表层面的拆分,就是分表。具体就是将一个表拆分为N个表,让每个表的数据量控制在一定的范围内,保证SQL的性能。否则,单表的数据量越大,SQL的性能也就越差,一般是200万行左右,不要太多。如果你的SQL越复杂,就尽量让单表的行数越少。无论是分库还是分表,主流的数据库中间件都是可以支持的。这些中间件可以在你分库分表之后,根据指定的某个字段值自动路由到对应的库和对应的表上面。这时就只要考虑项目如何分库分表就行了。一般来说,垂直拆分,可以在表层面做,即对一些字段特别多的表做一下拆分;水平拆分的话,可能是因为并发承载不了或容量承载不了,也就可以按某个字段去分布到不同的库表里面去。分库分表的两个方案,这里说一下两种分库分表的方案和它们的优缺点。1.按照range来分。比如说按照时间范围来分库分表,每个库表中存放的都是连续时间范围的数据。但是这种方式一般很少用,因为很容易会产生热点问题,大量的流量都打在最新的数据上了。这种方案的优点在于扩容的时候非常简单,比如只要预备好每个月都准备一个库就可以了,到了下一个新的月份自动将数据写入新的库。缺点则是,如果大部分请求都是访问最新的数据,那么在这里,分库分表的设计目的就只是简单的扩容,而不是为了应对高并发了。2.按照hash分发。按照某个字段的hash值均匀分散,这个较为常用。优点在于可以平均分配每个库表的数据量和请求压力;缺点在于扩容比较麻烦,因为会存在一个数据迁移的过程,即之前的数据需要重新计算hash值并重新分配到不同的库表中。
1.验证回文串(125-易)给定一个字符串,验证它是否是回文串,只考虑字母和数字字符,可以忽略字母的大小写。ps:我们将空字符串定义为有效的回文串。示例 :输入: "A man, a plan, a canal: Panama" 输出: true思路分析:法1:比较常规的做法就是使用双指针,这里注意忽略字母的大小写,即 A == a ,对于其他非法字符直接跳过,但只考虑字母和数字字符。法2:这里分享另一个 思路 。将字符建立映射关系,具体是把 '0' 到 '9' 映射到 1 到 10,'a' 到 'z' 映射到 11 到 36 ,'A' 到 'Z' 也映射到 11 到 36 。然后把所有数字和字母用一个 char 数组存起来,没存的字符就默认映射到 0 了。这样只需要判断字符串中每个字母映射过去的数字是否相等,如果是acsii值为 0, 就意味着它是非法字符,对应映射位置为空。代码实现:// 双指针 public boolean isPalindrome(String s) { s = s.toLowerCase(); if (s == null && s.length() == 0) { return true; } int l = 0, r = s.length() - 1; char[] cs = s.toCharArray(); while (l < r) { // 剔除非法字符(即找到有效的对比) while (!isAlphanumeric(cs[l]) && l < r) l++; while (!isAlphanumeric(cs[r]) && l < r) r--; if (cs[l] != cs[r]) { return false; } l++; r--; } return true; } // 合法字符:字母和数字 private boolean isAlphanumeric(char c) { if (c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9') { return true; } return false; }// 解法2:映射+双指针 private char[] charMap = new char[256]; public boolean isPalindrome(String s) { // 建立映射('1~10', '11~36') for (int i = 0; i < 10; i++) { charMap[i + '0'] = (char) (1 + i); } for (int i = 0; i < 26; i++) { charMap[i + 'A'] = charMap[i + 'a'] = (char) (11 + i); } char[] cs = s.toCharArray(); int l = 0, r = cs.length - 1; char sl, sr; while (l < r) { sl = charMap[cs[l]]; sr = charMap[cs[r]]; // 检查映射是否为空 if (sl != 0 && sr != 0) { if (sl != sr) { return false; } l++; r--; } else { if (sl == 0) { l++; } else { r--; } } } return true; }2.验证回文串ii(680-易)给定一个非空字符串 s,最多删除一个字符。判断是否能成为回文字符串。示例:输入: "abca" 输出: True 解释: 你可以删除c字符。思路分析:基于双指针实现,最多删除一个元素,也可以不做删除操作。那么我们就对删除一个元素进行分析,即出现某次不对称时,删除一个元素,继续进行比较,这里我们通过调用isPalindrome函数判断字符串区间是否回文。代码实现:class Solution { public boolean validPalindrome(String s) { char[] cs = s.toCharArray(); int i = 0, j = cs.length - 1; while (i < j && cs[i] == cs[j]) { i++; j--; } if (isPalindrome(cs, i, j - 1)) { return true; } if (isPalindrome(cs, i + 1, j)) { return true; } return false; } public boolean isPalindrome(char[] cs, int i, int j) { while (i < j) { if (cs[i] != cs[j]) { return false; } i++; j--; } return true; } }3.最短回文串(214-难)题目描述:给定一个字符串 s,你可以通过在字符串前面添加字符(即添加前缀)将其转换为回文串。找到并返回可以用这种方式转换的最短回文串。示例 :输入: "abcd" 输出: "dcbabcd"思路分析:本题可以用双指针实现,但这里用字符串匹配感觉比较合适。本案例关键就是求出从开头开始(前缀)的最长回文串!!!参考这里思路1:对原字符串进行反转匹配,举例:abbacd 原s: abbacd, 长度记为 n 逆r: dcabba, 长度记为 n 判断 s[0,n) 和 r[0,n) abbacd != dcabba 判断 s[0,n - 1) 和 r[1,n) abbac != cabba 判断 s[0,n - 2) 和 r[2,n) abba == abba 从开头开始的最长回文串也就找到了, 接下来只需要将末尾不是回文串的部分倒置加到原字符串开头即可,但过于暴力!思路2:对思路1字符串匹配进行优化,我们知道字符串匹配算法还有KMP算法。如果我们把思路1中的例子 abbacd dcabba看成一个字符串,中间加上一个分隔符 #(避免计算公共前后缀出错),abbacd#dcabba。左右部分可以对应拼接字符串的前缀和后缀,即寻找前缀和后缀相等的子串。我们如果求出了 abbacd#dcabba 的 next 数组,因为我们构造的字符串后缀就是原字符串的倒置,前缀后缀相等时,也就意味着当前前缀是一个回文串,而 next 数组是寻求最长的前缀,我们也就找到了开头开始的最长回文串。思路3:马拉车算法,待补充...代码实现:解法1:反转字符串public String shortestPalindrome(String s) { String r = new StringBuilder(s).reverse().toString(); int n = s.length(); int i = 0; for (; i < n; i++) { if (s.substring(0, n - i).equals(r.substring(i))) { break; } } return r.substring(0, i) + s; }解法2:KMPpublic String shortestPalindrome(String s) { String ss = s + "#" + new StringBuilder(s).reverse(); int max = getLastNext(ss); return new StringBuilder(s.substring(max)).reverse() + s; } // 获取next数组(即当前位置之前存放的最大匹配长度)的最后一个值 private int getLastNext(String s) { int n = s.length(); char[] chs = s.toCharArray(); int[] next = new int[n + 1]; // 初始化 next[0] = -1; next[1] = 0; int k = 0; // 最大匹配(前后缀相等)长度 int i = 2; // 当前最后一个位置 while (i <= n) { if (k == -1 || chs[i - 1] == chs[k]) { next[i] = k + 1; k++; i++; } else { k = next[k]; } } return next[n]; }4.构造最长的回文串(409-易)给定一个包含大写字母和小写字母的字符串,找到通过这些字母构造成的最长的回文串。在构造过程中,请注意区分大小写。比如 "Aa" 不能当做一个回文字符串。示例 :输入:"abccccdd" 输出:7 解释:我们可以构造的最长的回文串是"dccaccd", 它的长度是 7。思路分析:法1:本题要求通过输入构造最长回文串的长度,故可以统计每次字符出现的次数,最后考虑奇数情况完成字符串构造。法2:这里可以对数组长度和统计方法进行优化,这里只需要统计偶数(奇数 - 1)出现的个数,见解法2,参考这里:)代码实现:class Solution { // 统计每个字符出现的次数 public int longestPalindrome(String s) { int[] cnts = new int[256]; char[] cs = s.toCharArray(); for (char c : cs) { cnts[c]++; } int len = 0; for (int cnt : cnts) { // 只考虑偶数部分 len += (cnt / 2) * 2; } if (len < s.length()) { len++; } return len; } // 优化(题意优化空间和统计逻辑) public int longestPalindrome(String s) { int[] cnts = new int[58]; char[] cs = s.toCharArray(); for (char c : cs) { cnts[c - 'A']++; } int len = 0; for (int cnt : cnts) { len += cnt - (cnt & 1); } return len < s.length() ? len + 1 : len; } }ps:这里为什么申请58个位置,不应该是52吗?这里你要看一下ASCII码表,可以参考这里,大写字母ASCII值从64-90;小写字母的ASCII值从97-122,中间间隔6个字符,so。5.回文子串(647-中)给定一个字符串,你的任务是计算这个字符串中有多少个回文子串。具有不同开始位置或结束位置的子串,即使是由相同的字符组成,也会被计为是不同的子串。示例:输入: "aaa" 输出: 6 说明: 6个回文子串: "a", "a", "a", "aa", "aa", "aaa".思路分析:遍历字符串中的每个字符,尝试从字符串的每个位置进行字符串扩展,向左右两边扩展每个位置(即定义两个变量更新左右边界),并统计回文子串的数量。ps:但这里要注意奇偶,即每次固定字符个数,两种扩展情况!示例: z x x c 注:扩展时固定一个字符有4个,固定两个字符有2个,共6个 a b a 注:扩展时固定一个字符有4个,固定两个字符有0个,共4个代码实现:class Solution { private int count = 0; public int countSubstrings(String s) { char[] cs = s.toCharArray(); int n = cs.length; for (int i = 0; i < n; i++) { extendSubString(cs, i, i); extendSubString(cs, i, i + 1); } return count; } // 向两侧扩展字符串 private void extendSubString(char[] cs, int left, int right) { while (left >= 0 && right < cs.length && cs[left] == cs[right]) { left--; right++; count++; } } }6.最长的回文子串(5-中)给定一个字符串 s,找到 s 中最长的回文子串。你可以假设 s 的最大长度为 1000。示例 :输入: "babad" 输出: "bab" 注意: "aba" 也是一个有效答案。思路分析:思路1:和上题中回文子串思路相同,遍历字符串,从不同位置开始扩展字符串(获取扩展子串的长度),但要注意奇偶两种情况!定义两个变量 start 和 end ,当最大长度发生变化进行更新这两个边界。思路2:动态规划,同时也需要更新当前最大回文串的长度和左右边界。dp[i][j] 表示 s[i, j] 是否是回文串状态转移方程:cs[i] == cs[j] 并且 区间[i + 1, j - 1]是回文串或者只剩下i,j两个元素,区间[i, j]是一定是回文串,否则不是。思路3:马拉车算法,待补充...代码实现:class Solution { // 中心扩展法 public String longestPalindrome(String s) { char[] cs = s.toCharArray(); int len = cs.length; if (len < 2) { return s; } int start = 0; int end = 0; for (int i = 0; i < len; i++) { int len1 = extendSubString(cs, i, i); int len2 = extendSubString(cs, i, i + 1); int max = Math.max(len1, len2); if (max > end - start) { // 对于(如a b b a),i代表左b位置 start = i - (max - 1) / 2; end = i + max / 2; } } return s.substring(start, end + 1); } private int extendSubString(char[] cs, int left, int right) { while (left >= 0 && right < cs.length && cs[left] == cs[right]) { left--; right++; } return right - left - 1; } // 动态规划 public String longestPalindrome(String s) { char[] cs = s.toCharArray(); int len = s.length(); boolean[][] dp = new boolean[len][len]; int start = 0; int end = 0; int maxLen = 0; for (int j = 0; j < len; j++) { for (int i = 0; i < j; i++) { if (cs[i] == cs[j] && (dp[i + 1][j - 1] || j - i < 3)) { dp[i][j] = true; if (j - i + 1 > maxLen) { maxLen = j - i + 1; start = i; end = j; } } } } return s.substring(start, end + 1); } }7.最长回文子序列(516-中)给定一个字符串 s ,找到其中最长的回文子序列,并返回该序列的长度。可以假设 s 的最大长度为 1000 。子序列,指某个序列的子序列是从最初序列通过去除某些元素但不破坏余下元素的相对位置(在前或在后)而形成的新序列。示例 :输入:"bbbab" 输出:4 注:一个可能的最长回文子序列为 "bbbb"。思路分析:注意:一旦涉及到子序列/最值,那几乎可以肯定,考察的是动态规划技巧,时间复杂度一般都是 O(n^2)。dp 数组的定义是:在子串 s[i..j] 中,最长回文子序列的长度为 dp[i][j]。一般求什么设什么。状态转移方程:如果我们想求dp[i][j],假设你知道了子问题 dp[i + 1][j - 1] 的结果(s[i+1..j-1] 中最长回文子序列的长度),可以算出 dp[i][j]的值(s[i..j] 中,最长回文子序列的长度),关键:我们只需要判断新加入的两个元素的大小,若相等加入,若不相等,即不能同时加入,我们需要取两个元素加入的最大值即可。if (s[i] == s[j]) // 它俩一定在最长回文子序列中 dp[i][j] = dp[i + 1][j - 1] + 2; else // s[i+1..j] 和 s[i..j-1] 谁的回文子序列更长? dp[i][j] = max(dp[i + 1][j], dp[i][j - 1]);动态规划流程:base case:只有一个元素,显然最长回文子序列长度是 1,也就是 dp[i][j] = 1 (i == j);当i > j 时, 不存在最长子序列,即长度初始化0;上述代码和图示已经确定一个基本位置的依赖关系,目标位置dp[0][n - 1]为了保证每次计算 dp[i][j],左下右方向的位置已经被计算出来,只能斜着遍历或者反着遍历代码实现:class Solution { public int longestPalindromeSubseq(String s) { int n = s.length(); char[] cs = s.toCharArray(); int[][] dp = new int[n][n]; for (int i = 0; i < n; ++i) { dp[i][i] = 1; } for (int i = n - 1; i >= 0; i--) { for (int j = i + 1; j < n; j++) { if (cs[i] == cs[j]) { // 相等直接加入 dp[i][j] = dp[i + 1][j - 1] + 2; } else { dp[i][j] = Math.max(dp[i][j - 1], dp[i + 1][j]); } } } return dp[0][n - 1]; } }8.分割回文串(131-中)给定一个字符串 s,将 s 分割成一些子串,使每个子串都是回文串。返回 s 所有可能的分割方案。举例:输入: "aab" 输出: [ ["aa","b"], ["a","a","b"] ]思路分析:法1:暴力递归:将大问题分解为小问题(回文串),利用小问题的结果解决当前大问题。上述案例分析:输入:aab 先考虑在第 1 个位置切割,a | ab 这样我们只需要知道 ab 的所有结果,然后在所有结果的头部把 a 加入 ab 的所有结果就是 [a b] 每个结果头部加入 a,就是 [a a b] 再考虑在第 2 个位置切割,aa | b 这样我们只需要知道 b 的所有结果,然后在所有结果的头部把 aa 加入 b 的所有结果就是 [b] 每个结果头部加入 aa,就是 [aa b] 再考虑在第 3 个位置切割,aab | 因为 aab 不是回文串,所有直接跳过 最后所有的结果就是所有的加起来 [a a b] [aa b]法2:动态规划:每次判断一个字符串是否是回文串的时候,我们都会调用 isPalindrome 判断。这就会造成一个问题,比如字符串 abbbba,期间我们肯定会判断 bbbb 是不是回文串,也会判断 abbbba 是不是回文串。判断 abbbba 是不是回文串的时候,在 isPalindrome 中依旧会判断中间的 bbbb 部分。而其实如果我们已经知道了 bbbb 是回文串,只需要判断 abbbba 的开头和末尾字符是否相等即可。所以我们为了避免一些重复判断,可以用动态规划的方法,把所有字符是否是回文串提前存起来,空间换时间的思想。对于字符串 s。用 dp[i][j] 表示 s[i,j] 是否是回文串,然后有状态转移方程 dp[i][j] = s[i] == s[j] && dp[i+1][j-1] 。我们只需要两层 for 循环,从每个下标开始,考虑所有长度的子串即可。因为要保证 dp[i + 1][j - 1] 中 i + 1 <= j - 1:i + 1 <= j - 1 j = i + len - 1 化简得 len >= 3所以为了保证正确,多加了 len < 3 的条件。也就意味着长度是 1 和 2 的时候,我们只需要判断 s[i] == s[j]。然后把 dp 传入到递归函数中即可。法3:回溯算法:根据动态确定每个下标和长度的字符串是否为回文串。满足条件的话,依次进行更新:选择,递归,撤销选择。可以传入回溯算法作为参数,套用回溯算法模板。代码实现:public List<List<String>> partition(String s) { return partitionHelper(s, 0); } private List<List<String>> partitionHelper(String s, int start) { // 递归出口:分割位置start == s.length() if (start == s.length()) { List<List<String>> ans = new ArrayList<>(); List<String> list = new ArrayList<>(); ans.add(list); return ans; } List<List<String>> ans = new ArrayList<>(); for (int i = start; i < s.length(); i++) { if (isPalindrome(s.substring(start, i + 1))) { String left = s.substring(start, i + 1); for (List<String> l : partitionHelper(s, i + 1)) { // 每个结果头部加上回文子串left l.add(0, left); ans.add(l); } } } return ans; } private boolean isPalindrome(String s) { int i = 0, j = s.length() - 1; while (i < j) { if (s.charAt(i++) != s.charAt(j--)) return false; } return true; }解法2:动态规划(空间换时间)public List<List<String>> partition(String s) { int n = s.length(); boolean[][] dp = new boolean[n][n]; char[] ch = s.toCharArray(); for (int len = 1; len <= n; len++) { //所有长度 for (int i = 0; i <= n - len; i++) { //每个下标 int j = i + len - 1; dp[i][j] = ch[i] == ch[j] && (len < 3 || dp[i + 1][j - 1]); } } return partitionHelper(s, 0, dp); } private List<List<String>> partitionHelper(String s, int start, boolean[][] dp) { if (start == s.length()) { List<String> list = new ArrayList<>(); List<List<String>> ans = new ArrayList<>(); ans.add(list); return ans; } List<List<String>> ans = new ArrayList<>(); for (int i = start; i < s.length(); i++) { if (dp[start][i]) { // 看s[start...i]是否回文 String left = s.substring(start, i + 1); for (List<String> l : partitionHelper(s, i + 1, dp)) { l.add(0, left); ans.add(l); } } } return ans; }解法3:回溯算法private List<List<String>> ans = new ArrayList<>(); public List<List<String>> partition(String s) { int n = s.length(); // 长度和起始位置确定是否为回文(动态规划) boolean[][] dp = new boolean[n][n]; for (int len = 1; len <= n; len++) { for (int i = 0; i <= n - len; i++) { dp[i][i + len - 1] = s.charAt(i) == s.charAt(i + len - 1) && (len < 3 || dp[i + 1][i + len - 2]); } } partitionHelper(s, 0, dp, new ArrayList<>()); return ans; } private void partitionHelper(String s, int start, boolean[][] dp, List<String> temp) { if (start == s.length()) { ans.add(new ArrayList<>(temp)); } for (int i = start; i < s.length(); i++) { if (dp[start][i]) { String left = s.substring(start, i + 1); temp.add(left); partitionHelper(s, i + 1, dp, temp); temp.remove(temp.size() - 1); // 撤销选择 } } }9.分割回文串ii(132-难)给定一个字符串 s,将 s 分割成一些子串,使每个子串都是回文串。返回符合要求的最少分割次数。示例:输入: "aab" 输出: 1 解释: 进行一次分割就可将 s 分割成 ["aa","b"] 这样两个回文子串。思路分析:本题涉及最少分割次数,显然是一个动态规划问题。确定回文串参考8的动态规划解法。分析如下:dp[i]:以下标i作为结尾的最少的分割次数;确定递推关系:不失一般性,我们考虑j (j < i)字符(分割点)的分割方案。确定动态规划的状态转移方程:假设[0, i]已经是回文了,则dp[i] = 0。否则需要考虑j位置,判断[j + 1, i]是否为回文子串,如果是,那么以i作为结尾的最少分割次数为dp[i] = min(dp[i], dp[j] + 1);那么待解决的问题就是如何判断[j + 1, i]是回文子串,若字符串长度较大,那么双指针时间复杂度过高(与647不同)这里可以使用动态规划求回文子串。子串[j, i]是回文子串必须同时满足下面两个条件:[j + 1, i - 1] 是回文子串或j -i <= 1;s.charAt(i) == s.charAt(j),当长度小于等于2只需满足此项。从根据依赖关系从左下角至右上角依次填表,确定子串是否回文。代码实现:public int minCut(String s) { int n = s.length(); boolean[][] isPalindrome = new boolean[n][n]; for (int i = n - 1; i >= 0; --i) { for (int j = i; j < n; ++j) { if (s.charAt(i) == s.charAt(j) && (j - i <= 1 || isPalindrome[i + 1][j - 1])) { isPalindrome[i][j] = true; } } } // 初始化dp数组 int[] dp = new int[n]; for (int i = 0; i < n; ++i) dp[i] = i; for (int i = 1; i < n; ++i) { if (isPalindrome[0][i]) { dp[i] = 0; continue; } for (int j = 0; j < i; ++j) { if (isPalindrome[j + 1][i]) { dp[i] = Math.min(dp[i], dp[j] + 1); } } } return dp[n - 1]; }
常见排序算法可以分为两大类:非线性时间比较类排序:通过比较来决定元素间的相对次序,由于其时间复杂度不能突破O(nlogn),因此称为非线性时间比较类排序。如:快速排序、归并排序、堆排序、冒泡排序等。在排序的最终结果里,元素之间的次序依赖于它们之间的比较。每个数都必须和其他数进行比较,才能确定自己的位置。线性时间非比较类排序:不通过比较来决定元素间的相对次序,它可以突破基于比较排序的时间下界,以线性时间运行,因此称为线性时间非比较类排序。如:计数排序、基数排序、桶排序。非比较排序是通过确定每个元素之前,应该有多少个元素来排序。针对数组arr,计算arr之前有多少个元素,则唯一确定了arr在排序后数组中的位置。常见算法的复杂度分析参考这里:所谓稳定性是指待排序的序列中有两元素相等,排序之后它们的先后顺序不变.假如为A1,A2.它们的索引分别为1,2.则排序之后A1,A2的索引仍然是1和2.(相同的记录在排序前后相对次序不发生改变,那么就是稳定的排序)对于不稳定的 排序算法 ,只要举出一个实例,即可说明它的不稳定性;而对于稳定的排序算法,必须对算法进行分析从而得到稳定的特性。需要注意的是, 排序算法是否为稳定的是由具体算法决定的 ,不稳定的算法在某种条件下可以变为稳定的算法,而稳定的算法在某种条件下也可以变为不稳定的算法。例如,对于如下冒泡排序算法,原本是稳定的排序算法,如果将记录交换的条件改成a[j]>=a[j+1],则两个相等的记录就会交换位置。稳定性的意义:1、如果只是简单的进行数字的排序,那么稳定性将毫无意义。2、如果排序的内容仅仅是一个复杂对象的某一个数字属性,那么稳定性依旧将毫无意义(所谓的交换操作的开销已经算在算法的开销内了,如果嫌弃这种开销,不如换算法好了?)3、如果要排序的内容是一个复杂对象的多个数字属性,但是其原本的初始顺序毫无意义,那么稳定性依旧将毫无意义。4、除非要排序的内容是一个复杂对象的多个数字属性,且其原本的初始顺序存在意义,那么我们需要在二次排序的基础上保持原有排序的意义,才需要使用到稳定性的算法,例如要排序的内容是一组原本按照价格高低排序的对象,如今需要按照销量高低排序,使用稳定性算法,可以使得想同销量的对象依旧保持着价格高低的排序展现,只有销量不同的才会重新排序。(当然,如果需求不需要保持初始的排序意义,那么使用稳定性算法依旧将毫无意义)。稳定排序算法:冒泡排序、插入排序、归并排序、(计数排序、桶排序与基数排序)不稳定排序算法:希尔排序、选择排序、堆排序与快速排序复杂度分析:数据结构和算法本身解决的是“快”和“省”的问题,衡量算法执行效率的指标就包括复杂度复杂度分析包括时间复杂度和空间复杂度分析,时间复杂度是指算法执行的时间与数据规模的关系,空间复杂度是指算法占用的空间与数据规模的关系为什么进行复杂度分析?如何分析?为什么分析复杂度:通过测试、统计、监控,可以的得到算法执行的时间和占用的内存大小,但是测试结果非常依赖测试环境,测试结果受数据规模的影响很大;我们需要一个不依赖测试环境和数据规模就可以粗略估算算法执行效率的方法大O时间复杂度表示法:表示代码执行时间随数据规模增长的变化趋势,又称渐进时间复杂度。大O空间复杂度表示法:表示代码执行所占的内存空间随数据规模增长的变化趋势,又称渐进空间复杂度 ps:给出随着增长规模的下界,具体流程:https://www.cnblogs.com/zxhyJack/p/10596735.html大O复杂度表示法算法的执行效率,粗略地讲就是算法的执行时间。下面的代码是求1,2,3...n累加的和。cal(int n) { int sum = 0; int i = 1; for (; i <= n; ++i) { sum += i; // 两步操作 } return sum; }从CPU的角度,这段代码的操作是,读数据 -> 运算 -> 写数据,如果每一个操作都是unit_time,第二行和第三行是一个unit_time,而第四行和第五行的for循环是2n个unit_time,加上return操作。时间复杂度就是2n+3,一般计算的时候会把常量省略,所以这个程序的时间复杂度就是n。所以就可以推断出,所以代码的执行时间T(n)与每行代码的的执行次数成正比。引出重要概念:所有代码的执行时间 T(n) 与每行代码的执行次数 n 成正比,即T(n) = O(f(n))复杂度分析法则单段代码,看循环的次数。多段代码,看代码循环量级。嵌套代码求乘积,比如递归和多重循环。多个规模求加法,方法中并行的两个循环。常用的复杂度级别多项式阶:随着数据规模的增长,算法的执行时间和空间占用,按照多项式的比例增长&#