除了 Java 外,Scala、Clojure、Groovy,以及时下热门的 Kotlin,这些语言都可以运行在 Java 虚拟机之上。
Java 代码有很多种不同的运行方式。比如说可以在开发工具中运行,可以双击执行 jar 文件运行,也可以在命令行中运行,甚至可以在网页中运行。当然,这些执行方式都离不开 JRE,也就是 Java 运行时环境。
JRE 仅包含运行 Java 程序的必需组件,包括 Java 虚拟机以及 Java 核心类库等。JDK(Java 开发工具包)同样包含了 JRE,并且还附带了一系列开发、诊断工具。
运行 C++ 代码则无需额外的运行时。往往把这些代码直接编译成 CPU 所能理解的代码格式,也就是机器码。
当前的主流思路是这样子的,设计一个面向 Java 语言特性的虚拟机,并通过编译器将 Java 程序转换成该虚拟机所能识别的指令序列,也称 Java 字节码。之所以这么取名,是因为 Java 字节码指令的操作码(opcode)被固定为一个字节。
Java 虚拟机可以由硬件实现 ,但更为常见的是在各个现有平台(如 Windows_x64、Linux_aarch64)上提供软件实现。这么做的意义在于,一旦一个程序被转换成 Java 字节码,那么便可以在不同平台上的虚拟机实现里运行。也就是经常说的“一次编写,到处运行”。
虚拟机的另外一个好处是托管环境(Managed Runtime)。这个托管环境能够代替处理一些代码中冗长而且容易出错的部分。其中最广为人知的当属自动内存管理与垃圾回收。除此之外,托管环境还提供了诸如数组越界、动态类型、安全权限等等的动态检测,免于书写无关业务逻辑的代码。
以标准 JDK 中的 HotSpot 虚拟机为例,从虚拟机视角来看,执行 Java 代码首先需要将编译而成的 class 文件加载到 Java 虚拟机中。加载后的 Java 类会被存放于方法区(Method Area)中。实际运行时,虚拟机会执行方法区内的代码。
与X86中段式内存管理代码段类似。java虚拟机同样在内存中划分出堆和栈来存储运行时数据。并且Java虚拟机会将栈细分为面向 Java 方法的 Java 方法栈,面向本地方法(用 C++ 写的 native 方法)的本地方法栈,以及存放各个线程执行位置的 PC 寄存器。
在运行过程中,每当调用进入一个 Java 方法,Java 虚拟机会在当前线程的 Java 方法栈中生成一个栈帧,用以存放局部变量以及字节码的操作数。这个栈帧的大小是提前计算好的,而且 Java 虚拟机不要求栈帧在内存空间里连续分布。当退出当前执行的方法时,不管是正常返回还是异常返回,Java 虚拟机均会弹出当前线程的当前栈帧,并将之舍弃。
从硬件视角来看,Java 字节码无法直接执行。因此,Java 虚拟机需要将字节码翻译成机器码。在 HotSpot 里面,翻译过程有两种形式:第一种是解释执行,即逐条将字节码翻译成机器码并执行;第二种是即时编译(Just-In-Time compilation,JIT),即将一个方法中包含的所有字节码编译成机器码后再执行。
前者的优势在于无需等待编译,而后者的优势在于实际运行速度更快。HotSpot 默认采用混合模式,综合了解释执行和即时编译两者的优点。它会先解释执行字节码,而后将其中反复执行的热点代码,以方法为单位进行即时编译。
HotSpot 采用了多种技术来提升启动性能以及峰值性能,即时编译便是其中最重要的技术之一。
即时编译建立在程序符合二八定律的假设上,也就是百分之二十的代码占据了百分之八十的计算资源。对于占据大部分的不常用的代码,无需耗费时间将其编译成机器码,而是采取解释执行的方式运行;另一方面,对于仅占据小部分的热点代码,则可以将其编译成机器码,以达到理想的运行速度。
理论上讲,即时编译后的 Java 程序的执行效率,是可能超过 C++ 程序的。这是因为与静态编译相比,即时编译拥有程序的运行时信息,并且能够根据这个信息做出相应的优化。例如,虚方法是用来实现面向对象语言多态性的。对于一个虚方法调用,尽管有很多个目标方法,但在实际运行过程中可能只调用其中的一个。这个信息便可以被即时编译器所利用,来规避虚方法调用的开销,从而达到比静态编译的 C++ 程序更高的性能。
为了满足不同用户场景的需要,HotSpot 内置了多个即时编译器:C1、C2 和 Graal。Graal 是 Java 10 正式引入的实验性即时编译器。之所以引入多个即时编译器,是为了在编译时间和生成代码的执行效率之间进行取舍。
为了不干扰应用的正常运行,HotSpot 的即时编译是放在额外的编译线程中进行的。HotSpot 会根据 CPU 的数量设置编译线程的数目,并且按 1:2 的比例配置给 C1 及 C2 编译器。在计算资源充足的情况下,字节码的解释执行和即时编译可同时进行。编译完成后的机器码会在下次调用该方法时启用,以替换原本的解释执行。
对于发布频率不频繁(也就是长时间运行)的程序,其实选择线下编译和即时编译都一样,因为至多一两个小时后该即时编译的都已经编译完成了。另外,即时编译器因为有程序的运行时信息,优化效果更好,也就是说峰值性能更好。
Java 引进了八个基本类型,来支持数值计算。Java 这么做的原因主要是工程上的考虑,因为使用基本类型能够在执行效率以及内存使用两方面提升软件性能。
在 Java 虚拟机规范中,boolean 类型则被映射成 int 类型。具体来说,“true”被映射为整数 1,而“false”被映射为整数 0。这个编码规则约束了 Java 字节码的具体实现。
对于 Java 虚拟机来说,它看到的 boolean 类型,早已被映射为整数类型。因此,将原本声明为 boolean 类型的局部变量,(改动字节码)赋值为除了 0、1 之外的整数值,在 Java 虚拟机看来是“合法”的。
前面的值域被后面的值域所包含,因此,从前面的基本类型转换至后面的基本类型,无需强制转换。尽管他们的默认值看起来不一样,但在内存中都是 0。
基本类型中,boolean 和 char 是唯二的无符号类型。通常可以认定 char 类型的值为非负数。这种特性十分有用,比如说作为数组索引等。
Java 的浮点类型采用 IEEE 754 浮点数格式。以 float 为例,浮点类型通常有两个 0,+0.0F 以及 -0.0F。前者在 Java 里是 0,后者是符号位为 1、其他位均为 0 的浮点数,在内存中等同于十六进制整数 0x8000000(即 -0.0F 可通过 Float.intBitsToFloat(0x8000000) 求得)。尽管它们的内存数值不同,但是在 Java 中 +0.0F == -0.0F 会返回真。有了 +0.0F 和 -0.0F 这两个定义后,便可以定义浮点数中的正无穷及负无穷。正无穷就是任意正浮点数(不包括 +0.0F)除以 +0.0F 得到的值,而负无穷是任意正浮点数除以 -0.0F 得到的值。在 Java 中,正无穷和负无穷是有确切的值,在内存中分别等同于十六进制整数 0x7F800000 和 0xFF800000。 0x7F800001 又对应 NaN(Not-a-Number)。不仅如此,[0x7F800001, 0x7FFFFFFF] 和 [0xFF800001, 0xFFFFFFFF] 对应的都是 NaN。通过 +0.0F/+0.0F,在内存中应为 0x7FC00000。这个数值,我们称之为标准的 NaN,而其他的我们称之为不标准的 NaN。
NaN 有一个有趣的特性:除了“!=”始终返回 true 之外,所有其他比较结果都会返回 false。举例来说,“NaN<1.0F”返回 false,而“NaN>=1.0F”同样返回 false。对于任意浮点数 f,不管它是 0 还是 NaN,“f!=NaN”始终会返回 true,而“f==NaN”始终会返回 false。在程序里做浮点数比较的时候,需要考虑上述特性。
Java 虚拟机每调用一个 Java 方法,便会创建一个栈帧。其中解释器用的解释栈帧有两个主要的组成部分,分别是局部变量区,以及字节码的操作数栈。这里的局部变量是广义的,除了普遍意义下的局部变量之外,它还包含实例方法的“this 指针”以及方法所接收的参数。
在 Java 虚拟机规范中,局部变量区等价于一个数组,并且可以用正整数来索引。除了 long、double 值需要用两个数组单元来存储之外,其他基本类型以及引用类型的值均占用一个数组单元。
即boolean、byte、char、short 这四种类型,在栈上占用的空间和 int 是一样的,和引用类型也是一样的。因此,在 32 位的 HotSpot 中,这些类型在栈上将占用 4 个字节;而在 64 位的 HotSpot 中,他们将占 8 个字节。
这种情况仅存在于局部变量,而并不会出现在存储于堆中的字段或者数组元素上。对于 byte、char 以及 short 这三种类型的字段或者数组单元,它们在堆上占用的空间分别为一字节、两字节,以及两字节,也就是说,跟这些类型的值域相吻合。
Java 虚拟机的算数运算几乎全部依赖于操作数栈。也就是说,需要将堆中的 boolean、byte、char 以及 short 加载到操作数栈上,而后将栈上的值当成 int 类型来运算。
]]>实现搜索引擎的搜索关键词提示功能
Trie树也叫字典树,是一种专门处理字符串匹配的数据结构,用来解决在一组字符串集合中快速查找某个字符串的问题。
例如有6个字符串,分别是how,hi,her,hello,so,see。想要在里面多次查找某个字符串是否存在。如果每次查找都是依次匹配,效率就比较低。可以对6个字符串进行预处理组织成Trie树结构,之后每次查找都是在Trie树中进行匹配查找。Trie树的本质就是利用字符串之间的公共前缀,将重复的前缀合并在一起。
根节点不包含任何信息,每个节点表示一个字符串中的字符,从根节点到红色节点的一条路径表示一个字符串(注意:红色节点并不都是叶子节点)。
字符串的构造过程如下,Trie树构造的每一步,都相当于往Trie树中插入一个字符串。当所有字符串都插入完成后,Trie树就构造好了。
在进行查找时就是从根节点开始,但是不一定一路到叶子节点。可能提前终止。
Trie树主要有两个操作,一个是将字符串集合构造成Trie树。这个就是将字符串插入到Trie树的过程。另一个是在Trie树中查询一个字符串。
存储一个Trie树
一种经典的存储方式是借助散列表的思想,通过下标与字符–映射的数组来存储子节点的指针。
在Trie树中查找某个字符串的时间复杂分析:
在一组字符串中频繁查询某些字符串,用Trie树会非常高效,构建Trie树的过程需要扫描所有的字符串,时间复杂度是O(n)。一旦构建成功之后,后续的查询操作会非常高效。
构建好Trie树后,在其中查找字符串的时间复杂度是O(k),k表示要查找的字符串的长度。
Trie树内存分析:
在Trie树实现时,用到数组来存储一个节点的子节点指针,如果字符串中包含从a到z这26个字符,则每个节点都要存储一个长度为26的数组,即便一个节点只有很少的子节点,远少于26个,也要维护一个长度为26的数组。如果字符串中不仅包含小写字母,还包含大写字母、数字、中文,那需要的存储空间就更加多了。即Trie树不旦不能节省内存,还有可能会浪费更多的内存。
在工程中,更倾向于使用散列表或者红黑树,因为这两种数据结构都不需要自己去实现,Trie树更适合于查找前缀匹配的字符串,比如搜索引擎的提示框。
以及输入法的自动补全功能、IDE代码自动补全等。
用来实现敏感词过滤
BF、RK、BM、KMP算法都是单模式匹配算法,Trie树是多模式串匹配算法。
单模式串匹配算法,是在一个模式串和一个主串之间进行匹配,也就是在一个主串中查找一个模式串。多模式串匹配算法,就是在多个模式串和一个主串之间做匹配,就是在一个主串中查找多个模式串。
Trie树跟AC自动机之间的关系,就像单串匹配中朴素的串匹配跟KMP算法之间的关系。AC自动机实际上就是在Trie树上,加了类似KMP的next数组,只是next数组是构建在树上的。
AC自动机的构建包含两个操作:1.将多个模式串构建成Trie树 2.在Trie树上构建失败指针(相当于KMP中失效函数next数组)
]]>Tomcat实现2个核心功能:1处理Socket连接,负责网络字节流与Request和Response对象的转化。2加载和管理Servlet,以及具体处理Request请求。
Tomcat设计了两个核心组件连接器(Connector)和容器(Container)来分别做这两件事情。连接器负责对外交流,容器负责内部处理。
Tomcat支持的I/O模型有:
Tomcat支持的应用层协议有:
Tomcat为了实现支持多种I/O模型和应用层协议,一个容器可能对接多个连接器,单独的连接器或者容器都不能对外提供服务,需要把它们组装起来才能工作,组装后这个整体叫作Service组件。Service本身没有做什么重要的事情,只是在连接器和容器外面多包了一层,把它们组装在一起。通过在Tomcat中配置多个Service,可以实现通过不同的端口号来访问同一台机器上部署的不同应用。
最顶层是Server,指的是一个Tomcat实例。一个Server中有一个或多个Service,一个Service中有多个连接器和一个容器。连接器与容器之间通过标准的ServletRequest和ServletResponse通信。
连接器对Servlet容器屏蔽了协议及I/O模型等的区别,无论是HTTP还是AJP,在容器中获取到的都是一个标准的ServletRequest对象。
连接器功能需求:
连接器需要完成高内聚(相关度比较高的功能要尽可能集中,不要分散)的功能:
Tomcat的设计者设计了3个组件来实现这3个功能,分别是EndPoint、Processor和Adaptor。
EndPoint负责提供字节流给Processor,Processor负责提供Tomcat Request对象给Adaptor,Adaptor负责提供ServletRequest对象给容器。
I/O模型和应用层协议可以自由组合,比如NIO + HTTP或者NIO2 + AJP。Tomcat的设计者将网络通信和应用层协议解析放在一起考虑,设计了一个叫ProtocolHandler的接口来封装这两种变化点。各种协议和通信模型的组合有相应的具体实现类。比如:Http11NioProtocol和AjpNioProtocol。
系统也存在一些相对稳定的部分,Tomcat设计了一系列抽象基类来封装这些稳定的部分,抽象基类AbstractProtocol实现了ProtocolHandler接口。每一种应用层协议有自己的抽象基类,比如AbstractAjpProtocol和AbstractHttp11Protocol,具体协议的实现类扩展了协议层抽象基类。
EndPoint是通信端点,即通信监听的接口,是具体的Socket接收和发送处理器,是对传输层的抽象,因此EndPoint是用来实现TCP/IP协议的。
EndPoint是一个接口,它的抽象实现类AbstractEndpoint里面定义了两个内部类:Acceptor和SocketProcessor。
Acceptor用于监听Socket连接请求。SocketProcessor用于处理接收到的Socket请求,它实现Runnable接口,在Run方法里调用协议处理组件Processor进行处理。为了提高处理能力,SocketProcessor被提交到线程池来执行。而这个线程池叫作执行器(Executor)。
Processor用来实现HTTP协议,Processor接收来自EndPoint的Socket,读取字节流解析成Tomcat Request和Response对象,并通过Adapter将其提交到容器处理,Processor是对应用层协议的抽象。
Processor是一个接口,定义了请求的处理等方法。它的抽象实现类AbstractProcessor对一些协议共有的属性进行封装,没有对方法进行实现。具体的实现有AJPProcessor、HTTP11Processor等,这些具体实现类实现了特定协议的解析方法和请求处理方式。
EndPoint接收到Socket连接后,生成一个SocketProcessor任务提交到线程池去处理,SocketProcessor的Run方法会调用Processor组件去解析应用层协议,Processor通过解析生成Request对象后,会调用Adapter的Service方法。
由于协议不同,客户端发过来的请求信息也不尽相同,Tomcat定义了自己的Request类来“存放”这些请求信息。ProtocolHandler接口负责解析请求并生成Tomcat Request类。但是这个Request对象不是标准的ServletRequest,也就意味着,不能用Tomcat Request作为参数来调用容器。
Tomcat引入CoyoteAdapter,这是适配器模式的经典运用,连接器调用CoyoteAdapter的Sevice方法,传入的是Tomcat Request对象,CoyoteAdapter负责将Tomcat Request转成ServletRequest,再调用容器的Service方法。
如果连接器直接创建ServletRequest和ServletResponse对象的话,就和Servlet协议耦合了,设计者认为连接器尽量保持独立性,它不一定要跟Servlet容器工作的。对象转化的性能消耗比较少,Tomcat对HTTP请求体采取了延迟解析的策略,TomcatRequest对象转化成ServletRequest的时候,请求体的内容都还没读取,直到容器处理这个请求的时候才读取的。
Tomcat设计了四种容器,分别是Engine、Host、Context和Wrapper。容器是父子关系,Tomcat通过一种分层架构使得Servlet容器具有很好的灵活性。
Context表示一个Web应用程序;Wrapper表示一个Servlet,一个Web应用程序中可能会有多个Servlet;
Host代表的是一个虚拟主机,或者说一个站点,可以给Tomcat配置多个虚拟主机地址,而一个虚拟主机下可以部署多个Web应用程序;
Engine表示引擎,用来管理多个虚拟站点,一个Service最多只能有一个Engine。
Tomcat的server.xml配置文件:
Tomcat通过组合模式来管理这些容器,所有容器组件都实现了Container接口,组合模式可以使得用户对单容器对象和组合容器对象的使用具有一致性。
Tomcat通过Mapper组件来确定请求是由哪个Wrapper容器中的Servlet来处理的。Mapper组件的功能就是将用户请求的URL定位到一个Servlet,工作原理是:Mapper组件里保存了Web应用的配置信息,即容器组件与访问路径的映射关系,如Host容器里配置的域名、Context容器里的Web应用路径,以及Wrapper容器里Servlet映射的路径,这些配置信息就是一个多层次的Map。当一个请求到来时,Mapper组件通过解析请求URL里的域名和路径,再到自己保存的Map里去查找,就能定位到一个Servlet。一个请求URL最后只会定位到一个Wrapper容器,也就是一个Servlet。
用户访问一个URL,http://uer.shoping.com:8080/order/buy。经过一下几步定位到一个Servlet。
并不是只有Servlet才会去处理请求,这个查找路径上的父子容器都会对请求做一些处理。连接器中的Adapter会调用容器的Service方法来执行Servlet,最先拿到请求的是Engine容器,Engine容器对请求做一些处理后,会把请求传给自己子容器Host继续处理,依次类推,最后这个请求会传给Wrapper容器,Wrapper会调用最终的Servlet来处理。该过程使用Pipeline-Valve管道。Pipeline-Valve是责任链模式,责任链模式是指在一个请求处理的过程中有很多处理者依次对请求进行处理,每个处理者负责做自己相应的处理,处理完之后将再调用下一个处理者继续处理。
Valve是一个处理点,invoke方法就是来处理请求的。
Pipeline中维护了Valve链表,Valve可以插入到Pipeline中,Pipeline中没有invoke方法,因为整个调用链的触发时Valve来完成的,Valve完成自己的处理后,调用getNext.invoke()来触发下一个Valve调用。
不同容器的Pipeline是通过getBasic方法来调用BasicValve,其位于Valve链表的末端是Pipeline中必不可少的一个Valve,负责调用下层容器的Pipeline里的第一个Valve。
Wrapper容器的最后一个Valve会创建一个Filter链,并调用doFilter()方法,最终会调到Servlet的service方法。
Valve和Filter的区别:
Web容器
HTTP服务器(比如Apache、Nginx)向浏览器返回静态HTML,浏览器负责解析HTML,将结果呈现给用户。我们希望通过一些交互操作,来获取动态结果,需要一些扩展机制能够让HTTP服务器调用服务端程序。于是Sun公司推出了Servlet技术。可以把Servlet简单理解为运行在服务端的Java小程序,Servlet没有main方法,不能独立运行,所以必须把它部署到Servlet容器中,由容器来实例化并调用Servlet。
Tomcat和Jetty就是一个Servlet容器。为了方便使用,它们也具有HTTP服务器的功能,因此Tomcat或者Jetty就是一个“HTTP服务器 + Servlet容器”,我们也叫它们Web容器。Tomcat和Jetty算是一个轻量级的应用服务器。
Tomcat是Spring Boot默认的嵌入式Servlet容器。最新版本Tomcat和Jetty都支持Servlet 4.0规范。
HTTP协议是浏览器与服务器之间的数据传送协议。作为应用层协议,HTTP是基于TCP/IP协议来传递数据的(HTML文件、图片、查询结果等),HTTP协议不涉及数据包(Packet)传输,主要规定了客户端和服务器之间的通信格式。HTTP协议的本质就是一种浏览器与服务器之间约定好的通信格式。
HTTP通信机制是在一次完整的HTTP通信过程中,Web浏览器与Web服务器之间将完成的步骤:
HTTP请求数据由三部分组成,分别是请求行、请求报头、请求正文。当这个HTTP请求数据到达Tomcat后,Tomcat会把HTTP请求数据字节流解析成一个Request对象,这个Request对象封装了HTTP所有的请求信息。接着Tomcat把这个Request对象交给Web应用去处理,处理完后得到一个Response对象,Tomcat会把这个Response对象转成HTTP格式的响应数据并发送给浏览器。
HTTP的响应也是由三部分组成,分别是状态行、响应报头、报文主体。
Cookie是HTTP报文的一个请求头,Web应用可以将用户的标识信息或者其他一些信息(用户名等)存储在Cookie中。用户经过验证之后,每次HTTP请求报文中都包含Cookie,这样服务器读取这个Cookie请求头就知道用户是谁了。Cookie本质上就是一份存储在用户本地的文件,里面包含了每次请求中都需要传递的信息。
Cookie以明文的方式存储在本地,而Cookie中往往带有用户信息,这样就造成了非常大的安全隐患。而Session的出现解决了这个问题,Session可以理解为服务器端开辟的存储空间,里面保存了用户的状态,用户信息以Session的形式存储在服务端。当用户请求到来时,服务端可以把用户的请求和用户的Session对应起来。通过Cookie来将Session和请求对应起来。浏览器在Cookie中填充了一个Session ID之类的字段用来标识请求。
Java中,Web应用程序在调用HttpServletRequest的getSession方法时,由Web容器(比如Tomcat)创建的。
Tomcat的Session管理器提供了多种持久化方案来存储Session,通常会采用高性能的存储方式,比如Redis,并且通过集群部署的方式,防止单点故障,从而提升高可用。同时,Session有过期时间,因此Tomcat会开启后台线程定期的轮询,如果Session过期了就将Session失效。
Servlet接口其实是Servlet容器跟具体业务类之间的接口。
HTTP服务器不直接调用业务类,而是把请求交给容器来处理,容器通过Servlet接口调用业务类。因此Servlet接口和Servlet容器的出现,达到了HTTP服务器与业务类解耦的目的。
Servlet接口和Servlet容器这一整套规范叫作Servlet规范。Tomcat和Jetty都按照Servlet规范的要求实现了Servlet容器,同时它们也具有HTTP服务器的功能。作为Java程序员,如果要实现新的业务功能,只需要实现一个Servlet,并把它注册到Tomcat(Servlet容器)中,剩下的事情就由Tomcat处理了。
Servlet接口定义了下面五个方法:
其中最重要是的service方法,具体业务类在这个方法里实现处理逻辑。这个方法有两个参数:ServletRequest和ServletResponse。ServletRequest用来封装请求信息,ServletResponse用来封装响应信息,因此本质上这两个类是对通信协议的封装。
比如HTTP协议中的请求和响应就是对应了HttpServletRequest和HttpServletResponse这两个类。可以通过HttpServletRequest来获取所有请求相关的信息,包括请求路径、Cookie、HTTP头、请求参数等。此外,还可以通过HttpServletRequest来创建和获取Session。而HttpServletResponse是用来封装HTTP响应的。
可以看到接口中还有两个跟生命周期有关的方法init和destroy,这是一个比较贴心的设计,Servlet容器在加载Servlet类的时候会调用init方法,在卸载的时候会调用destroy方法。可能会在init方法里初始化一些资源,并在destroy方法里释放这些资源,比如Spring MVC中的DispatcherServlet,就是在init方法里创建了自己的Spring容器。
ServletConfig的作用就是封装Servlet的初始化参数。可以在web.xml给Servlet配置参数,并在程序里通过getServletConfig方法拿到这些参数。
有接口一般就有抽象类,抽象类用来实现接口和封装通用的逻辑,因此Servlet规范提供了GenericServlet抽象类,可以通过扩展它来实现Servlet。虽然Servlet规范并不在乎通信协议是什么,但是大多数的Servlet都是在HTTP环境中处理的,因此Servet规范还提供了HttpServlet来继承GenericServlet,并且加入了HTTP特性。这样通过继承HttpServlet类来实现自己的Servlet,只需要重写两个方法:doGet和doPost。
当客户请求某个资源时,HTTP服务器会用一个ServletRequest对象把客户的请求信息封装起来,然后调用Servlet容器的service方法,Servlet容器拿到请求后,根据请求的URL和Servlet的映射关系,找到相应的Servlet,如果Servlet还没有被加载,就用反射机制创建这个Servlet,并调用Servlet的init方法来完成初始化,接着调用Servlet的service方法来处理请求,把ServletResponse对象返回给HTTP服务器,HTTP服务器会把响应发送给客户端。
一般来说,以Web应用程序的方式来部署Servlet的。根据Servlet规范,Web应用程序有一定的目录结构,在这个目录下分别放置了Servlet的类文件、配置文件以及静态资源,Servlet容器通过读取配置文件,就能找到并加载Servlet。Web应用的目录结构大概是下面这样的:
Servlet规范里定义了ServletContext这个接口来对应一个Web应用。Web应用部署好后,Servlet容器在启动时会加载Web应用,并为每个Web应用创建唯一的ServletContext对象。
一个Web应用可能有多个Servlet,这些Servlet可以通过全局的ServletContext来共享数据,这些数据包括Web应用的初始化参数、Web应用目录下的文件资源等。由于ServletContext持有所有Servlet实例,还可以通过它来实现Servlet请求的转发。
设计一个规范或者一个中间件,要充分考虑到可扩展性。Servlet规范提供了两种扩展机制:Filter和Listener。
Filter是过滤器,这个接口允许对请求和响应做一些统一的定制化处理,比如可以根据请求的频率来限制访问,或者根据国家地区的不同来修改响应内容。过滤器的工作原理是这样的:Web应用部署完成后,Servlet容器需要实例化Filter并把Filter链接成一个FilterChain。当请求进来时,获取第一个Filter并调用doFilter方法,doFilter方法负责调用这个FilterChain中的下一个Filter。
Listener是监听器,是另一种扩展机制。当Web应用在Servlet容器中运行时,Servlet容器内部会不断的发生各种事件,如Web应用的启动和停止、用户请求到达等。 Servlet容器提供了一些默认的监听器来监听这些事件,当事件发生时,Servlet容器会负责调用监听器的方法。当然,可以定义自己的监听器去监听感兴趣的事件,将监听器配置在web.xml中。比如Spring就实现了自己的监听器,来监听ServletContext的启动事件,目的是当Servlet容器启动时,创建并初始化全局的Spring容器。
Tomcat&Jetty在启动时给每个Web应用创建一个全局的上下文环境,这个上下文就是ServletContext,其为后面的Spring容器提供宿主环境。
Tomcat&Jetty在启动过程中触发容器初始化事件,Spring的ContextLoaderListener会监听到这个事件,它的contextInitialized方法会被调用,在这个方法中,Spring会初始化全局的Spring根容器,这个就是Spring的IoC容器,IoC容器初始化完毕后,Spring将其存储到ServletContext中,便于以后来获取。
Tomcat&Jetty在启动过程中还会扫描Servlet,一个Web应用中的Servlet可以有多个,以SpringMVC中的DispatcherServlet为例,这个Servlet实际上是一个标准的前端控制器,用以转发、匹配、处理每个Servlet请求。
Servlet一般会延迟加载,当第一个请求达到时,Tomcat&Jetty发现DispatcherServlet还没有被实例化,就调用DispatcherServlet的init方法,DispatcherServlet在初始化的时候会建立自己的容器,叫做SpringMVC 容器,用来持有Spring MVC相关的Bean。同时,Spring MVC还会通过ServletContext拿到Spring根容器,并将Spring根容器设为SpringMVC容器的父容器,请注意,Spring MVC容器可以访问父容器中的Bean,但是父容器不能访问子容器的Bean, 也就是说Spring根容器不能访问SpringMVC容器里的Bean。说的通俗点就是,在Controller里可以访问Service对象,但是在Service里不可以访问Controller对象。
/bin:存放Windows或Linux平台上启动和关闭Tomcat的脚本文件。
/conf:存放Tomcat的各种全局配置文件,其中最重要的是server.xml。
/lib:存放Tomcat以及所有Web应用都可以访问的JAR文件。
/logs:存放Tomcat执行时产生的日志文件。
/work:存放JSP编译后产生的Class文件。
/webapps:Tomcat的Web应用目录,默认情况下把Web应用放在这个目录下。
catalina.***.log
主要是记录Tomcat启动过程的信息,在这个文件可以看到启动的JVM参数以及操作系统等日志信息。
catalina.out
catalina.out是Tomcat的标准输出(stdout)和标准错误(stderr),这是在Tomcat的启动脚本里指定的,如果没有修改的话stdout和stderr会重定向到这里。
localhost.**.log
主要记录Web应用在初始化过程中遇到的未处理的异常,会被Tomcat捕获而输出这个日志文件。
localhost_access_log.**.txt
存放访问Tomcat的请求日志,包括IP地址以及请求的路径、时间、请求协议以及状态码等信息。
manager.***.log/host-manager.***.log
存放Tomcat自带的manager项目的日志信息。
2004 Google发表的三篇论文,“三驾马车”,分别是分布式文件系统GFS、大数据分布式计算框架MapReduce和NoSQL数据库系统BigTable。
一个文件系统,一个计算框架,一个数据库系统。Hadoop,主要包括Hadoop分布式文件系统HDFS和大数据计算引擎MapReduce。
Facebook发布了Hive,支持SQL语法来进行大数据计算。
在Hadoop早期,MapReduce既是一个执行引擎,又是一个资源调度框架,服务器集群的资源调度管理由MapReduce完成。这样不利于资源复用,也使得MapReduce非常臃肿。于是一个新项目启动了,将MapReduce执行引擎和资源调度分离开来,这就是Yarn。2012年,Yarn成为一个独立的项目开始运营,随后被各类大数据产品支持,成为大数据平台上最主流的资源调度系统。
MapReduce进行机器学习计算的时候性能非常差,因为机器学习算法通常需要进行很多次的迭代计算,而MapReduce每执行一次Map和Reduce计算都需要重新启动一次作业,带来大量的无谓消耗。还有一点就是MapReduce主要使用磁盘作为存储介质,而2012年的时候,内存已经突破容量和成本限制,成为数据运行过程中主要的存储介质。Spark一经推出,立即受到业界的追捧,并逐步替代MapReduce在企业应用中的地位。
一般说来,像MapReduce、Spark这类计算框架处理的业务场景都被称作批处理计算,因为它们通常针对以“天”为单位产生的数据进行一次计算,然后得到需要的结果,这中间计算需要花费的时间大概是几十分钟甚至更长的时间。因为计算的数据是非在线得到的实时数据,而是历史数据,所以这类计算也被称为大数据离线计算。
而在大数据领域,还有另外一类应用场景,需要对实时产生的大量数据进行即时计算。这类计算称为大数据流计算,相应地,有Storm、Flink、Spark Streaming等流计算框架来满足此类大数据应用的场景。 流式计算要处理的数据是实时在线产生的数据,所以这类计算也被称为大数据实时计算。
在典型的大数据的业务场景下,数据业务最通用的做法是,采用批处理的技术处理历史全量数据,采用流式计算处理实时新增数据。而像Flink这样的计算引擎,可以同时支持流式计算和批处理计算。
除了大数据批处理和流处理,NoSQL系统处理的主要也是大规模海量数据的存储与访问,所以也被归为大数据技术。 NoSQL曾经在2011年左右非常火爆,涌现出HBase、Cassandra等许多优秀的产品,其中HBase是从Hadoop中分离出来的、基于HDFS的NoSQL系统。
网站实时处理通常针对单个用户的请求操作,虽然大型网站面临大量的高并发请求,比如天猫的“双十一”活动。但是每个用户之间的请求是独立的,只要网站的分布式系统能将不同用户的不同业务请求分配到不同的服务器上,只要这些分布式的服务器之间耦合关系足够小,就可以通过添加更多的服务器去处理更多的用户请求及由此产生的用户数据。这也正是网站系统架构的核心原理。
大数据计算处理通常针对的是网站的存量数据,也就是刚才我提到的全部用户在一段时间内请求产生的数据,这些数据之间是有大量关联的,比如购买同一个商品用户之间的关系,这是使用协同过滤进行商品推荐;比如同一件商品的历史销量走势,这是对历史数据进行统计分析。网站大数据系统要做的就是将这些统计规律和关联关系计算出来,并由此进一步改善网站的用户体验和运营决策。
这套方案的核心思路是,既然数据是庞大的,而程序要比数据小得多,将数据输入给程序是不划算的,那么就反其道而行之,将程序分发到数据所在的地方进行计算,也就是所谓的移动计算比移动数据更划算。
两台计算机要想合作构成一个系统,必须要在技术上重新架构。这就是现在互联网企业广泛使用的负载均衡、分布式缓存、分布式数据库、分布式服务等种种分布式系统。
移动计算程序到数据所在位置进行计算的实现:
杀毒软件从服务器更新病毒库,然后在Windows内查杀病毒,就是一种移动计算(病毒库)比移动数据(Windows可能感染病毒的程序)更划算的例子。
大规模数据存储都需要解决几个核心问题:
1.数据存储容量的问题。是数以PB计的数据计算问题,而一般的服务器磁盘容量通常1~2TB,如何存储这么大规模的数据呢?
2.数据读写速度的问题。一般磁盘的连续读写速度为几十MB,以这样的速度,几十PB的数据恐怕要读写到天荒地老。
3.数据可靠性的问题。磁盘大约是计算机设备中最易损坏的硬件了,通常情况一块磁盘使用寿命大概是一年,如果磁盘损坏了,数据怎么办?
RAID(独立磁盘冗余阵列)技术是将多块普通磁盘组成一个阵列,共同对外提供服务。主要是为了改善磁盘的存储容量、读写速度,增强磁盘的可用性和容错能力。
目前服务器级别的计算机都支持插入多块磁盘(8块或者更多),通过使用RAID技术,实现数据在多块磁盘上的并发读写和数据备份。
RAID 0是数据在从内存缓冲区写入磁盘时,根据磁盘数量将数据分成N份,这些数据同时并发写入N块磁盘,使得数据整体写入速度是一块磁盘的N倍;读取的时候也一样,因此RAID 0具有极快的数据读写速度。但是RAID 0不做数据备份,N块磁盘中只要有一块损坏,数据完整性就被破坏,其他磁盘的数据也都无法使用了。
RAID 1是数据在写入磁盘时,将一份数据同时写入两块磁盘,这样任何一块磁盘损坏都不会导致数据丢失,插入一块新磁盘就可以通过复制数据的方式自动修复,具有极高的可靠性。
结合RAID 0和RAID 1两种方案构成了RAID 10,它是将所有磁盘N平均分成两份,数据同时在两份磁盘写入,相当于RAID 1;但是平分成两份,在每一份磁盘(也就是N/2块磁盘)里面,利用RAID 0技术并发读写,这样既提高可靠性又改善性能。不过RAID 10的磁盘利用率较低,有一半的磁盘用来写备份数据。
RAID 3可以在数据写入磁盘的时候,将数据分成N-1份,并发写入N-1块磁盘,并在第N块磁盘记录校验数据,这样任何一块磁盘损坏(包括校验数据磁盘),都可以利用其他N-1块磁盘的数据修复。但是在数据修改较多的场景中,任何磁盘数据的修改,都会导致第N块磁盘重写校验数据。频繁写入的后果是第N块磁盘比其他磁盘更容易损坏,需要频繁更换,所以RAID 3很少在实践中使用。
相比RAID 3,RAID 5是使用更多的方案。RAID 5和RAID 3很相似,但是校验数据不是写入第N块磁盘,而是螺旋式地写入所有磁盘中。这样校验数据的修改也被平均到所有磁盘上,避免RAID 3频繁写坏一块磁盘的情况。
RAID 6和RAID 5类似,但是数据只写入N-2块磁盘,并螺旋式地在两块磁盘中写入校验信息(使用不同算法生成)。
RAID可以看作是一种垂直伸缩,一台计算机集成更多的磁盘实现数据更大规模、更安全可靠的存储以及更快的访问速度。而HDFS则是水平伸缩,通过添加更多的服务器实现数据更大、更快、更安全存储与访问。将RAID思想原理应用到分布式服务器集群上,就形成了Hadoop分布式文件系统HDFS的架构思想。
Hadoop的第一个产品是HDFS,可以说分布式文件存储是分布式计算的基础,也可见分布式文件存储的重要性。HDFS也许不是最好的大数据存储技术,但依然最重要的大数据存储技术。
HDFS是在一个大规模分布式服务器集群上,对数据分片后进行并行读写及冗余存储。因为HDFS可以部署在一个比较大的服务器集群上,集群中所有服务器的磁盘都可供HDFS使用,所以整个HDFS的存储空间可以达到PB级容量。
两个关键组件:DataNode和NameNode。
DataNode负责文件数据的存储和读写操作,HDFS将文件数据分割成若干数据块(Block),每个DataNode存储一部分数据块,这样文件就分布存储在整个HDFS服务器集群中。应用程序客户端(Client)可以并行对这些数据块进行访问,从而使得HDFS可以在服务器集群规模上实现数据并行访问,极大地提高了访问速度。在实践中,HDFS集群的DataNode服务器会有很多台,一般在几百台到几千台这样的规模,每台服务器配有数块磁盘,整个集群的存储容量大概在几PB到数百PB。
NameNode负责整个分布式文件系统的元数据(MetaData)管理,也就是文件路径名、数据块的ID以及存储位置等信息,相当于操作系统中文件分配表(FAT)的角色。HDFS为了保证数据的高可用,会将一个数据块复制为多份(缺省情况为3份),并将多份相同的数据块存储在不同的服务器上,甚至不同的机架上。这样当有磁盘损坏,或者某个DataNode服务器宕机,甚至某个交换机宕机,导致其存储的数据块不能访问的时候,客户端会查找其备份的数据块进行访问。
图中对于文件/ users / sameerp / data / part-0,其复制备份数设置为2,存储的BlockID分别为1、3。Block1的两个备份存储在DataNode0和DataNode2两个服务器上,Block3的两个备份存储DataNode4和DataNode6两个服务器上,上述任何一台服务器宕机后,每个数据块都至少还有一个备份存在,不会影响对文件/ users / sameerp / data / part-0的访问。
和RAID一样,数据分成若干数据块后存储到不同服务器上,可以实现数据大容量存储,并且不同分片的数据可以并行进行读/写操作,进而实现数据的高速访问。你可以看到,HDFS的大容量存储和高速访问相对比较容易实现,下面就是关于HDFS的高可用设计。
集群部署两台NameNode服务器,一台作为主服务器提供服务,一台作为从服务器进行热备,两台服务器通过ZooKeeper选举,主要是通过争夺znode锁资源,决定谁是主服务器。而DataNode则会向两个NameNode同时发送心跳数据,但是只有主NameNode才能向DataNode返回控制信息。
正常运行期间,主从NameNode之间通过一个共享存储系统shared edits来同步文件系统的元数据信息。当主NameNode服务器宕机,从NameNode会通过ZooKeeper升级成为主服务器,并保证HDFS集群的元数据信息,也就是文件分配表信息完整一致。
分布式系统可能出故障地方又非常多,内存、CPU、主板、磁盘会损坏,服务器会宕机,网络会中断,机房会停电,所有这些都可能会引起软件系统的不可用,甚至数据永久丢失。
常用的保证系统可用性的策略有冗余备份、失效转移和降级限流。
HDFS是通过大规模分布式服务器集群实现数据的大容量、高速、可靠存储、访问的。
MapReduce既是一个编程模型,又是一个计算框架。开发人员必须基于MapReduce编程模型进行编程开发,然后将程序通过MapReduce计算框架分发到Hadoop集群中运行。
其编程模型只包含Map和Reduce两个过程,map的主要输入是一对<Key, Value>值,经过map计算后输出一对<Key, Value>值;然后将相同Key合并,形成<Key, Value集合>;再将这个<Key, Value集合>输入reduce,经过计算输出零个或多个<Key, Value>对。
MapReduce非常强大的,不管是关系代数运算(SQL计算),还是矩阵运算(图计算),大数据领域几乎所有的计算需求都可以通过MapReduce编程来实现。
以wordcount为例
少量数据一个哈希表就能够完成。MapReduce版本WordCount程序的核心是一个map函数和一个reduce函数。
map函数的输入主要是一个<Key, Value>对,map函数的计算过程是,将这行文本中的单词提取出来,针对每个单词输出一个<word, 1>这样的<Key, Value>对。
MapReduce计算框架会将这些
这里reduce的输入参数Values就是由很多个1组成的集合,而Key就是具体的单词word。reduce函数的计算过程是,将这个集合里的1求和,再将单词(word)和这个和(sum)组成一个<Key, Value>,也就是<word, sum>输出。每一个输出就是一个单词和它的词频统计总和。
一个map函数可以针对一部分数据进行运算,这样就可以将一个大数据切分成很多块(这也正是HDFS所做的),MapReduce计算框架为每个数据块分配一个map函数去计算,从而实现大数据的分布式计算。
红圈对应的分别是MapReduce作业启动和运行,以及MapReduce数据合并与连接。
MapReduce运行过程涉及三类关键进程。
JobTracker进程和TaskTracker进程是主从关系,MapReduce的主服务器就是JobTracker,从服务器就是TaskTracker。HDFS也是主从架构吗,HDFS的主服务器是NameNode,从服务器是DataNode。
计算流程为:
每个Map任务的计算结果都会写入到本地文件系统,等Map任务快要计算完成的时候,MapReduce计算框架会启动shuffle过程,在Map任务进程调用一个Partitioner接口,对Map产生的每个<Key, Value>进行Reduce分区选择,然后通过HTTP通信发送给对应的Reduce进程。这样不管Map位于哪个服务器节点,相同的Key一定会被发送给相同的Reduce进程。Reduce任务进程对收到的<Key, Value>进行排序和合并,相同的Key放在一起,组成一个<Key, Value集合>传递给Reduce执行。
map输出的<Key, Value>shuffle到哪个Reduce进程是这里的关键,它是由Partitioner来实现,MapReduce框架默认的Partitioner用Key的哈希值对Reduce任务数量取模,相同的Key一定会落在相同的Reduce任务ID上。
分布式计算需要将不同服务器上的相关数据合并到一起进行下一步计算,这就是shuffle。
shuffle是大数据计算过程中最神奇的地方,不管是MapReduce还是Spark,只要是大数据批处理计算,一定都会有shuffle过程,只有让数据关联起来,数据的内在关系和价值才会呈现出来。shuffle也是整个MapReduce过程中最难、最消耗性能的地方。
Hadoop主要是由三部分组成,分布式文件系统HDFS、分布式计算框架MapReduce,还有一个是分布式集群资源调度框架Yarn。
在MapReduce应用程序的启动过程中,最重要的就是要把MapReduce程序分发到大数据集群的服务器上,在Hadoop 1中,这个过程主要是通过TaskTracker和JobTracker通信来完成。这种架构方案的主要缺点是,服务器集群资源调度管理和MapReduce执行过程耦合在一起,如果想在当前集群中运行其他计算任务,比如Spark或者Storm,就无法统一使用集群中的资源了。
Hadoop 2最主要的变化,就是将Yarn从MapReduce中分离出来,成为一个独立的资源调度框架。
Yarn包括两个部分:一个是资源管理器(Resource Manager),一个是节点管理器(Node Manager)。这也是Yarn的两种主要进程:ResourceManager进程负责整个集群的资源调度管理,通常部署在独立的服务器上;NodeManager进程负责具体服务器上的资源和任务管理,在集群的每一台计算服务器上都会启动,基本上跟HDFS的DataNode进程一起出现。
资源管理器又包括两个主要组件:调度器和应用程序管理器。
一个MapReduce程序,Yarn的整个工作流程:
MapReduce如果想在Yarn上运行,就需要开发遵循Yarn规范的MapReduce ApplicationMaster,相应地,其他大数据计算框架也可以开发遵循Yarn规范的ApplicationMaster,这样在一个Yarn集群中就可以同时并发执行各种不同的大数据计算框架,实现资源的统一调度管理。
管HDFS叫分布式文件系统,管MapReduce叫分布式计算框架,管Yarn叫分布式集群资源调度框架。
框架在架构设计上遵循一个重要的设计原则叫“依赖倒转原则”,依赖倒转原则是高层模块不能依赖低层模块,它们应该共同依赖一个抽象,这个抽象由高层模块定义,由低层模块实现。
实现MapReduce编程接口、遵循MapReduce编程规范就可以被MapReduce框架调用,在分布式集群中计算大规模数据;实现了Yarn的接口规范,比如Hadoop 2的MapReduce,就可以被Yarn调度管理,统一安排服务器资源。所以说,MapReduce和Yarn都是框架。
]]>BM算法是工程中非常常用的一种高效字符串匹配算法,是最高效最常用的字符串匹配算法。在所有字符串匹配算法中,最知名的非KMP算法莫属。
KMP算法的核心思想与BM算法非常相近。在模式串与主串匹配过程中,当遇到不可匹配的字符时,通过找到一些规律将模式串往后多滑动几位,跳过肯定不会匹配的情况。
当遇到坏字符的时候,就要把模式串往后滑动,在滑动的过程中,只要模式串和好前缀有上下重合,前面几个字符的比较,就相当于拿好前缀的后缀子串,跟模式串的前缀子串在比较。
KMP算法就是在试图寻找一种规律: 在模式串和主串匹配的过程中,当遇到坏字符后,对于已经比对过的好前缀,找到一种规律,将模式串一次性滑动很多位。
求好前缀的最长可匹配前缀和后缀自串不涉及主串,只需要通过模式串本身就能求解。KMP算法也可以提前构建一个数组,用来存储模式串中每个前缀(有可能是好前缀)的最长可匹配前缀自串的结尾字符下标。把这个数组定义为next数组,又叫失效函数。
最复杂的部分就是next数组计算。
最简单也最低效的是像👆一样依次遍历。
如何更好的理解和掌握 KMP 算法? - 逍遥行的回答 - 知乎
https://www.zhihu.com/question/21923021/answer/37475572
在计算next[i]时,前面的next[0],next[1],….,next[i-1]已经计算出来了,利用已经计算出来的next值,可以快速推导出next[i]的值。
例子:
abababzabababa
列个表计算一下:
对子字符串 abababzababab 来说,前缀有 a, ab, aba, abab, ababa, ababab, abababz, …后缀有 b, ab, bab, abab, babab, ababab, zababab, …所以子字符串 abababzababab 前缀后缀最大匹配了 6 个(ababab),容易看出次大匹配了 4 个(abab),更仔细地观察可以发现,次大匹配的前缀后缀只可能在 ababab 中,所以次大匹配数就是 ababab 的最大匹配数。
来计算 ? 的值:既然末尾字母不是 z,那么就不能直接 6+1=7 了,回退到次大匹配 abab,刚好 abab 之后的 a 与末尾的 a 匹配,所以 ? 处的最大匹配数为 5。
空间复杂度:
KMP算法只需要一个额外的next数组,数组的大小跟模式串相同。所以空间复杂度是O(m),m表示模式串的长度。
时间复杂度:
时间复杂度包括两部分,第一部分是构建next数组,第二部分是借助next数组匹配。
时间复杂度为O(m+n)
]]>java中的indexOf(),Python中的find()函数,底层依赖的就是字符串匹配算法。
字符串匹匹配算法有BF算法,RK算法,BM算法,KMP算法。
其中BF算法RK算法是单模式匹配算法,即为一个串和另一个串进行匹配。
Brute Force,即为暴力匹配算法,比较简单,性能不高。
主串的长度记作n,模式串的长度记作m。因为是在主串中查找模式串,所以n>m。在主串中,检查起始位置分别是0、1、…n-m且长度为m的n-m+1个子串,看有没有跟模式串匹配的。
算法最坏时间复杂度为O(n * m)
尽管理论上,BF算法的时间复杂度很高,但在实际的开发中,它却是一个比较常用的字符串匹配算法。
第一,实际的软件开发中,大部分情况下,模式串和主串的长度都不会太长。而且每次模式串与主串中的子串匹配的时候,当中途遇到不能匹配的字符的时候,就可以就停止了,不需要把m个字符都比对一下。所以,尽管理论上的最坏情况时间复杂度是O(n * m),但是,统计意义上,大部分情况下,算法执行效率要比这个高很多。
第二,朴素字符串匹配算法思想简单,代码实现也非常简单。简单意味着不容易出错,如果有bug也容易暴露和修复。在工程中,在满足性能要求的前提下,简单是首选。这也是常说的KISS (Keep it Simple and Stupid)设计原则。
在实际的软件开发中,绝大部分情况下,朴素的字符串匹配算法就够用了。
RK算法即为BF算法的升级版。
RK算法的思路为:通过哈希算法对主串中的n-m+1个子串分别求哈希值,然后逐个与模式串的哈希值比较大小。如果某个子串的哈希值与模式串相等,那就说明对应的子串和模式串匹配了。因为哈希值是一个数字,数字之间比较是否相等非常快速的,所以模式串和子串比较的效率就提高了。
为了避免遍历字串中的每一个字符,提高算法的效率,需要对哈希算法进行设计。
假设要匹配的字符串的字符集中只包含K个字符,可以用一个K进制数来表示一个子串,这个K进制数转化成十进制数,作为子串的哈希值。
这种哈希算法有一个特点, 在主串中,相邻两个子串的哈希值的计算公式有一定关系。
规律:相邻两个子串s[i-1]和s[i] (i 表示子串在主串中的起始位置,子串的长度都为m),对应的哈希值计算公式有交集,可以使用s[i-1]的哈希值很快的计算出s[i]的哈希值。用公式表示:
26^(m-1)这部分的计算,我们可以通过查表的方法来提高效率。我们事先计算好26^0、26^1、 2……26^(m -1),并且存储在一个长度为m的数组中,公式中的“次方”就对应数组的下标。当我们需要计算26的x次方的时候,就可以从数组的下标为x的位置取值,直接使用,省去了计算的时间。
RK算法时间复杂度分析:
整个RK算法包含两部分,计算子串哈希值和模式串哈希值与子串哈希值之间的比较。
第一部分,可以通过设计特殊的哈希算法,只需要扫描一遍主串就能计算出所有子串的哈希值了,这部分的时间复杂度是O(n)。
如果模式串很长,相应的主串中的子串也会很长,通过上面的哈希算法计算得到的哈希值就可能很大,如果超过了计算机中整型数据可以表示的范围,为了能将哈希值落在整型数据范围内,可以牺牲一下, 允许哈希冲突。比如可以将字符串中每个字母对应的数字相加得到哈希值,这样产生的哈希值数据范围就小很多。
当存在哈希冲突的时候,有可能存在这样的情况,子串和模式串的哈希值虽然是相同的,但是两者本身并不匹配。解决方法是当发现一个子串的哈希值跟模式串的哈希值相等的时候,只需要再对比一下子串和模式串本身。
极端情况下,如果存在大量的冲突,每次都要对比字串和模式串本身,时间复杂度会退化为O( n * m)。一般情况下,冲突不会很多,RK算法效率还是比BF算法高。
可以同理看做一个字符串来处理。
BF,RK算法中遇到不匹配,模式串往后滑动一位,然后从模式串第一个字符开始重新匹配。
主串中的c,在模式串中是不存在的,模式串向后滑动的时候,只要c与模式串有重合,肯定无法匹配。所以,可以一次性把模式串往后多滑动几位,把模式串移动到c的后面。
BM算法,本质上其实就是在寻找这种规律。借助这种规律,在模式串与主串匹配的过程中,当模式串和主串某个字符不匹配的时候,能够跳过一些肯定不会匹配的情况,将模式串往后多滑动几位。
BM算法包含两部分,分别是坏字符规则和好后缀规则。
BM算法的匹配顺序比较特别,是按照模式串下标从大到小的顺序,倒着匹配的。当发现某个字符没法匹配的时候。把这个没有匹配的字符叫作坏字符(主串中的字符)。
拿坏字符c在模式串中查找,发现模式串中并不存在这个字符,也就是说,字符c与模式串中的任何字符都不可能匹配。这个时候,可以将模式串直接往后滑动三位,将模式串滑动到c后面的位置,再从模式串的末尾字符开始比较。
然后对于模式串中存在的a,滑动两位,让两个串对齐。
总结规律为:
当发生不匹配的时候,把坏字符对应的模式串中的字符下标记作si。如果坏字符在模式串中存在,把这个坏字符在模式串中的下标记作xi。如果不存在,把xi记作-1。那模式串往后移动的位数就等于si-xi。(注意, 这里都是字符在模式串的下标)。
如果坏字符在模式串里多处出现,在计算xi的时候,选择最靠后的那个,这样不会让模式串滑动过多,导致本来可能匹配的情况被滑动略过。
单纯使用坏字符规则还是不够的。因为根据si-xi计算出来的移动位数,有可能是负数。所以,BM算法还需要用到“好后缀规则”
把已经匹配的bc叫作好后缀,记作{u}。我们拿它在模式串中查找,如果找到了另一个跟{u}相匹配的子串{u },那我们就将模式串滑动到子串{u }与主串中{u}对齐的位置。
如果在模式串中找不到另一个等于{u}的子串,就直接将模式串滑动到主串中{u}的后面。此时有可能存在滑动过头的情况,错过匹配字符串。所以不仅要看好后缀在模式串中,是否有另一个匹配的子串,还要考察好后缀的后缀子串,是否存在跟模式串的前缀子串匹配的。
分别计算好后缀和坏字符往后滑动的位数,然后取两个数中最大的,作为模式串往后滑动的位数。这种处理方法还可以避免根据坏字符规则,计算得到的往后滑动的位数,有可能是负数的情况。
坏字符,在模式串中顺序遍历查找,这样就会比较低效,势必影响这个算法的性能。可以将模式串中的每个字符及其下标都存到散列表中。这样就可以快速找到坏字符在模式串的位置下标。
表示模式字串中不同的后缀字串。
suffix数组下标k,表示后缀字串的长度,下标对应的数组值存储的是在模式串中跟好后缀{u}相匹配的字串{u * }的起始下标值。
不仅要在模式串中,查找跟好后缀匹配的另一个子串,还要在好后缀的后缀子串中,查找最长的能跟模式串前缀子串匹配的后缀子串。
除了suffix数组外,还需要另一个boolean类的prefix数组,来记录模式串的后缀字串能否匹配模式串的前缀字串。
BM内存消耗,其中bc数组的大小跟字符集大小有关,suffix 数组和prefix数组的大小跟模式串长度m有关。
如果处理字符集很大的字符串匹配问题,bc 数组对内存的消耗就会比较多。因为好后缀和坏字符规则是独立的,如果运行的环境对内存要求苛刻,可以只使用好后缀规则,不使用坏字符规则,这样就可以避免bc数组过多的内存消耗。不过,单纯使用好后缀规则的BM算法效率就会下降一些了。
BM算法核心思想是,利用模式串本身的特点,在模式串中某个字符与主串不能匹配的时候,将模式串往后多滑动几位,以此来减少不必要的字符比较,提高匹配的效率。BM算法构建的规则有两类,坏字符规则和好后缀规则。好后缀规则可以独立于坏字符规则使用。因为坏字符规则的实现比较耗内存,为了节省内存,可以只用好后缀规则来实现BM算法。
]]>MyBatis可以使用XML或注解进行配置和映射,MyBatis通过将参数映射到配置的SQL形成最终执行的SQL语句,最后将执行SQL的结果映射成Java对象返回。与其他的ORM (对象关系映射)框架不同,MyBatis 并没有将Java对象与数据库表关联起来,而是将Java方法与SQL语句关联。
在实际应用中,一个表一般会对应一个实体,用于INSERT、UPDATE、DELETE 和简单的SELECT操作,所以姑且称这个简单的对象为实体类。
为了方便对表进行直接操作,此处没有创建表之间的外键关系。对于表之间的关系,会通过业务逻辑来进行限制。
创建表和创建实体类。
特别注意,由于Java中的基本类型会有默认值,会导致很多隐藏的问题。所以在实体类中不要使用基本类型,基本类型包括byte,int,short,long,float,double,char,boolean。
创建数据表对应的XML文件,并创建对应的接口类。
将xml文件与对应的接口类产生联系。
将创建的mybatis-config.xml配置文件中的mappers元素中配置所有的mapper。
也可以进行更简单的配置。
这种配置方式会先查找tk. mybatis.simple . mapper包下所有的接口,循环对接口进行如下操作:
1.判断接口对应的命名空间是否已经存在,如果存在就抛出异常,不存在就继续进行接下来的操作。
2.加载接口对应的XML映射文件,将接口全限定名转换为路径,例如,将接口tk .mybatis. simple . mapper . UserMapper转换为tk/ mybatis/ simple/ mapper/ UserMapper.xml, 以.xml为后缀搜索XML资源,如果找到就解析XML。
3.处理接口中的注解方法。
在接口中添加一个selectById方法。
使用MyBatis时,只需要在XML中添加一个select元素,写一个SQL,做一些简单的配置,就可以将查询的结果直接映射到对象中。
在xml中添加代码:
XML中的select标签的id属性值和定义的接口方法名是一样的。MyBatis就是通过这种方式将接口方法和XML中定义的SQL语句关联到一起的,如果接口方法没有和XML中的id属性值相对应,启动程序便会报错。
jdbcType:列对应的数据库类型。JDBC 类型仅仅需要对插入、更新、删除操作可能为空的列进行处理。这是JDBC jdbcType的需要,而不是MyBatis的需要。
自动映射,使用resultType来设置返回结果的类型,需要在SQL中为所有列名和属性名不一致的列设置别名,通过设置别名使最终的查询结果列和resultType指定对象的属性名保持一致。
名称映射规则
property属性或别名要和对象中属性的名字相同,但是实际匹配时,MyBatis会先将两者都转换为大写形式, 然后再判断是否相同,即property=“userName” 和property=“username” 都可以匹配到对象的userName属性上。判断是否相同的时候要使用USERNAME,因此在设置property属性或别名的时候,不需要考虑大小写是否一致,但是为了便于阅读,要尽可能按照统一的规则来设置。
MyBatis还提供了一个全局属性mapUnderscoreToCamelCase,通过配置这个属性为true可以自动将以下划线方式命名的数据库列映射到Java对象的驼峰式命名属性中。这个属性默认为false。需要在MyBatis配置文件中启用。
考虑性能,通常都会指定查询列,很少使用星号代替所有列。
多表查询中,方法写在任何一个对应的Mapper接口中都可以。
返回的int值是指执行的SQL影响的行数。
在xml的标签中配置属性。
useGeneratedKeys设置为true后, MyBatis会使用JDBC的getGeneratedKeys方法来取出由数据库内部生成的主键。获得主键值后将其赋值给keyProperty配置的id属性。SQL上下两部分的列中去掉了id列和对应的#{ id }属性。
该方式不仅适用于不提供主键自增功能的数据库,也适用于提供主键自增功能的数据库。
当参数是一个基本类型的时候,它在XML文件中对应的SQL语句只会使用一个参数,例如delete方法。当参数是一个JavaBean类型的时候,它在XML文件中对应的SQL语句会有多个参数,例如insert、update 方法。但并不适合全部的情况,因为不能只为了两三个参数去创建新的JavaBean,参数较少时还可以采用Map类型作为参数或使用@Param注解。
给参数配置@Param注解后,MyBatis 就会自动将参数封装成Map类型,@Param 注解值会作为Map中的key,因此在SQL部分就可以通过配置的注解值来使用参数。
当只有一一个参数(基本类型或拥有TypeHandler配置的类型)的时候,在这种情况下(除集合和数组外),MyBatis不关心这个参数叫什么名字就会直接把这个唯一的参数值拿来使用。
可以通过动态代理这个桥梁将对接口方法的调用转换为对其他方法的调用。
MyBatis注解方式就是将SQL语句直接写在接口上。这种方式的优点是,对于需求比较简单的系统,效率较高。缺点是,当SQL有变化时都需要重新编译代码,一”般情况下不建议使用注解方式。
配置SqlSessionFactoryBean
在MyBatis. Spring中,SqlSessionFactoryBean是用于创建SqlSessionFactory的。
配置MapperScannerConfigurer
在请求离开浏览器时,1️⃣:会带有用户所请求内容信息,至少会包含请求的URL。还可能带有其他的信息,例如用户提交的表单信息。
单实例Servlet ,DispatcherServlet将请求委托给应用程序的其他组件来执行实际处理。通过所携带的URL来进行决策。
控制器返回时仅仅传递了一个逻辑名称,这个名字将会用来查找产生结果的真正视图。
传统方式,DispatcherServlet这样的Servlet会配置在web.xml中。但不是唯一的方法。扩展AbstractAnnotationConfigDispatcherServletInitializer的任意类都会自动配置DispatcherServlet和Spring应用上下文。Spring的应用上下文会位于应用程序的Servlet上下文之中。即为dispatchservlet.xml。
在 Servlet3环境中,容器会在类路径中查找实现 javax. servlet.ServletcontainerInitializer接口的类,如果能发现的话,就会用来配置 Servlet容器。Spring提供了这个接口的实现,名为SpringServletcontainerInitializer,这个类反过来又会查找实现 WebApplicationInitializer的类并将配置的任务交给它们来完成。 Spring3.2引入了一个便利的 WebApplicationInitializer基础实现,也就是AbstractAnnotationconfigDispatcherservletInitializer。当扩展了AbstractAnnotationconfigDispatcherServletinitializer,同时也就实现了 WebApplicationInitializer,因此当部署到 Servlet容器中的时候,容器会自动发现它,并用它来配置 Servlet上下文。
当DispatcherServlet启动的时候,它会创建Spring应用上下文,并加载配置文件或配置类中所声明的bean。
在Spring Web应用中,通常还会有另外一个应用上下文。另外的这个应用上下文是由ContextLoaderListener创建的。
DispatcherServlet加载包含Web组件的bean,如控制器、视图解析器以及处理器映射,而ContextLoaderListener要加载应用中的其他bean。 这些bean通常是驱动应用后端的中间层和数据层组件。
传统是使用xml,也可以通过config来配置。
@Controller注解与@Component注解实现的效果是一样的,表意性会强一些。
@RequestMapping的属性能够接受一个String类型的数组。
将Repository注入Controller。
Model作为参数,方法就能将从Repository中获取的东西填充到模型中。Model实际上就是一个Map,它会传递给视图,这样数据就能渲染到客户端了。当调用addAttribute方法不指定key时,key会根据值的对象类型推断确定。如果希望使用非Spring类型,可以用Map来替代Model。
在返回时统统返回的是jsp的名称。
JSP访问模型:当视图是JSP时,模型数据会作为请求属性放到请求(request)中,所以可以在jsp文件中使用JSTL的
Spring MVC允许多种方式将客户端的数据传送到控制器的处理器方法中,包括:
客户端发的请求形如:
带有参数的请求尽管可以正常工作,但是从面向资源的角度来看并不理想。
理想情况下,要识别的资源应该通过URL路径进行标示,而不是通过查询参数。对“spittles/ 12345”发起 GET 请求要优于对“ / spittles / show?spitte_id=12345”发起请求。前者能够识别出要查询的资源,而后者描述的是带有参数的一个操作,本质上是通过HTTP发起的RPC。
为了实现路径变量,Spring MVC允许在@RequestMapping路径中添加占位符。占位符的名称要用大括号(“{”和“}”)括起来。路径中的其他部分要与所处理的请求完全匹配,但是占位符部分可以是任意的值。
spittle ()方法会将参数传递到SpittleRepository的findOone ()方法中,用来获取某个Spittle对象,然后将Spittle对象添加到模型中。模型的key将会是spittle,这是根据传递到addAttribute ()方法中的类型推断得到的。
如果传递请求中少量的数据,那查询参数和路径变量是很合适的。通常还需要传递很多的数据(也许是表单提交的数据),查询参数显得有些笨拙和受限了。可以编写控制器方法来处理表单提交。
Spring MVC控制器为表单处理提供了良好的支持。使用表单分为两个方面:展现表单以及处理用户通过表单提交的数据。
当InternalResourceViewResolver看到视图格式中的“redirect:” 前缀时,就知道要将其解析为重定向的规则,而不是视图的名称。在本例中,它将会重定向到用户基本信息的页面。例如,如果Spitter .username属性的值为“jbauer”, 那么视图将会重定向到“ / spitter / jbauer”.
除了“redirect:”, InternalResourceViewResolver 还能识别“forward:” 前缀。当它发现视图格式中以“forward:” 作为前缀时,请求将会前往(forward) 指定的URL路径,而不再是重定向。
与其让校验逻辑弄乱处理器方法,还不如使用Spring对Java校验API 的支持。从Spring 3.0开始,在Spring MVC中提供了对Java校验API的支持。在Spring MVC中要使用Java校验API的话,并不需要什么额外的配置。只要保证在类路径下包含这个Java API的实现,比如Hibernate Validator。
@Valid注解,告知Spring,需要确保这个对象满足校验限制。
如果没有错误的话,Spitter 对象将会通过Repository 进行保存,控制器会像之前那样重定向到基本信息页面。
编写的控制器方法都没有直接产生浏览器中渲染所需的HTML。这些方法只是将一些数据填充到模型中,然后将模型传递给一个用来渲染的视图。这些方法会返回一个String类型的值,这个值是视图的逻辑名称,不会直接引用具体的视图实现。尽管我编写了几个简单的JavaServer Page (JSP) 视图,但是控制器并不关心这些。
将控制器中请求处理的逻辑和视图中的渲染实现解耦是Spring MVC的一个重要特性。控制器只通过逻辑视图名来了解视图,Spring视图解析器来确定使用哪一个视图实现来渲染模型。
Spring MVC定义了一一个名为ViewResolver的接口,
当给resolveViewName(方法传入一个视图名和Locale对象时,它会返回一个View实例。View 是另外一个接口。
View接口的任务就是接受模型以及Servlet 的request 和response对象,并将输出结果渲染到response中。
其中InternalResourceViewResolver只是ViewResolver的实现之一,将视图解析为Web应用的内部资源(一般为JSP)。JSP曾经是,而且现在依然还是Java领域占主导地位的视图技术。
Spring 提供了两种支持JSP视图的方式:
1.InternalResourceViewResolver会将视图名解析为JSP文件。另外,如果在JSP页面中使用了JSP 标准标签库( JSTL)的话,InternalResourceViewResolver 能够将视图名解析为JstIView形式的JSP文件,从而将JSTL本地化和资源bundle变量暴露给JSTL的格式化(formatting)和信息( message)标签。
2.Spring提供了两个JSP标签库,一个用于表单到模型的绑定,另一个提供了通用的工具类特性。
InternalResourceViewResolver所采取的方式并不那么直接。它遵循一种约定,会在视图名上添加前缀和后缀,进而确定一个 Web应用中视图资源的物理路径。
如果想让InternalResourceViewResolver将视图解析为JstlView,而不是InternalResourceView的话,只需设置viewClass属性即可:
XML中:
JSTL的格式化标签需要- -个 Locale对象,以便于恰当地格式化地域相关的值,如日期和货币。信息标签可以借助Spring的信息资源和Locale,从而选择适当的信息渲染到HTML之中。通过解析JstlView, JSTL能够获得Locale对象以及Spring中配置的信息资源。
不管使用Java配置还是使用XML,都能确保JSTL的格式化和信息标签能够获得Locale对象以及Spring中配置的信息资源。
当为JSP添加功能时,标签库是一种很强大的方式,能够避免在脚本块中直接编写Java代码。Spring 提供了两个JSP标签库,用来帮助定义Spring MVC Web的视图。
其中一个标签库会用来渲染HTML表单标签,这些标签可以绑定model中的某个属性。
另外一个标签库包含了一些工具类标签,随时都可以非常便利地使用它们。
Spring的表单绑定JSP标签库包含了14个标签,它们中的大多数都用来渲染HTML中的表单标签。但是,与原生HTML标签的区别在于会绑定模型中的一一个对象,能够根据模型中对象的属性填充值。标签库中还包含了一个为用户展现错误的标签,会将错误信息渲染到最终的HTML之中。
sf:form会渲染会一个HTML
如果存在校验错误的话,请求中会包含错误的详细信息,这些信息是与模型数据放到一起的。所需要做的就是到模型中将这些数据抽取出来,并展现给用户。sf:errors能够让这项任务变得很简单。
JSP存在一些缺陷,大多数的JSP模板都是采用HTML的形式,但是又掺杂上了各种JSP标签库的标签,使其变得很混乱。这些标签库能够以便利的方式为JSP带来动态渲染的强大功能,但是也摧毁了维持一个格式良好的文档的可能性。
同时,JSP规范是与Servlet 规范紧密耦合的。这意味着它只能用在基于Servlet 的Web应用之中。JSP 模板不能作为通用的模板(如格式化Email),也不能用于非Servlet的Web应用。
Thymeleaf模板是原生的,不依赖于标签库。能在接受原始HTML的地方进行编辑和渲染。因为没有与Servlet规范耦合,因此Thymeleaf模板能够进人JSP所无法涉足的领域。
为了要在Spring中使用Thymeleaf,我们需要配置三个启用Thymeleaf与Spring集成的bean:
ThymeleafViewResolver:将逻辑视图名称解析为Thymeleaf模板视图;
SpringTemplateEngine:处理模板并渲染结果;
TemplateResolver:加载Thymeleaf模板。
不管使用哪种配置方式,Thymeleaf 都可以将响应中的模板渲染到Spring MVC控制器所处理的请求中。
ThymeleafViewResolver是Spring MVC中ViewResolver的一个实现类。像其他的视图解析器一样, 会接受一个逻辑视图名称,并将其解析为视图。不过在该场景下,视图会是一个Thymeleaf模板。
其中ThymeleafViewResolver中注入了一个对SpringTemplateEngine的引用。SpringTemplateEngine会在Spring中启用Thymeleaf引擎,用来解析模板,并基于这些模板渲染结果。对其注入一个TemplateResolver 的引用。
TemplateResolver会最终定位和查找模板。与之前配置InternalResourceViewResolver类似,使用了prefix和suffix属性。前缀和后缀将会与逻辑视图名组合使用,进而定位Thymeleaf引擎。它的templateMode属性被设置成了HTML5,这表明预期要解析的模板会渲染成HTML5输出。
Thymeleaf在很大程度上就是HTML文件,与JSP不同,没有什么特殊的标签或标签库。Thymeleaf 之所以能够发挥作用,是因为通过自定义的命名空间,为标准的HTML标签集合添加Thymeleaf属性。
无向图,两者之间建立一条边,称为顶点的度。就是跟顶点相连接的边的条数。
有向图,在有向图中把度分为入度和出度。
带权图,每条边都有一个权重。
图最直观的存储方法就是邻接矩阵。
用邻接表来表示一个图虽然简单直观,比较浪费存储空间。如果存储的是稀疏图,就更加浪费空间了。
邻接矩阵的优点在于简单直接,在获取两个顶点关系时非常高效,其次还方便计算。
邻接表存储起来比较节省空间,使用起来比较耗时间。
如果链过长,为了提高查找效率,可以将链表换成其他更加高效的数据结构,比如红黑树。也可以使用其他动态数据结构比如跳表和散列表。
邻接表中存储了用户的关注关系,逆邻接表中存储的是用户的被关注关系。
借助队列。
广度优先搜索的时间复杂度是O(V+E),V表示顶点的个数,E表示边的个数。
广度优先搜索的控件消耗主要在几个辅助变量visited数组、queue队列、prev数组上。这三个存储空间的大小都不会超过顶点的个数。所以空间复杂度是O(V)。
借助栈。
深度优先搜索算法的时间复杂度是O(E),E表示边的个数。
深度优先搜索算法的消耗内存主要是visited、 prev 数组和递归调用栈。visited、 prev 数组的大小跟顶点的个数V成正比,递归调用栈的最大深度不会超过顶点的个数,所以总的空间复杂度就是O(V)。
]]>堆的应用场景非常多,最经典的就是堆排序。堆排序是一种原地,时间复杂度为O(nlogn)的排序算法。在实际软件开发中,快速排序性能要比堆排序好。
用数组存储一个堆。
往堆中插入一个元素后需要继续满足堆的两个特性,就需要进行调整,让其重新满足堆的特性,这个过程就叫做堆化。分为从下往上和从上往下。
堆化就是顺着节点所在的路径从上或从下对比交换。
让新插入的节点与父节点对比大小,如果不满足子节点小于等于父节点的大小关系,就互换两个节点,一直重复这个过程,直到父子节点之间满足大小关系。即为从下往上。
从上往下。
时间复杂度O(nlogn),原地排序算法。堆排序分为两个大步骤,建堆和排序。
有两种思路,
第一种,在堆中插入一个元素,起初堆中只包含一个数据,就是下标为1的数据,然后调用插入操作,将下标从2到n的数据依次插入到堆中,这样就将包含n个数据的数据组织成了堆。
第二种思路,直接从第一个非叶子节点开始,依次堆化。
建堆的时间复杂度分析:O(n)
建堆结束后,数组中数据已经是按照大顶堆的特性来组织的。数组中的第一个元素就是堆顶,也就是最大的元素。不断取出堆顶的元素也就完成了排序。
堆排序的时间复杂度、空间复杂度以及稳定性:
整个堆排序只需要极个别临时存储空间,堆排序是原地排序算法。堆排序包括建堆和排序两个操作。建堆的时间复杂度是O(n),排序过程的时间复杂度是O(nlogn),所以堆排序整体的时间复杂度是O(nlogn)。
堆排序不是稳定的排序算法,因为在排序过程中,存在将堆最后一个节点与堆顶节点互换的操作,所以就有可能改变相同数据原始相对顺序。
如果堆从0开始存储,实际上处理思路是没有变化的,唯一变化的是代码实现时计算子节点和父节点的下标公式改变了。
实际开发中,快排比堆排序性能好的两方面原因。
一,堆排序数据访问的方式没有快速排序友好。
对于快速排序来说,数据是顺序访问的,对于堆排序来说,数据是跳着访问的。这样对CPU缓存是不友好的。
二,对于同样的数据,在排序过程中,堆排序算法的数据交换次数多于快速排序。堆排序的第一步是建堆,建堆的过程会打乱数据原有的相对先后顺序,导致原数据的有序度降低。对于一组已经有序的数据,经过建堆,数据变得更无序了。
堆有几个非常重要的应用:优先级队列,求TopK和求中位数。
队列是先进先出,不过在优先级队列里,出队顺序不是先进先出,而是按照优先级来,优先级最高的最先出队。
一个堆就可以看作是一个优先级队列。
有很多数据结构和算法都依赖它,比如霍夫曼编码,图的最短路径,最小生成树算法等等。很多语言都提供了优先级队列的实现,比如Java的PriorityQueue,C++的priority_queue等。
假设有100个小文件,每个文件的大小是100MB,每个文件中存储的都是有序的字符串。希望将这些100个小文件合并成一个有序的大文件。 这里就会用到优先级队列。
从小文件中取出字符串放入小订堆汇总,堆顶的元素,也就是优先级队列队首的元素就是最小的字符串。将这个字符串放入到大文件中,并将其从堆中删除。再从小文件中取出下一个字符串,放入到堆中。循环这个过程,就可以将100个小文件依次放入到大文件中。
其中,删除堆顶数据和往堆中插入数据的时间复杂度都是O(logn)。
TopK问题分为两类,一类是针对静态数据集合,就是数据集合事先确定,不会再变。另一类是针对动态数据集合,就是数据集合事先并不确定,有数据动态地加入到集合中。
针对静态数据,如何在一个包含n个数据的数组中,查找前K大数据呢?我们可以维护一个大小为K的小顶堆,顺序遍历数组,从数组中取出数据与堆顶元素比较。如果比堆顶元素大,我们就把堆顶元素删除,并且将这个元素插入到堆中;如果比堆顶元素小,则不做处理,继续遍历数组。这样等数组中的数据都遍历完之后,堆中的数据就是前K大数据了。遍历数组需要O(n)的时间复杂度,一次堆化操作需要O(logK)的时间复杂度,所以最坏情况下,n个元素都入堆一次,时间复杂度就是O(nlogK)。
动态数据求得Top K就是实时Top K。一个数据集合中有两个操作,一个是添加数据,另一个询问当前的前K大数据。
实际上,我们可以一直都维护一个K大小的小顶堆,当有数据被添加到集合中时,我们就拿它与堆顶的元素对比。如果比堆顶元素大,我们就把堆顶元素删除,并且将这个元素插入到堆中;如果比堆顶元素小,则不做处理。这样,无论任何时候需要查询当前的前K大数据,都可以立刻返回。
对于静态数据,中位数是固定的,可以先排序取中间就是中位数,面对动态数据集合,中位数不断变动,如果再用先排序的方法,效率就不高了。借助堆这种数据结构,不用排序,就可以非常高效地实现求中位数操作。
需要维护两个堆,一个大顶堆,一个小顶堆。大顶堆中存储前半部分数据,小顶堆中存储后半部分数据,且小顶堆中的数据都大于大顶堆中的数据。
大顶堆的堆顶即为要找的中位数。
当新添加一个数据的时候,如果新加入的数据小于等于大顶堆的堆顶元素,就将这个新数据插入到大顶堆;否则,就将这个新数据插入到小顶堆。
这个时候就有可能出现,两个堆中的数据个数不符合约定的情况:如果n是偶数,两个堆中的数据个数都是n / 2;如果n是奇数,大顶堆有 n / 2 +1 个数据,小顶堆有 n / 2 个数据。这个时候,可以从一个堆中不停地将堆项元素移动到另一个堆,通过这样的调整,来让两个堆中的数据满足约定。
插入数据因为需要涉及堆化,所以时间复杂度为O(logn), 但是求中位数只需要返回大顶堆的堆顶元素,时间复杂度为O(1)。
通过类似的方法可以求任意百分位值。
]]>两种方法,一种是基于指针或引用的二叉链式存储法,一种是基于数组的顺序存储法。
每个节点有三个字段,其中一个存储数据,另外两个是指向左右子节点的指针。
基于数组,把根节点存储在下标i=1的位置,节点X存储在数组中下标i的位置,下标为2 i的位置存储的就是左子节点,下标为2 i+1的位置存储的就是右子节点。反过来下标i / 2的位置就是父节点。这样,只要知道根节点的位置(一般为了方便计算,根节点会存储在下标为1的位置)就可以把整颗树串起来。
如果是非完全二叉树,其实会浪费比较多的数组存储空间。
如果某棵二叉树是一棵完全二叉树,用数组存储是最节省内存的一种方式。因为数组存储不需要像链式存储一样额外存储左右子节点的指针。这也是为什么完全二叉树要求最后一层的子节点都靠左的原因。
堆其实就是一种完全二叉树,最常用的存储方式就是数组。
前序是指先遍历该节点,再遍历左,右节点。后序中序同理。
二叉树的前中后序遍历就是个递归的过程。
二叉树遍历的时间复杂度为:
从遍历顺序图可以看出,每个节点最多会被访问两次,所以遍历的时间复杂度跟节点的个数n成正比,也就是二叉树遍历的时间复杂度为O(n)。
二叉查找树最大的特点就是支持动态数据集合的快速插入删除查找操作。
二叉查找树要求,在树中的任意一个节点,其左子树中每个节点的值,都要小于这个节点的值,而右子树节点的值。
插入操作类似于查找操作。
删除操作比较复杂,针对要删除节点的子节点个数的不同,分为三种情况来处理。
第一种情况是,如果要删除的节点没有子节点,我们只需要直接将父节点中,指向要删除节点的指针置为nll。比如图中的删除节点55。
第二种情况是,如果要删除的节点只有一个子节点(只有左子节点或者右子节点),我们只需要更新父节点中,指向要删除节点的指针,让它指向要删除节点的子节点就可以了。比如图中的删除节点13。
第三种情况是,如果要删除的节点有两个子节点,这就比较复杂了。我们需要找到这个节点的右子树中的最小节点,把它替换到要删除的节点上。然后再删除掉这个最小节点,因为最小节点肯定没有左子节点(如果有左子结点,那就不是最小节点了),所以,我们可以应用上面两条规则(情况一二)来删除这个最小节点。比如图中的删除节点18。
实际上,关于二叉查找树的删除操作,还有个非常简单、取巧的方法,就是单纯将要删除的节点标记为“已删除”,但是并不真正从树中将这个节点去掉。这样原本删除的节点还需要存储在内存中,比较浪费内存空间,但是删除操作就变得简单了很多。而且,这种处理方法也并没有增加插入、查找操作代码实现的难度。
中序遍历二叉查找树,可以输出有序的数据序列,时间复杂度是O(n),非常高效。二叉查找树也叫二叉排序树。
在实际开发中,二叉查找树中存储的都是对象,利用对象的某个字段作为键值(key)来构建二叉查找树,对象中其他字段叫作卫星数据。
如果存储的两个对象键值相同,有两个解决方法。
第一种方法比较容易。二又查找树中每一个节点不仅会存储一个数据, 通过链表和支持动态扩容的数组等数据结构,把值相同的数据都存储在同一个节点上。
第二种不好理解但是更加优雅。每个节点仍然只存储一个数据。在查找插入位置的过程中,如果碰到一个节点的值,与要插入数据的值相同,我们就将这个要插入的数据放到这个节点的右子树,也就是说,把这个新插入的数据当作大于这个节点的值来处理。
当要查找数据的时候,遇到值相同的节点,我们并不停止查找操作,而是继续在右子树中查找,直到遇到叶子节点,才停止。这样就可以把键值等于要查找值的所有节点都找出来。
对于删除操作,我们也需要先查找到每个要删除的节点,然后再按前面讲的删除操作的方法,依次删除。
显然,极度不平衡的二叉查找树,它的查找性能肯定不能满足我们的需求。我们需要构建一种不管怎么删除、插入数据,在任何时候,都能保持任意节点左右子树都比较平衡的二叉查找树,这就是平衡二叉查找树。平衡二叉查找树的高度接近logn,所以插入、删除、查找操作的时间复杂度也比较稳定,是O(logn)。
散列表的插入、删除、查找操作的时间复杂度做到常量级O(1),而二叉查找树在比较平衡的情况下,插入、删除、查找操作时间复杂度才是O(logn)。
相比散列表二叉查找树的优势:
综合这几点,平衡二叉查找树在某些方面还是优于散列表的,所以,这两者的存在并不冲突。在实际的开发过程中,需要结合具体的需求来选择使用哪一个。
红黑树是一种平衡二叉查找树
定义:二叉树中任意一个节点的左右子树高度相差不能大于1.
最先发明出的平衡二叉查找树是AVL树,严格符合定义。是一种高度平衡的二叉查找树。
很多平衡二叉查找树其实并没有严格符合上面的定义(树中任意一个节点的左右子树的高度相差不能大于1)。发明平衡二叉查找树这类数据结构的初衷是,解决普通二叉查找树在频繁的插入、删除等动态更新的情况下,出现时间复杂度退化的问题。平衡二叉查找树中“平衡”的意思,其实就是让整棵树左右看起来比较“对称”、比较“平衡”,不要出现左子树很高、右子树很矮的情况。这样就能让整棵树的高度相对来说低一些, 相应的插入、删除、查找等操作的效率高一些。
红黑树:
暂时先将黑色的空的叶子节点都省略掉了。
“平衡” 的意思可以等价为性能不退化。“近似平衡”就等价为性能不会退化的太严重。
红黑树的高度:
完全二叉树的高度近似log2n, 这里的四叉“黑树”的高度要低于完全二叉树,所以去掉红色节点的“黑树”的高度也不会超过logn。
加上红色节点,最长路径不会超过2logn,也就是红黑树的高度近似2logn。
红黑树的高度只比高度平衡的AVL树的高度(logn)仅仅大了一倍,在性能上,下降得并不多。这样推导岀来的结果不够精确,实际上红黑树的性能更好。
Treap、Splay Tree,绝大部分情况下操作的效率都很高,但是也无法避免极端情况下时间复杂度的退化。尽管这种情况出现的概率不大,但是对于单次操作时间非常敏感的场景来说并不适用。
AVL树是一种高度平衡的二叉树,所以查找的效率非常高,但是,有利就有弊, AVL树为了维持这种高度的平衡,就要付出更多的代价。每次插入、删除都要做调整,就比较复杂、耗时。所以,对于有频繁的插入、删除操作的数据集合,使用AVL树的代价就有点高了。
红黑树只是做到了近似平衡,并不是严格的平衡,所以在维护平衡的成本上,要比AVL树要低。所以,红黑树的插入、删除、查找各种操作性能都比较稳定。对于工程应用来说,要面对各种异常情况,为了支撑这种工业级的应用,更倾向于这种性能稳定的平衡二叉查找树。
红黑树的平衡过程跟魔方复原非常神似,大致过程就是:遇到什么样的节点排布,就对应怎么去调整。只要按照这些固定的调整规则来操作,就能将一个非平衡的红黑树调整成平衡的。
在插入、删除节点的过程中,第三、第四点要求可能会被破坏,“平衡调整”,实际上就是要把被破坏的第三、第四点恢复过来。
左旋与右旋:
红黑树规定,插入的节点必须是红色的。而且,二叉查找树中新插入的节点都是放在叶子节点上。关于插入操作的平衡调整,有这样两种特殊情况。
除此之外,其他情况都会违背红黑树的定义,于是就需要进行调整,调整的过程包含两种基础的操作:左右旋转和改变颜色。
红黑树的平衡调整过程是一个迭代的过程。正在处理的节点叫作关注节点。关注节点会随着不停地迭代处理,而不断发生变化。最开始的关注节点就是新插入的节点。
新节点插入后,如果红黑树平衡被打破,一般会有三种情况,需要根据每种情况的特点不断调整,让红黑树继续符合定义,即继续保持平衡。
case 1:如果关注节点是a,叔叔节点d是红色,依次执行下面操作:
case2:如果关注节点是a,它的叔叔节点d是黑色,关注节点a是其父节点b的右子节点,依次执行下面操作:
CASE 3:如果关注节点是a,它的叔叔节点d是黑色,关注节点a是其父节点b的左子节点,依次执行下面的操作:
删除操作的平衡调整分为两步。
第一步是针对删除节点初步调整。初步调整是保证整颗红黑树在一个节点删除之后,仍然满足最后一条定义的要求,就是每个节点,从该节点到达其可达叶子节点的所有路径都包含相同数目的黑色节点。
第二步是针对关注节点进行二次调整,让它满足红黑树的第三条定义,即不存在相邻的两个红色节点。
经过初步调整后,为了保证满足红黑树定义的最后一条要求,有些节点会被标记成两种颜色。有些节点会被标记成两种颜色,“红黑”或“黑黑”。
case1:如果要删除的节点时a,它只有一个子节点b。依次进行下面的操作:
CASE 2:如果要删除的节点a有两个非空子节点,并且它的后继节点就是节点a的右子节点c。
太多且复杂,有的放矢不记了☺
之所以有这么奇怪的要求就是为了实现起来方便,只要满足这一条要求,在任何时刻,红黑树的平衡操作都可以归结为我们刚刚讲的那几种情况。
给红黑树添加黑色的空的叶子节点,不会比较浪费存储空间。虽然在讲解或者画图的时候,每个黑色的、空的叶子节点都是独立画出来的。实际上,在具体实现的时候,只需要共用一个黑色的、空的叶子节点就行了。
第一点,把红黑树的平衡调整的过程比作魔方复原,不要过于深究这个算法的正确性。
第二点,找准关注节点,不要搞丢、搞错关注节点。
第三点,插入操作的平衡调整比较简单,但是删除操作就比较复杂。针对删除操作,有两次调整,第一次是针对要删除的节点做初步调整,让调整后的红黑树继续满足第四条定义,“每个节点到可达叶子节点的路径都包含相同个数的黑色节点”。但是这个时候,第三条定义就不满足了,有可能会存在两个红色节点相邻的情况。第二次调整就是解决这个问题,让红黑树不存在相邻的红色节点。
]]>哈希算法即为将任意长度的二进制值串映射为固定长度的二进制值串。
哈希算法的一些要求:
常见的加密算法MD5(MD5消息摘要算法),SHA(安全散列算法),DES(数据加密标准),AES(高级加密标准)。
对于加密算法中四点要求中的两点格外重要,一是很难根据哈希值反向推导出原始数据,第二是散列冲突的概率要很小。
哈希算法无法做到零冲突,如MD5,能表示的数据是有限的,最多表示2^128个数据。所以散列冲突的概率要小于1/ 2^128。即便哈希算法存在冲突,在有限的时间和资源下,哈希算法还是很难破解的。
没有绝对安全的加密。越复杂、越难破解的加密算法,需要的计算时间也越长。比如SHA- 256比SHA-1要更复杂、更安全,相应的计算时间就会比较长。密码学界也一直致力于找到一种快速并且很难被破解的哈希算法。在实际的开发过程中,也需要权衡破解难度和计算时间,来决定究竟使用哪种加密算法。
在海量图库中搜索一张图是否存在时,可以给每个图片取唯一标识比如信息摘要,可以在图片的二进制码串开头取100字节,中间100字节,结尾100字节,然后300个字节放在一起,通过哈希算法得到一个哈希字符串,用作图片的唯一标识符。通过这个来判定是否在图库中可以减少很多工作量。
如果还想继续提高效率,我们可以把每个图片的唯一标识,和相应的图片文件在图库中的路径信息,都存储在散列表中。当要查看某个图片是不是在图库中的时候,我们先通过哈希算法对这个图片取唯一 标识,然后在散列表中查找是否存在这个唯一标识。
如果不存在,那就说明这个图片不在图库中;如果存在,我们再通过散列表中存储的文件路径,获取到这个已经存在的图片,跟现在要插入的图片做全量的比对,看是否完全一样。如果一样,就说明已经存在;如果不一样,说明两张图片尽管唯一标识相同,但是并不是相同的图片。
哈希算法有一个特点,对数据很敏感。只要文件块的内容有一丁点儿的改变,最后计算出的哈希值就会完全不同。所以,当文件块下载完成之后,可以通过相同的哈希算法,对下载好的文件块逐一求哈希值,然后跟种子文件中保存的哈希值比对。如果不同,说明这个文件块不完整或者被篡改了,需要再下载这个文件块。
散列函数也是哈希算法的一种应用。相对哈希算法的其他应用,散列函数对于散列算法冲突的要求要低很多。即便出现个别散列冲突,只要不是过于严重,都可以通过开放寻址法或者链表法解决。散列函数对于散列算法计算得到的值,是否能反向解密也并不关心。散列函数中用到的散列算法,更加关注散列后的值是否能平均分布,也就是一组数据是否能均匀地散列在各个槽中。除此之外,散列函数执行的快慢,也会影响散列表的性能,所以,散列函数用的散列算法一般都比较简单,比较追求效率。
负载均衡算法有很多,比如轮询、随机、加权轮询等。实现一个会话粘滞(session sticky)的负载均衡算法。也就是同一个客户端在一次会话中的所有请求都路由到同一个服务器上。需要借助哈希算法高效解决:
对客户端IP地址或者会话ID计算哈希值,将取得的哈希值与服务器列表的大小进行取模运算,最终得到的值就是应该被路由到的服务器编号。这样,我们就可以把同一个IP过来的所有请求,都路由到同一个后端服务器上。
假设有1T日志文件记录了用户搜索关键词,需要快速统计每个词的搜索次数。两个难点,一是搜索日志很大,没法放到一台机器内存中,二是只用一台机器处理,处理时间很长。
可以对数据进行分片,然后采用多态机器并行处理。从搜索记录的日志文件中,依次读出每个搜索关键词,并且通过哈希函数计算哈希值,然后再跟n取模,最终得到的值,就是应该被分配到的机器编号。这样,哈希值相同的搜索关键词就被分配到了同-个机器上。也就是说,同一个搜索关键词会被分配到同一个机器上。每个机器会分别计算关键词出现的次数,最后合并起来就是最终的结果。
这就是MapReduce的基本设计思想。
当图片数太多单机存不下时可以分布式存储。
当要判断一个图片是否在图库中的时候,我们通过同样的哈希算法,计算这个图片的唯一标识,然后与机器个数n求余取模。假设得到的值是k,那就去编号k的机器构建的散列表中查找。
这种方式存在缺陷:当增加缓存服务器时,之前缓存全部作废,后端服务器就会面临巨大压力。
hash(服务器A的IP地址)%(2^32)
对于图片
hash(图片)%2^32
假如B服务器坏掉,也只有部分缓存失效。
现实中要处理的是哈希环的倾斜问题。
某个服务器承担了太多压力。
可以通过虚拟节点来解决这个问题。虚拟节点是实际节点在hash环上的复制品,一个实际节点可以对应多个虚拟节点。
Java Queue中应当使用offer和poll而不是add和remove,原因是
一些队列有大小限制,因此如果想在一个满的队列中加入一个新项,多出的项就会被拒绝。 offer 方法不是对调用 add() 方法抛出一个 unchecked 异常,而只是得到由 offer() 返回的 false。
poll() 方法在用空集合调用时不是抛出异常,只是返回 null。因此新的方法更适合容易出现异常条件的情况。
peek,element区别:element() 和 peek() 用于在队列的头部查询元素。与 remove() 方法类似,在队列为空时, element() 抛出一个异常,而 peek() 返回 null。
Vector是Java早期提供的线程安全的动态数组,如果不需要线程安全,并不建议选择,毕竟同步是有额外开销的。Vector内部是使用对象数组来保存数据,可以根据需要自动的增加容量,当数组已满时,会创建新的数组,并拷贝原有数组数据。
ArrayList是应用更加广泛的动态数组实现,它本身不是线程安全的,所以性能要好很多。与Vector近似,ArrayL ist也是可以根据需要调整容量,不过两者的调整逻辑有所区别,Vector 在扩容时会提高1倍,而ArrayList则是增加50%。
LinkedList顾名思义是Java提供的双向链表,所以它不需要像上面两种那样调整容量,它也不是线程安全的。
Arrays.asList() 方法,这个方法会返回一个 ArrayList 类型的对象。但是用这个对象对列表进行添加删除更新操作,就会UnsupportedOperationException 异常。这个 ArrayList 类并非 java.util.ArrayList 类,而是 Arrays 类的静态内部类!内部类里面并没有 add、remove 方法,这个类继承的 AbstractList 类里面有这些方法。如果是想将一个数组转化成一个列表并做增加删除操作的话以
Arrays.asList() 作为参数新建一个arraylist,真正的arraylist。
Hashtable是早期Java类军提供的一个哈希表实现,本身是同步的,不支持null 键和值,由于同步导致的性能开销,所以已经很少被推荐使用。
HashMap是应用更加广泛的哈希表实现,行为上大致上与HashTable 一致,主要区别在于HashMap不是同步的,支持null键和值等。通常情况下,HashMap进行put或者get 操作,可以达到常数时间的性能,所以它是绝大部分利用键值对存取场景的首选,比如, 实现一个用户ID和用户信息对应的运行时存储结构。不是线程安全的。
TreeMap则是基于红黑树的一种提供顺序访问的Map,和HashMap不同,它的get、put、remove之类操作都是0 (log(n)) 的时间复杂度,具体顺序可以由指定的Comparator来决定,或者根据键的自然顺序来判断。
]]>config的三个作用域 local(default,只对某个仓库有效) global system
git config –list//查看现有配置信息
add到的是git暂存区的作用,commit前的一个区域,比如可以用于暂存工作状态,然后再做修改,比较两次的修改,满意了第二次覆盖,不满意将第一次的提交。暂存区中不合适也可以回退。
因为需要查看暂存区的状态,使用的命令是:
git status
git重命名命令
git mv src dest
git log
git log (–oneline一行显示) (–all所有分支) (-n4最近四个) (–graph绘制图形)
.git目录
HEAD存储指向当前工作的分支
config中记录user的信息,如果设置local配置,则会在此存储local信息。
refs引用存储heads(分支) tags(标签信息)
objects存储对象
commit,tree和blob对象之间的关系。
分离头指针(detached HEAD)指的是HEAD指向一个commit而不是一个分支,很可能会被git当作垃圾清理掉。
HEAD与branch
HEAD不仅可以指向分支,最终是落脚于某个commit,还可以指向之前一次commit。所以HEAD指向commit。HEAD可以用于指代所指commit。
清除不要分支
git branch -D 分支名
最新commit描述不准确,进行修改
git commit –amend//双单杠
老旧commit描述不准确,进行修改
git rebase -i 被修改的父类的id
进入交互式界面用于指定对commit要进行的操作
r
wq!保存并退出进入交互式界面,来修改message信息。
wq!保存并退出
历史多个连续commit合并
和上面一样的操作选择父节点,不过是r变s。
历史不连续commit合并
选择父节点,然后将要和并的不在父节点下的commit显式pick id号,将父节点下要合并的子节点挨着放,s开头。
比较暂存区与HEAD所含文件
git diff –cached
比较工作区和暂存区
git diff
将暂存区恢复成和HEAD一样
git reset HEAD
将工作区文件恢复成暂存区一样(add后又做了变更,然后不满意时)
git checkout – 文件名
取消暂存区部分文件变更
git reset HEAD –文件名
消除最近几次提交
git reset –hard commit_id
这样HEAD和暂存区都指向了commit_id所指的commit
比较不同提交的差异
git diff commit_id1 commit_id2
正确的删除一个文件
git rm 文件名
当开发时临时加了紧急任务时,将自己现在做的修改压入堆栈。
git stash
取回使用apply,取栈顶信息,不出栈。
git stash apply
取回使用pop,栈顶出栈
git stash pop
.gitignore用于指定不需要Git管理的文件,将文件名加在其中或者使用通配符来指定不需要git管理的文件。
git传输的协议分为哑协议和智能协议。在本地也存在git备份,类比于远端库有一个本地库。
配置公私钥 ssh 本地生产公私钥,将公钥粘贴到github中。
git pull是分两步走,先是fetch然后是merge。
当远端有文件,本地没有,这时fetch不是fast-forward方式,要通过merge合并。
也可以用rebase方式。
不同人修改不同文件如何处理
远端新建分支,本地建分支与远端相关联
git chechout -b(切换到新建分支) 本地分支命名 远端分支命名
查看分支情况,本地以及远端
git branch -av
修改相同文件不同区域,同理,可以不用人介入的merge。
当多人修改同一区域的时候,pull(fetch and merge)会失败,自动merge会失败。文件中会显式标出冲突的地方。
此时有两个选择,一,终止merge git merge –abort
二,解决冲突的地方,然后commit 然后push到远端
同时变更了文件名和文件内容,git可以正常的合并。
禁止向集成分支执行push -f操作。
高效搜索github项目。in:readme stars:>1000 advanced search
保证代码质量 fork下来,pull request
organization
项目分支情况 insights->network
merge rebase合并分支
分支和master产生冲突时,master会合并到分支解决冲突,解决完然后再提交pull request,又回到master产生一个commit。
]]>throw是方法内部抛出具体异常类对象的关键字,throws则用在方法上,表示方法调用者可以通过此方法声明向上抛出异常对象。
在Exception中,unchecked异常是运行时异常,它们都继承自RuntimeException,不需要程序进行显式的捕捉和处理。
(1)try代码块:监视代码执行过程,一旦发现异常直接跳转catch,没有catch。则直接跳转至finally。
(2)catch代码块。
(3)finally代码块:try存在时,可以只有catch代码块,也可以只有finally代码块。不管有没有异常发生,即使发生OutOfMemoryError也会执行,通常用于处理善后清理工作。
finally是在return表达式运行后执行的,此时将要return的结果已经被暂存起来,待finally代码块执行结束后再将之暂存的结果返回。
finally代码块中使用return语句使返回值的判断变得复杂,所以避免返回值不可控,不要在finally代码块中使用return语句。
Lock、ThreadLocal、InputStream等这些需要进行强制释放和清除的对象都得在finally代码块中进行显式的清理,避免产生内存泄露,或者资源消耗。
业界有一种争论,甚至可以算是某种程度的共识,Java语言的Cheaked Exception也许是一个设计错误。
(1)Checked Exception的假设是捕获了异常然后恢复程序。但是实际上大多数情况下根本不可能恢复。Checked Exception的使用已经大大偏离了最初的设计目的。
(2)Checked Exception不兼容functional编程。
但是确实有意向异常,比如和环境相关的IO、网络等其实是存在可恢复性的。
从性能角度看Java异常处理机制,有两个比较昂贵的地方:
(1)try-catch代码段会产生额外的性能开销,它往往会影响JVM对代码进行优化,所以建议仅捕获必要的代码段,不要一个大的try包住整段代码;与此同时用异常控制代码流程远比通常意义上的条件语句要低效。
(2)Java每实例化一个Exception,都会对当时的栈进行快照,这是一个相对比较重的操作。如果发生的比较频繁,这个开销就不能忽略了。
file类名字具有一定的误导性,它既能代表一个特定文件的名称,又能代表一个目录下的一组文件的名称。如果指的是一个文件集,就可以对此集合调用list()方法。返回一个字符数组,如果想要取得不同目录列表,只需再创建一个不同的File对象。
File类不仅仅只代表存在的文件或目录。也可以用File对象来创建新的目录或尚不存在的整个目录路径。我们还可以查看文件的特性(如:大小,最后修改日期,读/写)检查某个File对象代表的是一个文件还是一个目录,并可以删除文件。
有时我们必须把来自于“字节”层次结构中的类和“字符”层次结构中的类结合起来使用。为了实现这个目的,要用到“适配器”(adapter)类:InputStreamReader可以把InputStream转换为Reader,而OutputStreamWriter可以把OutputStream转换为Writer。
设计Reader和Writer继承层次结构主要是为了国际化。老的I/ O流继承层次结构仅支持8位字节流,并且不能很好地处理16位的Unicode字符。由于Unicode用于字符国际化(Java本身的char也是16位的Unicode),所以添加Reader和Writer继承层次结构就是为了在所有的I/O操作中都支持Unicode。另外,新类库的设计使得它的操作比旧类库更快。
缓存处理是所有IO操作的基础,术语输入输出只对数据移入移出缓存有意义。
用户空间与内核空间对应内存中不同的位置划分。通常32位Linux内核地址空间划分0~3G为用户空间,3~4G为内核空间。用户空间与内核空间也可以进行内存共享,以避免大量的数据复制。
进程执行操作系统的I/O请求包括数据从缓冲区排出(写操作)和数据填充缓冲区(读操作)。
例如一个磁盘,移动到进程的存储区域(例如RAM)中。首先,进程要求其缓冲通过read()系统调用填满。这个系统调用导致内核向磁盘控制硬件发出一条命令要从磁盘获取数据。磁盘控制器通过DMA直接将数据写入内核的内存缓冲区,不需要主CPU进一步帮助。当请求read()操作时,一旦磁盘控制器完成了缓存的填写,内核从内核空间的临时缓存拷贝数据到进程指定的缓存中。
DMA技术的出现,使得外围设备可以通过DMA控制器直接访问内存,与此同时,CPU可以继续执行程序。DMA控制器获得总线控制权后,CPU即刻挂起或只执行内部操作,由DMA控制器输出读写命令,直接控制RAM与I/O接口进行DMA传输。
虚拟内存是计算机系统内存管理的一种技术。它使得应用程序认为它拥有连续的可用的内存(一个连续完整的地址空间),而实际上,它通常是被分隔成多个物理内存碎片,还有部分暂时存储在外部磁盘存储器上,在需要时进行数据交换。
之所以要从内核空间拷贝到最终用户空间而不直接从磁盘到用户空间是由于虚拟内存的存在。
通过将内核空间地址映射到相同的物理地址作为一个用户空间的虚拟地址。这个缓存同时对内核和用户空间进程可见。
A:close()关闭流对象,但是先刷新一次缓冲区,关闭之后,流对象不可以继续再使用了。close()中会调用flush。
B:flush()仅仅是刷新缓冲区(一般写字符时要用,因为字符是先进入的缓冲区),流对象还可以继续使用
在io中,为了提高效率,通常是在缓存区满时进行一次读写,所以对于缓存区未满的情况下需要手动调用刷新将缓存区数据取出。
InputStream 是字节输入流的所有类的超类,一般我们使用它的子类,如FileInputStream等.一个byte一个byte的读。
InputStreamReader 是字节流通向字符流的桥梁,它将字节流转换为字符流.
bufferedwriter与filewriter的关系。
BufferedWriter
1.有缓冲区(默认8192字符=16384字节)可以通过构造方法来修改(一般不需修改)
2.由于有缓冲区所以效率要比FileWriter高
3.缓冲区能缓存8192个字符满了或者close、flush之后才会进行查码表之后再缓存在StreamEncoder的缓冲区中(8192字节)
4.内部是使用FileWriter来读写的
FileWriter
1.其实内部也有缓冲区(8192字节)
2.FileWriter效率低
3.来一个字符查一次码表缓冲在StreamEncoder的缓冲区中(8192字节)。
读写操作应该close的一个原因是其对文件操作会占用操作系统的文件描述符,操作系统的文件描述符有上限。
]]>散列表用的是数组支持按照下标随机访问数据的特性,所以散列表其实就是数组的一种扩展,由数组演化而来。可以说,如果没有数组,就没有散列表。
将键(关键字)转化为数组下标的映射方法就叫做散列函数(Hash函数),散列函数计算得到的值就是散列值(Hash值)。
散列函数构造的设计基本要求。
对于第三点,即便是业界著名的MD5,SHA,CRC等哈希算法,也无法完全避免散列冲突,数组的存储空间有限,也会加大散列冲突的概率。几乎无法找到一个完美的无冲突的散列函数,即便能找到,付出的时间成本、计算成本也是很大的,所以针对散列冲突问题,需要通过其他途径来解决。
再好的散列函数也无法避免散列冲突,常用的散列冲突解决方法有两类,开放寻址法,链表法。
开放寻址法的核心思想是,如果出现了散列冲突,我们就重新探测一个空闲位置,将其插入。
其中一个简单的实现是线性探测法:插入数据时,如果某个数据经过散列函数散列之后,存储位置已经被占用了,就从当前位置开始,依次往后查找,看是否有空闲位置,直到找到为止。
黄色代表空闲,橙色代表存储了数据
在散列表中查找元素类似于插入过程。通过散列函数求出要查找元素的键值对应的散列值,然后比较数组中下标为散列值的元素和要查找的元素。如果相等,则就是我们要找的元素,否则就顺序往后依次查找。如果遍历到数组中的空闲位置还没有找到,就说明要查找的元素并没有在散列表中。
散列表跟数组一样,不仅支持插入、查找操作,还支持删除操作。对于使用线性探测法解决冲突的散列表,不能单纯把要删除元素设为空。
可以将删除的元素特殊标记为deleted。当线性探测查找遇到标记为deleted的控件,不是停下来而是继续往下探测。
线性探测法的主要问题在于,当散列表中插入的数据越来越多时,散列冲突发生的可能性就会越来越大,空闲位置会越来越少,线性探测的时间越来越久,极端下需要探测整个散列表,最坏情况时间复杂度是O(n)。同理,在删除和查找时,也有可能线性探测整张散列表,才能找到查找或者删除数据。
对于开放寻址冲突解决方法,除了线性探测方法之外,还有另外两种比较经典的探测方法,二次探测(Quadratic probing)和双重散列(Double hashing)。
所谓二次探测,跟线性探测很像,线性探测每次探测的步长是1,那它探测的下标序列就是hash(key)+0, hash(key)+1, hash(key)+2…..二次探测探测的步长就变成了原来的“二次方”,也就是说,它探测的下标序列就是hash(key)+0, hash(key)+1^ 2, hash(key)+2^ 2…..
所谓双重散列,意思就是不仅要使用一个散列函数。 我们使用一组散列函数 hash1(key),hash2(key),hash3(key)….. 先用第一个散列函数, 如果计算得到的存储位置已经被占用,再用第二个散列函数,依次类推,直到找到空闲的存储位置。
不管采用哪种探测方法,当散列表中空闲位置不多的时候,散列冲突的概率就会大大提高。为了尽可能保证散列表的操作效率,一般情况下,我们会尽可能保证散列表中有一定比例的空闲槽位。我们用装载因子(load factor)来表示空位的多少。
装载因子越大,说明空闲位置越少,冲突越多,散列表的性能会下降。
当插入时,只需要通过散列函数计算出对应的散列槽位,将其插入到对应的链表中即可,所以插入的时间复杂度是O(1)。当查找,删除一个元素时,同样通过散列函数计算出对应的槽,这两个操作的时间复杂度与链表的长度k成正比。
常用的英文单词有20万个左右,假设单词的平均长度是10个字母,平均一个单词占用10个字节的内存空间,那20万英文单词大约占2MB的存储空间,就算放大10倍也就是20MB。对于现在的计算机来说,这个大小完全可以放在内存里面。所以我们可以用散列表来存储整个英文单词词典。
当用户输入某个英文单词时,我们拿用户输入的单词去散列表中查找。如果查到,则说明拼写正确;如果没有查到,则说明拼写可能有误,给予提示。借助散列表这种数据结构,我们就可以轻松实现快速判断是否存在拼写错误。
散列表的查询效率不能笼统地说成是O(1),跟散列函数、装载因子、散列冲突都有关系,如果散列函数设计不好,或装载因子过高,都可能导致散列冲突发生概率升高,查询效率下降。极端情况下,一些恶意攻击者可能通过精心构造的数据使得所有数据经过散列函数之后都散列到同一个槽里,这时散列表就会退化为链表,查询时间复杂度从O(1)退化到O(n)。
如果散列表中有10万个数据,退化后的散列表查询的效率就下降了10 万倍。更直接点说,如果之前运行100次查询只需要0.1秒,那现在就需要1万秒。这样就有可能因为查询操作消耗大量CPU或者线程资源,导致系统无法响应其他请求,从而达到拒绝服务攻击(DoS) 的目的。这也就是散列表碰撞攻击的基本原理。
散列函数设计的好坏,决定了散列表冲突的概率大小,也直接决定了散列表的性能。
首先,散列函数的设计不能太复杂。过于复杂的散列函数,势必会消耗很多计算时间,也就间接的影响散列表的性能。
其次,散列函数生成的值要尽可能随机并且均匀分布,这样才能避免或者最小化散列冲突,即便出现冲突,散列到每个槽里的数据也会比较平均,不会出现某个槽内数据特别多的情况。
还需要综合考虑各种因素,包括关键字的长度,特点,分布、还有散列表的大小等。
第一个例子就是学生运动会的例子,通过分析参赛编号的特征,把编号中的后两位作为散列值。还可以用类似的散列函数处理手机号码,因为手机号码前几位重复的可能性很大,但是后面几位就比较随机,可以取手机号的后四位作为散列值。这种散列函数的设计方法,一般叫作“数据分析法”。
第二个例子是Word拼写检查功能,可以将单词中每个字母的ASCII码值进位相加,然后再跟散列表的大小求余,取模,作为散列值。
对于没有频繁插入和删除的静态数据集合来说,我们很容易根据数据的特点、分布等,设计出完美的、极少冲突的散列函数,因为毕竟之前数据都是已知的。对于动态散列表来说,数据集合是频繁变动的,事先无法预估将要加入的数据个数,所以也无法事先申请一个足够大的散列表。随着数据慢慢加入,装载因子就会慢慢变大。当装载因子大到一定程度之后,散列冲突就会变得不可接受。
针对散列表,当装载因子过大时,也可以进行动态扩容,重新申请一个更大的散列表, 将数据搬移到这个新散列表中。假设每次扩容都申请一个原来散列表大小两倍的空间。如果原来散列表的装载因子是0.8,那经过扩容之后,新散列表的装载因子就下降为原来的一半,变成了0.4。针对数组的扩容,数据搬移操作比较简单。但是,针对散列表的扩容,数据搬移操作要复杂很多。因为散列表的大小变了,数据的存储位置也变了,所以需要通过散列函数重新计算每个数据的存储位置。
插入一个数据,最好情况下,不需要扩容,最好时间复杂度是O(1)。最坏情况下,散列表装载因子过高,启动扩容,需要重新申请内存空间,重新计算哈希位置,并且搬移数据,所以时间复杂度是O(n)。用摊还分析法,均摊情况下,时间复杂度接近最好情况,就是O(1)。
对于动态散列表,随着数据的删除,散列表中的数据会越来越少,空间会越来越多。如果对空间消耗敏感,可以在装载因子小于某个值后,启动动态缩容,如果更加在意执行效率,能容忍多消耗一点内存空间,就不用费劲缩容。
装载因子阈值需要选择得当,如果太大,会导致冲突过多,如果太小,会导致内存浪费严重。
在特殊情况下,当装载因子已经到达阈值,需要先进行扩容,再插入数据。这个时候,插入数据就会变得很慢,甚至会无法接受。
极端如当散列表大小为1GB,想要扩容为原来的两倍大小,就要对1GB的数据重新计算哈希值,并且从原来的散列表搬移到新的散列表,十分耗时,一次搬移就会造成用户等待过久。
为了解决一次性扩容耗时过久,可以将扩容操作穿插在插入操作的过程中,分批完成。当装载因子触达阈值之后,只申请新空间,但并不将老的数据搬移到新散列表。
当有新数据要插入时,将新数据插入到新散列表中,并且从老的散列表中拿出一个数据放入到新散列表。每次插入一个数据到散列表,都重复操作。经过多次插入操作之后,老的散列表中的数据就一点一点搬移到新散列表中了。这样没有集中的一次性数据搬移,插入操作就都变得很快了。
对于查询操作,先从新散列表中查找,如果没有找到,再去老的散列表查找。
通过这样的均摊方法,将一次性扩容的代价,均摊到多次插入操作,避免了一次性扩容耗时过多。任何情况下,插入一个数据的时间复杂度都是O(1)。
Java中LinkedHashMap采用链表法解决冲突,ThreadLocalMap是通过线性探测的开放寻址法来解决冲突。
优点:
开放寻址法不像链表法,需要拉很多链表。散列表中的数据都存储在数组中,可以有效地利用CPU缓存加快查询速度。而且,这种方法实现的散列表,序列化起来比较简单。链表法包含指针,序列化起来就没那么容易。
缺点:用开放寻址法解决冲突的散列表,删除数据的时候比较麻烦,需要特殊标记已经删除掉的数据。而且,在开放寻址法中,所有的数据都存储在一个数组中, 比起链表法来说,冲突的代价更高。所以,使用开放寻址法解决冲突的散列表,装载因子的上限不能太大。这也导致这种方法比链表法更浪费内存空间。
总结:当数据量较小,装载因子小时,适合采用开放寻址法。
因为链表节点可以在需要时再创建,并不需要事先申请好,所以链表法对内存的利用率比开放寻址法要高。
链表法对比开放寻址法对大装载因子容忍度更高。开放寻址法只适用于装载因子小于1的情况。接近1时,就可能会有大量的散列冲突。对于链表法,也只是链表长度变长了,虽然查找效率有所下降,但是比顺序查找快很多。
由于链表中的节点时零散分布在内存中不是连续的,所以对CPU缓存是不友好的,对于执行效率有一定的影响。
对链表法稍加改造就可以实现一个更加高效的散列表。将链表改造成其他高效的动态数据结构,即便出现散列冲突,极端情况下,所有数据都散列到一个桶内,最终退化的散列表查找时间也不过是O(logn)。就有效避免了散列碰撞攻击。
总结:基于链表的散列冲突处理方法比较适合存储大对象、大数据量的散列表,而且,比起开放寻址法,它更加灵活,支持更多的优化策略,比如用红黑树代替链表。
Java中的HashMap。
HashMap默认的初始大小是16,当然这个默认值是可以设置的,如果事先知道大概的数据量有多大,可以通过修改默认初始大小,减少动态扩容的次数,这样会大大提高HashMap的性能。
最大装载因子默认是0.75,当HashMap中元素个数超过0.75*capacity (capacity 表示散列表的容量)的时候,就会启动扩容,每次扩容都会扩容为原来的两倍大小。
HashMap底层采用链表法来解决冲突。即使负载因子和散列函数设计得再合理,也免不了会出现拉链过长的情况,- -旦出现拉链过长,则会严重影响HashMap的性能。
在JDK1.8版本中,为了对HashMap做进一步优化, 引入了红黑树。而当链表长度太长(默认超过8)时,链表就转换为红黑树。可以利用红黑树快速增删改查的特点,提高HashMap的性能。当红黑树结点个数少于8个的时候,又会将红黑树转化为链表。因为在数据量较小的情况下,红黑树要维护平衡,比起链表来,性能上的优势并不明显。
散列函数设计的并不复杂,追求的是简单高效,分布均匀。
散列表与链表经常放在一起使用。
缓存系统包括三个操作:添加(先要查找是否存在),删除,查找数据。都需要进行查找操作。只使用链表,时间复杂度是O(n),散列表+链表,时间复杂度O(1)。
查找:散列表中查找数据的时间复杂度接近O(1)。通过散列表,可以很快地在缓存中找到一个数据,当找到后还需要将它移动到双向链表的尾部。
删除:需要找到数据所在结点,将结点删除。借助散列表,可以在O(1)时间内找到要删除的节点。因为链表是双向链表,删除结点只需要O(1)时间复杂度。
添加:添加到缓存稍微有点麻烦,需要先看是否在缓存中,如果已经在其中,需要将其移动到双向链表的尾部,如果不在,就要看缓存有没有满,如果满了,则将双向链表头部结点删除,然后再将数据放到链表尾部。如果没有满就直接将数据放到链表的尾部。
Redis有序集合的操作就是下面这些:
1.添加一个成员对象。
2.按照键值来删除一个成员对象。
3.按照键值来查找一个成员对象。
4.按照分值区间查找数据,比如查找在[100,356]之间的成员对象。
5.按照分值从小到大排序成员变量。
如果只采用跳表,按key来删除,查询就会很慢。可以再按照键值构建一个散列表,这样按照key来删除,查找一个成员对象的时间复杂度就变成了O(1)。
Linked并不仅仅代表它是通过链表法解决散列冲突的。
这段代码的打印结果是1,2,3,5.
每次调用put函数添加数据时,都会将数据添加到尾部。
插入key=3时,已存在,将原来的删除,并将新的放在尾部。
当访问key为5时,将被访问的数据移动到链表的尾部。
可以发现与LRU缓存策略一模一样。
LinkedHashMap是通过双向链表和散列表组合的,Linked实际是指双向链表。
为什么散列表和链表经常一块使用?
散列表这种数据结构虽然支持非常高效的数据插入、删除、查找操作,但是散列表中的数据都是通过散列函数打乱之后无规律存储的。也就说,它无法支持按照某种顺序快速地遍历数据。如果希望按照顺序遍历散列表中的数据,那我们需要将散列表中的数据拷贝到数组中,然后排序,再遍历。
因为散列表是动态数据结构,不停地有数据的插入、删除,所以每当我们希望按顺序遍历散列表中的数据的时候,都需要先排序,那效率势必会很低。为了解决这个问题,我们将散列表和链表(或者跳表)结合在一起使用。
]]>二分查找是依赖数组随机访问的特性。如果数据是存储在链表中,则要对链表进行改造,支持类似二分的查找算法。改造后的数据结构叫做跳表。
跳表是一种各方面性能都比较优秀的动态数据结构。可以支持快速的插入删除查找,甚至可以替代红黑树。
Redis中的有序集合(Sorted Set)就是用跳表来实现的。
对于单链表查找数据只能从头到尾遍历,O(n)。
加上一层索引之后,查找一个结点需要遍历的结点数减少了,也就是查找效率提高了。
当链表的长度比较大时,在构建索引之后,查找效率的提升就会非常明显。
这种链表加多级索引的结构就是跳表。
O(logn)
等比数列求和。空间复杂度还是O(n)。
在实际的软件开发中,原始链表存储的可能是很大的对象,索引结点只需要存储关键值和几个指针,并不需要存储对象,所以当对象比索引节点大很多时,索引占用的额外空间就可以忽略了。
插入操作时间复杂度也是O(logn)。先要查找要插入的位置,再插入结点。
删除操作是,如果这个结点在索引中也有出现,除了删除原始链表汇总的结点,还要删除索引中的。在查找要删除的结点的时候,一定要获取前驱结点。如果使用的是双向链表,就不用考虑这个问题。
如果不停插入数据而不更新索引,就有可能出现某两个索引节点之间数据非常多的情况,极端情况下,跳表会退化为单链表。
可以通过随机函数来维护平衡性。
随机函数生成了值K,那么久将这个结点添加到第一级到第K级这K级索引中。
Redis有序集合支持的核心操作包括:(1)插入(2)删除(3)查找(4)按照区间查找数据(5)迭代输出有序序列
其中,插入、删除、查找以及迭代输岀有序序列这几个操作,红黑树也可以完成,时间复杂度跟跳表是一样的。但是,按照区间来查找数据这个操作,红黑树的效率没有跳表高。
]]> 依赖注入
方式1:构造器注入。
创建应用组件之间协作行为通常称为装配(wiring)。
可以用xml或java实现。
Spring通过应用上下文(Application Context)装载bean的定义并把它们组装起来。Spring应用上下文全权负责对象的创建和组装。Spring自带了多种应用上下文的实现,它们之间主要的区别仅仅在于如何加载配置。
对于XML使用CLassPathXMLApplication,对于Java配置,使用AnnotationConfigApplicationContext。
应用切面
DI能够让相互协作的软件组件保持松散耦合,而面向切面编程(AOP)允许你把遍布应用各处的功能分离出来形成可重用的组件。
可以把切面想象为覆盖在很多组件上的一个外壳。应用是由那些实现各自业务功能的模块组成的。
可以在xml中将某个bean声明为一个切面。
使用模板消除样板式代码
比如jdbcTemplate。
容纳Bean
在基于Spring的应用中,应用对象生存于Spring容器(container)中。Spring容器负责创建对象,装配它们,配置它们并管理它们的整个生命周期,从生存到死亡。
容器是 Spring框架的核心。 Spring容器使用DI管理构成应用的组件,它会创建
相互协作的组件之间的关联。毫无疑问,这些对象更简单干净,更易于理解,更易于重用并且更易于进行单元测试。
spring自带了多种容器实现,归为两种类型,bean工厂与应用上下文。bean工厂对于大多数应用来说往往太低级,因此,应用上下文要比bean工厂更受欢迎。
使用应用上下文
无论是从文件系统中装载应用上下文还是从类路径下装载应用上下文,将bean加载到bean工厂的过程都是相似的。区别在于前者在指定的文件系统路径下查找xml文件,后者是在所有的类路径(包括JAR文件)下查找xml文件。AnnotationConfigApplicationContext通过一个配置类加载bean。
应用上下文准备就绪之后,就可以调用上下文的getBean()方法从Spring容器中获取bean。
bean的生命周期
在传统的Java应用中,bean的生命周期很简单。使用Java关键字new进行bean实例化,然后该bean就可以使用了。一旦该bean不再被使用,则由Java自动进行垃圾回收。相比之下,Spring容器中的bean的生命周期就显得相对复杂多了。
Spring核心容器
容器是Spring框架最核心的部分,它管理着Spring 应用中bean的创建、配置和管理。在该模块中,包括了Spring bean 工厂,它为Spring提供了DI的功能。基于bean工厂,我们还会发现有多种Spring应用上下文的实现,每一种都提供了配置Spring的不同方式。所有的Spring模块都构建于核心容器之上,当配置应用时,其实隐式地使用了这些类。
Spring的AOP模块
在AOP模块中,Spring 对面向切面编程提供了丰富的支持。这个模块是Spring 应用系统中开发切面的基础。与DI一样, AOP可以帮助应用对象解耦。借助于AOP,可以将遍布系统的关注点(例如事务和安全)从它们所应用的对象中解耦出来。
在Spring中,对象无需自己查找或创建与其所关联的其他对象。容器负责把需要相互协作的对象引用赋予各个对象。创建应用对象之间协作关系的行为称为装配,也是依赖注入的本质。
Spring提供三种主要的装配机制:
作者建议尽可能地使用自动配置机制,显式配置越少越好,当必须要显式配置bean时,推荐使用类型安全并且比XML更加强大的JavaConfig。最后,只有想使用便利的XML命名空间,并且在JavaConfig中没有同样的实现时,才应该使用XML。
Spring从两个角度来实现自动化装配:
组件扫描和自动装配组合在一起就能发挥出强大的威力,能够将显式配置降低到最少。
@Component注解表明该类会作为组件类,并告知Spring要为这个类创建bean。
不过组件扫描默认是不启用的,还需要显式配置一下Spring,从而命令它去寻找带有@Component注解的类,并为其创建bean。
@ComponentScan注解可以在Spring中启用组件扫描。如果没有其他配置的话,@ComponentScan默认会扫描与配置类相同的包,Spring将会扫描这个包以及包下的所有子包,查找带有@Component注解的类并且在Spring中自动为其创建一个bean。
还可以使用XML来启动组件扫描。
还是基于Java的配置用的多,喜好问题。
一个实例:
@ContextConfiguration注解说明需要在CDPlayerConfig中加载配置。因为CDPlayerConfig类中包括了@ComponentScan,所有带有@Component注解的类都会创建为bean。
Spring应用上下文中所有的bean都会给定一个ID,就是将类名的第一个字母变为小写。也可以使用@Component为bean设置不同的ID。
默认扫描的是配置类所在的包作为基础包(base package)来扫描组件。
可以指定多个包,扫描多个包。但是basePackages有一个问题是其是字符串,所以当包不存在时IDE也不会第一时间报错。可以设置basePackageClasses,设置数组中包含了类,这些类所在的包将会作为组件扫描的基础包。
自动装配就是让Spring自动满足bean依赖的一种方法,在满足依赖的过程中,会在Spring应用上下文中寻找匹配某个bean需求的其他bean。可以借助@Autowired注解进行自动装配。
@Autowired注解不仅能够用在构造器上,还能用在属性的Setter方法上。如:
在Spring初始化bean之后,会尽可能去满足bean的依赖。不管是构造器,Setter还是其他的方法,Spring都会尝试满足方法参数上所声明的依赖,假设有且只有一个bean匹配依赖的话,那么这个bean将会被装配进来,如果没有匹配的bean,那么在应用上下文创建时,Spring会抛出一个异常,也可以将@Autowired的required属性设置为false避免抛异常,当设置为false时,Spring会尝试执行自动装配,但是如果没有匹配的bean的话,Spring将会让这个bean处于未装配的状态,此时需要谨慎对待这个bean,如果代码没有进行null检查的话,这个处于未装配的属性有可能会出现NullPointerException。
如果有多个bean都能满足依赖关系的话,Spring将会抛出一个异常,表明没有明确指定要选择哪个bean进行自动装配。
@Autowired是Spring特有的注解。如果你不愿意在代码中到处使用Spring的特定注解来完成自动装配任务的话,可以考虑将其替换为@Inject。
@Inject注解来源于Java依赖注入规范,该规范同时还定义了@Named注解。在自动装配中,Spring 同时支持@Inject和@Autowired. 尽管@Inject和@Autowired之间有着一些细微的差别, 但是在大多数场景下,它们都是可以互相替换的。
尽管在很多场景下通过组件扫描和自动装配实现Spring的自动化配置是更为推荐的方式,但有时候自动化配置的方案行不通,因此需要明确配置Spring。比如说,想要将第三方库中的组件装配到应用中,在这种情况下,是没有办法在它的类上添加@Component和@Autowired注解的,因此就不能使用自动化装配的方案了。
此时就需要采用显示装配的方式,分为Java和XML。在进行显式配置时,JavaConfig是更好的方案,因为其更为强大、类型安全并且对重构友好。因为它就是Java代码,就像应用程序中的其他Java代码一样。同时JavaConfig与其他的Java代码又有所区别,JavaConfig是配置代码,这意味着它不应该包含任何业务逻辑,JavaConfig也不应该侵入到业务逻辑代码之中,通常会将JavaConfig放到单独的包中,使它与其他的应用程序逻辑分离开。
创建JavaConfig类的关键在于为其添加@Configuration注解,@Configuration注解表明这个类是一个配置类, 该类应该包含在Spring应用上下文中如何创建bean的细节。对于显式配置,将@ConponentScan注解移除。此时那些bean不会被发现,配置类也没有作用了。
要在JavaConfig中声明bean,需要编写一个方法,这个方法会创建所需类型的实例,然后给这个方法添加@Bean注解。
@Bean注解告诉Spring方法返回一个对象,该对象要注册为Spring应用上下文中的bean。方法体中包含了最终产生bean实例的逻辑。
在JavaConfig中装配bean的最简单方式就是引用创建bean的方法。
通过调用方法来引用bean令人困惑,还有一种理解起来更为简单的方式:
这里使用构造器实现了DI功能,但是完全可以使用其他风格的DI配置,比如可以通过Setter方法注入:
带有@Bean注解的方法可以采用任何必要的Java功能来产生bean实例。构造器和Setter方法只是@Bean方法的两个简单样例。
在装配bean时还可以选择XML,现在已经不太合乎大家的心意了,但是在Spring中已经有很长的历史了。
在XML配置中,意味着要创建一个XML文件。并且要以
关于混合配置,首先不要在意装配的bean来自哪里,自动装配会考虑到Spring容器中所有bean,不管是在JavaConfig或XML中声明还是组件扫描获得的。
可以有一个更高级别的配置类,在类中使用@Import将两个配置类组合在一起:
假如配置在了XML中,则Spring可以使用@ImportResource注解加载。
两个bean,配置在JavaConfig中以及配置在XML中都会被加载到Spring容器中。
不管使用JavaConfig还是使用XML进行装配,通常都会创建一个根配置(root configuration),这个配置会将两个或更多的装配类和或XML文件组合起来。也会在根配置中启用组件扫描(通过context:component-scan或@ComponentScan)。
要使用profile,首先要讲所有不同的bean定义整理到一个或多个profile之中,将应用部署到每个环境时,要确保对应的profile处于激活(active)的状态。在Java配置中,可以使用@Profile注解指定某个bean属于哪个profile。
@Profile注解应用在了类级别上,它会告诉Spring这个配置类中的bean只有在dev profile激活时才会被创建,如果没有激活的话,那么带有@Bean注解的方法都会被忽略掉。
Spring在确定哪个profile处于激活状态时,需要依赖两个独立的属性:spring.profiles.active和spring.profiles.default. 如果设置了spring.profiles.active属性的话,那么它的值就会用来确定哪个profile 是激活的。但如果没有设置spring .profiles.active属性的话,那Spring 将会查找spring.profiles.default的值。如果spring.profiles.active 和spring.profiles.default均没有设置的话,那就没有激活的profile,因此只会创建那些没有定义在profile中的bean。
有多种方式来设置这两个属性:
1.作为DispatcherServlet的初始化参数;
2.作为Web应用的上下文参数;
3.作为JNDI条目;
4.作为环境变量;
5.作为JVM的系统属性;
6.在集成测试类上,使用@ActiveProfiles注解设置。
@Conditional注解可以用到带有@Bean注解的方法上,如果给定的条件计算结果为true,就会创建这个bean,否则,这个bean会被忽略。
设置给@Conditional的类可以是任意实现了Condition接口的类型。这个接口实现起来很简单直接,只需提供matches()方法实现即可。如果matches()方法返回true,就会创建带有@Conditional注解的bean,返回false则不会创建这些bean。
通过ConditionContext,可以做到:
AnnotatedTypeMetadata则能够让我们检查带有@Bean注解的方法还有其他什么注解。借助isAnnotated()方法,我们能够判断带有@Bean注解的方法是不是还有其他特定的注解。借助其他的那些方法,我们能够检查@Bean注解的方法上其他注解的属性。
@Profile注解如下所示:
@Profile本身也使用了@Conditional注解,并且在做出决策的过程中,考虑到了ConditionContext和AnnotatedTypeMetadata中的多个因素。
仅有一个bean匹配所需的结果时,自动装配才是有效的,如果不仅有一个bean能匹配结果的话,这种歧义性会阻碍Spring自动装配属性,构造器参数或方法参数。可以将可选bean中的某一个设为首选(primary)的bean,或者使用限定符(qualifier)来帮助Spring将可选的bean的范围缩小到只有一个bean。
使用@Primary注解
如果是通过Java配置显式声明,应该如下:
使用XML
Spring的限定符能够在所有可选的bean上进行缩小范围的操作,最终能够达到只有一个bean满足所规定的限制条件。如果将所有的限定符都用上后依然存在歧义性,那么可以继续使用更多的限定符来缩小选择范围。
@Qualifier注解是使用限定符的主要方式,它可以与@Autowired和@Inject协同使用,在注入时指定想要注入进去的是哪个bean。
@Qualifier注解所设置的参数就是想要注入的bean的ID。
自己为bean设置自己的限定符。在bean声明上添加@Qualifier注解。
在注入的地方,只要引用cold限定符就可以了。
通过Java配置显式定义bean时,@Qualifier也可以与@Bean注解一起使用:
比如自定义@Cold注解。
在注入点使用必要的限定符注解进行任意组合,从而将可选范围缩小到只有一个bean满足需求。
通过声明自定义的限定符注解,可以同时使用多个限定符。
在默认情况下,Spring 应用上下文中所有bean都是作为以单例(singleton) 的形式创建的。也就是说,不管给定的一个bean被注人到其他bean多少次,每次所注入的都是同一个实例。
Spring定义了多种作用域,可以基于这些作用域创建bean,包括:
1.单例( Singleton):在整个应用中,只创建bean的一个实例。
2.原型( Prototype):每次注入或者通过Spring应用上下文获取的时候,都会创建一个新的bean实例。
3.会话(Session): 在Web应用中,为每个会话创建一个bean实例。
4.请求(Rquest):在Web应用中,为每个请求创建一个bean实例。
这里使用ConfigurableBeanFactory类的SCOPE_ PROTOTYPE 常量设置了原型作用城。当然也可以使用@Scope (“prototype”),但是使用SCOPE_PROTOTYPE常量更加安全并且不易出错。
java配置中声明为原型bean则使用:
XML配置bean,使用
就购物车bean而言,会话作用域是最为合适的。
proxyMode属性的配置表明这个代理要实现ShoppingCart接口,并将调用委托给实现bean。
如果ShoppingCart是接口而不是类的话,这是可以的(最为理想的代理模式)。但如果ShoppingCart是一个具体的类的话, Spring 就没有办法创建基于接口的代理了。此时,它必须使用CGLib来生成基于类的代理。所以,如果bean类型是具体类的话,我们必须要将proxyMode属性设置为ScopedProxyMode .TARGET_ CLASS,以此来表明要以生成目标类扩展的方式创建代理。
aop:scoped-proxy是与@Scope注解的proxyMode属性功能相同的SpringXML配置元素。它会告诉Spring为bean创建一个作用域代理。默认情况下,它会使用CGLib创建目标类的代理。但是我们也可以将proxy-target-class 属性设置为false,进而要求它生成基于接口的代理。
当讨论依赖注入的时候,我们通常所讨论的是将一个 bean引用注入到另一个bean的属性或构造器参数中。它通常来讲指的是将一个对象与另一个对象进行关联。
bean装配的另外一个方面指的是将一个值注入到bean的属性或者构造器参数中。Spring提供了两种在运行时求值的方式:
1.属性占位符
2.Spring表达式语言(SpEL)
处理外部值的最简单方式就是声明属性源并通过Spring的Environment来检索属性。
Spring一直支持将属性定义到外部的属性的文件中,并使用占位符值将其插入到Spring bean中,占位符的形式为”${…}”
XML配置没有使用任何硬编码的值。
在软件开发中,散布于应用多处的功能被称为横切关注点。这些横切关注点从概念上是与应用的业务逻辑相分离的。把这些横切关注点与业务逻辑相分离正是面向切面编程(AOP)所要解决的问题。
如果要重用通用功能的话,最常见的面向对象技术是继承或委托。但是如果在应用中都使用相同的基类,继承往往会导致一个脆弱的对象体系;使用委托可能需要对委托对象进行复杂得调用。切面提供了取代继承和委托的另一种可选方案,在很多场景下更清晰简洁,在使用面向切面编程时,我们仍然在一个地方定义通用功能,但是可以通过声明的方式定义这个功能要以何种方式在何处应用,而无需修改受影响的类。横切关注点可以被模块化为特殊的类,这些类被称为切面。
这样做有两个好处:首先,现在每个关注点都集中于一个地方,而不是分散到多处代码中;其次,服务模块更简洁,因为它们只包含主要关注点(或核心功能)的代码,而次要关注点的代码被转移到切面中了。
通知(Advice):
在AOP术语中,切面的工作被称为通知。
Spring切面可以应用5种类型的通知:
连接点(Join point):
切面代码可以利用这些点插入到应用的正常流程之中,并添加新的行为。
切点(Poincut):
如果说通知定义了切面的“什么”和“何时”的话,那么切点就定义了“何处”。
切面(Aspect):
切面是通知和切点的结合。通知和切点共同定义了切面的全部内容一它是什么 ,在何时和何处完成其功能。
引入(Introduction):
引入允许我们向现有的类添加新方法或属性。
织入(Weaving):
织人是把切面应用到目标对象并创建新的代理对象的过程。切面在指定的连接点被织入到目标对象中。在目标对象的生命周期里有多个点可以进行织入:
Spring 提供了4种类型的AOP支持:
Spring通知是Java编写的
Spring所创建的通知都是用标准的Java类编写的。可以使用与普通Java开发一样的(IDE) 来开发切面。定义通知所应用的切点通常会使用注解或在Spring配置文件里采用XML来编写,这两种语法对于Java开发者来说都是相当熟悉的。
AspectJ与之相反。虽然AspectJ现在支持基于注解的切面,但AspectJ 最初是以Java语言扩展的方式实现的。这种方式有优点也有缺点。通过特有的AOP语言,可以获得更强大和细粒度的控制,以及更丰富的AOP工具集,但是需要额外学习新的工具和语法。
Spring在运行时通知对象
通过在代理类中包裹切面,Spring在运行期把切面织入到Spring管理的bean中。代理类封装了目标类,并拦截被通知方法的调用,再把调用转发给真正的目标bean。当代理拦截到方法调用时,在调用目标bean方法前,会执行切面逻辑。
直到应用需要被代理的bean 时,Spring 才创建代理对象。如果使用的是ApplicationContext的话,在ApplicationContext从BeanFactory中加载所有bean的时候,Spring 才会创建被代理的对象。因为Spring运行时才创建代理对象,所以我们不需要特殊的编译器来织人Spring AOP的切面。
Spring只支持方法级别的连接点
因为Spring基于动态代理,所以Spring只支持方法连接点。这与一些其他的AOP框架是不同的,例如AspectJ和JBoss,除了方法切点,还提供了字段和构造器接入点。Spring缺少对字段连接点的支持,无法让我们创建细粒度的通知,例如拦截对象字段的修改。而且它不支持构造器连接点,无法在bean创建时应用通知。
但是方法拦截可以满足绝大部分的需求。如果需要方法拦截之外的连接点拦截功能,那么我们可以利用Aspect来补充Spring AOP的功能。
在SpringAOP中,要使用AspectJ的切点表达式语言来定义切点。
使用execution ()指示器选择Performance的perform()方法。方法表达式以“*”号开始,表明不关心方法返回值的类型。然后指定了全限定类名和方法名。对于方法参数列表,使用两个点好(..)表明切点要选择任意的perform()方法,无论该方法的入参是什么。
Spring引入一个新的bean()指示器,允许在切点表达式中使用bean的ID来标识bean。
可以使用@Pointcut注解设置一个切点表达式。
performance ()方法的实际内容并不重要,在这里它实际上应该是空的。其实该方法本身只是一个标识,供@Pointcut注解依附。
接下来在JavaConfig的配置类级别上通过使用@EnableAspectJAutoProxy注解启动自动代理功能。
XML装配bean需要使用Spring aop。
环绕通知是最为强大的通知类型。它能够让你所编写的逻辑将被通知的目标方法完全包装起来。实际上就像在一个通知方法中同时编写前置通知和后置通知。
@Around注解表明方法作为切点的环绕通知。这个通知所达到的效果与之前的前置通知和后置通知是一样的。现在它们在同一个方法中,不像之前分散。
它接受ProceedingJoinPoint作为参数。这个对象是必须要有的,因为要在通知中通过它来调用被通知的方法。通知方法中可以做任何的事情,当要将控制权交给被通知的方法时,它需要调用ProceedingJoinPoint的proceed()方法。
别忘记调用proceed()方法,如果不调用这个方法,通知实际上会阻塞对被通知方法的调用。
利用被称为引入的AOP概念,切面可以为Spring bean添加新方法。
@DeclareParents注解由三部分组成:
1.value属性指定了哪种类型的bean要引入该接口。在本例中,也就是所有实现Performance的类型。(标记符后面的加号表示是Performance的所有子类型,而不是Performance本身。)
2.defaultImpl属性指定了为引入功能提供实现的类。在这里,我们指定的是De faultEncoreable提供实现。
3.@DeclareParents注解所标注的静态属性指明了要引入了接口。在这里,我们所引入的是Encoreable接口。
如果需要声明切面,但又不能Wie通知类添加注解时,就必须转向XML配置了。
使用aop:pointcut定义命名切点
现在切点是在一个地方定义的,并且被多个通知元素所引用。
delegate-ref属性引用了一个Spring bean作为引入的委托。这需要在Spring上下文中存在一个ID为encoreableDelegate的bean。
使用default-impl来直接标识委托和间接使用delegate-ref 的区别在于后者是Spring bean,它本身可以被注人、通知或使用其他的Spring配置。
虽然Spring AOP能够满足许多应用的切面需求,但是与AspectJ相比,Spring AOP是一个功能比较弱的AOP解决方案。AspectJ提供了Spring AOP所不能支持的许多类型的切点。
AOP是面向对象编程的一个强大补充。通过AspectJ,我们现在可以把之前分散在应用各处的行为放人可重用的模块中。我们显示地声明在何处如何应用该行为。这有效减少了代码冗余,并让我们的类关注自身的主要功能。
Spring提供了一个AOP框架,让我们把切面插人到方法执行的周围。现在我们已经学会如何把通知织人前置、后置和环绕方法的调用中,以及为处理异常增加自定义的行为。
关于在Spring应用中如何使用切面,我们可以有多种选择。通过使用@AspectJ注解和简化的配置命名空间,在Spring中装配通知和切点变得非常简单。
最后,当Spring AOP不能满足需求时,我们必须转向更为强大的AspectJ。对于这些场景,我们了解了如何使用Spring为AspectJ切面注入依赖。
]]>