1. Java基础
StringBuilder类与string类的区别
- String 类不可变,内部维护的char[] 数组长度不可变,为final修饰,String类也是final修饰,不存在扩容。字符串拼接,截取,都会生成一个新的对象。频繁操作字符串效率低下,因为每次都会生成新的对象。
- StringBuilder 类内部维护可变长度char[] , 初始化数组容量为16,存在扩容, 其append拼接字符串方法内部调用System的native方法,进行数组的拷贝,不会重新生成新的StringBuilder对象。非线程安全的字符串操作类, 其每次调用 toString方法而重新生成的String对象,不会共享StringBuilder对象内部的char[],会进行一次char[]的copy操作。
Java类的实例化过程
- 初始化父类静态变量、静态代码块(静态变量和静态代码块的初始化顺序由编写顺序决定)
- 初始化子类静态变量、静态代码块(静态变量和静态代码块的初始化顺序由编写顺序决定)
- 初始化父类非静态变量、非静态代码块(非静态变量和非静态代码块的初始化顺序由编写顺序决定)
- 初始化父类构造函数
- 初始化子类非静态变量、非静态代码块(非静态变量和非静态代码块的初始化顺序由编写顺序决定)
- 初始化子类构造函数
接口设计(六大)原则
-
单一职责原则:应该有且仅有一个原因引起类的变更。如:一个图形类中包含了draw() 绘画功能和 area(), setWidth(), setHeight() 等图形自身的属性。这样的话 如果图形属性的计算方式发生改变,则这个类就要做出对应的修改。同样的,如果图形的绘画功能做出改变 那么这个类也要同步的做出修改。这样这个类其实已经开始违反SRP原则,随着Graphical类负责的职责越来越多,那么该类引起变化的原因也越来越多。就等于把这些职责耦合在一起了,这种耦合很容易引起脆弱的设计。
用职责或变化原因来衡量接口或类,但职责或变化原因都是不可度量的,因项目而异。不能为了单一而单一,实现类就剧增了,或者使用聚合和组合,人为制造了系统的复杂性。 -
里氏替换原则(Liskov Substitution Principle, 简称LSP。):只要父类能出现的地方子类都可以出现,而且替换为子类也不会产生任何错误或异常。
- 子类必须完全实现父类的方法
- 子类可以有自己的个性(属性和方法)。
- 覆盖或实现父类的方法时输入参数可以被放大。
- 覆写或实现父类的方法时输出结果可以被缩小。
注:在类中调用其他类时务必要使用父类或接口,如果不能使用父类或接口,则说明类的设计已经违背了LSP原则。
-
依赖倒置原则(Dependence Inversion Principle, 简称DIP):精简的定义: 面向接口编程。
- 高层模块不应该依赖低层模块,两者都应该依赖其抽象。
- 抽象不应该依赖细节。
- 细节应该依赖抽象。
-
接口隔离原则:保证接口的纯结性
- 接口要尽量小。
- 接口要高内聚。
- 定制服务。
- 接口的设计是有限度的。
-
迪米特法则Law of Demeter, LOD。又称最少知识原则(Least Knowledge Principle, LKP)。通俗来讲:一个类应该对自己需要耦合或调用的类知道得最少,你(被耦合或调用的类)的内部是如何复杂都和我没有关系,那是你的事情,我就调用你提供的public方法,其他一概不关心。低耦合要求:
- 只和朋友交流朋友类:出现在成员变量、方法的输入输出参数中的类。方法体内部的类不属于朋友类。
- 朋友间也是有距离的迪米特法则要求类“羞涩”一点,尽量不要对外公布太多的public方法和非静态的public变量,尽量内敛,多使用private、package-private、protected等访问权限。
- 是自己的就是自己的如果一个方法放在本类中,既不增加类间关系,也对本类不产生负面影响,就放置在本类中
- 谨慎使用Serializable
-
开闭原则: 一个软件实体如类、模块和函数应该对扩展开放,对修改关闭
接口和抽象类的使用场景
- 概念上的区别:当你关注一个事物的本质的时候,用抽象类;当你关注一个操作的时候,用接口。即接口是对动作的抽象,抽象类是对根源的抽象(即对本质的抽象与其他类的本质不同。抽象类表示的是,这个对象是什么。接口表示的是,这个对象能做什么。
- 抽象类的功能要远超过接口,但是,定义抽象类的代价高。因为高级语言来说(从实际设计上来说也是)每个类只能继承一个类。在这个类中,你必须继承或编写出其所有子类的所有共性。虽然接口在功能上会弱化许多,但是它只是针对一个动作的描述。而且你可以在一个类中同时实现多个接口。在设计阶段会降低难度的。
ThreadLocal内存泄漏
- 实现原理:每个Thread 维护一个 ThreadLocalMap 映射表,这个映射表的 key 是 ThreadLocal 实例本身,value 是真正需要存储的Object。也就是说 ThreadLocal 本身并不存储值,它只是作为一个 key 来让线程从 ThreadLocalMap 获取 value。值得注意的是图中的虚线,表示ThreadLocalMap 是使用 ThreadLocal 的弱引用作为 Key 的,弱引用的对象在 GC 时会被回收。
- 为什么会内存泄漏:ThreadLocalMap使用ThreadLocal的弱引用作为key,如果一个ThreadLocal没有外部强引用来引用它,那么系统 GC 的时候,这个ThreadLocal势必会被回收,这样一来,ThreadLocalMap中就会出现key为null的Entry,就没有办法访问这些key为null的Entry的value,如果当前线程再迟迟不结束的话,这些key为null的Entry的value就会一直存在一条强引用链:Thread Ref -> Thread -> ThreaLocalMap -> Entry -> value永远无法回收,造成内存泄漏。
- 解决内存泄漏办法:ThreadLocal内部有个方法expungeStaleEntry,在调用get(),set(),remove()的时候都该方法会清除线程ThreadLocalMap里所有key为null的value
守护线程
所谓守护线程是指在程序运行的时候在后台提供一种通用服务的线程,比如垃圾回收线程就是一个很称职的守护者,并且这种线程并不属于程序中不可或缺的部分。因 此,当所有的非守护线程结束时,程序也就终止了,同时会杀死进程中的所有守护线程。反过来说,只要任何非守护线程还在运行,程序就不会终止。将线程转换为守护线程可以通过调用Thread对象的setDaemon(true)方法来实现。在使用守护线程时需要注意一下几点:
- thread.setDaemon(true)必须在thread.start()之前设置,否则会跑出一个IllegalThreadStateException异常。你不能把正在运行的常规线程设置为守护线程。
- 在Daemon线程中产生的新线程也是Daemon的。
- 守护线程应该永远不去访问固有资源,如文件、数据库,因为它会在任何时候甚至在一个操作的中间发生中断。
- 写java多线程程序时,一般比较喜欢用java自带的多线程框架,比如ExecutorService,但是java的线程池会将守护线程转换为用户线程,所以如果要使用后台线程就不能用java的线程池。
克隆,深克隆和浅克隆
某种场景下想使用已有对象的属性,由于new出来和反射出来的新对象是全新的对象,直接赋值又会影响到原有对象,克隆就是为了解决此类问题的。克隆又分为浅克隆和深克隆
- 浅克隆:被克隆类实现Cloneable接口,重写克隆方法。属性中的基本数据类型是直接赋值,引用类型克隆后的引用指向的还是同一个对象,改变原对象(P)和克隆对象(T)中任意的引用对象中的属性会彼此影响,这是为什么要使用深克隆。
- 深克隆:P和T之间任何属性不会彼此相互影响,彼此对立的个体。实现方法有两种:
- 类和类中的引用对象全部实现Cloneable接口并且重写clone方法,并且在克隆方法中逐级调用引用属性的克隆方法。
- 使用Java IO的对对象进行序列化和反序列化。
Servlet生命周期
- 容器启动将Servlet Class加载到虚拟机
- 第一个请求到达时,实例化Servlet 调用init()初始化方法,调用service()方法
- 第二个以及第二个请求之后的请求到达时,调用service()方法
- Servlet容器正常关闭时,调用destroy()方法。
BIO、NIO、AIO,分别是什么
-
BIO:传统的网络通讯模型,就是BIO,同步阻塞IO,每次一个客户端接入,都需要在服务端创建一个线程来服务这个客户端,处理期间该客户端必须一致等待返回结果,当大量客户端来的时候,就会造成服务端的线程数量可能达到了几千甚至几万,并且发送消息的客户端可能也就几百几千个,这样就既可能造成资源浪费,也可能会造成服务端过载过高,最后崩溃死掉。
-
NIO:NIO 模型中应用程序在一旦开始IO系统调用,会出现以下两种情况:
- 在内核缓冲区没有数据的情况下,系统调用会立即返回,返回一个调用失败的信息。
- 在内核缓冲区有数据的情况下,是阻塞的,直到数据从内核缓冲复制到用户进程缓冲。复制完成后,系统调用返回成功,应用进程开始处理用户空间的缓存数据。
发起一个non-blocking socket的read读操作系统调用,流程是这个样子:
- 在内核数据没有准备好的阶段,用户线程发起IO请求时,立即返回。用户线程需要不断地发起IO系统调用。
- 内核数据到达后,用户线程发起系统调用,用户线程阻塞。内核开始复制数据。它就会将数据从kernel内核缓冲区,拷贝到用户缓冲区(用户内存),然后kernel返回结果。
- 用户线程才解除block的状态,重新运行起来。经过多次的尝试,用户线程终于真正读取到数据,继续执行。
NIO的特点
应用程序的线程需要不断的进行 I/O 系统调用,轮询数据是否已经准备好,如果没有准备好,继续轮询,直到完成系统调用为止。
NIO的优点
每次发起的 IO 系统调用,在内核的等待数据过程中可以立即返回。用户线程不会阻塞,实时性较好。
NIO的缺点
需要不断的重复发起IO系统调用,这种不断的轮询,将会不断地询问内核,这将占用大量的 CPU 时间,系统资源利用率较低。
总结
总之,NIO模型在高并发场景下,也是不可用的。一般 Web 服务器不使用这种 IO 模型。一般很少直接使用这种模型,而是在其他IO模型中使用非阻塞IO这一特性。java的实际开发中,也不会涉及这种IO模型。
再次说明,Java NIO(New IO) 不是IO模型中的NIO模型,而是另外的一种模型,叫做IO多路复用模型( IO multiplexing )。 -
IO多路复用模型(I/O multiplexing):IO多路复用模型的基本原理就是select/epoll系统调用,单个线程不断的轮询select/epoll系统调用所负责的成百上千的socket连接,当某个或者某些socket网络连接有数据到达了,就返回这些可以读写的连接。
IO多路复用模型的读流程
- 进行select(windows)/epoll(linux)系统调用,查询可以读的连接。kernel会查询所有select的可查询socket列表,当任何一个socket中的数据准备好了,select就会返回。
- 当用户进程调用了select,那么整个线程会被block(阻塞掉)。
- 用户线程获得了目标连接后,发起read系统调用,用户线程阻塞。内核开始复制数据。它就会将数据从kernel内核缓冲区,拷贝到用户缓冲区(用户内存),然后kernel返回结果。
- 用户线程才解除block的状态,用户线程终于真正读取到数据,继续执行。
多路复用IO的特点:
- IO多路复用模型,建立在操作系统kernel内核能够提供的多路分离系统调用select/epoll基础之上的。多路复用IO需要用到两个系统调用(system call), 一个select/epoll查询调用,一个是IO的读取调用。
- 和NIO模型相似,多路复用IO需要轮询。负责select/epoll查询调用的线程,需要不断的进行select/epoll轮询,查找出可以进行IO操作的连接。
- 另外,多路复用IO模型与前面的NIO模型,是有关系的。对于每一个可以查询的socket,一般都设置成为non-blocking模型。只是这一点,对于用户程序是透明的(不感知)。
多路复用IO的优点
用select/epoll的优势在于,它可以同时处理成千上万个连接(connection)。与一条线程维护一个连接相比,I/O多路复用技术的最大优势是:系统不必创建线程,也不必维护这些线程,从而大大减小了系统的开销。多路复用IO的缺点
本质上,select/epoll系统调用,属于同步IO,也是阻塞IO。都需要在读写事件就绪后,自己负责进行读写,也就是说这个读写过程是阻塞的。
-
AIO:如何进一步提升效率,解除最后一点阻塞呢?这就是异步IO模型,全称asynchronous I/O,简称为AIO。
AIO的基本流程是
用户线程通过系统调用,告知kernel内核启动某个IO操作,用户线程返回。kernel内核在整个IO操作(包括数据准备、数据复制)完成后,通知用户程序,用户执行后续的业务操作。
kernel的数据准备是将数据从网络物理设备(网卡)读取到内核缓冲区;kernel的数据复制是将数据从内核缓冲区拷贝到用户程序空间的缓冲区。- 当用户线程调用了read系统调用,立刻就可以开始去做其它的事,用户线程不阻塞。
- 内核(kernel)就开始了IO的第一个阶段:准备数据。当kernel一直等到数据准备好了,它就会将数据从kernel内核缓冲区,拷贝到用户缓冲区(用户内存)。
- kernel会给用户线程发送一个信号(signal),或者回调用户线程注册的回调接口,告诉用户线程read操作完成了。
- 用户线程读取用户缓冲区的数据,完成后续的业务操作。
异步IO模型的特点
在内核kernel的等待数据和复制数据的两个阶段,用户线程都不是block(阻塞)的。用户线程需要接受kernel的IO操作完成的事件,或者说注册IO操作完成的回调函数,到操作系统的内核。所以说,异步IO有的时候,也叫做信号驱动 IO 。
异步IO模型缺点
需要完成事件的注册与传递,这里边需要底层操作系统提供大量的支持,去做大量的工作。
目前来说, Windows 系统下通过 IOCP 实现了真正的异步 I/O。但是,就目前的业界形式来说,Windows 系统,很少作为百万级以上或者说高并发应用的服务器操作系统来使用。
而在 Linux 系统下,异步IO模型在2.6版本才引入,目前并不完善。所以,这也是在 Linux 下,实现高并发网络编程时都是以 IO 复用模型模式为主。
什么是零拷贝机制(Zero Copy)
wiki对零拷贝的定义:不需要cpu参与在内存之间复制数据的操作。
我们需要从磁盘读取一个文件通过网络输出到一个客户端。在操作系统内部经历了一个较为复杂的过程:
- 数据从磁盘复制到内核缓冲区
- 从内核缓冲区复制到用户空间缓冲区
- 从用户缓冲区复制到内核的socket缓冲区
- 从socket缓冲区复制到协议引擎(这里是网卡驱动)
这里 要把数据从磁盘复制到内核缓冲区是必须的,因为系统需要读取数据输出给网卡嘛。但是为啥还要从内核复制一份到用户空间呢?应用程序直接使用内核缓冲区的数据不就行了吗?这是因为对于操作系统来说,可能有多个应用程序会同时使用这些数据,并有可能进行修改,如果让大家都使用同一份内核空间的数据就会产生冲突。因此,操作系统设计为:每个应用程序想使用这些数据都必须复制一份到自己的用户空间,这样就不会互相影响了。所以这个机制在碰到数据不需要做修改的场景时就产生了浪费,数据本来可以呆在内核缓冲区不动,没必要再多此一举拷贝一次到用户空间。
随着linux内核版本迭代,数据复制到内核缓冲区以后,不再需要整个拷贝到socket缓冲区,而是只需要将数据的位置和长度信息(append dscr)传输到socket缓冲区,这样DMA1引擎会根据这些信息直接从内核缓存区复制数据给协议引擎。数据只需要从磁盘复制到内存,再从内存复制到协议引擎,跟最开始相比减少了从内核到用户空间,从用户空间到socket缓冲两次复制。但是明明还有两次数据的复制,为什么要叫“零拷贝”呢?这是因为从操作系统的角度来说,数据没有从内存复制到内存的过程,也就没有了CPU参与的过程, 所以对于操作系统来说就是零拷贝了。
buffer中clear、compact方法的区别
- clear:position将被设回0,limit设置成capacity,Buffer中的数据并未被清除,只是这些标记告诉我们可以从哪里开始往Buffer里写数据。如果Buffer中有一些未读的数据,调用clear()方法,数据将“被遗忘”,意味着不再有任何标记会告诉你哪些数据被读过,哪些还没有。
- 如果Buffer中仍有未读的数据,且后续还需要这些数据,但是此时想要先写些数据,那么使用compact()方法。compact()方法将所有未读的数据拷贝到Buffer起始处。然后将position设到最后一个未读元素正后面。limit被设置成capacity。现在Buffer准备好写数据了,但是不会覆盖未读的数据。
2. 框架
谈谈对Spring IOC的理解
Spring Bean生命周期
Spring AOP原理
AOP(Aspect Oriented Programming) 面向切面编程。在编程中,我们希望将日志记录,性能统计,事务处理,异常处理等代码逻辑相似又不影响正常业务流程的代码提取出来,然后通过预编译方式和运行期动态代理实现在不修改源代码的情况下给程序动态统一添加功能的一种技术。假设把应用程序想成一个立体结构的话,OOP的利刃是纵向切入系统,把系统划分为很多个模块(如:用户模块,文章模块等等),而AOP的利刃是横向切入系统,提取各个模块可能都要重复操作的部分(如:权限检查,日志记录等等)。由此可见,AOP是OOP的一个有效补充。AOP不是一种技术,实际上是编程思想。凡是符合AOP思想的技术,都可以看成是AOP的实现。
参考:SpringAOP原理分析
Spring事务使用方式
1. 编程式事务
- 注入事务管理器:org.springframework.jdbc.datasource.DataSourceTransactionManager
- 注入事务管理模板:org.springframework.transaction.support.TransactionTemplate
- 将事务模板注入到代码中使用,伪代码如下:
public void buy(){
transactionTemplate.execute(status -> {
//查询余额
double banlance=queryFromDB();
//余额不足抛异常
if (banlance<10) {
throw new RuntimeException("余额不足!请充值");
}
//更新
update();
return null;
});
}
2. 声明式事务
声明式事务管理建立在AOP之上,其本质是对方法前后进行拦截,然后在目标方法开始之前创建或者加入一个事务,执行完目标方法之后根据执行的情况提交或者回滚。
首先要定义一个事务增强txAdvice,依赖于transactionManager。然后定义切面,切面内定义切点,然后指向具体的切面类。
Spring事务传播特性
事务行为 | 说明 |
---|---|
PROPAGATION_REQUIRED | 如果没有,就开启一个事务;如果有,就加入当前事务(方法B看到自己已经运行在 方法A的事务内部,就不再起新的事务,直接加入方法A) |
RROPAGATION_REQUIRES_NEW | 如果没有,就开启一个事务;如果有,就将当前事务挂起。(方法A所在的事务就会挂起,方法B会起一个新的事务,等待方法B的事务完成以后,方法A才继续执行) |
PROPAGATION_NESTED | 如果没有,就开启一个事务;如果有,就在当前事务中嵌套其他事务 |
PROPAGATION_SUPPORTS | 如果没有,就以非事务方式执行;如果有,就加入当前事务(方法B看到自己已经运行在 方法A的事务内部,就不再起新的事务,直接加入方法A) |
PROPAGATION_NOT_SUPPORTED | 如果没有,就以非事务方式执行;如果有,就将当前事务挂起,(方法A所在的事务就会挂起,而方法B以非事务的状态运行完,再继续方法A的事务) |
PROPAGATION_NEVER | 如果没有,就以非事务方式执行;如果有,就抛出异常。 |
PROPAGATION_MANDATORY | 如果没有,就抛出异常;如果有,就使用当前事务 |
其中前4种是开发中用到概率比较大的,建议熟记;后面3种不常用,了解就行。
3. 对比
不同点 | 编程式事务 | 声明式事务 |
---|---|---|
粒度 | 代码块级别 | 方法级别 |
实现 | 每个方法都需要单独实现,但业务量大功能复杂时,使用编程式事务无疑是痛苦的 | 声明式事务属于无侵入式,不会影响业务逻辑的实现,只需要在配置文件中做相关的事务规则声明或者通过注解的方式,便可以将事务规则应用到业务逻辑中 |
SpringMVC运行流程
- 用户发送请求至前端控制器DispatcherServlet。
- DispatcherServlet收到请求调用HandlerMapping处理器映射器。
- 处理器映射器找到具体的处理器(可以根据xml配置、注解进行查找),生成处理器对象及处理器拦截器(如果有则生成)一并返回给DispatcherServlet。
- DispatcherServlet调用HandlerAdapter处理器适配器。
- HandlerAdapter经过适配调用具体的处理器(Controller,也叫后端控制器)。
- Controller执行完成返回ModelAndView。
- HandlerAdapter将controller执行结果ModelAndView返回给DispatcherServlet。
- DispatcherServlet将ModelAndView传给ViewReslover视图解析器。
- ViewReslover解析后返回具体View.
- DispatcherServlet根据View进行渲染视图(即将模型数据填充至视图中)。
- DispatcherServlet响应用户。
参考:SpringMVC的简介和工作流程
分析springboot运行机制
- 首先主类上@SpringBootApplication注解,点进去主要有三个重要注解:@Configuration、@ComponentScan、@EnableAutoConfiguration。
- @Configuration将该类标记为配置类;
- @ComponentScan没有指定basePackages的话就以当前类所在的包为basePackages,这就是为什么将Bean放于主类所在包范围之外无法扫描到的原因;
- @EnableAutoConfiguration有个注解@Import({AutoConfigurationImportSelector.class}),而AutoConfigurationImportSelector最终实现了ImportSelector接口,该接口selectImports方法返回一组bean全类名数组,将实现对导入类的收集。 那么导入的类从哪来呢?
AutoConfigurationImportSelector调用SpringFactoriesLoader的loadSpringFactories 方法,该方法会加载class路径下META-INF/spring.factories配置文件里所有的配置类
MyBatis中#{}和${}区别
#{}是预编译处理,MyBatis会将#{}替换为?,配合PreparedStatement的set方法赋值,防止SQL注入。${}直接是字符串替换,不推荐使用。
MyBatis的分页方式
- 逻辑分页:使用自带的RowBounds分页,一次查很多数据(不是全部数据),然后在这些数据里面检索。本质上是使用DB的limit进行分页,表里数据量小速度较快,数据量大就很慢。比如:limit 100 offset 20就会查询满足120条的数据,然后取出20条,可想而知随着limit后的数值增大越来越慢。
- 物理分页:使用分页插件PageHelper,直接去数据库里查询指定条数的数据。
谈谈MyBatis缓存
MyBatis延迟加载
开启延迟加载
<setting name="lazyLoadingEnabled" value="true"/>
默认是侵入式延迟加载机制:如果只查询主表数据而不进行使用,级联表的数据不会被查询;如果使用了主表数据,即使级联表的数据没有使用,也会查询
关闭侵入式延迟加载机制:使用到数据才会去查找相关表
<setting name="aggressiveLazyLoading" value="false"/>
3. JVM
Java内存区域(JVM内存结构)
-
程序计数器:线程私有的(为了保证线程切换后能恢复到正确位置),当前线程执行字节码的行号指示器。字节码解释器就是通过改变这个计数器的值来选取下条需要执行的字节码指令(分支、循环、跳转、异常处理、线程恢复等)。程序计数器是此区域是唯一一个没有规定OutOfMemoryError的区域。
-
Java虚拟机栈:线程私有,描述的是Java方法执行的内存模型,每个方法执行的时都会创建一个栈帧用于储存局部变量表 2、操作数栈、动态链接、方法出口等信息。如果线程请求栈深度大于虚拟机允许,抛出StackOverflowError;如果无法申请到足够内存抛出OutOfMemoryError
-
本地方法栈:线程私有,与Java虚拟机栈相似,只不过是为虚拟机使用到的native方法服务的
-
Java堆:线程共享,Java虚拟机管理的内存最大的一块区域,在虚拟机启动时创建。此区域唯一目的是存放对象实例,几乎所有对象实例都在这里分配内存3。无法申请所需内存时抛出OutOfMemoryError
-
方法区:线程共享,储存被虚拟机加载的类信息、常亮、静态变量、即时编译器编译的代码等数据。无法申请所需内存时抛出OutOfMemoryError。方法区还包含运行时常量池,Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息-常量池,用于存放编译期生成的各种字面量和符号引用,这部分信息将存放于运行时常量池中。
- 操作数栈:是一个后入先出的栈。其最大深度在编译时写入到code属性的max_stacks数据项中。每个元素可以使任意的Java数据类型,包括long、double。32位占栈容量1,64位位2。方法执行时,操作数栈深度不会超过设定的max_stacks。
参考:《深入理解Java虚拟机》
- 操作数栈:是一个后入先出的栈。其最大深度在编译时写入到code属性的max_stacks数据项中。每个元素可以使任意的Java数据类型,包括long、double。32位占栈容量1,64位位2。方法执行时,操作数栈深度不会超过设定的max_stacks。
class文件、class content、Class文件、class 对象
- class文件:java文件反编译后存储在硬盘上的文件
- class content:class文件加载到虚拟机的内容
- Class文件:类加载器对class content解析后生成的文件
- class 对象:使用new关键词生成的对象
Java引用类型
JDK1.2之前Java中引用只有引用和没被引用两种状态,过于狭隘,对于“食之无味弃之可惜”的对象无能为力。我们希望某些对象在内存足够时保留,内存不足时抛弃。JDK1.2之后对引用进行了扩充,由以下4中以此减弱:
- 强引用(Strong Reference):类似“Object obj=new Object()”,只要强引用存在对象永远不会被回收
- 软引用(Soft Reference):有用,非必须对象。软引用关联的对象会在内存将要溢出时被系统列入回收范围,进行二次回收
- 弱引用(Weak Reference):被弱引用关联的对象无论内存是否足够,只能存活到下次垃圾回收发生之前
- 虚引用(Phantom Reference):被虚引用关联的对象,其生存完全不受引用影响,也无法通过该引用获取对象实例。唯一作用是在对象被回收时收到一个通知
参考:《深入理解Java虚拟机》
JVM垃圾回收机制(判断对象是否存活)
- 判断对象是否可用
- 引用计数算法:给对象添加一个引用计数器,每当一个地方引用它计数器就加1,每当一个引用失效时计数器减1,任意时刻计数器为0时,该对象不可用。缺点是:无法解决循环引用问题。
- 可达性分析算法:通过一系列成为“GC Roots”对象作为起点向下搜索,搜索走过的路径被称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连时,改对象不可用。Java中,可作为GC Roots对象包括:
① 虚拟机栈(栈帧中本地变量表)中引用的对象
②方法区中静态属性引用的对象
③方法区中常量引用的对象
④本地方法栈中JNI引用的对象
- 判断对象是否死亡
如果根搜索算法中判断对象不可用,并不代表对象真正死亡。对象真正死亡要经历两次标记:- 如果对象不可达那么将会被第一次标记,并且进行一次筛选,筛选条件是是否需要执行finalize()方法(对象没有覆盖finalize()方法,或者执行过了finalize()就没必要执行,任何对象的finalize()只会执行一次)
- 如果对象有必要执行finalize()方法,将会被放进F-Queue队列中。然后GC将对该对象进行第二次标记,对象如果在执行finalize()方法时成功自救(重新与引用链上任意对象建立关联),将被移除即将回收的集合,否则就离死不远了
参考:《深入理解Java虚拟机》
垃圾收集算法
- 标记-清除:首先标记处所有需要回收的对象,标记完成后统一进行回收。有两个不足:
- 标记和清除效率低下
- 标记清除后产生大量不连续的内存碎片
- 复制算法:将内存等分为两块,每次只使用其中一块,当一块内存用完了就将活着的对象复制到另一块,然后清除掉。优点是实现简单,效率高,内存连续。缺点是内存使用率低,代价高。
现在的商业虚拟机使用这种方式回收新生代,新生代百分之98是朝生夕死对象,所以不需要1:1分配内存,而是将内存分为一块较大的Eden空间和两块较小的Survior(Survior from、Survior to)空间,回收时将Eden和Servior From存活对象复制到Servior to上,然后清理掉自己。HotSpot默认Eden和Servior为8:1,我们没法保证每次回收存活对象不多于10%,当内存不够时需要依赖老年代进行分配担保,也就是当Servior to没有足够内存存放上一次新生代的存活对象时,这些对象将通过分配担保机制进入老年代。
- 假设对象存活率在100%(老年代完全有可能),那么复制算法就不适合了,所以提出来标记-整理方法。标记过程如同标记-清除算法,然后将标记的对象向一侧移动,最后一次清理掉边界之外的内存
- 分代收集算法:当代虚拟机都采用这种方法,将Java堆分为新生代、老年代。新生代每次收集都会有大量的对象死亡,采用复制算法。老年代对象存活率高、没有额外空间对它进行担保,采用标记-清理或者标记-整理算法
参考:《深入理解Java虚拟机》
Java为什么需要性能调优
Java应用随着用户量增大等原因会导致需要的内存不断增大,一旦所需内存大于物理机可分配内存就会导致系统崩溃,因此就需要对JVM内存进行配置限制,一旦到达临界点就会进行内存回收释放,系统永远不会因为内存问题而导致崩溃。
类加载时机
加载、验证、准备、初始化、卸载这5个阶段顺序是固定的。为了支持Java运行时绑定,解析阶段可以在初始化之后进行。以下5种情况必须初始化:
- 遇到new、getstatic、putstatic或invokestatic这4条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化。生成这4条指令的最常见的Java代码场景是:使用new关键字实例化对象的时候,读取或设置一个类的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)的时候,以及调用一个类的静态方法的时候。
- 使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化。
- 当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
- 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。
- 当使用JDK 1.7动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且该方法句柄所对应的类没有初始化过,则先触发初始化。
Java类加载的过程
- 加载:在加载阶段,虚拟机需要完成3件事情:
- 通过一个类的全限定名来获取定义此类的二进制字节流;
获取二进制流途径:
- 从ZIP包获取,是JAR、EAR、WAR格式基础
- 网络中获取,如:Applet
- 运行时计算机生成,这种场景使用最多的是动态代理技术,在java.lang.reflect.Proxy就是使用ProxyGenerator.generateProxyClass来为特定的接口生成二进制字节流。
- 其他文件生成,如JSP
- 从数据库中读取,如某些中间件服务器将代码安装到数据库中来实现代码在集群中分发
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构;
- 在Java堆中生成一个代表这个类的java.lang.Class对象,作为方法区这些数据的访问入口。
- 通过一个类的全限定名来获取定义此类的二进制字节流;
- 验证:验证阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。整体来看,验证阶段大致分为4个验证动作:
- 文件格式验证:第一阶段是验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理。主要目的是保证输入的字节流能正确地解析并存储于方法区之内,格式上符合描述一个Java类型信息的要求。该阶段是基于二进制字节流验证的,只有通过了这个阶段的验证,字节流才会进入内存的方法去中存储,后面的3个验证都是基于方法区的存储结构进行的。
这一阶段可能的验证点:a.是否以魔数0xCAFEBABE开头
b.主、次版本号是否在当前虚拟机处理范围内
c.常量池的常量数据类型是否被支持(检查常亮tag标志)
d. 指向常量的各种索引值是否有指向不存在或者复合类型的常亮
e. CONSTANT_utf8_info型常量中是否有不符合utf8编码的数据
f. Class文件中各个部分以及文件本身是否有被删除或者附件的其他信息
… - 元数据验证:元数据验证是对字节码描述信息进行语义分析,以保证其描述的信息符合Java语言规范的要求。这个阶段可能的验证点:
a. 是否有父类
b. 是否继承了不被允许继承的类
c. 如果该类不是抽象类,是否实现了其父类或接口要求实现的所有方法
d. 类中的字段、方法是否与父类产生矛盾(类如覆盖父类final字段,或者错误方法重载)
… - 字节码验证:字节码验证的主要目的是通过数据流和控制流分析,确定程序语义的合法性和逻辑性。该阶段将对类的方法体进行校验分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的事情。这个阶段可能的验证点:
a. 保证任何时候操作数栈的数据类型与指令代码序列的一致性,不会出现这种情况:在操作栈中放置了一个int类型数据,使用时却按照long类型加载入本地变量表中;
b.跳转指令不会跳转到方法体以外的字节码指令上
… - 符号引用验证:符号引用验证的主要目的是保证解析动作能正常执行,如果无法通过符号引用验证,则会抛出异常。这个阶段可能的验证点:
a. 符号引用的类、字段、方法的访问性(public、private等)是否可被当前类访问
b. 指定类是否存在符合方法的字段描述符以及简单名称所描述的方法和字段
c. 符号引用中的类、字段、方法的访问性(private、protected…)是否可被当前类访问
…
- 文件格式验证:第一阶段是验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理。主要目的是保证输入的字节流能正确地解析并存储于方法区之内,格式上符合描述一个Java类型信息的要求。该阶段是基于二进制字节流验证的,只有通过了这个阶段的验证,字节流才会进入内存的方法去中存储,后面的3个验证都是基于方法区的存储结构进行的。
数组的加载
数组是直接由Java虚拟机创建的,但是数组组件是由类加载器创建的,一个数组创建遵循以下规则:
- 如果数组组件是引用类型,就是用上节讲到的加载过程去递归加载,数组将在加载该组件的类加载器的类名空间上被标识(一个类必须与类加载器一起确定唯一性)
- 如果数组组件不是引用类型(如int[]数组),Java虚拟机将会把数组标识为与引导类加载器关联
- 数组类的可见性和组件可见性一致,如果组件不是引用类型,那么数组可见性默认为public。
什么是类加载器
为了让应用程序自己去决定如何获取自己需要的类,将通过一个全类名来获取类的二进制字节流的这个动作放到Java虚拟机外部去实现。实现这个动作的代码块就是类加载器。
类加载器的分类
- 启动类加载器(Bootstrap ClassLoader):负责加载存放在<JAVA_HOME>lib目录中或者被-Xbootclasspath指定路径中并且是Java虚拟机识别的类库加载到虚拟机内存中。开发者不可使用该类加载器
- 拓展类加载器(Extension ClassLoader):由sun.misc.Launcher$ExtClassLoader实现,负责加载存放在<JAVA_HOME>libext目录中或者被系统变量java.ext.dirs所指定的路径的所有类库加载到虚拟机内存中。开发者可以直接使用该类加载器
- 应用程序类加载器(Application ClassLoader):由sun.misc.Launcher$AppClassLoader实现,是ClassLoader中getSystemClassLoader()方法的返回值,所以也成为系统类加载器。负责加载用户类路径classpath上的类。开发者可以直接使用,这是程序中使用的默认类加载器,用户可以自定义类加载器。
类加载器双亲委派模型
- 定义:如果一个类加载器收到加载类请求,它首先不会自己去加载这个类,而是将该类加载工作委派给父加载器,因此所有的类加载请求最终都会传到顶层Bootstrap ClassLoader进行加载。只要父加载器反馈自己无法完成这个加载请求(它在自己的搜索范围内没找到所需的类)时,子加载器才会尝试自己加载
- 意义:Java类随着类加载器一起具有优先级关系。例如java.lang.Object,存放在rt.jar总,无论哪个类加载器加载它最终都会委派给处于模型最顶层类加载器进行加载,因此Object在程序中各种类加载器加载的结构都是同一个类。反之,若没有使用双亲委派模型,如果用户自己编写一个java.lang.Object类,放于classpath路径下,再新建了个类加载器去加载它,系统中就会出现不同的Object对象。
参考:《深入理解Java虚拟机》
Java多线程实现原理
Java多线程是通过线程轮流切换并分配处理器执行时间来实现的,在任何时刻,一个处理器只能执行一条线程中的指令。多线程程序并不能提高程序的运行速度,但能够提高程序运行效率,让CPU的 使用率更高。
4. 并发多线程
进程与线程
- 程序:是为完成特定任务,用某种语言编写的一组指令的集合,即一段静态代码,静态对象。
- 进程:是程序的一次执行过程,或是正在运行的一个程序,是一个动态的过程,每个程序都有一个独立的内存空间
- 线程:是进程中的一个执行路径,共享一个内存空间,线程之间可以自由切换,并发执行. 一个进程最少有一个线程,线程实际上是在进程基础之上的进一步划分,一个进程启动之后,里面的若干执行路径又可以划分成若干个线程
并发与并行的区别是什么
你吃饭吃到一半,电话来了,你一直到吃完了以后才去接,这就说明你不支持并发也不支持并行。
你吃饭吃到一半,电话来了,你停了下来接了电话,接完后继续吃饭,这说明你支持并发。
你吃饭吃到一半,电话来了,你一边打电话一边吃饭,这说明你支持并行。
并发的关键是你有处理多个任务的能力,不一定要同时。
并行的关键是你有同时处理多个任务的能力。
Runnable与Callable的异同
- Runnable没有返回值;Callable可以返回执行结果
- Callable接口的call()允许抛出异常;Runnable的run()不能抛出
- Callable还会获取返回值——Callalble接口支持返回执行结果,需要调用FutureTask.get()得到,此方法会阻塞主进程的继续往下执行,如果不调用不会阻塞。
线程状态
jdk1.8中为线程设置了5个状态:
- NEW:新创建的线程,未调用start()方法
- RUNNABLE:可能是正在运行,也可能是在等待cpu进行调度,可以理解为READY(start())和RUNNING
- BLOCKED:一般是线程等待获取一个锁,来继续执行下一步的操作,例如使用synchronized修饰的代码块,等待获取锁的线程就是处于这种状态
- WAITING:调用以下方法进入这种状态:Thread.sleep(long)、Object.wait(long)、Thread.join(long)、LockSupport.parkNanos(long)、LockSupport.parkUntil()
- TERMINATED:线程执行结束之后的状态
wait、sleep、yield、join、interrupt
- Object.wait:线程会释放掉它所占有的锁,从而使别的线程有机会抢占该锁。
当前线程必须拥有当前对象锁,否则会抛出IllegalMonitorStateException异常。唤醒当前对象锁的等待线程使用notify或notifyAll方法,也必须拥有相同的对象锁,否则也会抛出IllegalMonitorStateException异常。wait()和notify()必须在synchronized函数或synchronized block中进行调用。如果在non-synchronized函数或non-synchronized block中进行调用,虽然能编译通过,但在运行时会发生IllegalMonitorStateException的异常。 - Thread.Sleep:在指定时间内使当前线程进入BLOCKED状态,不会释放锁
- Thread.yield:作用是让步,不会释放锁。它能让当前线程由“运行状态”进入到“就绪状态”,从而让其它具有相同优先级的等待线程获取执行权;但是,并不能保证在当前线程调用yield()之后,其它具有相同优先级的线程就一定能获得执行权;也有可能是当前线程又进入到“运行状态”继续运行!
- Thread.join:等待调用join方法的线程结束,再继续执行。
- Thread.interrupt:改变中断状态而已,它不会中断一个正在运行的线程。这一方法实际完成的是,给受阻塞的线程发出一个中断信号,这样受阻线程就得以退出阻塞的状态。更确切的说,如果线程被Object.wait, Thread.join和Thread.sleep三种方法之一阻塞,此时调用该线程的interrupt()方法,那么该线程将抛出一个 InterruptedException中断异常(该线程必须事先预备好处理此异常),从而提早地终结被阻塞状态。如果线程没有被阻塞,这时调用 interrupt()将不起作用,直到执行到wait(),sleep(),join()时,才马上会抛出InterruptedException。interrupt()方法的简单理解
线程间通信方式
- 使用volatile关键字:volatile保证了被修饰的变量对所有线程的可见性
- 使用Object类的wait() 和 notify() 方法,wait和 notify必须配合synchronized使用,wait方法释放锁,notify方法不会释放锁直到代码执行完
- 使用JUC工具类 CountDownLatch,基于AQS框架,相当于也是维护了一个线程间共享变量state
- 使用 ReentrantLock 结合 Condition
- 基本LockSupport实现线程间的阻塞和唤醒
参考:线程间通信的几种实现方式
主存和工作内存交互时虚拟机保证的天然原子性操作有哪些
lock(锁定)、unclock(解锁)、read(读取)、load(载入)、use(使用)、assign(赋值)、store(储存)、write(写入)
- 虚拟机未将lock、unlock直接开放给用户,但是提供了更高层次的字节码指令monitorenter、monitorexit来隐式使用这两个指定,反映到Java就是synchronized关键字,因此synchronized修饰的代码块具备原子性
- 我们可以认为基本数据类型的访问读写是具有原子性的(long、double例外,但是大部分商用虚拟机都将它们读写当做原子性对待,平时在写long、double变量时不需要声明为volatile)
谈谈volatile关键字
当一个变量定义为 volaiile 之后,它将具备两种特性:
-
第一是保证此变量对所有线程的可见性,这里的“可见性”是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的,在各线程的工作内存中变量也存在不一致的情况,但是由于每次使用变量前都需要先刷新,执行引擎看不到不一致的情况,因此可以认为不存在不一致的情况。而普通变量不能做到这一点,普通变量的值在线程间传递均需要通过主内存来完成,例如,线程 A 修改一个普通变量的值,然后向主内存进行回写,另外一条线程 B 在线程 A 回写完成了之后再从主内存进行读取操作,新变量值才会对线程 B 可见。
不过,无法保证非原子性操作的变量线程安全,例如i++问题,对以下代码进行反编译:
private static int i = 0; public static void increase() { i++; }
反编译结果:
public static void increase(); Code: 0: getstatic #2 // Field i:I 3: iconst_1 4: iadd 5: putstatic #2 // Field i:I 8: return
getstatic将i的值取到操作栈顶时,volatile保证此时变量是正确的的,但是当执行iconst_1、iadd这些操作时,其它线程已经对i的值进行了修改,putstatic就会将较小的值同步回主内存
-
禁止指令重排:指令重排是指CPU在正确处理指令依赖情况以保证程序得出正确结果的前提下,不按程序规定的顺序将多条指令分开发给不同的电路单元处理。被volatile修饰的变量,会在赋值后多执行一步相当于添加内存屏障的操作,指令重排时不能将后面的指令重排到内存屏障之前。
synchronized关键字原理
程序编译后会在添加synchronized关键字代码块的前后分别添加monitorenter和monitorexit字节码指令,这两个指令都需要同一个reference类型的参数来指明要锁定和解锁的对象。执行monitorenter指令是就会尝试获取对象的锁。如果对象没有被锁定或者当前线程已经拥有对象的锁,就把锁的计数器加1,因此对同一个线程来说在synchronized中是可重入的,不会自己把自己锁死。相应的,在执行monitorexit指令时就将锁计数器减1,当计数器为0时释放锁。
乐观锁与悲观锁
- 悲观锁,正如其名,具有强烈的独占和排他特性。它指的是对数据被外界(包括本系统当前的其他事务,以及来自外部系统的事务处理)修改持保守态度。因此,在整个数据处理过程中,将数据处于锁定状态。
- 乐观锁会假设整个数据处理过程中数据不会被修改,只有当操作提交操作时才检查数据是否被修改,如果发生冲突了就返回错误信息,反之提交成功。Java使用CAS实现乐观锁。
乐观锁出现的问题
ABA问题,假设有个变量a,线程1读到的值为2,然后进行修改3操作,线程b将a修改为4然后又改回为2,线程1提交时发现数据还是2,提交成功,这就是ABA问题,线程1读取了脏数据。
解决办法就是添加版本号,每次提交时获取最新版本号和之前版本号进行对比,一致就提交。
JUC包通过提供一个带有标记的原子引用类“AomicStampedReference”来解决ABA问题,它可以通过控制变量值的版本来保证CAS正确性,不过目前来说这个类比较鸡肋,大部分情况ABA问题不会影响并发正确性,要解决ABA问题改用互斥同步更高效
互斥同步和非阻塞同步
- 互斥同步:多个线程并发访问同一个数据时,保证同一时刻只被一个线程访问,是一种悲观并发策略。互斥同步手段:synchronized是原生语法的互斥锁;ReenterantLock是API层面的互斥锁。
- 非阻塞同步:这是一种基于冲突检测的乐观并发策略,先进性操作如果没有其他线程争用共享数据,那操作就成功了;如果共享数据有争用,产生了冲突,那就采取其它补偿措施(例如:不断尝试直到成功),这种乐观的并发策略许多实现都不需要将线程挂起,因此被称为非阻塞同步(Non-Blocking Synchronization)。
由于需要保证操作和冲突检测两个步骤具备原子性,如果依靠互斥同步就失去了意义,只能依靠硬件指令集的发展,硬件保证一个从语义上看起来需要多次操作的行为通过一条处理器指令就能完成,常用的指令有:
- 测试并设置(Test-and-Set)
- 获取并增加(Fetch-and-Increment)
- 交换(Swap)
- 比较并交换(Compare-and-Swap,CAS)
- 加载链接/条件储存(Load-linked/Store-Conditional,LL/SC)
CAS原理
CAS:Compare and Swap,即比较再交换。CAS是一种无锁算法,CAS有3个操作数,内存值V,旧的预期值A,要修改的新值B。当且仅当预期值A和内存值V相同时,将内存值V修改为B,否则什么都不做。
ReenterantLock和synchronized关键字对比
ReenterantLock需要lock()和unlock()配合try catch finally使用,相比synchronized关键字ReenterantLock增加了以下高级功能:
- 等待可中断:正在等待的线程可以放弃等待,改为处理其他事情。
- 实现公平锁:多个线程等待一个锁是可以按照申请时间顺序依次获取锁,synchronized是非公平的,ReenterantLock默认是非公平锁,可以通过带boolean的构造函数使用公平锁
- 绑定多个条件:一个ReenterantLock可以同时绑定多个Condition对象,而synchronized中锁对象的wait()、notify()、notifyAll()可以实现一个隐含条件,如果要和多于一个条件关联就必须再加一个锁,ReenterantLock只需要多次调用newCondition()即可。
性能方面,1.6之前单核synchronized性能高,多核ReenterantLock性能高。1.6之后对synchronized大大优化,它们性能基本持平synchronized甚至优之,所以现在性能不是选择ReenterantLock的理由
5. 分布式
分布式锁应该具备哪些条件
- 在分布式系统环境下,一个方法在同一时间只能被一个机器的一个线程执行
- 高可用的获取锁与释放锁
- 高性能的获取锁与释放锁
- 具备可重入特性(可理解为重新进入,由多于一个任务并发使用,而不必担心数据错误)
- 具备锁失效机制,防止死锁
- 具备非阻塞锁特性,即没有获取到锁将直接返回获取锁失败
- 加锁解锁过程必须具有原子性
数据库分布式锁
- 创建一个锁表,使用将方法名设置为唯一索引
- 抢占锁时,进行insert数据,成功则获取到锁
- 释放锁时delete数据
有几点问题:
- 数据库挂掉怎么办?
使用多个备份数据库,加锁前进行数据同步,一个挂掉迅速切换到备用库 - 抢占到锁后,未来得及释放锁宕机?
表中添加一个时间戳字段,使用定时任务删除过期的锁。 - 插入失败就会抛异常,如何保证阻塞?
使用while死循环,直到插入成功 - 如何保证重入?
在数据库表中加个字段,记录当前获得锁的机器的主机信息和线程信息,那么下次再获取锁的时候先查询数据库,如果当前机器的主机信息和线程信息在数据库可以查到的话,直接把锁分配给他就可以了。
redis分布式锁
1. 使用setnx命令
- 加锁:setnx(lock_key,val),根据返回结果若值设置成功,则key不存在,加锁成功,反之key已经存在,加锁失败。
- 解锁:del(lock_key)
- 死锁问题:线程1获取锁成功,在未执行完任务时挂掉,没有显示的释放锁,那么其它线程就永远无法获取改锁造成死锁。所以需要设置过期时间,可以利用
expire命令,但是setnx和expire命令是两个动作无法保证加锁操作原子性。还有个问题,假设线程1设置锁成功,但是任务没有执行完时锁已经超时,此时线程2抢占了锁,然后线程1执行完了进行del解锁,此时将会错误的对线程2进行锁释放。
2. 使用set(locl_key,val ,expire_time,NX)命令
针对setnx的问题,可以利用set(locl_key,val ,expire_time,NX)命令,该命令类似setnx并且可以设置过期时间,将val值设置成加锁线程id,每次解锁时可以与当前线程进行对比,是同一个线程就进行解锁,但是判断和解锁又不是一个原子性操作了。
针对这个问题,我们可以让获得锁的线程开启一个守护线程,使用expire命令用来给快要过期的锁“续航”。比如,设置过期时间为60s,每当50s该key还存在时就进行续命50s。
参考:009.分布式协调(分布式锁)
Zookeeper分布式锁
Zookeeper是树形数据结构,类似于文件目录,树是由节点(Znode)组成,每个Znode都拥有唯一路径,数据也会存储在Znode下。Zookeeper的节点有以下四种类型:
- 持久节点(PERSISTENT):默认的节点类型。创建节点的客户端与 Zookeeper 断开连接后,该节点依旧存在。
- 持久节点顺序节点(PERSISTENT_SEQUENTIAL):所谓顺序节点,就是在创建节点时,Zookeeper 根据创建的时间顺序给该节点名称进行编号:
- 临时节点(EPHEMERAL):和持久节点相反,当创建节点的客户端与 Zookeeper 断开连接后,临时节点会被删除
- 临时顺序节点(EPHEMERAL_SEQUENTIAL):有顺序的临时节点
1. 获取锁
- 创建一个持久节点ParentLock
- 当第一个客户端想要获得锁时,需要在 ParentLock 这个节点下面创建一个临时顺序节点 Lock1。
- Client1 查找 ParentLock 下面所有的临时顺序节点并排序,判断自己所创建的节点 Lock1 是不是顺序最靠前的一个。如果是第一个节点,则成功获得锁。
- 这时候,如果再有一个客户端 Client2 前来获取锁,则在 ParentLock 下载再创建一个临时顺序节点 Lock2。
- Client2 查找 ParentLock 下面所有的临时顺序节点并排序,判断自己所创建的节点 Lock2 是不是顺序最靠前的一个,结果发现节点 Lock2 并不是最小的。
- Client2 向排序仅比它靠前的节点 Lock1 注册 Watcher,用于监听 Lock1 节点是否存在。这意味着 Client2 抢锁失败,进入了等待状态。
- 同样的,假如Client3也来抢占锁,就会监听Lock2,这样一来,Client1 得到了锁,Client2 监听了 Lock1,Client3 监听了 Lock2。这恰恰形成了一个等待队列。
2. 释放锁
释放锁分为两种情况:
- 任务完成,客户端显示释放:当任务完成时,Client1 会显示调用删除节点 Lock1 的指令。
- 任务执行过程中,客户端崩溃:获得锁的 Client1 在任务执行过程中,如果崩溃,则会断开与 Zookeeper 服务端的链接。根据临时节点的特性,相关联的节点 Lock1 会随之自动删除。
由于 Client2 一直监听着 Lock1 的存在状态,当 Lock1 节点被删除,Client2 会立刻收到通知。这时候 Client2 会再次查询 ParentLock 下面的所有节点,确认自己创建的节点 Lock2 是不是目前最小的节点。如果是最小,则 Client2 顺理成章获得了锁。
分布式事务:2PC(two phase commit)两阶段提交
1. 概念
2PC即两阶段提交协议,是将整个事务流程分为两个阶段,准备阶段(Prepare phase ) 、 提交阶段(commit phase ),2是指两个阶段,P是指准备阶段C是指提交阶段
- 准备阶段(Prepare phase) :事务管理器给每个参与者发送Prepare消息, 每个数据库参与者在本地执行事务, 并写本地的Undo/Redo日志, 此时事务没有提交。(Undo日志是记录修改前的数据, 用于数据库回滚, Redo日志是记录修改后的数据, 用于提交事务后写入数据文件)
- 提交阶段(commit phase) :如果事务管理器收到了参与者的执行失败或者超时消息时,直接给每个参与者发送回滚(Rollback) 消息; 否则, 发送提交(Commit) 消息; 参与者根据事务管理器的指令执行提交或者回滚操作,并释放事务处理过程中使用的锁资源。注意:必须在最后阶段释放锁资源。
2. XA解决方案
2PC的传统方案是在数据库层面实现的, 如Oracle、MySQL都支持2PC协议, 为了统一标准减少行业内不必要的对接成本, 需要制定标准化的处理模型及接口标准, 国际开放标准组织Open Group定义了分布式事务处理模型DTP(Distributed Transaction Processing Reference Model) 。为了让大家更明确XA方案的内容程,下面新用户注册送积分为例来说明:
- 执行流程如下:
- 应用程序(AP)持有用户库和积分库两个数据源。
- 应用程序(AP)通过TM通知用户库RM新增用户,同时通知积分库RM为该用户新增积分,RM此时并未提交事务,此时用户和积分资源锁定。
- TM收到执行回复,只要有一方失败则分别向其他RM发起回滚事务,回滚完毕,资源锁释放。
- TM收到执行回复,全部成功,此时向所有RM发起提交事务,提交完毕,资源锁释放。
- DTP模型定义如下角色:
- AP(Application Program) :即应用程序, 可以理解为使用DTP分布式事务的程序。
- RM(ResourceManager) :即资源管理器, 可以理解为事务的参与者, 一般情况下是指一个数据库实例, 通过资源管理器对该数据库进行控制,资源管理器控制着分支事务。
- TM(TransactionManager) :事务管理器, 负责协调和管理事务, 事务管理器控制着全局事务, 管理事务生命周期,并协调各个RM。全局事务是指分布式事务处理环境中,需要操作多个数据库共同完成一个工作,这个工作即是一个全局事务。
- 以上三个角色之间的交互方式如下:
- TM向AP提供应用程序编程接口,AP通过TM提交及回滚事务。
- TM交易中间件通过XA接口来通知RM数据库事务的开始、结束以及提交、回滚等。
- XA的由来:DTP模型定义TM和RM之间通讯的接口规范叫X A, 简单理解为数据库提供的2PC接口协议, 基于数据库的X A协议来实现2PC又称为XA方案。
- 总结
整个2PC的事务流程涉及到三个角色AP、RM、TM。AP指的是使用2PC分布式事务的应用程序;RM指的是资源管理器,它控制着分支事务;TM指的是事务管理器,它控制着整个全局事务。
a. 在准备阶段RM执行实际的业务操作,但不提交事务,资源锁定;
b. 在提交阶段TM会接受RM在准备阶段的执行回复,只要有任一个RM执行失败,TM会通知所有RM执行回滚操作,否则,TM将会通知所有RM提交该事务。提交阶段结束资源锁释放。 - XA方案的问题:
- 需要本地数据库支持XA协议。
- 资源锁需要等到两个阶段结束才释放,性能较差。
6. 微服务
SpringCloud原理
SpringCloud的核心组件有:Eureka、Ribbon、Feign、Hystrix、Zuul。
-
微服务将模块服务化,他们之间会相互调用,随着业务增多,服务增多服务间管理逐渐复杂,Eureka Server提供服务注册功能,服务启动后会将自己的服务名、ip、端口信息注册到Eureka Server上,Eureka Client进行服务调用时就会从Eureka Server上拉取服务信息。
-
Eureka Client调用某个具有多个实例服务时,应该从众多服务中进行选择,Ribbon提供服务的负载均衡,Ribbon内置了一些负载均衡算法(轮询、随机等),用户也可以自定义算法。
-
SpringCloud的服务调用可以直接通过自行封装Http发送请求,但是每次服务调用都需要大量代码去封装发送和解释返回结果。Java都推崇面向接口编程,使用Feign发送远程请求就像SpringMVC的前端请求后端一样简单,原理如下
- 在启动时Feign会对使用了@FeignClient注解的接口进行扫描生成动态代理类注册到Spring容器中。
- 然后当调用Feign中的接口时,代理类根据接口上的@RequestMapping等注解,来动态封装HTTP请求,发送请求
- 请求结果返回后,代理类会对结果进行解码返回给调用者
-
当某个服务在被调用时发生网络故障或者宕机时,服务调用者由于等不到响应会阻塞直到超时,如果有很多服务调用该服务那么所有的服务都将被阻塞。Hystrix会为每个服务提供独立的线程池,服务调用先打到Hystrix中,某个服务发生故障不会影响到其它服务调用,并且Hystix提供服务降级功能,某个服务挂掉时Hystix可以通过fallback直接构造返回结果,并且处理失败结果,比如说将失败信息保存起来以便进行恢复。
-
随着服务的增多,几十个、几百个甚至是几千个服务,每次调用服务都需要记住服务名。在前后端分离开发的应用中,前端工程师就需要知道每个服务名,这是不切实际的。所有的服务通过zuul配置路径后,发送的请求都通过zuul向服务转发,实现服务访问统一管理。zuul还可以实现统一服务降级、身份权限认证、限流等功能
Hystrix原理
Hystrix [hɪst’rɪks]的中文含义是豪猪, 因其背上长满了刺,而拥有自我保护能力. Netflix的 Hystrix 是一个帮助解决分布式系统交互时超时处理和容错的类库, 它同样拥有保护系统的能力.
Hystrix的设计原则包括:资源隔离
、熔断器
、命令模式
- 资源隔离:Hystrix通过将每个依赖服务分配独立的线程池进行资源隔离, 从而避免服务雪崩.
- 熔断器:服务的健康状况 = 请求失败数 / 请求总数.熔断器开关由关闭到打开的状态转换是通过当前服务健康状况和设定阈值比较决定的.熔断器的开关能保证服务调用者在调用异常服务时, 快速返回结果, 避免大量的同步等待. 并且熔断器能在一段时间后继续侦测请求执行结果, 提供恢复服务调用的可能.
- 当熔断器开关关闭时, 请求被允许通过熔断器. 如果当前健康状况高于设定阈值, 开关继续保持关闭. 如果当前健康状况低于设定阈值, 开关则切换为打开状态.
- 当熔断器开关打开时, 请求被禁止通过.
- 当熔断器开关处于打开状态, 经过一段时间后, 熔断器会自动进入半开状态, 这时熔断器只允许一个请求通过. 当该请求调用成功时, 熔断器恢复到关闭状态. 若该请求失败, 熔断器继续保持打开状态, 接下来的请求被禁止通过.
3.命令模式:Hystrix使用命令模式(继承HystrixCommand类)来包裹具体的服务调用逻辑(run方法), 并在命令模式中添加了服务调用失败后的降级逻辑(getFallback).
参考:防雪崩利器:熔断器 Hystrix 的原理与使用
Zookeeper和Eureka区别
7. 中间件
Redis哨兵模式
Redis的主从复制模式下, 一旦主节点由于故障不能提供服务, 需要人工将从节点设置为主节点, 同时还要通知客户端切换数据源,这就无法达到高可用,哨兵模式就可以解决这一问题。 哨兵是特殊的redis服务,不提供读写服务,主要用来监控redis实例节点。 哨兵架构下client端第一次从哨兵找出redis的主节点,后续就直接访问redis的主节点,不会每次都通过 sentinel代理访问redis的主节点,当redis的主节点发生变化,哨兵会第一时间感知到,并且哨兵会在从节点中重新选出来一个新的master(Redis服务是通过配置文件配置的,此时哨兵会修改主从节点的配置文件),并且将新的master信息通知给client端。这里面redis的client端一般都实现了订阅功能,订阅sentinel发布的节点变动消息。
1. 哨兵模式搭建
- 配置Redis的主从服务器,修改redis.conf文件如下
# 使得Redis服务器可以跨网络访问 bind 0.0.0.0 # 设置密码 requirepass "123456" # 指定主服务器,注意:有关slaveof的配置只是配置从服务器,主服务器不需要配置 slaveof 192.168.11.128 6379 # 主服务器密码,注意:有关slaveof的配置只是配置从服务器,主服务器不需要配置 masterauth 123456
- 配置3个哨兵,每个哨兵的配置都是一样的。在Redis安装目录下有一个sentinel.conf文件,copy一份进行修改
# 禁止保护模式 protected-mode no # 配置监听的主服务器,这里sentinel monitor代表监控,mymaster代表服务器的名称,可以自定义,192.168.11.128代表监控的主服务器,6379代表端口,2代表只有两个或两个以上的哨兵认为主服务器不可用的时候,才会进行failover操作。 sentinel monitor mymaster 192.168.11.128 6379 2 # sentinel author-pass定义服务的密码,mymaster是服务名称,123456是Redis服务器密码 # sentinel auth-pass <master-name> <password> sentinel auth-pass mymaster 123456
2. Java中使用哨兵模式
/**
* 测试Redis哨兵模式
* @author liu
*/
public class TestSentinels {
@SuppressWarnings("resource")
@Test
public void testSentinel() {
JedisPoolConfig jedisPoolConfig = new JedisPoolConfig();
jedisPoolConfig.setMaxTotal(10);
jedisPoolConfig.setMaxIdle(5);
jedisPoolConfig.setMinIdle(5);
// 哨兵信息
Set<String> sentinels = new HashSet<>(Arrays.asList("192.168.11.128:26379",
"192.168.11.129:26379","192.168.11.130:26379"));
// 创建连接池
JedisSentinelPool pool = new JedisSentinelPool("mymaster", sentinels,jedisPoolConfig,"123456");
// 获取客户端
Jedis jedis = pool.getResource();
// 执行两个命令
jedis.set("mykey", "myvalue");
String value = jedis.get("mykey");
System.out.println(value);
}
}
为什么要使用消息中间件MQ
- 缓冲和削峰:突然发生大量请求,服务器可能扛不住压力直接宕机,MQ在中间可以起到一个缓冲的作用,把消息暂存在MQ中,服务器就可以按照自己的节奏进行慢慢处理。
- 解耦和扩展性:项目开始的时候,并不能确定具体需求。消息队列可以作为一个接口层,解耦重要的业务流程。只需要遵守约定,针对数据编程即可获取扩展能力。
- 发布订阅:可以采用一对多的方式,一个生产者发布消息,可以被多个服务订阅,供多个毫无关联的业务使用。
- 异步通信:对于对实时性要求不高的请求,可以将请求发给MQ,MQ 在支持同步通讯的同时,提供了基于消息队列存储 - 转发机制的异步通讯模式,应用程序只需将消息交给 MQ,就由 MQ 负责将消息安全、可靠地发送出去。
AMQP messaging 中的基本概念
- Broker: 接收和分发消息的应用,RabbitMQ Server就是Message Broker。
- Virtual host: 出于多租户和安全因素设计的,把AMQP的基本组件划分到一个虚拟的分组中,类似于网络中的namespace概念。当多个不同的用户使用同一个RabbitMQ server提供的服务时,可以划分出多个vhost,每个用户在自己的vhost创建exchange/queue等。
- Connection: publisher/consumer和broker之间的TCP连接。断开连接的操作只会在client端进行,Broker不会断开连接,除非出现网络故障或broker服务出现问题。
- Channel: 如果每一次访问RabbitMQ都建立一个Connection,在消息量大的时候建立TCP Connection的开销将是巨大的,效率也较低。Channel是在connection内部建立的逻辑连接,如果应用程序支持多线程,通常每个thread创建单独的channel进行通讯,AMQP method包含了channel id帮助客户端和message broker识别channel,所以channel之间是完全隔离的。Channel作为轻量级的Connection极大减少了操作系统建立TCP connection的开销。
- Exchange: message到达broker的第一站,根据分发规则,匹配查询表中的routing key,分发消息到queue中去。常用的类型有:direct (point-to-point), topic (publish-subscribe) and fanout (multicast)。
- Queue: 消息最终被送到这里等待consumer取走。一个message可以被同时拷贝到多个queue中。
- Binding: exchange和queue之间的虚拟连接,binding中可以包含routing key。Binding信息被保存到exchange中的查询表中,用于message的分发依据。
参考:RabbitMQ与AMQP协议详解
Kafka特点
- 高吞吐:即使是非常普通的硬件Kafka也可以支持每秒数百万的消息,不要因为将消息持久化的磁盘怀疑它的性能。
- 消息持久化:通过O(1)的磁盘数据结构提供消息的持久化,这种结构对于即使数以TB的消息存储也能够保持长时间的稳定性能。
- 完全分布式:Producer、Broker、Consumer支持水平拓展。
- 允许应用程序充当流处理器(stream processor),从一个或多个主题获取输入流,并生产一个输出流至一个或多个的主题,能够有效地变换输入流为输出流
Kafka使用场景
- 日志收集:收集各个服务的日志数据
- 消息系统:解耦和生产者和消费者、缓存消息等
- 用户活动跟踪:Kafka经常被用来记录web用户或者app用户的各种活动,如浏览网页、搜索、点击等活动,这些活动信息被各个服务器发布到kafka的topic中,然后订阅者通过订阅这些topic来做实时的监控分析,或者装载到hadoop、数据仓库中做离线分析和挖掘。
- 流式处理:比如spark streaming和storm
kafka中的 zookeeper 起到什么作用
- broker在zk中注册
kafka的每个broker(相当于一个节点,相当于一个机器)在启动时,都会在zk中注册,,当节点失效时,zk就会删除该节点,就很方便的监控整个集群broker的变化,及时调整负载均衡。 - topic在zk中注册
在kafka中可以定义很多个topic,每个topic又被分为很多个分区。一般情况下,每个分区独立在存在一个broker上,所有的这些topic和broker的对应关系都有zk进行维护 - consumer(消费者)在zk中注册
- 注册新的消费者,当有新的消费者注册到zk中,zk会创建专用的节点来保存相关信息,路径ls /consumers/{group_id}/ [ids,owners,offset]
Ids:记录该消费分组有几个正在消费的消费者,
Owmners:记录该消费分组消费的topic信息,
Offset:记录topic每个分区中的每个offset - 监听消费者分组中消费者的变化
监听/consumers/{group_id}/ids的子节点的变化,一旦发现消费者新增或者减少及时调整消费者的负载均衡
- 注册新的消费者,当有新的消费者注册到zk中,zk会创建专用的节点来保存相关信息,路径ls /consumers/{group_id}/ [ids,owners,offset]
为什么要使用Elasticsearch?
因为在我们商城中的数据,将来会非常多,所以采用以往的模糊查询,模糊查询前置配置,会放弃索引,导致商品查询是全表扫面,在百万级别的数据库中,效率非常低下,而我们使用ES做一个全文索引,我们将经常查询的商品的某些字段,比如说商品名,描述、价格还有id这些字段我们放入我们索引库里,可以提高查询速度。
全文搜索(Full-text Search)
全文检索是指计算机索引程序通过扫描文章中的每一个词,对每一个词建立一个索引,指明该词在文章中出现的次数和位置,当用户查询时,检索程序就根据事先建立的索引进行查找,并将查找的结果反馈给用户的检索方式。
倒排索引(Inverted Index)
倒排索引(英语:Inverted index),是一种索引方法,被用来存储在全文搜索下某个单词在一个文档或者一组文档中的存储位置的映射。它是文档检索系统中最常用的数据结构。通过倒排索引,可以根据单词快速获取包含这个单词的文档列表。
该列表中的每一项都包括一个单词和该单词的各记录的地址。由于不是由记录来确定属性值,而是由属性值来确定记录的位置,因而称为倒排索引(inverted index)。Elasticsearch能够实现快速、高效的搜索功能,正是基于倒排索引原理。
ES基本概念
- 索引:Elasticsearch 数据管理的顶层单位就叫做 Index(索引),相当于关系型数据库里的数据库的概念。另外,每个Index的名字必须是小写。
- 文档(Document):Index里面单条的记录称为 Document(文档),相当于关系型数据库column。许多条 Document 构成了一个 Index。Document 使用 JSON 格式表示。同一个 Index 里面的 Document,不要求有相同的结构(scheme),但是最好保持相同,这样有利于提高搜索效率。
- 类型(Type):Document 可以分组,比如employee这个 Index 里面,可以按部门分组,也可以按职级分组。这种分组就叫做 Type,它是虚拟的逻辑分组,用来过滤 Document,类似关系型数据库中的数据表。
不同的 Type 应该有相似的结构(Schema),性质完全不同的数据(比如 products 和 logs)应该存成两个 Index,而不是一个 Index 里面的两个 Type(虽然可以做到)。 - 文档元数据(Document metadata):文档元数据为_index, _type, _id, 这三者可以唯一表示一个文档,_index表示文档在哪存放,_type表示文档的对象类别,_id为文档的唯一标识。
ES对比Solr
- Solr 利用 Zookeeper 进行分布式管理,而 Elasticsearch 自身带有分布式协调管理功能;
- Solr 支持更多格式的数据,而 Elasticsearch 仅支持json文件格式;
- Solr 官方提供的功能更多,而 Elasticsearch 本身更注重于核心功能,高级功能多有第三方插件提供;
- Solr 在传统的搜索应用中表现好于 Elasticsearch,但在处理实时搜索应用时效率明显低于 Elasticsearch。
- Solr 是传统搜索应用的有力解决方案,但 Elasticsearch 更适用于新兴的实时搜索应用。
- 随着数据量的增加,Solr的搜索效率会变得更低,而Elasticsearch却没有明显的变化。
参考:Elasticsearch与Solr优缺点比较
8. 设计模式
静态代理、JDK动态代理、CGLIB动态代理的区别
代理方式 | 特点 | 缺点 |
---|---|---|
静态代理 | 需要定义父类或者接口,代理对象和被代理对象需要同时继承父类或者实现该接口,一次代理一个类 | 随着代理类增多,出现大量重复代码,难维护,造成类膨胀 |
jdk动态代理 | 目标类需要实现至少一个接口,代理对象通过JAVA的API动态生成,可以代理一个借口的多个实现 | 只能够代理实现了接口的目标类 |
cglib动态代理 | 代理类要实现MethodInterceptor接口,通过Enhancer创建目标类的子类为代理对象,所有也是通过继承关系创建代理类的,然后通过实现intercept(Object o, Method method, Object[] objects, MethodProxy proxy)方法对所有的方法进行拦截,添加增强处理,注意该方法中要通过代理类的invokeSuper调用父类的方法 | 不能代理final修饰的类 |
设计模式-策略模式
- 解决问题:在有多种算法相似的情况下,使用 if…else 所带来的复杂和难以维护。
- 概念:在策略模式(Strategy Pattern)中,一个类的行为或其算法可以在运行时更改。
- 角色:Context: 环境类、Strategy: 抽象策略类、ConcreteStrategy: 具体策略类
- 代码
- 抽象策略类Sort
public interface Sort { void sort(); }
- 具体策略类ConcreteSort1
public class ConcreteSort1 implements Sort { @Override public void sort() { System.out.println("使用快速排序"); } }
- 具体策略类ConcreteSort2
public class ConcreteSort2 implements Sort { @Override public void sort() { System.out.println("使用归并排序"); } }
- 定义Context环境类
public class Context { public AbstractSort method; public Context(AbstractSort abstractSort) { this.method = abstractSort; } public void contextSort() { method.sort(); } }
- 客户端类Main
public class Main { public static void main(String[] args) { //传入不同的具体策略即可 Context context = new Context(new ConcreteSort2()); context.contextSort(); } }
- 抽象策略类Sort
- 在JDK中的使用:ThreadPoolExecutor中的四种拒绝策略
- AbortPolicy:直接抛出异常。
- CallerRunsPolicy:只用调用者所在线程来运行任务。
- DiscardOldestPolicy:丢弃队列里最近的一个任务,并执行当前任务。
- DiscardPolicy:不处理,丢弃掉。
9. 数据库和SQL
数据库事务ACID
- 原子性(Atomicity):事务中的所有操作作为一个整体像原子一样不可分割,要么全部成功,要么全部失败。
- 一致性(Consistency):事务的执行结果必须使数据库从一个一致性状态到另一个一致性状态。
一致性状态是指: a. 系统的状态满足数据的完整性约束。b. 系统的状态反应数据库本应描述的现实世界的真实状态,比如转账前后两个账户的金额总和应该保持不变。
- 隔离性(Isolation):并发执行的事务不会相互影响,其对数据库的影响和它们串行执行时一样。比如多个用户同时往一个账户转账,最后账户的结果应该和他们按先后次序转账的结果一样。
- 持久性(Durability):事务一旦提交,其对数据库的更新就是持久的。任何事务或系统故障都不会导致数据丢失。
参考数据库事务的概念及其实现原理
数据库常见的并发异常
- 脏读:指一个事务读取了另一个事务未提交的数据
- 不可重复读:指一个事务对同一数据的读取结果前后不一致。脏读和不可重复读的区别在于:前者读取的是事务未提交的脏数据,后者读取的是事务已经提交的数据,只不过因为数据被其他事务修改过导致前后两次读取的结果不一样
- 幻读:事务读取某个范围的数据时,因为其他事务的操作导致前后两次读取的结果不一致,针对数据新增删除。幻读和不可重复读的区别在于,不可重复读是针对确定的某一行数据而言,而幻读是针对不确定的多行数据。因而幻读通常出现在带有查询条件的范围查询中
- 第一类丢失更新(回滚丢失):指事务回滚了其他事务对数据项的已提交修改
- 第二类丢失更新(提交覆盖丢失):指事务覆盖了其他事务对数据的已提交修改,导致这些修改好像丢失了一样
SQL标准为事务定义了不同的隔离级别,从低到高依次是
- 读未提交(READ UNCOMMITTED)
- 读已提交(READ COMMITTED)
- 可重复读(REPEATABLE READ)(行锁)
- 串行化(SERIALIZABLE)(表锁)
大多数数据库隔离级别为READ COMMITED,ruSql Server、Oracle,MySql为REPEATABLE READ
谈谈对数据库索引的理解
数据库索引设计的初衷是可以通过索引快速查找表中数据。索引是建立某种数据结构和表中数据一种关系,这种数据结构必须能够快速查找到目标值,然后通过这种关系定位到所需的数据行。索引是对数据库表中一列或多列的值进行排序的一种结构,使用索引可快速访问数据库表中的特定信息。以MySQL的MyISAM为例,每个表对应的数据库文件有三个,一个保存表信息,一个保存索引信息,一个保存数据。保存索引的数据结构是B+Tree,如果根据某个字段获取数据MySQL首先判断该字段是否建立了索引,如果有索引,就先通过在B+Tree上快速查找目标值,如果找到目标值,则会通过该索引对应的物理地址定位到数据文件中的数据,获得查询结果。
谈谈聚集索引和非聚集索引
聚集索引就是将索引和数据存在一个文件中,查找数据时找到索引值后直接能获取到数据。非聚集索引是将索引和数据分开储存,索引文件储存的时索引值和对应数据的物理地址,找到索引后还需要根据物理地址找到对应数据。对比直线聚集索引比非聚集索引效率要高。
SQL优化
- 首先应考虑在 where 及 order by 涉及的列上建立索引
- 避免放弃索引全表扫描。例如以下情况:
① where 子句中对字段进行 null 值判断。解决办法把null值用字符代替,比如0,查询:where num=0
② where 子句中使用!=或<>操作符
③ where 子句中使用 or 来连接条件。如:select id from t where num=10 or num=20,可以这样查询:select id from t where num=10 union all select id from t where num=20
④ where 子句使用in或者not in。如:select id from t where num in(1,2,3),对于连续的数值,能用 between 就不要用 in 了:select id from t where num between 1 and 3
⑤ where 子句使用like前模糊查询,如where name like ‘%abc%’或者where name like ‘%abc’
⑥ where 子句中的“=”左边进行函数、算术运算或其他表达式运算。如:a. select id from t where substring(name,1,3)=‘abc’–name以abc开头的id,应改为:select id from t where name like ‘abc%’
b. select id from t where num/2=100,应改为:select id from t where num=100*2
⑦ 在 where 子句中的“=”左边进行函数、算术运算或其他表达式运算。如:select id from t where substring(name,1,3)=‘abc’–name以abc开头的id,应改为:select id from t where name like ‘abc%’ - 很多时候用 exists 代替 in 是一个好的选择:select num from a where num in(select num from b),用下面的语句替换:select num from a where exists(select 1 from b where num=a.num)
- 并不是所有索引对查询都有效,SQL是根据表中数据来进行查询优化的,当索引列有大量数据重复时,SQL查询可能不会去利用索引,如一表中有字段sex,male、female几乎各一半,那么即使在sex上建了索引也对查询效率起不了作用。
- 索引并不是越多越好,索引固然可以提高相应的 select 的效率,但同时也降低了 insert 及 update 的效率,
因为 insert 或 update 时有可能会重建索引,所以怎样建索引需要慎重考虑,视具体情况而定。
一个表的索引数最好不要超过6个,若太多则应考虑一些不常使用到的列上建的索引是否有必要。 - 尽量使用数字型字段,若只含数值信息的字段尽量不要设计为字符型,这会降低查询和连接的性能,并会增加存储开销。
这是因为引擎在处理查询和连接时会逐个比较字符串中每一个字符,而对于数字型而言只需要比较一次就够了。 - 固定程度字符使用char可变长度字符使用varchar,char使用空间换时间,所以varchar存取速度较之低
- 在新建临时表时,如果一次性插入数据量很大,那么可以使用 select into 代替 create table,避免造成大量 log ,
以提高速度;如果数据量不大,为了缓和系统表的资源,应先create table,然后insert。 - 如果使用到了临时表,在存储过程的最后务必将所有的临时表显式删除,先 truncate table ,然后 drop table ,这样可以避免系统表的较长时间锁定。
- 尽量避免使用游标,因为游标的效率较差,如果游标操作的数据超过1万行,那么就应该考虑改写。
- 尽量避免向客户端返回大数据量,若数据量过大,应该考虑相应需求是否合理
谈谈Redis缓存穿透、缓存击穿、缓存雪崩
-
缓存穿透:同一时间客户端大量的请求在Redis和数据库中都不存在的数据会导致每次请求都会查DB。解决办法:
- 将从DB查询出来的空值进行缓存“null”,需要设置较短的过期时间
- 使用布隆过滤器。(有一定的误判率,谷歌guava的默认误判率为0.03)
-
缓存击穿:缓存击穿是指缓存中没有但数据库中有的数据(一般是缓存时间到期),这时由于并发用户特别多,同时读缓存没读到数据,又同时去数据库去取数据,引起数据库压力瞬间增大,造成过大压力
- 设置热点数据永远不过期。
- 加互斥锁,互斥锁参考代码如下:
-
缓存雪崩:大量的key在同一时间过期,同一时间收到大量的请求,流量会直接到达DB,造成DB宕机。解决办法:
- 设置热点数据key永不过期
- 设置过期时间不要集中在一起
10. WEB
幂等性
HTTP/1.1中对幂等性的定义是:一次和多次请求某一个资源对于资源本身应该具有同样的结果(网络超时等问题除外)。也就是说,其任意多次执行对资源本身所产生的影响均与一次执行的影响相同。
参考:深入理解幂等性
一次web请求的过程
- 在web客户端中输入网址
- web客户端通过DNS将域名解析成ip
- 根据ip在互联网上找到对应的服务器,建立tcp连接
- web客户端向服务器发起http请求,获取服务器资源。一般在客户端和服务器间会使用Nginx进行请求转发和静态资源处理
- 应用服务器处理接收到的请求,进行业务处理,将处理结果返回给web客户端
- web客户端服务器断开连接(Http1.1之后是长连接,不一定是请求完成后就断开连接,这取决于服务器的操作。)
- web客户端拿到请求结果后进行界面渲染
XSS(Cross site scripting)跨站脚本攻击
为了不与CSS重名故简写为XSS。攻击者在网页嵌入恶意脚本程序,用户打开网页时便在客户端浏览器上执行,以盗取cookie、用户名和密码,下载病毒甚至是获取admin权限。
例如
:用户在input输入的内容是:
<script>alert("哈哈")</script>
用户查看界面时就会出现以下代码:
<body>
<script>alert("哈哈")</script>
</body>
防范
:对特殊字符进行转义
html字符 | 转以后字符 |
---|---|
> | < |
< | > |
’ | & |
" | " |
CRFS(Cross site request forgery)跨站请求伪造
听起来和XSS
相似,但是有很大区别。XSS
是利用站立信任用户,而CRFS
是通过伪装来自信任用户的请求来利用受信任的网站。可以这么理解:攻击者盗用了你的身份,然后向第三方网站发送恶意请求,包括:发邮件、短信、交易转账等。
受害者只需要完成以下两件事,就能被攻击:
- 登录信任站点A,并在本地生成cookie
- 不登出站点A(或者不清除cookie),访问恶意站点B
防范
- 将cookie设置为
HttpOnly
,java设置方法如下:
response.setHeader("Set-Cookie","cookiename=cookievalue;HttpOnly");
- 增加token
- 通过Referer识别:Http协议头中有个字段Referer,记录了请求来源网站。通常,访问一个安全受限的请求都来自于同一个网站。只需要验证请求中的Referer值即可,获取方法:
String referer=request.getHeader("Referer");
TCP/IP组成或者模型
- 应用层(细分为应用层、表示层、会话层):定义数据格式并按照对应的格式解读数据
- 传输层:定义端口,标识应用程序身份,实现端口到端口的通信
- 网络层:定义网络地址、区分网段、子网内MAC寻址、对于不同子网的数据包进行路由
- 网络接口层(细分为物理层、数据链路层):对电信号进行分组并形成具有特定意义的数据帧,然后以广播的形式通过物理介质发送给接收方。
参考:TCP/IP协议
TCP和UDP
- TCP面向连接;UDP是一种无连接协议
- TCP提供可靠的服务,使用阻塞控制和流量控制;UDP不建立连接,不关心消息是否被接受到,无视网络状况数据以恒定速率想发就发,无法提供可靠的服务,不使用阻塞控制和流量控制
- TCP1对1通信;UDP支持1对1,1对多,多对1,多对多通信
- TCP面向字节流;UDP面向报文
- TCP慢;UDP快
- TCP适用于可靠传输的应用(文件传输);UDP适用于实时应用(IP电话、视频会议、直播等)
TCP三次握手
- 连接建立通常由客户端首先发起,客户端发送SYN(SEQ=x)报文给服务器端,进入SYN_SEND状态。
- 服务器端收到SYN报文,回应一个SYN (SEQ=y)ACK(ACK=x+1)报文,进入SYN_RECV状态。
- 客户端收到服务器端的SYN报文,回应一个ACK(ACK=y+1)报文,进入Established状态。
- SYN(Synchronize Sequence Numbers):同步序列编号
- SEQ:初始序号
TCP四次挥手
- 客户端A发送一个FIN,用来关闭客户A到服务器B的数据传送
- 服务器B收到这个FIN,它发回一个ACK,确认序号为收到的序号加1。和SYN一样,一个FIN将占用一个序号
- 服务器B数据发送完毕后,发送一个FIN给客户端A,关闭与客户端A的连接
- 客户端A发回ACK报文确认,并将确认序号设置为收到序号加1
每一端都能主动关闭 这个连接(即首先发送SYN),一般由客户端决定何时终止连接,我们能改变上 边的标识,将左方定为服务器,右方定为客户,一切仍将像显示的一样工作
TCP为什么要四次挥手三次不行吗
- 解释1:这是由于TCP的半关闭造成的,因为TCP连接是全双工的(即数据可在两个方向上同时传递)所以进行关闭时每个方向上都要单独进行关闭,这个单方向的关闭就叫半关闭.关闭的方法是一方完成它的数据传输后,就发送一个FIN来向另一方通告将要终止这个方向的连接.当一端收到一个FIN,它必须通知应用层TCP连接已终止了这个方向的数据传送,发送FIN通常是应用层进行关闭的结果
- 解释2:虽然服务端的LISTEN状态下的SOCKET当收到SYN报文的建连请求后,它可以把ACK和SYN(ACK起应答作用,而SYN起同步作用)放在一个报文里来发送。但关闭连接时,当收到对方的FIN报文通知时,它仅仅表示对方没有数据发送给你了;但未必你所有的数据都全部发送给对方了,所以你可以未必会马上会关闭SOCKET,也即你可能还需要发送一些数据给对方之后,再发送FIN报文给对方来表示你同意现在可以关闭连接了,所以它这里的ACK报文和FIN报文多数情况下都是分开发送的。
个人更加偏向解释2
TCP沾包
只有TCP有粘包现象,UDP永远不会粘包,因为TCP是基于数据流的协议,而UDP是基于数据报的协议。发送端可以是一K一K地发送数据,而接收端的应用程序可以两K两K地提走数据,或者一次只提走几个字节的数据,也就是说,应用程序所看到的数据是一个整体,或说是一个流(stream),一条消息有多少字节对应用程序是不可见的,因此TCP协议是面向流的协议,这也是容易出现粘包问题的原因。所谓粘包问题主要还是因为接收方不知道消息之间的界限,不知道一次性提取多少字节的数据所造成的。
沾包的两种情况
- 发送方:TCP为提高传输效率,发送方往往要收集到足够多的数据后才发送一个TCP段。若连续几次需要send的数据都很少,通常TCP会根据negal优化算法把这些数据合成一个TCP段后一次发送出去,这样接收方就收到了粘包数据。
- 接收方:接收方不及时接收缓冲区的包,造成多个包接收(客户端发送了一段数据,服务端只收了一小部分,服务端下次再收的时候还是从缓冲区拿上次遗留的数据,产生粘包)
如何处理粘包现象?
1.发送方: 对于发送方造成的粘包问题,可以通过关闭Nagle算法来解决,使用TCP_NODELAY选项来关闭算法。
2. 接收方:接收方没有办法来处理粘包现象,只能将问题交给应用层来处理。
3. 应用层:循环处理,应用程序从接收缓存中读取分组时,读完一条数据,就应该循环读取下一条数据,直到所有数据都被处理完成,但是如何判断每条数据的长度呢?
- 格式化数据:每条数据有固定的格式(开始符,结束符),这种方法简单易行,但是选择开始符和结束符时一定要确保每条数据的内部不包含开始符和结束符。
- 发送长度:发送每条数据时,将数据的长度一并发送,例如规定数据的前4位是数据的长度,应用层在处理时可以根据长度来判断每个分组的开始和结束位置。
TCP能发空消息吗?UDP能发空消息吗
tcp是基于数据流的,于是收发的消息不能为空,这就需要在客户端和服务端都添加空消息的处理机制,防止程序卡住,而udp是基于数据报的,即便是你输入的是空内容(直接回车),那也不是空消息,udp协议会帮你封装上消息头
UDP为什么不会沾包
UDP(user datagram protocol,用户数据报协议)是无连接的,面向消息的,提供高效率服务。每个UDP段都是一条消息,应用程序必须以消息为单位提取数据,不能一次提取任意字节的数据,不会使用块的合并优化算法, 由于UDP支持的是一对多的模式,所以接收端的skbuff(套接字缓冲区)采用了链式结构来记录每一个到达的UDP包,在每个UDP包中就有了消息头(消息来源地址,端口等信息),这样,对于接收端来说,就容易进行区分处理了。 即面向消息的通信是有消息保护边界的。
SSL TLS建立连接过程
特别解释:
- 第二步:服务端向客户端发送公开密钥证书,服务端有一套公钥秘钥假如叫做:PK、SK,CA机构也有一套假如叫做CPK、CSK,此时发送的公开秘钥其实是服务器公钥和CA秘钥加密的结果就是证书,证书=f(PK,CSK)
- 第五步客户端验证证书,其中有一步就是获取服务端公钥,就需要去CA机构获取CPK来解密证书,这个过程可能被攻击拦截,所以客户端内置了很多的CA机构公钥CPK。拿到证书后,直接使用CPK进行解密,得到服务端PK。
加密方式
1. 对称加密
a. k:加密的key
b. f():加密算法
c. data:加密对象
d. x:加密后密文数据
使用k对数据data进行加密,表示为:f(k,data)=x;再次使用k对密文x进行解密,就会得到原始数据data,表示为:f(pk,y)=data;
2. 非对称加密
a. pk:公钥
b. sk:私钥
c. f():加密算法
d. data:加密对象
e. y:加密后密文数据
如果使用公钥pk对data进行加密,表示为:f(pk,data)=y,那么使用私钥sk就可以对密文y进行解密,表示为:f(sk,y)=data;如果使用秘钥sk对data进行加密,表示为:f(sk,data)=y,那么使用公钥pk就可以对密文y进行解密,表示为:f(pk,y)=data;
11. 算法
二分法查找
- 算法:二分法查找适用于数据量较大时,但是数据需要先排好顺序。
- 主要思想是:(设查找的数组区间为array[low, high])
- 确定该区间的中间位置K
- 将查找的值T与array[k]比较。若相等,查找成功返回此位置;否则确定新的查找区域,继续二分查找。
- 区域确定如下:如果a.array[k] > T 由数组的有序性可知array[k,k+1,……,high] > T,故新的区间为array[low,……,K-1];如果b.array[k]<T 类似上面查找区间为array[k+1,……,high]。每一次查找与中间值比较,可以确定是否查找成功,不成功当前查找区间将缩小一半,递归查找即可。时间复杂度为:O(log2n)。
- 示例
public static int indexedBinarySearch(List<Integer> list, int key) { if (list.isEmpty()) { throw new RuntimeException("List can`t be empty !"); } //排序 list.sort(Integer::compareTo); System.out.println(Arrays.toString(list.toArray())); int low = 0; int high = list.size() - 1; while (low <= high) { int mid = (low + high) >>> 1; Integer midVal = list.get(mid); if (midVal < key) low = mid + 1; else if (midVal > key) high = mid - 1; else return mid; // key found } return -(low + 1); // key not found }
贪心算法
分治算法
常见的两种分支限界法
矩阵连成问题
回溯法
DMA技术是Direct Memory Access的缩写。其意思是“存储器直接访问”。它是指一种高速的数据传输操作,允许在外部设备和存储器之间直接读写数据,既不通过CPU,也不需要CPU干预。 ↩︎
局部变量表存放编译器可知的各种基本变量类型、对象引用、返回类型(指向一条字节码指令的地址)。其中64位的long和double占两个局部变量空间(slot)。局部变量表所需内存空间编译期间完成分配,当进入某方法时这个方法需要在栈帧中分配的局部变量表空间是完全确定的,方法运行时不会改变。 ↩︎
随着JIT编译器的发展和逃逸分析技术的成熟,栈上分配、标量替换优化技术将会导致一些微妙变化,所有对象实例在堆上分配内存变得不“绝对”了。 ↩︎